Skip to content

Monomorphization & Dispatch

The Traits & Generics module introduced static dispatch (generics) and dynamic dispatch (dyn Trait) and their basic trade-off. This lesson proves the underlying mechanics directly — one compiled copy per type, a real vtable, and an actual assembly excerpt showing exactly what each strategy costs.

A generic function like fn identity<T>(x: T) -> T is not compiled once. For every distinct T it’s actually called with, the compiler generates a separate, fully concrete copy — this is monomorphization, and each copy is compiled and optimized completely independently, as if you had hand-written identity_i32 and identity_i64 yourself.

You can observe this directly: two monomorphized instantiations of the same generic function are genuinely different function items to the type system, not the same code wearing different data.

What the Compiler Says: Two Instantiations Are Two Different Types

Section titled “What the Compiler Says: Two Instantiations Are Two Different Types”
// @expect-error E0308
fn identity<T>(x: T) -> T {
x
}
fn main() {
let mixed = [identity::<i32>, identity::<i64>];
println!("{:?}", mixed);
}
error[E0308]: mismatched types
--> src/main.rs:8:36
|
8 | let mixed = [identity::<i32>, identity::<i64>];
| ^^^^^^^^^^^^^^^ expected `i32`, found `i64`
|
= note: expected fn item `fn(i32) -> i32 {identity::<i32>}`
found fn item `fn(i64) -> i64 {identity::<i64>}`
error: aborting due to 1 previous error

identity::<i32> and identity::<i64> aren’t the same value wrapped around different data — they’re two distinct types (fn item types, one per monomorphized instantiation), which is exactly what you’d expect if the compiler had generated two entirely separate functions, because it has. This is also a direct, verifiable proof that generics cost zero indirection: there is no single shared identity function being looked up at runtime for either call — each call site is compiled to invoke its own specific, concrete function.

You can also compare their actual addresses once coerced to a plain function pointer (fn(T) -> T, which erases which generic instantiation they came from, the same way dyn Trait erases a concrete type):

fn identity<T>(x: T) -> T {
x
}
fn main() {
let f_i32: fn(i32) -> i32 = identity::<i32>;
let f_f64: fn(f64) -> f64 = identity::<f64>;
println!("f_i32 as usize == f_f64 as usize? {}", f_i32 as usize == f_f64 as usize);
println!("f_i32(5) = {}", f_i32(5));
println!("f_f64(2.5) = {}", f_f64(2.5));
}
f_i32 as usize == f_f64 as usize? false
f_i32(5) = 5
f_f64(2.5) = 2.5

Two different instantiations of the exact same generic function land at two different addresses in the compiled binary — direct evidence they’re two separate copies of machine code, not one function branching on a type tag at runtime.

Code Size: The Cost Side of Monomorphization

Section titled “Code Size: The Cost Side of Monomorphization”

One copy per type is exactly what makes generic calls free of runtime dispatch cost — and exactly what makes them cost compiled code size. A generic function called with 20 different types produces 20 copies of its machine code in the final binary, even if most of those copies are nearly identical. This is invisible from the source (you wrote the function once) and easy to lose track of in a codebase with many small generic helpers each instantiated across many types — cargo bloat (a separate, widely used cargo subcommand) exists specifically to show which functions’ monomorphized copies are actually taking up space in a release binary, since the compiler itself doesn’t surface this by default.

dyn Trait takes the opposite trade: one shared function, resolved through a vtable lookup at runtime, instead of a separate compiled copy per type.

flowchart LR
  subgraph FatPointer["&dyn Speak (data ptr + vtable ptr)"]
      Data["data pointer"]
      VtablePtr["vtable pointer"]
  end
  Data --> Value["Dog value"]
  VtablePtr --> Vtable["Dog's Speak vtable"]
  Vtable --> Slot["speak fn pointer slot"]
  Slot -->|indirect call| DogSpeak["Dog::speak"]
A dyn Trait call: two loads (data, vtable) then one indirect call through the vtable slot

