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

Tree-Walking Evaluation

The last two chapters introduced Bridger’s AST and its values. This chapter begins the evaluator: the procedure that maps an AST to a value. It covers the arithmetic, Boolean, string, list, and tuple expressions that Milestone M1 asks you to build, whose semantic rules take the form : the expression evaluates to the value . The rest of those rules carry detail that does not bear on this fragment, and this chapter sets it aside.

Tree-walking evaluator

The rules for evaluation are compositional: the value of an expression is defined in terms of the values of its subexpressions. The E-Add rule only fixes the meaning of once the meanings of and are known.

This shape of giving meaning is naturally recursive: to evaluate a node, first evaluate its children, then combine their values as the node’s rule prescribes. A leaf (a literal) is the base case: it carries its own value and does not recurse. The little arithmetic language below is fixed by four rules: E-Add above, together with a literal axiom, negation, and multiplication:

The evaluator you develop following this book will recurse over the structure of an Expr. Below is a tree-walking evaluator for the little language above (similar to the one you will be asked to write):

enum Expr {
    Lit(i64),                       // 5
    Neg(Box<Expr>),                 // -e
    Add(Box<Expr>, Box<Expr>),      // e + e
    Mul(Box<Expr>, Box<Expr>),      // e * e
}

fn eval(e: &Expr) -> i64 {
    match e {
        Expr::Lit(n)    => *n,                       // E-Lit
        Expr::Neg(x)    => -eval(x),                 // E-Neg
        Expr::Add(l, r) => eval(l) + eval(r),        // E-Add
        Expr::Mul(l, r) => eval(l) * eval(r),        // E-Mul
    }
}

fn main() {
    // (1 + 2) * 4
    let e = Expr::Mul(
        Box::new(Expr::Add(Box::new(Expr::Lit(1)), Box::new(Expr::Lit(2)))),
        Box::new(Expr::Lit(4)),
    );
    println!("{}", eval(&e)); // 12
}

Each rule became one arm of the match; each premise became a recursive call; each conclusion became the value the arm returns. The calls eval makes on trace the same tree as the derivation the rules build for it — eval(Mul) waits on eval(Add) and eval(Lit 4), and eval(Add) waits on its two literals — so the call tree and the proof tree have the same shape. Running the program computes the value at the root by the same steps the derivation uses to justify it.

Interpreting a program by a function that recurses over its structure is the oldest way to run one. McCarthy’s LISP defined its own evaluator this way: eval was an ordinary function, written in LISP, that took a program — itself a LISP list — and walked it, dispatching on each form and calling itself on the pieces. An interpreter for a language, written in that same language, is a metacircular evaluator, and the arithmetic walk above is the same idea with the program held as a Rust enum instead of a list.

From rules to match arms

Bridger’s evaluator has the same skeleton, over the real Expr and the real Value. Two things grow past the arithmetic sketch. Evaluation can fail — an operand can have the wrong shape, so the function returns Result<Value, Control> rather than a bare value, and a subexpression’s exit is propagated with ?. And the arithmetic fragment has no variables, so the environment carried by the reference judgment plays no part yet; eval reads only its &Expr, and the rules are read in the store-free, env-free slice — the starter’s eval_expr already carries an env parameter, which nothing reads until Part III. The environment enters with let and scope there.

The correspondence is otherwise unchanged: one rule, one arm. The previous chapter wrote the Add arm in full — evaluate both operands, and on two integers return their sum, otherwise raise a TypeError. The rest of eval is built the same way, arm by arm:

fn eval(e: &Expr) -> Result<Value, Control> {
    match e {
        // E-Lit: a literal evaluates to itself
        Expr::Lit(lit, _) => Ok(match lit {
            Lit::Int(n)  => Value::Int(*n),
            Lit::Bool(b) => Value::Bool(*b),
            Lit::Str(s)  => Value::Str(s.clone()),
            Lit::Unit    => Value::Unit,
        }),

        // E-Arith: evaluate both, combine two integers (values.md)
        Expr::Binary(BinOp::Add, l, r, span) => { /* … */ }

        // the remaining forms are the work of Milestone M1
        _ => todo!("Sub, Mul, comparisons, and, or, not, tuples, lists, …"),
    }
}

Most of the remaining arms repeat Add‘s shape with a different operator, string concatenation among them. ++ joins two strings, and E-Concat names both operands’ values exactly as E-Add does:

Its arm is the Add arm with Value::Str in place of Value::Int: evaluate both children, return their concatenation on two strings, and raise a TypeError otherwise. The same ++ also joins two lists, so the arm matches that shape as well — one operator over two value shapes, which is operator overloading; the general mechanism that lets one operator serve many types arrives with traits. Comparing strings for order arrives the same way, through cmp rather than <.

