Skip to content

← Trait Objects and Dispatch step 4 of 10

Medium End-to-End

Enum dispatch: the third option

Most Rust tutorials present exactly two ways to hold a mixed bag of types: generics (fast, but homogeneous) and Box<dyn Trait> (heterogeneous, but indirect). There is a third, it is systematically under-taught, and on the measurements from the previous item it wins outright:

shape (implementation chosen at run time) time
opaque &dyn Op, 5M elements 0.904 ms
enum + match 0.250 ms
statically-known concrete call 0.2499 ms

Enum dispatch was indistinguishable from the fully static case and 3.6× faster than dyn. It also allocates nothing.

The idea is simple to the point of feeling like cheating: instead of erasing the type behind a vtable, name every possibility in one enum and implement the trait for the enum by delegating each method to a match.

enum AnyShape { Circle(Circle), Rect(Rect), Triangle(Triangle) }

impl Shape for AnyShape {
    fn area(&self) -> f64 {
        match self {
            AnyShape::Circle(c) => c.area(),
            ...
        }
    }
}

Now Vec<AnyShape> is a flat, contiguous array of values. No Box. No heap allocation per element. No vtable load. The match compiles to a jump table or a handful of predictable branches, and — crucially — the optimiser can see every arm, so it can inline all of them.

In the wider ecosystem a macro crate (enum_dispatch) generates this boilerplate for you. There are no external crates here, so you write it by hand, which is better pedagogy anyway: you will see exactly how much code you are trading for the speed. (macro_rules! is available and can generate the delegating arms — a nice payoff once you reach the macros track.)

The trade, stated honestly

  1. The enum is as big as its largest variant, plus a tag. A Vec of them wastes space on the small ones. If one variant is enormous, box that variant — clippy’s large_enum_variant will tell you.
  2. It closes the set. Nobody outside your crate can add a shape. That is the entire reason dyn exists: plugins, user extension, Box<dyn Error>. If the set of implementors is genuinely open, you need dyn. If it is five things you wrote yourself, an enum is almost always better.
  3. Adding a variant is a breaking change for every match — which is usually a feature. E0004 will find every place you forgot.

What to write

A Shape trait and three concrete shapes are given. You write:

pub fn make(kind: u8, a: f64, b: f64) -> AnyShape
impl Shape for AnyShape { ... }
pub fn total_area(shapes: &[AnyShape]) -> f64

make selects on kind % 3: 0Circle { r: a }, 1Rect { w: a, h: b }, 2Triangle { b: a, h: b }.

total_area sums in a specified order: start at 0.0 and add each shape’s area in slice order, left to right. Floating-point addition is not associative, so an unspecified order would not be reproducible — a point the performance track will hammer on.

The gate that matters

One test builds 100 000 shapes and counts heap allocations during construction. The vector itself is pre-sized by the harness before counting starts, so the budget is zero allocations. A Vec<Box<dyn Shape>> design allocates 100 000 times and fails on the spot. That number is deterministic — no timing, no flakiness, and it is the honest way to prove the structural claim.

Remember the grade is compile + tests + clippy -D warnings.

Loading visualization…