“Zero-cost abstraction” is the most repeated sentence in Rust marketing, and almost nobody explains the mechanism. It is not magic and it is not free. It is monomorphisation, and understanding it lets you predict which design decisions cost build time, why dyn exists as an escape valve, and why your CI takes eleven minutes.
What actually happens
When you write
fn largest<T: PartialOrd + Copy>(items: &[T]) -> T { ... }
rustc does not compile one function. It compiles nothing at all until it sees a call. Then, for every distinct T you actually call it with, it stamps out a separate copy with T replaced by the concrete type, type-checks it, optimises it, and emits machine code for it. Call it with i32, f64 and u8 and you get three functions in your binary, each specialised, each inlinable, each with no trace of the generic parameter left.
That is why a generic call is exactly as fast as a hand-written monomorphic one. There is no dictionary passed at run time (as in Haskell), no boxing (as in Java before value types), no interface table lookup (as in Go). The generic disappeared at compile time.
💡If generics disappear at compile time, why can rustc type-check a generic function *before* knowing which types it will be called with — reporting errors in a function nobody has instantiated? click to reveal
Because Rust separates two jobs that C++ templates fuse.
Rust type-checks the generic against its bounds, once, on the definition. Inside fn largest<T: PartialOrd + Copy>, the only operations allowed on a T are the ones PartialOrd and Copy provide. If you write items[0] + items[1], you get an error at the definition — “cannot add T to T“ — even if nobody ever calls it, because Add is not in the bounds.
C++ templates do the opposite: the body is checked only at instantiation, against the concrete type. That is why a one-character mistake in a C++ template produces four hundred lines of error naming types you have never heard of, and why C++20 added concepts — to buy back the property Rust had from the start.
The trade-off is real, though. Rust’s discipline means you must state every bound you use, which is more up-front annotation, and it makes some duck-typed patterns awkward. But the error messages point at the mistake instead of at the instantiation, and a library author cannot accidentally ship a generic function that only works for the types they happened to test.
Monomorphisation still happens after all this — type-checking once, codegen many times.
The cost, measured honestly
Twenty monomorphisations of a moderate generic function, compared against one dyn-based version of the same program:
| build | binary |
|---|---|
dyn version |
514 KB |
| 20 monomorphisations | 528 KB |
2.7%. That is the whole effect at toy scale, and it would be dishonest to show you the folklore number instead. A single-file program is dominated by the standard library; twenty extra copies of a small function are noise against it.
The effect is real at crate scale, and it compounds in ways the toy measurement cannot show:
- Generic code in a library is compiled in the downstream crate, once per instantiation, in every crate that uses it. It cannot be cached in the library’s own build.
- Deeply generic stacks multiply. A generic function calling a generic function calling a generic iterator adapter produces the product of the instantiations, not the sum.
- Every copy is optimised separately. LLVM time, not just rustc time, scales with the count.
This is why serde_json, diesel and the async ecosystem have a reputation for slow builds, and why “my project compiles slowly” is more often a generics problem than a dependency-count problem.
Learners conflate two different “slow”
The most common confusion in this area is thinking “generic” means “slow to run”. It is the exact opposite:
Generics are fast to run and slow to build.
dynis fast to build and (sometimes) slow to run.
Both statements are load-bearing. A person who only knows the first half boxes everything and wonders why their hot loop regressed. A person who only knows the second half genericises everything and wonders why CI takes eleven minutes.
Two details that surprise people:
-
impl Traitin argument position is still monomorphisation.fn draw(x: &impl Draw)is a generic parameter with a hidden name. It is notdynand it does not save you a single instantiation. -
Const generics multiply too.
fn f<const N: usize>()produces a separate copy per distinctN.Matrix<3>andMatrix<4>share no code at all.
The pattern worth stealing: outline the body
This is the single most useful practical takeaway in the whole topic, and it is almost never taught.
The ergonomic reason to make a function generic is usually the argument conversion, not the body. File::open takes impl AsRef<Path> so you can pass a &str, a String, a PathBuf or a &Path. But the body — a hundred lines of platform-specific syscall handling — does not care which you passed; it only ever sees a &Path.
So the standard library does this:
pub fn open<P: AsRef<Path>>(path: P) -> io::Result<File> {
// thin generic shell: converts, delegates, disappears when inlined
File::open_inner(path.as_ref())
}
fn open_inner(path: &Path) -> io::Result<File> {
// one copy, ever, no matter how many argument types callers use
...
}
Every instantiation of open is three instructions. The big body exists once. You get the caller ergonomics and pay the code-size cost of a single non-generic function.
💡You maintain a library with pub fn process<T: Into<Config>>(cfg: T) -> Report, whose body is 400 lines. Downstream crates call it with six different types. Apply the outlining pattern, and say precisely what improves and what does not.
click to reveal
The refactor:
pub fn process<T: Into<Config>>(cfg: T) -> Report {
process_inner(cfg.into())
}
fn process_inner(cfg: Config) -> Report {
// the 400 lines, exactly once
}
What improves. Before: six instantiations × 400 lines of MIR, monomorphised, optimised and codegen’d in each downstream crate that uses them. After: six instantiations of a one-line shell, plus one copy of the 400 lines compiled once, in your crate, cached in its .rlib and reused by every dependent. Downstream build time and binary size both drop.
What does not change. Run-time speed, in almost every case. The shell is trivially inlinable, so the call sequence at the machine level is identical. The API is byte-for-byte the same — this is a non-breaking, purely internal change.
What could regress. If the 400-line body genuinely benefited from knowing the concrete T — constant-folding a configuration field, eliminating a branch, unrolling a loop with a known bound — you have taken that away. In practice Into<Config> erases the source type at the boundary anyway, so there is nothing left to specialise on. That is exactly the signal that outlining is safe: if the body only ever touches the converted value, the generic parameter was never doing work inside it.
How to find candidates in your own code: cargo llvm-lines ranks functions by how many lines of LLVM IR their instantiations generate in total. The top of that list is almost always a fat generic that wants outlining.
Tools
-
cargo llvm-lines— attributes LLVM IR line counts to (generic function, instantiation) pairs. The best single tool for finding where monomorphisation is costing you. -
cargo bloat— attributes binary size to functions and crates. Different question, complementary answer. -
cargo build --timings— shows which crates dominate wall-clock, which tells you whether the problem is generics or dependency graph shape.
None of them exist in this harness, which compiles one file with rustc and no cargo. That is precisely why this item is an article: the thing it teaches is invisible at this scale, and pretending otherwise with a fake measurement would be worse than saying so.
The summary worth remembering
Monomorphisation is why Rust’s abstractions are free at run time and expensive at build time. dyn inverts both. Neither is the default answer; the outlining pattern lets you refuse the question in the common case where the generic was only ever about the arguments.