Skip to content
Select theme

Evaluating expressions

The tree is complete, and nothing reads it. Last step’s build ended in a row of dead_code warnings — one per field nobody looks at — because the parser builds nodes and stops. This step gives the nodes something to do: a tree-walking evaluator, the simplest kind of interpreter there is. A Value enum says what expressions produce, evaluate walks an Expr and returns one, execute walks statements. By the end, 1 + 2 * 3; prints 7, [10, 20, 30][1] prints 20, and false and (1 / 0); prints false — the division never ran.

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

Terminal window
git checkout interpreter-ch04
cargo run -q

That prints the syntax trees from last time. This article stays inside the same single file, src/main.rs: it adds a Value enum, two functions — evaluate and execute — and a new main, and touches nothing above them. The scanner and the parser are byte-identical to the last step. At the end, the same command at the interpreter-ch05 tag runs programs instead of printing trees.

The tree describes computation; a value is the thing computation produces. birch needs one more enum for that, and this one has no recursion at all:

#[derive(Debug, Clone, PartialEq)]
enum Value {
Number(f64),
Str(String),
Bool(bool),
Nil,
List(Vec<Value>),
}

No Box anywhere. Expr needed boxes because an expression contains expressions; a value contains only smaller values, and a list’s elements sit behind a Vec anyway. The recursion is gone from the type because it is gone from the grammar: evaluating a subtree always finishes.

Two small impls ride along. is_truthy fixes the one question if and ! will ask — everything is true except nil and false themselves, the rule Python and Lua use:

impl Value {
fn is_truthy(&self) -> bool {
!matches!(self, Value::Nil | Value::Bool(false))
}
}

And a Display impl, because Debug would print Number(7.0) where the language should print 7. Without it, every result the interpreter shows would be wearing Rust’s clothes.

The evaluator is two functions, one per half of the grammar, and each is a match over the enum the parser builds. evaluate takes a reference to an Expr and hands back either a Value or a RuntimeError:

fn evaluate(expr: &Expr) -> Result<Value, RuntimeError> {
match expr {
Expr::Number(n) => Ok(Value::Number(*n)),
Expr::Str(s) => Ok(Value::Str(s.clone())),
Expr::Bool(b) => Ok(Value::Bool(*b)),
Expr::Nil => Ok(Value::Nil),
Expr::List(items) => {
let mut values = Vec::with_capacity(items.len());
for item in items {
values.push(evaluate(item)?);
}
Ok(Value::List(values))
}
// ... every other variant, one arm each

The shape of the code is the shape of the tree. Children are evaluated first — post-order, the same order the parser built the nodes — and then the operator acts on the values that came back. Binary evaluates both operands and dispatches on the operator it stored last step:

Expr::Binary { op, left, right } => {
let l = evaluate(left)?;
let r = evaluate(right)?;
match op {
TokenKind::Plus => match (&l, &r) {
(Value::Number(a), Value::Number(b)) => Ok(Value::Number(a + b)),
(Value::Str(a), Value::Str(b)) => Ok(Value::Str(format!("{a}{b}"))),
_ => Err(RuntimeError {
message: format!(
"`+` needs two numbers or two strings, got {} and {}",
type_name(&l),
type_name(&r)
),
}),
},
// Minus, Star, Slash, Percent: numbers only
// Less, LessEqual, Greater, GreaterEqual: numbers to Bool
// EqualEqual, BangEqual: derived PartialEq

Three decisions hide in those arms. + has two behaviours — numbers add, strings concatenate — and anything else is a runtime error, not a silent coercion. Division refuses to run at all when the right side is zero, because f64 would otherwise happily return inf or NaN:

TokenKind::Slash | TokenKind::Percent if *b == 0.0 => {
return Err(RuntimeError {
message: String::from("division by zero"),
});
}

And equality does nothing at all: the PartialEq the enum derives is the whole implementation, which makes 1 == "1" simply false — no coercion, no error, no code.

Binary is the eager half: both operands evaluate, always. Logical is the half that decides early, and this is why and and or got their own variant last step:

Expr::Logical { op, left, right } => {
let value = evaluate(left)?;
let decided = match op {
// `and`: a falsey left has already decided the result
TokenKind::And => !value.is_truthy(),
// `or`: a truthy left has already decided the result
TokenKind::Or => value.is_truthy(),
_ => unreachable!("the parser only builds Logical from `and` and `or`"),
};
if decided {
Ok(value)
} else {
evaluate(right)
}
}

Short-circuiting is a fact about evaluation order, and the tree recorded it as data: one variant for the operators that can skip, one for the operators that cannot. The result is the deciding operand’s value, not a Boolean — so nil or "fallback" is "fallback", the way Python and JavaScript read.

Then the match runs out of easy variants. Variable needs an environment to look names up in; Assign needs the same environment to store into; Call needs functions to exist as values. None of that exists yet — the environment is the next step’s subject — but the match still has to be exhaustive. That is the property this whole track has been building towards: the compiler will not let an evaluator forget a node kind. Add an Expr variant anywhere in this file and every match over Expr stops compiling until it is answered.

So the three blocked variants fail with the errors a reader would actually hit, and Index — which needs no state at all, because both sides evaluate to plain values — works in full:

Expr::Variable(name) => Err(RuntimeError {
message: format!("undefined variable `{name}`"),
}),
Expr::Assign { name, value } => {
// the right side still evaluates, in the order a real
// assignment would use; only the store is missing
evaluate(value)?;
Err(RuntimeError {
message: format!("undefined variable `{name}`"),
})
}

Assign still evaluates its right side first, and Call still evaluates its callee and arguments, in the order a real call would use — only the final act is missing. When the environment arrives, these are the arms it replaces, and nothing else in the evaluator changes.

RuntimeError is one field:

struct RuntimeError {
message: String,
}

Compare that with ParseError, which carries a line and a column. The parser can point: it still holds tokens, and tokens have positions. The evaluator cannot — the tree stores operators as bare TokenKind, and the nodes carry no positions at all, so a runtime error in a two-hundred line script says division by zero and leaves the where to the reader. That is a real limitation and the price of last step’s design, and it is on the itinerary: the error-reporting step puts positions back into the tree itself.

execute mirrors evaluate, one arm per statement:

Stmt::Expression(expr) => {
let value = evaluate(expr)?;
println!("{value}");
Ok(())
}
Stmt::If { condition, then_branch, else_branch } => {
if evaluate(condition)?.is_truthy() {
execute(then_branch)
} else if let Some(else_branch) = else_branch {
execute(else_branch)
} else {
Ok(())
}
}

An expression statement evaluates and prints its value — the cheapest way to watch the interpreter work until the REPL step gives the language a real read-eval-print loop. If, While and blocks need no state: their conditions and bodies are plain subtrees, so they work in full today. A while loop is worth a second look, though. Looping needs something to change between iterations, and with no variables nothing can change — so every loop this step either runs zero times or never ends. The environment fixes that next step.

The three statements that remain need somewhere to put things: var evaluates its initializer and then has nowhere to store it, fun has nowhere to store the function, and return has no call to return from. Each fails with the message a reader would hit — return outside of a function is the same words every language prints — and all three arms are what the environment step replaces.

This is the complete src/main.rs for this step. The scanner and the parser are unchanged from last step; the values, the evaluator and the new main are new:

Terminal window
cargo run -q

The program runs each snippet as a script. Expression statements print their values in order; a runtime error stops that snippet at the first failure, the way a script aborts:

1 + 2 * 3;
"na" + "na";
!(1 == 2);
[10, 20, 30][1];
if (2 > 1) "big"; else "small";
7
nana
true
20
big
"count: " + 3;
runtime error: `+` needs two numbers or two strings, got a string and a number
1 / 0;
runtime error: division by zero
false and (1 / 0);
false
nil or "fallback";
fallback
x;
runtime error: undefined variable `x`
var count = 0;
runtime error: cannot declare `count`: there is no environment yet
return 1;
runtime error: return outside of a function

Worth checking against the source. false and (1 / 0); prints false — not a division error — because the left side decided and the right side never evaluated. nil or "fallback"; prints fallback for the same reason read the other way round. And the four runtime errors at the bottom are the not-implemented-yet half of the grammar failing loudly instead of silently, exactly as the exhaustive match demanded.

One detail from last step is now settled: the dead_code warnings are gone. Every field of every node is read — var’s initializer and return’s value by the evaluator that runs them before reporting that it cannot store them, fun’s parameters by the one check that needs no storage (two parameters with the same name could never be told apart), and fun’s body by being named rather than skipped. The tree is fully walked.

The project at interpreter-ch05 scans, parses and now evaluates the whole birch grammar — everything, that is, except the parts that need an environment. x; is undefined because there is nowhere for x to be defined, and that is precisely the gap. The next step, Variables and scopes, closes it: a chain of environments that stores values, looks them up, and gives while loops something to change. Until then, the track overview has the full itinerary.