Categories cannot add member variables — that is a fundamental rule of Objective-C. But associated objects provide a workaround that stores data externally, solving the problem without breaking the language design. Then one day I tried to add a member variable in a Category, and Xcode immediately threw an error. That’s when I realized these two things are fundamentally different. After digging into the Runtime source code, I finally understood that the storage structure and lifecycle of associated objects are completely different from regular member variables.
Why We Need Associated Objects
In Objective-C, the underlying structure of a Category is category_t, which contains method lists, protocol lists, and property lists — but no ivar list. A class’s instance memory layout is determined at compile time and cannot be modified at runtime.
Associated objects were designed to solve this problem — they don’t change the class’s memory structure. Instead, they attach additional data to objects externally, stored in an independent global hash table.
I hit this exact problem in a project where I needed to attach a loading state to UIView without subclassing — associated objects were the only clean solution. The key insight is that the value lives in a global hash table, not inside the object itself, which is why it bypasses the Category ivar restriction entirely.
Storage Structure: Four Layers of Hash Tables
The storage structure of associated objects can be understood as four nested hash tables.
Layer 1: AssociationsManager
A global singleton that manages all associated objects. It internally maintains an AssociationsHashMap with a lock for thread safety.
class AssociationsManager {
static spinlock_t _lock;
static AssociationsHashMap *_map; // Global hash table
public:
AssociationsHashMap &associations() {
if (_map == NULL)
_map = new AssociationsHashMap();
return *_map;
}
};
Layer 2: AssociationsHashMap
The global hash table where the key is the object’s address (processed through DISGUISE) and the value is an ObjectAssociationMap. When you call objc_setAssociatedObject, it first looks up this global table by the object’s address.
disguised_ptr_t disguised_object = DISGUISE(object);
// Use disguised_object as the key to look up in AssociationsHashMap
Layer 3: ObjectAssociationMap
Each object has its own entry in the AssociationsHashMap, pointing to an ObjectAssociationMap. The key of this map is the key you pass to objc_setAssociatedObject, and the value is an ObjcAssociation.
Layer 4: ObjcAssociation
The lowest-level data structure that stores the actual associated value (_value) and the association policy (_policy).
class ObjcAssociation {
uintptr_t _policy;
id _value;
public:
ObjcAssociation(uintptr_t policy, id value) : _policy(policy), _value(value) {}
};
The Setter Flow
objc_setAssociatedObject is implemented as _object_set_associative_reference. Here’s the simplified core logic:
void _object_set_associative_reference(id object, const void *key, id value, uintptr_t policy) {
// 1. Create the new value (retain/copy based on policy)
id new_value = value ? acquireValue(value, policy) : nil;
ObjcAssociation old_association(0, nil);
{
AssociationsManager manager;
AssociationsHashMap &associations = manager.associations();
disguised_ptr_t disguised_object = DISGUISE(object);
if (new_value) {
// 2. Look up this object in AssociationsHashMap
auto i = associations.find(disguised_object);
if (i != associations.end()) {
// Found it — get the ObjectAssociationMap
ObjectAssociationMap *refs = i->second;
// 3. Look up the key in ObjectAssociationMap
auto j = refs->find(key);
if (j != refs->end()) {
// Key exists — save the old value for later release
old_association = j->second;
// Replace with the new value
j->second = ObjcAssociation(policy, new_value);
} else {
// Key doesn't exist — insert it directly
(*refs)[key] = ObjcAssociation(policy, new_value);
}
} else {
// First associated object for this object — create ObjectAssociationMap
ObjectAssociationMap *refs = new ObjectAssociationMap;
associations[disguised_object] = refs;
(*refs)[key] = ObjcAssociation(policy, new_value);
// Mark the isa's has_assoc bit
object->setHasAssociatedObjects();
}
} else {
// value is nil — this is a deletion
// Remove the key from ObjectAssociationMap
// If ObjectAssociationMap is empty, remove the object from AssociationsHashMap
}
}
// 4. Release the old value (outside the lock)
if (old_association.hasValue()) {
releaseValue(old_association);
}
}
The Getter Flow
objc_getAssociatedObject is implemented as _object_get_associative_reference:
id _object_get_associative_reference(id object, const void *key) {
id value = nil;
uintptr_t policy = OBJC_ASSOCIATION_ASSIGN;
{
AssociationsManager manager;
AssociationsHashMap &associations = manager.associations();
disguised_ptr_t disguised_object = DISGUISE(object);
auto i = associations.find(disguised_object);
if (i != associations.end()) {
ObjectAssociationMap *refs = i->second;
auto j = refs->find(key);
if (j != refs->end()) {
ObjcAssociation &entry = j->second;
value = entry.value();
policy = entry.policy();
// Retain/autorelease based on policy
if (policy & OBJC_ASSOCIATION_GETTER_RETAIN) {
objc_msgSend(value, SEL_retain);
}
}
}
}
if (value && (policy & OBJC_ASSOCIATION_GETTER_AUTORELEASE)) {
objc_msgSend(value, SEL_autorelease);
}
return value;
}
Lifecycle
The timing of associated object release and removal is not always the same.
When an object is deallocated, dealloc calls _object_remove_assocations, which removes all associated objects. However, if the association policy is OBJC_ASSOCIATION_ASSIGN, the associated object may be released before the host object is destroyed — because assign doesn’t hold the object, and when the object releases itself, the stored pointer becomes a dangling pointer.
The removal happens during dealloc, not during release. This prevents retain cycles — if releasing associated objects triggered the host object’s release, and the host object’s release triggered the associated objects’ release, it could create a deadlock.
void _object_remove_assocations(id object) {
vector<ObjcAssociation> elements;
{
AssociationsManager manager;
AssociationsHashMap &associations = manager.associations();
disguised_ptr_t disguised_object = DISGUISE(object);
auto i = associations.find(disguised_object);
if (i != associations.end()) {
ObjectAssociationMap *refs = i->second;
// Collect all associated objects
for (auto &entry : *refs) {
elements.push_back(entry.second);
}
// Delete the ObjectAssociationMap
delete refs;
// Remove the object's entry from the global table
associations.erase(i);
}
}
// Release all associated objects outside the lock
for (auto &assoc : elements) {
releaseValue(assoc);
}
}
Association Policies and Lifecycle
objc_AssociationPolicy determines how the associated object’s lifecycle is managed:
OBJC_ASSOCIATION_ASSIGN: Does not hold the object — the associated value becomes a dangling pointer when the object is released, causing crashes on access. Corresponds toassign/unsafe_unretained.OBJC_ASSOCIATION_RETAIN_NONATOMIC: Strong reference, non-atomic. Corresponds tostrong, nonatomic.OBJC_ASSOCIATION_COPY_NONATOMIC: Copy, non-atomic. Corresponds tocopy, nonatomic.OBJC_ASSOCIATION_RETAIN: Strong reference, atomic. Corresponds tostrong, atomic.OBJC_ASSOCIATION_COPY: Copy, atomic. Corresponds tocopy, atomic.
The ASSIGN policy has a pitfall: after the associated value is released, objc_getAssociatedObject doesn’t return nil — it returns a dangling pointer. When using the ASSIGN policy, you must ensure the associated object’s lifetime is longer than the host object’s, or you’ll crash on access.
Choosing the Key
When using associated objects, the key must be unique. Apple recommends three approaches:
// Method 1: static char address
static char kAssociatedObjectKey;
objc_setAssociatedObject(self, &kAssociatedObjectKey, value, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
// Method 2: static void * pointer
static void *kAssociatedObjectKey = &kAssociatedObjectKey;
objc_setAssociatedObject(self, kAssociatedObjectKey, value, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
// Method 3: @selector (recommended — concise)
objc_setAssociatedObject(self, @selector(myProperty), value, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
// In the getter, just use _cmd
return objc_getAssociatedObject(self, _cmd);
The advantage of using @selector is that you don’t need to define an extra variable — _cmd is the current method’s SEL, which is the most convenient option.
Summary
In the Runtime implementation examined for this article, associated objects are stored outside the host object through structures such as AssociationsManager, AssociationsHashMap, ObjectAssociationMap, and ObjcAssociation. This is useful for understanding lookup costs and cleanup, but these names and layers are implementation details rather than a stable API contract. Production code should depend only on the documented association functions and policies.
The lifecycle depends on the association policy: RETAIN/COPY policies release the associated object when the host object is deallocated; ASSIGN doesn’t hold the object, so you must ensure the associated object’s lifetime yourself.
Core point: Associated objects aren’t stored in the object’s own memory — they’re in the global hash table. This lets them bypass the Category restriction on adding member variables, but also introduces additional lookup overhead and lifecycle management complexity.
A Real Project Scenario: Attaching Request Diagnostics
I have used associated objects when a mature networking type needed a small amount of diagnostic context, but changing its public model or subclass hierarchy would have affected too many callers. The reproducible project attaches a request ID, start time, and display label to an existing DemoRequest through a category. The request class remains focused on business data, while the tracing extension owns the extra metadata.
This is a narrow use case, not a recommendation to turn associated objects into hidden global storage. The extra state should have a clear owner, a documented purpose, and predictable cleanup. If a normal stored property or a wrapper object fits the design, those options are usually easier to discover and test.
Implementing Category Accessors with Unique Keys
A category can declare property syntax, but it does not synthesize storage. The getter and setter below use two address-unique static keys. Keeping the keys private to the implementation prevents accidental collisions with other categories.
static const void *DemoTraceContextKey = &DemoTraceContextKey;
- (void)setDemo_traceContext:(DemoTraceContext *)context {
objc_setAssociatedObject(self, DemoTraceContextKey, context,
OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}
- (DemoTraceContext *)demo_traceContext {
return objc_getAssociatedObject(self, DemoTraceContextKey);
}
The sample uses RETAIN_NONATOMIC for the context object and COPY_NONATOMIC for the display label. It mutates the original mutable string after assignment, then verifies that the associated label did not change. That small test makes the policy difference visible instead of leaving it as a table to memorize.
Lifecycle, Replacement, and Retain-Cycle Boundaries
Under a retaining or copying association policy, the Runtime keeps the associated value alive while the owner lives. Replacing the value releases the previous association according to normal ownership rules. Passing nil removes the value for one key, while objc_removeAssociatedObjects removes all associations and is rarely appropriate for shared category code because it can erase state owned by another component.
The executable holds a weak reference to its trace context, releases the request at the end of an autorelease pool, and prints Owner lifecycle released context: PASS. One important caveat is that automatic cleanup does not prevent a retain cycle. If the associated context strongly references the request that retains it, neither object can be released. Use a weak owner reference or redesign the relationship.
Download and Run the Complete Project
The complete standalone project is available in its dedicated GitHub repository: objc-associated-object-request-context-demo. It requires macOS and the Xcode Command Line Tools.
git clone https://github.com/2252408699/objc-associated-object-request-context-demo.git
cd objc-associated-object-request-context-demo
make run
A successful run prints both Copy policy preserved label: PASS and Owner lifecycle released context: PASS. The repository contains the request class, trace context, category accessors, lifetime checks, build file, and cleanup command.
