1. Protocol-oriented design: some, any & dispatch¶
What & why¶
Protocols are Swift's main abstraction tool, but how you use a protocol type changes performance, flexibility, and what the compiler lets you do. This lesson covers the three ways to use a protocol (generics, some, any), how method dispatch works, type erasure, and the design heuristics that follow.
Three ways to say "anything that conforms to P"¶
protocol Shape {
func area() -> Double
}
struct Circle: Shape { var r: Double; func area() -> Double { .pi * r * r } }
struct Square: Shape { var s: Double; func area() -> Double { s * s } }
// 1. Generic: the caller picks the concrete type; one type per call.
func describe<S: Shape>(_ shape: S) -> String { "area \(shape.area())" }
// 2. Opaque parameter: sugar for (1).
func describe2(_ shape: some Shape) -> String { "area \(shape.area())" }
// 3. Existential: a box that can hold *any* conforming type at runtime.
func describe3(_ shape: any Shape) -> String { "area \(shape.area())" }
<S: P> / some P (parameter) |
some P (return) |
any P |
|
|---|---|---|---|
| Concrete type known at compile time | yes (per call) | yes (by compiler, hidden from caller) | no — decided at runtime |
| Can mix different types in a collection | no | no | yes |
| Overhead | none after specialization | none | box + dynamic dispatch (+ possible heap allocation) |
Can use protocols with associated types / Self |
yes | yes | yes, with primary associated types or limited members |
Rule of thumb: use some/generics by default; use any when you genuinely need heterogeneous storage or runtime flexibility ([any Shape], a stored property that changes type, plugin systems).
some as a return type¶
The function picks one concrete type and the caller can't see it:
func makeShape() -> some Shape { Circle(r: 1) } // always Circle
// func makeShape(flag: Bool) -> some Shape {
// flag ? Circle(r: 1) : Square(s: 1) // ERROR: different types in branches
// }
If you need to choose among types at runtime, return any Shape (or use an enum, or @ViewBuilder-style result builders — lesson 3).
Opaque return types shine for chains of generic wrappers whose types are unspeakable:
func evenSquares(upTo n: Int) -> some Sequence<Int> {
(1...n).lazy.filter { $0.isMultiple(of: 2) }.map { $0 * $0 }
}
Existentials in practice¶
let shapes: [any Shape] = [Circle(r: 1), Square(s: 2)]
let total = shapes.reduce(0) { $0 + $1.area() }
Opening an existential: when a method needs a generic parameter, Swift 5.7+ implicitly "opens" the box and calls the generic function with the dynamic type:
func printArea<S: Shape>(_ s: S) { print(s.area()) }
for shape in shapes { printArea(shape) } // works: the existential is opened
Existentials with Self or associated-type requirements restrict what you can call. Use primary associated types to constrain them:
let numbers: any Sequence<Int> = [1, 2, 3] // any Sequence whose Element == Int
for n in numbers { print(n) }
ExistentialAny¶
Writing a bare protocol name as a type ([Shape]) is legacy syntax. Enable the upcoming feature to make any mandatory, which makes the cost visible in code review:
Static vs. dynamic dispatch¶
How does x.area() find the right code?
- Static dispatch — the compiler knows the exact function; it can inline it. Used for structs/enums,
finalclasses, generic code that's specialized, and extension methods. - Dynamic dispatch via a witness table — for a protocol-typed value (
any P), the call goes through a table of function pointers for that conformance. - Dynamic dispatch via a vtable — for class methods that can be overridden.
The visible consequence is the extension-method trap:
protocol Greeter { func hello() -> String } // hello is a requirement
extension Greeter {
func hello() -> String { "default hello" } // default implementation
func bye() -> String { "default bye" } // NOT a requirement
}
struct Loud: Greeter {
func hello() -> String { "HELLO" }
func bye() -> String { "BYE" }
}
let g: any Greeter = Loud()
print(g.hello()) // HELLO — requirement: dispatched dynamically
print(g.bye()) // default bye — extension-only: dispatched statically on the declared type!
print(Loud().bye()) // BYE
Design rule: if a method is meant to be customised, declare it in the protocol. Put non-customisable helpers in extensions.
For classes, mark things final unless you intend subclassing — it enables static dispatch and documents intent. In library code, prefer public final class.
Type erasure¶
Sometimes you want one concrete type that hides a generic or protocol-based implementation — before any supported associated types, this was the only way. The standard library still ships AnySequence, AnyHashable, AnyIterator. Writing one by hand:
struct AnyCounter {
private let _next: () -> Int
init<C: Counter>(_ base: C) {
var copy = base // each AnyCounter owns its own copy
_next = { copy.next() }
}
func next() -> Int { _next() }
}
protocol Counter { mutating func next() -> Int }
struct Ticker: Counter { var n = 0; mutating func next() -> Int { n += 1; return n } }
let counters = [AnyCounter(Ticker()), AnyCounter(Ticker(n: 100))]
Today, prefer any Counter unless you need Equatable/Hashable behaviour on the erased type or must hide a generic parameter in a stored property.
Protocol design heuristics¶
- Start with concrete types. Extract a protocol when you have two real conforming types or need a test double — not before.
- Small protocols (
Readable,Writable) compose with&. - Name by capability:
-able,-ing, or a role (Delegate,DataSource). - Prefer composition over inheritance: a struct holding a
any Loggerbeats a base classLoggingObject. - Provide default implementations in extensions so conformance is cheap.
- Beware "protocol soup": if every type has a matching
FooProtocol, you're abstracting too early. - Marker protocols (
Sendable) express guarantees, not behaviours.
Dependency injection with protocols¶
protocol Logger { func log(_ message: String) }
struct ConsoleLogger: Logger { func log(_ message: String) { print(message) } }
struct Service<L: Logger> { // generic: statically dispatched, test with a spy type
let logger: L
func run() { logger.log("running") }
}
struct DynamicService { // existential: swap implementation at runtime
var logger: any Logger
func run() { logger.log("running") }
}
Use a generic when the dependency is fixed per instance and performance matters; use any when the implementation is chosen late (configuration, plugins, tests with many fakes).
Common mistakes¶
- Using
anyeverywhere and paying for boxing and dynamic dispatch unnecessarily. - Returning
some Pfrom a function with branches returning different types. - Expecting extension-only methods to be overridable through a protocol-typed variable.
- Over-engineering with erasure wrappers where
any P<…>works. - Non-final classes in performance-critical code without need.
- Protocols with
Selfrequirements used as plain types (Equatablecan't beany Equatablein useful ways).
Exercises¶
- Write a
Rendererprotocol and use it three ways: generic,some,any. Look at the optimizer output difference with-Oand-emit-assemblyif you're curious. - Reproduce the extension-method dispatch trap, then fix it by declaring the method in the protocol.
- Build a heterogeneous
[any Plugin]list where each plugin hasnameandrun(). - Write a function
func evens() -> some Sequence<Int>and then change it to returnany Sequence<Int>— what changes for callers? - Implement
AnyEquatableBoxthat wraps anyEquatable & Hashablevalue and compares correctly (hint:AnyHashable). - Make a
Service<L: Logger>and test it with aSpyLoggerthat records messages.
Recap¶
- Use generics /
someby default; useanyfor heterogeneous or runtime-chosen values. somereturn types hide a single concrete type;anyboxes at runtime.- Declare customisable methods in the protocol; extension-only methods dispatch statically.
- Primary associated types (
any Sequence<Int>) make existentials practical. - Introduce protocols when you need them, and keep them small.
Next: Advanced generics →