Skip to content

Step 1 of 3 · Reading · ~3 min

Functions and Closures in Bytecode

Compiling to Bytecode

Every Function Gets Its Own Chunk

So far your compiler has emitted into a single Chunk for the whole program. Functions break that: each fun name(params) { body } compiles to its own separate Chunk, wrapped in a Function object (name, arity, its chunk, its own constant pool). The outer chunk doesn't inline the function's bytecode — it just references the compiled Function as a constant and emits OP_CLOSURE <const_idx> to produce a callable value from it at runtime.

python

Compiling a function body means starting a new compiler instance chained to the enclosing one (enclosing = current_compiler), so it has its own locals list and scope_depth, but can still walk outward through enclosing to resolve names from surrounding scopes.

Call Frames: One Per Active Call

At runtime, calling a function doesn't reuse the caller's ip and stack window — it pushes a call frame:

python

OP_CALL <argc> pops the callee and its argc arguments off the stack (they're actually left in place — the callee's frame base just points at where they already are), pushes a new CallFrame, and the VM's main loop starts executing from that frame's chunk/ip instead. OP_RETURN pops the return value, discards the callee's frame and its stack slots, and resumes the caller exactly where it left off. This is how recursion, including mutual recursion, comes for free — the frame stack is a real stack.

Closures: Functions + Captured Variables

A bare Function is just compiled code — it can't remember values from an enclosing scope. A Closure pairs a Function with the specific upvalues it captured when it was created:

python

Consider:

fun makeCounter() {
    var count = 0;
    fun increment() { count = count + 1; return count; }
    return increment;
}

increment references count, a local of the enclosing function makeCounter. By the time increment is called, makeCounter's stack frame may already be gone — so count can't just be "a stack slot," because the slot won't exist anymore. It has to be captured.

Resolving Upvalues at Compile Time

When the compiler for increment sees count and doesn't find it in its own locals, it asks the enclosing compiler to resolve it:

  • If the enclosing compiler has count as a local, mark it as (is_local=True, index=<slot>) — this is a direct capture.
  • If the enclosing compiler doesn't have it either but its enclosing compiler does, resolve it there and add an upvalue entry pointing at the enclosing function's own upvalue list, (is_local=False, index=<upvalue index>) — this chains capture through multiple nesting levels.

OP_CLOSURE is followed by one (is_local, index) pair per upvalue the function needs, so the VM knows, at closure-creation time, exactly where to find each captured cell — either directly on the current stack frame or by forwarding an upvalue the creating closure already holds.

What to build

Compile fun declarations into their own Function/Chunk, emit OP_CLOSURE with upvalue descriptors from the outer scope, and implement OP_CALL/OP_RETURN with a call-frame stack. Verify recursive calls (fun fact(n) { if (n < 2) return 1; return n * fact(n - 1); }) and a closure that captures a variable from an enclosing function that has already returned — that's the case that proves capture-by-reference, not capture-by-value, is working.

Up nextMark-and-Sweep — The Simplest GCGarbage Collection

Discussion

Ask a question, share an insight, or help someone who’s stuck.

Sign in to post a comment or reply.

Loading…