I first noticed KVO’s hidden subclass while debugging a setter breakpoint. The object printed as Person, but the call stack showed a class name I had never created. For a few minutes I wondered whether another library had swizzled the model behind my back.
Printing both [object class] and object_getClass(object) before and after adding the observer made the change obvious. The public class stayed the same, while the runtime class changed. That small experiment finally made KVO feel mechanical instead of magical.
Below is the path I followed, including the places where this implementation detail actually matters and the places where application code should deliberately ignore it.
The Two Log Lines That Gave It Away
Before observation, an object’s runtime class is normally the class used to create it. After an observer is registered for an automatically observable key, the runtime may create an intermediate subclass and redirect the instance’s isa pointer to it. Other instances of the original class do not need to change.
What finally made the behavior click for me was watching the two class checks disagree in the debugger. The generated subclass handled the observed setter, while an ordinary [object class] call still returned Person. Calling object_getClass(object) exposed the extra runtime layer without changing the model object I was working with.
Person *person = [Person new];
NSLog(@"public: %@", [person class]);
NSLog(@"runtime: %@", object_getClass(person));
[person addObserver:observer
forKeyPath:@"name"
options:NSKeyValueObservingOptionNew
context:&Context];
NSLog(@"public: %@", [person class]);
NSLog(@"runtime: %@", object_getClass(person));
Run this in a debug target and compare the two logs before and after registration. Treat the runtime subclass name as diagnostic evidence only. Code should not branch on its exact spelling.
Following the Setter Call
The generated setter conceptually performs three steps: announce that a value will change, invoke the original implementation, and announce that the change completed. The KVO machinery records the old value when requested, reads the new value, and delivers an observation change to registered observers.
A simplified mental model looks like this:
- (void)setName:(NSString *)name {
[self willChangeValueForKey:@"name"];
[super setName:name];
[self didChangeValueForKey:@"name"];
}
The actual implementation is runtime-managed and more careful, but this model explains why automatic observation follows setter calls. Directly changing an instance variable can bypass the setter and therefore bypass automatic notification. Manual notification is possible, but it must match the documented KVO rules.
This also explains dependent behavior during debugging. A breakpoint on the original setter may appear to arrive through an unfamiliar subclass. The receiver is still the same object; its dispatch path has changed.
Why My Two Class Checks Disagreed
-class is an Objective-C method and can be overridden. The KVO-generated subclass can return its superclass so application code continues to see the public type. object_getClass reads the object’s actual runtime class pointer and is therefore useful for low-level inspection.
After seeing that difference, I stopped treating the runtime class as the object’s product-facing identity. In application code I still use isKindOfClass: and class. I reach for object_getClass only when a breakpoint or test tells me that dispatch is taking an unexpected route.
That distinction has saved me from a few bad fixes. The extra subclass explains the call stack, but it is not something the rest of the app needs to know about.
The Awkward Part: Mixing KVO with Swizzling
Method swizzling changes which implementation corresponds to a selector on a class. KVO may place its override on a dynamic subclass. If swizzling code assumes every instance dispatches directly through the original class, the order of registration and swizzling can produce surprising results.
For example, swizzling the original setter after an object has begun observation may not replace the override on the generated subclass. I once considered swizzling the hidden subclass as a shortcut, but that would have tied the fix to a class created at runtime and only to the instances already being observed. I backed out and moved the timing hook to the request layer instead.
If interception is unavoidable, test both orders: install interception before adding the observer, and add the observer before installing interception. Test observed and unobserved instances. Verify that each assignment calls the original setter once and delivers the expected number of notifications. Do not infer correctness from a single happy-path log.
When I Turn Off Automatic Notifications
A class can opt out of automatic notifications for a key by implementing +automaticallyNotifiesObserversForKey:. Manual notification is appropriate when several internal changes form one logical update, or when mutation does not pass through an ordinary setter.
Manual control carries responsibility. Calls to willChangeValueForKey: and didChangeValueForKey: must be balanced, and nested or collection changes require the correct variants. Sending both automatic and manual notifications for the same mutation can produce duplicates.
Document why a key is manual and test its change dictionaries. Observers may depend on old and new values, so a notification that merely “fires” is not necessarily correct.
Observation Lifecycle and Removal
Traditional Objective-C KVO APIs require careful lifecycle management. The observer, observed object, and registration form a relationship that must end predictably. Modern token-based or Swift observation APIs can simplify ownership, but older code often still uses context pointers and explicit removal.
Use a unique static context address rather than comparing string key paths in a shared callback. Forward unknown contexts to super. Keep registration and removal close to the owner of the observation, and avoid broad exception handling that hides double-removal mistakes.
Reproduce lifecycle cases in tests: observer deallocates first, observed object deallocates first, observation is replaced, and callbacks trigger further mutations. A passing setter test does not cover teardown behavior.
My Debugging Order Now
When a notification is missing, first confirm that the observed key is KVC-compliant and that mutation goes through the expected setter. Log [object class] and object_getClass(object) before and after registration. Then place a symbolic breakpoint on the setter and inspect the receiver’s runtime class.
If notifications are duplicated, search for manual willChange/didChange calls and verify whether automatic notifications are still enabled. Also inspect repeated registrations. If the problem appears only after swizzling, reduce the test to one class, one key, and one instance, then vary installation order.
I use these runtime details to prove or disprove a debugging theory, then keep the final application fix at a normal ownership or API boundary. That way the diagnostic trick stays in the test or debug build instead of quietly becoming a dependency.
Notes I Keep for Older KVO Code
- Use public class-membership APIs in application logic.
- Use
object_getClassonly when runtime identity matters for diagnostics. - Ensure observed mutations pass through a compliant setter or use balanced manual notifications.
- Do not depend on the hidden subclass’s exact name.
- Test interactions with swizzling in both installation orders.
- Give every traditional registration a clear lifecycle owner.
- Use unique context pointers and forward unknown contexts.
- Test old/new values, duplicate delivery, and teardown—not only callback presence.
After this experiment, KVO stopped being a piece of “runtime magic” in my head. I now picture one object, a changed dispatch path, and a setter wrapped with notification work. The observed object does not become a different model object. Its runtime class temporarily participates in an extra layer that wraps selected setters and preserves the public class abstraction.
Download the Complete Runnable Project
The complete code for this article is available in its own GitHub repository: https://github.com/2252408699/objc-kvo-isa-swizzling-demo.
Download it with git clone, then follow the repository README to build and run the example locally.
