Objective-C Runtime vs Static Compilation

Understand, as a synthesis of everything this addendum has covered piece by piece, exactly where the line falls between what the Objective-C compiler verifies before your app ever runs and what only exists — and can only be checked — once the Objective-C runtime is actually executing your code, and learn to recognize the family of bugs that live precisely on that line: code that compiles cleanly and still crashes.

Nearly every lesson in this addendum has, in one way or another, poked at the same seam: the seam between Objective-C the statically-typed C superset and Objective-C the dynamically-dispatched, Smalltalk-descended messaging system living underneath it. Selectors, dynamic messaging, runtime introspection, method swizzling, runtime class creation, forwarding — each of those lessons showed one specific way the runtime does something the compiler cannot see or verify. This lesson is the synthesis. It steps back from any single mechanism and draws the boundary itself as cleanly as possible: here is what compile time checks, here is what only runtime checks, and here — concretely, in code you could paste into a project — is what it looks like when that boundary is where a bug hides. If the rest of the addendum was about individual dynamic features, this lesson and its sibling, "Dynamic Features vs Type Safety," are about the shape of the tradeoff those features all share.

Two different machines, two different jobs

It helps to think of "Objective-C" as two separate machines that happen to share one syntax. The first machine is the compiler (clang, in practice) — a program that reads your .m files once, before your app ever runs, and turns them into machine code plus a set of metadata structures the second machine will use. The compiler's job is entirely about the shape of your program: does this syntax parse, do these types match up the way you declared them, does this expression make sense given what you told the compiler about it. Once it's done, the compiler is out of the picture. It never runs again while your app is executing.

The second machine is the Objective-C runtime — a C library (libobjc) that is very much alive while your app runs. It holds the actual class objects, the actual method tables, the actual selector-to-implementation mappings, and it is consulted on essentially every single message send your Objective-C code performs. The runtime doesn't know or care what the compiler warned about; it only knows what data structures actually exist right now, in this process, at this moment.

The confusion that trips people up — and the reason this lesson exists — is that both machines process the exact same source syntax, and nothing about the text on your screen visually distinguishes a line the compiler will fully verify from a line the compiler can barely check at all. [id boxedThing doSomething] and [NSString stringWithFormat:@"%@"] look equally unremarkable side by side; only the compiler's actual knowledge of what's being sent to what tells you which one is being genuinely checked.

• Compile time is a single pass, done once, before the app ever runs — it produces a binary and then it's finished • Runtime is continuous — it's active for the entire lifetime of the running process, consulted on every message send • The compiler reasons about declared types and syntax; the runtime reasons about actual objects and actual method tables • A construct can be perfectly legal at one layer and fail at the other — that gap is the entire subject of this lesson

What the compiler actually checks

When clang processes your Objective-C source, it performs several distinct kinds of checking, all before a single line of your code executes. First, syntax: is this valid Objective-C grammar — matched brackets on message sends, correctly formed @interface/@implementation blocks, valid selector syntax. A syntax error is a hard stop; the compiler refuses to produce a binary at all.

Second, types — but only as far as your declarations tell it to. If you declare a variable as NSString *, the compiler will warn (and, depending on your build settings, error) if you send that variable a message no method declared anywhere in NSString's visible interface responds to, or if you try to assign it something incompatible. This checking is only as good as the information you give the compiler: a concretely-typed pointer gives it real signal to check against; an id-typed pointer, as covered below, gives it almost nothing.

Third, warnings — a large, configurable set of diagnostics the compiler emits for code that is legal but suspicious: an unused variable, a method that might not return a value on every path, a selector the compiler can't find a declared method for anywhere in the classes it has seen. Warnings do not stop compilation by default, which is exactly why they matter for this lesson — a warning is often the compiler's only way of telling you "I can't fully verify this," right before it hands you a binary that runs anyway.

