Type switches and checked exceptions
An interpreter for a tiny expression tree. It uses the two features that most change how
Reed code is structured: a type switch to dispatch on which node a reference actually is,
and a throws clause so the one operation that can fail is visible in every signature that
carries it.
The program evaluates (6 / 2) + 4 to 7, then evaluates 1 / 0 and recovers.
// A node hierarchy. `Node` is the parent; each variant adds its own fields.
struct Node {}
struct Lit : Node { value: i32 }
struct Add : Node { left: &Node, right: &Node }
struct Div : Node { left: &Node, right: &Node }
tag DivideByZero(i32);
// The `throws (DivideByZero)` is part of the signature: every caller can see that
// evaluating an arbitrary node might fail, and the compiler makes them say what to do
// about it.
func eval(node: &Node) -> i32 throws (DivideByZero) {
switch (node) {
case &Lit: {
// Inside the arm, `node` is narrowed to `&Lit`, so `value` is readable without
// a cast. Narrowing works here because `node` is a parameter -- a bare immutable
// binding.
return node->value;
}
case &Add: {
// Both recursive calls can throw, so both need `?`. Without it this is a compile
// error, not a silent runtime trap.
return eval(node->left)? + eval(node->right)?;
}
case &Div: {
let divisor: i32 = eval(node->right)?;
if (divisor == 0) {
throw DivideByZero(0);
}
return eval(node->left)? / divisor;
}
default: {
// Reached only if a new node type is added without a case for it.
return 0;
}
}
}
// A caller that handles the failure instead of propagating it needs no `throws` clause of
// its own -- the `try` discharges the effect. Note there is no `?` on this call: the
// handler below already covers the only tag `eval` can throw, so nothing is left to
// propagate, and a `?` here would be an error.
func eval_or(node: &Node, fallback: i32) -> i32 {
try {
return eval(node);
} catch DivideByZero(_) {
return fallback;
}
}
func check() -> i32 {
// (6 / 2) + 4
let ok: &Node = new Add {
left: new Div { left: new Lit { value: 6 }, right: new Lit { value: 2 } },
right: new Lit { value: 4 },
};
if (eval_or(ok, -1) != 7) { return 1; }
// 1 / 0 -- throws, and the handler supplies the fallback.
let bad: &Node = new Div { left: new Lit { value: 1 }, right: new Lit { value: 0 } };
if (eval_or(bad, -1) != -1) { return 2; }
return 0;
}
export func check as "check";
What the two features buy
The type switch replaces a chain of is tests plus casts. Each case lowers to a
br_on_cast, and the arm body sees the scrutinee at the narrowed type. The equivalent
written with if (node is &Lit) needs the same tests but re-states the type at each use.
The restriction to bare immutable bindings is the same for both, and a scrutinee that
cannot narrow (a var, a field read, a call result) is a warning at the switch, not a
confusing error later — see
type tests and narrowing.
throws plus ? makes the failure path a type-checked property rather than a
convention. eval cannot silently start failing: adding a throw to it without updating
the clause is an error, and every caller either handles the tag or marks the call with ?
and declares it too. Removing the ? from any recursive call above is a compile error
naming both options.
A ? that has nothing to propagate is also an error, which is what keeps the marks
meaningful — they appear exactly where a call can fail, so the failure paths in a function
can be read off its body.
See also
- Control flow for
switch's two forms. - Exceptions for tags, handlers, and rethrow.
- GC and narrowing for the
is-based form of the same dispatch.