6-26.【架构设计】为什么“protocol+default implementation”有时会破坏架构边界?请从派发和依赖方向角度解释。

5 阅读2分钟

一、问题根源:Swift 的派发机制

  1. 协议方法的派发规则
  • Protocol requirement(协议声明的方法):

    • 如果被类实现 → 动态派发(类似虚方法)
    • 如果通过协议类型调用 → 动态派发
  • Protocol extension 默认实现

    • 如果没有被类型实现 → 执行扩展里的默认实现
    • 通过具体类型调用时,会静态绑定(静态派发)
    • 通过协议类型调用时,会调用默认实现(除非类型重写)

换句话说,protocol + extension 默认实现是 静态绑定 + 动态派发混合,很容易隐藏真实调用者。


例子

protocol Repo {
    func fetch() -> String
}

extension Repo {
    func fetch() -> String { "Default" }
}

class UserRepo: Repo {
    func fetch() -> String { "UserRepo" }
}

let repo: Repo = UserRepo()
repo.fetch()  // 输出 "UserRepo" ✅
(UserRepo() as Repo).fetch() // 输出 "UserRepo" ✅

但是:

struct LocalRepo: Repo {} 
let repo: Repo = LocalRepo()
repo.fetch() // 输出 "Default" ⚠️
  • 默认实现被静态绑定 → Module B 依赖 Module A 协议,但调用的是 Module A 的默认实现
  • Module B 实际依赖 Module A 的实现而非接口

二、依赖方向问题

假设:

Module A (定义协议 + default impl)
Module B (依赖协议)

1️⃣ 理想情况(正确的模块边界)

  • Module B 只依赖协议
  • Module A 提供具体实现
  • Module B 调用协议方法 → 通过注入的实例实现
Module B → depends on Repo protocol → inject UserRepo / MockRepo
  • 依赖方向:单向
  • Module B 不知道 Module A 实现细节
  • UI / State / Reducer 可测试可替换

2️⃣ 使用 protocol + default implementation 问题

  • Module A 写了协议扩展默认实现
  • Module B 依赖协议类型调用
  • Module B 可能无意中使用了 Module A 的默认实现
  • Module B 不再独立,可被 Module A 的内部逻辑污染 → 反向依赖
Module B → Repo protocol → 调用默认实现 → 依赖 Module A 内部逻辑
  • 结果:

    • 隐式耦合
    • 测试困难(想 mock 需要额外措施)
    • 隐藏真实调用者 → 难调试

三、工程直觉

使用场景风险建议做法
协议扩展提供默认实现默认实现可能跨模块被依赖 → 破坏依赖方向避免跨模块提供默认实现;只在同模块内部使用
Mock / 测试默认实现会被 unintentionally 调用测试用 mock 明确注入,不依赖默认实现
高级组合 / Trait默认实现混合静态/动态派发 → 不可预测用纯协议 + 注入实现,保持单向依赖

四、总结一句话

protocol + default implementation 的危险在于:它让调用者可能隐式依赖提供默认实现的模块,从而破坏单向依赖和模块边界,而不仅仅是语法糖问题。

  • 核心点 = 依赖方向被隐藏 → 逻辑跨越模块 → 模块边界失效
  • 安全策略 = 协议只声明接口,跨模块不提供默认实现 → 注入具体实现