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

Milestone M1

The Expression Evaluator

Part II built the pieces of an evaluator: the values it produces, the walk that produces them, the failures it reports, and the rules that fix what each expression means. This milestone puts them together into a working evaluator for the expression fragment, and gives you a way to know it runs correctly.

The codebase

The starter is one interpreter that you grow across the milestones. Nearly all of it is provided and frozen — the parser, the value and error types, the environment and the store — so your effort goes into meaning rather than the plumbing that would have you fighting the borrow checker. The files that matter here:

src/
  ast.rs            the Expr tree and its companion types    (provided)
  interp/
    eval.rs         eval_expr — YOUR file
    value.rs        the Value type, and type_of              (provided)
    error.rs        RuntimeError and Control                 (provided)
    env.rs          the environment (used from M2)           (provided)
    store.rs        the store (used from M4)                 (provided)
  parser/           source text -> Expr                      (provided)
  types/            the type checker (M5)                    (deferred)
  relations/        the Datalog fragment (M6)                (deferred)

Most work happens in one method, which you grow a little each milestone:

pub fn eval_expr(&mut self, e: &Expr, env: &Env) -> Result<Value, Control>

Every Expr variant already has a match arm. The ones you have not reached yet are milestone-tagged holes — a todo_mN!(…) that compiles as any type but stops the run if reached — so the crate builds from the first clone with nothing written. As you fill an arm, you replace its hole with the rule’s implementation. To read the shape of any provided type — Value, Expr, RuntimeError — while you work, build the API docs and open them in your browser:

cargo doc --no-deps --open

Setting up

Install the Rust toolchain from rustup — it brings cargo, the formatter, and the linter — and confirm it runs:

cargo --version

Clone the starter repository, then build it once. A fresh clone builds green, because every hole compiles:

cd bridger-interpreter
cargo build

From there, the loop you repeat on every milestone:

cargo m1                                    # run the Milestone-1 tests
cargo fmt                                   # format your code
cargo clippy --all-targets -- -D warnings   # lint

cargo m1 is short for cargo test --features m1 — the tests you should pass in Milestone M1. The milestones are cumulative. So cargo m2 will run the tests for Milestone M2 and Milestone M1.

Finding the holes

Every place you write code is a macro named for its milestone, so a search lists exactly what a milestone asks for:

grep -rn 'todo_m1!' src

turns up the six arms of eval_expr — and in general, grep -rn 'todo_mN!' src is Milestone N’s checklist. The six for M1:

// src/interp/eval.rs — the six M1 arms of eval_expr
Expr::Lit(..)    => todo_m1!("E-Lit"),
Expr::Unary(..)  => todo_m1!("E-Neg / E-Not"),
Expr::Binary(..) => todo_m1!("E-Arith/Ord/Eq, E-And/Or, E-Concat, E-Cons"),
Expr::Tuple(..)  => todo_m1!("E-Tuple"),
Expr::List(..)   => todo_m1!("E-List"),
Expr::Proj(..)   => todo_m1!("E-Proj"),

Every other Expr variant is a hole tagged for a later milestone, which is why the crate compiles with none of them written.

The specification you must satisfy

Each expression form, the rule that gives it meaning, and the case that leaves it stuck:

ExpressionRuleStuck when
n, b, s, ()E-Lit
-eE-Negthe operand is not an Int
e + e, e - e, e * eE-Arithan operand is not an Int
e / e, e % eE-Div / E-Modan operand is not an Int, or the divisor is 0
e < e, <=, >, >=E-Ordan operand is not an Int
e == e, e != eE-Eq(structural; at M1, never)
not e, e and e, e or eE-Not / E-And / E-Oran operand is not a Bool
e ++ eE-Concatoperands are not both strings or both lists
e :: eE-Consthe right operand is not a list
(e, …), [e, …]E-Tuple / E-List
e.iE-Proje is not a tuple, or i is past its end

The full rules are in the Program Semantics chapter; this table is the checklist your evaluator is measured against, not a second statement of them.

Three contracts hold across every arm. On success an arm returns Ok(v); when the expression is stuck it returns Err(Control::Raise(…)) carrying the RuntimeError and the span of the node the rule blames — the interpreter reports rather than panics. and and or short-circuit: the right operand is evaluated only when the left has not already settled the result. And integer arithmetic wraps modulo , as the values chapter fixed.

Implementing the arms

Destructure each arm’s .. into the fields you need. The M1 variants and what they carry:

VariantFieldsCovers
Expr::Lit(Lit, Span)a literal: Lit::Int, Bool, Str, Unit
Expr::Unary(UnOp, Box<Expr>, Span)UnOp::Neg, UnOp::Not
Expr::Binary(BinOp, Box<Expr>, Box<Expr>, Span)the operators below
Expr::Tuple(Vec<Expr>, Span)(e, …)
Expr::List(Vec<Expr>, Span)[e, …]
Expr::Proj(Box<Expr>, u32, Span)e.i, the index a literal u32

BinOp names the operator: Add Sub Mul Div Mod for arithmetic, Eq Ne Lt Le Gt Ge for comparison, And Or, Concat for ++, and Cons for ::.

