Scoped Environments
Factorial works. But there’s a hidden bug in our evaluator.
The Closure Bug
Consider what happens when we create two adders:
evaluate(["make-adder", "=", ["lambda", "x", ["lambda", "y", ["x", "+", "y"]]]]);
evaluate(["add3", "=", ["make-adder", 3]]); // sets x = 3
evaluate(["add5", "=", ["make-adder", 5]]); // sets x = 5, clobbers x!
evaluate(["add3", 10]); // expects 13, gets 15
The second call to make-adder overwrites x in the global environment. When add3 runs, it reads x and finds 5 instead of 3.
The problem: our evaluator has a single flat environment. Every function call shares the same set of bindings. There’s no isolation between calls.
The Fix: Scoped Environments
The solution is to give each function call its own scope — a private set of bindings that doesn’t interfere with any other call.
The key insight is lexical scoping: a function should remember the environment where it was defined, not where it’s called. When make-adder is called with 3, the inner lambda should remember that x = 3, no matter what happens later.
The match library now provides three new functions:
getEnv()— returns the current environmentsetEnv(env)— switches to a different environmentcreateEnv(parent)— creates a new scope that extends a parent environment
The set and get functions still work on the current environment, but get now walks up the parent chain to find names defined in outer scopes.
What to Change
Lambda: capture the current environment so the function remembers where it was defined.
{ param: expr[1], body: expr[2], env: getEnv() }
Call: create a new scope extending the lambda’s captured environment, not the caller’s environment.
- Evaluate
fnExprto get the function - Evaluate
argExprin the caller’s scope (before switching!) - Save the current environment
- Create a new scope from
fn.env(the lambda’s captured environment) - Bind the parameter to the already-evaluated argument
- Evaluate the body
- Restore the saved environment, return the result
WARNING
The argument must be evaluated before switching to the new scope. Otherwise, variables like n in factorial(n - 1) would be looked up in the callee’s empty scope instead of the caller’s.
NOTE
Native functions don’t need scoping — they capture values in JavaScript closures. Only handle scoping for lambdas.
What You’ve Just Built
When your tests pass, you’ll have implemented closures. Not as a special feature bolted onto functions, but as the natural consequence of getting scope right — capturing the defining environment at lambda creation time and restoring the caller’s environment after the call returns.
Most programmers learn closures as an advanced concept. You’ve just built them from scratch, and seen that they’re simply what happens when functions remember where they came from.