Witness tables, some vs any, and where a call really goes
A protocol is a contract. What makes protocols interesting in Swift is that the same contract can be satisfied in two completely different ways at runtime, and the syntax barely changes between them.
protocol Shape {
var area: Double { get }
}
func describe(_ shape: any Shape) -> String // existential: one function, any shape
func describe<S: Shape>(_ shape: S) -> String // generic: one function per shape, at compile time
The generic version is specialised: the compiler can generate a copy of describe for each concrete
type it sees, inline the area call, and often erase the abstraction entirely. The existential
version compiles to one function that receives a box: a pointer to the value (or the value itself if
it fits in three words) plus a pointer to a witness table — the list of implementations for this
type. Every area call goes through that table.
Neither is “correct”. The existential is a real feature: it lets you store mixed types in one array, return different types from one function, and keep binary size down. But it costs a dynamic call and possibly a heap allocation, and it is the thing to look at first when a hot loop is slower than it should be.
Swift makes you say which one you mean.
some Shape is an opaque type: one specific type, chosen by the implementation, hidden from
the caller. The compiler knows it statically.any Shape is an existential: any type, decided at runtime, boxed.func makeShape() -> some Shape { Circle(radius: 1) } // always a Circle; caller cannot know
func loadShape() -> any Shape { randomShape() } // could be anything
The rule of thumb: use some for parameters and returns where one type flows through, use any
only when you genuinely need heterogeneity. some in parameter position is just shorthand for a
generic parameter — func draw(_ shape: some Shape) and func draw<S: Shape>(_ shape: S) compile
to the same thing.
An associated type is a placeholder the conformer fills in:
protocol Container {
associatedtype Item
var count: Int { get }
subscript(index: Int) -> Item { get }
}
For years this made a protocol unusable as a type: any Container was rejected, because the
compiler could not say what subscript returned. Swift 5.7 lifted that with primary associated
types, which let you constrain them in angle brackets:
protocol Container<Item> { // Item is the primary associated type
associatedtype Item
var count: Int { get }
subscript(index: Int) -> Item { get }
}
func sum(_ container: any Container<Int>) -> Int {
(0..<container.count).reduce(0) { $0 + container[$1] }
}
any Container<Int> is a usable type now. This is the same feature that makes any Collection<String>
and some Sequence<Int> work, and it removed most of the reasons people used to write type-erasing
AnyContainer wrappers by hand.
Extensions can supply default implementations, which is how Swift’s standard library gives you
hundreds of methods on anything that conforms to Sequence. There is one rule that catches everyone:
protocol Greeter {
func hello() -> String // a requirement
}
extension Greeter {
func hello() -> String { "hello" }
func goodbye() -> String { "bye" } // NOT a requirement
}
struct Friendly: Greeter {
func hello() -> String { "HELLO" }
func goodbye() -> String { "BYE" }
}
let greeter: any Greeter = Friendly()
greeter.hello() // "HELLO" — a requirement, dispatched through the witness table
greeter.goodbye() // "bye" — not a requirement, dispatched statically on `Greeter`
goodbye is not in the protocol, so there is no witness table entry to override. Through a
Greeter-typed variable the compiler resolves it statically to the extension’s version. Same
object, same method name, different answer depending on the declared type of the variable.
Warning
If a method is meant to be customisable, declare it in the protocol body. An extension-only method is a convenience, not a customisation point — and the difference only shows up through an existential, which is exactly where it is hardest to spot.
A protocol earns its place when there are genuinely several implementations, or when you need a seam for testing. Two conformers where one is a mock is a reasonable seam. One conformer and no test double is just an extra file.
Prefer small protocols. Equatable has one requirement and is implemented by half the standard
library; a ViewModelProtocol with fourteen members is implemented by exactly one type and exists
only so somebody could write any ViewModelProtocol in a property. Composition —
some Codable & Sendable — beats one large protocol, because callers state exactly what they need.