Home

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 final unless 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 dynamic only 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.