Fourth, linking — resolving symbol references (class names, C function names, external symbols) against the object files and libraries being linked into the final binary. If you reference a class or function that genuinely does not exist anywhere in the link, that's a linker error, not a runtime error — this is one of the few failure modes that actually does get caught before the app runs, distinct from the far larger category this lesson is about.

What only exists at runtime

Everything on the other side of that line depends on the actual, live state of a running process, and simply cannot be evaluated by looking at source text alone. The actual class of an object — not its declared static type, but what class_getName() would return if you asked the runtime right now — is a runtime fact. Two variables can be declared with the identical static type and hold objects of completely different actual classes (a superclass-typed variable holding any of several subclass instances is the ordinary, unremarkable version of this; id holding literally anything is the extreme version).

Whether a selector is actually implemented — whether some method table, walked up the actual live class hierarchy of the actual live object, contains an IMP for this exact SEL — is a runtime fact, checked fresh on every single message send via the objc_msgSend lookup path (see "objc_msgSend in Depth" for that mechanism in full). Dynamic dispatch resolution — which concrete IMP actually executes for a given [receiver message] send — is decided at that moment, not at compile time, which is precisely what makes polymorphism through a common superclass or protocol work at all.

Introspection — asking an object at runtime "what class are you really," "do you actually respond to this selector," "what's in your instance variable layout" — only has data to answer with because the runtime maintains live metadata while the process runs; none of that metadata exists in a form the compiler can consult, because the compiler finished its job before any of it was populated. And forwarding — the mechanism by which an object gets one last chance to handle a message its class hierarchy doesn't implement, before the process crashes — is a purely runtime-time negotiation that has no compile-time analogue whatsoever.

Diagram: the two branches

The full picture is best seen laid out as two branches, because it makes the asymmetry obvious: the compile-time branch is short and finishes; the runtime branch is what actually determines what your program does, for as long as it's running.

Why the compiler can't just check the runtime stuff for you

A natural question: if the runtime knows so much more, why doesn't the compiler just do that checking too? The honest answer is that much of what the runtime resolves genuinely cannot be known in advance, by design — that's the entire point of Objective-C's dynamic messaging model. A selector built at runtime from a string (NSSelectorFromString(someDynamicString)) has no fixed value the compiler could look up in advance, because the string itself might come from user input, a network response, or a configuration file loaded after the binary already exists. A category loaded from a dynamically-linked framework might add or override a method the compiler never saw when it compiled your call site.

Objective-C's design deliberately trades some of this static verifiability for exactly this flexibility — the same flexibility that makes method swizzling, runtime class creation, KVO's dynamic subclassing, and message forwarding possible at all. The cost of that design is precisely the subject of this lesson: some class of bugs simply cannot be caught before the app runs, because the information needed to catch them doesn't exist yet at compile time.

Reading a crash log with this distinction in mind

Once you internalize the compile-time/runtime split, a whole category of confusing bug reports stops being confusing: "it built fine, how is it crashing?" stops being a paradox and becomes the expected behavior of a specific, nameable kind of bug. The three examples below are the canonical members of that category, and recognizing the pattern — id or a dynamically-constructed selector involved, a clean build, a crash only when a specific code path actually executes — is often enough to jump straight to the right fix without extensive debugging.

The unifying diagnostic question to ask, every time you see "unrecognized selector" or a similarly runtime-flavored crash despite a clean, warning-free build, is: what did the compiler actually have the information to check here, and where did that information run out? Usually the answer points directly at an id, a dynamically-built SEL, or a category the compiler had no way to cross-reference against the method it's overriding.

Compile Time vs Runtime

Compile Time is a single pass that finishes before your app runs and never sees live object state. Runtime is continuous — active for the whole life of the process, consulted on every message send. A concretely-typed pointer gives the compiler real signal for its Types check; id gives it almost none. Selectors, Dynamic Dispatch, Introspection, and Forwarding have no meaningful compile-time analogue at all.

