Swift 与 Objective-C

objc_msgSend 完整解析:Objective-C 方法调用为何不是函数调用

在 Objective-C 代码中,用方括号包裹的 [receiver message] 语法看起来就像普通的方法调用。

5 分钟阅读
objc_msgSend 完整解析:Objective-C 方法调用为何不是函数调用 封面图

在 Objective-C 代码中,方括号包裹的 [receiver message] 语法看起来就像普通的方法调用。

但它与 C++ 或 Java 的方法调用有本质区别。

先从结论说起。

Objective-C 的方法调用不是“函数调用”,而是“消息发送”。

要执行哪段代码,是在运行时决定的,而不是编译时。

理解这一点后,方法调配、KVO(Key-Value Observing)以及崩溃日志中的unrecognized selector就能串联起来。这也是 Effective Objective-C 2.0 第 11~12 项讨论的内容。


先看核心总结(3 点)

  1. [obj foo] 编译后会变成 objc_msgSend(obj, @selector(foo)) 函数调用。
  2. objc_msgSend 会在运行时搜索类的方法表,找到要执行的函数(IMP)。
  3. 找不到时并不会立即崩溃,而是进入称为消息转发的三阶段补救流程。

所有消息都会汇集到 objc_msgSend

C++ 的虚函数会在编译时确定 vtable 索引。相比之下,Objective-C 几乎所有消息都会经过 objc_msgSend 这个网关。更准确地说,[super foo]会编译为 objc_msgSendSuper;在 x86-64 时代,还存在用于结构体返回的 objc_msgSend_stret 等兄弟函数(arm64 中已合并到 objc_msgSend),但运行原理完全相同。

NSString *result = [greeting uppercaseString];

这段代码经过编译器后,会变成这样的 C 函数调用。

NSString *result = objc_msgSend(greeting, @selector(uppercaseString));

objc_msgSend 做的事情很简单。

  1. 沿着 receiver 的 isa 指针找到类
  2. 先查找类的方法缓存(非常快)
  3. 缓存中没有时搜索方法列表,仍然没有就继续查找父类
  4. 跳转到找到的函数指针(IMP)

查找结果会保存到缓存中,因此第二次发送相同消息时,速度已经接近函数调用。这就是“动态派发很慢”在实际开发中几乎不是问题的原因。

即使查找失败,也还剩三次补救机会
即使查找失败,也还剩三次补救机会

找不到怎么办?消息转发的 3 个阶段

如果方法查找一直到最顶层类仍然失败,运行时会在崩溃前提供三次机会。

第 1 阶段:动态方法解析(resolveInstanceMethod:)

它会询问类:“现在要不要添加与这个 selector 对应的方法?”Core Data 的 @dynamic 属性会在这个阶段实时创建并添加访问器。

+ (BOOL)resolveInstanceMethod:(SEL)sel {
    if (sel == @selector(dynamicMethod)) {
        class_addMethod(self, sel, (IMP)dynamicIMP, "v@:");
        return YES;
    }
    return [super resolveInstanceMethod:sel];
}

第 2 阶段:备用接收者(forwardingTargetForSelector:)

它会问:“如果你无法处理,能否告诉我一个可以代为处理的对象?”如果返回其他对象,消息就会转交给它。无需继承就能模拟“类似多重继承的东西”时,会用到这一阶段。

第 3 阶段:完整转发(forwardInvocation:)

进入这一阶段前,运行时会先通过 methodSignatureForSelector: 请求方法签名。如果返回 nil,转发甚至不会开始,而是直接进入 doesNotRecognizeSelector: 并崩溃。如果存在签名,整个消息会根据它封装成 NSInvocation 对象后传入。在这里可以修改参数、分发给多个对象,甚至静默吞掉消息。它最灵活,但成本也最高。

直到 3 个阶段全部失败,那个著名的崩溃才会真正发生。

-[MyViewController buttonTapped:]: unrecognized selector sent to instance 0x7f8a2c400c50

现在就能理解,为什么崩溃日志写的是“无法识别 selector”,而不是“没有函数”。编译时没有任何问题,结果只是运行时查找和转发全部失败。


这种结构带来的功能

iOS 开发中熟悉的功能,都运行在这一运行时结构之上。

  • KVO:添加观察者后,运行时会悄悄将对象的 isa 替换为子类
  • 方法调配:在运行时替换 selector 与 IMP 之间的连接
  • NSProxy:创建完全依靠转发运行的代理对象(解决 NSTimer 循环引用时也会用到)
  • @dynamic:不在编译时创建访问器,而是在第 1 阶段转发中解析

Swift 选择了静态派发和 vtable 派发,而不是消息派发,用速度和安全性换取了这份灵活性。因此,在 Swift 中使用 KVO 必须添加@objc dynamic。这表示要将该属性重新交回 Objective-C 运行时的消息世界。

现在,这份崩溃日志读起来不一样了
现在,这份崩溃日志读起来不一样了

总结

  • Objective-C 方法调用的本质,是通过 objc_msgSend 发送消息
  • 要执行的代码在运行时决定,有了缓存后无需过分担心速度
  • 查找失败时,会按动态解析 → 备用接收者 → 完整转发的顺序获得补救机会
  • unrecognized selector 崩溃意味着所有这些阶段都失败了

即使你不接触 Objective-C 遗留代码,这个运行时仍在 UIKit 底层运行。当你开始用不同方式读懂崩溃日志中的一句话时,调试思路也会随之改变。

延伸阅读