To support proper closures, we must change how the virtual machine handles the
concept of "scope."
In the previous architecture, every new function call created a local
environment that inherited directly from globalEnv. To support lexical
scoping, a function must instead capture the environment that was active at
the moment the function was defined, and use that captured environment as the
parent for its local variables when it is called.
By leveraging JavaScript's native prototype chain, this modification requires
changing only two opcodes in your execution loop.
1. Modifying the MAKE_FUNC Opcode
When the compiler emits a MAKE_FUNC instruction, it is telling the VM to
create a Code Object. We must modify this step so that the VM attaches a
snapshot of the current Call Frame's environment (the frame.env) to that Code
Object.
case Opcodes.MAKE_FUNC:
// inst.val contains { params, code } from the compiler
const codeObject = {
params: inst.val.params,
code: inst.val.code,
// THE CRITICAL ADDITION: Capture the lexical environment
// active at the exact moment this function is being defined.
closureEnv: frame.env
};
dataStack.push(codeObject);
break;
2. Modifying the CALL Opcode
When a function is invoked, it pops the Code Object off the data stack.
Instead of creating a new environment using Object.create(globalEnv), we use
the environment that was bundled into the Code Object during MAKE_FUNC.
case Opcodes.CALL:
const func = dataStack.pop();
// THE CRITICAL ADDITION: The new local scope inherits from the
// captured closure environment, creating a continuous scope chain.
const localEnv = Object.create(func.closureEnv);
// Bind arguments to parameters
for (let i = inst.argCount - 1; i >= 0; i--) {
localEnv[func.params[i]] = dataStack.pop();
}
// Push the new frame to the Call Stack
callStack.push({ ip: 0, code: func.code, env: localEnv });
break;
How the Prototype Scope Chain Works
This approach is highly elegant because it offloads the complex mechanics of
variable resolution entirely to the host language (V8/Node.js).
Imagine this JSON-Lisp AST, which returns a function from a function:
[
"def", "make_adder", ["x"],
[
"def", "adder", ["y"],
["+", "x", "y"]
]
]
When you call ["make_adder", 10]:
* The VM pushes a new frame for make_adder. Its environment is { x: 10 }.
* Inside make_adder, it encounters MAKE_FUNC for adder.
* The adder Code Object is created, and its closureEnv is set to { x: 10 }.
* make_adder returns the adder Code Object.
Later, when you call ["adder", 5]:
* The VM creates a new local environment for adder: { y: 5 }.
* Crucially, its prototype is set to the closureEnv: { x: 10 }.
* When the OP_LOAD instruction looks for "x", it checks { y: 5 }, doesn't
find it, transparently falls back to the prototype chain, finds 10, and pushes
it to the data stack.
A Note on Memory Optimization
While using Object.create() creates a perfect lexical scope chain, it
technically retains the entire parent environment in memory as long as the
closure exists. In heavy production virtual machines, engines optimize this by
using "Upvalues" (Lua) or "Flat Closures" (V8). Instead of retaining the whole
environment object, the compiler analyzes which specific variables are
actually accessed by the inner function, and the MAKE_FUNC opcode copies only
those specific variables into a small, isolated array.
However, for a minimal, self-documenting implementation, relying on the
prototype chain is the most optimal balance of complexity and functionality.