dyn Trait or generics: the decision is about the collection
Rust gives you two ways to accept “something implementing this trait”, and they compile to completely different things:
fn render(shape: &impl Shape) { … } // generic — monomorphised
fn render(shape: &dyn Shape) { … } // trait object — vtable
Coming from Swift, this is the same fork as some Protocol against any Protocol, and the reasoning
transfers almost exactly.
What each one compiles to
impl Trait in argument position is sugar for a generic. The compiler generates a separate copy of
the function for every concrete type it is called with — monomorphisation. Each copy is specialised,
inlinable, and knows exactly what shape.area() means.
render(&circle); // compiles a render::<Circle>
render(&square); // compiles a render::<Square>
dyn Trait is a fat pointer: one pointer to the data, one to a vtable of function pointers. One copy
of the function, and each method call is an indirect jump through the table.
The question that decides it
Not “which is faster” — the answer to that is almost always “it does not matter”. The question is:
Do I need a collection of different concrete types?
If yes, you need dyn. There is no other option:
let shapes: Vec<Box<dyn Shape>> = vec![
Box::new(Circle::new(3.0)),
Box::new(Square::new(4.0)),
];
Vec<impl Shape> does not mean “a vector of various shapes”. It means “a vector of one specific type
I am not naming”, and every element must be the same type. That is a different thing entirely, and
the compiler will tell you so.
If no — a function taking one value, a struct holding one thing — use the generic. It is faster, it allows inlining, and it costs the caller nothing.
The other three reasons to reach for dyn
Binary size. Monomorphisation duplicates code. A generic function called with twenty types
compiles twenty times, and in a large codebase that adds up. dyn compiles once.
Compile time. Same reason. Heavily generic code is slower to compile, and this is noticeable before binary size ever is.
Object safety as a design boundary. A plugin system, or anything where the set of implementers is
open and unknown at compile time, is naturally dyn.
Tip
The performance difference is smaller than people assume. A vtable call is an extra pointer dereference — single-digit nanoseconds. It matters in a loop running millions of times, mostly because it blocks inlining rather than because of the jump itself. For anything doing I/O, it is unmeasurable.
Object safety, and the errors it produces
Not every trait can be a trait object. A trait is object-safe only if none of its methods:
- return
Self - have generic type parameters
- take
selfby value (unless behindBox)
trait Shape {
fn area(&self) -> f64; // fine
fn scaled(&self, by: f64) -> Self; // NOT object safe
}
The error message says “the trait Shape cannot be made into an object”, which is accurate and does
not tell you which method is responsible. It is nearly always a -> Self return.
The fix is usually to split the trait:
trait Shape {
fn area(&self) -> f64; // object safe — this is the dyn part
}
trait ScalableShape: Shape {
fn scaled(&self, by: f64) -> Self where Self: Sized;
}
The where Self: Sized bound is the other escape hatch — it excludes that one method from the
object-safety requirement, so the trait stays usable as dyn and the method is only callable on
concrete types.
Async traits
This is where it currently bites hardest. async fn in a trait works now, but the resulting trait is
not object safe — the return type is an anonymous impl Future, which is a generic return.
trait Fetcher {
async fn fetch(&self, url: &str) -> Result<Vec<u8>>; // not dyn-safe
}
If you need Box<dyn Fetcher> — for a swappable implementation in tests, typically — the current
answer is async_trait, which boxes the future:
#[async_trait]
trait Fetcher: Send + Sync {
async fn fetch(&self, url: &str) -> Result<Vec<u8>>;
}
That allocates per call, which is the cost of the workaround. For a trait called once per request it is irrelevant; in a hot loop it is not.
What I do
Generic by default, dyn when the collection or the boundary requires it. Concretely:
- Function parameters →
impl Trait. Free specialisation, no reason not to. - Struct fields holding one implementation chosen at startup → generic parameter if the type is
known statically,
Box<dyn>if it is chosen at runtime. - Collections of mixed types →
Vec<Box<dyn Trait>>, no alternative. - A trait with two or three known implementers → consider an
enuminstead. It is faster than both, keeps exhaustiveness checking, and needs no boxing. This is the option people forget exists, and it is often the best one.