Skip to content
Select theme

Variables and scopes

Last step’s evaluator computes every expression in the grammar, but the four arms that mention names all refuse to run — and x; failing with undefined variable \x`was the honest outcome, because there was nowhere forxto be *defined*. This step gives the interpreter a memory. AnEnvironmentholds the names a program has made and the values they carry, and the two walking functions carry it with them as they go. By the end,vardeclares,=assigns, blocks shadow, and awhile` loop finally has something to change.

The previous step is published and tagged, so start from its end state:

Terminal window
git checkout interpreter-ch05
cargo run -q

Still one file, src/main.rs. The scanner and the parser are untouched; nothing above the evaluator moves. The changes are: both walking functions gain a second parameter, the blocked name arms become real ones, a new environment section sits between RuntimeError and the evaluator, and main runs a new set of programs. At the end, the same command at the interpreter-ch06 tag runs variables, loops and scoped blocks.

The textbook picture is a linked chain of maps: the current scope points at the scope that encloses it, and looking up a name walks outward until somebody answers. In Rust the same chain is a stack — a Vec of maps, innermost last:

struct Environment {
scopes: Vec<HashMap<String, Value>>,
}

A parent-pointer chain is the classic shape, and a Rust version with Option<Box<Environment>> exists — but entering a block means moving the whole chain in and out of the tree, and the borrow checker makes that dance noisy. A stack is the same chain laid out in memory. Push a scope on block entry, pop it on exit, and “walking the chain” is iterating the Vec backwards. The three moves the language needs:

impl Environment {
fn new() -> Self {
Environment {
scopes: vec![HashMap::new()],
}
}
// `var`: a name appears in the innermost scope, shadowing anything
// of the same name further out
fn define(&mut self, name: String, value: Value) {
self.scopes.last_mut().unwrap().insert(name, value);
}
// reading walks the chain from the top down and returns the first
// match; a missing name is the caller's error to report
fn get(&self, name: &str) -> Option<Value> {
for scope in self.scopes.iter().rev() {
if let Some(value) = scope.get(name) {
return Some(value.clone());
}
}
None
}
// assignment also walks the chain, but it can only *update* a scope
// that already has the name — `count = 1` inside a block writes to
// the outer binding, or fails if no scope down the chain has it
fn assign(&mut self, name: &str, value: Value) -> bool {
for scope in self.scopes.iter_mut().rev() {
if scope.contains_key(name) {
scope.insert(name.to_string(), value);
return true;
}
}
false
}
}

Shadowing falls out of the iteration order — no code anywhere says the word “shadow”. get returns the first match walking outwards, so an inner var x simply hides the outer one until its scope dies. And assign is deliberately narrower than define: assignment never creates a binding, it can only update one some scope already has. That one rule is the difference between

var count = 0;

and

count = 0;

— the second is a runtime error unless a count exists somewhere down the chain, which is the guard that catches a misspelled name instead of silently inventing a second variable.

evaluate and execute now both take the environment as their second parameter — &mut Environment, because assignment and var write to it — and every recursive call passes it down, including the arms that do not need it. Last step’s blocked arms become real ones:

Expr::Variable(name) => env.get(name).ok_or(RuntimeError {
message: format!("undefined variable `{name}`"),
}),
Expr::Assign { name, value } => {
// assignment is an expression: its value is the value
// assigned, which is what makes `a = b = 2` parse and work
// with no extra machinery — the parser already made `=`
// right-associative, and the value just falls through
let value = evaluate(value, env)?;
if env.assign(name, value.clone()) {
Ok(value)
} else {
Err(RuntimeError {
message: format!("undefined variable `{name}`"),
})
}
}

Assign returning a value is not an extra feature the evaluator grew — it is a consequence of the grammar. The parser made = an expression two steps ago (right-associative, binding loosest), so a = b = 2 is already a tree whose node produces the assigned value. The evaluator just declines to throw it away.

Declarations are the mirror image:

