Environments and let
Through Part II an expression meant one value wherever it stood: 2 + 2 is 4 in every context,
and the evaluator needed nothing but the expression itself to find it. A
variable is the first form whose value the expression does not settle on its own. x stands for 3
where a binding says so, for "hi" where another one does, and for nothing at all where none does.
The structure that supplies each name’s value is the environment, and this chapter describes
what an environment is and how it came to be.
Algol 60 made where does a name refer? a question with a principled
answer — a name introduced in a region of a program means something inside that region and not
outside it — but left open what such a region is made of once the program runs. The environment is
the answer: a map from the names in scope to the values they currently denote. In Bridger, we use
let x = e to update the environment to say that the name x now refers to the value e
evaluates to.
A name has a value only in context
The evaluation judgment has carried an
environment since Part I, and every rule threaded the
same through its premises. Until now nothing read or modified the environment. The
arithmetic, boolean, string, and list forms have no variable to look up, so each rule passed
along untouched and the derivations dropped it, writing ; the starter’s eval_expr did
the same, taking an env parameter that no arm consulted. A variable changes that. Its rule reads
the environment and nothing else:
is a finite map from variables in scope to values, ;
is the value it binds to . When has no binding for , the premise
cannot be met, no rule applies, and the expression is
stuck — the same absence of a
derivation that a type mismatch produces, reported as the runtime error UnboundVariable. An
unbound name is the failure this chapter adds to the list; every other stuck case so far came from a
value of the wrong shape, and this one comes from a name with no value at all.
The environment as an explicit part of an evaluator goes back to Peter Landin’s SECD machine, whose
four components — stack, environment, control, dump — evaluate an expression by looking each
variable up in the environment component, the E (Landin,
1964). Two years later the same author’s ISWIM gave the
notation let x = e (and its mirror, the where clause) for naming a value over a region of an
expression (Landin, 1966); the keyword has since landed in
languages far from ISWIM, Bridger among them.
The let operator
A block is a sequence of items evaluated top to bottom, and its value is
its final expression. A let is one kind of item: it evaluates its right-hand side and binds the
name over the rest of the block. Writing for the block that remains
after the first item, the rule extends the environment for that remainder and for nothing else:
The notation is the environment with bound to added: it agrees with on every other name and answers for . The right-hand side is evaluated in — the environment before the binding is added — so the name being bound is not yet in scope while its own value is computed. The rest of the block is evaluated in the extended environment, which is where the new name can be read.
An item that is a bare expression is evaluated for whatever it does and its value discarded; the block carries on in the same environment. The final expression is the block’s value, and an empty block is unit:
Because extends the environment only over the block’s remainder, a binding reaches
from its let to the end of the enclosing block and no further. A name bound inside a block is not
in scope after the block ends, and a name bound in an outer block is in scope within an inner one
unless the inner block binds the same name again.
A binding is confined to its block even when that block is nested inside another. Take a block whose first item is itself a block:
{
{ let x = 1; x + 1 }; // inner block; x is bound only within it
x // UnboundVariable: x's scope ended at the }
}
The outer block evaluates its first item — the inner block — by , which evaluates it
in the current environment and discards the result. Inside, extends the environment
with for the inner block’s remainder alone, yielding 2. then
evaluates the rest of the outer block in the same environment it began with — the one with no
x — so the final x is unbound. A block produces a value, not an environment: the binding made
inside the inner block never leaves it.
Binding a name a second time is shadowing: the
new binding answers for from that point on, and the earlier value is
unreachable through the name for the rest of that region though it is not destroyed. Since the
right-hand side is evaluated before the binding is added, a let may define a name in terms of the
value the same name held before it:
let x = 1;
let x = x + 1; // the x on the right is the old 1; x is now 2
x // 2
Building it
The environment now reaches the evaluator’s signature. The provided method is
fn eval_expr(&mut self, e: &Expr, env: &Env) -> Result<Value, Control>
and the env parameter, carried unused since Milestone M1, is what the
new arms read. An Env offers two operations: lookup, which returns the value a name is bound to
or None, and extend, which returns a new environment with one more binding and leaves the one
it was called on untouched. needs only the first: look the name up, and a miss is
UnboundVariable. A block threads the second — it walks its items carrying an environment that
grows as each let is met — and because every extension produces a fresh scope, that growth reaches
the rest of the block and stops at its closing brace.
// M2: read a variable, and evaluate a block by threading a
// scope that each `let` extends — scope = scope.extend(x, v)
Expr::Var(..) => todo_m2!("look up a variable"),
Expr::Block(..) => todo_m2!("evaluate a block"),
That the environment is never rewritten is the rule the whole chapter turns on. is
read-only: a let adds a binding, and rebinding a name is a new binding that shadows the old,
not a change to it. Making a name’s value change over time — a counter that counts — is a separate
mechanism, a mutable cell held in the store, and it arrives in Part IV.
How an Env is represented, and what lookup costs as scopes nest, is the next
chapter; how a name’s binding is fixed by where it is written rather
than by where it is used — the property that makes scope decidable before the program
runs — is the chapter after. Reading variables and evaluating blocks
completes the evaluator’s work for Milestone M2.
The design space
let need not be a form of its own. Landin’s observation was that a block beginning
let x = e_1; computes what applying the function — whose body is the rest of
the block — to the argument computes: both evaluate , bind the result to , and
evaluate that body in the extended environment. A language with functions can treat let as sugar
for that application and carry one rule instead of two. Bridger keeps let a form in its own
right — functions arrive in Part V, and let is available
from the first block a program writes — but the two describe the same binding, and the closure rule
in Part V will extend the environment the same way does.
Whether a name is in scope within its own right-hand side is a genuine fork, and
takes a side: it evaluates in the environment before the binding is added, so x is bound
after its definition, not during it. A definition may therefore use the earlier meaning of a
name it rebinds, and a let-bound function cannot call itself, since the name is not in scope while
the function value is being built. Languages that want a let to bind a recursive function offer a
separate recursive-binding form — let rec, or a letrec that binds the name over its own
right-hand side — that closes exactly this gap. In Bridger, self-reference goes through a top-level
fn, where every top-level name is in scope in every definition, so functions may call themselves
and each other regardless of order.
Rebinding a name that is already in scope is a choice too. Bridger allows it — shadowing is ordinary — where some languages reject a second binding of a name in the same scope and require the programmer to choose a fresh one. Allowing it lets a block reuse a name for a series of related values; rejecting it turns an accidental reuse into an error the compiler catches. The tradeoff is between a convenience and a class of mistake, and Bridger takes the convenience.
Further reading
The environment and let: Peter Landin’s The Mechanical Evaluation of
Expressions (The Computer Journal, 1964) introduces the
SECD machine, whose environment component is the structure a variable is resolved against, and The
Next 700 Programming Languages (Communications of the ACM,
1966) introduces ISWIM and its let and where notations for naming a value over a region of an
expression, together with the reading of a local definition as a function applied to its
right-hand side.
The environment model of evaluation: Abelson and Sussman’s Structure and Interpretation of Computer Programs (MIT Press, free online) develops evaluation as the extension of environments in its chapter of that name, working through how a binding form adds to the environment and how a name is looked up in it.
Concept checks
Evaluating x in an environment with no binding for x produces the runtime error UnboundVariable. In what sense is this the same kind of failure as 1 + true, and in what sense is it new?
It is the same kind in that it is stuck: ’s premise cannot be met,
so no rule applies and there is no derivation, exactly as no rule applies to adding a boolean to an
integer. The interpreter returns Err(Control::Raise(RuntimeError::UnboundVariable { … })) and
halts the run; the missing value is the absence of a result, not a special result. What is new is
the cause. Every stuck case in Part II came from a value of the wrong shape reaching an operator;
this one comes from a name that denotes no value at all, so it is caught by looking in the
environment rather than by inspecting a computed value.
What does the block { let x = 1; let x = x + 1; x } evaluate to, and which environment is each x + 1 and final x read in?
It evaluates to 2. The first let evaluates 1 in the incoming environment and extends it to
. The second let’s right-hand side x + 1 is evaluated in that environment —
the one before the second binding is added, by — so its x is the 1 just
bound, and the block extends to , in which x now reads 2. The
final x is evaluated there and yields 2. The first binding is shadowed, not overwritten: it
supplied the value the second definition read.
let x = e; rest and applying λx. rest to e compute the same value. Why does Bridger keep let as its own form, and why can't a let-bound lambda call itself?
The two agree because both evaluate e, bind the result to x, and evaluate the body in the
extended environment; a language with functions can define let as that application. Bridger keeps
let a primitive form because binding is available from the first block a program writes, before
functions are introduced in Part V, and carrying it as its own rule keeps blocks self-contained. A
let-bound lambda cannot call itself because evaluates the right-hand side in the
environment before the binding is added: the name is not yet in scope while the function value is
built, so the closure captures no binding for it. Self-reference in Bridger goes through a top-level
fn, where all top-level names are mutually in scope.
The environment is described as read-only, yet a program plainly binds many names. What is read-only about it, and what does extend returning a new child scope guarantee?
Read-only means no operation changes a binding once made: extend produces a new environment with
an added binding and leaves its parent exactly as it was, and lookup only reads. Binding x
twice does not rewrite the first binding; it produces a further environment in which a lookup of
x finds the newer one first. Because extend yields a child rather than mutating in place, the
environment a block builds is local to that block — when evaluation of the block finishes, that
environment is discarded and the caller’s environment is untouched, which is why a name bound
inside a block is not in scope outside it. Making a name’s value change over time is a different
mechanism, a mutable cell in the store, and it is the subject of Part IV.
What does the block { let x = 5; } — a block whose last item is a let, with no trailing expression — evaluate to?
It evaluates to (). A block’s value is its trailing expression, and this block has none: its only
item is a binding. A let binds a name over the rest of the block and contributes no value itself,
so a block that ends with one has the same value as an empty block, unit. The binding x is
in scope only for whatever would follow it in the block, and here nothing does.