Swift & Objective-C

Objective-C Message Forwarding: 3 Stages

One of the most common lines in iOS crash logs is unrecognized selector sent to instance.

5 min read
Cover image for Objective-C Message Forwarding: 3 Stages

One of the most common lines in iOS crash logs is unrecognized selector sent to instance.

But this crash is not actually an “instant death.” Before crashing, the Objective-C runtime gives the object three chances. This recovery process is Message Forwarding.

In the previous objc_msgSend article, I covered that “when method lookup fails, the three forwarding stages run.” This article examines each stage in code. The key points are why stage 2 is nicknamed Fast Forwarding and why stage 3 needs a method signature.


The Big Picture: Three Chances and Their Costs

If method lookup reaches the topmost class without finding the IMP, a function pointer to the method’s actual implementation, the runtime asks the following questions in order.

Stage Method Question Cost
1. Dynamic Method Resolution resolveInstanceMethod: “Would you like to add the method now?” Low
2. Alternate Receiver forwardingTargetForSelector: “Is there an object that can receive it instead?” Low (Fast)
3. Full Forwarding methodSignatureForSelector: + forwardInvocation: “I’ll give you the whole message—would you like to handle it?” High

The order is also the cost order. The runtime tries the cheapest option first; if all are rejected, doesNotRecognizeSelector: is called and the infamous crash occurs.


Stage 1: resolveInstanceMethod: — Adding the Method at Runtime

The first question comes as a class method call. If you attach an implementation with class_addMethod and return YES, message dispatch starts over and succeeds this time.

void dynamicIMP(id self, SEL _cmd) {
    NSLog(@"Dynamically added implementation");
}

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

The third argument’s "v@:" is type encoding. The return value is void (v), the receiver is id (@), and the selector is (:). This reveals that every Objective-C method receives two hidden arguments, self and _cmd.

Core Data is a representative user of this stage. Properties declared with @dynamic have no accessor at compile time; the accessor is created dynamically at the first call, during this stage.

One caveat: resolveInstanceMethod: can be called even when forwarding is not involved. It is also invoked when respondsToSelector: or KVC (Key-Value Coding) performs an internal method lookup, so logging here produces far more output than expected.


Stage 2: forwardingTargetForSelector: — What Fast Forwarding Really Is

The second question is: “If you cannot handle it, can you point me to an object that can?”

- (id)forwardingTargetForSelector:(SEL)aSelector {
    if ([self.helper respondsToSelector:aSelector]) {
        return self.helper;
    }
    return [super forwardingTargetForSelector:aSelector];
}

Returning a non-nil object resends the entire message to that object. The reason this stage is called Fast Forwarding becomes clear compared with stage 3: it only swaps the receiver, without creating an NSInvocation object, so it costs nearly the same as ordinary message dispatch.

This stage also has clear practical uses.

  • Simulating multiple inheritance: Delegate selectors to several helper objects to combine multiple classes’ capabilities without inheritance.
  • Weak proxy: The intermediary delegate that breaks NSTimer’s retain cycle uses this point—more precisely, NSProxy’s forwarding.
  • API safety net: Defensive code that routes methods available only on newer OS versions to an alternate object on older versions.
Three-stage message-forwarding flow after objc_msgSend lookup fails, from resolveInstanceMethod to forwardInvocation
A crash is avoided if any one of the three stages succeeds

Stage 3: Full Forwarding — Why the Signature Must Come First

After stage 2 is rejected, the runtime uses its last resort. At this stage, you must override two methods as a pair, not just one.

- (NSMethodSignature *)methodSignatureForSelector:(SEL)aSelector {
    NSMethodSignature *sig = [super methodSignatureForSelector:aSelector];
    if (!sig) {
        sig = [self.target methodSignatureForSelector:aSelector];
    }
    return sig;
}

- (void)forwardInvocation:(NSInvocation *)invocation {
    for (id target in self.targets) {
        if ([target respondsToSelector:invocation.selector]) {
            [invocation invokeWithTarget:target];
        }
    }
}

Why does the signature come first? To package the message into an NSInvocation object, the runtime must know how many arguments there are and how many bytes each occupies. If methodSignatureForSelector: fails to return a valid signature, forwardInvocation: is never called and execution goes straight to a crash.

Once you have an NSInvocation, you can do much more. You can change arguments, broadcast the same message to multiple objects as in the example above (a multicast delegate), or record a response and replay it later. NSUndoManager’s prepareWithInvocationTarget: uses this exact approach: it captures an undoable method call as an NSInvocation and replays it when undo is performed.

It is the most flexible, but also the most expensive. Because creating NSInvocation and packing arguments costs extra, Apple’s documentation explicitly states that it is “much slower than ordinary message dispatch.” For performance-critical paths, stopping at stage 2 is the right answer.


Pitfall: respondsToSelector: Does Not Know About Forwarding

Even an object that handles a message correctly through forwarding answers NO when asked via respondsToSelector:. Selector lookup only examines method lists; the forwarding path works only when the message is actually sent.

To build a forwarding-based proxy correctly, also override respondsToSelector: and make it tell the matching lie: “I can receive that message.” In the Objective-C world, where delegate checks (if ([delegate respondsToSelector:...])) are common, omitting this creates the mystery of a forwarding path that is fully prepared but never called.

This also explains why NSProxy is a separate root class that does not inherit from NSObject. The more inherited methods a class has, the more messages it handles itself without reaching forwarding, so NSProxy leaves only the bare framework.

Fast Forwarding alternate-receiver illustration of one robot handing a message envelope to another
Stage 2 passes the message on by changing only the receiver, which is why it is fast

Summary

  • An unrecognized selector crash is the result of all three recovery stages failing, not instant death.
  • Stage 1, resolveInstanceMethod:, adds a method on the fly — @dynamic and Core Data operate here.
  • Stage 2, forwardingTargetForSelector:, is Fast Forwarding, which only swaps the receiver and finishes cheaply without NSInvocation.
  • Stage 3 is the methodSignatureForSelector: and forwardInvocation: pair — a signature is required to create NSInvocation.
  • Multicast delegates, NSUndoManager, and weak proxies are all applications built on this structure.
  • When building a forwarding proxy, do not forget to override respondsToSelector:.

Following why Swift chooses static dispatch by default instead of this flexibility, and how you can still re-enter this world through @objc dynamic, makes the design philosophies of the two languages much more three-dimensional.