match instead of the visitor

The visitor pattern exists because a C++ function called on a base class knows the base and not the derived type it really has. Two virtual calls in a row fix that: the object accepts a visitor and the visitor is called with the concrete type. Rust has an easier answer, because a value of an enum carries its variant in the type and a match sees every variant at once.

This lesson writes the pattern with two traits, writes the same walk as a match, counts events through a handler, evaluates a tree of expressions in one function and ends with the compiler error for the variant that no arm covers.

Double dispatch with a trait

accept is the first dispatch: the call goes to the concrete shape and the shape knows its own fields. It then calls the matching method of the visitor, which is the second dispatch and the one that knows the operation. A new operation is a new visitor, and no shape changes.

The program has two shapes and one visitor that prints an area. The vector holds boxed shapes, so the concrete types are forgotten at the call and recovered by the two dispatches.

trait Visitor { fn square(&mut self, side: f64); fn circle(&mut self, radius: f64); } trait Shape { fn accept(&self, visitor: &mut dyn Visitor); } struct Square { side: f64, } struct Circle { radius: f64, } impl Shape for Square { fn accept(&self, visitor: &mut dyn Visitor) { visitor.square(self.side); } } impl Shape for Circle { fn accept(&self, visitor: &mut dyn Visitor) { visitor.circle(self.radius); } } struct Area; impl Visitor for Area { fn square(&mut self, side: f64) { println!("square {}", side * side); } fn circle(&mut self, radius: f64) { println!("circle {:.2}", 3.14159 * radius * radius); } } fn main() { let shapes: Vec<Box<dyn Shape>> = vec![ Box::new(Square { side: 3.0 }), Box::new(Circle { radius: 2.0 }), ]; let mut area = Area; for shape in &shapes { shape.accept(&mut area); } }

rustc --edition 2024 visit.rs
./visit

square 9
circle 12.57

The pattern works and it is the C++ design, with the same costs: one trait method per shape in every visitor, a dynamic call per node, and a new shape that breaks every visitor. The lesson on trait objects covers the dispatch, and the C++ course builds the same pair of traits in its own lesson.

The same walk as a match

An enum with data holds the variant and its fields in one value, and a match reads both at once. The operation lives in a function that takes the enum, so a new operation is a new function and the types do not change.

The program stores a square and a circle in one array, names each one and adds the areas. The two match expressions are exhaustive, and the compiler checks that.

enum Shape { Square { side: f64 }, Circle { radius: f64 }, } fn area(shape: &Shape) -> f64 { match shape { Shape::Square { side } => side * side, Shape::Circle { radius } => 3.14159 * radius * radius, } } fn name(shape: &Shape) -> &'static str { match shape { Shape::Square { .. } => "square", Shape::Circle { .. } => "circle", } } fn main() { let shapes = [Shape::Square { side: 3.0 }, Shape::Circle { radius: 2.0 }]; let mut total = 0.0; for shape in &shapes { total += area(shape); println!("{}", name(shape)); } println!("{total:.2}"); }

rustc --edition 2024 matched.rs
./matched

square
circle
21.57

Every arm of an exhaustive match is a place the compiler will point at when the data changes, and none of them can be forgotten silently, which is the property the visitor pattern tries to build with conventions. The array is one type and lives on the stack, with no box and no vtable. A new operation is a function like name, and the pattern of reading fields is the same in every one of them. The lesson on enums covers the variants with data, and pattern matching covers the .. that ignores the fields.

A handler that counts

The visitor earns its keep when the operation has state and the code that walks the data is not the code that reacts to it. An event loop is the classic case: the queue produces events, a handler keeps counters, and neither knows much about the other.

Here the walk is a match inside dispatch, which is the visitor's accept written once for the whole enum rather than once per type, and the handler is a trait object.

