The environment model of evaluation
Lesson 09 left us stranded: assignment gave us objects with persistent state, but it killed the substitution model — substitution has no notion of "the current value of a name" and no rule for changing it. This lesson installs the replacement. In the environment model, a name does not stand for a fixed value; it denotes a binding in a frame, and frames chain together into environments. Evaluation looks names up along that chain, and assignment changes a binding in place. Two rules do all the work, and — crucially — they explain exactly why each make_account call from Lesson 09 gets a private, persistent balance: a function object remembers the environment in which it was defined.
make_account / block structure. We follow that arc but the diagrams, traces, and the closure-of-the-loop with Lesson 09 are original.New capability: trace evaluation precisely with frames, bindings, enclosing environments, and function objects that remember their defining environment — and use that to explain closures and private persistent state exactly, not by analogy.
make_account and read off why each balance is private and persistent. (4) Use the model on block structure / internal declarations, and on assignment.1 · The machinery — frames, environments, function objects
The substitution model had one kind of thing: an expression being rewritten. The environment model has three, and the move that makes state expressible is splitting "a name's value" into a name and a place that holds a value.
balance: 100. Assignment changes the value side of an existing binding — this is the place that the substitution model lacked.The last piece is the one Lesson 09 secretly relied on. A function object is not just code. When you evaluate a function expression, you create a pair:
That second component — the defining environment — is the entire secret of closures. The code is inert text; the environment pointer is what lets the function later reach the variables that were in scope where it was written.
2 · The two rules
Everything about evaluation reduces to two rules. Learn these and you can hand-execute any program with state.
Step (3) is the rule the whole model turns on, and the place every other operational model gets it wrong. The new frame's parent is where the function was defined, not where it was called. (Calling-environment parenting would be "dynamic scope," and closures would not work.) Assignment gets the obvious third rule: x = v finds the binding of x by Rule 1's search and changes its value in that frame; let x = v creates a new binding in the current frame.
3 · Worked example — the full environment diagram for make_account
function make_account(balance) {
function withdraw(amount) {
if (amount <= balance) { balance = balance - amount; return balance; }
else return "insufficient funds";
}
return withdraw; // simplified: return the withdraw closure directly
}
const acc1 = make_account(100);
const acc2 = make_account(100);
acc1(50); // 50
acc1(20); // 30
acc2(50); // 50 -- untouched by acc1
After the two make_account calls and acc1(50), the environment structure is:
GLOBAL frame
+-------------------------------------------------+
| make_account: ---> [fn object: params(balance), |
| body..., env = GLOBAL ] |
| acc1: ----------------+ |
| acc2: ------------+ | |
+-------------------|---|---------------------------+
^ | | ^
enclosing-ptr | | | | enclosing-ptr
| v v |
E1 (make_account call) E2 (make_account call)
+----------------------+ +----------------------+
| balance: 100 -> 50 | | balance: 100 | <-- two SEPARATE
| withdraw: [fn,env=E1]| | withdraw: [fn,env=E2]| balance bindings
+----------------------+ +----------------------+
^ ^
| acc1 points here | acc2 points here
(acc1's defining env) (acc2's defining env)
Read it off. Each call to make_account ran Rule 2: it built a new frame (E1, then E2) binding balance to 100, with parent GLOBAL. The withdraw closure created inside each call captured that frame as its defining environment — so acc1 is <withdraw-code, E1> and acc2 is <withdraw-code, E2>. When we call acc1(50), Rule 2 makes a fresh frame binding amount: 50 whose parent is E1 (acc1's defining env). Inside, balance is found by Rule 1 in E1 (value 100), and balance = balance - 50 rewrites E1's binding to 50. acc2's balance lives in E2 and is never touched.
Private: nothing reaches E1's
balance except closures whose defining env is E1 — i.e. acc1. Persistent: E1 is not discarded when make_account returns, because acc1 still points into it; the binding survives between calls, which is exactly the "remembered state" of Lesson 09. The model explains both, with no hand-waving.4 · The model on block structure and assignment
Internal declarations — functions and lets nested inside a function — are just frames within frames. When withdraw was declared inside make_account, evaluating that declaration in E1 created a binding withdraw: [fn, env=E1] in E1. So withdraw can see balance (it is in the same frame) and so can deposit (its parent is also E1) — they share state precisely because they share a defining frame. Block structure is not a separate feature; it falls straight out of Rule 2.
| Question from Lesson 09 | Environment-model answer |
|---|---|
| What is the "current value" of a mutable name? | The value in its binding now — found by Rule 1's chain search. There is now a "now": the contents of a frame at a point in time. |
| How does assignment change meaning? | It overwrites the value side of an existing binding (located by Rule 1). No expression is rewritten — a place is updated. |
| Why does each closure keep private state? | Each carries a pointer to its own defining frame (Rule 2, step 3); separate calls make separate frames. |
| Why does state persist after the function returns? | The frame is kept alive by the closure that points into it; returning does not delete a frame that is still referenced. |
Common mistakes / failure modes
make_account returns, but E1 lives on because acc1 points into it — that is why the balance persists.x = v searches up the chain and overwrites an existing binding; let x = v makes a fresh binding in the current frame, shadowing any above. Mixing them up changes which state you mutate.Checkpoint exercise
function make_adder(n) {
return function(x) { return x + n; };
}
const add10 = make_adder(10);
const add100 = make_adder(100);
add10(5); // ?
add100(5); // ?
add10(add100(5)); // ?
Answer. Each make_adder call builds a frame binding n (one with n:10, call it F10; one with n:100, F100). The returned closure captures that frame as its defining env: add10 = <fn, F10>, add100 = <fn, F100>. Calling add10(5) makes a frame x:5 with parent F10; Rule 1 finds n:10 → 15. add100(5) → frame x:5, parent F100, n:100 → 105. add10(add100(5)) = add10(105) → 115. The two ns never collide because they live in separate frames reached only through their own closure — same mechanism as the two balances.Where this points next
The environment model restores an operational account of evaluation that handles state cleanly: a name denotes a binding in a frame, lookup walks the chain, and assignment overwrites a binding in place. So far, though, the mutable state has lived only in variables. Real systems put changing state inside data structures — a list whose cells are rewritten, a queue you push and pop, a table you update. Lesson 11 generalizes assignment from variables to compound data: set_head / set_tail mutate the pairs of Lesson 04 directly, and from that one capability we build mutable queues, tables, and an event-driven circuit simulator. The environment model still narrates it all — but the place being changed is now a field of a data object, not a frame binding.
make_account diagram reads off Lesson 09's mysteries directly: each call makes a separate frame (E1, E2) holding a separate balance; each withdraw closure captures its own frame; the frame survives because the closure points into it — hence private and persistent state. Block structure and internal declarations are just frames within frames. Next we move the mutable place from a frame binding into a data structure.Interview prompts
- State the two rules of the environment model. (§2 — Rule 1: look up a name by searching the current frame then walking the enclosing chain. Rule 2: apply by creating a frame binding params to args, with parent = the function's defining environment, then evaluate the body there.)
- Why must a new frame's parent be the defining environment, not the caller's? (§2 — so closures see the variables that were in scope where they were written; using the caller's environment gives dynamic scope and breaks private persistent state.)
- Using an environment diagram, explain why two accounts have independent balances. (§3 — each
make_accountcall runs Rule 2, building a separate frame (E1, E2) with its ownbalance; eachwithdrawclosure captures its own frame, so a mutation in E1 cannot touch E2.) - Why does an account's balance persist after
make_accountreturns? (§3–4 — the call's frame is not discarded because the returned closure still points into it as its defining environment; a frame lives as long as it is referenced.) - How does the environment model handle assignment, and how is that different from substitution? (§2,4 — assignment overwrites the value of an existing binding found by Rule 1; substitution only ever replaced a name by a value and had no notion of a mutable place.)
- What is a function object in this model, and why are both components needed? (§1 — (code, defining-environment); the code is inert, the environment pointer is what lets the function reach the variables in scope at its definition — the essence of a closure.)
- What is the difference between
x = vandlet x = vin the model? (§2,4 —x = vfinds an existing binding up the chain and overwrites it;let x = vcreates a new binding in the current frame, possibly shadowing one above.)