Lesson 21 / 24
Capturing in Closures
Measures that a closure stored in an object's own property blocks deinit when it captures self, that a weak self capture list resolves it, and that a capture list is evaluated once when the closure is created.
Published Updated
In this lesson
- Reads from the deinit output that a closure stored in a property builds a retain cycle when it captures self strongly
- Breaks the cycle with a weak self capture list and observes the closure run empty after the object is gone
- Measures that a capture list is evaluated once when the closure is created, while a variable captured without a list is read with its current value on every call
- Distinguishes that a closure that is not stored does not build a cycle when it uses self
Contents
The previous lesson built a retain cycle between two class properties. The same cycle can be built through a closure that a class stores in its own property, and in practice this is the form met most often. A closure takes the outside values it uses in its body into itself; this is called capturing. If the body of the closure uses self, the closure captures the instance with a strong reference. If the instance also holds the closure in a property, the two sides hold each other.
This lesson builds the cycle once, breaks it with a [weak self] capture list, and then measures another property of the capture list: the expression inside the list is evaluated not on every call of the closure but once, while the closure is being created. At the end comes a measurement that draws the limit of the question: not every closure builds a cycle.
// 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)")
}
}
When the Closure Holds the Instance
The Button class stores an optional closure named onTap. The install method assigns to this property a closure whose body reads self.name. In each round a Button is built, its closure is installed, it is called once, and the name is released.
// main.swift — a closure that captures self strongly blocks deinit
final class Button {
let name: String
let owner: Probe
var onTap: (() -> Void)?
init(_ name: String, owner: Probe) {
self.name = name
self.owner = owner
}
func install() {
onTap = { print("tapped \(self.name)") }
}
deinit {
print("deinit button \(name)")
_ = owner.eval(name)
}
}
let trapped = Probe("closure-cycle")
for round in 1...2 {
var button: Button? = Button("button-\(round)", owner: trapped)
button?.install()
button?.onTap?()
_ = trapped.use { button = nil }
print("released \(round): \(trapped.evaluations) deinit")
}
trapped.report()
tapped button-1 released 1: 0 deinit tapped button-2 released 2: 0 deinit closure-cycle: 0 evaluations, 2 uses -> never
The closure was called and the name was written, but after the release no deinit line ever appears. Button holds the closure through onTap, and the closure holds Button through self. When the outside name is gone, the counter drops to one, not to zero. The rule of the previous lesson holds here too: closure-cycle records two uses and zero evaluations, and its moment is never.
Breaking It with Weak Self
The fix is to put a capture list at the head of the closure. The [weak self] list gives the closure a weak copy of self; the closure no longer holds the instance, but once the instance is gone, self becomes optional and can be nil. The body therefore first checks with guard let self whether the instance is still alive. The measurement calls the closure twice: before the name is released and after. For the call after the release, the closure is saved in a separate variable.
// main.swift — [weak self] gets ahead of the cycle
final class Button {
let name: String
let owner: Probe
var onTap: (() -> Void)?
init(_ name: String, owner: Probe) {
self.name = name
self.owner = owner
}
func install() {
onTap = { [weak self] in
guard let self else {
print("button gone")
return
}
print("tapped \(self.name)")
}
}
deinit {
print("deinit button \(name)")
_ = owner.eval(name)
}
}
let broken = Probe("weak-capture")
for round in 1...2 {
var button: Button? = Button("button-\(round)", owner: broken)
button?.install()
let saved = button?.onTap
saved?()
_ = broken.use { button = nil }
saved?()
print("released \(round): \(broken.evaluations) deinit")
}
broken.report()
tapped button-1 deinit button button-1 button gone released 1: 1 deinit tapped button-2 deinit button button-2 button gone released 2: 2 deinit weak-capture: 2 evaluations, 2 uses -> on-every-use
This time the deinit line appears in every round right after the release. The closure did not hold Button, so when the outside name was gone, the counter dropped to zero. After the release the same closure can still be called, but guard let self fails and button gone is written. The closure now runs empty: the instance is gone, the closure remains. weak-capture records two uses and two evaluations; over the two rounds a deinit runs at every use, and its moment is on-every-use.
When a Capture List Is Evaluated
[weak self] is only one use of the capture list. A list can also hand the closure a value: each entry written in the form [snapshot = expression] is evaluated once, while the closure is being created, and becomes a fixed copy inside the closure. A variable captured without a list, on the other hand, is read with its current value on every call of the closure. The measurement below shows this difference with two Probe objects. First both closures are created while level is 1, then level becomes 5, and then each closure is called twice.
// main.swift — a capture list at creation, an unlisted read on every call
let listed = Probe("capture-list")
let live = Probe("no-capture-list")
do {
var level = 1
let withList = { [snapshot = listed.eval(level)] in snapshot }
let withoutList = { live.eval(level) }
level = 5
print("listed: \(listed.use { withList() }) \(listed.use { withList() })")
print("unlisted: \(live.use { withoutList() }) \(live.use { withoutList() })")
}
listed.report()
live.report()
listed: 1 1 unlisted: 5 5 capture-list: 1 evaluations, 2 uses -> immediately no-capture-list: 2 evaluations, 2 uses -> on-every-use
The closure with the list returns 1 in both calls: the copy was taken while level was still 1, and the later assignment did not change it. The closure without the list returns 5 in both calls, because it reads the variable again on every call. The Probe output records these as two different moments. capture-list records a single evaluation, and because that evaluation happened before the first use, its moment is immediately. no-capture-list records two evaluations, so it is on-every-use. This lesson’s moment is immediately: the expression in the capture list is evaluated once, at the point where the closure is created.
Two Closures Share a Variable
Capturing is often not the copying of a value but the sharing of a variable. Two closures that capture the same local variable look at the same box of that variable. This measurement builds an incrementing closure and a reading closure that capture a total variable. The incrementer makes an evaluation through Probe on every call, and the question is whether the reader sees the change made by the incrementer.
// main.swift — two closures capture the same variable
let shared = Probe("shared-variable")
do {
var total = 0
let add = { total += shared.eval(10) }
let read = { total }
print("start \(read())")
shared.use { add() }
print("first increase \(read())")
shared.use { add() }
print("second increase \(read())")
}
shared.report()
start 0 first increase 10 second increase 20 shared-variable: 2 evaluations, 2 uses -> on-every-use
The read closure sees the change made by the incrementer: start 0, then first increase 10 and second increase 20. If the two closures held separate copies of the value of total, the reader would always give 0. The closures look at the same box, and every call that changes the box is visible to the other. shared-variable counts two evaluations in two uses and says on-every-use. The capture list of the previous section cuts this sharing: a closure written with [total] takes the value at that moment, not the variable. Sharing is the default; copying is a behavior that has to be asked for explicitly.
Escaping and Non-Escaping Closures
A closure can outlive the function that receives it. If it will, its parameter is marked @escaping; an unmarked closure has to finish before the function returns. These two forms are measured with Probe. The non-escaping closure is called immediately in the run function. The escaping closure is added to an array in the store function and is called only later. After two store calls, and before the closures are called, later.evaluations is read.
// main.swift — non-escaping evaluates now, escaping evaluates later
let now = Probe("non-escaping")
let later = Probe("escaping")
do {
var pending: [() -> Int] = []
func run(_ work: () -> Int) -> Int {
work()
}
func store(_ work: @escaping () -> Int) {
pending.append(work)
}
for round in 1...2 {
_ = now.use { run { now.eval(round) } }
store { later.eval(round) }
print("round \(round): non-escaping \(now.evaluations), escaping \(later.evaluations)")
}
for work in pending {
_ = later.use { work() }
}
print("escaping after calls \(later.evaluations)")
}
now.report()
later.report()
round 1: non-escaping 1, escaping 0 round 2: non-escaping 2, escaping 0 escaping after calls 2 non-escaping: 2 evaluations, 2 uses -> on-every-use escaping: 2 evaluations, 2 uses -> on-every-use
In each round the non-escaping counter rises before run returns: 1 in the first round, 2 in the second. The escaping counter, on the other hand, is still 0 after both store calls, because store only adds the closure to the array and does not call it. The closures are called only in the next loop, and the counter becomes escaping after calls 2. Both Probe objects say on-every-use, but the place of the evaluation differs: the evaluation of the non-escaping closure is inside the call of the function that receives it. The evaluation of the escaping closure can come long after the line where the closure was created. This difference also explains why a closure forces self to be written explicitly: an escaping closure can outlive the instance it captures. A non-escaping closure can never hold the instance, because it ends when the function returns.
The Strong Temporary Copy
The guard let self line does not only check whether self is nil; it also sets up a strong temporary reference that lives through the body. This reference holds the instance until the closure ends. The measurement tests it as follows: the closure of a Job instance calls a function in the middle of its body that releases the outside name. Does deinit run at the release line, or after the body finishes?
// main.swift — guard let self holds the instance through the body
final class Job {
let name: String
let owner: Probe
var run: (() -> Void)?
init(_ name: String, owner: Probe) {
self.name = name
self.owner = owner
}
func install(release: @escaping () -> Void) {
run = { [weak self] in
guard let self else {
print("job gone")
return
}
print("body started \(self.name)")
release()
print("body ending \(self.name)")
}
}
deinit {
print("deinit job \(name)")
_ = owner.eval(name)
}
}
let held = Probe("temporary-strong")
for round in 1...2 {
var job: Job? = Job("job-\(round)", owner: held)
job?.install { job = nil }
let saved = job?.run
_ = held.use { saved?() }
print("closure returned: \(held.evaluations) deinit")
}
held.report()
body started job-1 body ending job-1 deinit job job-1 closure returned: 1 deinit body started job-2 body ending job-2 deinit job job-2 closure returned: 2 deinit temporary-strong: 2 evaluations, 2 uses -> on-every-use
In each round release() is called between the lines body started and body ending, and the outside job name becomes nil. Even so, the line deinit job job-1 comes after the line body ending job-1. The temporary strong reference set up by guard let self keeps the instance up until the body ends; the last reference is released only as the closure returns. The line closure returned: 1 deinit confirms it: at the moment the closure returns, the counter is 1. temporary-strong counts two evaluations in two rounds and says on-every-use. The practical consequence runs in two directions. [weak self] keeps the closure from holding the instance permanently, but the instance does not disappear while the body is running. The work done in the body does not have to be split for fear that the instance will vanish.
The Bounding Measurement
Not every closure builds a cycle. For a cycle, the closure has to be stored in the instance. A closure that is created inside a method and finishes before the method returns, for example a closure given to a transformation of an array, is stored nowhere, and its capturing self does not hold the instance. The measurement shows this in a Report instance: the joined method reads the name property inside parts.map { ... }, the result is returned, and the instance is released.
// main.swift — a closure that is not stored builds no cycle
final class Report {
let name: String
let owner: Probe
let parts = ["a", "b", "c"]
init(_ name: String, owner: Probe) {
self.name = name
self.owner = owner
}
func joined() -> String {
parts.map { "\($0)-\(name)" }.joined(separator: ",")
}
deinit {
print("deinit report \(name)")
_ = owner.eval(name)
}
}
let passing = Probe("temporary-closure")
for round in 1...2 {
var report: Report? = Report("report-\(round)", owner: passing)
print(report?.joined() ?? "none")
_ = passing.use { report = nil }
print("released \(round): \(passing.evaluations) deinit")
}
passing.report()
a-report-1,b-report-1,c-report-1 deinit report report-1 released 1: 1 deinit a-report-2,b-report-2,c-report-2 deinit report report-2 released 2: 2 deinit temporary-closure: 2 evaluations, 2 uses -> on-every-use
In each round deinit runs at the moment of the release. The map closure read self, but the closure ended the moment the method returned; no reference to it remained. The leak does not come from capturing itself but from the capturing closure continuing to live in the instance. The only place that needs a capture list is a stored closure.
MA3. Every measured class stores its closure in a single property. That several stored closures, or an object that keeps its closure in an outside list, follow the same rules was not measured in this lesson. Having a single closure is a design choice.
Summary
- If a closure stored in a property captures
selfstrongly, the instance and the closure hold each other anddeinitnever runs. - The
[weak self]capture list does not hold the instance; when the instance is gone,selfisnilandguard let selfmakes the closure run empty. [snapshot = expression]in a capture list is evaluated once while the closure is created; a variable captured without a list is read with its current value on every call.- A closure that is not stored, such as the ones inside
map, builds no cycle even when it usesself. - This lesson’s moment: immediately. The expression in the capture list is evaluated once, at the point where the closure is created.
Next Step
In this lesson the closure was a box that took a value or an instance into itself and stored it. Property wrappers do something similar for a property itself: they move how a value is stored and read to a separate type. The next lesson measures when the wrappedValue getter runs and when the initial value of the wrapper is set up.
To keep your progress and take notes, Log in
My notes
Log in to take notes.