Chapter 6
Closures
Functions that remember where they were born
In the previous chapter we built a bytecode VM whose local variables are stack slots: fast to read, fast to write, and gone the instant a function returns. That speed comes from a strong assumption — that a variable's lifetime matches the function call that created it. This chapter is about the one place that assumption breaks, and how the VM keeps the speed anyway.
The Problem
Consider a function that builds another function and hands it back:
fn makeCounter() {
let count = 0
return fn() {
count = count + 1
return count
}
}
let counter = makeCounter()
print(counter()) // 1
print(counter()) // 2
print(counter()) // 3
The inner function reads and writes count — a local of makeCounter. But by the time we call counter(), makeCounter has already returned. Its stack frame is gone. A function that reaches back into a variable from an enclosing scope that has outlived its call is a closure: it "closes over" that variable.
The tree-walking interpreter gets this for free. A function value there holds a reference to the environment it was defined in, and environments are heap objects linked in a chain — so the environment containing count simply stays alive as long as the closure does. The VM has no such chain. It has a flat value stack.
Why the Naive Model Can't Do It
In the common case the VM treats a function as nothing more than a pointer to its compiled bytecode. Functions are hoisted into a global table at compile time and called by name. That model is wonderfully cheap, but it carries no captured state — there is nowhere for count to live once makeCounter's frame is popped.
Worse, if the inner function tried to read count out of makeCounter's old slot, that slot would by then hold whatever the next function call put there. Reading it would return garbage. So the naive "functions are just global pointers" model cannot represent makeCounter at all.
The Boxed-Cell Model
Bloom's fix is to give a captured variable a home that does not live on the stack. When a local is captured by a nested function, it is stored in a small heap object — a cell:
export interface Cell {
type: 'cell';
v: VMValue; // the boxed value lives here, on the heap
}
export interface VMClosure {
type: 'closure';
compiled: CompiledFunction;
upvalues: Cell[]; // live references to the cells it captured
}
The captured local's stack slot no longer holds the value directly — it holds the Cell. A closure built inside that frame grabs a reference to the same cell and stashes it in its upvalues array. Because the cell is a heap object, it survives after the enclosing frame is popped: the stack slot disappears, but the cell it pointed at lives on for as long as any closure still references it. Reads and writes from the closure go through cell.v, so they see — and update — the one true count.
Deciding What to Box: Free-Variable Analysis
Boxing every local would be wasteful — almost no locals are ever captured. So before compiling a function's body, the compiler runs a pure AST walk to answer one question: which of this function's own locals are referenced by some nested function?
It works in two parts. freeVarsOfFunction collects the free variables of a function — the identifiers it uses that it does not itself declare (not a parameter, not a local). capturedLocalsOf then takes the union of the free variables of every nested function and intersects it with the enclosing function's own declared names. Whatever survives that intersection is captured, and therefore must be boxed.
// Which of THIS function's locals does a nested function reach into?
export function capturedLocalsOf(params, body): Set<string> {
// 1. union of free variables of every nested function
// 2. intersect with this function's own params + locals
// 3. what remains is captured -> box it
}
The analysis is deliberately conservative: it walks into nested functions so a variable captured two levels down still surfaces, and it never under-reports. If the captured set comes back empty — the overwhelmingly common case — nothing is boxed and the function compiles exactly as before.
The Opcodes
A small, self-contained family of instructions implements the model. They live at 0xC0–0xC5 and are emitted only when a real capture exists:
MAKE_CELLCell { v } wrapping it. Emitted where a captured local is declared.GET_CELL <slot>stack[base+slot].v — read a captured local through its cell (the boxed counterpart of LOAD_LOCAL).SET_CELL <slot>STORE_LOCAL's peek-don't-pop).GET_UPVALUE <idx>currentClosure.upvalues[idx].v — read a variable captured from an enclosing function.SET_UPVALUE <idx>currentClosure.upvalues[idx].v.CLOSURE <fn> <n> [...]VMClosure at runtime from a compiled-function constant plus n capture descriptors, then push it.Each capture descriptor in CLOSURE is a pair (isLocal, index). When isLocal is 1, the new closure grabs the cell sitting in the enclosing frame's slot. When it's 0, it forwards one of the enclosing closure's own upvalues — that's how a capture threads up through two or more levels of nesting. The runtime handler is exactly that loop:
for (let i = 0; i < upvalCount; i++) {
const isLocal = bytecode[ip++];
const index = bytecode[ip++];
upvalues[i] = isLocal === 1
? this.stack[stackBase + index] as Cell // capture frame local
: currentClosure.upvalues[index]; // forward outer upvalue
}
this.push({ type: 'closure', compiled, upvalues });
An Additive Design
The single most important property of all this is that it costs nothing when it isn't used. A non-capturing function emits byte-identical bytecode to what it did before closures existed: it stays a hoisted global, called by name, with plain LOAD_LOCAL/STORE_LOCAL on unboxed slots. The cells, the CLOSURE opcode, the VMClosure wrapper — none of it appears unless capturedLocalsOf found a real capture. The fast path the previous chapter described is completely untouched.
A Worked Example
Put the model to work with two independent counters from the same factory:
let a = makeCounter()
let b = makeCounter()
print(a()) // 1
print(a()) // 2
print(b()) // 1 <- b has its own count
print(a()) // 3
Each call to makeCounter runs MAKE_CELL for its own count, producing a brand-new cell. The closure it returns captures that cell via CLOSURE with one isLocal=1 descriptor. So a and b hold references to two different cells: incrementing a writes through a's cell with SET_UPVALUE and never touches b's. Two closures, two cells, two private counts — exactly the behavior the interpreter produces through separate environments, reached here through separate heap cells.
With closures handled on the fast path, we can finally read what the compiler emits. The next chapter shows how to disassemble a program and follow its opcodes line by line.