Message Passing - The Heart of Objective-C
Every time you write [object doSomething] in Objective-C, you're not calling a function. You're sending a message. The runtime figures out which method to invoke at runtime, not compile time. This single decision shapes everything about the language.
The Syntax Tells the Story
Objective-C's square bracket syntax isn't just aesthetics. It reflects the underlying model:
[receiver message];
[receiver messageWithArgument:value];
This translates directly to the runtime function:
objc_msgSend(receiver, @selector(message));
objc_msgSend(receiver, @selector(messageWithArgument:), value);
The runtime looks up the selector in the receiver's class, finds the corresponding implementation, and calls it. If the class doesn't implement the method, it checks superclasses. If nobody implements it, the message forwarding system kicks in.
nil Handling
Message sending to nil does nothing and returns zero:
NSString *name = nil;
NSUInteger length = [name length]; // Returns 0, doesn't crash
[name appendString:@"test"]; // Does nothing
This isn't special-cased syntax. The runtime checks for nil receivers and short-circuits. No method is invoked.
This behavior lets you write cleaner code:
// No need for nil checks before every message
NSString *uppercase = [[self userName] uppercaseString];
If userName returns nil, uppercaseString receives a nil receiver and returns nil. No crashes, no explicit guards.
Selectors as Data
Selectors (SEL) are just interned strings. You can store them, pass them around, and use them later:
SEL action = @selector(buttonPressed:);
[button addTarget:self action:action forControlEvents:UIControlEventTouchUpInside];
The target-action pattern depends on this. Buttons don't know about your methods at compile time. They store a selector and send it to the target when triggered.
// What the button does internally
[target performSelector:action withObject:sender];
Dynamic Method Resolution
If a class doesn't implement a method, the runtime asks if it wants to add one dynamically:
+ (BOOL)resolveInstanceMethod:(SEL)sel {
if (sel == @selector(dynamicMethod)) {
class_addMethod([self class], sel, (IMP)dynamicImplementation, "v@:");
return YES;
}
return [super resolveInstanceMethod:sel];
}
void dynamicImplementation(id self, SEL _cmd) {
NSLog(@"Dynamic method called!");
}
Core Data uses this for managed object accessors. Your @dynamic properties don't have written implementations—the runtime adds them when first called.
Message Forwarding
If dynamic resolution fails, the object can forward the message elsewhere:
- (id)forwardingTargetForSelector:(SEL)aSelector {
if ([self.wrappedObject respondsToSelector:aSelector]) {
return self.wrappedObject;
}
return [super forwardingTargetForSelector:aSelector];
}
This enables proxy objects, decorators, and other patterns without inheritance. NSProxy is built entirely on message forwarding.
The Full Forwarding Path
When you send a message, here's what happens:
- Check cache for recently-called method
- Search class's method list
- Search superclass chain
- Call
+resolveInstanceMethod:(chance to add method dynamically) - Call
-forwardingTargetForSelector:(fast forwarding) - Build
NSInvocationand call-forwardInvocation:(slow forwarding) - Call
-doesNotRecognizeSelector:and crash
Most messages hit step 1 or 2. The forwarding steps exist for advanced uses.
Performance Considerations
Dynamic dispatch has overhead compared to C function calls. But the runtime is heavily optimized:
Method caching remembers recent lookups. After calling a method once, subsequent calls hit the cache—nearly as fast as a function pointer.
Tagged pointers skip message sends for small objects. Small strings and numbers encode their data in the pointer itself.
IMP caching lets you bypass message sending entirely in tight loops:
NSUInteger count = array.count;
IMP objectAtIndexIMP = [array methodForSelector:@selector(objectAtIndex:)];
SEL objectAtIndexSEL = @selector(objectAtIndex:);
for (NSUInteger i = 0; i < count; i++) {
id obj = objectAtIndexIMP(array, objectAtIndexSEL, i);
// ...
}
This is rarely necessary, but it shows that you can opt out of dynamic dispatch when needed.
What This Enables
Dynamic messaging makes possible:
- Categories: Add methods to existing classes without subclassing
- Swizzling: Replace method implementations at runtime
- KVO: Automatically notify observers when properties change
- Delegation: Call methods on objects whose type you don't know at compile time
- Introspection: Ask objects what they can do with
respondsToSelector:
None of these work the same way in statically dispatched languages. The runtime flexibility is the point.
The Swift Contrast
Swift uses static dispatch by default, falling back to dynamic dispatch for classes and protocols. Swift methods marked @objc dynamic use Objective-C's message passing.
The tradeoff: Swift is faster for known types, Objective-C is more flexible. Both have their place, which is why they interoperate.
Understanding message passing transforms how you see Objective-C. The brackets aren't syntax—they're the language's philosophy made visible.