Swift 方法派发机制深度解析 —— 兼与 Objective-C `objc_msgSend` 对比

11 阅读8分钟

基于 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_msgSendSEL→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-ActionUIApplication sendAction:to:from:forEvent: 完全建立在 SEL 之上。
  • 消息转发forwardInvocation: 让一个对象"假装"实现某协议,是 OC 多代理与 RPC 框架的基石。

代价是:编译期不知道 IMP 是什么,全程无法内联,也无法做跨方法优化


3. Swift:四种派发方式共存

Swift 没有"统一派发"的设计,而是根据声明上下文挑选最便宜的合法方式。理解 Swift 性能模型的关键,就是搞清"什么场景用哪种"。

3.1 Static Dispatch(直接派发)

调用直接编译成 call <symbol>,可被内联、可被常量折叠。触发条件:

  • structenum 的所有方法(值类型不存在继承)
  • 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") }
}

Animalspeak 通过 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-TableStaticV-Table(兼可 OC 调)Message
class extension 中的方法StaticStaticV-TableMessage
protocol 要求的方法WitnessMessage(要求 @objc protocolMessage
protocol extension 默认实现Static不允许不允许
NSObject 子类的方法V-TableStaticV-TableMessage

几条容易踩的经验法则:

  • extension 中定义的方法即使被子类重写也不会触发动态派发,重写会被静默忽略。要支持覆写就把方法定义放回类主体。
  • 协议 extension 的"默认实现"是 static 的,不会走 PWT。如果某个类型实现了同名方法,但调用方持有的是协议变量,仍可能调到 default 实现(这是经典面试题)。
  • @objcdynamic:前者只是给 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()Leaffinal,编译器去虚化为 Static,可被内联。

把同一个方法在三种持有方式下分别调用,得到三种不同的派发路径——这是 OC 永远不会出现的现象,也是 Swift 性能调优最容易被忽略的细节。