Skip to content
rusthacks.dev
Generics

Enum Dispatch in Rust for Speed Without Trait Objects

When you know every implementor of a trait, an enum can stand in for a trait object with no allocation and with inlinable calls. The cost is a closed set.

7 min read
Summarize in AI
Flat illustration for the post “Enum Dispatch in Rust for Speed Without Trait Objects”, composed around traits.

Enum dispatch replaces Box<dyn Trait> with a plain enum whose variants wrap the concrete types, and forwards each trait method with a match. It is the answer to "I like the trait, but I know every implementor, and I would rather not allocate for each one". The trade is real on both sides, and the speedup is smaller than the folklore suggests. This post builds the pattern by hand, removes the boilerplate with a macro, measures it against a boxed trait object with a benchmark that includes the unflattering case, and says when the closed set is a liability instead of a feature. Everything compiled on Rust 1.99, edition 2024.

The starting point: a trait and a boxed list

Three shapes share a behaviour. The usual first version stores them as trait objects:

rust
trait Shape {
    fn area(&self) -> f64;
    fn name(&self) -> &'static str;
}

struct Circle(f64);
struct Rect(f64, f64);
struct Tri(f64, f64);

impl Shape for Circle {
    fn area(&self) -> f64 {
        3.14159 * self.0 * self.0
    }
    fn name(&self) -> &'static str {
        "circle"
    }
}

impl Shape for Rect {
    fn area(&self) -> f64 {
        self.0 * self.1
    }
    fn name(&self) -> &'static str {
        "rect"
    }
}

impl Shape for Tri {
    fn area(&self) -> f64 {
        0.5 * self.0 * self.1
    }
    fn name(&self) -> &'static str {
        "triangle"
    }
}

fn main() {
    let shapes: Vec<Box<dyn Shape>> = vec![Box::new(Circle(2.0)), Box::new(Rect(3.0, 4.0)), Box::new(Tri(6.0, 2.0))];
    for s in &shapes {
        println!("{:<9}{:>7.2}", s.name(), s.area());
    }
}
Text
circle     12.57
rect       12.00
triangle    6.00

Each element is a pointer to a separate heap allocation plus a vtable pointer, so the vector holds 16 bytes per element and the shapes themselves live elsewhere. Calls go through the vtable.

The enum version, written by hand

An enum with one variant per concrete type, an impl of the trait for the enum, and a match in each method:

rust
trait Shape {
    fn area(&self) -> f64;
    fn name(&self) -> &'static str;
}

struct Circle(f64);
struct Rect(f64, f64);
struct Tri(f64, f64);

impl Shape for Circle {
    fn area(&self) -> f64 {
        3.14159 * self.0 * self.0
    }
    fn name(&self) -> &'static str {
        "circle"
    }
}

impl Shape for Rect {
    fn area(&self) -> f64 {
        self.0 * self.1
    }
    fn name(&self) -> &'static str {
        "rect"
    }
}

impl Shape for Tri {
    fn area(&self) -> f64 {
        0.5 * self.0 * self.1
    }
    fn name(&self) -> &'static str {
        "triangle"
    }
}

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

impl Shape for AnyShape {
    fn area(&self) -> f64 {
        match self {
            AnyShape::Circle(s) => s.area(),
            AnyShape::Rect(s) => s.area(),
            AnyShape::Tri(s) => s.area(),
        }
    }
    fn name(&self) -> &'static str {
        match self {
            AnyShape::Circle(s) => s.name(),
            AnyShape::Rect(s) => s.name(),
            AnyShape::Tri(s) => s.name(),
        }
    }
}

fn main() {
    let shapes = vec![
        AnyShape::Circle(Circle(2.0)),
        AnyShape::Rect(Rect(3.0, 4.0)),
        AnyShape::Tri(Tri(6.0, 2.0)),
    ];
    let total: f64 = shapes.iter().map(|s| s.area()).sum();
    println!("{total:.2}");
    println!("{} bytes per element, {} for a Box<dyn Shape>", std::mem::size_of::<AnyShape>(), std::mem::size_of::<Box<dyn Shape>>());
}
Text
30.57
24 bytes per element, 16 for a Box<dyn Shape>

The vector now holds the shapes inline, with no per-element allocation. The enum is 24 bytes (a tag plus the largest payload of two f64), which is larger than a 16-byte box pointer but does not need a second allocation to reach the data. The match compiles to a jump on the tag, and each arm calls a method the compiler can inline.

The price is the forwarding code, which has the shape "one arm per variant per method" and grows with both. Three methods and ten variants is thirty arms of boilerplate. Two fixes exist: a declarative macro, or the enum_dispatch crate, which generates the same code from an attribute.

Removing the boilerplate with a macro

A small macro_rules! keeps the list of variants in one place and writes the forwarding match for every method you name:

