I learned the limitation of Objective-C categories in a very ordinary way: I added a property to a category, expected the compiler to create storage, and then discovered that no backing instance variable or accessor existed. A category can contribute methods to an existing class, but it cannot enlarge instances of that already-defined class. Once I stopped thinking of a category property as stored data and started treating it as an accessor contract, the behavior made sense.
Why a Category Property Does Not Create Storage
A property declaration describes accessor methods and ownership intent. In a normal class implementation, property synthesis can also create a backing ivar. A category has no place to add that ivar to the original instance layout, so declaring a property in a category does not synthesize working storage. If the getter and setter are not implemented, calling them ends in an unrecognized-selector exception.
@interface NSOperation (RequestContext)
@property (nonatomic, copy) NSString *td_traceIdentifier;
@end
// This declaration alone does not create _td_traceIdentifier
// and does not implement the two accessors.
The reason is the object’s physical layout. An instance is allocated with enough space for the ivars known to its class and superclasses. Existing machine code also uses offsets to reach those fields. Allowing a category loaded later to insert another field would make old instances too small and invalidate assumptions made by compiled code. Methods do not have that problem: the runtime can attach method metadata without changing the size of each object.
What class_addIvar Really Allows
The Objective-C runtime exposes class_addIvar, but it is not an escape hatch for categories. Apple’s runtime documentation limits it to a class created with objc_allocateClassPair before that class is registered with objc_registerClassPair. At that moment no instance exists and the runtime can still calculate a valid layout. Calling it for an existing registered class fails.
Discussions of runtime internals often mention structures such as class_ro_t and class_rw_t. They are useful when studying a particular open-source runtime version, but they are implementation details rather than contracts application code should depend on. The durable public rule is simpler: categories add behavior; they do not add per-instance ivar storage to an existing class.
A Real Project Scenario: Request Metadata on NSOperation
In one networking pipeline I needed to attach a trace identifier and retry count to operations created by different modules. Subclassing NSOperation was awkward because some operations were block operations and others came from framework code. A category was a good place for the shared API, while associated objects provided the external storage.
#import <Foundation/Foundation.h>
#import <objc/runtime.h>
@interface NSOperation (RequestContext)
@property (nonatomic, copy) NSString *td_traceIdentifier;
@property (nonatomic, strong) NSNumber *td_retryCount;
@end
@implementation NSOperation (RequestContext)
- (void)setTd_traceIdentifier:(NSString *)value {
objc_setAssociatedObject(self, @selector(td_traceIdentifier), value,
OBJC_ASSOCIATION_COPY_NONATOMIC);
}
- (NSString *)td_traceIdentifier {
return objc_getAssociatedObject(self, _cmd);
}
- (void)setTd_retryCount:(NSNumber *)value {
objc_setAssociatedObject(self, @selector(td_retryCount), value,
OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}
- (NSNumber *)td_retryCount {
return objc_getAssociatedObject(self, _cmd);
}
@end
The selector used by the getter is a convenient unique key, and _cmd in that getter is the same selector. I prefix category members with td_ because categories share a method namespace with the original class and other categories. A generic name such as identifier is more likely to collide with a future framework method.
Choosing an Association Policy Deliberately
Association policies resemble familiar property ownership choices. The demo uses OBJC_ASSOCIATION_COPY_NONATOMIC for the trace string, so assigning a mutable string preserves the value at assignment time. It uses OBJC_ASSOCIATION_RETAIN_NONATOMIC for the number. Setting a value to nil removes that one association.
ASSIGNdoes not provide zeroing-weak behavior and is rarely suitable for object ownership.RETAIN_NONATOMICkeeps the associated object strongly.COPY_NONATOMICis appropriate for values such as strings or blocks when snapshot semantics are wanted.- The atomic variants change accessor synchronization costs, but they do not make a multi-step operation logically thread-safe.
Associated objects are released as the host object is torn down, but that does not mean retain cycles are impossible. If an associated block or helper strongly captures the host, the pair can keep each other alive. I apply the same ownership review I would use for ordinary strong properties. I also avoid objc_removeAssociatedObjects in category code because it removes associations belonging to every library, not only mine.
When I Prefer a Subclass or Wrapper
Associated objects are useful for small, optional metadata on objects whose class I do not control. They are not my default model layer. If the state is central to an object’s identity, requires invariants, needs serialization, or has complex concurrency rules, a subclass or wrapper makes that design visible. For request tracing, the extra values are supplemental and short-lived, so an association is a reasonable tradeoff.
Another option is a separate map keyed by object identity. That can be easier to inspect and can support explicit cleanup, but it introduces its own synchronization and lifetime problems. The right choice depends on whether the added state belongs conceptually to the object, whether you control construction, and how easily the behavior can be tested.
Run the Complete Foundation Demo
The companion repository is a macOS Foundation command-line program, so it runs without an iOS simulator or third-party dependency. It creates two independent NSBlockOperation objects and verifies copy behavior, retained retry state, instance isolation, an unset value, and removal by assigning nil.
git clone https://github.com/2252408699/objc-category-associated-state-demo.git
cd objc-category-associated-state-demo
make run
On a successful run, the program prints five PASS lines followed by All checks passed. It exits with status zero, which makes the same example usable in a simple CI job. Run make clean to remove the compiled executable.
The Rules I Keep in Code Review
- A category property needs explicitly implemented accessors.
- Use a unique static address or a selector as the association key.
- Prefix category methods to reduce collisions with framework updates.
- Match the association policy to the intended ownership semantics.
- Do not promise thread safety just because the runtime protects its internal association table.
- Check strong associations for retain cycles.
- Prefer a subclass or wrapper when the new state is essential rather than supplemental.
Complete source code: objc-category-associated-state-demo on GitHub
