Mutation and References
A let binds a name to a value, and the binding does not change: within its scope the name denotes
that one value, and Part III built the environment so that looking the name up always finds it. A
program that only binds and reads can compute a result, but it has no way to express a quantity that
changes over time while the name that refers to it stays put — a counter that climbs, a running
total a loop adds into, a balance that a deposit raises. Each of those is one thing, referred to by
one name, holding a different value at a later moment than it held before.
The environment cannot supply that on its own. It maps a name to a value once, when the name is
bound, and a later read of the name returns the same value; rebinding the name with a second let
creates a new, separately scoped binding rather than altering the first. What changes over time has
to live somewhere the environment points at rather than somewhere it is — a cell whose contents
a program can overwrite, with the name holding the cell rather than the contents. This chapter
introduces that cell. It is the mutation the previous chapter named as Bridger’s
one built-in effect; here are the forms that create a cell, read it, and write it, and the addition
to the evaluation judgment that lets a written value be read back later.
Origins of mutable cells
Two traditions answer the question of what a name denotes, and they place mutation differently.
In Fortran, Algol 60, and their descendants C and Java, a variable
is a mutable cell, and the name and the cell are the same thing. Writing x = x + 1 reads the cell
the name x denotes, adds one, and stores the result back into that same cell; the name does not
acquire a new binding, the cell it has always named has the value it contains overwritten. Assignment
to a variable is the primitive action, and every variable is a memory cell a program can read or
write.
The functional tradition places it elsewhere. In Lisp and in Landin’s ISWIM, the reading of let
the book took in Part III treats a binding as a fixed association
between a name and a value; a name, once bound, denotes its value for the extent of its scope and is
never modified. A language built only from that has no mutation at all. Standard ML reconciled
the two by keeping the binding immutable and adding a separate kind of value — a reference cell,
a box allocated on demand, whose contents a program may read and overwrite while the binding that
holds the box stays fixed. Mutation moves out of the binding and into a value the binding refers to.
Bridger follows that split: a let binds once and for all, and a program that needs a value to
change asks for a cell and mutates the cell.
The store
A cell needs somewhere to live. The environment already maps names to values and does not change an existing entry, so the cells go in a second map, the store, written : a finite map from locations to values. A location, written , is the identity of one allocated cell — a value in its own right, the thing a name holds when it refers to a cell. The environment maps a name to a value; the store maps a location to a value; and a name bound to a cell is a name the environment maps to a location that the store, in turn, maps to the cell’s current contents.
The store changes as a program runs, so evaluation has to carry it. The judgment grew in Part III from to when names arrived; it grows again here to thread the store through the evaluation, taking one store in and handing one store out:
read as: in environment , evaluating against the store yields the value and
leaves the store . The environment goes in but does not come back — evaluating an
expression cannot change what a name is bound to — while the store goes in and comes out, because
evaluating an expression can change what a cell holds. (The reference’s full judgment threads one
more component, the relation database of Part VII,
and carries the extra outcome a return produces; this part uses the
env-and-store slice.)
Every rule now threads the store, and the threading fixes the order in which subexpressions run. A form with two operands evaluates the first against the incoming store, then the second against the store the first handed out, then combines the results — left to right, each step’s output store the next step’s input:
Addition takes the store in and passes it along unchanged in the sense that it writes nothing of its
own, but it still threads it: if evaluating allocates a cell, evaluates against the
store that already holds it. Part II left the order of +’s operands open,
observing that it could be fixed once effects existed; the threaded store is what fixes it. Most
forms are like +: they thread the store to pin down order and hand it back untouched. Three forms
change it.
Creating, reading, and writing a cell
Bridger writes the three operations as ref e, deref e, and e1 := e2.
ref e allocates a fresh cell holding the value of e, and the cell — its location — is the value
the form returns. The rule evaluates e, picks a location not already in use, and extends the store
so that the new location maps to the value:
The value handed back is , the location, not ; a program keeps the cell by binding the
location to a name, let counter = ref 0.
deref e reads a cell. It evaluates e to a location and returns what the store maps that location
to, leaving the store as it found it:
e1 := e2 overwrites a cell. It evaluates e1 to the location to write, then e2 to the value to
store there, then updates the store at that location and returns () — assignment is an effect, and
its value is the unit that reports only that the effect happened, exactly as
the previous chapter described for the effectful forms:
The order is the cell first, then the value written into it. It is visible when both sides have
effects of their own: in (f()) := (g()), f runs before g, because e1 is evaluated against
the incoming store and e2 against the store e1 produced. A cell together with these three forms
is enough to count:
let counter = ref 0;
counter := deref counter + 1;
deref counter // 1
ref 0 allocates a cell holding 0 and binds counter to its location. The assignment reads the
cell (deref counter is 0), adds one, and writes 1 back into the same cell; the location
counter holds has not changed, its contents have. The trailing deref counter reads the cell
again and finds 1. This is the shape a while loop
repeats to accumulate a result across
its iterations, which is why the cell arrives before the loop that needs it.
A location is a value like any other, so it can be read from a cell, passed to a function, or returned — and two names can end up holding the same location:
let a = ref 0;
let b = a; // b holds the same location as a
a := 1;
deref b // 1
let b = a binds b to the value a holds, which is a location, so a and b name one cell.
Writing through a changes the cell both refer to, and reading through b finds the new value.
This is aliasing: two names for one mutable cell, so that a write reached by one name is
observed through the other. It follows directly from a name holding the location rather than the
contents, and it is what makes shared mutable state — and structures that point at each other —
expressible. It is also what makes a program with mutation harder to reason about than one without,
since a write here can be a change seen there; Part V
returns to the style that keeps mutation scarce for that reason.
Building it
The store is a piece of interpreter state beside the environment. The provided runtime supplies it
as a Store with three operations — allocate a cell and get back its location, read a location, and
write a location — and a location is a small handle that a Value can carry:
Value::Ref(Loc) // a value that is a location in the store
impl Store {
fn alloc(&mut self, v: Value) -> Loc; // E-Ref: fresh cell, gives its location
fn read(&self, l: &Loc) -> Value; // E-Deref: the cell's contents
fn write(&mut self, l: &Loc, v: Value); // E-Assign: overwrite the cell
}
The three forms are three arms of eval_expr. ref e evaluates e
and calls alloc, returning Value::Ref of the location. deref e evaluates e to a
Value::Ref(l) and returns store.read(&l). e1 := e2 evaluates e1 to a Value::Ref(l), then
e2 to a value, calls store.write(&l, v), and returns Value::Unit; a deref or := whose
operand is not a location is a runtime type error, the store’s stuck
case. The rules thread a store in and a store out; the interpreter instead keeps the one store on
the interpreter and mutates it in place, so alloc and write are the step from to
— the threading made physical. A cell, once allocated, is never reclaimed: the store only
grows, matching E-Ref, which always takes a fresh location and never reuses one. When the store is
read back as a space cost, that choice returns as a subject in
Part X.
The surface form ref x = e is not a new binding. It reads as let x = ref e: allocate a cell for
e and bind x to its location. It earns its own keyword because binding a name to a fresh cell is
the common case and ref x = 0 says it more plainly than let x = ref 0, but the parser expands it
to the let and the evaluator sees only the two forms it already has.
The type a reference carries, written ref<τ> for a cell holding values of type τ, and the rules
that keep deref and := from being applied to a non-location before the program runs, are the
subject of the type checker in Part VI.
The evaluator here admits any location where one is required and reaches its stuck case at runtime
when handed something else.
The design space
The three languages that keep the Algol boundary of the previous chapter also keep the
variable-as-cell model. In C, Java, and Python a plain variable is mutable: x = e reassigns the
name, and there is no separate word to allocate a cell or to read one, because every variable
already is one. What Bridger writes as ref/deref/:= is, in those languages, the unmarked
default state of every name. Aliasing is present there too, carried by pointers or object
references, and it is likewise implicit — two variables may refer to one object with nothing in the
syntax to say so.
ML draws the line where Bridger does: a binding is immutable, a reference cell is a separate value
created by ref and reached by an explicit read and write, and a mutation site is visible in the
program as one of those operations. The cost is the two extra words, ref and deref, on every use
of a cell; what they buy is that a name not bound to a ref cannot be assigned into at all, so the
places a program mutates are exactly the places those words appear.
Rust marks mutation on the binding but restricts the sharing. A let is immutable and let mut
makes a rebindable variable, closer to the variable-as-cell model; sharing goes through borrows,
& and &mut, and the compiler’s rules forbid a mutable borrow to coexist with any other borrow of
the same value, so the aliasing that Bridger and ML permit freely is the case Rust’s borrow checker
exists to rule out. Bridger takes ML’s first-class cell rather than Rust’s borrowing because a cell
that is an ordinary value needs no second discipline layered over the language — one immutable let
binds cells and non-cells alike, and a location is passed, stored, and compared like any other
value. What that leaves on the table is the compiler-checked guarantee against data races and
aliased writes that Rust’s rules provide; Bridger permits the aliasing and asks the programmer to
manage it.
Across the three, the decision is where a program says “this can change”: on every variable by
default, on a binding marked mut, or on a value obtained through ref. Bridger’s answer keeps the
binding immutable and names the cell, so that the environment of Part III is left exactly as it was
and everything that changes over time lives in the store.
Further reading
Reference cells and the store: The Definition of Standard ML (Robin Milner, Mads Tofte, Robert
Harper, and David MacQueen, revised edition, 1997) gives the reference cell — ref, !, and := —
its formal semantics over a store, the model Bridger’s cell follows. Benjamin Pierce’s Types and
Programming Languages (2002) devotes its chapter “References” to building mutable cells on top of a
pure calculus: the store as a map from locations to values, the evaluation relation threaded through
it, and the typing of Ref T, in the same order this part and Part VI take them. The
variable-as-cell model, for contrast, is described in the “References”
and ownership chapters of The Rust Programming Language, where mutability is a property of a
binding and a borrow rather than a value, and the borrow checker rules out the aliased writes the
store model here permits.
Concept checks
After let a = ref 0; let b = a; a := 1;, what does deref b evaluate to, and why?
1. ref 0 allocates one cell and returns its location; let a binds a to that location, and
let b = a binds b to the value a holds — the same location — so a and b name one cell.
a := 1 writes 1 into that cell. deref b reads the cell b holds, which is the cell a wrote,
so it finds 1. The two names are aliases because each holds the location, not a copy of the
contents; if let b = a had somehow copied the cell rather than the location, deref b would still
be 0.
e1 := e2 evaluates the cell e1 before the value e2. When is that order observable, and what value does the assignment return?
The order matters whenever evaluating e1 and e2 both have effects, since one runs against the
store the other leaves behind. In (f()) := (g()), f is called before g: e1 evaluates against
the incoming store and e2 against the store e1 produced, so an allocation or a write inside f
is already in place when g runs. If f and g are pure the order cannot be told apart. The
assignment itself returns (): it is written for its effect on the cell, and unit is the value that
reports only that the effect happened.
Why does the evaluation judgment take a store in and hand a store out, while the environment only goes in?
Evaluating an expression can change what a cell holds — that is the whole point of := — so the
store that a later step sees has to be the one earlier steps produced, which means each evaluation
must both consume and produce a store. Threading it this way is also what fixes left-to-right order:
a two-operand form evaluates its first operand against the incoming store and its second against the
store the first handed out. The environment, by contrast, is not changed by evaluating an
expression: a let extends the environment for the code in its scope, but no expression
reassigns an existing binding, so the environment is an input the evaluation reads and never an
output it revises.
C makes every variable a mutable cell; Bridger keeps bindings immutable and creates cells with ref. What does each choice make easy, and what does each give up?
C’s default costs nothing to write — x = e mutates with no extra word, because a variable already
is a cell — and the price is that every name is a possible mutation site and aliasing through
pointers is implicit, so a reader cannot tell from a name whether it changes or is shared. Bridger’s
default is the reverse: a name bound by let cannot be assigned into, and mutation shows up in the
program as ref, deref, or :=, so the places state changes are exactly the places those forms
appear; the price is the extra words on every use of a cell. One makes mutation frictionless and its
extent invisible; the other makes mutation deliberate and its extent visible. Neither removes
aliasing — both allow two names for one cell — they differ in whether the syntax marks where it can
happen.
The surface form ref x = e is not a distinct binding form. What does it stand for, and why does keeping it sugar simplify the evaluator?
ref x = e reads as let x = ref e: allocate a fresh cell holding e and bind x to its
location. Because it expands to a let of a ref expression, the parser can rewrite it away and
the evaluator never sees a third binding form — it has the let binding of Part III and the ref
expression of this chapter, and their composition is all ref x = e means. Giving the common case
its own spelling costs nothing at evaluation time, since the two forms it abbreviates already carry
the semantics.