The Ownership & Borrowing module’s smart-pointers lesson and this module’s memory-layout lesson already measured &dyn Trait at 16 bytes (a fat pointer: data pointer + vtable pointer) versus 8 for &T. That extra pointer is exactly what a dyn Trait call has to load and jump through, and it’s directly visible in compiled assembly.

What the Compiler Actually Generates: A Real --emit=asm Comparison

Section titled “What the Compiler Actually Generates: A Real --emit=asm Comparison”

Compiling this program with optimizations (rustc -O --emit=asm) and looking at the generated AArch64 assembly for main shows the two dispatch strategies producing structurally different code, not just theoretically different code:

use std::hint::black_box;
trait Speak {
fn speak(&self) -> i32;
}
struct Dog;
impl Speak for Dog {
fn speak(&self) -> i32 { 1 }
}
struct Cat;
impl Speak for Cat {
fn speak(&self) -> i32 { 2 }
}
#[inline(never)]
fn call_generic<T: Speak>(x: &T) -> i32 {
x.speak()
}
#[inline(never)]
fn call_dyn(x: &dyn Speak) -> i32 {
x.speak()
}
fn main() {
let d = Dog;
let c = Cat;
println!("{}", call_generic(black_box(&d)));
let obj: &dyn Speak = if std::env::args().count() > 100 { &c } else { &d };
println!("{}", call_dyn(black_box(obj)));
}
1
1

The generated assembly for the monomorphized call_generic::<Dog> call in main contains no call instruction at all:

mov w8, #1
str w8, [sp, #32]

Even though call_generic is marked #[inline(never)] and its argument is wrapped in black_box (to stop the compiler from constant-folding based on the value it’s called with), the optimizer can still see straight through the monomorphized call: Dog::speak unconditionally returns 1, call_generic::<Dog> does nothing but call it, and because the compiler knows the concrete type at this call site, it can trace that chain all the way through and replace the entire call with the constant 1 at compile time.

The dyn call, by contrast, compiles to a genuine indirect call the compiler cannot see through, because it doesn’t know at compile time which concrete type’s speak will actually run:

ldr x1, [x8, #24]
bl __ZN4main8call_dyn17he0bc89168f5fae1cE

and call_dyn’s entire compiled body is exactly one instruction:

__ZN4main8call_dyn17he0bc89168f5fae1cE:
br x1

ldr x1, [x8, #24] is the vtable lookup — loading the speak function pointer out of the vtable at a fixed offset, into register x1. call_dyn’s whole job is then br x1: jump to whatever address happens to be sitting in that register. This is the entire, literal cost of dynamic dispatch: one memory load plus one indirect jump the optimizer has no way to resolve ahead of time — and, just as importantly, an opaque boundary that blocks the kind of cross-function constant-folding the monomorphized call above got for free.

#[inline] is a hint, not a directive — it asks the compiler to consider inlining a function at its call sites (mainly useful for small functions defined in one crate and called from another, where the compiler otherwise might not consider it), but the compiler is always free to ignore it. #[inline(always)] is a much stronger request, close to a directive, and #[inline(never)] is what this lesson used above to force a real, named call_dyn function to exist in the output rather than let the compiler fold it away — useful for exactly this kind of measurement, and occasionally for code-size-sensitive builds where a rarely-hot function shouldn’t be duplicated into every caller. Neither attribute changes what runs, only whether the compiler duplicates a call site’s target inline or keeps it as a separate, callable function.

Generics and dyn Trait aren’t the only way to run different code for different types — when the full set of concrete types is fixed and known, an enum with a match is a third option that sidesteps the trade-off entirely:

trait Speak {
fn speak(&self) -> i32;
}
struct Dog;
impl Speak for Dog {
fn speak(&self) -> i32 { 1 }
}
struct Cat;
impl Speak for Cat {
fn speak(&self) -> i32 { 2 }
}
enum Animal {
Dog(Dog),
Cat(Cat),
}
impl Animal {
fn speak(&self) -> i32 {
match self {
Animal::Dog(d) => d.speak(),
Animal::Cat(c) => c.speak(),
}
}
}
fn main() {
let animals = vec![Animal::Dog(Dog), Animal::Cat(Cat)];
for a in &animals {
println!("{}", a.speak());
}
}
1
2

Animal is one concrete type (no monomorphization needed, no code duplication), and match compiles to a direct jump based on the discriminant — no vtable, no indirect call through a pointer the compiler can’t see through. The cost is that adding a new kind of Speak-implementing type means adding a new Animal variant and updating every match on it (which the compiler will point out for you, since match is exhaustive) — the same closed-set-vs-open-set trade-off the Traits & Generics module covered when choosing between Box<dyn Trait>, an enum, and a generic.

impl Trait in Return Position Is Static Dispatch

Section titled “impl Trait in Return Position Is Static Dispatch”

fn make_speaker() -> impl Speak looks similar to -> Box<dyn Speak> at a glance, but it compiles completely differently: impl Trait in return position means “some specific, single concrete type, decided by this function’s own body, that the caller doesn’t need to name” — the compiler knows the exact concrete type at every call site, exactly like a generic parameter, so there’s no vtable and no dynamic dispatch involved at all.

trait Speak {
fn speak(&self) -> i32;
}
struct Dog;
impl Speak for Dog {
fn speak(&self) -> i32 { 1 }
}
fn make_speaker() -> impl Speak {
Dog // concrete type baked into the return type at compile time -- this is static dispatch
}
fn main() {
println!("{}", make_speaker().speak());
}
1

This is why impl Trait in return position cannot return different concrete types from different branches (an if/else returning Dog in one arm and Cat in the other is a compile error) — the whole point is that the compiler resolves one single concrete type for the function, at compile time, the same way it resolves a generic parameter.

use std::hint::black_box;
trait Speak {
fn speak(&self) -> i32;
}
struct Dog;
impl Speak for Dog {
fn speak(&self) -> i32 { 1 }
}
struct Cat;
impl Speak for Cat {
fn speak(&self) -> i32 { 2 }
}
fn call_generic<T: Speak>(x: &T) -> i32 {
x.speak()
}
fn call_dyn(x: &dyn Speak) -> i32 {
x.speak()
}
enum Animal {
Dog(Dog),
Cat(Cat),
}
impl Animal {
fn speak(&self) -> i32 {
match self {
Animal::Dog(d) => d.speak(),
Animal::Cat(c) => c.speak(),
}
}
}
fn main() {
let d = Dog;
let c = Cat;
println!("static dispatch (generic): {}", call_generic(black_box(&d)));
println!("dynamic dispatch (dyn): {}", call_dyn(black_box(&c as &dyn Speak)));
let animals = vec![Animal::Dog(Dog), Animal::Cat(Cat)];
for a in &animals {
println!("enum dispatch: {}", a.speak());
}
println!("&dyn Speak is {} bytes, &Dog is {} bytes",
std::mem::size_of::<&dyn Speak>(), std::mem::size_of::<&Dog>());
}

Think of a generic function as a recipe the compiler hand-copies and specializes, once per ingredient list it’s ever asked to cook with — by the time it’s in the oven (compiled), there’s no “recipe lookup” left at all, just that one specific dish’s exact instructions, which is why the optimizer can simplify it as aggressively as if you’d written that exact dish by hand. A dyn Trait call is the opposite: one single kitchen (one compiled function) that takes instructions from a card (the vtable) handed to it at the door, and looks at that card fresh, every single time, precisely because it has no way of knowing ahead of time which dish it will be asked to make next.

Monomorphization is transparent to every later optimization pass — inlining, constant folding, the cross-function analysis this lesson’s assembly excerpt showed directly — at the cost of one compiled copy of code per concrete type, which can measurably grow binary size across a large generic-heavy codebase. dyn Trait caps code size at one shared function regardless of how many types implement the trait, and is the only option that lets genuinely different concrete types live together in one collection, at the cost of one runtime indirection per call and a hard optimization boundary the compiler cannot see through. Enum dispatch avoids both costs when the full set of types is fixed and known, at the cost of needing to update the enum (and its matches) whenever a new type is added.

  • “#[inline(never)] makes a function never appear as a separate copy in the binary.” It stops the compiler from copying that function’s code into its callers, forcing it to remain one distinct, callable function in the output — which is why this lesson used it to force call_dyn to appear as an inspectable symbol rather than being folded away.
  • “Zero-cost abstraction means literally no cost anywhere in the system.” For generics, it means no runtime dispatch cost and code indistinguishable from hand-specialized code — the cost that does exist (compiled code size, one copy per type) shows up in the binary, not at the call site’s runtime behavior.
  • “dyn Trait is always meaningfully slower in practice.” The measured cost is one memory load and one indirect jump per call — real, and non-zero, but frequently negligible next to whatever the method actually does; the more consequential loss is usually the blocked cross-function optimization (inlining, constant folding) this lesson’s assembly comparison showed directly, which matters most for very small, very hot methods.
  • “impl Trait in return position works like dyn Trait — the caller gets a value that could be any implementor.” It’s the opposite: impl Trait in return position always resolves to one single concrete type per function, decided entirely by that function’s own body, with zero dynamic dispatch involved — the compiler simply doesn’t require the caller to write the concrete type’s name out.

Generated code sometimes reaches for Box<dyn Trait> as a default whenever a function needs to “return something that implements a trait,” even when the function only ever returns one single concrete type — throwing away the compiler’s ability to devirtualize, inline, and constant-fold for no actual flexibility gained.

🐛 Spot the bug

trait Shape {
fn area(&self) -> f64;
}
struct Square {
side: f64,
}
impl Shape for Square {
fn area(&self) -> f64 {
self.side * self.side
}
}
// "Box<dyn Shape> so callers can treat the return value generically" -- AI-added comment
fn make_unit_square() -> Box<dyn Shape> {
Box::new(Square { side: 1.0 })
}
fn main() {
let s = make_unit_square();
println!("{}", s.area());
}
Show the answer

This compiles and runs correctly, printing 1, and it’s easy to see why it survives review: nothing about it is wrong, exactly. But make_unit_square only ever constructs a Square — there is no branch, no configuration, no case where it could return any other concrete type. Box<dyn Shape> pays for a heap allocation and a vtable indirection on every call to .area(), in exchange for a flexibility (“callers can treat the return value generically”) that was never actually exercised: every caller receives the exact same concrete type every time. fn make_unit_square() -> impl Shape returns the identical value with the identical calling code at every call site, but with zero dynamic dispatch, zero heap allocation, and the return type fully known and inlinable at every call site. Box<dyn Shape> earns its cost the moment a second concrete Shape might come back from a different branch or configuration — until then, it’s paying for a capability that doesn’t exist yet.

Why does this fail to compile?
fn identity<T>(x: T) -> T { x }
fn main() {
    let mixed = [identity::<i32>, identity::<i64>];
}
What does comparing identity::<i32> as usize == identity::<f64> as usize (after coercing both to plain fn pointers) prove?
In the --emit=asm comparison, why does the monomorphized call_generic::<Dog> call disappear entirely from main's compiled code (replaced by mov w8, #1), even with #[inline(never)] and a black_box'd argument?
What does call_dyn's compiled body (br x1, a single instruction) preceded by ldr x1, [x8, #24] in main represent?
When is an enum with a match a better choice than either generics or dyn Trait for running type-specific code?
Why can't fn make() -> impl Shape return a Square from one branch and a Circle from another, while fn make() -> Box<dyn Shape> can?
A function only ever constructs and returns one concrete type wrapped in Box<dyn Trait>, with no branch or configuration that could produce a different type. What is the better alternative, and why?