Skip to content
← All articles

Choosing between generics, impl Trait, &dyn, Box<dyn> and enums

Five ways to say "something that implements this trait", what each costs, and the decision procedure — including the enum option almost nobody teaches.

There are five ways to write “a value that implements Draw“ in Rust. Learners who never make the choice consciously settle into one of two ruts: box everything, or genericise everything. Both ruts are visible in review, and both are avoidable in about thirty seconds of thought.

This is a genuine, ongoing style argument with no settled answer. What follows is the decision procedure, the costs, and where reasonable people disagree.

The five spellings

fn a<T: Draw>(x: &T)          // generic parameter
fn b(x: &impl Draw)           // APIT — argument-position impl Trait
fn c(x: &dyn Draw)            // borrowed trait object
fn d(x: Box<dyn Draw>)        // owned trait object
fn e(x: &AnyShape)            // enum over a closed set

The first two are the same thing. &impl Draw desugars to a generic parameter you cannot name — same monomorphisation, same code, same speed. The only differences are that you cannot turbofish it at the call site and you cannot use it twice to mean “the same type”. Prefer impl Trait when the parameter appears once and you never need to name it; prefer <T: Draw> when you need T in more than one position.

💡fn pair(a: &impl Draw, b: &impl Draw) and fn pair<T: Draw>(a: &T, b: &T) — do these accept the same set of calls? click to reveal

No, and this is the trap.

Each impl Draw introduces its own anonymous type parameter. So the first signature is really fn pair<T1: Draw, T2: Draw>(a: &T1, b: &T2)a and b may be different types. The second forces them to be the same type: pair(&circle, &square) is a type error.

Neither is more correct; they express different contracts. If your function compares the two, or swaps them, or stores them in one Vec, you want the named parameter. If it just draws both, impl Trait is looser and therefore better.

A second, subtler difference: with a named T, callers can write pair::<Circle>(...) to disambiguate. With impl Trait there is nothing to name, so if inference gets stuck the caller has no way out. That is a real ergonomic cost in generic-heavy APIs.

The table

dispatch heap code size extensible by others? can hold a mixed collection?
<T: Draw> / impl Draw static no one copy per type yes no
&dyn Draw dynamic no one copy yes yes (as Vec<&dyn Draw>)
Box<dyn Draw> dynamic one per value one copy yes yes
enum AnyShape match no one copy no yes

Read the two rightmost columns first, because they are the ones that actually decide most cases. “Can someone else’s crate add an implementor?” and “do I need to put different types in one container?” are structural questions with yes/no answers. Speed is usually the tiebreak, not the driver.

The decision procedure

  1. One type per call site, and the caller knows it? Generic or impl Trait. This is the default and covers most functions you will ever write.
  2. Mixed collection, or the type genuinely varies at run time? Now you need type erasure or an enum.
  3. Is the set of implementors closed — five things you wrote yourself? Use an enum. It is faster (measured indistinguishable from a static call), allocation-free, and the compiler will find every match when you add a variant.
  4. Is the set open — plugins, user extension, Box<dyn Error>? Use dyn. This is the whole reason dyn exists, and it is not a compromise, it is the correct tool.
  5. &dyn or Box<dyn>? Borrow if you can, own if you must. &dyn Draw is two words on the stack with no allocation. Box<dyn Draw> allocates, and you want it when the value must outlive the caller’s frame or be stored in a struct.
💡You are writing a logging library. Users register handlers; your crate does not know what they will be. Which shape, and why not the others? click to reveal

Vec<Box<dyn Handler>>, or Vec<Arc<dyn Handler + Send + Sync>> if handlers are shared across threads.

Walk the alternatives:

  • Generic Logger<H: Handler> — one handler type per logger. Users cannot register a file handler and a network handler on the same logger. Dead on arrival.
  • Enum — you would have to enumerate every handler anyone will ever write, in your crate. That is exactly the closed-set property, and here the set is open by design.
  • Vec<&dyn Handler> — now the logger borrows the handlers, so every handler must outlive the logger, and the lifetime propagates into every type that holds a logger. Technically possible; miserable in practice. This is the case where Box earns its allocation.

Note that the one allocation per handler happens once at registration, not per log call. That is the shape of most legitimate Box<dyn> use: allocate at the edge, dispatch in the middle.

What dyn costs, honestly

From the measurements in the previous two items, on this toolchain:

  • When the optimiser can see the implementor, &dyn and a generic are identical — 0.0589 ms versus 0.0598 over five million elements. It devirtualised and inlined the call and the dyn vanished.
  • When the implementor comes from run-time input, opaque &dyn is 0.904 ms against 0.250 for a statically-known call — 3.6×.
  • Enum dispatch in the same opaque test: 0.250 ms. Indistinguishable from static.

So the cost of dyn is not a fixed tax. It is the cost of an unpredictable indirect call in a hot loop, and it is zero when any one of those three conditions fails to hold.

And it buys something. One copy of the code instead of one per type means less to compile, less instruction cache pressure, and a smaller binary. Generics are fast to run and slow to build; dyn is the escape valve. That trade is the subject of the next item.

Two shapes that are almost always wrong

&Box<dyn Trait>. A pointer, to a pointer, to a fat pointer. clippy’s borrowed_box fires on it, and in the microbenchmark it measured 0.0710 ms against 0.0589 for &dyn Trait — a real 20% for one redundant word. Take &dyn Trait.

Box<dyn Any> plus downcast_ref. Any lets you erase a type and then ask, at run time, “was it really a Circle?” It exists, it is safe, and reaching for it is nearly always a signal that you have re-implemented a match badly — with the exhaustiveness check deleted. If you find yourself downcasting through a chain of if let, you wanted an enum.

The legitimate exceptions are narrow and worth knowing: extracting a concrete error from Box<dyn Error> (err.downcast_ref::<io::Error>()), recovering a panic payload from catch_unwind, and heterogeneous extension maps where the key type genuinely is the API (TypeId-keyed context bags). Outside those, treat it as a smell.

💡clone is not dyn compatible — fn clone(&self) -> Self mentions Self outside the receiver. So how do real libraries manage Vec<Box<dyn Shape>> where the shapes need cloning? click to reveal

Three answers, in increasing order of how much you should like them.

1. Add a boxed-clone method to your own trait.

trait Shape {
    fn area(&self) -> f64;
    fn clone_box(&self) -> Box<dyn Shape>;
}

impl<T: Shape + Clone + 'static> ... // blanket impl of clone_box
impl Clone for Box<dyn Shape> {
    fn clone(&self) -> Self { self.clone_box() }
}

clone_box returns Box<dyn Shape>, not Self, so it is dispatchable. Implementing Clone for Box<dyn Shape> on top makes it invisible at the call site. This is what the dyn-clone crate automates, and writing it by hand is about ten lines.

2. Use Rc/Arc instead of Box. Rc<dyn Shape> clones by bumping a counter, no trait involvement at all. If the values are immutable — and shapes usually are — this is cheaper and simpler than deep cloning. Often the right answer.

3. Use an enum. #[derive(Clone)] on the enum just works, because the enum is Sized and its variants are concrete. If the set is closed, the whole problem evaporates — which is the recurring theme of this article.

The one-line version

Reach for generics by default; reach for an enum when the set is closed; reach for dyn when the set is open. Then measure, because the folklore about which is fast has been wrong twice already in this track.