Protocols
This lesson defines protocols, extends them with shared implementations, composes them, and finishes with the design rules that keep protocol-oriented code maintainable.
Defining Protocols
A protocol states what must exist and says nothing about how it is done. The declaration therefore contains no bodies: just the shape of the properties and methods that a conforming type must provide.
Method & Property Requirements
A property requirement declares readability, and optionally writability, with get or get set. A method requirement is an ordinary signature ending without braces. Marking a requirement mutating allows a struct to fulfil it.
// A protocol is a contract: it lists what must exist, never how it works.
protocol Drawable {
// Read-only requirement: a stored property, a computed property, or even a
// `let` constant can satisfy it — only the getter is needed.
var area: Double { get }
// Read-write requirement: the conforming type must allow assignment, so a
// `let` constant can never satisfy this one.
var name: String { get set }
// A method requirement is a signature with no body.
func draw() -> String
// `mutating` lets a struct (or an enum) satisfy the requirement as well:
// without the keyword, value types could not conform.
mutating func scale(by factor: Double)
}
A requirement may also be static, in which case the conforming type supplies a type-level member. A protocol can demand an initializer too, by listing init(…) as a requirement.
Adopting a Protocol
Conformance is declared by naming the protocol after the type name and a colon. The compiler checks the complete list of requirements at that moment: forget one and the build fails with the missing member named, not with a mysterious crash later.
Figure 1 — the protocol states the requirements; each conforming type declares conformance and supplies the members, or inherits them from a protocol extension.
struct Circle: Drawable {
var name = "circle" // satisfies `{ get set }` — it is a var
var radius: Double
// A computed property satisfies a `{ get }` requirement.
var area: Double { 3.14159 * radius * radius }
func draw() -> String { "circle of radius \(radius)" }
// `mutating` here matches the requirement, so a struct may conform.
mutating func scale(by factor: Double) {
radius *= factor
}
}
class Square: Drawable {
var name: String
var side: Double
init(side: Double) {
self.side = side
self.name = "square"
}
var area: Double { side * side }
func draw() -> String { "square of side \(side)" }
// A class mutates the shared instance, so no `mutating` is needed.
func scale(by factor: Double) { side *= factor }
}
An enum cannot satisfy a { get set } requirement, because enums hold no stored properties for the setter to write to. That is a good argument for requiring only { get } unless you truly need assignment.
// A getter-only contract is friendlier: it lets structs, classes, AND enums
// conform, since none of them has to offer a writable property.
protocol Labelled {
var label: String { get }
}
enum Signal: Labelled {
case red, amber, green
var label: String {
switch self {
case .red: "stop"
case .amber: "wait"
case .green: "go"
}
}
}
print(Signal.amber.label) // wait — the enum satisfies the contract with a switch
Value or Reference Conformance
A protocol is silent about semantics. What gets copied when you assign a variable depends on the underlying type, not on the protocol it conforms to — conformance adds requirements, never behaviour you did not write.
// The struct keeps value semantics even while conforming to Drawable.
var a = Circle(name: "c", radius: 1)
var b = a // ordinary struct copy
b.scale(by: 2)
print(a.radius) // 1.0 — a is untouched
// The class keeps reference semantics, conformance or not.
let s = Square(side: 2)
let t = s // ordinary reference copy
t.scale(by: 3)
print(s.side) // 6.0 — both names see one object
// A protocol-typed variable boxes the value it holds. Calling a mutating
// requirement through it is legal when the variable itself is a `var`.
var shape: any Drawable = a
shape.scale(by: 3) // mutates the boxed copy, not `a`
print(a.radius) // 1.0 — the original value is still 1.0
Protocol Extensions
A protocol declares requirements; an extension can implement them. That single feature removes most of the duplication that object-oriented code pays for, because one implementation serves every conforming type instead of one per class in a hierarchy.
Default Implementations
Any member added in a protocol extension becomes available to all conforming types. Implement it in the extension and it acts as a default that a type may still replace; add a brand-new method and it becomes a free capability nobody had to declare.
// The same protocol as before, stated with just two requirements for brevity.
protocol Drawable {
var name: String { get }
func draw() -> String
}
extension Drawable {
// A brand-new method: not a requirement, granted to every conforming type.
func describe() -> String {
"\(name) -> \(draw())"
}
// A default body for a requirement. Types that say nothing inherit this one.
func draw() -> String {
"[\(name)]"
}
}
struct Dot: Drawable {
var name = "dot" // uses both extension members as-is
}
struct Star: Drawable {
var name = "star"
func draw() -> String { "*\(name)*" } // replaces the default implementation
}
print(Dot().describe()) // dot -> [dot]
print(Star().describe()) // star -> *star*
There is a sharp edge here, and it is worth memorising. A method that exists only in the extension is dispatched statically, so a type's own version is never reached through a protocol-typed variable.
protocol Speaker { } // no requirements at all
extension Speaker {
func speak() -> String { "default voice" }
}
struct Robot: Speaker {
// This does NOT override the extension method: the protocol never asked for
// it, so the extension body is merely a method the struct also happens to have.
func speak() -> String { "beep" }
}
let direct = Robot()
print(direct.speak()) // beep — concrete type, its own method
let boxed: any Speaker = Robot()
print(boxed.speak()) // default voice — extension, static dispatch
// The fix is to declare it as a requirement, so the implementation is looked up
// in the conforming type instead of being taken from the extension.
protocol Talker {
func speak() -> String
}
extension Talker {
func speak() -> String { "default voice" }
}
Constrained Extensions
An extension may restrict itself with a where clause, so the added capability exists only for the types where it is meaningful. This is how the standard library offers extra methods for numeric collections or comparable sequences.
// The capability appears only when the elements support + and a zero start.
extension Collection where Element: Numeric {
func total() -> Element {
reduce(0, +)
}
}
print([1, 2, 3].total()) // 6
print([1.5, 2.5].total()) // 4.0
// print(["a", "b"].total()) // error: String is not Numeric
// A second constraint on a standard-library protocol.
extension Sequence where Element: Comparable {
func largest() -> Element? {
reduce(nil) { best, next in
guard let best else { return next } // first element becomes the seed
return next > best ? next : best
}
}
}
print([3, 9, 4].largest() ?? 0) // 9
// An extension can also add conformance for every type that already conforms.
extension Drawable: CustomStringConvertible {
var description: String { describe() }
}
print(Dot()) // dot -> [dot] — printed via description
Retroactive Conformance
Because conformance lives in an extension, you can make an existing type adopt your protocol without touching its declaration. The type does not even have to be yours — which is both the power and the danger of the technique.
// A type that already exists and that you may not own.
struct Temperature {
var celsius: Double
}
protocol Formattable {
func formatted() -> String
}
// Retroactive conformance: the extension is declared in YOUR module, while the
// type and the protocol come from somewhere else.
extension Temperature: Formattable {
func formatted() -> String { "\(celsius)°C" }
}
print(Temperature(celsius: 21).formatted()) // 21.0°C
// Both the type and the protocol are non-local here, so keep this conformance
// in exactly ONE module. If two modules declare it, the two conformances can
// disagree and the compiler may reject the ambiguity at the point of use.
Composition & Abstraction
Swift offers three ways to talk about "some type that conforms": listing several protocols with &, storing a value as any protocol, and hiding the concrete type behind some. Knowing which one to use is the difference between clear code and code that boxes everything for no reason.
The & Composition Operator
Composition demands that a value satisfies several contracts at once. It replaces the "one protocol per role, glued by a base class" pattern with a list joined by &.
protocol Named { var name: String { get } }
protocol Aged { var age: Int { get } }
// The argument must satisfy BOTH contracts — not one or the other.
func introduce(_ person: any Named & Aged) -> String {
"\(person.name), age \(person.age)"
}
struct Cat: Named, Aged {
var name: String
var age: Int
}
print(introduce(Cat(name: "Ada", age: 3))) // Ada, age 3
// Name a combination once and reuse it everywhere.
typealias Pet = Named & Aged
func feed(_ pet: any Pet) {
print("feeding \(pet.name)")
}
feed(Cat(name: "Ada", age: 3)) // feeding Ada
Existentials with any
any Drawable is an existential: a box that holds a value whose concrete type is decided at run time. It buys you heterogeneous collections — many different types in one array — at the cost of a box and a table lookup per call.
// One array, two different concrete types: only possible through `any`.
var shapes: [any Drawable] = [
Circle(name: "c", radius: 1),
Square(side: 2)
]
for shape in shapes {
// Requirements and extension members are reachable through the box.
print(shape.describe())
}
// Members that belong only to a concrete type are not: the contract does not
// mention `radius`, so the compiler cannot promise it exists.
// print(shapes[0].radius) // error: value of type `any Drawable` has no member
// Downcast when you genuinely need the concrete type back.
if let circle = shapes[0] as? Circle {
print(circle.radius) // 1.0
}
// The conforming type can also be changed after the box exists.
shapes[0] = Circle(name: "big", radius: 10)
print(shapes[0].describe())
Opaque Types with some
some Drawable is an opaque type: one specific type, known to the compiler, hidden from the caller. Because the compiler still knows it, calls stay direct and no box is allocated — while the function remains free to change its implementation later without breaking anyone.
// The concrete type is fixed, but the caller is not told which one it is.
func makeShape() -> some Drawable {
Circle(name: "c", radius: 1)
}
let hidden = makeShape()
print(hidden.area) // 3.14159 — members of the concrete type stay reachable
print(hidden.describe()) // c -> circle of radius 1.0
// Every return path must produce the SAME concrete type, because `some` names
// exactly one type. This is rejected at compile time:
// func broken(_ flag: Bool) -> some Drawable {
// flag ? Circle(name: "c", radius: 1) : Square(side: 2) // error
// }
// SwiftUI uses this everywhere: `some View` hides a large nested type while
// keeping the result one fixed concrete type for the compiler.
| Aspect | any Protocol | some Protocol |
|---|---|---|
| Concrete type | Chosen at run time; may differ per value. | Fixed at compile time; one type per declaration. |
| Dispatch | Witness table, with a box for the value. | Static — a direct call, no box. |
| Mixed collections | Yes: [any Drawable] holds any conforming type. |
No: every element would have to be the same type. |
| Typical use | Storage, arrays of mixed types, APIs across modules. | Factory functions, computed properties, view builders. |
Protocol-Oriented Design
Swift's standard library is built from protocols: Equatable, Hashable, Comparable, Sequence, Collection, Codable. Designing your own types the same way means composition instead of a deep hierarchy — and it works for value types, which inheritance never can.
Protocol or Superclass?
Both mechanisms share behaviour. The table makes the trade-off explicit so you can choose with reasons rather than habit.
| Aspect | Protocol + extension | Superclass inheritance |
|---|---|---|
| Who can adopt it | Struct, class, and enum. | Classes only. |
| How many | Unlimited; combine roles with &. |
Exactly one superclass. |
| Shared code | Written once in a protocol extension. | Inherited down the chain, whether needed or not. |
| Semantics | Value or reference, chosen by the conforming type. | Always a reference: instances live on the heap. |
| Effect of a change | Add a default and existing types keep compiling. | Changing a base method ripples into every subclass. |
| Reach for it when | Capabilities combine and vary by role. |