Compile Time
 ├── Syntax
 ├── Types
 ├── Warnings
 └── Linking

Runtime
 ├── Classes
 ├── Messaging
 ├── Selectors
 ├── Dynamic Dispatch
 ├── Introspection
 └── Forwarding

Code Examples

NSSelectorFromString() takes an arbitrary NSString and hands back a SEL — the compiler has no way to inspect the contents of that string at compile time, because the string's value in general isn't known until the program actually runs (it might come from a typo, but it might just as easily come from user input, a plist, or a server response). Because performSelector: is declared to accept any SEL, this entire call type-checks perfectly. It is only at runtime, when objc_msgSend actually walks someString's real class hierarchy looking for an IMP registered under that exact SEL and finds nothing, that anything goes wrong. Expected result: *** Terminating app due to uncaught exception 'NSInvalidArgumentException', reason: '-[__NSCFString lowercaseStrng]: unrecognized selector sent to instance 0x600002a1c0a0' id result = [someString performSelector:sel];

Common mistake: Assuming that because the code compiled without a single warning, the selector must exist. The compiler cannot verify a SEL value it never saw as a literal @selector(...) — it only has genuine signal for statically-written selector literals sent to a concretely-typed receiver.

Had this been written as NSNumber *count = @42; [count stringByAppendingString:@" items"];, clang would emit a warning at compile time — "NSNumber may not respond to -stringByAppendingString:" — because it has real, declared-interface information about NSNumber to check the send against. The moment the exact same object is reinterpreted as id, that checking evaporates: id tells the compiler "trust me, I know what I'm doing; don't check this send against any particular interface." The send still compiles, cleanly, with zero diagnostics, and still fails identically at runtime, because id changed nothing about what the actual object at runtime can do — only about what the compiler was willing to verify in advance. Expected result: *** Terminating app due to uncaught exception 'NSInvalidArgumentException', reason: '-[__NSCFNumber stringByAppendingString:]: unrecognized selector sent to instance 0x6000012345a0'

NSString *oops = [boxedCount stringByAppendingString:@" items"];

Common mistake: Using id as a way to "get past" a compiler warning rather than fixing the actual type mismatch. id doesn't fix anything — it only hides the warning; the underlying object still can't do what you're asking.

Categories are not checked for override consistency by the compiler at all — clang has no concept of "this category redefines a method that already existed, and here is the difference in behavior." The category above compiles with no warning whatsoever, because as far as the compiler can tell, it is simply providing an implementation for a declared method, exactly as intended. The break only shows up later, and only along the specific code path that calls -logRequestForDebugging:, when parseCanonicalDebugDescription: receives a string in a format it wasn't written to expect — a purely behavioral failure with no compile-time signal anywhere in its history. Expected result: Parser failure or garbled log output — no crash, no warning, just silently wrong behavior that only appears once this exact path executes.

[self.logParser parseCanonicalDebugDescription:desc];

Common mistake: Assuming a clean compile means a category's override is "safe." The compiler has no override-consistency check for categories at all — that verification, if it happens, has to happen in your head, in code review, or via tests that actually exercise the affected code path.

shape is declared as Shape *, and Shape has no -drawWithRadius: method at all — a direct, statically-typed call like [shape drawWithRadius:10.0] would be flagged by the compiler immediately. Routing the same call through performSelector:withObject: sidesteps that check entirely, because performSelector: is declared generically enough to accept any selector. Whether this crashes depends entirely on the runtime fact of what currentShapeFromFactory actually returned — a Circle instance makes it work, a Square instance (or any other Shape subclass without that method) crashes, and the compiler has no way to distinguish those two futures at compile time. Expected result: Either succeeds silently (if the runtime object happens to be a Circle) or: '*** Terminating app due to uncaught exception 'NSInvalidArgumentException', reason: '-[Square drawWithRadius:]: unrecognized selector sent to instance ...'' This is the same underlying phenomenon as Example 1 and 2 — a message send the compiler cannot fully verify — but shown through performSelector:withObject: specifically, since it's one of the most common ways this pattern shows up in real production code.

