Skip to content

04 · Closures Advanced

Level 1's closure lesson covered syntax and basic capturing. Now the harder question: what exactly does a closure capture, and how long does it live? Get this wrong and you get retain cycles (memory leaks) or closures that unexpectedly hold onto stale data — two of the most common intermediate-level Swift bugs.

Capture by reference, not by value

By default, a closure captures variables by reference — it doesn't freeze the value at closure-creation time, it keeps a live link to the variable itself:

var counter = 0

let incrementAndPrint = {
    counter += 1
    print("Counter is now \(counter)")
}

incrementAndPrint()   // Counter is now 1
counter = 100          // mutated from OUTSIDE the closure
incrementAndPrint()   // Counter is now 101 -- sees the external change

If closures captured by value, the second call would print 2, not 101. This "live reference" behavior is exactly what powers patterns like makeIncrementer from Level 1.

Capture lists: freezing a value at creation time

Sometimes you want the value as it was when the closure was created, not a live reference. A capture list — square brackets right after { — does that:

var score = 10

let liveClosure = {
    print("Live: \(score)")
}

let frozenClosure = { [score] in
    print("Frozen: \(score)")   // "score" here is a new constant, captured at creation
}

score = 999

liveClosure()     // Live: 999
frozenClosure()   // Frozen: 10

[score] creates a new constant named score inside the closure, initialized to the current value at the moment the closure is created — later changes to the outer score don't affect it. This is invaluable for capturing loop variables or state you want "snapshotted."

@escaping vs non-escaping closures

A closure parameter is non-escaping by default: it's guaranteed to be called before the function returns, so Swift lets the compiler make optimizations and doesn't require special handling of self. An escaping closure is stored somewhere and called after the function has already returned — for example, a completion handler kept around for a network callback:

// Non-escaping (default): the closure is used and done before the function returns
func performTwice(_ action: () -> Void) {
    action()
    action()
}

// Escaping: the closure outlives the function call
var savedCompletions: [() -> Void] = []

func performLater(_ action: @escaping () -> Void) {
    savedCompletions.append(action)   // stored for later -- must be @escaping
}

performLater { print("Called later!") }
print("performLater returned, nothing printed yet")
savedCompletions.forEach { $0() }
// performLater returned, nothing printed yet
// Called later!

Without @escaping, the line savedCompletions.append(action) wouldn't compile — the compiler can prove the closure needs to survive past the function's return, and it forces you to acknowledge that explicitly.

The retain cycle trap

Classes are reference types managed by ARC (Automatic Reference Counting): an instance is deallocated once nothing holds a strong reference to it. An escaping closure that captures self strongly, while self also holds onto that closure, creates a cycle — neither can ever reach zero references, so neither is ever freed:

class DataLoader {
    var onComplete: (() -> Void)?
    let name: String

    init(name: String) {
        self.name = name
    }

    func startLoading() {
        // BUG: this closure captures "self" strongly, and self.onComplete
        // holds the closure -- a retain cycle. DataLoader can never deinit.
        onComplete = {
            print("\(self.name) finished loading")
        }
    }

    deinit {
        print("\(name) deinitialized")
    }
}

// var loader: DataLoader? = DataLoader(name: "Loader A")
// loader?.startLoading()
// loader = nil
// -- "Loader A deinitialized" never prints: the closure keeps it alive forever

Breaking the cycle with [weak self]

Adding self to the capture list as weak breaks the strong reference — the closure holds a weak (optional) link that becomes nil automatically if the object is deallocated elsewhere:

class FixedDataLoader {
    var onComplete: (() -> Void)?
    let name: String

    init(name: String) {
        self.name = name
    }

    func startLoading() {
        onComplete = { [weak self] in
            // self is now Self? -- must be unwrapped
            guard let self = self else {
                print("Loader was already deallocated")
                return
            }
            print("\(self.name) finished loading")
        }
    }

    deinit {
        print("\(name) deinitialized")
    }
}

var loader: FixedDataLoader? = FixedDataLoader(name: "Loader B")
loader?.startLoading()
loader?.onComplete?()
loader = nil
// Loader B finished loading
// Loader B deinitialized

guard let self = self else { return } is the standard pattern: it temporarily creates a strong reference for the duration of the closure body (so self doesn't vanish mid-execution) without holding one permanently between calls.

[unowned self]: the sharper alternative

unowned also avoids the retain cycle, but — unlike weak — it's not optional, and it crashes if you access it after the object is gone. Use it only when you're certain the closure can never outlive self:

class Repeater {
    let name: String
    init(name: String) { self.name = name }

    lazy var greet: () -> Void = { [unowned self] in
        print("Hi from \(self.name)")   // no unwrapping needed, but unsafe if self is gone
    }
}

let repeater = Repeater(name: "R2")
repeater.greet()   // Hi from R2

[weak self] is the safer default; reach for [unowned self] only for tightly coupled closures (like a lazy property referencing its own instance) where you can prove the lifetimes are locked together.

Cheat sheet

Capture style Syntax Behavior
Default (by reference) { ... } Live link to the variable; sees later mutations
Capture list, by value { [x] in ... } Snapshots x's value at closure creation
Weak capture { [weak self] in ... } Optional reference; nil if the object is gone; safest for escaping closures
Unowned capture { [unowned self] in ... } Non-optional reference; crashes if accessed after deallocation
Non-escaping param (default) (() -> Void) Must be called before the function returns
Escaping param (@escaping () -> Void) Can be stored and called after the function returns

How It Actually Works

  • @escaping closures stored in properties retain their captured context — if that context includes self (a class instance), the closure holds a strong reference, and the object holds a strong reference to the closure (through the property) — a genuine retain cycle, which is why [weak self] matters mechanically, not just stylistically: it tells the compiler to generate a capture that stores self as an Unmanaged/weak reference (via a side table entry, see the ARC chapter) rather than a strong one, so the object graph doesn't keep itself alive forever.
  • Autoclosures (@autoclosure) don't change what a closure is — they change what the call site looks like. func assert(_ condition: @autoclosure () -> Bool) causes the compiler to implicitly wrap whatever expression you pass in a zero-argument closure at the call site, which is why assert(x > 0) reads like a plain boolean expression but is actually lazily evaluated — the expression x > 0 is never computed unless assert's body actually calls the closure.
  • Closures as function parameters get specialized per call site when possible: if the compiler can see the concrete closure passed at a call site (no dynamic dispatch involved), it can inline the closure body directly into the caller, eliminating the function-pointer indirection entirely — this is why higher-order functions like map/filter/reduce used with closure literals are typically just as fast as a hand-written loop after optimization, despite looking more "functional."
  • Capture lists ([x = someExpr]) evaluate the right-hand expression immediately, at closure-creation time, and store the result in the closure's context — distinct from referencing x directly, which captures a reference to the variable (and sees later mutations).

Exercise

Build a small class Timer with a stored onFire: (() -> Void)? and a method simulateFire() that just calls onFire?(). Create a second class, class Widget, with a name: String and a method startTimer(_ timer: Timer) that sets timer.onFire to a closure printing "\(name) fired". Do it first with a plain (strongly capturing) closure and add a deinit to Widget that prints when it's deallocated — set the widget to nil and observe that deinit never runs. Then fix it with [weak self] and confirm deinit now prints as expected.