Lesson 14 / 24
Protocols
Measures when a value that fulfills a protocol requirement is evaluated: a default implementation runs on every call, but the conforming type decides the moment.
Published Updated
In this lesson
- Defines a protocol as a contract with requirements and a default implementation
- Measures that a computed property in a protocol extension runs on every call
- Compares how a protocol type with some and any calls the same requirement
- Tells apart the moment of two types that fulfill the same requirement with a stored and a computed property
Contents
The previous lesson measured properties: a stored property is evaluated on the line where it is defined, a computed property runs again on every read, and a lazy property waits until the first read. Now we move to the question of who writes a property. A type can carry a property in order to follow a contract set by another type, and the contract itself can supply a body. How these two cases affect the moment is the subject of this lesson.
A protocol is a list of the members a type has to carry: properties, methods, initializers, subscripts and associated types. Each item on the list is a requirement. It carries a name, a type and whether it is readable or writable, but it carries no body. A type that conforms to the protocol fulfills the requirement with its own body. In a protocol’s extension, though, a default implementation can be written for a requirement; if the conforming type does not give its own body, the default runs. Extensions get their own treatment in lesson 07; here we use only protocol extensions.
The Measurement Setup
Every lesson starts with the same helper. Probe counts how many times an expression is evaluated and how many times it is used; moment derives the moment from those two numbers.
// Probe.swift — records when an expression is evaluated
final class Probe {
let label: String
private(set) var evaluations = 0
private(set) var uses = 0
private var evaluatedBeforeUse = false
init(_ label: String) {
self.label = label
}
func eval<T>(_ value: T) -> T {
evaluations += 1
if uses == 0 { evaluatedBeforeUse = true }
return value
}
func use<T>(_ access: () -> T) -> T {
uses += 1
return access()
}
var moment: String {
if evaluations == 0 { return "never" }
if evaluations > 1 { return "on-every-use" }
return evaluatedBeforeUse ? "immediately" : "on-first-use"
}
func report() {
print("\(label): \(evaluations) evaluations, \(uses) uses -> \(moment)")
}
}
func tally(_ probes: [Probe]) {
for moment in ["immediately", "on-first-use", "on-every-use", "never"] {
let count = probes.filter { $0.moment == moment }.count
print("\(moment): \(count)")
}
}
TS21. Every observation, apart from never, carries two uses; the numbers come from two calls on a single type, and because a protocol is not a value itself, what is measured is always an instance of a conforming type.
A Property in a Protocol Extension
The protocol below sets two requirements: a name and a Probe. greeting, on the other hand, is a computed property written in the protocol’s extension; no conforming type defines it. Two types, Person and Robot, use the same default implementation.
// main.swift — a property in a protocol extension runs on every call
protocol Describable {
var name: String { get }
var tracker: Probe { get }
}
extension Describable {
var greeting: String { tracker.eval("hello \(name)") }
}
struct Person: Describable {
let name: String
let tracker: Probe
}
struct Robot: Describable {
let name: String
let tracker: Probe
}
func makeRobot(_ tracker: Probe) -> some Describable {
Robot(name: "Rex", tracker: tracker)
}
func twice<T: Describable>(_ value: T) -> Int {
value.greeting.count + value.greeting.count
}
let direct = Probe("default-implementation")
let existential = Probe("any-protocol-type")
let opaque = Probe("some-protocol-type")
let generic = Probe("generic-function")
let ada = Person(name: "Ada", tracker: direct)
_ = direct.use { ada.greeting }
_ = direct.use { ada.greeting }
let crowd: [any Describable] = [Person(name: "Can", tracker: existential)]
_ = existential.use { crowd[0].greeting }
_ = existential.use { crowd[0].greeting }
let made = makeRobot(opaque)
_ = opaque.use { made.greeting }
_ = opaque.use { made.greeting }
_ = generic.use { twice(Person(name: "Eda", tracker: generic)) }
_ = generic.use { twice(Person(name: "Eda", tracker: generic)) }
let all = [direct, existential, opaque, generic]
for probe in all { probe.report() }
tally(all)
default-implementation: 2 evaluations, 2 uses -> on-every-use any-protocol-type: 2 evaluations, 2 uses -> on-every-use some-protocol-type: 2 evaluations, 2 uses -> on-every-use generic-function: 4 evaluations, 2 uses -> on-every-use immediately: 0 on-first-use: 0 on-every-use: 4 never: 0
All four observations came out as on every use. A default implementation is a computed property: the protocol does not store it, and it runs the body again on every read. The three ways of writing a protocol type gave the same result. any Describable creates an existential type: the real type of the value is stored at runtime and the call is forwarded to it. some Describable gives an opaque type, which hides the type but keeps it fixed at compile time. The T: Describable constraint of a generic function (detailed in lesson 08) reaches the same requirement as well. In all three, greeting was computed again on each call; because twice reads it twice in one call, the evaluation count in that observation rose to four. In M13/K01 we saw that the common interface Enumerable supplies bodies; in Ruby, which body answers from that interface is a separate question. We do not ask it here, we ask only when the body runs. TS22. In the measurement no conforming type rewrote the default with its own body; a type giving its own body stayed outside this lesson.
For standard protocols such as Equatable, even the compiler can write the default. It is enough for a structure whose stored properties are all equatable to say that it conforms to Equatable; the compiler derives the comparison operator.
// main.swift — Equatable derivation
struct Point: Equatable {
let x: Int
let y: Int
}
print(Point(x: 1, y: 2) == Point(x: 1, y: 2))
print(Point(x: 1, y: 2) == Point(x: 2, y: 1))
true false
We did not write the equality operator; looking at the type definition, the compiler produced a body that compares each field in order. This derivation is a decision made at compile time, not something we measure at run time.
The Customization Point
The default implementation in a protocol extension has a subtlety: not every member written in the extension is the same. A member that is also counted among the protocol’s requirements becomes a customization point. When the conforming type gives its own body, a call made through the protocol type goes to that body too. A member that is not in the requirement list and is written only in the extension is not a customization point: when it is called through the protocol type, the body in the extension runs, even if the conforming type has a member of the same name.
We watch this with two Probes. standardProbe counts the default body in the extension, and customProbe counts the conforming type’s own body. title is a requirement of the protocol; badge is written only in the extension. Book rewrites both with its own body.
// main.swift — the difference between a requirement and an extension-only member
protocol Badged {
var standardProbe: Probe { get }
var customProbe: Probe { get }
var title: String { get }
}
extension Badged {
var title: String { standardProbe.eval("default title") }
var badge: String { standardProbe.eval("default badge") }
}
struct Book: Badged {
let standardProbe: Probe
let customProbe: Probe
var title: String { customProbe.eval("book title") }
var badge: String { customProbe.eval("book badge") }
}
func probes(_ label: String) -> (standard: Probe, custom: Probe) {
(Probe("\(label)/extension"), Probe("\(label)/type"))
}
let titleViaProtocol = probes("title-via-protocol")
let badgeViaProtocol = probes("badge-via-protocol")
let badgeViaType = probes("badge-via-type")
let titled: any Badged = Book(standardProbe: titleViaProtocol.standard, customProbe: titleViaProtocol.custom)
_ = titleViaProtocol.custom.use { titled.title }
_ = titleViaProtocol.custom.use { titled.title }
let badged: any Badged = Book(standardProbe: badgeViaProtocol.standard, customProbe: badgeViaProtocol.custom)
_ = badgeViaProtocol.standard.use { badged.badge }
_ = badgeViaProtocol.standard.use { badged.badge }
let book = Book(standardProbe: badgeViaType.standard, customProbe: badgeViaType.custom)
_ = badgeViaType.custom.use { book.badge }
_ = badgeViaType.custom.use { book.badge }
let all = [titleViaProtocol.standard, titleViaProtocol.custom, badgeViaProtocol.standard, badgeViaProtocol.custom, badgeViaType.standard, badgeViaType.custom]
for probe in all { probe.report() }
title-via-protocol/extension: 0 evaluations, 0 uses -> never title-via-protocol/type: 2 evaluations, 2 uses -> on-every-use badge-via-protocol/extension: 2 evaluations, 2 uses -> on-every-use badge-via-protocol/type: 0 evaluations, 0 uses -> never badge-via-type/extension: 0 evaluations, 0 uses -> never badge-via-type/type: 2 evaluations, 2 uses -> on-every-use
When title, a requirement, was called through the protocol type, Book‘s own body ran; the default body in the extension stayed never. badge, which is not a requirement, ran the body in the extension when called through the same any Badged value, and Book‘s own badge body stayed never. When the same badge was called through the concrete Book type, the reverse happened: the type’s own body ran. This difference shows that writing a member in the requirement list is not only a declaration; it decides which body will run. If you want the types to be able to customize a member added to an extension, you first write it into the protocol’s requirements. TS23. This difference was measured for structure types and for the any protocol type; classes and values returned with some were not tried separately.
Protocol Composition
If a value has to conform to more than one protocol, we join the types with &: some Named & Priced. A protocol composition does not define a new protocol; it only asks, for the type of a parameter or a return value, for several requirements together. To measure whether a composition changes the moment of the requirements, we use a type that fulfills the two requirements in different ways: name is stored, price is computed.
// main.swift — a protocol composition does not change the moment of the requirements
protocol Named { var name: String { get } }
protocol Priced { var price: Int { get } }
struct Product: Named, Priced {
let name: String
let items: [Int]
let tracker: Probe
var price: Int { tracker.eval(items.reduce(0, +)) }
}
func label(_ value: some Named & Priced) -> String {
"\(value.name): \(value.price)"
}
let nameProbe = Probe("composition-name")
let priceProbe = Probe("composition-price")
let product = Product(name: nameProbe.eval("Notebook"), items: [3, 4], tracker: priceProbe)
_ = nameProbe.use { label(product) }
_ = nameProbe.use { label(product) }
_ = priceProbe.use { label(product) }
let pair = [nameProbe, priceProbe]
for probe in pair { probe.report() }
composition-name: 1 evaluations, 2 uses -> immediately composition-price: 3 evaluations, 1 uses -> on-every-use
A composition is only a boundary: the label function asks for the requirements of both protocols together, but the moment of each requirement comes from the conforming type’s declaration. The stored name was evaluated once, when Product was built: immediately. The computed price, on the other hand, ran again each time label was called. Of the three calls to label, two were counted with nameProbe and one with priceProbe, so priceProbe shows three evaluations in total; this shows which calls the counter passed through and does not indicate a separate moment. Even though priceProbe carries a single use, it received three evaluations, so the rule counts it as on every use without falling into the ambiguity of a single-use observation. TS24. The composition was measured here as a function parameter and with some; the any Named & Priced spelling was not tried separately.
A Constrained Protocol Extension
A where condition can be added to a protocol extension: extension Ranked where Self: Comparable. The members in such an extension are visible only on types that meet the condition. If the same name is written in both the general extension and the constrained extension, the more specific one, that is the constrained extension, is chosen for a type that meets the condition; a type that does not meet it falls back to the general one. We see which body runs with two Probes: generalProbe counts the general extension and specificProbe counts the constrained one.
// main.swift — the constrained extension is chosen when it is more specific
protocol Ranked {
var points: Int { get }
var generalProbe: Probe { get }
var specificProbe: Probe { get }
}
extension Ranked {
var rank: String { generalProbe.eval("unranked") }
}
extension Ranked where Self: Comparable {
var rank: String { specificProbe.eval("ranked") }
}
struct Score: Ranked, Comparable {
let points: Int
let generalProbe: Probe
let specificProbe: Probe
static func < (left: Score, right: Score) -> Bool { left.points < right.points }
static func == (left: Score, right: Score) -> Bool { left.points == right.points }
}
struct Plain: Ranked {
let points: Int
let generalProbe: Probe
let specificProbe: Probe
}
let comparableGeneral = Probe("comparable/general")
let comparableSpecific = Probe("comparable/constrained")
let plainGeneral = Probe("plain/general")
let plainSpecific = Probe("plain/constrained")
let score = Score(points: 3, generalProbe: comparableGeneral, specificProbe: comparableSpecific)
_ = comparableSpecific.use { score.rank }
_ = comparableSpecific.use { score.rank }
let plain = Plain(points: 3, generalProbe: plainGeneral, specificProbe: plainSpecific)
_ = plainGeneral.use { plain.rank }
_ = plainGeneral.use { plain.rank }
let all = [comparableGeneral, comparableSpecific, plainGeneral, plainSpecific]
for probe in all { probe.report() }
tally(all)
comparable/general: 0 evaluations, 0 uses -> never comparable/constrained: 2 evaluations, 2 uses -> on-every-use plain/general: 2 evaluations, 2 uses -> on-every-use plain/constrained: 0 evaluations, 0 uses -> never immediately: 0 on-first-use: 0 on-every-use: 2 never: 2
Because Score is both Comparable and conforms to Ranked, the rank call went to the body in the constrained extension; the general body stayed never. Plain is not Comparable, so the constrained extension was ignored for it and the general body ran on every call. In both cases the body that ran was evaluated again on every use: the constraint decides which body is chosen, not when it runs. This choice is made at compile time, when the concrete type is known; calling a constrained extension through a protocol value is a separate question and was not measured here. TS25. The choice was measured only for calls made through a concrete type.
The Bounding Measurement
The protocol does not decide the moment itself: the type that fulfills the requirement does. The Totaled protocol below sets a single requirement: var total: Int { get }. StoredTotal fulfills it with a stored let, and ComputedTotal with a computed property. Both types are read from the same function; the code that reads cannot see the difference between them.
// main.swift — the same requirement, two conformances, two moments
protocol Totaled {
var total: Int { get }
}
struct StoredTotal: Totaled {
let total: Int
}
struct ComputedTotal: Totaled {
let items: [Int]
let tracker: Probe
var total: Int { tracker.eval(items.reduce(0, +)) }
}
func read<T: Totaled>(_ value: T) -> Int { value.total }
let storedProbe = Probe("stored-conformance")
let computedProbe = Probe("computed-conformance")
let fixedTotal = StoredTotal(total: storedProbe.eval(9))
let derivedTotal = ComputedTotal(items: [4, 5], tracker: computedProbe)
_ = storedProbe.use { read(fixedTotal) }
_ = storedProbe.use { read(fixedTotal) }
_ = computedProbe.use { read(derivedTotal) }
_ = computedProbe.use { read(derivedTotal) }
let pair = [storedProbe, computedProbe]
for probe in pair { probe.report() }
stored-conformance: 1 evaluations, 2 uses -> immediately computed-conformance: 2 evaluations, 2 uses -> on-every-use
The same total requirement, two different moments: the stored conformance is immediately, the computed conformance is on every use. The protocol does not see this difference; it only says that an Int will be returned. So when you read a protocol requirement, you cannot work out the cost of the expression from the protocol’s name; you have to look at the declaration of the conforming type. TS26. In this measurement the value of StoredTotal was built with an expression wrapped in a Probe; where a stored value comes from in a real program is a separate question and was not measured here.
Summary
- A protocol is a contract that lists requirements; the default implementation written in its extension supplies a body to types that do not fulfill the requirement themselves.
- A computed property in a protocol extension runs again on every read;
any,someand a generic constraint did not change this behavior. - The compiler derives the implementation from the stored properties for some protocols such as
Equatable. - If a stored property fulfills the same requirement, the measurement is immediately; if a computed property does, it is on every use: the moment is decided by the conforming type, not the protocol.
- This lesson’s moment: on every use (the default implementation in a protocol extension).
Next Step
A protocol extension can add a body to a protocol whose definition belongs to someone else. But does this extension mechanism work only with protocols? The next lesson measures adding methods and properties from the outside to standard types such as Int and String, and what cannot be added.
To keep your progress and take notes, Log in
My notes
Log in to take notes.