Rust dyn Trait vs generics: how to switch, and the 16-byte cost

rust dev.to

TL;DR Rust dyn Trait vs generics comes down to one number: dyn Trait is a 16-byte fat pointer — a data pointer plus a vtable pointer — twice the size of the plain 8-byte reference generics compile down to. Generics pay their cost at compile time (monomorphization: one function body per concrete type you call with), while dyn Trait pays it on every call instead (one vtable load plus an indirect jump). Use dyn Trait only where you need one collection or return type to hold genuinely different concrete types at once; some traits — anything with a method returning Self, or a generic method — can't become trait objects at all, no matter which one you'd prefer.

Every Rust codebase eventually hits the same fork: a Draw trait implemented by Circle, Square, and Triangle, and a function that needs to draw any of them. Generics and dyn Trait both compile — the compiler is happy to accept either — but they solve different problems, and picking the wrong one shows up either as a binary bigger than it needs to be, or as a Vec that won't compile because its elements aren't all the same concrete type.

This is the same trade-off you run into when you're staring at struct layout and padding or deciding how much unsafe is worth a performance win — except here the compiler enforces the boundary whether you understand why or not. So here's the trade-off made explicit: what dyn Trait actually costs, measured in bytes, and the rule for when that cost is worth paying.

What dyn Trait actually costs in memory

A plain Rust reference, &T, is a thin pointer — 8 bytes on a 64-bit target, holding nothing but the address of the value. Write &dyn Draw instead and the compiler hands you a fat pointer: 16 bytes, twice the size, because it now carries two addresses instead of one — a pointer to the concrete value's data, and a pointer to that type's vtable, a static table of function pointers used to find the right draw() implementation for whatever concrete type is actually behind the reference.

You can verify this yourself, no benchmark required: std::mem::size_of::<&dyn Draw>() reports 16 on any 64-bit target, against 8 for std::mem::size_of::<&Circle>(). Box<dyn Draw> costs the same 16 bytes for the pointer, plus whatever the concrete value needs on the heap — boxing a trait object doesn't make the fat pointer thinner, it just adds ownership of whatever it points to.

The detail that trips people up: the vtable is keyed on the (concrete type, trait) pair, not on the type alone. If Duck implements both Fly and Swim, a &dyn Fly and a &dyn Swim built from the same duck value carry an identical data pointer but two different vtable pointers — one full of Fly's methods, one full of Swim's. There's no single "the vtable" for a type; there's one per trait it's viewed through.

How static dispatch avoids the cost — and what it costs instead

Write the same function generically — fn draw_shape(shape: &T) — and the compiler does something completely different: it emits a separate compiled copy of the function for every concrete type you actually call it with. draw_shape:: and draw_shape:: become two distinct functions in the binary, and each one calls Circle::draw or Square::draw directly, with no lookup at all. This is monomorphization, and it's the reason generics in Rust are called a zero-cost abstraction: by the time the program runs, there's no polymorphism left to resolve — it happened at compile time.

The cost doesn't disappear, it moves. Every additional concrete type a generic function gets instantiated with is another compiled function body in your binary — ten call sites with ten different types produce ten function bodies, not one. That's a compile-time and binary-size cost, not a runtime one, which is the opposite trade from dyn Trait: one function body, paid for with a vtable load and an indirect call on every use.

Rust also draws this line in a different place than C++. In C++, a class either has virtual methods or it doesn't — the choice is baked into the class definition, and every instance carries a vtable pointer whether or not you ever call through it dynamically. In Rust, the same type can be used generically in one function and boxed behind dyn in another; the choice is made per call site&dyn Trait or Box<dyn Trait> — not per type definition.

The decision table: dyn Trait vs generics

Axis Generics (`/impl Trait`) dyn Trait
Reference size 8 bytes (thin) 16 bytes (fat: data + vtable)
Dispatch cost per call None — resolved at compile time One vtable load + indirect call
Binary size Grows with each concrete type instantiated One function body, regardless of how many types implement the trait
Heterogeneous collections (Vec>) Not directly possible — a Vec needs one concrete T The whole point — different concrete types in one collection
Generic methods on the trait Supported Not supported — breaks object safety
Compile time Increases with each instantiation Unaffected by how many types implement the trait

Nothing in this table is a tie-breaker by itself — it's the input to the one question that actually decides it.

Rust dyn Trait vs generics: when should you switch?

Reach for dyn Trait when you need one collection, field, or return type to hold genuinely different concrete types at runtime — a plugin registry, a list of UI widgets, a set of parsers chosen by content type, a callback registered by code you don't control and can't monomorphize against. That's the case dyn Trait exists to solve, and generics can't solve it at all: Vec requires every element to be the same concrete T.

Reach for generics everywhere else, including the default case of "one call site, one concrete type at a time." You get the same abstraction over the trait's methods with zero per-call cost, and the compiler catches a bound mismatch immediately at the call site — it doesn't wait until you try to build a heterogeneous Vec to tell you something doesn't fit. If you're not sure yet whether you'll ever need more than one concrete type behind a given reference, start generic; switching to dyn Trait later is a smaller change than the reverse.

What breaks if you default to dyn Trait everywhere?

The most common mistake is reaching for Box<dyn Trait> out of habit and then hitting E0038: the trait cannot be made into an object — the compiler refusing to build a vtable for a trait that isn't object-safe (covered next). The fix is almost never "force it"; it's picking generics for that call site instead, or restructuring the trait.