enum Event { Click { x: i32 }, Key { code: u8 }, Close, } trait Handler { fn click(&mut self, x: i32); fn key(&mut self, code: u8); fn close(&mut self); } impl Event { fn dispatch(&self, handler: &mut dyn Handler) { match self { Event::Click { x } => handler.click(*x), Event::Key { code } => handler.key(*code), Event::Close => handler.close(), } } } struct Counter { clicks: u32, keys: u32, closed: bool, } impl Handler for Counter { fn click(&mut self, _x: i32) { self.clicks += 1; } fn key(&mut self, _code: u8) { self.keys += 1; } fn close(&mut self) { self.closed = true; } } fn main() { let events = [ Event::Click { x: 10 }, Event::Key { code: 65 }, Event::Click { x: 20 }, Event::Close, ]; let mut counter = Counter { clicks: 0, keys: 0, closed: false }; for event in &events { event.dispatch(&mut counter); } println!("{} {} {}", counter.clicks, counter.keys, counter.closed); }

rustc --edition 2024 events.rs
./events

2 1 true

Two clicks, one key and a closed flag, counted without any clone of the events. The handler is a trait object, so a second handler that logs instead of counting needs no change in Event. The state of the handler lives in the struct and the state of the queue lives in the array, which is the separation that makes an event loop testable: a handler can be replaced by one that records the calls, and the queue does not know. The lesson on traits covers the default methods a handler of this shape often gives.

A tree of expressions

A tree is where the visitor pattern hurts most in C++, because every node type needs its own accept and every operation needs a method per node. An enum with a boxed child turns the whole walk into one recursive function.

The recursion also makes the cost visible. Each box is one allocation and each call is one indirect jump, so a long expression is a chain of allocations. A small tree is cheap and readable; a compiler that evaluates millions of nodes keeps the nodes in a flat vector and refers to them by index, and the walk then has the cache behaviour of the layout lesson rather than the shape of a linked list.

The expression below is two plus three times four. The recursion follows the boxes, and the match decides at each node whether to return a number or to combine two calls.

enum Expr { Num(f64), Add(Box<Expr>, Box<Expr>), Mul(Box<Expr>, Box<Expr>), } fn eval(expr: &Expr) -> f64 { match expr { Expr::Num(value) => *value, Expr::Add(left, right) => eval(left) + eval(right), Expr::Mul(left, right) => eval(left) * eval(right), } } fn main() { let expr = Expr::Add( Box::new(Expr::Num(2.0)), Box::new(Expr::Mul(Box::new(Expr::Num(3.0)), Box::new(Expr::Num(4.0)))), ); println!("{}", eval(&expr)); }

rustc --edition 2024 tree.rs
./tree

14

A second operation, a printer or a simplifier, is another function with the same three arms. A visitor with the same job needs a trait with three methods, an accept in all three types and a wrapper for each new operation, which is more code for the same result. The lesson on Box explains why the recursive type needs the box at all.

The variant no arm handles

The advantage of the enum over the trait appears when the data changes. A new variant breaks every match that does not use a wildcard, at compile time, with the name of the variant in the message. The visitor equivalent compiles and does nothing until someone remembers to add the method.

enum Shape { Square, Circle, Triangle, } fn name(shape: &Shape) -> &'static str { match shape { Shape::Square => "square", Shape::Circle => "circle", } } fn main() { println!("{}", name(&Shape::Triangle)); }

rustc --edition 2024 exhaustive_err.rs

