全部文章

理解 Objective-C Runtime:从消息发送到消息转发

从 isa、类与元类出发,串起 SEL、IMP、方法缓存和消息转发,理解 Objective-C 语法背后的运行机制。

为什么需要理解 Runtime

Runtime 经常出现在面试中,但它的价值远不止于此。理解语法下面发生了什么,才能在遇到陌生行为时推理,而不是靠记忆回答。

从编译到运行

普通的编译过程会把源代码转成汇编,再生成机器指令。Objective-C 在这条链路上加入了动态运行时:类、对象和方法最终由 C 数据结构和运行时函数表达。

本文讨论现代 64 位 Apple 平台上的 Objective-C 2.0 Runtime。具体内存布局属于私有实现,随版本变化。下文的结构用于解释概念,不应当作稳定 ABI 直接访问。

一次消息发送发生了什么

[obj method] 表面上是方法调用,底层则会生成类似 objc_msgSend(obj, method) 的消息发送操作。大致查找过程如下:

  1. 解析对象编码后的 isa 信息,找到类对象。
  2. 先查类的方法缓存,再查方法元数据。
  3. 当前类没有对应 selector 时,沿父类链继续查找。
  4. 找到实现 IMP 后调用,并返回结果。
instance
  └─ encoded isa → class object
                      ├─ superclass
                      ├─ method metadata: selector + type encoding + implementation
                      └─ lookup cache: selector → implementation

旧版头文件里可以看到 methodLists、cache 等字段,但一些声明明确标为 OBJC2_UNAVAILABLE。直接把这些旧结构当作现代类的布局,容易得出错误结论。

实例与 isa

// Represents an instance of a class.
// A pointer to an instance of a class.
typedef struct objc_object *id;

每个实例都携带足以确定其类的 isa 信息。在现代 64 位实现中,它可能是同时包含标志位与类信息的非指针编码。因此,“沿着 isa 指针找到类”适合画示意图,却不代表原始比特就是普通地址。

类对象与元类

类本身也是对象。Class 是指向运行时类元数据的不透明指针;运行时通过它获得父类、实例大小、实例变量、方法、协议和缓存。应用应使用公开的 Runtime API,不应依赖私有结构字段。

类记录随可执行映像生成,在映像加载时由运行时完成初始化。一个已经实现化的类对象,提供了实例创建和实例方法分派所共享的身份。

类对象也能接收消息,例如类方法调用。它的 isa 指向元类,元类中的方法元数据用于处理这些消息。元类是类对象的方法分派入口,而不是简单意义上“创建类的类”。

在典型的 NSObject 继承体系里,元类的父类链最终汇聚到根元类,根元类的 isa 指向自身。

实例、类、元类与父类的关系示意图只表达消息分派关系,省略现代 isa 编码与私有元数据布局。

Method:方法的描述

一个方法包含 selector、编码后的类型签名和实现地址。公开的 Method 同样是不透明类型:

typedef struct objc_method *Method;

通过 method_getName、method_getTypeEncoding 和 method_getImplementation 获取对应信息,比自行解释内存布局可靠。

SEL:方法名的身份

// objc.h
typedef struct objc_selector *SEL;
@property SEL selector;

selector 是 Runtime 为方法名维护的共享标识。sel_getName 返回它的 C 字符串名称;@selector() 可以静态获得 selector,sel_registerName 则支持动态注册。

不同类可以使用相同的 selector。在一个类的有效方法接口中,不能靠不同参数类型区分同名 selector。Objective-C 方法名中的冒号属于名称的一部分,但参数类型不属于 selector。因此,它不等同于 C++ 的函数重载。

IMP:真正执行的函数

实现可以概念化为一个函数指针,前两个参数是接收者和 selector:

typedef id (*IMP)(id, SEL, ...);

实际调用时,C 函数类型必须匹配方法真正的返回值与参数类型。这个简化声明不是可以套用到任意方法上的通用强制转换。

方法缓存

如果每次发送消息都遍历方法列表和父类链,开销会很大。应用又往往反复调用一小组方法,因此 Runtime 会缓存成功的查找结果。后续消息优先查询缓存,命中后直接调用对应实现。

Category 的作用

编译后的 Category 提供方法和协议元数据,Runtime 在映像加载时将它们附加到目标类。它不会改变已经编译好的实例内存布局,所以能增加行为和协议声明,却不能直接增加普通实例变量。

找不到方法时:消息转发

正常查找失败后,还有三次处理机会。全部失败,最终就会触发 doesNotRecognizeSelector:,产生 unrecognized selector 异常。

第一阶段:动态方法解析

Runtime 调用 +resolveInstanceMethod: 或 +resolveClassMethod:,允许类动态提供实现。例如通过 class_addMethod 添加方法,再返回 YES,Runtime 就可以重新查找。没有提供实现,则进入后续转发路径。

第二阶段:换一个接收者

-forwardingTargetForSelector: 可以返回另一个能够处理消息的对象。这条路径比较轻量:当前对象不会处理,就将消息交给具备兼容行为的对象。

第三阶段:完整消息转发

Runtime 先调用 -methodSignatureForSelector: 获取参数和返回值类型。返回 nil 会走向无法识别 selector 的错误;有效签名则用于构造表示原始消息的 NSInvocation,随后传入 -forwardInvocation:。

在 forwardInvocation: 中,接收者可以检查 invocation,并把它转交给合适的目标。

从原理走向实践

这些概念是方法交换、关联对象、动态创建方法、运行时自省和代理转发的基础。先弄清身份、查找和调用之间的区别,再使用这些能力,通常比直接套用技巧更稳妥。

参考资料

OBJC_EXPORT id objc_msgSend(id self, SEL op, ...);
  • Class 的声明:
typedef struct objc_class *Class;