Stmt::Var { name, init } => {
// the initializer evaluates first, in the scope the name
// is about to live in — so `var x = x;` reads the outer
// `x`, the way most dynamic languages do
let value = evaluate(init, env)?;
env.define(name.clone(), value);
Ok(())
}

One statement left changes the shape of the environment rather than its contents:

// A block is a scope: push a fresh map, run the body, pop it — even when
// a statement in the middle fails, so the caller's environment comes
// back exactly the way it went in.
fn execute_scoped(statements: &[Stmt], env: &mut Environment) -> Result<(), RuntimeError> {
env.scopes.push(HashMap::new());
let result = statements.iter().try_for_each(|s| execute(s, env));
env.scopes.pop();
result
}

That is why the pop happens before the result is returned: a runtime error in the middle of a block unwinds the scope anyway, and the caller’s environment comes back exactly the way it went in. if and while need nothing of the sort — their bodies are ordinary statements (usually blocks, which scope themselves), so their arms only gained the extra argument.

Call stays blocked. The callee and its arguments still evaluate, in the order a real call would use, and then the arm reports a number cannot be called — still true, because nothing in the language is callable yet. That is the next step’s machinery, and it is the only variant the environment did not unlock.

Last step’s awkward admission: every while loop either ran zero times or never ended, because nothing could change between iterations. Now:

var total = 0;
var i = 1;
while (i <= 5) {
total = total + i;
i = i + 1;
}
total;

The transcript doubles as a trace. Each assignment statement prints its value — assignment is an expression, remember — so every iteration leaves two lines: the new total, then the new i. 1, 2, 3, 3, 6, 4, 10, 5, 15, 6, then the loop stops with i at 6, and the final total; prints 15.

The same read explains the block demo:

var count = 0;
{
count = count + 1;
count = count + 1;
}

prints 1 and 2 — both writes reached the outer binding, because the block contains no var count and assign keeps walking. Contrast it with the shadowing demo in the output below, where var x = "inner" creates a binding the block does own, and the outer x is exactly where it was when the block ends.

This is the complete src/main.rs for this step. The scanner and the parser are unchanged from last step; the environment section, the new signatures and the unblocked arms are new:

Terminal window
cargo run -q

Each snippet runs as its own script with its own environment — a runtime error aborts that snippet and nothing leaks into the next:

1 + 2 * 3;
"na" + "na";
!(1 == 2);
[10, 20, 30][1];
if (2 > 1) "big"; else "small";
7
nana
true
20
big
var greeting = "hello";
greeting = greeting + ", world";
hello, world
var total = 0;
var i = 1;
while (i <= 5) {
total = total + i;
i = i + 1;
}
total;
1
2
3
3
6
4
10
5
15
6
15
var x = "outer";
{
var x = "inner";
x;
}
x;
inner
outer
var count = 0;
{
count = count + 1;
count = count + 1;
}
1
2
var a = 1;
var a = 2;
a;
2
missing;
runtime error: undefined variable `missing`
{ undone = 3; }
runtime error: undefined variable `undone`
fun add(a, b) { return a + b; }
runtime error: cannot define `add`: functions are not implemented yet
return 1;
runtime error: return outside of a function

Two errors at the bottom are worth reading as a pair. missing; fails because no scope anywhere holds the name. { undone = 3; } fails for a subtler reason: the block’s scope is real, but assign refuses to create a binding in it, so the walk reaches the global scope, finds nothing, and reports the name — exactly the guard the last section promised. And fun add(a, b) still refuses, now with a message that dates it: functions are one step away, not missing forever.

birch at interpreter-ch06 scans, parses, evaluates and remembers. Variables, assignment, blocks and loops all run; what remains is the half of the grammar that needs more than storage — fun bodies cannot run, and return outside of a function is the literal truth. The next step, Control flow and functions, adds the call machinery: a value that can be called, a call stack, and what return returns to. Until then, the track overview has the full itinerary.