Where does a captured variable live: on the stack or in a heap box, and what decides it?
Predict first
For a non-escaping closure capturing var n, what does the SIL show?
import Swift
@inline(never) func run(_ f: () -> Int) -> Int { f() }
func local() -> Int { var n = 0; let r = run { n += 1; return n }; return r + n }
- alloc_stack for n and partial_apply [on_stack]; no alloc_box
- alloc_box for n
- a global
- nothing: closures are never allocated
Predict first
And for an escaping closure capturing var n?
import Swift
@inline(never) func store(_ f: @escaping () -> Int) -> () -> Int { f }
func escaping() -> () -> Int { var n = 0; return store { n += 1; return n } }
- alloc_box for n; partial_apply keeps it alive
- alloc_stack with on_stack
- no capture at all
- a copy of n
Mental model. A non-escaping closure borrows a note from your own desk and hands it back before you leave. An escaping one photocopies the note into a folder that others hold, so the folder has to be kept alive and counted.
A closure is a function pointer plus a context holding what it captured. For a non-escaping closure the compiler knows the closure cannot outlive the call, so the captured variable can stay in the caller’s stack frame: the SIL shows an alloc_stack for n and a partial_apply [callee_guaranteed] [on_stack], with no heap allocation. For an escaping closure that captures a mutable variable, the variable must survive the function, so it is moved into a reference-counted box on the heap (alloc_box) and the closure retains it. That box is where the “closure keeps n alive” behaviour, and the cost of capturing, comes from.
So @escaping has a performance meaning as well as a lifetime one: stack context, no ARC on the capture, and often inlining, versus heap box and retain/release. Capturing a let value copies it into the context (no shared box); capturing self of a class adds a strong reference, the usual source of retain cycles. At -O a non-escaping closure passed to a function that is then specialised can disappear entirely, as the specialised run in my output showed. I looked at -Onone SIL for the allocations and at -O only to confirm the stack case stays on the stack.
The capture stays in the caller’s frame.
The variable moves to a heap box so the closure can outlive the call.
Common traps
- Assuming every closure allocates. Non-escaping ones usually do not.
- Capturing a mutable local in an escaping closure and expecting a copy. It shares one box; mutations are visible to every holder.
They might follow up with
- What does a capture list change?
[n]copies the value at creation, so no shared box. - Why does the compiler force
@escapingto be written? So the lifetime and self-capture rules are explicit at the declaration.
Related questions
- 5.1.4 How do you read SIL to tell static dispatch from dynamic dispatch?
- 5.3.2 How does an unspecialised generic function know the concrete type, and what does that cost?
- 3.2.1 What is the difference between strong, weak, unowned and unowned(unsafe)?
- 27.3.3 A screen stores a DispatchWorkItem whose closure uses self. Does cancelling the item fix the leak, and what…
← What do -Onone, -O, -Osize and -Ounchecked each change, and which… What do @guaranteed and @owned mean in SIL, which does Swift use by… →