(原文出处:Objective-C Internals | Always Processing)
比较了Apple的关联引用实现和我为历史背景编写的一个实现,并附加了关于与标记指针对象的使用以及assign关联策略实际上是做什么的额外说明。
我记得迫不及待地等待我们在即将成为Microsoft Office 2016 for Mac的项目中将最低部署目标更改为Mac OS X 10.6[1]。Snow Leopard引入了许多新的API,包括Grand Central Dispatch和blocks。但我最期待的是开始使用Objective-C的关联引用来替换一些糟糕的代码。
旧的方式
Objective-C最大的优势(也是缺点)是其动态方法绑定。几乎所有主要的第三方应用程序都(滥用)使用此功能来填补功能差距或减轻应用程序/系统体系结构的不匹配之处。
将对象的生命周期与由第三方(即Apple)实例化和控制的对象的生命周期绑定在一起,就是一个此类功能的差距。在运行时提供此功能之前,应用程序可以通过部分地预修复实现-[NSObject dealloc]来实现此功能。以下代码示例显示了一个第三方可能使用这种方法实现关联引用的方式。
C
#import <Foundation/Foundation.h>
#import <objc/runtime.h>
static OSSpinLock s_lock; // 用于主要侧表
static NSMapTable *s_associatedObjects; // 主要侧表
static IMP s_NSObject_dealloc; // 原始实现
void APAssociatedObjectSet(id object, id association) {
id previousAssociation = nil;
// 在锁之外保持,以最小化保持锁的时间
[association retain];
OSSpinLockLock(&s_lock);
previousAssociation = [s_associatedObjects objectForKey:object];
if (association != nil) {
[s_associatedObjects setObject:association forKey:object];
} else {
[s_associatedObjects removeObjectForKey:object];
}
OSSpinLockUnlock(&s_lock);
// 在锁之外释放,以防这是最后一个释放,
// 因为dealloc实现会获取锁
[previousAssociation release];
}
id APAssociatedObjectGet(id object) {
OSSpinLockLock(&s_lock);
id association = [s_associatedObjects objectForKey:object];
// 保持关联的对象,以确保在调用者使用期间它不会被释放,
// 以防其他线程在此期间更改关联的对象
[association retain];
OSSpinLockUnlock(&s_lock);
return [association autorelease];
}
static void APAssociatedObject_dealloc(id self, SEL _cmd) {
// 释放任何关联的对象并删除侧表项
APAssociatedObjectSet(self, nil);
(*s_NSObject_dealloc)(self, _cmd);
}
void APAssociatedObjectInitialize(void) {
s_lock = OS_SPINLOCK_INIT;
// 该键使用弱引用,以防止对象变得永不释放。
// 值使用弱引用,以显式地控制保持计数,以防止dealloc重入死锁。
s_associatedObjects=[NSMapTable mapTableWithWeakToWeakObjects];
// 预先修复-[NSObject dealloc]以清理s_associatedObjects
Method m = class_getInstanceMethod([NSObject class],
@selector(dealloc));
s_NSObject_dealloc = method_getImplementation(m);
method_setImplementation(m, (IMP)&APAssociatedObject_dealloc);
}
尽管实现只有59行,包括空格和注释,但还有一些我想要指出的事情:
这个实现支持0个或1个对象关联,但可以通过轻微的修改来支持任意数量的关联,就像objc_setAssociatedObject()一样。或者,客户端可以使用NSMutableDictionary来关联任意数量的对象。
每个-dealloc都需要获取一个锁来执行记录(除了运行时和分配器锁定获取)。在先前的帖子中,我们看到,运行时对于没有关联引用(除其他条件外)的对象实例有一个快速的释放路径,使其能够避免大多数情况下的锁定开销。
删除关联可能会导致关联的对象被释放,反过来可能会导致其关联的对象被释放。因此,在保持锁定的同时,实现必须避免递归,因为OSSpinLock不可重入。
在获得一个关联的对象之前,保持锁定并保留关联的对象,以确保在调用者使用期间不会释放对象,以防另一个线程在从映射表中检索对象之后将其释放。
预修复正在获得关联的对象的类上的-dealloc不是可行的方法,有两个原因:
类层次结构可能有多个补丁。例如,在NSObject上设置了一个关联的对象,然后在NSView上设置了另一个关联的对象,在dealloc期间,包括子类,所有NSView实例都会两次调用补丁。实现可能处理此情况,但代价是额外的复杂性。
从补丁中调用正确的-dealloc变得更加具有挑战性。继续上面的例子,如果NSTableView正在释放,那么补丁如何知道它应该调用-[NSView dealloc]实现还是调用-[NSObject dealloc]实现?(self的类标识始终是NSTableView。)需要大量的记录才能跟踪对象在其dealloc链中的位置,并处理作为其dealloc的一部分发生的其他附加释放。
Objective-C的自动引用计数(ARC)直到OS X 10.7 Lion才首次亮相。因此,我想强调两点,这对现代Objective-C程序员来说已不再相关:
在APAssociatedObjectGet()中的retain和autorelease调用保证了返回的对象在当前的autorelease范围内存在。如果没有这个,另一个线程可能会在从映射表中检索对象和将其返回给调用者之间使对象释放。
在地图表的mapTableWithWeakToWeakObjects工厂方法中使用的弱引用不具有ARC的零化弱引用语义。相反,它相当于ARC的unsafe_unretained。
APAssociatedObjectInitialize()可以有一个__attribute__((constructor))来在调用main()之前初始化该功能。我把这个留出来,因为主要的应用程序通常有一个复杂的初始化系统会调用这个函数。
接下来,让我们看看Apple的Objective-C运行时是如何实现这个功能的。
苹果的实现方式
上述第三方实现和注释与Apple的实现惊人地相符。(我之前编写了这个实现,然后才查阅了Apple的实现[2]。)
首先,让我们看看objc_setAssociatedObject(),它只是调用了_object_set_associative_reference()。
C
runtime/objc-references.mm lines 170-219
DisguisedPtr<objc_object> disguised{(objc_object *)object};
ObjcAssociation association{policy, value};
// 在锁之外保留新值(如果有的话)。
association.acquireValue();
bool isFirstAssociation = false;
{
AssociationsManager manager;
AssociationsHashMap &associations(manager.get());
if (value) {
auto refs_result = associations.try_emplace(disguised, ObjectAssociationMap{});
if (refs_result.second) {
/* 这是我们做的第一个关联 */
isFirstAssociation = true;
}
/* 建立或替换关联 */
auto &refs = refs_result.first->second;
auto result = refs.try_emplace(key, std::move(association));
if (!result.second) {
association.swap(result.first->second);
}
} else {
auto refs_it = associations.find(disguised);
if (refs_it != associations.end()) {
auto &refs = refs_it->second;
auto it = refs.find(key);
if (it != refs.end()) {
association.swap(it->second);
refs.erase(it);
if (refs.size() == 0) {
associations.erase(refs_it);
}
}
}
}
}
if (isFirstAssociation)
object->setHasAssociatedObjects();
// 在锁之外释放旧值。
association.releaseHeldValue();
鉴于与上一节的对应关系,我将简要概述与我的实现相似之处和差异。
- DisguisedPtr用于阻止在诸如泄漏之类的工具中对堆进行追踪。
- ObjcAssociation辅助对象实现关联策略(使用assign、retain或copy语义,以及读取是原子的还是非原子的)。
- AssociationsManager是RAII便利对象,用于锁定和解锁关联自旋锁(现在是一个不公平的锁)。
- 使用哈希映射(具体为LLVM的DenseMap)存储对象关联。顶级哈希映射将对象指针映射到一个关联哈希映射,将键映射到ObjcAssociations(对象及其保留策略)。
- 关联nil值会删除任何先前关联的对象。
- 当对象获得其第一个关联时,运行时会更新其状态,以关闭快速的释放路径。
- 释放任何先前关联的对象发生在锁之外。
- 像getter和setter函数一样,objc_getAssociatedObject()只是调用_object_get_associative_reference()。获取路径很简单,所以对此我没有什么可评论的! 🙊
苹果的实现提供了一个奇特的函数,objc_removeAssociatedObjects()。老实说,我真不知道为什么这是一个公共API,runtime.h中的注释建议不要使用它(而且有很好的原因):
这个函数的主要目的是使对象返回"原始状态"变得容易。您不应该使用此函数来从对象中通常删除关联,因为它还会删除其他客户端可能已经添加到对象的关联。通常情况下,您应该使用objc_setAssociatedObject来使用nil值清除关联。
与getter和setter函数一样,objc_removeAssociatedObjects()调用了_object_remove_associations()。但是,这个内部函数多了一个参数:bool deallocating,在调用objc_removeAssociatedObjects()时为false。这个内部函数只有一个其他的调用者,objc_destructInstance(),毫不奇怪,将deallocating设置为true。
那么,deallocating标志是做什么的呢?函数中的一条注释解释了它的目的:
如果我们没有正在释放,则保留SYSTEM_OBJECT关联。
苹果有一个内部的策略标志,OBJC_ASSOCIATION_SYSTEM_OBJECT,防止其关联对象被objc_removeAssociatedObjects()删除。您可以通过这个函数自己给自己设置一个陷阱,但是苹果将阻止您违反他们的假设。
我怀疑这就是为什么关联键的类型为void *:指针键在Apple的框架中很难被识别并在第三方应用程序中(滥用)使用,例如,相对于容易被找到和使用的字符串键(例如NSNotificationName)。
标记指针对象
在标记指针对象上设置关联对象会发生什么?效果与将对象赋值给具有相同存储策略的全局变量相同:对象保持不变,直到分配新值。因此,在标记指针对象上设置关联对象将有效地泄漏关联对象。
关联对象实现没有处理标记指针的代码路径(甚至没有在控制台中记录警告)。因此,运行时将标记指针存储在关联哈希映射中,其中它会永远存在,因为标记指针对象永远不会释放。
标记指针对象的另一个副作用是,它们有效地将所有值合并为一个。虽然某些类型,如NSNumber,被知道实现了某种形式的合并,但NSString没有这样的行为。但是,NSString标记指针的代码路径足够激进,以至于从磁盘加载的本地化字符串可能会产生标记指针对象!因此,设置与NSString类型的任何对象关联对象的代码可能会发现关联对象不是放在彼此之间而是放在标记指针对象上。
尽管使用标记指针对象被视为内部实现细节,但请查看使用标记指针的类,并避免在具有这些类型的任何对象上使用关联对象。
assign存储的更近距离看
在撰写本文时,我意识到我在过去十多年中一直在错误地使用这个API 🤦♂️。在OBJC_ASSOCIATION_ASSIGN策略旁边的注释说:
指定与关联对象的弱引用。
如上面所述,ARC之前,弱等价于ARC的unsafe_unretained;此标志不使用ARC零化弱引用语义。查看一下使用弱的实现。没有任何!
这个星期我有很多地方需要进行搜索和代码审查...
结论
第三方实现的关联引用几乎可以与Apple的一方实现相媲美,主要的一方优势是对于没有关联对象的对象提供了快速的释放路径的可用性。新的运行时优化(即标记指针对象)可能会导致意外行为,使代码关联到随操作系统版本变化而变化的对象的唯一性和生命周期。历史背景是必要的,文档的假设可能随着时间的变化而改变,扭曲其含义。