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.
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:
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:
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
countas 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.
Discussion
Ask a question, share an insight, or help someone who’s stuck.
Sign in to post a comment or reply.
Loading…