Closures

Three concepts: lexical capture, escaping variables, and capture analysis.

This is the conceptual peak of the curriculum. It is also the place where the most languages have made a decision their users find surprising — JavaScript's var, Python's late binding, Go's loop variable before 1.22 — which makes it unusually good material for learning to reason about semantics rather than memorize them.

Read this chapter, then upvalues, then do the lab. Do not try to learn both at once.


Concept 1: Capturing the Variable, Not the Value

1. Concept

A closure is a function plus the environment it captured. When a function body mentions a name that is neither a parameter, nor one of its own locals, nor a global, that name refers to a variable in an enclosing function — and the closure must keep it alive.

2. Problem

function make_counter()
  local count = 0
  local function inc() count = count + 1; return count end
  local function get() return count end
  return inc, get
end

local inc, get = make_counter()
print(inc(), inc(), inc())   -- 1  2  3
print(get())                 -- 3   ← THE SAME count

count was a stack slot in make_counter's frame, and make_counter has returned. The frame is gone. Two separate functions still read and write one variable.

Two things must be true, and they pull in different directions:

  1. The value must survive the frame's death.
  2. inc and get must share it, not each get a copy.

3. Mental model

A closure captures the variable, not its value. Think of the variable as a small box. The closure holds a reference to the box, not a copy of what is inside it. Two closures over the same variable hold references to the same box.

   DURING make_counter()                 AFTER make_counter() RETURNS

   stack                                 stack
   ┌───────────┐                         ┌───────────┐
   │ count = 0 │◀──┐                     │  (gone)   │
   └───────────┘   │                     └───────────┘
                   │ both closures
   ┌──────┐        │ reference the       heap
   │ inc  │────────┤ same VARIABLE       ┌──────────────┐
   └──────┘        │                     │ box: count=3 │◀──┬── inc
   ┌──────┐        │                     └──────────────┘   │
   │ get  │────────┘                                        └── get
   └──────┘                              the box MOVED; both still see it

That box is an upvalue, and the movement is called closing it. The next chapter is entirely about that transition.

4. Implementation

Two structures, and the split matters:

#![allow(unused)]
fn main() {
/// The IMMUTABLE, SHARED, compiled function. One per `function` expression in
/// the source, regardless of how many times it is evaluated.
pub struct Proto {
    pub name: Option<String>,
    pub nparams: u8,
    pub is_vararg: bool,
    pub max_stack: u16,
    pub chunk: Chunk,
    pub upvals: Vec<UpvalDesc>,     // HOW to capture, decided at compile time
    pub local_names: Vec<LocalDebug>,
}

/// One per EVALUATION of that expression. Holds the captured boxes.
pub struct Closure {
    pub proto: Rc<Proto>,
    pub upvals: Vec<GcRef<Upvalue>>,   // the boxes themselves
}

/// Compile-time instruction: where does upvalue `i` come from?
pub struct UpvalDesc {
    pub from_parent_local: bool,   // true → parent's stack slot; false → parent's upvalue
    pub index: u8,
    pub name: String,              // debug only
}
}

Proto is shared; Closure is per-evaluation. That distinction is why for i = 1, 3 do fs[i] = function() return i end end creates three closures over one Proto, and why each must capture a different i.

At run time, CLOSURE p does this:

#![allow(unused)]
fn main() {
Op::Closure(p) => {
    let proto = self.chunk().protos[p as usize].clone();
    let mut ups = Vec::with_capacity(proto.upvals.len());
    for d in &proto.upvals {
        ups.push(if d.from_parent_local {
            // Capture a slot of the CURRENT frame. find_or_create is what makes
            // two closures over the same slot SHARE one box — see upvalues.md.
            self.find_or_create_open_upvalue(self.frame().base + d.index as usize)
        } else {
            // Capture something the CURRENT closure already captured.
            self.current_closure().upvals[d.index as usize]
        });
    }
    let c = self.heap.alloc_closure(proto, ups)?;
    self.push(Value::Closure(c));
}
}

Twelve lines. All the difficulty is in find_or_create_open_upvalue and in the compiler pass that filled in proto.upvals.

5. Alternatives

