Sending Messages: The Full Pipeline From Bracket Syntax to Executing Code
Trace exactly what happens when a message send like [person sayHello] executes — from receiver and selector through the Objective-C runtime's method lookup to the implementation that actually runs — and understand how messages with arguments, nested sends, and messages to nil behave differently from a direct C function call.
The Objective-C Syntax Mental Model lesson introduced the vocabulary of message sending — receiver, selector, message, arguments — and made the case, at a conceptual level, that a message send is not a function call wearing different punctuation. This lesson goes one level deeper: it walks through the actual pipeline a message send travels through at runtime, names each stage explicitly, and uses that pipeline to explain three behaviors that consistently surprise programmers coming from C — messages with multiple arguments, message sends nested inside other message sends, and the famously permissive way Objective-C handles a message sent to nil. This is one of the most important lessons in the whole Messaging module, because every other topic in it — selectors, performSelector, and the full dynamic dispatch mechanism covered later in the Runtime module — is really just filling in detail on the pipeline introduced here.
The Basic Form: [person sayHello]
The simplest possible message send has no arguments at all: [person sayHello];. Reading it left to right inside the brackets, person is the receiver — the object being asked to do something — and sayHello is the selector, the name identifying which behavior is being requested. There is no comma-separated argument list the way a C function call would have one; a no-argument selector like sayHello is simply a bare identifier with no trailing colon.
It's worth being precise about what this statement does not do, compared to a C function call. It does not jump directly to a fixed address determined when the program was compiled and linked. Instead, it packages up the receiver and the selector (and, for statements with arguments, the argument values) and hands all of that to the Objective-C runtime, a piece of system-level code that ships with every Objective-C program and is responsible for actually deciding what happens next. Everything downstream of that handoff — figuring out which specific block of code should run, and actually running it — is the runtime's job, not something the compiler nailed down in advance.
This is the detail that most sharply separates message sending from a C function call, and it's worth restating plainly before moving into the pipeline itself: in C, sayHello() and the code that runs when you call it are one and the same thing, fixed together forever at link time. In Objective-C, [person sayHello] and whatever code eventually executes in response are two separate things, connected only at the moment the program actually runs, by a lookup the runtime performs fresh, in principle, on every single send.
• [person sayHello]; — person is the receiver, sayHello is the selector, no arguments, no trailing colon. • A message send does not jump straight to fixed code the way a C function call does. • The receiver and selector are instead handed to the Objective-C runtime, which decides what runs. • The message and the code that ultimately runs are connected at runtime, not fixed together at link time.
The Full Pipeline: From Receiver to Implementation
Every message send, no matter how simple or complex, travels through the same sequence of stages on its way to actually running code. Naming these stages explicitly, in order, gives you a concrete mental model to replace the vague idea of 'the runtime figures it out somehow' — and this exact sequence is what the diagram accompanying this lesson depicts.
It begins with the receiver — the specific object the message is aimed at, person in the running example. Paired with the receiver is the selector — sayHello — which by itself is just a name, carrying no information yet about which actual code should run for this particular receiver. Together, receiver and selector (plus any arguments) constitute the message send, the complete unit of work the compiler emits code to hand off.
That handoff goes to the Objective-C runtime, a genuine piece of executing software (not a compile-time abstraction) that receives the message send and is responsible for method lookup: examining the receiver's actual class — not the type the variable was declared with in source code, but the real class of the actual object sitting in memory at that moment — and searching that class's method tables for an entry matching the selector sayHello. If it isn't found directly on that class, the runtime walks up the class's inheritance chain, checking each superclass in turn, until either a match is found or the chain is exhausted.
Once a match is found, the runtime has located the implementation: the actual block of executable code that was written to handle this selector for this class. The runtime jumps to it, and that implementation runs, doing whatever sayHello was actually written to do — printing something, returning a value, mutating internal state, or all three. This is the essential shape of dynamic dispatch: the same six stages (receiver, selector, message send, runtime, method lookup, implementation) apply identically whether the message has zero arguments or five, whether it's the simplest possible send or deeply nested inside other sends.
• Receiver: the specific object the message targets, evaluated by its actual runtime class, not its declared type. • Selector: the name identifying the requested behavior — carries no code of its own. • Message send: receiver + selector (+ arguments) packaged together and handed to the runtime. • Runtime: the executing system responsible for deciding what code actually runs. • Method lookup: the runtime searches the receiver's class, then its superclasses, for a matching selector. • Implementation: the actual code found by that search — the runtime jumps to it and it executes.
Messages With Arguments and Nested Message Sends
Adding arguments to a message send doesn't change the pipeline at all — it only changes what gets bundled into the message send stage. [person sayHelloTo:@"World"]; sends the selector sayHelloTo: to the receiver person, with the string @"World" carried along as the argument. The runtime still performs exactly the same method lookup — find sayHelloTo: on person's class, walking up superclasses if necessary — the only difference is that once the implementation is found, the argument value is also passed to it, the same way a C function's arguments are passed to its body.
Message sends can also be nested inside one another, exactly the way C function calls can be nested: [person sayHelloTo:[friend name]];. Evaluation here works inside-out, just as it does for nested C function calls — [friend name] is itself a complete message send (receiver friend, selector name, no arguments), and it runs through the entire pipeline first, from method lookup all the way to producing a return value. Only once that inner send has fully completed and produced a result does the outer send, [person sayHelloTo:...], receive that result as its argument and begin its own trip through the pipeline.
This inside-out evaluation order is intuitive because it matches C, but it's worth stating explicitly because nested sends are extremely common in real Objective-C code, and it's easy to lose track of the fact that each bracket pair is its own independent message send, with its own receiver, its own selector, and its own full pass through method lookup — nesting doesn't create one combined operation; it creates several separate ones, sequenced by ordinary evaluation order.
• Arguments ride along inside the message send stage; the pipeline itself (runtime lookup, then implementation) is unchanged. • [person sayHelloTo:[friend name]]; — the inner send [friend name] fully completes first, producing a value. • That returned value then becomes the argument for the outer send, which then runs its own complete pipeline. • Each bracket pair is an independent message send with its own receiver, selector, and method lookup — nesting sequences them, it doesn't merge them.
Messages to nil: A Distinctive Objective-C Behavior
One of the most distinctive things about the message-sending pipeline — and something that has no real analog in C — is what happens when the receiver is nil, Objective-C's null object pointer. In C, calling a function through a null function pointer, or dereferencing a null pointer to call a method on it (in a language that supports that), typically crashes the program immediately. Objective-C's message-sending pipeline is built to tolerate this case gracefully by design, not by accident.
When the runtime receives a message send whose receiver is nil, it does not attempt method lookup at all — there is, after all, no real object and no real class to search. Instead, sending any message to nil simply does nothing and returns a well-defined 'zero' value matching the method's declared return type: a method returning an object pointer returns nil; a method returning an integer or floating-point type returns 0 or 0.0; a method returning BOOL returns NO; and a method returning a struct returns that struct with every field set to zero. [person sayHello]; where person is nil simply does nothing at all and returns nothing meaningful — it does not crash.
This behavior has real, practical consequences for how Objective-C code is written. It means a chain like [[person friend] sayHello]; fails gracefully rather than catastrophically if person happens to be nil at that moment — [person friend] returns nil, and sending sayHello to that nil result again does nothing and returns a zeroed value, rather than crashing the whole program the way dereferencing a null pointer in raw C typically would. It also means, though, that bugs caused by an unexpectedly-nil object can be easy to miss: code can run to completion, silently doing nothing useful, rather than failing loudly and immediately at the exact point something went wrong, which makes disciplined nil-checking and logging important habits even though the language itself won't force a crash to surface the problem.
It's worth being precise about the limits of this behavior, because it's easy to overstate: messages to nil are safe specifically because the runtime never gets far enough to attempt method lookup or jump to any implementation — it short-circuits before that. This is exactly why the behavior is so predictable (always a well-defined zero value, matching the declared return type) rather than undefined the way a null-pointer C bug typically is.
• Sending any message to a nil receiver skips method lookup entirely and simply does nothing. • The message expression evaluates to a zero value matching the method's declared return type: nil for objects, 0/0.0 for numbers, NO for BOOL, a zeroed struct for struct returns. • This lets chains like [[person friend] sayHello]; fail gracefully instead of crashing when an intermediate value is nil. • The tradeoff: bugs from an unexpectedly-nil receiver can silently do nothing rather than crashing loudly at the exact failure point.
How This Differs From the Direct C Function Call Model
The Objective-C Syntax Mental Model lesson already established the headline difference between C function calls and Objective-C message sends: a C function call resolves to one fixed piece of code at build time, while a message send's handling code is resolved at runtime via the receiver. This lesson's pipeline explains precisely how that runtime resolution happens — receiver, selector, message send, runtime, method lookup, implementation — and it's worth drawing out a few concrete consequences of that difference now that the mechanism itself is visible.
First, cost: a C function call is, at the machine level, close to a single jump instruction, because there's nothing left to decide by the time the program is running. A message send does real work at runtime — the method lookup stage genuinely searches a class's method tables, potentially walking up several superclasses — even though the Objective-C runtime is engineered (through caching and other techniques covered later in the Runtime module) to make this fast in practice. There is real, though usually small, overhead to sending a message that a direct C call simply does not have.
Second, and more important conceptually: a C function call cannot behave differently depending on what's 'inside' its arguments — add(a, b) always runs the exact same code, whatever a and b happen to contain. A message send's very identity — which implementation actually executes — depends on the receiver's real class, discovered fresh at the method lookup stage. And, as this lesson just covered, a message send additionally tolerates a nil receiver gracefully, something that has no equivalent at all in the direct C function call model, where operating on an invalid pointer is undefined behavior rather than a well-defined, safe no-op. Together, these two differences — runtime resolution of which code runs, and graceful handling of nil — are what make Objective-C's messaging model a genuinely different execution model from C's direct function calls, not merely a different-looking syntax for the same underlying idea.
• C function call: near-zero runtime cost, because nothing is left to decide once the program is compiled. • Message send: the method lookup stage does genuine runtime work, searching the receiver's class and superclasses. • C: add(a, b) always runs identical code. Objective-C: which implementation runs depends on the receiver's actual class, discovered at the lookup stage. • C: an invalid/null pointer call is undefined behavior. Objective-C: a nil receiver is a well-defined, safe no-op — no equivalent exists in the direct call model.
Code Examples
The first send has a real receiver, so the runtime performs method lookup: it examines person's actual class, finds sayHello's implementation, and runs it. The second send targets nil, so the runtime never attempts lookup at all -- it simply does nothing and the expression evaluates to a zero value matching sayHello's declared return type (void here, so there's nothing to return at all).
[person sayHello];
sayHelloTo: is a one-argument selector; @"World" rides along inside the message send stage of the pipeline. In the nested version, [friend name] is a complete, independent message send with its own receiver (friend) and selector (name); it runs through the full pipeline -- including its own method lookup -- and produces a return value before the outer sayHelloTo: send even begins its own trip through the pipeline.
[person sayHelloTo:[friend name]];
Person *person = [[Person alloc] init];
// Receiver: person
// Selector: sayHello
[person sayHello];
// If person happened to be nil, this line would simply do nothing
// and return nothing meaningful -- no crash, no method lookup attempted.
Person *missing = nil;
[missing sayHello];// A message with one argument
[person sayHelloTo:@"World"];
// A nested message send: the inner send runs to completion first
[person sayHelloTo:[friend name]];
// Equivalent, written out with an intermediate variable to make the
// evaluation order explicit:
NSString *friendName = [friend name]; // inner send completes first
[person sayHelloTo:friendName]; // outer send runs secondThe Message-Sending Pipeline
Every message send, from the simplest [person sayHello] to a deeply nested chain of sends, travels through this exact same sequence of stages before any actual code runs.
// Assuming [person sayHello] jumps directly to fixed code, the way sayHello() would in C
// Understanding that [person sayHello] hands receiver + selector to the runtime, which performs method lookup on person's actual class before any code runs
Treating a message send as if it were resolved once, at compile time, hides the entire point of the pipeline: the runtime genuinely looks up the implementation fresh, based on the receiver's real class, every time the message is sent.
Person *p = nil; [p sayHello]; // assumed to crash the way a null-pointer C call would
Person *p = nil; [p sayHello]; // safely does nothing and returns a zero value -- no crash
Sending a message to nil is a well-defined, safe no-op in Objective-C: the runtime skips method lookup entirely and the expression evaluates to nil/0/0.0/NO/a zeroed struct depending on the declared return type. This is fundamentally different from C's undefined behavior on an invalid pointer call.
// Treating [person sayHelloTo:[friend name]] as one single combined operation
// Recognizing it as two independent message sends: [friend name] completes fully first, then its result becomes the argument for the outer send
Each bracket pair is its own message send with its own receiver, selector, and full pass through the runtime's method lookup. Nested brackets sequence separate sends via ordinary inside-out evaluation order; they do not merge into a single lookup.
In the pipeline Receiver → Selector → Message Send → Objective-C Runtime → Method Lookup → Implementation, what does the Method Lookup stage actually do? A. It compiles the selector name into machine code B. It searches the receiver's actual class, and its superclasses if needed, for an implementation matching the selector C. It converts the message send into a C function call D. It checks whether the receiver is nil before anything else in the pipeline runs
B. It searches the receiver's actual class, and its superclasses if needed, for an implementation matching the selector — Method Lookup is where the runtime examines the receiver's real class and searches its method tables (walking up superclasses if necessary) to find the implementation matching the given selector.
What happens when you send a message to a nil receiver in Objective-C, such as [person sayHello] where person is nil? A. The program crashes immediately, the same as dereferencing a null pointer in C B. The runtime throws a compile-time error C. The runtime skips method lookup entirely; the send safely does nothing and evaluates to a zero value matching the method's declared return type D. The runtime substitutes NSObject's default implementation automatically
C. The runtime skips method lookup entirely; the send safely does nothing and evaluates to a zero value matching the method's declared return type — A message sent to nil never reaches method lookup at all -- the runtime short-circuits, the send does nothing, and the expression evaluates to nil, 0, 0.0, NO, or a zeroed struct depending on the method's declared return type. This is a distinctive, well-defined Objective-C behavior with no equivalent in raw C.
In [person sayHelloTo:[friend name]], what runs first? A. The outer send, [person sayHelloTo:...], begins immediately and evaluates [friend name] lazily as needed B. Both sends begin simultaneously and race to complete C. The inner send, [friend name], runs through the entire pipeline to completion and produces a value, which then becomes the argument for the outer send D. Neither send actually performs method lookup because they are nested
C. The inner send, [friend name], runs through the entire pipeline to completion and produces a value, which then becomes the argument for the outer send — Nested message sends evaluate inside-out, just like nested C function calls: [friend name] is a complete, independent message send that runs its full pipeline (including its own method lookup) before its result is passed as the argument to the outer sayHelloTo: send.
What is the core difference between a C function call like add(a, b) and an Objective-C message send in terms of what determines which code runs? A. There is no difference once the program is running B. A C function call is fixed to one block of code at build time; a message send's implementation is found by the runtime at the method lookup stage, based on the receiver's actual class C. A message send always runs slower, but resolves to the same fixed code as a C function call would D. C function calls also perform method lookup, just using a different name for the same process
B. A C function call is fixed to one block of code at build time; a message send's implementation is found by the runtime at the method lookup stage, based on the receiver's actual class — This is the central distinction the pipeline makes concrete: C resolves add(a, b) to one fixed piece of code at compile/link time, while an Objective-C message send's implementation is discovered at runtime by the method lookup stage, based on the receiver's actual class.
Receiver
↓
Selector
↓
Message Send
↓
Objective-C Runtime
↓
Method Lookup
↓
Implementation