skip to content

A captured local is reassigned by both the closure and its enclosing function — why can it not stay in the call frame?

level: middleimportance: must knowfreq 55%

answer

  1. two lifetimes, one name
  2. the frame can return first
  3. one shared cell, two holders
  4. both sides rewritten, not just one
  5. allocation plus indirection per access

basics

~20 s

Because two lifetimes now share one variable: the closure can outlive the call, and each side must see the other's writes. The variable is boxed — moved into a small cell both the frame and the closure hold — so exactly one copy exists.

solid answer

~40 s

A call frame is torn down when the call returns, but the closure may be invoked long afterwards, so a variable both of them write cannot live only in the frame. The usual implementation is **boxing**: the variable is moved into a one-slot container, the closure captures the container, and every read and write on *both* sides is rewritten to go through it. There is then one storage location and no possibility of the two sides drifting. The costs are an allocation, an extra indirection on each access, and the loss of the snapshot property — the closure now sees whatever the caller wrote last. A compiler can skip the box whenever it can prove the variable is never reassigned after capture, since a copy is then indistinguishable.

code

pseudocode · 19 lines
pseudocode
// what the programmer wrote
function makeCounter() {
    count = 0
    bump = function() { count = count + 1 }
    bump()
    bump()
    report(count)              // 2 - both sides share one variable
    return bump
}

// what the compiler builds
function makeCounter() {
    cell = box(0)                              // one slot, outlives the frame
    bump = function() { cell.slot = cell.slot + 1 }
    bump()
    bump()
    report(cell.slot)                          // the enclosing read is rewritten too
    return bump
}

go deeper

for a junior

Remember that a closure can outlive the call that created it, so a variable it shares with that call cannot simply live in the frame.

for a middle

Describe boxing precisely: one cell, both sides rewritten to use it, an allocation and an indirection as the price, and the snapshot given up in exchange.

for a senior

Judge when the cost matters — a capture in a hot loop, a cell allocated per call — and know which cases a compiler can legally optimise away.

for a principal

Set the convention: where shared mutable state is allowed to hide inside a capture at all, versus where it must be declared as a container someone can see and reason about.

## Two lifetimes, one name A local variable normally lives in the **call frame** of the function that declared it, and that frame is gone once the function returns. A closure created inside that function may be stored, queued or registered, and invoked much later. So the moment a closure captures a local *and* either side can reassign it, the language faces a conflict it must resolve at compile time: - the variable must survive the frame, because the closure can read or write it afterwards; - there must be **one** location, not two, because a write on either side has to be visible to the other; - the source text still looks like an ordinary local, so whatever is done must be invisible to the programmer. ## Boxing: one cell, two holders The standard answer is to **box** the variable. The compiler synthesises a small container with a single slot, initialises it with the variable's initial value, and then rewrites the program: - the enclosing function's declaration becomes the creation of the cell; - every read of the name in the enclosing body becomes a read of the cell's slot; - every write in the enclosing body becomes a write to that slot; - the closure captures **the cell** — by value, as it happens, because copying a handle to the cell is harmless — and its own reads and writes go to the same slot. The key point, and the one candidates most often miss, is that the rewrite applies to **both** sides. It is not "the closure reads through a box while the function keeps its plain local"; that arrangement would let the two drift apart, which is exactly what boxing exists to prevent. | | Copy of the value | Boxed shared cell | |---|---|---| | Locations after capture | two independent slots | one slot, two holders | | Enclosing write seen inside | no | yes | | Closure write seen outside | no | yes | | Extra cost | one copy | an allocation plus an indirection per access | | Survives the frame returning | the copy does | the cell does | ## What it costs Boxing is cheap but not free, and the costs are worth naming: - **Allocation.** The cell is an object with a lifetime independent of the frame. If the compiler cannot prove the closure stays within the call, the cell must live somewhere the frame's teardown does not reach. - **Indirection.** Each access is a load of the cell followed by a load of the slot, which also blocks some optimisations that assume a local cannot change between two reads. - **Loss of the snapshot.** Once boxed, the variable is by definition shared. A reader inside the closure sees the latest write, not the value at capture — which is the behaviour you wanted, but it means there is no snapshot left to fall back on. - **It says nothing about safety under concurrent access.** A shared cell guarantees that both sides address one location; what a second thread observes when they write at the same time is a separate subject with its own rules. ## When the box can be skipped A compiler is free to avoid the cell whenever the observable behaviour is identical: 1. **The variable is never reassigned after capture.** Then a copy and a shared cell are indistinguishable, and copying is cheaper. This is also why some languages simply forbid capturing anything reassignable: it makes the optimisation the only case. 2. **The closure provably does not escape.** If the closure is called and discarded within the enclosing call, the cell can be kept in the frame, keeping the indirection but losing the allocation. 3. **Only one side ever writes and the other never reads after that write.** This is harder to prove and rarely worth it, but it is the same argument: if no observer can tell, the compiler may choose the cheap layout. ## How to talk about it in an interview Start from the conflict — a frame that dies and a function value that does not — and the mechanism follows. Then make the two claims that show you understand rather than remember: that the enclosing function's own accesses are rewritten too, and that the box is an implementation of *sharing*, not of safety. Being able to say what the compiler could legally skip, and why, is what separates an answer about the mechanism from an answer about one compiler's output.

  • When may a compiler leave a captured local unboxed?
    When nothing reassigns it after capture, because a copy then behaves identically and costs less. It may also keep the cell inside the frame when it can prove the closure never outlives the call, which removes the allocation while keeping the shared slot.
  • Does sharing one cell make it safe to bump the counter from two execution contexts at once?
    No. Boxing only guarantees that both sides address one location; it says nothing about interleaved reads and writes or about when one context observes another's write. That is a separate subject, and treating a boxed variable as synchronised is a common and expensive mistake.
  • What becomes of the enclosing function's own local after boxing?
    It stops existing as a slot in the frame. Every read and write of that name in the enclosing body is rewritten to go through the cell, which is precisely why the two sides cannot disagree about the current value.

Instead of each person keeping a private notepad, both agree to write on one shared whiteboard. Neither can be out of date — though nothing about the whiteboard stops two hands writing at once.

saying these in an interview costs you the question

  • The closure keeps a pointer into the caller's stack frame.
  • Boxing copies the value, so the two sides drift apart.
  • Only the closure's accesses change; the function keeps its plain local.
  • Boxing is just a rename and costs nothing.
  • A boxed variable is safe to update from several threads.