在 Swift 中,Protocol Extension(协议扩展)提供的默认实现究竟是静态派发还是动态派发,取决于该方法是否在 Protocol 的主定义(Declaration) 中声明。
这是一个非常隐蔽的性能/行为陷阱。以下是其底层派发原理的深度拆解:
1. 核心判定规则
- 有声明 + 扩展实现:动态派发(通过 Witness Table)。类似于类的虚函数,支持多态。
- 无声明 + 扩展实现:静态派发(直接调用)。不支持多态,调用哪个实现取决于指针的静态类型。
2. 静态派发的底层原理:直接地址调用
当你在 Protocol Extension 中定义了一个原协议中不存在的方法时,编译器会将其视为一个全局函数,只是它的作用域被限制在该协议下。
- 编译期绑定:编译器在编译调用方的代码时,会直接查找当前变量声明的类型。如果类型是协议本身,编译器就直接把 Extension 中那个方法的内存地址写死在调用处。
- 跳过 PWT:这种调用不会去查阅协议见证表(Protocol Witness Table),因此也就无法在运行时发现子类/实现类是否“重写”了这个方法。
3. “隐蔽陷阱”演示
这是一个典型的由于静态派发导致的“非预期行为”:
Swift
protocol Animal {}
extension Animal {
func breathe() { print("Animal breathing") } // 没在 protocol 声明,属于静态派发
}
struct Cat: Animal {
func breathe() { print("Cat breathing") }
}
let cat: Cat = Cat()
let animal: Animal = cat
cat.breathe() // 输出: "Cat breathing"(静态类型是 Cat)
animal.breathe() // 输出: "Animal breathing"(静态类型是 Animal,直接调 Extension 实现)
为什么 animal.breathe() 输出了 Animal?
因为 breathe 没有在 protocol Animal 的大括号里声明。编译器在处理 animal.breathe() 时,只看到 animal 是 Animal 协议类型,于是直接跳到了协议扩展的实现地址。
4. 动态派发原理:见证表索引 (Witness Table Offset)
如果方法在 Protocol 中声明了,其派发逻辑会完全改变:
- PWT 槽位分配:每个声明的方法都会在协议见证表(PWT)中获得一个固定的偏移量(Offset) 。
- 默认实现作为保底:如果实现类没有提供自己的实现,PWT 对应槽位会指向扩展里的默认实现;如果实现了,则指向类自己的实现。
- 运行时查找:无论指针的静态类型是什么,程序都会先根据实例找到 PWT,再按偏移量取出真正的函数指针进行跳转。
5. 性能与灵活性的博弈
| 特性 | 静态派发 (仅 Extension) | 动态派发 (Declaration + Extension) |
|---|---|---|
| 派发开销 | 极低(等同于普通函数) | 中等(查表 + 间接跳转) |
| 多态支持 | ❌ 不支持 | ✅ 支持 |
| 编译器优化 | 支持内联 (Inline) 优化 | 很难进行内联优化 |
| 适用场景 | 提供不希望被重写的辅助工具方法 | 定义需要多态表现的核心业务逻辑 |
💡 总结与建议
如果你希望在扩展里写一个“默认实现”,且希望子类能够通过多态来重写它,务必记得在 Protocol 的原始定义中写下该方法的声明。