Mutable data and simulators
Lesson 10 gave us an operational model in which a name denotes a binding in a frame, and assignment overwrites that binding. There the changing state lived in variables. This lesson moves it where real systems keep it — inside data structures. We add two mutators to the pairs of Lesson 04, set_head and set_tail, and from that single capability we build mutable queues (whose rear-pointer trick is the whole reason to allow mutation), mutable tables, an event-driven digital-circuit simulator, and bidirectional constraint propagation. Along the way we meet the new hazard mutation brings: sharing — when two structures point at the same cell, a change to one is silently a change to the other.
New capability: build mutable data abstractions — keep the mutation behind a small interface — and use them to simulate changing worlds (a wire that propagates a signal, a constraint network that has no preferred direction).
set_head/set_tail and confront sharing & identity. (2) Build a mutable queue and show the rear-pointer is the entire payoff of mutation. (3) Mutable tables, 1-D and 2-D. (4) The event-driven, agenda-based digital-circuit simulator. (5) Constraint propagation — relations, not procedures. Then the hazard that forces Lesson 12.1 · Mutators, sharing, and identity
A pair (Lesson 04) was a constructor pair(a, b) with selectors head/tail. Make it mutable by adding two mutators that overwrite a field in place:
set_head(p, v); // change the head field of pair p to v
set_tail(p, v); // change the tail field of pair p to v
This is the environment model's assignment, aimed at a data object's field instead of a frame binding. It immediately raises a question that immutable data never did: what if two names refer to the same pair? Then mutating through one is visible through the other — they are shared, or aliased.
const a = list(1, 2, 3); // a -> [1] -> [2] -> [3]
const b = a; // b is the SAME list (alias), not a copy
const c = list(4, 5);
set_tail(c, tail(tail(a))); // c now shares a's last cell: c -> [4] -> [5] -> [3]
set_head(tail(a), 99); // intend to edit "a" only...
// a is now (1, 99, 3)
// b is now (1, 99, 3) <-- b changed too: b IS a
display(b); // (1, 99, 3) -- surprise if you thought b was a snapshot
a ─┐
├─> [1|·]──>[99|·]──>[3|/]
b ─┘ ^
| c and a SHARE this last cell
c ────> [4|·]──>[5|·]─────┘
Nothing copied. b = a made a second name for one object; set_tail(c, ...) spliced a's tail into c. A mutation anywhere a cell is reachable is a mutation everywhere it is reachable. This is the identity-vs-equality distinction from Lesson 09 made physical: two pairs can hold equal contents yet be different cells (a change to one leaves the other alone), or be the same cell (a change to one changes both).The discipline that tames this is the same as always: keep mutation behind a small interface. Expose constructors, selectors, and a few named mutators; never let clients hold raw cells and splice them by hand. The abstraction barrier now guards who is allowed to change what.
2 · A mutable queue — the rear-pointer is the point
A queue is FIFO: insert at the rear, remove from the front. You could implement it on an immutable list, but inserting at the rear of an n-element list means rebuilding all n cells — Θ(n) per insert. Mutation buys the whole reason a queue exists: keep an explicit rear pointer to the last cell so insertion is Θ(1).
front_ptr and rear_ptr, both into one shared chain of cells.
function make_queue() { return pair(null, null); } // [front_ptr | rear_ptr]
const front_ptr = q => head(q), rear_ptr = q => tail(q);
const set_front = (q,p) => set_head(q,p), set_rear = (q,p) => set_tail(q,p);
const is_empty = q => front_ptr(q) === null;
function insert_queue(q, item) {
const cell = pair(item, null);
if (is_empty(q)) { set_front(q, cell); set_rear(q, cell); }
else { set_tail(rear_ptr(q), cell); // splice onto the OLD rear -- O(1)
set_rear(q, cell); } // advance the rear pointer -- O(1)
return q;
}
function delete_queue(q) {
set_front(q, tail(front_ptr(q))); // drop the front cell -- O(1)
if (is_empty(q)) set_rear(q, null);
return q;
}
insert a, then b, then c:
front_ptr ─┐ ┌─ rear_ptr
v v
[a|·]───>[b|·]───>[c|/]
delete_queue: just advance front_ptr to the next cell — no shifting, no copy.
Without set_tail there is no rear pointer to update and no in-place splice; every insert would rebuild the list. The mutation is the optimization. This is the cleanest case of "mutation behind an interface": clients see insert_queue/delete_queue, never the two pointers.3 · Mutable tables
A table maps keys to values with in-place update. Represent it as a headed list of [key, value] records; lookup walks the chain, insert mutates an existing record or splices a new one after the head.
function make_table() { return list("*table*"); } // a dummy head cell
function lookup(key, table) {
const rec = assoc(key, tail(table)); // find [key,val] or false
return rec ? tail(rec) : undefined;
}
function insert(key, value, table) {
const rec = assoc(key, tail(table));
if (rec) set_tail(rec, value); // UPDATE in place
else set_tail(table, pair(pair(key, value), tail(table))); // SPLICE new record
}
A two-dimensional table is a table whose values are themselves tables (a subtable per first key) — exactly the operation/type table that drove apply_generic in Lessons 07–08, now seen as the mutable structure it always was. Building generic dispatch required a mutable table so that installing a new representation could add an entry without rebuilding the whole structure.
4 · The digital-circuit simulator — event-driven mutation
Now mutation earns its keep as simulation. Model a digital circuit as wires carrying a signal (0 or 1) and function boxes (and/or/inverter gates) that watch their input wires and, after a propagation delay, set their output wire. The engine is an agenda: a priority queue (built from §2's mutable structure) of actions scheduled at future times.
set_signal updates the value and notifies; add_action registers an interest.set_signal on the output wire after the gate's delay, by adding to the agenda.function inverter(input, output) {
function invert_input() {
const new_val = input_signal(input) === 0 ? 1 : 0;
after_delay(INVERTER_DELAY, () => set_signal(output, new_val)); // SCHEDULE
}
add_action(input, invert_input); // watch the input wire
}
// driver loop:
function propagate() {
while (!agenda_empty(the_agenda)) {
const action = first_agenda_item(the_agenda);
action(); // may mutate wires and schedule more events
remove_first_agenda_item(the_agenda);
}
}
Set an input wire to 1 → its action procedures fire → the inverter schedules a set_signal(output,0) at now + INVERTER_DELAY → the driver later pops that, mutates output, which fires its watchers, and so on. The whole simulation is mutation choreographed by the agenda. Time is modeled explicitly as a number we advance — note that idea; Lesson 12 will say time becomes a problem when it is implicit.5 · Constraint propagation — relations, not procedures
The simulator still has a direction: inputs flow to outputs. Constraint propagation drops even that. A constraint like a + b = c is a relation: a network of connectors (cells holding a value or "no value") wired to constraint boxes (adder, multiplier, constant). Set any two of a, b, c and the box computes the third — it has no preferred direction. The same adder that finds c from a,b finds a from c,b. When a connector gets a value it notifies its constraints; each constraint checks whether it now has enough known values to fix an unknown, and if so sets it (recording who set it, so retracting a value can propagate backward). This is a foretaste of Lesson 18's logic programming, where "run forwards or backwards" becomes the whole paradigm — and it is only possible because connectors are mutable, shared, observed objects.
Common mistakes / failure modes
b = a aliases; it does not snapshot. A later set_head through a is visible through b. If you need an independent value, copy the structure explicitly.rear_ptr corrupts it — the next insert appends to the wrong cell. The whole point of mutation is keeping that pointer in sync.set_tail bypasses the interface and creates accidental sharing. Mutation must stay behind constructors/selectors/mutators, or invariants rot.Checkpoint exercise
set_head/set_tail, write append_in_place(x, y) that mutates the last cell of list x to point at y (no copying). What goes wrong if a caller later mutates y? (b) Trace this queue and give the final structure:
const q = make_queue();
insert_queue(q, "a"); insert_queue(q, "b");
delete_queue(q); insert_queue(q, "c");
Answers. (a) Walk to the last cell of x with repeated tail, then set_tail(lastCell, y). Danger: x and y now share all of y's cells — a later mutation through y is visible through x, and a cycle results if y later points back into x. (b) Insert a,b → [a]->[b], front→a, rear→b. delete → front advances to b: [b], rear→b. insert c → splice onto b and advance rear: [b]->[c], front→b, rear→c. Final queue front-to-rear: b, c. Every step was an O(1) pointer mutation.Where this points next
Mutation behind a clean interface gave us queues, tables, and faithful simulations of changing worlds. But notice what the agenda quietly guaranteed: the circuit simulator only computed the right answer because events were processed in a single, definite time order. The instant mutable state is shared among processes that run concurrently — that interleave rather than run in one fixed sequence — that guarantee evaporates, and the order in which operations touch the shared cell becomes a correctness problem. Two withdrawals from one account, interleaved the wrong way, can lose an update. Lesson 12 confronts this directly: concurrency turns the implicit time that mutation introduced into an explicit hazard, and asks what it means for a concurrent program to be correct.
set_head/set_tail moves changing state from frame bindings into the fields of data objects. The new hazard is sharing: aliased pairs mean a mutation anywhere a cell is reachable changes it everywhere — equality of contents no longer implies it is safe to substitute one object for another. The discipline is to keep mutation behind a small interface. Mutation pays off concretely: a queue with an explicit rear pointer inserts in Θ(1) (the pointer update is the entire reason to allow mutation); tables update in place and underpin the very dispatch tables of Lessons 07–08. Two simulators show the range: the digital-circuit simulator is event-driven mutation choreographed by a time-ordered agenda; constraint propagation drops direction entirely, computing any unknown from the knowns. Both lean on shared, observed, mutable objects — and the circuit's correctness depended on one definite time order, which is exactly what concurrency (Lesson 12) takes away.Interview prompts
- What hazard does mutating data introduce that mutating a variable did not make obvious? (§1 — sharing/aliasing: two structures pointing at the same cell, so a change through one is silently a change through the other; identity now matters, not just contents.)
- Why is mutation the whole point of a mutable queue? (§2 — an explicit rear pointer plus
set_tailgives Θ(1) rear insertion; without mutation each insert would rebuild the list in Θ(n).) - How does
inserton a mutable table avoid rebuilding it, and where have you seen this table before? (§3 — it mutates an existing record withset_tailor splices one new record after the head; it is the operation/type table behindapply_genericin Lessons 07–08.) - Explain the digital-circuit simulator's architecture. (§4 — wires are stateful objects with watcher actions; gates schedule
set_signalon outputs after a delay; an agenda (mutable time-ordered queue) drives events, each of which may schedule more — event-driven mutation.) - What makes constraint propagation bidirectional? (§5 — a constraint is a relation among connectors with no preferred direction; given enough known connectors a box computes any unknown, so the same adder runs "forwards" or "backwards" — a preview of logic programming.)
- Why does
b = afollowed byset_head(a, ...)changeb? (§1 —b = aaliases the same object rather than copying; the mutation is visible through every name that reaches the cell.) - What did the agenda silently rely on, and why does that point to concurrency? (§4, Where next — a single definite time order for events; once shared mutable state is touched by interleaving processes, that order is gone and ordering becomes a correctness problem.)