OptionWhat is capturedConsequence for the counter example
A. By variable / by reference (ours, Lua, JS, Python, Ruby, Scheme)a box1 2 3 then 3 — sharing works
B. By value, at closure creationa copy1 1 1 and get() returns 0 — the example breaks
C. Programmer chooses per captureeitherC++ ([=] vs [&]), and [&] gives you dangling references it will not check
D. No closures; explicit environment argumentnothingC. The caller passes a struct. Honest, verbose, and what closures desugar to

Option D is worth holding in your head, because it is exactly what closure conversion does: a closure is a function pointer plus a struct of captured variables, and every implementation of A is some flavor of D with the struct built for you.

6. Decision

A. There is no real choice — the counter example is the canonical behavior and every language in Lua's family has it.

What is a decision is where the box lives, and that is the next chapter. Ember takes Lua's answer (open/closed upvalues) after first implementing the naive one, so that the optimization is a diff you can read.

7. Tradeoffs

We gainWe lose
Shared mutable state between closures, which is the featureCaptured variables cannot live purely on the stack
Natural, expected semanticsA heap object and a GC edge per captured variable
Iterators, callbacks, and object-like patterns become expressibleCycles become trivially constructible — closure → upvalue → closure

That last row is the one to sit with. Closures are why Ember needs a tracing collector, not tables. A table cycle takes a deliberate t.self = t; a closure cycle takes a recursive local function, which people write by accident every day:

local function loop(n)
  if n == 0 then return end
  return loop(n - 1)         -- the closure captures a box holding... itself
end

8. Production concerns

The loop-variable question, which every language has gotten wrong at least once:

local fs = {}
for i = 1, 3 do fs[i] = function() return i end end
print(fs[1](), fs[2](), fs[3]())   -- Lua: 1  2  3
LanguageResultWhy
Lua, Ember1 2 3The for variable is a fresh binding per iteration
JavaScript with let1 2 3Same — let was defined to do this
JavaScript with var3 3 3One function-scoped binding, shared
Python3 3 3One binding; closures capture it, not its value ("late binding")
Go before 1.223 3 3One binding per loop
Go 1.22+1 2 3They changed the language, because the old behavior caused real bugs

Go changing a fifteen-year-old semantic in a language that promises compatibility tells you how costly the wrong answer is. This is not a closure decision; it is a scope decision, made in Lab 6 when the numeric for creates its binding. If you got that right there, you get this for free here.

Other concerns:

  • Captured variables are GC roots' targets, not roots. A closure holds GcRef<Upvalue>; the upvalue holds a Value. Both edges must be traced. Forgetting the second is a missing-edge bug whose symptom is a captured value turning to garbage mid-call.
  • A closure keeps its captures alive. A callback registered once and never dropped keeps every variable it captured, transitively. In a long-lived Engine that is a leak with no cycle in it — the collector is behaving correctly, and the program is wrong. Section 5's --stats heap census is how a host finds it.
  • Recursion via closures makes cycles. See above. Any design that "just uses Rc" leaks here.

9. References

rg -n 'OP_CLOSURE|pushclosure' lvm.c lparser.c
rg -n 'LClosure|UpVal|Proto' lobject.h
  • Lua's lobject.h: Proto, LClosure, UpVal. Note that LClosure has a flexible array member of UpVal* — flat closures, exactly as above.
  • The Implementation of Lua 5.0, §5 ("Closures") — the design and its rationale in two pages.
  • The Go 1.22 release notes on the loop variable change, and the accompanying design document. It is the best available case study of a closure-capture decision judged in retrospect.
  • Crafting Interpreters, chapter 25 — the same mechanism in C, with excellent diagrams.

Concept 2: Escaping Variables

1. Concept

A local variable escapes if a closure created inside its scope outlives it. Escaping variables cannot live only on the stack.

2. Problem

The stack's whole discipline is that frames die in reverse order of creation. A closure that outlives its defining frame violates it. Something has to move — and you would like to move as little as possible, because heap allocation is the expensive thing.

3. Mental model

Most locals never escape. Ideally, only the ones that do should pay. Escape analysis is the compiler pass that decides — and in Ember, the compiler already knows, because it performed the capture analysis that produced proto.upvals.

   function outer()
     local a = 1        ← never mentioned inside a nested function: STACK ONLY
     local b = 2        ← mentioned by `inner`:                     ESCAPES
     local function inner() return b end
     return inner
   end

4. Implementation

Two strategies, and Ember builds both:

StrategyWhen the box is allocatedCost
Naive (Lab 14, step 1)eagerly, at declaration, for every captured localOne allocation per captured variable per execution — even if the closure is never created
Lua-style (Lab 14, step 2)lazily: the variable lives on the stack until it must notZero allocations unless a closure is actually made, and none at all until the frame dies

The naive version is genuinely simpler: a captured local is GcRef<Upvalue> from birth, and GET_LOCAL on it dereferences. It is also correct, and you should build it first — then the open/closed design is a measurable optimization rather than a magic trick.

5–7. Alternatives, decision, tradeoffs

OptionWhere captured variables liveSystems
A. Everything on the heapevery local is a boxEarly Lisps, some Scheme implementations. Simple, uniformly slow
B. Escape analysis, boxes for escapeescaptured ones on the heap from the startThe naive Lab 14 step; also what many JVM/JS implementations do after inlining
C. Open/closed upvalues (ours, Lua)the stack until the frame dies, then the heapZero cost when the closure never escapes the frame's lifetime
D. Whole-environment capturea heap frame per call, chainedScheme's classic model; simple, and every variable access walks a chain

Decision: build B in Lab 14, then replace it with C and measure. Record both numbers.

Option D deserves a note because it is the "obvious" design and it is what a naive reading of "a closure captures its environment" suggests. Its cost is that variable access becomes a chain walk proportional to nesting depth — the same problem the tree walker's environment had, now permanent. Lua's flat closures pay a small cost at closure creation to make every access O(1).

8. Production concerns

  • Escape analysis is not free of judgement. A variable captured by a closure that provably does not escape the frame (called immediately, never stored) could stay on the stack. Real JITs do this; Ember does not, and says so.
  • The naive strategy allocates even when no closure is created. if rare_condition then return function() return x end end allocates a box for x on every call under B, and never under C. That asymmetry is the benchmark to run.

Concept 3: Capture Analysis in the Compiler

1. Concept

For each free name in a function body, the compiler must decide: local, upvalue, or global — and for an upvalue, which enclosing variable and how to reach it.

2. Problem

The decision requires information from enclosing functions, which the single-pass compiler is in the middle of. And discovering an upvalue retroactively marks the enclosing local as captured, which changes the code the enclosing function must emit at scope exit.

3. Mental model

Resolution walks outward: my locals, then my enclosing function's locals (creating an upvalue), then its enclosing function's locals (creating upvalues at every level in between), then globals. Each level that forwards a capture gains an upvalue of its own — capture is transitive, and the chain is built at compile time so that access is direct at run time.

   function a()
     local x = 1
     return function b()
       return function c()
         return x            ← c's upvalue[0] ── from b's upvalue[0] ── from a's local x
       end
     end
   end

   Proto a: locals [x]                     x is marked CAPTURED
   Proto b: upvals [{from_parent_local: true,  index: <slot of x>}]
   Proto c: upvals [{from_parent_local: false, index: 0}]   ← from b's upvalue 0

b gains an upvalue it never mentions. That is the part that surprises people: a function in the middle of a chain must carry a capture purely to forward it. Lua does exactly this, and you can see it: put that program in a file and run luac -l -l on it.

4. Implementation

#![allow(unused)]
fn main() {
impl Compiler<'_> {
    /// local → Some(GetLocal) | upvalue → Some(GetUpval) | neither → None (global)
    fn resolve(&mut self, name: &str) -> Option<Access> {
        if let Some(slot) = self.resolve_local(name) { return Some(Access::Local(slot)); }
        if let Some(idx) = self.resolve_upvalue(name) { return Some(Access::Upval(idx)); }
        None
    }

    fn resolve_upvalue(&mut self, name: &str) -> Option<u8> {
        let parent = self.parent.as_deref_mut()?;

        // Case 1: it is a LOCAL of my immediate parent. Mark it captured — the
        // parent must now emit CLOSE_UPVALS instead of a plain POP for it.
        if let Some(slot) = parent.resolve_local(name) {
            parent.mark_captured(slot);
            return Some(self.add_upvalue(UpvalDesc {
                from_parent_local: true, index: slot, name: name.into() }));
        }

        // Case 2: my parent can reach it (recursively). My parent gains an
        // upvalue too, even though its own body never mentions the name.
        let parent_idx = parent.resolve_upvalue(name)?;
        Some(self.add_upvalue(UpvalDesc {
            from_parent_local: false, index: parent_idx, name: name.into() }))
    }

    /// Upvalues are DEDUPLICATED per proto: two mentions of `x` in one function
    /// must produce ONE upvalue, or the two would be different boxes and
    /// assignment through one would be invisible through the other.
    fn add_upvalue(&mut self, d: UpvalDesc) -> u8 {
        if let Some(i) = self.upvals.iter().position(
            |e| e.from_parent_local == d.from_parent_local && e.index == d.index) {
            return i as u8;
        }
        self.upvals.push(d);
        (self.upvals.len() - 1) as u8
    }
}
}