[shape performSelector:@selector(drawWithRadius:) withObject:@(10.0)];

ProxyObject compiles cleanly regardless of what _target actually is — its whole purpose is to forward arbitrary selectors, so the compiler has essentially nothing concrete to check against for any message sent to a ProxyObject. Whether -performBatchOperation succeeds depends entirely on the runtime fact of whether whatever object was passed in as someTarget happens to implement -performBatchOperation. Swap in a differently-configured target object during a refactor, months later, and the exact same call site — unchanged, still compiling without a single warning — starts crashing, because the runtime fact it depends on changed even though the source code didn't.

[proxy performBatchOperation];

Common mistake: Treating a forwarding-based proxy's compile-clean status as evidence it's correct. A forwarding proxy is, by construction, a place where the compiler has given up almost all static checking — correctness has shifted entirely onto runtime contracts (what respondsToSelector: actually returns for actual target objects) that no build step verifies.

NSString *someName = @"lowercaseString"; // exists — fine
// ... elsewhere, built from user-facing input or a typo ...
NSString *badName = @"lowercaseStrng"; // typo, no such selector anywhere

SEL sel = NSSelectorFromString(badName);
id result = [someString performSelector:sel]; // compiles cleanly!
NSNumber *count = @42;
id boxedCount = count; // same object, but now typed as id

// A concretely-typed NSNumber * here would get a compiler warning —
// NSNumber has no -stringByAppendingString: method.
NSString *oops = [boxedCount stringByAppendingString:@" items"];
// NetworkLayer.m — written by one part of the team
@interface NSURLRequest (NetworkLayerExtras)
- (NSString *)debugDescription; // meant as a harmless helper
@end

@implementation NSURLRequest (NetworkLayerExtras)
- (NSString *)debugDescription {
  return [NSString stringWithFormat:@"Request: %@", self.URL];
}
@end

// Elsewhere, unrelated code written months later relies on the
// ORIGINAL Foundation debugDescription format for log parsing —
// this compiles cleanly, and only breaks when this exact path runs.
- (void)logRequestForDebugging:(NSURLRequest *)request {
  NSString *desc = request.debugDescription;
  [self.logParser parseCanonicalDebugDescription:desc]; // expects Foundation's own format
}
@interface Shape : NSObject
- (void)draw;
@end

@interface Circle : Shape
- (void)drawWithRadius:(CGFloat)radius; // Circle-only method
@end

Shape *shape = [self currentShapeFromFactory]; // declared Shape *,
                                                // but factory might hand back any Shape subclass
[shape performSelector:@selector(drawWithRadius:) withObject:@(10.0)];
// compiles: performSelector:withObject: takes any SEL and any object
@implementation ProxyObject {
  id _target;
}

- (id)forwardingTargetForSelector:(SEL)aSelector {
  if ([_target respondsToSelector:aSelector]) {
    return _target;
  }
  return nil; // falls through to the normal doesNotRecognizeSelector: crash
}

@end

// Later, in application code that compiles perfectly:
ProxyObject *proxy = [[ProxyObject alloc] initWithTarget:someTarget];
[proxy performBatchOperation]; // fine, as long as someTarget implements it

Compile Time vs Runtime

Compile Time is a single, finite pass over your source that finishes before the app ever runs — it can only check what's visible in the text: grammar, declared types, suspicious patterns worth a warning, and symbol linkage. Runtime is everything that depends on the live, executing process: what an object's actual class is, whether a selector is truly implemented for that live object, which IMP dynamic dispatch actually resolves to, what introspection can discover, and whether forwarding rescues an otherwise-fatal message send. Every "compiles fine, crashes later" bug lives in the gap between these two lists.

Compile Time
 ├── Syntax
 ├── Types
 ├── Warnings
 └── Linking