Filling in each arm is the walk applied to one rule at a time. Most arms follow a single pattern — evaluate both children, then combine their values — but two rules depart from that pattern.

When the walk departs from evaluating both

Division carries an extra premise: E-Div fires only when the divisor is nonzero. When it is zero no rule applies, so the expression is stuck, and the interpreter reports it rather than dividing. The arm reads the premise off as a guard:

// E-Div: the divisor must be nonzero, or evaluation is stuck
Expr::Binary(BinOp::Div, l, r, span) => {
    match (eval(l)?, eval(r)?) {
        (Value::Int(_), Value::Int(0)) => Err(Control::Raise(
            RuntimeError::DivByZero { span: *span },
        )),
        (Value::Int(x), Value::Int(y)) => Ok(Value::Int(x.wrapping_div(y))),
        (a, b) => Err(type_error2(Ty::int(), &a, &b, *span)),
    }
}

type_error2 builds the RuntimeError::TypeError the previous chapter wrote out by hand, blaming the first operand that is not an Int (type_error is its one-operand form); here the new case is the zero check, the guard in the rule became a match arm. Modulo is the same arm with %.

The boolean connectives depart further. and and or short-circuit: the right operand is evaluated only when the left has not already settled the result. Two rules cover and — a false left operand settles it without touching the right, and a true left operand hands the result to the right — and the walk follows their shape, evaluating the left, then reaching the right on true:

// E-And-False / E-And-True: evaluate the right only when the left is true
Expr::Binary(BinOp::And, l, r, span) => {
    match eval(l)? {
        Value::Bool(false) => Ok(Value::Bool(false)),
        Value::Bool(true) => match eval(r)? {
            b @ Value::Bool(_) => Ok(b),
            other => Err(type_error(Ty::bool(), &other, *span)),
        },
        other => Err(type_error(Ty::bool(), &other, *span)),
    }
}

The arithmetic arm evaluated both children before combining them; the and arm evaluates one and may stop there. What each arm does is dictated by its rule — an axiom is a leaf, a rule with two operand premises recurses twice, a short-circuiting rule recurses once and then decides — so reading the rules off as arms also settles the questions prose semantics left open, such as whether and ever evaluates its right operand. The remaining forms of Expr — the other arithmetic and comparison operators, not, building tuples and lists, prepending to a list with ::, and projecting a tuple component with e.0 — are each one more arm of the same walk, and completing them is Milestone M1. A tuple has positional access because its arity is fixed and the index is a literal.

Beyond tree-walking evaluators

Walking the AST is one way to run a program; however, there are other alternatives when implementing an interpreter. The walk re-inspects the tree every time control reaches a node: each visit dispatches on the node’s variant and follows the Box pointers to its children. For a literal evaluated once is nothing. For the body of a loop that runs a million times — once the language has loops — it is a million repetitions of the same dispatch and the same pointer-chasing over a tree that never changed.

Interpreters that need the speed spend a compile step to remove that repetition.

  • A bytecode virtual machine lowers the tree once into a flat sequence of simple instructions, then runs a tight loop that dispatches on one instruction at a time. The instructions sit in an array rather than scattered across the heap, and each is a single indexed step, so the repeated dispatch and pointer-chasing of the walk are gone. The cost is a second representation — the bytecode — to design, produce, and debug alongside the tree. CPython, the JVM, and Lua run this way, and it is the second interpreter Nystrom’s book builds for the same language as its first.
  • Closure generation walks the tree once and produces, for each node, a closure that already holds its children’s closures and knows what to do; running the program calls closures and never inspects the AST again. The repeated dispatch is gone without leaving the host language or defining a bytecode, at the cost of a closure allocated per node. Feeley and Lapalme set the approach out in 1987.
  • Just-in-time compilation translates the parts of a program that run hot into machine code while the program runs, reaching the speed of compiled code on those parts. It is the most involved and the most machine-specific of the three, and drives the production JavaScript and JVM engines.

A separate choice is the walk’s use of host-language recursion. eval calling itself uses the Rust call stack for the tree’s depth and for every Bridger call in progress, so a runaway recursion would exhaust it. The provided interpreter runs on a large stack and counts the calls in progress, refusing the next one past 100 000 as the stuck state StackOverflow, before the host stack runs out. An interpreter that must bound its memory more tightly keeps an explicit stack of work to do and loops over it instead of recursing, trading the directness of the recursive arms for control over memory.

