Lesson 11 / 15
The Delegate Pattern
A measurement of how a weakly held delegate that is not wired or has been released drops the call and leaves the screen stale, and how not being wired and a method that writes nothing give the same result.
Published Updated
In this lesson
- Distinguishes the delegate as a protocol relationship in which one object hands part of its work to another
- Actually measures that a weak reference becomes nil when the object is released and that the call is dropped
- Counts the screen left behind by a delegate that is not wired as stale
- Verifies that not being wired and a method that writes nothing give the same stale result
Contents
In the previous lesson, Collection Views, we saw that a view does not keep the data and the behavior itself but consults another object. The name of that consulting relationship is the delegate pattern. An object hands part of its work to another object that conforms to a protocol: when a selection is made, when a save finishes or when a row is tapped, the delegate’s method is called. If the delegate is not wired, the call goes nowhere, and this is a silent break.
The measurement uses the core file.
// Sync.swift — compares every field shown on screen with the model's current value
final class Screen {
let name: String
private(set) var shown: [String: String] = [:]
init(_ name: String) { self.name = name }
func set(_ key: String, _ value: String) { shown[key] = value }
func clear(_ key: String) { shown[key] = nil }
}
func verdict(model: String?, shown: String?) -> String {
switch (model, shown) {
case (nil, nil): return "out-of-scope"
case (nil, _?): return "leftover"
case (_?, nil): return "missing"
case let (wanted?, displayed?): return wanted == displayed ? "matching" : "stale"
}
}
func check(_ label: String, model: [String: String], screen: Screen, keys: [String]) -> [String] {
keys.map { key in
let result = verdict(model: model[key], shown: screen.shown[key])
print("\(label) | \(screen.name).\(key): model=\(model[key] ?? "-") screen=\(screen.shown[key] ?? "-") -> \(result)")
return result
}
}
func countVerdicts(_ verdicts: [String]) {
for name in ["matching", "stale", "missing", "leftover"] {
print("\(name): \(verdicts.filter { $0 == name }.count)")
}
}
The core compares every field shown on screen with the model’s current value. In this lesson, pure Swift code made of a picker and the delegates that listen to it is also measured. This code contains no platform framework, so it really runs. The model builds what the code writes to the screen; it is not the framework itself.
A Weakly Wired Delegate
On a payment screen, a picker provides the delivery address. The picker informs the delegate when an address is chosen; the screen’s job is to show this address. The delegate is held in the picker with a weak reference: if the picker kept the screen alive and the screen kept the picker alive, the two would enter a retain cycle in which neither releases the other. A weak reference prevents this, but it has a cost: if the delegate is released, the reference becomes nil.
// AddressPicker.swift — the address picker and the delegates that listen to it
protocol AddressPickerDelegate: AnyObject {
func picker(_ picker: AddressPicker, didSelect address: String)
}
final class AddressPicker {
weak var delegate: AddressPickerDelegate?
func choose(_ address: String) {
delegate?.picker(self, didSelect: address)
}
}
final class CheckoutScreen: AddressPickerDelegate {
let screen: Screen
init(_ screen: Screen) { self.screen = screen }
func picker(_ picker: AddressPicker, didSelect address: String) {
screen.set("address", address)
}
}
final class SilentCheckout: AddressPickerDelegate {
func picker(_ picker: AddressPicker, didSelect address: String) {}
}
choose(_:) calls the delegate with optional chaining: if the delegate is nil, nothing happens and no error appears. CheckoutScreen writes the chosen address to the screen. SilentCheckout conforms to the protocol but its method is empty. In the first measurement the picker is first used without being wired, then it is wired.
// main.swift — a delegate that is not wired leaves the screen stale, it becomes matching once wired
let screen = Screen("Checkout")
screen.set("address", "Ankara")
var model = ["address": "Ankara"]
let picker = AddressPicker()
let checkout = CheckoutScreen(screen)
let start = check("start", model: model, screen: screen, keys: ["address"])
model["address"] = "Izmir"
picker.choose("Izmir")
let unwired = check("no-delegate", model: model, screen: screen, keys: ["address"])
picker.delegate = checkout
picker.choose("Izmir")
let wired = check("delegate-wired", model: model, screen: screen, keys: ["address"])
countVerdicts(start + unwired + wired)
start | Checkout.address: model=Ankara screen=Ankara -> matching no-delegate | Checkout.address: model=Izmir screen=Ankara -> stale delegate-wired | Checkout.address: model=Izmir screen=Izmir -> matching matching: 2 stale: 1 missing: 0 leftover: 0
In the start step it is matching. In the no-delegate step the model is Izmir, the screen is Ankara and the result is stale: the picker chose the address but passed it to no one. In the delegate-wired step the same call reached the screen this time and the row is matching. The count is two matching, one stale.
LC1. This measurement assumes that the delegate’s method is called synchronously. Whether the call ran on another queue was not measured.
A Released Delegate
A delegate that was never wired is the first break that comes to mind. A more insidious one is a wired delegate that is released. The screen closes or another object stops holding it; the weak reference becomes nil and the picker keeps making calls as if it were wired. In this lesson becoming nil is not a guess but is actually measured: the reference is read twice below.
// main.swift — a weak reference becomes nil when the delegate is released, the call is dropped
let screen = Screen("Checkout")
screen.set("address", "Ankara")
let model = ["address": "Bursa"]
let picker = AddressPicker()
var handler: CheckoutScreen? = CheckoutScreen(screen)
picker.delegate = handler
print("delegate wired: \(picker.delegate != nil)")
handler = nil
print("delegate wired: \(picker.delegate != nil)")
picker.choose("Bursa")
let released = check("delegate-released", model: model, screen: screen, keys: ["address"])
countVerdicts(released)
delegate wired: true delegate wired: false delegate-released | Checkout.address: model=Bursa screen=Ankara -> stale matching: 0 stale: 1 missing: 0 leftover: 0
The first line is true, the second line false. When the delegate was released, the weak reference became nil by itself; the following choose call went nowhere. The screen stayed Ankara, the model is Bursa and the result is stale. The count is one stale.
The first two lines are not an assumption of the model but the real result of Swift’s weak reference behavior. The stale in the third line is the consequence of those two lines: since the call was dropped, nothing was written to the screen.
LC2. This measurement assumes only that setting the handler variable to nil is a release. The case where another strong reference keeps the object alive was not measured.
On the SOURCE side, the same link is built by a view controller making itself the delegate while setting up a picker.
import UIKit
final class CheckoutController: UIViewController, AddressPickerDelegate {
let picker = AddressPicker()
override func viewDidLoad() {
super.viewDidLoad()
picker.delegate = self
}
}
If the picker.delegate = self line is forgotten, the compiler does not warn; only the screen stays stale.
Protocol Design
In the delegate pattern, an object asks another object or informs it at a point where a decision has to be made on its behalf. The asking object does not know who the delegate is; it only knows that it conforms to a protocol. In this way the owner of the question and the owner of the answer are separated. Most ready-made views work with this pattern: table views, picker controllers and text fields leave their behavior to their delegates.
A good delegate protocol has three properties. First, the method name describes the event with a verb pattern: something will happen (will), is happening (did) or needs approval (should). Second, the first parameter is the object that sends the event; in this way a single delegate can serve several objects and tell which one a call came from. Third, the protocol is small: each method carries one decision or notification, and the protocol itself does not describe the whole behavior of a class.
import UIKit
protocol AddressPickerDelegate: AnyObject {
func addressPicker(_ picker: AddressPicker, didSelect address: String)
func addressPickerDidCancel(_ picker: AddressPicker)
}
The protocol is restricted with AnyObject, so only classes can conform. The reason is in the next heading.
The Weak Reference
The object that holds a delegate stores it with a weak reference. The reason is the memory cycle: if the screen holds the picker and the picker holds the screen as its delegate, the two objects do not let go of each other and neither is ever released. A weak reference does not keep the object alive; if the delegate disappears elsewhere, the reference becomes nil by itself.
import UIKit
final class AddressPicker {
weak var delegate: AddressPickerDelegate?
func choose(_ address: String) {
delegate?.addressPicker(self, didSelect: address)
}
}
weak is valid only for class types; this is why restricting the protocol with AnyObject is necessary. If the delegate is nil, the call is skipped silently, and this is the real trap of this lesson. If the delegate was not wired, the picker works, gives no error and nothing happens.
A Delegate That Is Not Wired
Never assigning the delegate is the most common mistake of the pattern. Because the call is made with optional chaining, there is no crash and no warning; the screen does not respond. The first and second measurements of this lesson show this. A selection made before the delegate is assigned does not reach the screen and the row stays stale. Once assigned, the same selection gives a matching result. The second measurement also really tests the weak reference: when the screen is released, the picker’s link to the delegate becomes nil and later selections again reach no one.
Delegate or Closure
The same job can often be done with a closure as well. The picker can take as a property a closure that it will call when the selection is complete. For a single event this is shorter and does not require writing a protocol. If there are several related events, for example selection, cancellation and a validation question, a protocol is more readable, and because the sending object is passed as a parameter, a single delegate can serve several objects.
Closures can also create a memory cycle: if the closure holds the screen and the screen holds the picker, a cycle is formed and the weak reference has to be written separately inside the closure. In the delegate pattern this risk is collected in a single place, in the weak keyword.
Common Mistakes
- Holding the delegate with a strong reference. A cycle forms and neither object is ever released.
- Forgetting to assign the delegate. The call is skipped silently; there is no output.
- Not restricting the protocol with
AnyObject. A weak reference cannot be written and the compiler gives an error. - Not passing the sending object as a parameter. When a single delegate serves several objects, it is not known which one called.
- Doing heavy work in the delegate. The call runs in the picker’s flow, and long work blocks the screen.
- Gathering the protocol used to ask the delegate for data and the protocol used for notification in one place. The first expects an answer, the second does not; this distinction is kept in the naming.
Interpreting the Measured Result
In the first measurement the screen agreed with the model at the start. When the model became Izmir, the screen stayed Ankara because the delegate was not wired, and the row came out stale. When the delegate was wired, the same selection reached the screen and the row became matching. In the second measurement the weak reference was really measured: the link printed true first and false once the screen was released, and since the following selection reached no one, the row came out stale. In the third measurement two different screens reached the same result: one stayed stale because the delegate was not wired, the other because the delegate was wired but did not write the selection to the screen. The output does not tell the two apart.
The conclusion is this: the delegate pattern can fail without producing an error. A broken link and a wired delegate that does not do its job give the same silent picture, and the only way to see it is to compare the output with the model.
In practice two checks are enough. The first is that the place where the delegate is assigned is single and explicit: while the screen is being set up, the picker is created and the delegate is assigned immediately, so the assignment is not left in between. The second is not leaving a silent path behind the delegate call. If the result of the call matters, a condition that warns when there is no delegate is added, or it is stopped with a check during development. Silent skipping is the comfortable side of the pattern, but as the measurement shows, it also hides an error. These two habits prevent most of the stale rows seen in the measurement at the writing stage already; when the place where the link is built is single, a link that breaks or is never built is also looked for in a single place and is not tracked separately on every screen.
The Bounding Measurement
A stale result can have two separate causes: the delegate may never have been wired, or it is wired and the method writes nothing. One is forgetting the link, the other is leaving the inside of the method empty. The rule does not read who forgot. The model looks only at what was written to the screen.
// main.swift — not being wired and a method that writes nothing give the same stale result
let model = ["address": "Bursa"]
let unwiredScreen = Screen("UnwiredCheckout")
let silentScreen = Screen("SilentCheckout")
unwiredScreen.set("address", "Ankara")
silentScreen.set("address", "Ankara")
let unwiredPicker = AddressPicker()
unwiredPicker.choose("Bursa")
let silentPicker = AddressPicker()
let silentDelegate = SilentCheckout()
silentPicker.delegate = silentDelegate
silentPicker.choose("Bursa")
let unwiredResult = check("not-wired", model: model, screen: unwiredScreen, keys: ["address"])
let silentResult = check("method-wrote-nothing", model: model, screen: silentScreen, keys: ["address"])
countVerdicts(unwiredResult + silentResult)
not-wired | UnwiredCheckout.address: model=Bursa screen=Ankara -> stale method-wrote-nothing | SilentCheckout.address: model=Bursa screen=Ankara -> stale matching: 0 stale: 2 missing: 0 leftover: 0
Both rows are stale, the count is two stale. One was never called, the other was called and wrote nothing; the output does not tell them apart. This is a limit: to fix the result, where the call broke has to be traced separately, and the model does not say that.
LC3. This measurement compares the two causes with the same model value. The correctness of the argument passed when the delegate’s method is called was not measured separately.
Summary
- A delegate is the pattern in which an object hands part of its work to another object that conforms to a protocol.
- The delegate is held weakly so that no retain cycle forms; when the delegate is released, the weak reference becomes
niland the call is dropped. - A delegate that is not wired or has been released leaves the screen stale; once wired, the screen is matching.
- The weak reference becoming
nilwas actually measured in this lesson. - Not being wired and a method that writes nothing give the same stale result; the rule does not read who forgot.
- This lesson’s agreement: stale.
Next Step
The delegate builds the link between objects; drawing determines what a view writes to its own surface. In the next lesson it will be measured that a view drawn with draw(_:) keeps showing the old value when no redraw is requested.
To keep your progress and take notes, Log in
My notes
Log in to take notes.