Method dispatch
Where a call ends up, and what it cost to find out
Every method call has to answer one question: which body of code runs? Swift has three answers, and which one you get is decided by where the method is declared — not by how it is called.
Static dispatch. The address is known at compile time. The call is a direct jump, and the optimizer may inline it away entirely.
Table dispatch. The object carries a table of function pointers; the call reads the slot and jumps. One indirection, no inlining across it.
Message dispatch. Objective-C’s objc_msgSend walks the class hierarchy at run time looking for
a selector. Slowest, and completely dynamic — the implementation can be swapped while the program
runs.
What gets which
| Declared in | Dispatch |
|---|---|
struct / enum |
Static, always |
final class |
Static |
class, non-final method |
Table (vtable) |
| Protocol requirement | Table (witness table) |
| Protocol extension, not a requirement | Static |
| Class extension | Static |
@objc dynamic |
Message |
Two rows in that table cause most of the surprises.
The protocol extension trap
A method in a protocol extension that is not declared in the protocol itself is dispatched statically, on the static type of the variable. That produces genuinely counter-intuitive behaviour:
protocol Greeter {
func hello() // a requirement
}
extension Greeter {
func hello() { print("protocol hello") }
func goodbye() { print("protocol goodbye") } // NOT a requirement
}
struct English: Greeter {
func hello() { print("English hello") }
func goodbye() { print("English goodbye") }
}
let direct = English()
direct.hello() // English hello
direct.goodbye() // English goodbye
let viaProtocol: Greeter = English()
viaProtocol.hello() // English hello — witness table
viaProtocol.goodbye() // protocol goodbye — static, no witness entry
goodbye() has no slot in the witness table, because the protocol never declared it. The compiler
resolves it against the only type it knows: Greeter.
Warning
This is not a bug and it is not going to change. The rule to work by: if a method is meant to be overridable by conforming types, declare it in the protocol. An extension without a requirement is a default implementation only for types that do not supply their own — and only when called through a concrete type.
final is not just documentation
Marking a class or method final moves it from table to static dispatch, which is a real
optimization and occasionally a large one in a hot loop. More importantly, it lets the optimizer
inline, which unlocks everything downstream of inlining.
final class Renderer {
func draw() { … } // static — direct call, inlinable
}
The compiler can sometimes work this out for itself. Whole Module Optimization lets it see that a class has no subclasses in the module and devirtualize the call. This only works within a module, so a public class in a framework stays table-dispatched no matter what — the compiler cannot know what another module will subclass.
That is the reasoning behind Swift’s default access rules: classes and methods in a package or
framework are not open for subclassing unless you say open, precisely so the optimizer keeps its
freedom.
Where @objc dynamic is required
Message dispatch is the slowest, and there are cases that need it:
class ViewModel: NSObject {
@objc dynamic var progress: Double = 0
}
KVO requires it. Key-value observing works by swizzling the setter at run time, which is only
possible with message dispatch. A dynamic property is the price of being observable this way, and
it is also how @Published differs — Combine achieves observation without the Objective-C runtime.
Method swizzling requires it. Anything that replaces an implementation at run time — some analytics SDKs, some testing tools — needs message dispatch to have something to replace.
Some UIKit patterns require it. #selector targets, and anything the framework looks up by name
rather than by pointer.
Outside those, @objc dynamic is a cost with no benefit.
What this actually costs
Worth keeping in proportion. A table dispatch is roughly one extra memory read and an indirect jump — a few nanoseconds, and irrelevant for anything that touches the network, the disk or the screen. It matters in two places: inside a tight loop running millions of times, and where it blocks inlining that would have enabled other optimizations.
The second is usually the larger effect. A static call that gets inlined lets the optimizer see through it — constant-fold, eliminate bounds checks, keep values in registers. A table dispatch is an opaque wall.
So the practical advice is not “avoid classes”. It is:
- Mark classes
finalunless they are designed for subclassing. This is free and expresses intent. - Declare protocol requirements in the protocol, not only in an extension.
- Reach for
@objc dynamiconly when the runtime feature genuinely needs it. - Measure before restructuring anything for dispatch. In application code the answer is almost always that dispatch was not the problem.