Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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.