Three details, each of which is a bug if omitted:

  1. mark_captured changes the enclosing function's code generation: at scope exit it must emit CLOSE_UPVALS for that slot instead of counting it into a plain POP. A retroactive change to already-planned code — which is fine here because the POP is emitted at end_scope, after all captures are known. Check that ordering in your own compiler.
  2. Deduplication in add_upvalue. Without it, function() x = x + 1 end creates two upvalue entries for x, pointing at the same slot but becoming different boxes when closed — and the write is then invisible to the read.
  3. The recursion in case 2 is what makes capture transitive. It is three lines and it is the whole of multi-level capture.

5–7. Alternatives, decision, tradeoffs

OptionNotes
A. Resolve during code generation (ours, Lua)One pass; needs the parent pointer and retroactive mark_captured
B. A separate resolver passCrafting Interpreters does this for jlox. Cleaner separation; another traversal; the AST must carry the annotations
C. Closure conversion / lambda liftingA real IR transformation: rewrite every closure into a top-level function taking an explicit environment. What functional-language compilers do, and it makes the environment a first-class, optimizable thing

Decision: A. The parent: Option<&mut Compiler> field was put in Lab 10 for exactly this moment, which is why it costs nothing now.

8. Production concerns

  • The upvalue index is u8, so 256 upvalues per function. Lua's limit is 255 (MAXUPVAL). Exceeding it is a clean compile error naming the function.
  • Debug names matter here more than elsewhere. GET_UPVAL 2 is unreadable; GET_UPVAL 2 ; count is not. Keep UpvalDesc.name and print it in the disassembly.
  • A global that shadows nothing is not an upvalue. If resolve returns None you emit GET_GLOBAL. Getting this order wrong — checking globals before enclosing locals — silently breaks every closure.
  • _ENV, if you ever add it, makes every global access an upvalue access, and this pass is where that lands. That is the upgrade path noted in ADR-012.

9. References

rg -n 'singlevar|searchupvalue|newupvalue|markupval' lparser.c
  • Lua's lparser.c: singlevar is resolve, searchupvalue/newupvalue are add_upvalue, and markupval is mark_captured. Fifty lines, and it is the same algorithm.
  • Crafting Interpreters, chapter 25, §25.2 — option B's shape, with the tradeoff discussed.
  • Any compiler text's treatment of closure conversion (option C) — Appel's Compiling with Continuations is the classic.

Things to Notice

  • Closures capture variables, not values. Everything else in this chapter follows from that one sentence.
  • Proto is shared, Closure is per-evaluation. Confusing them is why "all my closures see the same value" happens.
  • The loop-variable question is a scope decision, not a closure decision. Get the fresh binding right in the for loop and this solves itself. Go changed their language over it.
  • A middle function gains upvalues it never mentions. Capture is transitive and the chain is built at compile time so that access stays O(1).
  • Deduplicate upvalues per proto, or a read and a write of the same name become different boxes.
  • Closures are why you need a tracing collector. A recursive local function is a cycle, and people write those constantly.

Validation / Self-check

  1. Why does get() return 3 and not 0 in the counter example? What would each answer imply about the implementation?
  2. Draw the before/after diagram for make_counter returning.
  3. Give five languages' answers to the loop-variable question and say which one changed.
  4. What is the difference between Proto and Closure, and which is shared?
  5. Trace resolve_upvalue for the three-level example. What does the middle function end up with?
  6. Why must add_upvalue deduplicate? Give the program that breaks without it.
  7. What does mark_captured change in the enclosing function's output, and why is it safe to do it retroactively?
  8. Name the four capture strategies and say which two Ember implements, in which order, and why.
  9. Why are closures — not tables — the reason Ember cannot use Rc?

Next: Upvalues.