The second mistake is subtler: assuming a dyn Trait reference is "basically just a pointer" and forgetting the doubling. A struct with several Box<dyn Trait> fields is measurably bigger than the same struct built around an enum of concrete variants, and that adds up across a large collection of such structs.

The third is a cache-locality problem, not a dispatch-cost one. Vec> scatters its elements across independent heap allocations — iterating it means chasing a different, unpredictable address on every step, on top of the vtable jump itself. Vec (or an enum, if the type set is closed) keeps its elements contiguous in memory, and that locality is usually worth more in a hot loop than avoiding one indirect call.

Object safety: why some traits can't become trait objects

Two patterns disqualify a trait from ever becoming dyn Trait, and both come down to the same problem: the compiler can't build a fixed-size vtable entry for them.

  1. A method that returns Self. Clone::clone(&self) -> Self needs the caller to know the concrete type's size to allocate the returned value — but behind a &dyn Trait, all the caller has is a data pointer and a vtable. That's exactly why Clone alone can't be a trait object; the standard workaround is a second, object-safe trait with a clone_box(&self) -> Box<dyn Trait> method that returns a boxed value instead of Self directly.
  2. A generic method. fn serialize(&self, out: &mut T) would need one vtable entry per type T the method is ever called with — an unbounded, open-ended set the compiler can't enumerate ahead of time, so it refuses to generate a vtable at all.

Both rules exist for the same reason: a vtable is a fixed-size table decided once at compile time, and anything whose shape depends on information only available at the call site can't fit in one.

Converting a generic function to dyn Trait, step by step

  1. Check object safety first. Does the trait have any method returning Self, or any generic method? If yes, you'll need a second trait (an object-safe subset) before dyn Trait will compile.
  2. Change the signature. fn draw_shape(shape: &T) becomes fn draw_shape(shape: &dyn Draw), or Box<dyn Draw> if the function needs to own the value.
  3. Update call sites. Concrete values now need an explicit &circle or Box::new(circle) where a bare value used to satisfy a generic bound directly.
  4. Watch the lifetime bound. Box<dyn Draw> implicitly requires dyn Draw + 'static unless you write out a shorter lifetime — a common compile error the first time you make this switch.
  5. Re-measure the hot path, don't assume. If this function runs in a loop that matters, benchmark before and after — the vtable jump itself is rarely the story; a scattered Vec> replacing a contiguous Vec usually is.

`rust
// Before: generic, monomorphized per concrete type
fn draw_shape(shape: &T) {
shape.draw();
}

// After: dyn Trait, one function body, one vtable jump per call
fn draw_shape(shape: &dyn Draw) {
shape.draw();
}

// Now the caller can hold a Vec of genuinely different shapes:
let shapes: Vec> = vec![Box::new(Circle), Box::new(Square)];
for shape in &shapes {
draw_shape(shape.as_ref());
}
`

FAQ

What is a fat pointer in Rust?

A fat pointer is a reference that carries two addresses instead of one. &dyn Trait is the most common example: one word points to the value's data, the other points to that type's vtable. A plain reference like &T is a thin pointer — a single 8-byte address — because the compiler already knows T's layout and methods at compile time and has nothing extra to attach.

Does Box<dyn Trait> cost more than &dyn Trait?

The pointer itself is the same 16 bytes in both cases — a Box is still a fat pointer when it points at a trait object. The difference is ownership: Box<dyn Trait> also heap-allocates and owns the underlying value, while &dyn Trait only borrows a value that lives somewhere else. Neither one makes the fat pointer thinner.

Why can't Clone be used as a trait object?

Clone::clone returns Self, and behind a &dyn Trait the caller only knows the vtable and a data pointer — it has no way to know how many bytes Self needs to allocate for the returned value. Rust's object-safety rule bans any method that returns Self for exactly this reason. The usual workaround is a second trait with a clone_box(&self) -> Box<dyn Trait> method, which returns a fixed-size, object-safe type instead of Self.

Does using generics instead of dyn Trait always make binaries bigger?

Only if you call the generic function with many different concrete types — monomorphization compiles one function body per type actually used, so ten call sites with ten types produce ten function bodies. A generic function called with one or two types costs about the same as a non-generic one. dyn Trait keeps exactly one function body no matter how many types implement the trait, which is the trade you're making in the other direction.

Is the vtable lookup in dyn Trait actually slow?

In isolation, one indirect call through a vtable is a handful of nanoseconds — rarely the bottleneck by itself. The cost that actually shows up in practice is indirect: a Vec> scatters its elements across separate heap allocations, so iterating it means chasing a different, unpredictable address on every step, which is what actually hurts cache behavior in a hot loop. A Vec keeps its elements contiguous and doesn't pay that price.

Can I mix dyn Trait and generics in the same codebase?

Yes, and most real Rust codebases do. The choice is made per call site, not per type — the same type can implement a trait and be used generically in one function while being boxed as a dyn Trait in another. Pick generics as the default for a single-type call path and reach for dyn Trait only at the specific boundary where you need one collection or return type to hold genuinely different concrete types.

Sources

If you're weighing this alongside other memory-layout decisions, cutting a Rust struct's footprint and Node.js's pointer-compression trade-off are the same kind of "make the cost explicit, then decide" exercise applied to different problems. And if the LSP you're running to catch these decisions is itself memory-hungry, Glancer on 8GB of RAM is worth a look.


Originally published at umesh-malik.com

Keep reading on umesh-malik.com:

Source: dev.to

arrow_back Back to Tutorials