Runtime
 ├── Classes
 ├── Messaging
 ├── Selectors
 ├── Dynamic Dispatch
 ├── Introspection
 └── Forwarding

Important Rules

The compiler checks syntax, declared types, and linkage — it produces a binary once and then stops; it has no visibility into anything that only exists while the process runs.

The runtime checks actual classes, actual method tables, and resolves every message send fresh — it is active for the entire life of the process, not a one-time pass.

A concretely-typed pointer (NSString *, MyClass *) gives the compiler real signal to check message sends against; id gives it almost none.

A dynamically-constructed or looked-up SEL (from NSSelectorFromString, a string, or user input) cannot be verified by the compiler no matter how the receiver is typed.

Categories are never checked for override consistency by the compiler — a category can silently replace an existing method's behavior with zero warnings.

A clean, warning-free build is not proof of runtime correctness for any code path that routes through id, performSelector:, a dynamically-built SEL, forwarding, or an unchecked category override.

Where this shows up

Every crash report that reads "unrecognized selector sent to instance" is, without exception, an instance of this lesson's core idea: something the compiler could not verify turned out, at runtime, to be false. In real codebases this most often traces back to performSelector: calls built from configuration-driven or server-driven selector names (feature flags that name a method to call, a plugin architecture that dispatches by string), id-typed properties or collections that erode static checking over the life of a refactor, and categories added by one team that unknowingly override a method another team's code depends on. Recognizing this pattern — clean build, runtime crash, id or a dynamic selector somewhere in the chain — is usually the fastest route from a crash log straight to the actual bug.

SEL sel = NSSelectorFromString(userProvidedString); [myObject performSelector:sel]; // no guard at all

