Swift & Objective-C

objc_msgSend explained: why Objective-C method calls are not function calls

In Objective-C code, [receiver message] syntax wrapped in brackets looks like an ordinary method call.

4 min read
Cover image for objc_msgSend explained: why Objective-C method calls are not function calls

In Objective-C code, the bracketed [receiver message] syntax looks like an ordinary method call.

But it is fundamentally different from a C++ or Java method call.

Let’s start with the conclusion.

An Objective-C method call is not a “function call” but “message passing”.

Which code runs is decided at runtime, not at compile time.

Once you understand this difference, method swizzling, KVO (Key-Value Observing), and unrecognized selector in crash logs all connect. This is also covered by items 11–12 of Effective Objective-C 2.0.


Core summary first (3 points)

  1. [obj foo] compiles to an objc_msgSend(obj, @selector(foo)) function call.
  2. objc_msgSend searches the class’s method table at runtime to find the function (IMP) to execute.
  3. If it cannot find one, a three-stage recovery process called message forwarding runs instead of causing an immediate crash.

All messages converge on objc_msgSend

C++ virtual functions have their vtable index fixed at compile time. In Objective-C, almost every message passes through the objc_msgSend gateway. More precisely, [super foo] compiles to objc_msgSendSuper, and in the x86-64 era there were sibling functions such as objc_msgSend_stret for structure returns (merged into objc_msgSend on arm64), but the operating principle is the same.

NSString *result = [greeting uppercaseString];

After compilation, this code becomes a C function call like this.

NSString *result = objc_msgSend(greeting, @selector(uppercaseString));

objc_msgSend does something simple.

  1. Follow the receiver’s isa pointer to find its class
  2. Search the class’s method cache first (very fast)
  3. If it is not cached, search the method list, then move up to the superclass
  4. Jump through the discovered function pointer (IMP)

Because the result is cached, sending the same message a second time is nearly as fast as a function call. That is why “dynamic dispatch is slow” is rarely a practical concern.

Even if lookup fails, three recovery chances remain
Even if lookup fails, three recovery chances remain

Not found? The three stages of message forwarding

If method lookup still fails after reaching the root class, the runtime gives you three chances before crashing.

Stage 1: Dynamic method resolution (resolveInstanceMethod:)

The class is asked, “Would you like to add the method for this selector now?” Core Data’s @dynamic properties create and attach accessors on the fly at this stage.

+ (BOOL)resolveInstanceMethod:(SEL)sel {
    if (sel == @selector(dynamicMethod)) {
        class_addMethod(self, sel, (IMP)dynamicIMP, "v@:");
        return YES;
    }
    return [super resolveInstanceMethod:sel];
}

Stage 2: Fast forwarding (forwardingTargetForSelector:)

It asks, “If you cannot handle it, can you tell me an object that can?” If another object is returned, the message is passed to it. This is useful for simulating “something like multiple inheritance” without inheritance.

Stage 3: Full forwarding (forwardInvocation:)

Before entering this stage, the runtime first asks for the method signature with methodSignatureForSelector:. If nil is returned, forwarding never starts; it goes straight to doesNotRecognizeSelector: and crashes. If a signature exists, the entire message is wrapped in an NSInvocation object and passed along. You can change arguments, broadcast to multiple objects, or silently swallow it. This is the most flexible stage, but also the most expensive.

Only when all three stages fail does the famous crash finally occur.

-[MyViewController buttonTapped:]: unrecognized selector sent to instance 0x7f8a2c400c50

It is now clear why the crash log says “unrecognized selector” rather than “function not found.” Compilation was fine; runtime lookup and forwarding simply failed completely.


What this design enabled

Familiar iOS features run on top of this runtime design.

  • KVO: adding an observer makes the runtime secretly replace the object’s isa with a subclass
  • Method swizzling: replaces the selector-to-IMP connection at runtime
  • NSProxy: creates a proxy object that works entirely through forwarding (also used to resolve NSTimer retain cycles)
  • @dynamic: resolves accessors during stage-1 forwarding instead of creating them at compile time

Swift chose static and vtable dispatch instead of message dispatch, trading away this flexibility for speed and safety. That is why Swift properties need @objc dynamic to use KVO. It signals that the property should be handed back to the Objective-C runtime’s messaging world.

This crash log reads differently now
This crash log reads differently now

Conclusion

  • The reality of an Objective-C method call is message passing through objc_msgSend
  • The code to execute is decided at runtime, and caching means you can stop worrying about performance
  • When lookup fails, recovery proceeds through dynamic resolution → alternative receiver → full forwarding
  • An unrecognized selector crash means every one of these stages failed

Even if you never touch legacy Objective-C code, this runtime still runs underneath UIKit. Once a single crash-log line starts reading differently, your debugging changes too.