Objective-C Internals: Associated References
A comparison of Apple’s Associated References implementation and one I wrote for historical context, with additional notes about use with tagged pointer objects and what the assign
association policy actually does.
I remember eagerly awaiting the day we changed the minimum deployment target in what would become Microsoft Office 2016 for Mac to Mac OS X 10.6[1]. Snow Leopard introduced a lot of new APIs, including Grand Central Dispatch and blocks. But, I was most excited to start using Objective-C Associative References to replace some terrible code.
The Old Way
Objective-C’s greatest strength (and weakness) is its dynamic method binding. Virtually all major third-party apps (ab)use this feature to fill a functionality gap or mitigate app/system architecture impedance mismatches.
The ability to tie an object’s lifetime to the lifetime of an object instantiated and controlled by a third party (i.e., Apple) was one such functionality gap. Before the runtime provided this feature, an app could implement this functionality by, in part, pre-patching the implementation of -[NSObject dealloc]
. The following code sample shows how a third party may have implemented Associative References using this approach.
#import <Foundation/Foundation.h>
#import <objc/runtime.h>
static OSSpinLock s_lock; // for main side table
static NSMapTable *s_associatedObjects; // main side table
static IMP s_NSObject_dealloc; // original implementation
void APAssociatedObjectSet(id object, id association) {
id previousAssociation = nil;
// retain does not require the lock, so do it outside of the
// lock to minimize time spent holding the lock
[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);
// release outside of the lock in case this is the last
// release, as the dealloc implementation acquires the lock
[previousAssociation release];
}
id APAssociatedObjectGet(id object) {
OSSpinLockLock(&s_lock);
id association = [s_associatedObjects objectForKey:object];
// retain the associated object to ensure it's not deallocated
// while in use by the caller in case another thread changes the
// associated object between now and then
[association retain];
OSSpinLockUnlock(&s_lock);
return [association autorelease];
}
static void APAssociatedObject_dealloc(id self, SEL _cmd) {
// release any associated object and remove the side table entry
APAssociatedObjectSet(self, nil);
(*s_NSObject_dealloc)(self, _cmd);
}
void APAssociatedObjectInitialize(void) {
s_lock = OS_SPINLOCK_INIT;
// The key is weak to prevent the object from becoming immortal.
// The value is weak to explicitly control the retain count to
// prevent dealloc reentrancy deadlocks.
s_associatedObjects=[NSMapTable mapTableWithWeakToWeakObjects];
// Pre-patch -[NSObject dealloc] to clean up s_associatedObjects
Method m = class_getInstanceMethod([NSObject class],
@selector(dealloc));
s_NSObject_dealloc = method_getImplementation(m);
method_setImplementation(m, (IMP)&APAssociatedObject_dealloc);
}
Although the implementation is only 59 lines, including white space and comments, there are a few things I want to call out:
-
This implementation supports 0 or 1 object associations but could support an arbitrary number of associations, like
objc_setAssociatedObject()
, with minor revisions. Alternatively, the client could use anNSMutableDictionary
to associate an arbitrary number of objects. -
Every
-dealloc
needs to acquire a lock to perform bookkeeping (in addition to the runtime and allocator lock acquisition(s)). We saw in a previous post that the runtime has a fast deallocation path for object instances that do not have an associated reference (in addition to other criteria), enabling it to avoid locking overhead for most cases. -
Removing an association may cause the associated object to deallocate, which, in turn, may cause its associated objects to deallocate. So, the implementation must avoid recursion while holding the lock, as
OSSpinLock
is not reentrant. -
Pre-patching
-dealloc
on the class of the object gaining an association is not a viable approach for two reasons:-
A class hierarchy may have multiple patches. For example, after setting an associated object on an
NSObject
and another onNSView
, allNSView
instances, including subclasses, call into the patch twice during deallocation. An implementation could handle this case but at the cost of additional complexity. -
Calling into the correct
-dealloc
from the patch becomes more challenging. Continuing with the above example, if anNSTableView
is deallocating, how does the patch know whether it should call the-[NSView dealloc]
implementation or the-[NSObject dealloc]
implementation? (The class identity ofself
is alwaysNSTableView
.) A significant amount of bookkeeping would be required to track where an object is in its dealloc chain and to handle additional deallocations that occur as part of its deallocation.
-
-
Objective-C Automatic Reference Counting (ARC) didn’t debut until OS X 10.7 Lion. So I want to highlight two things that are no longer relevant to the modern Objective-C programmer:
-
The
retain
andautorelease
calls inAPAssociatedObjectGet()
guarantee the returned object lives through the current autorelease scope. Without this, another thread may cause the object to deallocate between its retrieval from the map table and its return to the caller. -
The use of weak in the map table’s
mapTableWithWeakToWeakObjects
factory method does not have ARC’s zeroing weak reference semantics. Instead, it’s the equivalent of ARC’sunsafe_unretained
.
-
-
APAssociatedObjectInitialize()
could have an__attribute__((constructor))
to initialize the feature beforemain()
is called. I left that out as major apps usually have a sophisticated initialization system that would call this function.
Next, let’s see how Apple’s Objective-C runtime implements this feature.
The Apple Way
The above third-party implementation and commentary align shockingly well with Apple’s implementation. (I say shocking because I wrote it before looking up Apple’s implementation[2].)
First, let’s look at objc_setAssociatedObject()
, which simply calls _object_set_associative_reference()
.
runtime/objc-references.mm
lines 170-219DisguisedPtr<objc_object> disguised{(objc_object *)object};
ObjcAssociation association{policy, value};
// retain the new value (if any) outside the lock.
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) {
/* it's the first association we make */
isFirstAssociation = true;
}
/* establish or replace the association */
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();
// release the old value (outside of the lock).
association.releaseHeldValue();
Given the alignment with the previous section, I’ll simply highlight key similarities and differences relative to my implementation.
-
DisguisedPtr
is used to inhibit heap tracing in tools likeleaks
. -
The
ObjcAssociation
helper object implements the association policy (storage withassign
,retain
, orcopy
semantics, and whether reads areatomic
ornonatomic
). -
The
AssociationsManager
is an RAII convenience object to lock and unlock the associations spinlock (now an unfair lock). -
Object associations are stored using a hash map (specifically LLVM’s DenseMap). A top-level hash map maps object pointers to an associations hash map, which maps keys to
ObjcAssociation
s (the object and its retain policy). -
Associating a
nil
value removes any previously associated object. -
When an object gains its first association, the runtime updates its state to turn off the fast deallocation path.
-
The release of any previously associated object takes place outside of the lock.
Like the setter, objc_getAssociatedObject()
simply calls _object_get_associative_reference()
. The get path is straightforward, so there’s nothing for me to comment on! 🙊
Apple’s implementation provides a curious function, objc_removeAssociatedObjects()
. I’m honestly not sure why this is a public API—the comment in runtime.h
advises against using it (and for a good reason):
The main purpose of this function is to make it easy to return an object to a "pristine state”. You should not use this function for general removal of associations from objects, since it also removes associations that other clients may have added to the object. Typically you should use
objc_setAssociatedObject
with a nil value to clear an association.
Like the getter and setter functions, objc_removeAssociatedObjects()
calls _object_remove_associations()
. But, this internal function takes one additional parameter: bool deallocating
, which is false
when called by objc_removeAssociatedObjects()
. This internal function has only one other caller, objc_destructInstance()
, which, unsurprisingly, passes true
for deallocating
.
So, what does the deallocating
flag do? A comment in the function explains its purpose:
If we are not deallocating, then
SYSTEM_OBJECT
associations are preserved.
Apple has an internal policy flag, OBJC_ASSOCIATION_SYSTEM_OBJECT
, which prevents its associated objects from being removed by objc_removeAssociatedObjects()
. You can shoot yourself in the foot using this function, but Apple will prevent you from violating their assumptions.
I suspect this is why the association key has a type of void *
: pointer keys are hard to identify in Apple’s frameworks and subsequently (ab)use in third party-apps vs., for example, string keys which are easy-ish to find and use (e.g., NSNotificationName
).
Tagged Pointer Objects
What happens when setting an associated object on a tagged pointer object? The effect is the same as assigning the object to a global variable with the same storage policy: the object remains until a new value is assigned. So, setting an associated object on a tagged pointer object will effectively leak the associated object.
The associated object implementation has no code paths to handle tagged pointers (not even to log a warning in the console). So, the runtime stores the tagged pointer in the associations hash map where it lives indefinitely because tagged pointer objects never deallocate.
Another side effect of tagged pointer objects is that they effectively intern all values. While some types like NSNumber
are known to implement some form of interning, NSString
did not have such behavior. But, the NSString
tagged pointer code path is aggressive enough that localized strings loaded from disk may yield a tagged pointer object! Thus, any code setting associated objects on types of NSString
may find associated objects stomping on each other if the string instances are tagged pointer objects instead of discrete instances.
Although the use of tagged pointer objects is considered an internal implementation detail, take a look at the classes that use tagged pointers and avoid using associated objects with any objects with those types.
A Closer Look At assign Storage
In writing the post, I realized I’ve been misusing this API for over a decade 🤦♂️. The comment next to the OBJC_ASSOCIATION_ASSIGN
policy says:
Specifies a weak reference to the associated object.
As mentioned in The Old Way section, before ARC, the term weak is equivalent to ARC’s unsafe_unretained
; this flag does not use ARC zeroing weak reference semantics. Take a look through the implementation for any use of weak. There isn’t any!
I have a lot of grepping and code reviewing to do this week…
Conclusion
A third-party implementation of Associated References can nearly match what Apple can do with a first-party implementation, with the main first-party advantage being the availability of a fast deallocation path for objects without associated objects. New runtime optimizations (i.e., tagged pointer objects) may cause unexpected behavior for code associating objects with objects whose uniqueness and lifetime may change across OS versions. And, historical context is essential—the assumptions underlying documentation may change over time, warping its meaning.