SEL sel = NSSelectorFromString(userProvidedString); if ([myObject respondsToSelector:sel]) { [myObject performSelector:sel]; } else { // handle the missing-selector case explicitly instead of crashing }

Whenever a selector is built dynamically, the compiler has already given up on checking it — respondsToSelector: is the runtime-side check that has to replace the static one the compiler can't provide. Skipping it means the first time an unexpected string reaches this code path, the app crashes with unrecognized selector instead of failing gracefully.

id thing = someFactoryMethodThatReturnsId(); [thing doSomethingSpecific]; // no static checking at all, because thing is id

SpecificType *thing = someFactoryMethodThatReturnsId(); // now the compiler checks -doSomethingSpecific against SpecificType's real interface [thing doSomethingSpecific];

Retyping a value from id to its actual concrete type the moment you know what it really is restores the compiler's ability to check the message sends that follow. Leaving it as id for longer than necessary throws away static checking you could have had essentially for free.

@implementation NSString (QuietOverride) - (NSString *)description { return @"overridden"; } // silently replaces Foundation's own @end

// Prefer a differently-named method, or a subclass, over overriding // a method a widely-used class already implements: @implementation NSString (SafeExtras) - (NSString *)debugLabel { return @"custom label"; } @end

A category that overrides an existing method — especially on a class you don't own, like a Foundation class — compiles without any warning and can silently change behavior everywhere that method is called throughout the entire process, including inside Foundation's own code. Naming a new method instead of overriding an existing one avoids this entire class of compile-clean, runtime-surprising bug.

[Multiple Choice] Which of the following is checked by the compiler, not the runtime? A. Whether a selector built from NSSelectorFromString() is actually implemented B. Syntax and declared types visible in the source at compile time C. Which concrete IMP a message send resolves to for a given live object D. Whether message forwarding successfully rescues an otherwise-fatal send

B. Syntax and declared types visible in the source at compile time — Syntax and declared-type checking happen once, at compile time, from the source text alone. Selector existence, IMP resolution, and forwarding all depend on the live state of a running process and can only be resolved at runtime.

[Multiple Choice] Why does calling performSelector: with a dynamically constructed SEL compile cleanly even when no such method exists anywhere? A. performSelector: is a compiler intrinsic that is never actually checked B. The compiler cannot inspect the runtime value of a SEL built from a string, so it has no basis to flag the call C. The Objective-C compiler does not support method calls at all D. performSelector: automatically checks respondsToSelector: for you at compile time

B. The compiler cannot inspect the runtime value of a SEL built from a string, so it has no basis to flag the call — A SEL produced from an arbitrary NSString (e.g. via NSSelectorFromString) has no value the compiler can know in advance, so there is nothing concrete for it to check the send against — the call type-checks regardless of whether a matching method actually exists.

[True / False] Typing a variable as id can cause the compiler to skip checks it would otherwise perform on a concretely-typed pointer. A. True B. False

A. True — True. id tells the compiler not to check message sends against any specific class interface, which discards the (limited but real) static checking a concrete type like NSString * would otherwise provide.

[Multiple Choice] Why can a category silently override an existing method's behavior with no compiler warning at all? A. Categories are checked more strictly than normal methods, so this scenario cannot happen B. The compiler has no override-consistency check for categories — it simply compiles a provided implementation for a declared method C. Categories can only add new methods, never override existing ones D. This only happens when the category is in a different file than the original class

B. The compiler has no override-consistency check for categories — it simply compiles a provided implementation for a declared method — The compiler treats a category's method implementation like any other — it has no mechanism to detect or warn that the category is replacing an existing implementation with different behavior, so this compiles cleanly regardless of the consequences.

[Multiple Choice] A crash log reads 'unrecognized selector sent to instance' despite the project having built with zero warnings. What does this tell you? A. The crash log must be wrong, since a clean build guarantees no runtime crashes B. Somewhere on that code path, something the compiler could not verify (id, a dynamic selector, an unchecked forwarding path, or an inconsistent category) turned out to be false at runtime C. The app must have been compiled with a corrupted toolchain D. This can only happen if -ObjC linker flags were omitted

B. Somewhere on that code path, something the compiler could not verify (id, a dynamic selector, an unchecked forwarding path, or an inconsistent category) turned out to be false at runtime — 'Unrecognized selector' is definitionally a runtime-only failure — it means the runtime looked for an IMP for a given SEL on a given object's actual class hierarchy and found none, which is exactly the category of thing a clean compile cannot guarantee against.

[True / False] Message forwarding has a meaningful compile-time analogue that lets the compiler verify a forwarding proxy's behavior in advance. A. True B. False

B. False — False. Forwarding is resolved entirely at runtime, by consulting whatever the forwarding object's -forwardingTargetForSelector: or related methods return for the actual live target at that moment — there is no static equivalent the compiler could check ahead of time.

Write a small "CommandDispatcher" class that receives command names as NSStrings (e.g. from a config file) and looks up a corresponding SEL to call on a delegate object. First write the naive version that calls performSelector: directly with no check, and note where it can crash. Then rewrite it to use respondsToSelector: as a guard, and add a fallback path (e.g. logging an "unknown command" message) for when the delegate doesn't implement the requested selector.

@interface CommandDispatcher : NSObject @property (nonatomic, weak) id delegate; @end @implementation CommandDispatcher // Naive — compiles fine, crashes on any unimplemented command name - (void)dispatchNaive:(NSString *)commandName { SEL sel = NSSelectorFromString(commandName); [self.delegate performSelector:sel]; } // Correct — guards the runtime fact the compiler couldn't check - (void)dispatch:(NSString *)commandName { SEL sel = NSSelectorFromString(commandName); if ([self.delegate respondsToSelector:sel]) { [self.delegate performSelector:sel]; } else { NSLog(@"Unknown command: %@ — delegate does not implement it", commandName); } } @end

The naive version should look exactly like the Example 1 pattern above — NSSelectorFromString followed directly by performSelector:. The fix adds exactly one respondsToSelector: check before the send.