Rust Patterns That Matter #14: Enum Dispatch vs Trait Objects
Post 14 of 22 in Rust Patterns That Matter. Companion series: Building a Chat Server in Rust.
Previous: #13: 'static + Clone | Next: #15: Fn, FnMut, FnOnce
You have different types that share common behaviour. Classic polymorphism. In most languages, you'd reach for an interface or base class. Rust gives you two tools: enums and trait objects. They solve the same problem differently, and choosing wrong leads to friction as the code evolves.
Approach 1: Enum dispatch
enum Shape {
Circle { radius: f64 },
Rect { width: f64, height: f64 },
Triangle { base: f64, height: f64 },
}
impl Shape {
fn area(&self) -> f64 {
match self {
Shape::Circle { radius } =>
std::f64::consts::PI * radius * radius,
Shape::Rect { width, height } =>
width * height,
Shape::Triangle { base, height } =>
0.5 * base * height,
}
}
fn perimeter(&self) -> f64 {
match self {
Shape::Circle { radius } =>
2.0 * std::f64::consts::PI * radius,
Shape::Rect { width, height } =>
2.0 * (width + height),
Shape::Triangle { base, height } => {
let side = (base * base / 4.0 + height * height).sqrt();
base + 2.0 * side
}
}
}
}
Adding a new operation (like perimeter) is easy:
write a new method with a match. All variants are handled in one place.
Adding a new variant (like Pentagon) requires changing
every match arm in every method. The compiler helps - exhaustive
matching means you get errors for every method you haven't updated. But the changes
are scattered.
Approach 2: Trait objects
trait Shape {
fn area(&self) -> f64;
fn perimeter(&self) -> f64;
}
struct Circle { radius: f64 }
struct Rect { width: f64, height: f64 }
impl Shape for Circle {
fn area(&self) -> f64 {
std::f64::consts::PI * self.radius * self.radius
}
fn perimeter(&self) -> f64 {
2.0 * std::f64::consts::PI * self.radius
}
}
impl Shape for Rect {
fn area(&self) -> f64 { self.width * self.height }
fn perimeter(&self) -> f64 { 2.0 * (self.width + self.height) }
}
// Usage with trait objects:
let shapes: Vec<Box<dyn Shape>> = vec![
Box::new(Circle { radius: 5.0 }),
Box::new(Rect { width: 3.0, height: 4.0 }),
];
Adding a new variant (a new struct implementing Shape)
is easy: write the struct and its impl. No existing code changes.
Adding a new operation (a new method on the trait) is hard: every existing implementation must be updated. If the trait is in a library, adding a method is a breaking change.
The expression problem
This is a well-known tradeoff in language design:
- Enums: easy to add operations, hard to add variants
- Trait objects: easy to add variants, hard to add operations
Neither is universally better. The question is: how will your code evolve? Will you add new types or new operations?
Performance
Enum dispatch is static. The compiler sees the match and can inline
each arm. No heap allocation, no vtable indirection. This matters in hot loops.
Trait objects use dynamic dispatch: a vtable pointer for method calls, and typically
a Box for heap allocation. The indirection prevents inlining. For
event handlers or plugin systems called infrequently, this is irrelevant. For
per-pixel rendering or per-packet processing, it can matter.
Telex's choice
Telex uses an enum for its View type:
enum View {
Text(String),
Container { children: Vec<View> },
Button { label: String, on_click: Rc<dyn Fn()> },
// ... other built-in widgets
}
The set of built-in widget types is known and controlled by the framework. New widgets are added by the Telex developers, not by users. This is a closed set. New operations (rendering, diffing, layout) are added more often than new widget types. Enum dispatch is the natural fit. See Designing a TUI Framework - Part 1 for the rationale.
When to use which
- Enum dispatch when the set of variants is known, controlled by your crate, and unlikely to grow rapidly. When you add operations more often than types. When performance matters.
- Trait objects when callers define the types: plugin systems, user-extensible components, anything where downstream crates provide implementations. When the set of types is open-ended.
The decisive question: who defines the concrete types? If it's you, use an enum. If it's your users, use a trait.
See it in practice: Building a Chat Server #4: Commands and Plugins uses this pattern for the /join, /nick, /kick command system.
Telex