Where does a captured variable live: on the stack or in a heap box, and what decides it?

5.9.2 · L5 · Learning

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 @escaping to 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… →