Local state and assignment
Lesson 08 finished the purely functional tower: generic arithmetic dispatched by apply_generic, every value a timeless function of its inputs. But the world is not timeless. A bank account that you withdraw from is the same account before and after, yet it answers differently — it has identity that persists through change. To model that we add one new ingredient, assignment (= on an already-declared name), plus local variables hidden inside a function. That tiny addition buys us objects with private, persistent state. It also detonates the evaluation model we have used since Lesson 01: the substitution model is now invalid, and this lesson pinpoints the exact step where it dies.
New capability: model an object whose behavior depends on its history, using a local variable captured in a closure plus assignment to update it — and articulate the precise price you pay for it.
withdraw(50) twice and watch the same expression give different answers. (4) Pinpoint the single substitution step that is now illegal, killing the model from Lesson 01. (5) Sameness and change — why two accounts that "look equal" are not interchangeable.1 · Assignment and local state — a counter, then a bank account
Until now every name was bound once: const introduced a value that stood for one thing forever. Assignment is the operation that changes what an already-declared name refers to. We write it with a mutable binding (let) and the = operator:
function make_counter() {
let count = 0; // a LOCAL variable, private to this call
return function() { // a closure that captures count
count = count + 1; // ASSIGNMENT: rebinds count to a new value
return count;
};
}
const c = make_counter();
c(); // 1
c(); // 2 -- same expression c(), different result. State is remembered.
Two things are new. count is a local variable — it lives only inside the call to make_counter, invisible to everyone else. And the returned function captures it: each call to make_counter manufactures a fresh, private count that survives between calls to the closure. The closure is no longer a pure function of its (empty) argument list; its result depends on how many times it has been called — on its history.
Now the canonical object of Part III, a bank account modeled as a message-passing object: a dispatch function holding a private balance, answering to the messages it understands.
function make_account(balance) { // balance is the private local state
function withdraw(amount) {
if (amount <= balance) {
balance = balance - amount; // assignment: state changes in place
return balance;
} else {
return "insufficient funds";
}
}
function deposit(amount) {
balance = balance + amount;
return balance;
}
function dispatch(msg) { // message-passing: route by message
if (msg === "withdraw") return withdraw;
else if (msg === "deposit") return deposit;
else return "unknown request: " + msg;
}
return dispatch;
}
const acc = make_account(100);
acc("withdraw")(50); // 50 -- balance is now 50
acc("withdraw")(60); // "insufficient funds"
acc("deposit")(40); // 90
acc("withdraw")(60); // 30
balance is reachable only through the closures withdraw/deposit; nothing outside can read or corrupt it. That encapsulation is the same boundary idea as the abstraction barriers of Lesson 04 — but now what is hidden is not just a representation, it is changing state.2 · What state buys — modular world-models
Why pay for this at all? Because assignment lets us decompose a system the way the world is decomposed: into independent objects, each carrying its own state, evolving on its own. Without assignment, to model "an account with $100 from which $50 is withdrawn" you would thread the balance through every function as an explicit argument and return the new balance everywhere — the entire program would have to know about every piece of state. With local state, the account is the state-holder; callers just send messages.
This is genuinely new expressive power. But every model we keep must come with an operational account of how it computes — and our only such account so far is the substitution model. The next two sections show it can no longer cope.
3 · The substitution model breaks — two evaluations, two answers
Recall the substitution model from Lesson 01: to evaluate a function call, substitute the argument expressions for the parameters in the body, then proceed. Its hidden assumption is referential transparency — a name (or call) stands for one fixed value, so you may replace it by that value anywhere. Pure code obeys this; make_account does not. Here is the same expression evaluated twice:
const w = acc("withdraw"); // grab the withdraw closure once
w(50); // first evaluation of w(50) → returns 50 (balance 100 → 50)
w(50); // second evaluation of w(50) → "insufficient funds" (balance is now 50)
The expression w(50) is textually identical both times, yet it returns 50 then "insufficient funds". Under the substitution model that is impossible: w(50) should denote a single value, and substituting that value for the call should never change meaning. The result now depends on when you evaluate it relative to other evaluations — on time and history. The expression is no longer referentially transparent.
4 · Pinpointing the illegal step
It is worth being exact about where substitution becomes illegal, because that is the precise crack the next model must fill. Substitution evaluates w(50) by replacing the parameter amount with 50 in the body of withdraw:
withdraw(50) -- substitute amount := 50 into the body
if (50 <= balance) { balance = balance - 50; return balance; } else ...
STEP A look up `balance` -- substitution says: replace `balance` by THE value
it is bound to. But which value? 100 or 50?
It has had BOTH, at different times. <-- ILLEGAL
STEP B balance = balance - 50 -- substitution has NO rule for assigning to a name.
Substitution only ever REPLACES a name by a value;
it cannot CHANGE what the name denotes. <-- ILLEGAL
Both failures are the same disease seen from two sides. The substitution model treats a name as an immutable abbreviation for a value, so (Step A) it cannot answer "what is balance now?" — there is no "now" in substitution — and (Step B) it has no operation for the assignment balance = balance - 50, because changing a name's meaning is exactly the move substitution forbids. The model is not merely awkward here; it is undefined. We need an operational model in which a name denotes a place that holds a value over time, not a fixed value.
f(x) + f(x) may no longer equal 2 * f(x); the order of evaluation can change the answer. Assignment does not dent the model — it removes its foundation.5 · Sameness and change
State forces a question that never arose for pure values: when are two things the same? Consider:
const a1 = make_account(100);
const a2 = make_account(100); // a separate account that "looks the same"
const a3 = a1; // a3 is the SAME account as a1
a1("withdraw")(50); // 50
a2("withdraw")(50); // 50 -- a2 was untouched by a1's withdrawal
a3("withdraw")(50); // "insufficient funds" -- a3 IS a1; balance already 50
a1 and a2 were created with identical balances, but they are not interchangeable: withdrawing from one does not affect the other, so they have distinct identity. a3, by contrast, is the same object as a1 — a change through one is visible through the other. For pure values, "equal" and "the same" coincide (two 3s are utterly interchangeable). Once objects can change, equality of current contents no longer implies sameness of identity, and substituting one "equal" object for another can silently change the program. This is the conceptual cost that rides along with the operational one.
Common mistakes / failure modes
w(50) means the same thing twice, or that f(x)+f(x) == 2*f(x). Once a function reads or writes state, equational reasoning is unsound — you must trace the order of evaluation.balance directly defeats encapsulation — outside code can corrupt it. State is only safe while it is reachable solely through the object's interface.balance outside make_account) makes them alias the same state — a preview of the concurrency hazards of Lesson 12.Checkpoint exercise
make_account with a "balance" message that reads the balance without changing it. Is the account still impure? (b) Predict the output of this sequence, then say which line could not be explained by substitution and why:
const acc = make_account(100);
acc("withdraw")(40); // ?
acc("deposit")(10); // ?
acc("withdraw")(40); // ?
Answers. (a) Yes — even a pure-looking read returns a value that depends on history (clause-1 break: a hidden input, the current balance), so the object is still stateful and not referentially transparent. (b) Outputs 60, then 70, then 30. Every line defeats substitution: each acc("withdraw") / acc("deposit") call reads and reassigns balance, and substitution has no rule for "the current value of a mutable name" nor for assignment — the second withdraw(40) succeeds only because the prior two calls changed balance to 70.Where this points next
We have gained objects with persistent identity, and lost our only operational model of evaluation: substitution is undefined for assignment, because it treats a name as a fixed value and has no notion of "the current value" or of changing what a name denotes. We need a replacement that makes a name denote a place in some context, lets that place be updated, and explains exactly why each make_account call gets a private, persistent balance. Lesson 10 builds that model — the environment model: frames of bindings chained together, with assignment changing a binding in a frame, and a function remembering the environment in which it was defined. That last rule is precisely what makes the closures of this lesson work.
let name) plus a local variable captured in a closure lets us build objects with private, persistent state — a counter that remembers its count, a bank account that is the same account before and after a withdrawal. State buys modular world-models: each object owns its state, so the program mirrors the world. The price is exact and fatal to our old model: the substitution model from Lesson 01 is now invalid. Evaluating w(50) twice yields different answers, and the illegal steps are precise — substitution cannot say what value a mutable name has "now," and has no rule for assigning to a name. State also splits equality from identity: two accounts with equal balances are not interchangeable. We need an operational model in which names denote mutable places in a context — the environment model of Lesson 10.Interview prompts
- What does local state plus assignment buy you that pure functions cannot? (§1–2 — objects with identity that persists through change: a private variable captured in a closure, updated by assignment, so the program can mirror a world of independent, evolving objects.)
- Show that the substitution model is invalid once you add assignment. (§3–4 — evaluate
w(50)twice and get 50 then "insufficient funds"; substitution requires a name to denote one fixed value and has no rule for "current value of a mutable name" or for assignment itself.) - Pinpoint the exact substitution step that becomes illegal. (§4 — looking up
balancehas no single answer because it has held both 100 and 50, andbalance = balance - 50is an assignment, which substitution cannot perform — it only replaces names by values.) - What is message passing, and how does
make_accountuse it? (§1 — the object is a dispatch function that routes a message ("withdraw"/"deposit") to the right internal procedure; the dual of the operation/type tables of Lesson 07.) - Distinguish equality from identity with two bank accounts. (§5 — two accounts opened with equal balances are not the same object: a withdrawal from one is invisible to the other; equal contents no longer imply interchangeability once objects can change.)
- Why can't you reason that
f(x) + f(x) == 2 * f(x)for a statefulf? (§3–4 — the two calls can read/mutate shared state and return different values, so equational substitution and reordering are unsound — the model that licensed them is gone.)