059: Expression Tree
Difficulty: 2 Level: Beginner Represent arithmetic expressions as a recursive enum and evaluate them with structural recursion.The Problem This Solves
Calculators, compilers, query engines, and rule systems all need to represent expressions: things that can be composed from smaller expressions. `(1 + 2) * (10 - 4)` is an expression. Its sub-expressions (`1 + 2`) and (`10 - 4`) are expressions too, composed of leaf values. A flat `String` or `Vec` can't capture this structure. You need a recursive data type: an expression is either a leaf (a number) or a node (an operator applied to two sub-expressions). This is an Abstract Syntax Tree โ the core data structure of every compiler. Without a proper recursive type, you end up with string parsing, switch statements on magic strings, or parallel arrays โ all fragile. The enum makes the structure explicit and the compiler enforces it.The Intuition
An expression tree is a tree where:- Leaves hold values (`Num(3.0)`)
- Branches hold operators and point to their operands (`Add(left, right)`)
How It Works in Rust
#[derive(Debug, Clone, PartialEq)]
pub enum Expr {
Num(f64),
Add(Box<Expr>, Box<Expr>), // Box<Expr> required: Expr cannot have infinite size
Sub(Box<Expr>, Box<Expr>),
Mul(Box<Expr>, Box<Expr>),
Div(Box<Expr>, Box<Expr>),
}
impl Expr {
// Convenience constructors โ hide Box::new boilerplate
pub fn num(n: f64) -> Self { Expr::Num(n) }
pub fn new_add(l: Expr, r: Expr) -> Self { Expr::Add(Box::new(l), Box::new(r)) }
// Structural recursion: eval mirrors the shape of the type
pub fn eval(&self) -> f64 {
match self {
Expr::Num(n) => *n,
Expr::Add(l, r) => l.eval() + r.eval(),
Expr::Sub(l, r) => l.eval() - r.eval(),
Expr::Mul(l, r) => l.eval() * r.eval(),
Expr::Div(l, r) => l.eval() / r.eval(),
}
}
}
// Build: (1 + 2) * (10 - 4) = 18
let e = Expr::new_mul(
Expr::new_add(Expr::num(1.0), Expr::num(2.0)),
Expr::new_sub(Expr::num(10.0), Expr::num(4.0)),
);
assert!((e.eval() - 18.0).abs() < f64::EPSILON);
`Box<Expr>` provides one level of heap indirection, giving `Expr` a known size at compile time. Without it, `Expr` would contain `Expr` which would contain `Expr` โ infinite size.
What This Unlocks
- Compiler front-ends โ this is the AST pattern every language implementation uses.
- Rule engines and query builders โ represent composable conditions as expression trees.
- Safe error handling improvement โ `eval_safe()` returning `Result<f64, String>` for divide-by-zero shows how Rust improves on OCaml's silent `infinity`.
Key Differences
| Concept | OCaml | Rust |
|---|---|---|
| Recursive type | Implicit heap allocation | `Box<Expr>` required for known size |
| Constructors | `Add (Num 1.0, Num 2.0)` | `Expr::new_add(Expr::num(1.0), Expr::num(2.0))` |
| Eval function | `let rec eval = function ...` | `impl Expr { fn eval(&self) -> f64 }` |
| Display | Manual `to_string` function | `impl Display for Expr` โ enables `format!("{e}")` |
| Division safety | Returns `infinity` silently | Can return `Result` for explicit error |
//! # Recursive Variant โ Expression Tree
//!
//! OCaml's recursive variants with payloads map to Rust's `enum` with
//! `Box`-wrapped recursive fields. The key difference: Rust requires
//! explicit heap allocation (`Box`) for recursive types because it must
//! know the size of every type at compile time.
// ---------------------------------------------------------------------------
// Approach A: Idiomatic Rust โ enum with Box, methods via impl
// ---------------------------------------------------------------------------
/// An arithmetic expression tree.
///
/// `Box<Expr>` is required because `Expr` is recursive โ without it,
/// `Expr` would have infinite size. OCaml handles this implicitly because
/// all variant payloads are heap-allocated.
#[derive(Debug, Clone, PartialEq)]
pub enum Expr {
Num(f64),
Add(Box<Expr>, Box<Expr>),
Sub(Box<Expr>, Box<Expr>),
Mul(Box<Expr>, Box<Expr>),
Div(Box<Expr>, Box<Expr>),
}
/// Convenience constructors to avoid writing `Box::new(...)` everywhere.
/// This is a common Rust pattern for recursive data structures.
impl Expr {
pub fn num(n: f64) -> Self {
Expr::Num(n)
}
pub fn new_add(l: Expr, r: Expr) -> Self {
Expr::Add(Box::new(l), Box::new(r))
}
pub fn new_sub(l: Expr, r: Expr) -> Self {
Expr::Sub(Box::new(l), Box::new(r))
}
pub fn new_mul(l: Expr, r: Expr) -> Self {
Expr::Mul(Box::new(l), Box::new(r))
}
pub fn new_div(l: Expr, r: Expr) -> Self {
Expr::Div(Box::new(l), Box::new(r))
}
}
impl Expr {
/// Evaluate the expression tree recursively.
///
/// Mirrors OCaml's `eval` function โ structural recursion over the variant.
/// Takes `&self` (borrowed reference) rather than consuming the tree.
pub fn eval(&self) -> f64 {
match self {
Expr::Num(n) => *n,
Expr::Add(l, r) => l.eval() + r.eval(),
Expr::Sub(l, r) => l.eval() - r.eval(),
Expr::Mul(l, r) => l.eval() * r.eval(),
Expr::Div(l, r) => l.eval() / r.eval(),
}
}
}
/// Display produces the same parenthesized format as OCaml's `to_string`.
impl std::fmt::Display for Expr {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Expr::Num(n) => write!(f, "{n}"),
Expr::Add(l, r) => write!(f, "({l} + {r})"),
Expr::Sub(l, r) => write!(f, "({l} - {r})"),
Expr::Mul(l, r) => write!(f, "({l} * {r})"),
Expr::Div(l, r) => write!(f, "({l} / {r})"),
}
}
}
// ---------------------------------------------------------------------------
// Approach B: Free functions โ closer to OCaml style
// ---------------------------------------------------------------------------
/// Evaluate as a standalone function (OCaml style).
pub fn eval(expr: &Expr) -> f64 {
expr.eval()
}
/// Convert to string as a standalone function.
pub fn to_string(expr: &Expr) -> String {
format!("{expr}")
}
// ---------------------------------------------------------------------------
// Approach C: Safe division with Result
// ---------------------------------------------------------------------------
/// Division-safe evaluation that returns an error on divide-by-zero
/// instead of producing `inf` or `NaN`.
///
/// This is a Rust improvement over the OCaml version โ OCaml's version
/// silently produces `infinity` on division by zero.
pub fn eval_safe(expr: &Expr) -> Result<f64, String> {
match expr {
Expr::Num(n) => Ok(*n),
Expr::Add(l, r) => Ok(eval_safe(l)? + eval_safe(r)?),
Expr::Sub(l, r) => Ok(eval_safe(l)? - eval_safe(r)?),
Expr::Mul(l, r) => Ok(eval_safe(l)? * eval_safe(r)?),
Expr::Div(l, r) => {
let divisor = eval_safe(r)?;
if divisor == 0.0 {
Err(format!("Division by zero: {expr}"))
} else {
Ok(eval_safe(l)? / divisor)
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
// (1 + 2) * (10 - 4) = 18
fn sample_expr() -> Expr {
Expr::new_mul(
Expr::new_add(Expr::num(1.0), Expr::num(2.0)),
Expr::new_sub(Expr::num(10.0), Expr::num(4.0)),
)
}
#[test]
fn test_eval_basic() {
assert!((sample_expr().eval() - 18.0).abs() < f64::EPSILON);
}
#[test]
fn test_eval_single_num() {
assert!((Expr::num(42.0).eval() - 42.0).abs() < f64::EPSILON);
}
#[test]
fn test_display() {
assert_eq!(to_string(&sample_expr()), "((1 + 2) * (10 - 4))");
}
#[test]
fn test_eval_nested_division() {
// 10 / (5 - 3) = 5
let e = Expr::new_div(
Expr::num(10.0),
Expr::new_sub(Expr::num(5.0), Expr::num(3.0)),
);
assert!((e.eval() - 5.0).abs() < f64::EPSILON);
}
#[test]
fn test_eval_safe_division_by_zero() {
let e = Expr::new_div(Expr::num(1.0), Expr::num(0.0));
assert!(eval_safe(&e).is_err());
}
#[test]
fn test_eval_safe_ok() {
assert!((eval_safe(&sample_expr()).unwrap() - 18.0).abs() < f64::EPSILON);
}
#[test]
fn test_display_num() {
assert_eq!(format!("{}", Expr::num(3.14)), "3.14");
}
#[test]
fn test_clone_and_eq() {
let e = sample_expr();
let e2 = e.clone();
assert_eq!(e, e2);
}
}
fn main() {
println!("{:?}", (sample_expr().eval() - 18.0).abs() < f64::EPSILON);
println!("{:?}", (Expr::num(42.0).eval() - 42.0).abs() < f64::EPSILON);
println!("{:?}", to_string(&sample_expr()), "((1 + 2) * (10 - 4))");
}
(* Recursive Variant โ Expression Tree *)
type expr =
| Num of float
| Add of expr * expr
| Sub of expr * expr
| Mul of expr * expr
| Div of expr * expr
(* Implementation 1: Direct recursive eval *)
let rec eval = function
| Num n -> n
| Add (l, r) -> eval l +. eval r
| Sub (l, r) -> eval l -. eval r
| Mul (l, r) -> eval l *. eval r
| Div (l, r) -> eval l /. eval r
(* Implementation 2: to_string with parentheses *)
let rec to_string = function
| Num n -> string_of_float n
| Add (l, r) -> Printf.sprintf "(%s + %s)" (to_string l) (to_string r)
| Sub (l, r) -> Printf.sprintf "(%s - %s)" (to_string l) (to_string r)
| Mul (l, r) -> Printf.sprintf "(%s * %s)" (to_string l) (to_string r)
| Div (l, r) -> Printf.sprintf "(%s / %s)" (to_string l) (to_string r)
(* Tests *)
let () =
(* (1 + 2) * (10 - 4) = 18 *)
let e = Mul (Add (Num 1., Num 2.), Sub (Num 10., Num 4.)) in
assert (eval e = 18.);
assert (eval (Num 42.) = 42.);
assert (eval (Div (Num 10., Sub (Num 5., Num 3.))) = 5.);
Printf.printf "%s = %.1f\n" (to_string e) (eval e);
Printf.printf "All expression-tree tests passed!\n"
๐ Detailed Comparison
Comparison: Expression Tree โ OCaml vs Rust
OCaml
๐ช Show OCaml equivalent
type expr =
| Num of float
| Add of expr * expr
| Mul of expr * expr (* etc. *)
let rec eval = function
| Num n -> n
| Add (l, r) -> eval l +. eval r
| Mul (l, r) -> eval l *. eval r
(* ... *)
let rec to_string = function
| Num n -> string_of_float n
| Add (l, r) -> Printf.sprintf "(%s + %s)" (to_string l) (to_string r)
(* ... *)
Rust โ Idiomatic (Box + impl)
#[derive(Debug, Clone, PartialEq)]
pub enum Expr {
Num(f64),
Add(Box<Expr>, Box<Expr>),
Mul(Box<Expr>, Box<Expr>), // etc.
}
impl Expr {
pub fn new_add(l: Expr, r: Expr) -> Self {
Expr::Add(Box::new(l), Box::new(r))
}
pub fn eval(&self) -> f64 {
match self {
Expr::Num(n) => *n,
Expr::Add(l, r) => l.eval() + r.eval(),
Expr::Mul(l, r) => l.eval() * r.eval(),
// ...
}
}
}Rust โ Safe Division (Result)
pub fn eval_safe(expr: &Expr) -> Result<f64, String> {
match expr {
Expr::Div(l, r) => {
let divisor = eval_safe(r)?;
if divisor == 0.0 { Err("Division by zero".into()) }
else { Ok(eval_safe(l)? / divisor) }
}
// ...
}
}Comparison Table
| Aspect | OCaml | Rust | |
|---|---|---|---|
| Recursive type | `type expr = Num of float \ | Add of expr expr` | `enum Expr { Num(f64), Add(Box<Expr>, Box<Expr>) }` |
| Heap allocation | Implicit (GC-managed) | Explicit with `Box<T>` | |
| Constructor | `Add (l, r)` directly | `Expr::Add(Box::new(l), Box::new(r))` or helper | |
| Pattern matching | `\ | Add (l, r) -> ...` | `Expr::Add(l, r) => ...` (l, r are `&Box<Expr>`) |
| Float ops | `+.` `-` `.` `/.` (separate from int) | `+` `-` `` `/` (overloaded via traits) | |
| Division safety | Returns `infinity` | Can return `Result` with error | |
| Pretty-print | Custom `to_string` function | `impl Display` trait |
Type Signatures Explained
OCaml: `val eval : expr -> float` โ takes an expression, returns a float. The recursive structure is handled by the GC.
Rust: `fn eval(&self) -> f64` โ borrows `self` (`&Expr`). When matching `Add(l, r)`, `l` and `r` are `&Box<Expr>`, which auto-derefs to `&Expr` for the recursive call.
Takeaways
1. Box is the price of ownership: OCaml's GC handles recursive types transparently; Rust makes heap allocation explicit 2. Constructor boilerplate: Helper functions like `new_add()` are a common Rust pattern to hide `Box::new` 3. Safety upgrade: Rust's `Result` type makes error handling for division explicit and composable with `?` 4. Display trait integrates with `format!`, `println!`, and `to_string()` automatically 5. Auto-deref makes working with `Box<Expr>` ergonomic โ you rarely notice the indirection in pattern matching