12 · Protocol extensions — who gets called?
Same method, same object, three different answers — static vs dynamic dispatch through protocol extensions and subclasses.
Shape: what does this print · Reported: Zomato — witness tables and static/dynamic dispatch; TikTok — dynamic vs static dispatch · Verified: run with Swift 6.4
"Every line calls a method on the same kind of object. Tell me which implementation runs each time — and why."
protocol Greeter {
func greet() -> String // a requirement
}
extension Greeter {
func greet() -> String { "default greet" }
func wave() -> String { "default wave" } // NOT a requirement
}
struct Friendly: Greeter {
func greet() -> String { "friendly greet" }
func wave() -> String { "friendly wave" }
}
let concrete = Friendly()
let existential: any Greeter = Friendly()
print(concrete.greet(), "|", existential.greet()) // ①
print(concrete.wave(), "|", existential.wave()) // ②
func introduce<G: Greeter>(_ g: G) -> String { g.wave() }
print(introduce(concrete)) // ③
class Base: Greeter {} // uses the default greet()
class Child: Base {
func greet() -> String { "child greet" }
}
let child = Child()
let asBase: Base = child
let asGreeter: any Greeter = child
print(child.greet(), "|", asBase.greet(), "|", asGreeter.greet()) // ④A hint, if you're stuck
- Look at what the protocol itself declares, not what the extension adds.
- For each call, ask: does the compiler know the concrete type here, or only "something that conforms"?
- In ④,
Child.greetcompiles without theoverridekeyword. What does that tell you?
Answers (verified output)
friendly greet | friendly greet
friendly wave | default wave
default wave
child greet | default greet | default greet- ① both
friendly greet.greetis a requirement, so every conformance records which function implements it. Throughany Greeterthe call looks that up at runtime and findsFriendly's version. Same answer either way — this is the case that behaves as people expect. - ②
friendly wave | default wave.wavelives only in the extension. Through the concrete type, the compiler seesFriendly.waveand calls it. Throughany Greeter, the compiler only knows "some Greeter", and the protocol has nowaveslot to look up — so it calls the one function it can see, the extension's, chosen at compile time. Same object, different answer. - ③
default wave. Generics don't rescue it. Insideintroduce,Gis only known to be aGreeter, sog.wave()binds to the extension at compile time, exactly as in ②. - ④
child greet | default greet | default greet. The gotcha. WhenBaseconformed, it had nogreetof its own, so its conformance recorded the extension's default — and that record is shared by subclasses.Child.greetis a new method, not an override (the compiler didn't ask foroverridebecauseBasehas nogreetto override). Throughany Greeterthe lookup findsBase's record: default. ThroughBase, there's no class method to call, so it falls back to the extension: default. Only the static typeChildfindschild greet.
The idea behind it
Dispatch is how a call finds the code to run. Static dispatch means the compiler picks the function while building the app, from the type it can see. Dynamic dispatch means the choice waits until the program runs and looks at the actual object.
Swift does dynamic dispatch with lookup tables. A class has a vtable: a list of its overridable methods, which a subclass copies and edits when it overrides one. A protocol conformance has a witness table: for each requirement of the protocol, "this is the function that satisfies it for this type". It's like a phone directory per company — call "support" at any company and the directory says which person picks up.
The rule that explains every line above: only requirements get a directory entry. A method added in an extension but not declared in the protocol has no entry, so through the protocol type there's nothing to look up and the compiler calls the extension directly. And a witness table entry is filled in once, when the type conforms — a subclass adding a same-named method later doesn't edit it.
final on a class or method says "nobody will override this", so the compiler can skip the table and call the function directly. That's faster, and it removes the guessing.
How to make it unsurprising
protocol Greeter {
func greet() -> String
func wave() -> String // promoted to a requirement
}
extension Greeter {
func greet() -> String { "default greet" }
func wave() -> String { "default wave" }
}
struct Friendly: Greeter {
func greet() -> String { "friendly greet" }
func wave() -> String { "friendly wave" }
}
let existential: any Greeter = Friendly()
print(existential.wave()) // friendly wave
class Base: Greeter {
func greet() -> String { "base greet" } // the class owns the witness
}
class Child: Base {
override func greet() -> String { "child greet" }
}
let child = Child()
let asBase: Base = child
let asGreeter: any Greeter = child
print(child.greet(), "|", asBase.greet(), "|", asGreeter.greet())
// child greet | child greet | child greetWhy each piece:
- Declare anything a conformer may customise in the protocol body. Then it gets a witness table entry and dispatches dynamically everywhere. Extension-only methods should be pure helpers built from requirements, never something a type "overrides".
- A class that wants subclasses to customise a protocol method must implement it itself. Then the witness points at a class method, which goes through the vtable, so
overrideworks through every static type. The requiredoverridekeyword is the compiler confirming it. - Mark classes
finalwhen nobody should subclass them. It turns a silent shadowing bug into a compile error.
What I'm really scoring
One rule, applied every time: a protocol requirement is looked up at runtime; a method that only lives in an extension is picked at compile time from the type the compiler can see. Most candidates get ① and ②. ④ is the separator — the strong answer spots that Child.greet has no override and says what that means. In a real review, that missing keyword is the line I'd want flagged.
What I'd ask next
- "Does
some Greeterchange ②?" — No.somehides the concrete type from the caller, so the call still resolves againstGreeter, andwavestill binds to the extension. - "What are the dispatch kinds in Swift?" — Static (direct call), vtable (class methods), witness table (protocol requirements), and Objective-C message sending (
@objc dynamic), which is what KVO and method swizzling rely on. - "Why is static dispatch faster?" — The call target is known, so the compiler can inline it and optimise across it. A table lookup is cheap, but it blocks inlining.
- "Would a linter catch ②?" — The compiler doesn't warn. In review, the smell is a conforming type implementing a method that only exists in a protocol extension — that's the line to ask about.