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:
- The value must survive the frame's death.
incandgetmust 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
| Option | What is captured | Consequence for the counter example |
|---|---|---|
| A. By variable / by reference (ours, Lua, JS, Python, Ruby, Scheme) | a box | 1 2 3 then 3 — sharing works |
| B. By value, at closure creation | a copy | 1 1 1 and get() returns 0 — the example breaks |
| C. Programmer chooses per capture | either | C++ ([=] vs [&]), and [&] gives you dangling references it will not check |
| D. No closures; explicit environment argument | nothing | C. 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 gain | We lose |
|---|---|
| Shared mutable state between closures, which is the feature | Captured variables cannot live purely on the stack |
| Natural, expected semantics | A heap object and a GC edge per captured variable |
| Iterators, callbacks, and object-like patterns become expressible | Cycles 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
| Language | Result | Why |
|---|---|---|
| Lua, Ember | 1 2 3 | The for variable is a fresh binding per iteration |
JavaScript with let | 1 2 3 | Same — let was defined to do this |
JavaScript with var | 3 3 3 | One function-scoped binding, shared |
| Python | 3 3 3 | One binding; closures capture it, not its value ("late binding") |
| Go before 1.22 | 3 3 3 | One binding per loop |
| Go 1.22+ | 1 2 3 | They 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 aValue. 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
Enginethat is a leak with no cycle in it — the collector is behaving correctly, and the program is wrong. Section 5's--statsheap 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 thatLClosurehas a flexible array member ofUpVal*— 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:
| Strategy | When the box is allocated | Cost |
|---|---|---|
| Naive (Lab 14, step 1) | eagerly, at declaration, for every captured local | One 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 not | Zero 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
| Option | Where captured variables live | Systems |
|---|---|---|
| A. Everything on the heap | every local is a box | Early Lisps, some Scheme implementations. Simple, uniformly slow |
| B. Escape analysis, boxes for escapees | captured ones on the heap from the start | The 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 heap | Zero cost when the closure never escapes the frame's lifetime |
| D. Whole-environment capture | a heap frame per call, chained | Scheme'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 endallocates a box forxon 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:
mark_capturedchanges the enclosing function's code generation: at scope exit it must emitCLOSE_UPVALSfor that slot instead of counting it into a plainPOP. A retroactive change to already-planned code — which is fine here because thePOPis emitted atend_scope, after all captures are known. Check that ordering in your own compiler.- Deduplication in
add_upvalue. Without it,function() x = x + 1 endcreates two upvalue entries forx, pointing at the same slot but becoming different boxes when closed — and the write is then invisible to the read. - 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
| Option | Notes |
|---|---|
| A. Resolve during code generation (ours, Lua) | One pass; needs the parent pointer and retroactive mark_captured |
| B. A separate resolver pass | Crafting Interpreters does this for jlox. Cleaner separation; another traversal; the AST must carry the annotations |
| C. Closure conversion / lambda lifting | A 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 2is unreadable;GET_UPVAL 2 ; countis not. KeepUpvalDesc.nameand print it in the disassembly. - A global that shadows nothing is not an upvalue. If
resolvereturnsNoneyou emitGET_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:singlevarisresolve,searchupvalue/newupvalueareadd_upvalue, andmarkupvalismark_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.
Protois shared,Closureis 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
forloop 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
- Why does
get()return3and not0in the counter example? What would each answer imply about the implementation? - Draw the before/after diagram for
make_counterreturning. - Give five languages' answers to the loop-variable question and say which one changed.
- What is the difference between
ProtoandClosure, and which is shared? - Trace
resolve_upvaluefor the three-level example. What does the middle function end up with? - Why must
add_upvaluededuplicate? Give the program that breaks without it. - What does
mark_capturedchange in the enclosing function's output, and why is it safe to do it retroactively? - Name the four capture strategies and say which two Ember implements, in which order, and why.
- Why are closures — not tables — the reason Ember cannot use
Rc?
Next: Upvalues.