Step 6 of 7
Enums or trait objects?
You've now seen two ways to handle "one of several kinds of thing": an enum with a match (enums module), and a trait with dyn. Here are the same shapes as an enum:
enum Shape {
Circle { radius: f64 },
Rect { w: f64, h: f64 },
}
impl Shape {
fn area(&self) -> f64 {
match self {
Shape::Circle { radius } => 3.0 * radius * radius,
Shape::Rect { w, h } => w * h,
}
}
fn name(&self) -> &'static str {
match self {
Shape::Circle { .. } => "circle",
Shape::Rect { .. } => "rect",
}
}
}
fn main() {
let shapes = vec![Shape::Circle { radius: 1.0 }, Shape::Rect { w: 2.0, h: 3.0 }];
for s in &shapes {
println!("{} {}", s.name(), s.area());
}
}
circle 3
rect 6
No Box, no trait, and Vec<Shape> holds both kinds. So which should you use? It depends on what will change:
| Enum | Trait objects | |
|---|---|---|
| The set of kinds | closed: all variants are listed in one place | open: anyone can add a type, even in another crate |
| Add a new kind | add a variant, then fix every match (the compiler lists them) |
write one new impl; nothing else changes |
| Add a new operation | write one new method with a match |
add a method to the trait, then implement it for every type |
| Memory | stored inline, no allocation | one Box per value (heap) |
| Extras | derive(Debug, Clone, PartialEq), pattern matching on the data |
needs a dyn-compatible trait; can't easily inspect the concrete type |
(Computer scientists call this trade-off the expression problem: one design makes new kinds easy, the other makes new operations easy.)
Rules of thumb
- You know all the cases and you own the code: use an enum. Commands in a CLI, messages in a protocol, tokens in a parser, states of an order, events in your app. This covers most application code, and the compiler's exhaustiveness check is a huge help when the cases change.
- Other people (or other crates) need to add cases without editing your code: use a trait. Plugins, middleware, storage backends, notification channels, a library's extension points.
- Not sure? Start with the enum. It's simpler, and switching later is straightforward.
Common mistakes
- A catch-all
_ =>arm in amatchon your own enum. It switches off the exhaustiveness check: add a variant later and thatmatchsilently takes the default branch. List the variants instead. - Trait objects for a fixed set, out of habit from other languages. One struct and one impl per case, a
Boxper value, and no pattern matching, for no gain. - An enum as a library's extension point. Users of your crate can't add variants, and adding one yourself breaks every
matchthey wrote. That's what traits are for.
Your task
A checkout used to model payment methods as three structs behind a Payment trait. The set of methods is fixed and owned by the team, so it's being rewritten as an enum. Implement label and fee_cents with one match each:
Card { last4 }: labelcard ending <last4>, fee 2% of the amount (amount * 2 / 100)Cash: labelcash, no feeVoucher { code }: labelvoucher <code>, a flat fee of 50 cents
Challenge 2
A chat app's Message enum needs a new kind: Video { url: String, seconds: u32 }. Add the variant, then let the compiler's non-exhaustive patterns errors guide you to every match to update:
preview:video: <url> (<m>:<ss>), for examplevideo: cat.mp4 (1:05)size_hint:seconds * 500
Challenge 3
The other side: an open set. A web framework (pretend you can't edit it) checks every request path against a list of Box<dyn Filter>. Without changing Filter or check, add two filters of your own:
BlockPrefix(String): rejects paths that start with its prefixMaxLength(usize): rejects paths longer than its limit (in bytes)
Previous: Dyn compatibility (object safety) Next: Downcasting with Any (briefly)