Three tools do the work of every arm:

  • Evaluate a subexpression with self.eval_expr(sub, env)?. The ? sends a stuck operand straight out, so an arm only handles the case where its operands produced values.
  • Build the resultValue::Int, Value::Bool, Value::Str, Value::Tuple, Value::List. Integer arithmetic uses the wrapping operations (i64::wrapping_add, wrapping_mul, …), since the reference fixes 64-bit wraparound.
  • Report a stuck expression — return RuntimeError::TypeError { expected, found, span } or RuntimeError::DivByZero { span }. The provided type_of(&v) gives the found type, Ty::int() / Ty::bool() / … name the expected one, and span is the offending node’s. A From<RuntimeError> for Control is provided, so .into() (or ?) lifts a RuntimeError into the Control::Raise the signature returns.

You have already seen this in Part II: the Add arm is worked in full, and Div and and show the divisor guard and short-circuiting. The rest of M1 is the same shape, one arm at a time.

Worked examples

An expression, and the value it evaluates to:

1 + 2 * 3          ⇓ 7
-(3 - 5)           ⇓ 2
7 / 2              ⇓ 3          (integer division)
7 % 2              ⇓ 1
2 < 3              ⇓ true
(1, 2) == (1, 2)   ⇓ true       (structural)
"ab" ++ "cd"       ⇓ "abcd"
[1, 2] ++ [3]      ⇓ [1, 2, 3]
0 :: [1, 2]        ⇓ [0, 1, 2]
(10, 20).1         ⇓ 20
false and (1 / 0)  ⇓ false      (right operand never evaluated)

And expressions with no value, which halt the run:

1 + true      ⇓ stuck   (an Int operator met a Bool)
5 / 0         ⇓ stuck   (division by zero)
"a" ++ [1]    ⇓ stuck   (++ needs both strings or both lists)
(1, 2).5      ⇓ stuck   (projection past the end of a 2-tuple)

How to know it works

cargo m1 runs the set of provided milestone M1 tests; all pass means the fragment is likely correct. Each test builds an Expr and calls eval_expr on it — one rule at a time. A value test checks the Value; a stuck test checks the error’s variant, its expected/found types, and the span it blames, but not its wording. The builders (int, binary, tuple, …) and the assertions value_of and stuck come with the repository, so a check reads close to the rule it exercises:

// 1 + 2 * 3  ⇓  7
let e = binary(Add, int(1, 0), binary(Mul, int(2, 2), int(3, 4), 3), 1);
assert_eq!(value_of(&e), Value::Int(7));

// 1 + true  is stuck: an Int operator met a Bool
let bad = binary(Add, int(1, 0), boolean(true, 2), 1);
assert!(matches!(stuck(&bad), RuntimeError::TypeError { .. }));

Fill an arm, run cargo m1, and repeat until the all M1 tests pass.

Design problems

Your eval_expr returns a single Value, never a set of them. Which property of the rules lets it, and where do the rules secure it?

Evaluation is deterministic: no M1 expression has two values. The rules are syntax-directed — the outermost form of an expression picks the rule that could conclude e ⇓ v — and the only forms with more than one rule, and and or, have mutually exclusive premises (the left operand is true in one rule and false in the other, and it cannot be both). Since each subexpression has at most one value, so does each expression built from them. A single Value return type is honest because the judgment e ⇓ v is a partial function: one value, or none when the expression is stuck. The Program Semantics chapter makes this argument in full.

Write the list [1, 2, 3] two ways using only M1 operators, and say whether the two expressions produce equal values.

The literal [1, 2, 3], and the same list built by prepending onto the empty list, 1 :: 2 :: 3 :: [] (or 1 :: [2, 3]). Both evaluate to the same value — the reference models a list as cons cells, and E-List builds [1, 2, 3] as exactly 1 :: 2 :: 3 :: [] (values), so the two expressions denote the same cons list. == on them is true: equality is structural, and they have the same shape and equal elements.

Write an M1 expression that evaluates to a value but would be stuck if and and or did not short-circuit. Which rule makes the difference?

false and (1 / 0) evaluates to false. The rule E-And-False concludes the result from the left operand alone — it has no premise about the right operand — so 1 / 0 is never evaluated and never gets a chance to be stuck. An evaluator that evaluated both operands before combining them would evaluate 1 / 0, hit the divide-by-zero, and leave the whole expression stuck. true or (1 / 0) ⇓ true works the same way through E-Or-True.

5 / 0 is stuck, and the run halts. A program might rather detect the zero and fall back to a default. At M1, can it? What later feature would let it?

At M1 it cannot. A stuck evaluation is the absence of a result, not a value the program can inspect — there is no result to branch on, and M1 has no branching anyway. Choosing a fallback needs two things that arrive later: a conditional to test the divisor first (Part IV), and, to carry “an answer or a failure” as a value the program handles with match, the Option and Result types built from algebraic data types in Part VIII. The type checker of Part VI rules out the type-shaped stuck cases — 1 + true and its kin — before a program runs, but division by zero is not one it can catch ahead of time, so it survives into every later milestone.