Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

23. Closures capture variables, not scopes

  • Status: Accepted
  • Date: 2026-09-04

Context

The tree-walking interpreter gives a closure a reference to the whole Environment it was defined in. That is one pointer, it is trivially correct, and it makes the closure keep alive every binding of every enclosing scope for as long as it lives, reachable only by walking a chain of hash maps at each use.

The bytecode VM cannot work that way, because the point of compiling is that a local variable is an index into a frame rather than a name in a map. But a frame is gone once its call returns, and a closure can outlive the call that made it:

fn counter():
    n = 0
    fn tick():
        n = n + 1
        return n
    return tick

tick is still using n after counter has returned. Something has to move n somewhere that survives, without breaking the case where counter is still running and both it and tick must see the same n.

Decision

Closures capture individual variables, in shared cells, following the design in Crafting Interpreters' bytecode VM.

  • A closure is a compiled function plus a list of cells, one per variable it actually mentions from an enclosing function. It does not capture anything it does not use.
  • A cell is Open(slot) or Closed(value). While the owning frame is alive the cell is Open and reads and writes go straight to that frame's live local slot, so the closure and the frame see each other's changes. When the frame goes away the value is copied into the cell, which becomes Closed.
  • One cell per slot. Two closures capturing the same variable get the same cell, so a mutation through one is visible through the other. This is what makes a get/set pair over one variable work.
  • The compiler records, for each capture, whether it comes from the enclosing frame's locals or from the enclosing closure's captures. A closure three levels deep reaches a variable at the top by each level forwarding it outwards.
  • Cells close whenever a frame goes away, not only when it returns. An exception unwinding past a frame discards it just as surely as a return does, and a cell left Open after that would point at a slot the VM has since reused.

The interpreter is not changed. It keeps capturing environments, which is part of why it is worth keeping as an oracle (ADR 0024): two genuinely different closure implementations that agree are evidence, where two copies of one implementation would not be.

Consequences

  • A closure keeps alive exactly the variables it uses. The interpreter keeps alive every binding in scope, so the VM is also the better-behaved engine for a long-lived callback.
  • Reading a captured variable is a pointer hop, not a chain of map lookups.
  • Loop variables behave as they always have: Korrin's for variable is one binding reused each iteration, not one per iteration (ADR 0011), so closures made in a loop all see its final value. Under the VM this is not a special case, it falls out of there being one slot. It is pinned by a test either way, because it is the kind of thing an "improvement" could silently change.
  • The "close on every frame exit" rule is the part most likely to be got wrong, since the returning path is the one you naturally write first and the unwinding path is the one you only notice when a closure that escaped a raise starts reading someone else's data. There is a differential test for exactly that shape.
  • Value now has two closure representations: Function for the interpreter and Closure for the VM. Both report "function" to type(), and no program can hold both, since only one engine runs it.
  • Cells are Rc<RefCell<..>>, so a closure that captures a variable holding the closure itself is a reference cycle and will not be freed. Korrin has no garbage collector, and this is the same leak the interpreter already has with a self-referential environment.

Alternatives considered

  • Capture the environment, as the interpreter does. Correct, and it would make the two engines agree by construction, but then locals cannot be slots and the VM is a tree-walker with extra steps.
  • Copy captured values into the closure at creation time. Simple, no cells, no closing. It also silently breaks every closure that mutates what it captured, which ADR 0011 explicitly promises works.
  • Put every captured variable in a heap cell from the start, skipping the open/closed distinction. Simpler, and it costs an allocation per captured variable per call even when nothing escapes, which is the common case.
  • Close cells only on return, treating unwinding as rare enough to ignore. Rejected: "rare" and "impossible" are different, and the failure is silent data corruption rather than a crash.