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:

src/lang/bytecode.ts
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.

Contrast with Crafting Interpreters clox uses open and closed upvalues: an upvalue starts as a pointer into the live stack and is "closed" (copied to the heap) only when the variable leaves scope. That saves a heap allocation for variables captured while still on the stack. Bloom skips the open phase entirely and boxes a captured local into a heap cell the moment it is declared. The trade is a little allocation for a lot of simplicity — and crucially it's additive: only captured locals pay, and the VM never has to walk the stack to close upvalues on return.

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.

src/lang/bytecode.ts
// 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 0xC00xC5 and are emitted only when a real capture exists:

Opcode
What it does
MAKE_CELL
Pop a value, push a fresh Cell { v } wrapping it. Emitted where a captured local is declared.
GET_CELL <slot>
Push stack[base+slot].v — read a captured local through its cell (the boxed counterpart of LOAD_LOCAL).
SET_CELL <slot>
Write the value on top of the stack into the cell, leaving it on the stack (mirrors STORE_LOCAL's peek-don't-pop).
GET_UPVALUE <idx>
Push currentClosure.upvalues[idx].v — read a variable captured from an enclosing function.
SET_UPVALUE <idx>
Write into currentClosure.upvalues[idx].v.
CLOSURE <fn> <n> [...]
Build a 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:

src/lang/bytecode.ts — the CLOSURE handler
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.

Why closures no longer fall back Earlier, the VM refused closures and Bloom re-ran those sketches on the interpreter. With the boxed-cell model the VM runs them natively, at full VM speed. The only thing that still triggers a fallback to the interpreter is a module / import, which the VM has no system for.

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.