在 Swift 中,Protocol + Default Implementation(协议 + 默认实现) 是一把双刃剑。虽然它提供了强大的“混入”(Mixin)能力,但在多模块架构下,它常会引发逻辑泄露(Logic Leakage)和依赖污染,从而破坏你精心设计的模块边界。
1. 为什么它会破坏模块边界?
A. 隐式的逻辑侵入
当你为一个定义在底层模块(如 Core)的协议提供了默认实现时,任何遵循该协议的模块都会自动获得这段逻辑。
- 风险:如果默认实现中引用了某个特定的第三方库或上层模块的功能,底层模块就会被迫产生不该有的依赖。
B. 静态分发与“接口假象”
这是最坑的一点。如果在协议定义中没有声明某个方法,但在扩展(Extension)中写了默认实现,该方法是静态分发的。
- 边界破坏:当下层模块通过协议调用该方法时,它实际运行的是扩展里的代码。如果扩展逻辑是针对特定业务设计的,这就导致底层模块在无意中执行了上层模块的私有逻辑,形成了隐性耦合。
C. “强制性”能力赋予
默认实现有时会给不该具备某种能力的类型强制赋予了能力。
- 例子:如果你给
BaseViewModel协议增加了一个默认的日志保存实现。那么,所有的子 ViewModel 都会带上这个行为。即使某个轻量级的“隐私模式”模块不希望有日志行为,它也无法在不修改协议的情况下完全剥离。
2. 如何避免?
A. 限制扩展的作用域 (Constrained Extensions)
不要定义全局的默认实现。利用 where 子句将实现限制在特定范围内。
- 做法:只有满足特定条件的类型才获得该能力。
Swift
extension MyProtocol where Self: UIViewController {
// 只有在 UI 模块中,且是 VC 时才具备此逻辑,
// 这样就不会污染 Core 层的纯逻辑对象。
func showCommonAlert() { ... }
}
B. 将实现搬移到“中介者”或“适配器”
不要在协议扩展里写复杂的逻辑。扩展应该只做简单的转发,或者仅作为能力的标记。
- 做法:如果逻辑复杂,定义一个专门的服务类,在默认实现中通过依赖注入(DI)调用。
C. 遵循“接口隔离原则”(ISP)
如果发现默认实现变得臃肿,说明协议承载了太多职责。
-
方案:将协议拆分为更小的单元。
LoadableRetryableLogger
-
模块可以根据需要组合(Composition)这些小协议,而不是被迫继承一个带有臃肿默认实现的巨型协议。
D. 使用“特化模块”存放扩展
如果你想为 Core 模块的协议提供针对 SwiftUI 的优化,不要写在 Core 模块里。
- 做法:创建一个名为
Core+SwiftUI的粘合模块(Glue Module)。在这个模块里对协议进行扩展。这样,只有同时引用了这两个模块的 UI 层能看到默认实现,核心逻辑层依然保持纯净。
3. 一个典型的错误 vs 修正
❌ 错误:在底层定义了涉及上层的逻辑
Swift
// 在 Core 模块
protocol DataService { }
extension DataService {
func logError() {
// 这里的 FirebaseLogger 属于上层库,导致 Core 依赖了 Firebase
FirebaseLogger.shared.log("Error")
}
}
✅ 修正:解耦默认实现
Swift
// 在 Core 模块
protocol DataService {
var errorHandler: ((Error) -> Void)? { get }
}
// 在 App 层(或 Glue 模块)
extension DataService {
func logError(error: Error) {
// 这里的逻辑由外部注入或通过闭包回调,Core 层不感知 Firebase
errorHandler?(error)
}
}
总结:黄金法则
如果你正在写一个协议的 extension,请问自己: “如果我把这段代码删掉,这个模块还能独立运行吗?” 如果答案是否定的,说明你正在通过默认实现跨越不该跨越的边界。