Associative References
分类能否添加属性?实例变量?
在讲解 Associative References 为为何物之前,我们先看一下这个问题:
分类中能否添加实例变量?如果可以如何添加?如果不可以,解释为什么?
可以通过编译器看看能否在分类中添加实例变量:
如上图所示,在分类中虽然可以添加属性,但是并不会生成对应的实例变量,也无法手动添加实例变量。编译器提示,实例变量不能放在分类中。那么现在的问题是,为什么 Objective-C 语言设计了分类语法,但是分类中却无法给类添加实例变量?
Objective-C 的分类 (@category) 被设计为一个轻量级、无侵入式扩展已有类的方式,其首要目标是添加方法(包括实例方法和类方法),而不是修改类的实例结构。不允许添加成员变量(实例变量/ivar)是经过深思熟虑的设计选择,主要原因如下:
- 保持运行时稳定性和避免内存布局冲突(核心原因):
- 当你定义一个类时,编译器会为其创建固定的内存布局信息(类结构在底层的定义 objc_class),其中包含了实例变量(ivars)的名称、类型、偏移量等信息。编译后的代码(包括其他可能依赖这个类的代码)以及运行时环境都基于这个布局信息来访问对象的数据(比如 object->ivar)。
- 如果分类可以添加 ivars,这就意味着需要在运行时动态改变类的内存布局!想象一下:
- 程序启动,类 A 被加载,它的内存布局是固定的 (包含 ivar1, ivar2)。
- 稍后,一个包含新增 ivar3 的分类被加载。运行时必须尝试“扩展”类 A 的内存布局来容纳 ivar3。
- 所有已经存在的类 A 的实例都需要被“调整”!因为之前创建时并没有预留 ivar3 的空间。这不仅成本极高(遍历所有现有实例并调整大小几乎不可能高效完成),而且在多线程环境下极其脆弱。
- 更重要的是,任何编译时依赖于原内存布局的代码(如直接使用偏移量访问成员变量的代码——编译器优化后常有)会立即出错,因为 ivar1 和 ivar2 的偏移量很可能因为新增了 ivar3 而改变。
- 这种对内存布局的动态修改会引入巨大的不稳定性、兼容性问题和性能瓶颈。禁止分类添加 ivars 确保了类一旦被编译和加载,其基结构就是稳定的。
- 内存管理和生命周期复杂性:
- 成员变量是对象的一部分,与对象本身具有相同的生命周期(随对象创建而创建,随对象销毁而销毁)。内存管理也由其所属类(或其父类)负责。
- 如果分类可以添加 ivars,那么这个变量内存的分配、初始化(构造函数逻辑放在哪?)、析构(析构函数逻辑放在哪?)、内存管理(如 retain/release,ARC 时代自动合成)的职责归属就变得模糊不清。它打破了类本身对其实例数据的单一管理责任原则。特别是当多个分类都试图管理自己的 ivars 时,问题会变得非常混乱。分类本身也不具有像类那样的初始化(+initialize)和析构机制。
- 分类的设计哲学:无状态的行为扩展:
- 分类的主要设计目的是扩展类的行为(方法),而不是修改类或实例的状态(数据)。它让你可以:
- 为系统类或第三方类添加新功能(方法)。
- 将一个庞大类的实现分拆到多个文件中(模块化管理)。
- 声明那些本应是私有的方法(在实现文件里声明匿名分类/类扩展 @interface ClassName ())来避免公开声明。
- 它被设计为一种相对安全、隔离的方式来添加功能。添加方法通常不会破坏已有的代码或内存布局(重写现有方法或类方法除外,那需要谨慎)。保持分类“无状态”(指不携带自己的数据)是这个安全隔离的关键。
- 分类的主要设计目的是扩展类的行为(方法),而不是修改类或实例的状态(数据)。它让你可以:
- 替代方案:关联对象
- 虽然不能直接添加 ivars,但 Objective-C 运行时提供了 关联对象(Associated Objects) 机制来模拟给已有类的实例动态添加关联值(不是真正的实例变量!)。
- 原理:运行时维护一个全局的弱引用表,将键值对(key-value)关联到指定的对象实例上。访问关联对象时,通过这个表和对象实例来查找。
- 关键区别:
- 内存位置: Ivars 存储在对象实例内部的内存块中。关联对象存储在运行时管理的独立表中。
- 访问速度: Ivar 访问是直接的内存偏移访问,非常快。关联对象访问需要查表,相对慢一些(虽然对于绝大多数应用场景可忽略)。
- 生命周期: Ivars 与对象共存亡。关联对象可以通过策略(objc_AssociationPolicy)来控制其生命周期是随对象保留(OBJC_ASSOCIATION_RETAIN)、复制(OBJC_ASSOCIATION_COPY)还是赋值(OBJC_ASSOCIATION_ASSIGN),并且在对象释放时自动清理(根据策略)。但这需要手动管理策略。
- 管理责任: Ivars 是类定义的一部分,内存管理责任清晰(由类或父类负责)。关联对象的管理责任在添加分类的代码上。
- 关联对象的权衡: 关联对象提供了一种灵活性,但也引入了间接访问、潜在的循环引用风险(如果策略选择不当)和轻微的性能开销。语言设计者选择了在分类中添加“成员变量”的责任交给开发者显式地通过关联对象来实现,而不是破坏类实例内存布局的稳定性和简洁性。
Objective-C 限制分类添加成员变量是一个深思熟虑的设计决策,核心目的是:
- 保证运行时稳定: 防止动态修改已加载类的内存布局而导致崩溃和不可预测行为。
- 明确内存管理责任: 避免与类和对象固有生命周期管理模型冲突。
- 维护分类的设计初衷: 专注于安全、无状态地扩展行为(方法),而非状态(数据)。分类应该是功能的“插件”,而不是修改类根基的“手术刀”。
Associative References
Associative References 中文直译过来是关联引用,在 Xcode 中,使用快捷键 Command + Shift + O 再输入 objc/runtime.h 就能看到文件中有一个部分就是 Associative References。它包括了三个 API 和一个枚举类型:
1 | typedef OBJC_ENUM(uintptr_t, objc_AssociationPolicy) { |
可以看到 Associative References 是 iOS 3.1 引入的技术。其中经常使用的只有前面两个 API。objc_removeAssociatedObjects(id _Nonnull object) 在 99.999% 的应用开发场景下都绝不应该调用,它仅推荐在某些极端的场景下谨慎的调用。比如:
- 调试与测试:
- 在编写单元测试时,你需要确保被测试对象在测试结束后没有任何残留状态(包括关联对象),以便下一个测试在一个干净的环境开始。
- 在调试极其复杂的内存泄漏或对象状态问题时,你可能想观察移除所有关联对象后对问题的影响(但这通常是最后手段)。
- 非常特殊的框架或工具:
- 如果你在开发一个底层调试工具、一个对象序列化/反序列化框架(需要精确控制内存镜像)、或一个对象池实现(需要在对象回收时彻底清除所有外部状态),或许可以考虑在极端谨慎和严格控制上下文中使用它。即使在这里,也需要考虑兼容性问题。
- 对第三方对象行为的应急干预(极其危险,不推荐):
- 遇到一个行为异常、怀疑其内部关联对象状态混乱的第三方对象,且在无其他解决方案时铤而走险尝试用它“重置”对象。这无异于赌博,极易引入新问题且难以维护。
总之应该把这个 API 当作核按钮看待,威力巨大,但按下后的后果通常是灾难性的、不可控的。日常开发中应该使用 objc_setAssociatedObject() 并传入 nil 作为 value 参数来实现移除关联对象。
接着就是如何设置关联对象和获取关联对象了:
1 | @interface Person : NSObject |
这里使用 @selector(age) 作为 key 用来存取关联对象。个人认为这种方式是最简洁方便的,当然除了这种方式,还有很多其他取 key 值的方式:
static const void *kKeyName = &kKeyName;这种方式及很多其他变体,都需要额外一个全局变量,不论如何都不如 @selector() 来的方便。- 直接使用属性名字符串。这种方式同样也比较简洁,不需要额外的变量。但是需要自己拼写属性名字符串,何不直接使用 @selector() 还有编译器提示。
关联对象的底层实现
objc/runtime.h 的实现在 objc4 库里面,Apple 开源了这个库,可以在 https://github.com/apple-oss-distributions/objc4 下载到。搜索其中的 API 很容易就能找到实现的位置,位于 objc-references.mm 文件中。其实如果熟悉 C++ 语言的话,实现的代码看起来也不难。
关联对象(Associated Object)功能的大致流程和几个核心 C++ 类之间的关系可以归纳如下:
- AssociationsManager:全局管理者
- 职责:管理所有对象的关联数据,确保线程安全
- 实现机制:
- 内部通过静态变量 _mapStorage 存储全局唯一的 AssociationsHashMap。
- 使用自旋锁 spinlock_t AssociationsManagerLock 保证多线程环境下的数据安全,构造函数加锁,析构函数解锁。
- AssociationsHashMap:全局存储表
- 定义:typedef DenseMap<DisguisedPtr
, ObjectAssociationMap> AssociationsHashMap; - 职责:以对象地址为键,存储每个对象的关联数据表(ObjectAssociationMap)
- 数据结构:
- 键:将对象包装为 DisguisedPtr
- 值:ObjectAssociationMap
- 键:将对象包装为 DisguisedPtr
- 定义:typedef DenseMap<DisguisedPtr
- ObjectAssociationMap:对象专属关联表
- 定义:typedef DenseMap<const void *, ObjcAssociation> ObjectAssociationMap;
- 职责:存储单个对象的所有关联键值对。
- 数据结构:
- 键:用户传入的 void *key 参数(例如使用 @selector(propertyName) 或静态变量地址)
- 值:ObjcAssociation(封装关联值和内存管理策略)
- ObjcAssociation:关联值包装器
- 职责:存储关联值(id _value)和内存管理策略 (uintptr_t _policy)
- 要点:负责在
objc_setAssociatedObject,objc_getAssociatedObject中根据 _policy 进行 retain、release、autorelease 等引用计数操作。
工作流程图:
Objective-C runtime 就借助这几个 C++ 类高效、安全地在任意对象上动态「挂载」或「取回」任意类型的关联数据。我知道对于很多不熟悉 C++ 的同学来说,不论说明的再如何详细,其实看起来都还是一脸蒙蔽,于是,我使用 Objective-C 语法实现了类似结构的伪代码帮助理解。
使用 Objective-C 模仿关联对象的底层实现
以下是基于 Objective-C 运行时关联对象底层原理的简化代码模拟,核心还原了四级结构(AssociationsManager → AssociationsHashMap → ObjectAssociationMap → ObjcAssociation)的设计逻辑。代码以伪实现为主,忽略线程安全等细节,重点展示数据结构关联性:
1 | // 1. 模拟 ObjcAssociation(封装值和策略) |
