Values and Runtime Errors
In the previous chapter, we covered the AST of Bridger as provided by
the accompanying Rust parser. The provided code base also includes a Rust enum type Value
representing the possible values a Bridger expression may evaluate to. Throughout this book,
you will work through the milestones to create an interpreter that evaluates an Expr (and whole
programs) to a Value. This chapter details the Value type you will be working with. It also
covers what the interpreter should produce when an expression has no value at all (a runtime
error). The mathematical value grammar and the semantic rules that define the meaning of Bridger
programs are detailed in Appendix D: language reference.
The space of values
Evaluation produces a value, and every value a Bridger program can compute is one of a fixed set of shapes: an integer, a boolean, a string, unit; a tuple or a list built from smaller values; a constructor value or a struct value; a location into the store; a closure. The reference writes the set as a grammar:
The last two stand for names rather than data — a declared relation, and a struct or type named as the receiver of an associated call — and nothing compares, prints, or stores them.
The above set of values represents the result of running any Bridger program or expression to completion. We describe each in detail in future chapters: integers, booleans, and strings in the tree-walking evaluation as the result of evaluating simple arithmetic, boolean, and string expressions; tuples and lists in that same chapter, as compound expressions built from smaller values; unit, the value of statements and blocks, with control flow; locations with mutable references; closures with functions; and constructor and struct values with algebraic data types and structs later still.
Bridger’s values in Rust
The set of value shapes is a sum type, the same correspondence the AST turned on: one variant per
shape, each carrying what that shape is built from. The Value enum, provided with this book
alongside the AST, holds them. It reuses type Name = String from the
AST chapter for the names in Ctor and Struct.
enum Value {
Int(i64), // 5
Bool(bool), // true
Str(Rc<str>), // "hi"
Unit, // ()
Tuple(Rc<[Value]>), // (v, v, …), two or more
List(List), // [] or a cons cell v :: v, see below
Ctor(Name, Rc<[Value]>), // a constructor value: C(v, …)
Struct(Name, Rc<[(Name, Value)]>), // a struct value: S { f = v, … }
Ref(Loc), // a location in the store (Part IV)
Closure(Rc<Closure>), // a function + its captured env (Part V)
Relation(Name), // a declared relation, by name (Part VII)
Type(Name), // a type name, receiver of `T.m()` (Part IX)
}
The comment on each variant gives an example of the kind of value it holds, and the variants line up
with the grammar above one for one. The string, tuple, constructor, and struct payloads sit behind
Rc, as the types in the AST chapter do: a value is copied whenever it is
read from a variable or a cell, and a reference-counted pointer makes that copy a pointer bump
rather than a walk of the payload. The Value enum is to a running program what Expr is to a
parsed one: the type every part of the interpreter agrees on for the thing it passes around. Where
Expr is what the evaluator takes apart, Value is what it builds.
One representation choice is visible in List. The reference models a list as either the empty list
or a cons , a head joined to a tail, and the List type holds exactly that: the
empty list, or a cell with a head, a tail, and the length so far, the tail shared with any other
list that ends the same way. Sharing is what makes the rules’ shape a good representation and
not only a good notation. Prepending with ::, taking a list apart with the pattern h :: t,
and head and tail each touch one cell, whatever the list’s length, so a function that
recurses down a list does work proportional to the list. An array would make ::, tail, and
the tail of h :: t copy the whole list. A list literal and ++ cost the left operand’s length,
since its cells are rebuilt onto the shared right operand, and len reads the count each cell
stores. What the array would give back, reading the nth element in one step, Bridger has no
operation for.
Expressions that do not evaluate to a value
Evaluating an expression usually produces a value; however, there are two scenarios when an
expression does not evaluate to a value. Evaluation can get stuck: adding a boolean to an integer,
applying a value that is not a function, projecting a field off a tuple, or dividing by zero. Each
such scenario has no corresponding evaluation rule (and the expression has no well-formed meaning
as a value). Instead, the interpreter immediately halts on such runtime errors. On the other hand,
an evaluation can choose to return early: a return e computes e and then abandons the rest
of the enclosing function’s body, carrying that value out to the function’s own result. We represent
both non-local exits with one type Control in our Rust interpreter.
enum Control {
Raise(RuntimeError), // stuck: no rule applies; the run halts
Return(Value), // an early return, sent to the enclosing function
}
This chapter describes the purpose of Raise in detail. Stuckness is the absence of a result rather
than a special result, a notion the operational semantics the book writes its rules in makes precise,
and the reference collects the
full list of cases. In the untyped evaluator of Parts II through V, these stuck states are how a
nonsensical program fails; they are Bridger’s runtime errors. Return carries a value that
did finish computing, only not here; a return is legal only inside a function body. We will explain
how return e and the Control::Return variant are treated within the interpreter we build
in the Control flow chapter
and functions.
The interpreter we build must handle expressions that may contain runtime errors. Rather than
having our interpreter crash or have undefined behavior on runtime errors, we have the interpreter
we build make use of Rust’s Result and Error types to represent such runtime errors explicitly.
That means, when the interpreter encounters such runtime errors, rather than halting it stops
evaluation early and returns a RuntimeError describing why the interpreter stopped evaluation.
The variants below are the ones the arms of this part and the next raise; the driver and the
prelude add theirs (no main, a cycle among global initializers, a failed read from input, a
range or a call depth past the machine’s bound, printing a function), and each later part its
own as its constructs arrive (a missing struct field, a method with no receiver, a query inside a
filter).
enum RuntimeError {
// x not in scope
UnboundVariable { name: Name, span: Span },
// 1 + true, a non-bool condition, iterating a non-list, …
TypeError { expected: Ty, found: Ty, span: Span },
// 5 / 0 or 5 % 0
DivByZero { span: Span },
// .f a value lacks
NoSuchField { field: String, span: Span },
// applying a non-function
NotAFunction { found: Ty, span: Span },
// wrong number of arguments
ArityMismatch { expected: usize, found: usize, span: Span },
// no arm matched the value
NonExhaustiveMatch { span: Span },
// == on a function or relation
NotComparable { found: Ty, span: Span },
}
Evaluation therefore does not return a bare Value. It returns a Value or a Control, and Rust
spells “one or the other” with its own sum type, Result. Its companion Option spells “a value or
nothing”:
enum Result<T, E> { Ok(T), Err(E) } // a T, or an error E
enum Option<T> { None, Some(T) } // a T, or nothing
Both come from Rust’s standard library, and both are how a Rust program carries the possibility of
failure in a value the compiler forces every caller to look at. The evaluator puts a Value on the
Ok side and a Control on the Err side — a normal value, or one of the two non-local exits:
fn eval(e: &Expr) -> Result<Value, Control> {
match e {
Expr::Lit(Lit::Int(n), _) => Ok(Value::Int(*n)),
Expr::Binary(BinOp::Add, l, r, span) => {
let a = eval(l)?; // a Raise or Return bubbles out here
let b = eval(r)?;
match (a, b) {
(Value::Int(x), Value::Int(y)) => Ok(Value::Int(x.wrapping_add(y))),
(a, b) => {
let found =
if matches!(a, Value::Int(_)) { type_of(&b) }
else { type_of(&a) };
Err(Control::Raise(RuntimeError::TypeError {
expected: Ty::int(), found, span: *span,
}))
}
}
}
_ => todo!("the remaining forms: the next chapter"),
}
}
The Ok/Err split names both outcomes at the type level: a caller of eval cannot use the value
without first deciding what to do when it is an Err. The ? after eval(l) is Rust’s propagation
operator — when the subexpression is an Err, ? returns it from eval at once, so whichever exit
it carries — a Raise with its span, or a Return with its value — travels out through every
enclosing operation untouched. One mechanism threads both: every operation propagates a Control
the same way, and only a function call inspects a Return, catching it at the boundary and turning
it back into the call’s value. When both operands are integers the addition proceeds; when they are
not, the last arm raises a RuntimeError::TypeError naming the Ty it expected and the Ty
type_of read from the offending operand, and carrying the addition’s span. The addition wraps,
since the reference fixes integers as 64-bit values with arithmetic modulo .
Two kinds of failure
The Err(Control::Raise(…)) an addition returns and a Bridger value like Err("file not found")
read alike on the page and are different in kind. The first is the interpreter stopping: no rule
applied, so there is no value, and the host Result carries that fact out to the top level, where
it is printed. A Bridger program cannot observe it or recover from it; the program has already
stopped.
The second is an ordinary Bridger value, a constructor value Err(s) that a successful evaluation
produced, which the program inspects with match and carries on from. Evaluating it is a completed
derivation that yields a value; nothing is stuck.
Bridger has its own Option and Result for exactly this second kind of failure, defined in the
prelude as ordinary data types and handled with match — separate from
Rust’s Option and Result, which are how the interpreter is written. The recoverable failures a
Bridger program models with those values, the surface that makes them convenient, and the design
space of failure mechanisms (values against exceptions against a halting panic) are the subject of
Error handling, once
algebraic data types give the language the constructors
to build them. What this chapter fixes is the boundary: a runtime type error is the host stopping,
and it stays outside the values a program computes with.
That boundary is why every stuck case is stated precisely. Each carries a
span and names the Ty it expected against the one it found, because for Parts II through V a runtime
type error is the only report a mistake gets, and the reader debugging a program has nothing else to
go on. When
the type checker arrives in Part VI
it rules out the type-shaped stuck cases before the program runs, trading the clear failures
described here for a guarantee made ahead of time. Division by zero, a failed read from input, and
the interpreter’s two bounds — on call depth and on the size of a range — cannot be ruled out
statically, nor, until exhaustiveness checking arrives, can a match no arm covers.
How a value is represented
A tagged union is one way to give an interpreter a single value type, and the choice reads as a choice against the alternatives a faster or a more open interpreter reaches for.
The enum stores a tag — which variant this is — beside the payload, and every value costs the
tag plus room for the largest variant. match reads the tag to choose an arm, and the compiler
checks that the arms cover every variant, the same exhaustiveness the AST walk relied on. A value
kind cannot be added without editing the one enum and revisiting the matches the addition now
leaves incomplete.
Other interpreters and compilers optimizing for speed instead often make use of smaller, packed value representations. For instance, a production virtual machine for a dynamic language often stores every value in a single 64-bit word, reading a few spare bits as the tag: small integers are represented inline with a tag bit set. This is how OCaml represents its values. It represents its native integers with 63 bits and the low bit determines whether the 64-bit word is an integer or a pointer to a structure. These packed representations achieve faster computations due to the denser representation that leads to fewer memory reads. However, the cost is that the representation is tied to a specific word size, and such packed representations are often hard to read and understand.
An interpreter written in an object-oriented language reaches instead for a class hierarchy: each
value kind a subclass of a common value class, and each operation a virtual method the runtime
dispatches on the object’s class. That arrangement makes the set of value kinds open — a new
kind is a new subclass, added without editing a central type — and gives up the closed, checked
case analysis the enum and its match provide, where the compiler can prove every kind is
handled. In this book, we make the decision to use an enum type to represent the AST and
Value because it makes it clear which cases are handled and which are not and is easier to
read and understand for learning purposes.
Further reading
Representing values in an interpreter: Robert Nystrom’s Crafting
Interpreters (free online) gives its
bytecode interpreter a tagged-union value type in its “Types of Values” chapter, the same shape as
the Value here, and its later “Optimization”
chapter rewrites that type as a NaN-boxed word and measures what the packing buys.
Tagged data and dispatching on it: Abelson and Sussman’s Structure and Interpretation of
Computer Programs (MIT
Press, free online) builds data that carries a type tag and operations that read it, and works
through both the central-table and the class-style organizations for dispatching on that tag — the
two the last section set against the enum.
Concept checks
The reference models a list as either [] or a cons v :: v, and Value::List holds cons cells. What would change if it held a Vec<Value> instead, and which programs would notice?
No program would see a different value: [1, 2, 3] denotes the same list, == and printing agree,
and every rule still applies. What changes is cost. With cons cells, x :: xs and the pattern
h :: t share the tail and touch one cell; with an array they copy the list, so a function that
walks a list by taking its head off each time does work proportional to the square of the length.
A program summing a list of fifty thousand elements would notice. In the other direction, an array
reads its nth element in one step, which cons cells cannot; since Bridger has no indexing
operation, nothing in the language can tell.
Why does eval return Result<Value, Control> rather than Value? What would a plain Value return type force on the expression 1 + true?
Because 1 + true has no value: it is a stuck evaluation, and there is no Value that honestly
represents “there was no result.” A Value return type would force the evaluator to invent one — to
pick some integer, or add a Value::Error variant that then has to be threaded and checked by hand
through every operation, or to panic and lose the span. Result<Value, Control> names the
outcomes at the type level: a success carrying a Value, or a Control exit — for 1 + true, a
Raise carrying a RuntimeError::TypeError (expected Int, found Bool) and the span to point at.
A caller cannot read the value without first handling the Err case, so a stuck subexpression cannot
be quietly used as though it had succeeded.
A Bridger program evaluates Err("nope"); separately it evaluates 1 + true. Both look like failures. How do the two differ inside the interpreter?
Err("nope") is an ordinary Bridger value — a constructor value the evaluator builds by a
completed, successful derivation, of type Value, which the program goes on to inspect with
match. Nothing is stuck; the failure it represents is one the program chose to model as a value.
1 + true is a stuck
evaluation: no rule applies, so there is no Value at all, and the interpreter returns
Err(Control::Raise(RuntimeError::TypeError { … })) in the host language and halts that run. The
program cannot observe or recover from it, because the program has stopped. One failure is a value
the program computes with; the other is the interpreter reporting that it could not compute.
Bridger represents values with a tagged enum. An interpreter in an object-oriented language might make each value kind a subclass of a common value class instead. What does each arrangement make easy, and what does each give up?
The enum makes the set of value kinds closed and the case analysis checked: every kind is
a variant of one type, and match will not compile until it handles them all, so a new kind forces
the compiler to list every operation that now has a gap. It gives up open extension — adding a kind
means editing that central type. The subclass arrangement makes the set open: a new value kind
is a new subclass with its own methods, added without touching a central definition, and operations
dispatch through virtual methods on the object’s class. It gives up the checked exhaustiveness —
nothing proves every kind implements every operation, and a missing case surfaces at run time rather
than at compile time. An interpreter defined once, in one place, gains from the closed, checked
side, which is why Bridger takes the enum.
The reference says == is stuck on two closures. Why can't the interpreter compare two Value::Closure values structurally, the way it compares two tuples?
Structural equality on a tuple or a list bottoms out at integers, booleans, and strings, which have a plain answer. A closure is a function body paired with a captured environment, and neither piece has a useful structural answer: two closures with different bodies can compute the same function, and deciding whether two functions agree on every input is not something the interpreter can compute in general. Comparing the captured environments or the syntax of the bodies would give an answer, but not one that means “these are the same function,” so it would mislead more than it helps. The language leaves closure equality stuck rather than returning an answer that does not correspond to any equality a programmer would want.
A return deep in a function body and a stuck 1 / 0 both leave eval through its Err channel as a Control. Why can one ? propagate both — and what makes a return stop at its enclosing function while a stuck error travels to the top?
Both are non-local exits, so both ride Err(Control::…), and ? propagates any Err identically —
no per-operation code has to thread either one outward. They differ only at the boundary: the Call
arm inspects the body’s outcome, and on Control::Return(v) it catches it and yields v as the
call’s value, so a return unwinds exactly to its enclosing call and no further. Nothing inspects a
Control::Raise on the way up, so it reaches the top level and halts the run. This is the
reference’s Val v | Ret v short-circuit convention, realized as Rust error propagation.