error[E0004]: non-exhaustive patterns: `&Shape::Triangle` not covered
--> exhaustive_err.rs:8:11
|
8 | match shape {
| ^^^^^ pattern `&Shape::Triangle` not covered
|
note: `Shape` defined here
--> exhaustive_err.rs:1:6
|
1 | enum Shape {
| ^^^^^
...
4 | Triangle,
| -------- not covered
= note: the matched value is of type `&Shape`
help: ensure that all possible cases are being handled by adding a match arm with a wildcard pattern or an explicit pattern as shown
|
10 ~ Shape::Circle => "circle",
11 ~ &Shape::Triangle => todo!(),
|

The message names the variant and offers the arm that would fix it, and a wildcard _ would silence the check and hide the next variant as well. A program that wants the check should match every variant by name. The next lesson applies the same choice to behaviour: a generic parameter or a trait object.

Exercises

  1. Add a Triangle variant and make every match compile again without a wildcard.
  2. Write a second visitor that prints the perimeter of each shape instead of the area.
  3. Add a Div node to the expression tree and decide what a division by zero should do.

Questions

A new set of questions in a new order every time. Choose one answer for each, then check.

  1. What is double dispatch in the visitor pattern?

    Answer

    Two calls: the object picks the type, the visitor picks the operation

    accept resolves the concrete type and the visitor method resolves the operation.

  2. What happens when a variant is added to an enum that a match already handles?

    Answer

    Every match without a wildcard fails to compile

    Exhaustiveness is checked at compile time, and the message names the missing variant.

  3. Why is a match over an enum usually shorter than a visitor in Rust?

    Answer

    One function sees every variant and no type needs an accept method

    The walk lives in the function, so a new operation is a new function rather than a trait.

  4. What does the visitor pattern make easy, and what does it make hard?

    Answer

    Easy to add operations, hard to add types

    An operation is a new visitor, while a new type needs a method in every visitor.

  5. Why does an enum with data avoid the heap for a plain value?

    Answer

    The largest variant fits in the enum, which lives where the value lives

    The enum is as large as its largest variant, and only a recursive variant needs a Box.

  6. What does this program print? enum Shape { Square { side: f64 }, Circle { radius: f64 }, } fn area(shape: &Shape) -> f64 { match shape { Shape::Square { side } => side * side, Shape::Circle { radius } => 3.14159 * radius * radius, } } fn main() { let shapes = [Shape::Square { side: 3.0 }, Shape::Circle { radius: 2.0 }]; let total: f64 = shapes.iter().map(area).sum(); println!("{total:.2}"); }

    Answer

    21.57

    Nine and twelve point five seven add to twenty-one point five seven.

  7. What does this program print? enum Event { Click, Key, Close, } fn count(events: &[Event]) -> (u32, u32) { let mut clicks = 0; let mut keys = 0; for event in events { match event { Event::Click => clicks += 1, Event::Key => keys += 1, Event::Close => {} } } (clicks, keys) } fn main() { let events = [Event::Click, Event::Key, Event::Click, Event::Close]; let (clicks, keys) = count(&events); println!("{clicks} {keys}"); }

    Answer

    2 1

    Two clicks and one key, and the close event changes nothing.

  8. What does this program print? enum Expr { Num(f64), Add(Box<Expr>, Box<Expr>), } fn eval(expr: &Expr) -> f64 { match expr { Expr::Num(value) => *value, Expr::Add(left, right) => eval(left) + eval(right), } } fn main() { let expr = Expr::Add(Box::new(Expr::Num(1.5)), Box::new(Expr::Add(Box::new(Expr::Num(2.0)), Box::new(Expr::Num(2.5))))); println!("{}", eval(&expr)); }

    Answer

    6

    The sum of one and a half, two and two and a half is six.

  9. What does this program print? trait Visitor { fn number(&mut self, value: i32); fn plus(&mut self); } trait Node { fn accept(&self, visitor: &mut dyn Visitor); } struct Num(i32); impl Node for Num { fn accept(&self, visitor: &mut dyn Visitor) { visitor.number(self.0); } } struct Plus; impl Node for Plus { fn accept(&self, visitor: &mut dyn Visitor) { visitor.plus(); } } struct Printer; impl Visitor for Printer { fn number(&mut self, value: i32) { print!("{value} "); } fn plus(&mut self) { print!("+ "); } } fn main() { let nodes: Vec<Box<dyn Node>> = vec![Box::new(Num(2)), Box::new(Plus), Box::new(Num(3))]; let mut printer = Printer; for node in &nodes { node.accept(&mut printer); } println!(); }

    Answer

    2 + 3

    Each node dispatches in order, so the printer writes the numbers around the plus.

  10. What is the first line of the compiler output for this program? enum Shape { Square, Circle, } fn name(shape: &Shape) -> &'static str { match shape { Shape::Square => "square", } } fn main() { println!("{}", name(&Shape::Circle)); }

    Answer

    error[E0004]: non-exhaustive patterns: &Shape::Circle not covered

    The match leaves one variant out, and the compiler names it.

The code on this page was built with rustc 1.96.1 and run on Alpine Linux 3.24.3 (x86_64) in a Docker container.

Article author: Arthur Isaev

Banner image