rust
trait Shape {
    fn area(&self) -> f64;
    fn name(&self) -> &'static str;
}

macro_rules! dispatch_enum {
    ($enum_name:ident { $($variant:ident($ty:ty)),+ $(,)? }) => {
        enum $enum_name {
            $($variant($ty)),+
        }

        $(impl From<$ty> for $enum_name {
            fn from(v: $ty) -> Self {
                $enum_name::$variant(v)
            }
        })+

        impl Shape for $enum_name {
            fn area(&self) -> f64 {
                match self { $($enum_name::$variant(s) => s.area()),+ }
            }
            fn name(&self) -> &'static str {
                match self { $($enum_name::$variant(s) => s.name()),+ }
            }
        }
    };
}

struct Circle(f64);
struct Rect(f64, f64);

impl Shape for Circle {
    fn area(&self) -> f64 {
        3.14159 * self.0 * self.0
    }
    fn name(&self) -> &'static str {
        "circle"
    }
}

impl Shape for Rect {
    fn area(&self) -> f64 {
        self.0 * self.1
    }
    fn name(&self) -> &'static str {
        "rect"
    }
}

dispatch_enum!(AnyShape { Circle(Circle), Rect(Rect) });

fn main() {
    let shapes: Vec<AnyShape> = vec![Circle(1.0).into(), Rect(2.0, 5.0).into()];
    for s in &shapes {
        println!("{} {:.2}", s.name(), s.area());
    }
}
Text
circle 3.14
rect 10.00

Adding a shape is now one entry in the macro invocation. The From impls generated alongside let callers write Circle(1.0).into() without naming the variant. This is the shape of what enum_dispatch does for you, with the advantage that it also handles generic traits and traits with many methods. Macros are covered in macro_rules basics.

Measuring: how much faster, really?

The benchmark sums areas over two million shapes, stored either as Vec<Box<dyn Shape>> or Vec<AnyShape>, built in sequence so neither version gets a cache disadvantage from allocation order. It ran in a release build on an Apple M1 Max, best of seven, with the slice passed through black_box to a non-inlined function. Two data sets: the kinds chosen pseudo-randomly, and every element a circle.

DataVec<Box<dyn Shape>>Vec<AnyShape> (enum)Enum speedup
Random mix of three kinds11.29 ms8.82 ms1.28x
All circles (predictable)1.99 ms1.99 ms1.00x

That is a smaller gain than the claims you may have seen, and the shape of the result is the useful part. When the next element is always the same kind, the branch predictor makes both versions equally cheap and they tie at about 1 nanosecond per element. When the kinds are unpredictable, both versions pay for mispredicted branches (the enum's match and the dyn version's indirect call), roughly 5 nanoseconds per element, and the enum is a quarter faster because it also avoids the pointer chase and the vtable load. So the benefit is real and modest, the data layout rather than the call itself does most of the work, and the benefit grows when the objects are scattered across the heap (the benchmark allocated them back to back, the friendliest case for the boxed version). Measure with your own data before you commit to a macro for 5 percent.

It is worth saying why the random case costs about five nanoseconds per element, since the figure explains the whole result. A modern core predicts the target of a branch from history. Three kinds in random order give the predictor nothing to learn, so roughly two thirds of the elements mispredict, and a mispredict costs on the order of a dozen cycles. The enum version spends them on the jump out of the match; the trait object version spends them on the indirect call. Sorting the elements by kind first confirms it: the same two million shapes then took 1.90 ms boxed and 1.91 ms as an enum, both about six times faster than the random order. That is a much bigger win than switching dispatch style, and it is free to try when the order of the elements does not matter.

What you give up

ConcernEnum dispatchTrait object
Set of typesClosed: only the variants you listOpen: any crate may implement
Adding a typeEdit the enumAdd an impl, touch nothing else
Size per elementThe largest variant (can waste space)16 bytes plus a heap allocation
Match exhaustivenessCompiler-checkedNone
Printing, comparing, cloningderive works on the enumNeeds extra traits and tricks

The size row is the one that surprises people. An enum is as big as its largest variant, so one variant holding a 400-byte buffer makes every element 400 bytes, including the small ones. If the variants differ wildly in size, box only the big one (Big(Box<Large>)), which keeps the common case inline.

When enum dispatch is the wrong choice

If the implementors live in other crates, or you want users to plug in their own, the set is open and the enum cannot express it; use a trait object, as in trait objects. If the calls are not hot, the extra code buys nothing, and a boxed trait object reads better. If the type set changes every month, you will edit one central enum on every addition and merge-conflict on it. And if you only need the closed set for pattern matching, with no shared behaviour, the enum is just an enum and no trait is involved. The decision framework across all of these is in static versus dynamic dispatch, and the same enum-over-trait choice appears when comparing strategies in the strategy pattern.

Share

Keep reading

Get new posts by email

We send one email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.