Swift 方法派发机制深度解析 —— 兼与 Objective-C `objc_msgSend` 对比
基于 Swift 5.10(含部分 Swift 6 行为)与现代 Objective-C(ARC + LLVM clang)。 文中代码片段为最小化示例,仅作为论点的佐证。
核心要点
| 派发方式 | 调用开销 | 触发条件 | 可被 Hook | 典型场景 |
|---|---|---|---|---|
| Static Dispatch(直接派发) | 最低,可内联 |
struct/enum 方法、final、全局函数、@inlinable
|
否 | 值类型、性能敏感路径 |
| V-Table Dispatch(虚表派发) | 一次间接跳转 |
class 的非 final 方法(无 @objc) |
否 | 普通 Swift 类继承 |
| Witness Table Dispatch | 一次表查 + 一次间接跳转 | 通过协议变量调用协议方法 | 否 | 面向协议编程 |
Message Dispatch(OC objc_msgSend) |
SEL→IMP 查表(带缓存) |
@objc dynamic、继承自 NSObject 且未优化 |
是(Swizzle/KVO) | OC 互操作、AOP |
一句话总结:Swift 默认追求"能静态就静态",只在继承、协议、互操作三条路径上才退化为表派发或消息派发;OC 则把所有方法调用统一成 objc_msgSend 的动态消息查找,灵活但每次调用都要付出查表代价。
1. 为什么要谈"派发"
方法派发(method dispatch)就是编译器/运行时如何把"调用某方法"翻译成"跳转到某段机器指令"。
派发方式直接决定三件事:
- 性能:是否能内联、是否要查表、是否能命中分支预测。
- 可扩展性:能不能在运行时替换实现(Swizzle、KVO、Mock)。
- 二进制兼容:库的方法表布局变化是否会破坏调用方。
OC 与 Swift 在这三个维度上的设计哲学截然相反,下面分别拆解。
2. Objective-C:一切皆消息
2.1 objc_msgSend 的本质
OC 中 [obj doSomething:arg] 在编译期不会被解析为某个具体函数地址,而是被翻译成:
((void (*)(id, SEL, id))objc_msgSend)(obj, @selector(doSomething:), arg);
objc_msgSend 是一段手写汇编,做的事情大致是:
1. 取 obj->isa 拿到 Class
2. 在 Class 的 method cache 里按 SEL hash 查 IMP
3. 命中 → 直接 jmp IMP(尾调用,不入栈)
4. 未命中 → __class_lookupMethodAndLoadCache3 走 method list / 父类链
5. 查到 → 写回 cache
6. 仍找不到 → 进入 forwarding(resolveInstanceMethod / forwardingTargetForSelector / forwardInvocation)
⚠️ 实战提示:
objc_msgSend的 cache 是 per-class 的开放寻址哈希表,命中率通常 > 95%。这意味着"OC 方法慢"的直觉并不准确——在热路径上一次调用通常只多花几个时钟周期,远低于一次 cache miss。真正的成本不在派发本身,而在无法内联导致优化器失去全局视野。
2.2 消息派发带来的能力
消息派发让以下能力成为零成本默认值:
-
Method Swizzling:替换
Class的 method list 即可全局劫持。 -
KVO:runtime 动态生成
NSKVONotifying_XXX子类并替换isa。 -
响应链 / Target-Action:
UIApplication sendAction:to:from:forEvent:完全建立在 SEL 之上。 -
消息转发:
forwardInvocation:让一个对象"假装"实现某协议,是 OC 多代理与 RPC 框架的基石。
代价是:编译期不知道 IMP 是什么,全程无法内联,也无法做跨方法优化。
3. Swift:四种派发方式共存
Swift 没有"统一派发"的设计,而是根据声明上下文挑选最便宜的合法方式。理解 Swift 性能模型的关键,就是搞清"什么场景用哪种"。
3.1 Static Dispatch(直接派发)
调用直接编译成 call <symbol>,可被内联、可被常量折叠。触发条件:
-
struct、enum的所有方法(值类型不存在继承) -
class中标了final的方法、或final class的全部方法 -
private方法(编译器能证明无覆写) - 全局函数、
static函数 -
@inlinable/@_transparent修饰的方法
struct Counter {
var value = 0
mutating func tick() { value += 1 }
}
var c = Counter()
c.tick()
c.tick() 在 -O 下会被完全内联,最终汇编里看不到调用指令,只剩一条 add。
3.2 V-Table Dispatch(虚表派发)
Swift 的 class 与 C++ 类似,每个类有一张 V-Table(在 metadata 末尾),按声明顺序存放方法指针。调用时:
1. 从 obj 的 metadata 偏移取 V-Table
2. 按方法在表中的固定 index 取 IMP
3. call IMP
只有一次表查 + 一次间接跳转,比 objc_msgSend 少了 SEL hash 与 cache 命中判断,但因为是间接调用,仍然无法跨方法内联。
class Animal {
func speak() { print("...") }
}
final class Dog: Animal {
override func speak() { print("woof") }
}
Animal 的 speak 通过 V-Table 派发;Dog 因为 final,其调用点会被去虚化(devirtualize)回 static dispatch。
3.3 Witness Table Dispatch(协议见证表)
通过协议变量调用协议方法时,Swift 用一张 Protocol Witness Table(PWT):每个"类型 × 协议"对生成一张表,表里按协议方法的固定 index 存放该类型的具体实现。
protocol Drawable {
func draw()
}
struct Circle: Drawable { func draw() { /* ... */ } }
func render(_ d: Drawable) {
d.draw()
}
render 拿到的 d 是一个 existential container(值 + 类型 metadata + PWT 指针)。d.draw() 实际是:
1. 从 existential container 取 PWT
2. 按 draw 在协议中的 index 取 witness
3. call witness(witness 内部再 call 真正的实现)
⚠️ 实战坑:
some Drawable(opaque return type)和Drawable(existential)的派发完全不同。前者编译期就确定了具体类型,可以走 static dispatch + 单态化(specialization),后者必须走 PWT。把热路径里的func make() -> Drawable改成func make() -> some Drawable,在 SwiftUI / 集合操作里能拿到数量级的性能提升。
3.4 Message Dispatch(走 objc_msgSend)
Swift 在以下两种情况会退化到 OC 的消息派发:
- 显式标注
@objc dynamic - 类继承自
NSObject,且方法满足@objc暴露规则,且没有被去虚化优化
class MyVC: UIViewController {
@objc dynamic func reload() { /* ... */ }
}
只有 @objc dynamic 的方法是保证走 objc_msgSend 的,因此也只有它能被 KVO / Method Swizzling 劫持。仅 @objc 不带 dynamic 的方法,编译器仍可能选择 V-Table 派发。
4. 派发规则速查表
把"声明位置 × 修饰符"组合起来,就是一张完整的决策表:
| 声明上下文 | 默认派发 | 加 final
|
加 @objc
|
加 @objc dynamic
|
|---|---|---|---|---|
struct / enum 方法 |
Static | — | 不允许 | 不允许 |
class 直接定义的方法 |
V-Table | Static | V-Table(兼可 OC 调) | Message |
class extension 中的方法 |
Static | Static | V-Table | Message |
protocol 要求的方法 |
Witness | — | Message(要求 @objc protocol) |
Message |
protocol extension 默认实现 |
Static | — | 不允许 | 不允许 |
NSObject 子类的方法 |
V-Table | Static | V-Table | Message |
几条容易踩的经验法则:
-
extension中定义的方法即使被子类重写也不会触发动态派发,重写会被静默忽略。要支持覆写就把方法定义放回类主体。 - 协议 extension 的"默认实现"是 static 的,不会走 PWT。如果某个类型实现了同名方法,但调用方持有的是协议变量,仍可能调到 default 实现(这是经典面试题)。
-
@objc≠dynamic:前者只是给 OC runtime 暴露符号,后者才强制走消息派发。
5. 性能:到底差多少
简化的相对开销(命中 cache、无优化干扰的情况下):
| 派发方式 | 相对开销 | 备注 |
|---|---|---|
| Inlined static | ~1× | 实质上没有调用 |
| Direct call (static) | ~1× | 一条 call
|
| V-Table | ~1.5–2× | 一次 load + 间接 call |
| Witness Table | ~2× | 与 V-Table 量级相同 |
objc_msgSend(cache 命中) |
~3–5× | 多了 SEL hash 与 cache 比对 |
objc_msgSend(cache miss) |
数十× | 走 method list 查找 |
但真实业务里这些差异通常被淹没,例如 UI 主线程一次 reloadData 的耗时可能是 10ms 量级,几百次 objc_msgSend 完全可以忽略。性能差异真正显著的场景是:
-
集合的内层热循环(
map/filter/ 自定义 reduce) -
每帧调用的渲染回调(
CADisplayLink、SwiftUI 的body求值) - 大量小对象的属性 getter/setter(特别是泛型容器)
6. 选型与最佳实践
6.1 写 Swift 类型时
- 默认优先
struct,需要引用语义或 OC 互操作再用class。 -
class不需要继承时直接final class,让编译器去虚化。 - 协议返回值能用
some P就别用P,能用any P就别忘加any让代码意图清晰。 - 性能敏感的 ABI 稳定库导出 API 时配合
@inlinable+@usableFromInline。
6.2 需要动态能力时
- 要被 KVO 监听 →
@objc dynamic var ... - 要被 Swizzle / Aspect →
@objc dynamic func ... - 要在 OC 代码里调用 →
@objc(不必加dynamic) - 要做 Mock / Stub → 优先用协议依赖注入,而不是 Swizzle
6.3 OC 仍不可替代的场景
公平起见,OC 的消息派发模型在以下场景仍有 Swift 难以替代的优势:
| 维度 | OC 占优的原因 |
|---|---|
| 编译速度 | 没有泛型单态化、类型推断爆炸,增量编译显著快于 Swift |
| 运行时反射 |
class_copyMethodList / class_copyIvarList 等一整套 runtime API |
| 二进制体积 | 没有 witness table / metadata 膨胀,纯 OC 类的 metadata 极小 |
| AOP / Hook 生态 | Aspect、Stinger、KVO 都建立在 objc_msgSend 之上,Swift 难以等价替代 |
| C / C++ 互操作 | 与 C 二进制接口零成本互通 |
工程实践中,底层基础库(埋点、网络、监控)继续用 OC,业务层迁 Swift,往往是兼顾性能与生产力的最稳妥分工。
7. 一个综合案例
下面这段代码同时触发了四种派发,是理解 Swift 派发模型的一个好对照:
@objc protocol Refreshable { func refresh() }
class Base: NSObject, Refreshable {
func refresh() { print("base") }
}
final class Leaf: Base {
override func refresh() { print("leaf") }
}
let a: Refreshable = Leaf()
let b: Base = Leaf()
let c: Leaf = Leaf()
a.refresh()
b.refresh()
c.refresh()
-
a.refresh():Refreshable是@objc protocol,走objc_msgSend。 -
b.refresh():Base继承NSObject,编译器保守起见走 V-Table(若Base也是final,可去虚化)。 -
c.refresh():Leaf是final,编译器去虚化为 Static,可被内联。
把同一个方法在三种持有方式下分别调用,得到三种不同的派发路径——这是 OC 永远不会出现的现象,也是 Swift 性能调优最容易被忽略的细节。