This book takes the recursive AST-walk. It is the most direct realization of the semantics — one match arm per rule, the call tree the derivation tree — so the evaluator reads as the rules it implements, which is what serves a reader learning what those rules mean.

Further reading

Two interpreters for one language: Robert Nystrom’s Crafting Interpreters (free online) builds a tree-walking interpreter in its first half and a bytecode virtual machine in its second, for the same language, so the two strategies of the last section can be read against each other line for line, with the walk’s “Evaluating Expressions” the direct counterpart of this chapter.

Generating closures instead of walking: Marc Feeley and Guy Lapalme’s “Using closures for code generation” (Computer Languages 12:1, 1987, doi:10.1016/0096-0551(87)90012-9) compiles an expression into a network of closures whose application evaluates it, the middle ground that removes the walk’s repeated dispatch without a bytecode.

A register machine for a scripting language: Roberto Ierusalimschy, Luiz Henrique de Figueiredo, and Waldemar Celes’s “The Implementation of Lua 5.0” describes a production bytecode interpreter in detail, including why its virtual machine passes values through registers rather than a stack.

The evaluator written in its own language: Abelson and Sussman’s Structure and Interpretation of Computer Programs (MIT Press, free online) develops a metacircular evaluator — eval and apply for a Lisp, written in that Lisp — the modern presentation of the walk LISP first ran on.

Concept checks

Evaluating (1 + 2) * 4, in what order does eval reach the nodes, and why must the two operands of the * be evaluated before the * itself?

eval is called on the Mul first, but it cannot return until its children have; it calls eval on the Add, which calls eval on 1 and on 2 and returns 3, then eval on 4, and only then multiplies. The values are produced bottom-up — the leaves first, each parent after its children — so the node visited first is the last to finish. The * must wait because its rule, E-Mul, names the operands’ values ( and ) in its premises and builds the result from them; there is no value to combine until the subexpressions have been evaluated. The call tree has the same shape as the derivation the rules build for the expression.

The + arm evaluates both operands before combining them. The and arm evaluates its left operand and sometimes stops there. Why the difference?

Because the rules differ. Addition has one rule with both operands’ values as premises, so its arm must produce both before it can add. and has two rules: E-And-False, whose only premise is that the left operand is false, settles the whole expression without mentioning the right; E-And-True reaches the right only after the left is true. The arm follows that shape — evaluate the left, and call eval on the right only in the true case. The walk’s structure at each node is dictated by that node’s rule, which is also what makes the short-circuit behaviour a definite part of the language rather than an accident of how the interpreter happened to be written.

On 1 + true the + arm returns Err(Control::Raise(...)). Why is there no arm that returns some Value for it?

Because the expression has no value: no evaluation rule covers adding a boolean to an integer, so no derivation of 1 + true ⇓ v exists, and the expression is stuck. An arm that returned a Value would have to invent one the rules do not license — pick an integer, or a fabricated error value — and a later operation could then use it as though the addition had succeeded. Returning Err(Control::Raise(RuntimeError::TypeError { … })) instead reports that evaluation could not proceed, and because eval returns a Result, ? carries that report out and no caller can read a value that was never produced.

A bytecode VM lowers the tree once and then loops over an instruction array; the recursive walk re-examines each node every time control reaches it. Where does that difference show up, and where does it not?

It shows up wherever the same node is evaluated many times — the body of a loop, a function called repeatedly. The walk re-dispatches on each node’s variant and re-follows its child pointers on every visit, so a body run a million times pays that cost a million times; the VM paid the lowering cost once and each later run is a linear pass over an array with tighter dispatch and no pointer-chasing. It does not show up on code evaluated once — a literal, a top-level expression — where the walk does the same constant work the VM’s single pass would, and the VM’s compile step is pure overhead. The VM buys its speed with a second representation to build and maintain, which is why an interpreter meant to be read, or run over each node about once, takes the walk.

The recursive walk uses the host language's call stack. What can go wrong on a deeply nested expression, and what does an interpreter do instead when it must not?

Each recursive eval call uses a frame of the Rust call stack, and the depth of nesting is the depth of the tree plus the Bridger calls in progress. Without a bound, a runaway recursion would exhaust it and abort the process; the provided interpreter counts Bridger calls in progress and reports the 100 001st as the stuck state StackOverflow, and runs on a stack large enough that an expression a hundred thousand nodes deep still evaluates. An interpreter that must bound its stack keeps its own explicit stack of pending work on the heap and loops, pushing and popping subexpressions instead of calling itself, so the depth it can handle is limited by heap rather than by the fixed host stack. The cost is directness: the arms no longer read as the rules, because the recursion that mirrored the derivation has been turned into manual stack bookkeeping.