JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

Generics, Monomorphization, and Zero-Cost Abstractions

Guide 2 showed you the two ways a trait can be called: one vtable for everyone, or a separate copy per type. This guide is about the second road — how generics get stamped out into concrete machine code at compile time, why that makes a high-level abstraction cost exactly nothing at runtime, and what you pay for it instead.

One function, written once, for every type

In the last guide you met two ways to call a trait method. A trait object like `&dyn Draw` carries a pointer to a vtable and looks the right method up at runtime — one piece of code that works for any type, paid for with an indirect call. This guide is about the other road, the one Rust takes by default. When you write a generic function such as `fn largest<T: Ord>(items: &[T]) -> &T`, you are not writing one function that handles many types at runtime. You are writing a single template that the compiler will use to manufacture a separate, concrete function for each type you actually call it with.

The `<T: Ord>` part is a trait bound, and it is the contract that makes this possible. It tells the compiler: T can be anything, but whatever it is, it must implement Ord, so I am allowed to compare two values of it. The body of `largest` is type-checked once, against that contract alone — not against any particular T. So the compiler can prove the function is correct before it has any idea whether you will call it on numbers or strings. That single check, done against the bound rather than against each concrete type, is what separates generics from copy-pasting the function by hand.

Monomorphization: stamping out the copies

Here is the central machine. When the compiler finishes type-checking and starts generating code, it walks your program and finds every concrete type a generic is actually used with. For each distinct one it makes a fresh copy of the function with T replaced by that real type, and compiles that copy as if you had written it out longhand. This process has a heavy name — monomorphization — but the idea is plain: turn one many-typed (poly-morphic) function into several single-typed (mono-morphic) ones. The word literally means "making into one form."

Make it concrete. Suppose you call `largest` once on a slice of i32 and once on a slice of char. After monomorphization the binary contains two functions, roughly `largest_i32` and `largest_char`, each compiled with the comparison and the dereferences specialized for exactly that type. There is no T left anywhere in the generated code, no runtime tag describing which type you have, no lookup. The sketch below shows what the compiler effectively produces from a single generic source.

// you write ONE generic:
fn largest<T: Ord>(items: &[T]) -> &T { /* compare with < */ }

// call sites:
largest(&[3i32, 7, 1]);     // uses T = i32
largest(&['c', 'a', 't']);  // uses T = char

// compiler stamps out TWO concrete functions (conceptually):
fn largest_i32 (items: &[i32])  -> &i32  { /* i32 compare,  inlined */ }
fn largest_char(items: &[char]) -> &char { /* char compare, inlined */ }
// no T, no vtable, no runtime dispatch anywhere
Monomorphization conceptually: one generic source becomes one concrete, fully specialized function per type it is called with.

Because each copy is just an ordinary function with concrete types, the optimizer gets to treat it like any other ordinary function. It can inline the comparison, fold constants, keep values in registers like rax instead of spilling them, and unroll loops. None of that is possible through a vtable pointer, because there the called code is not known until runtime. This is precisely the trade-off from guide 2 turned inside out: dynamic dispatch is one copy of code with a runtime lookup; static dispatch via monomorphization is many copies with no lookup and full optimization of each.

Zero-cost: what the slogan really claims

Rust borrows a slogan from C++: a zero-cost abstraction. People hear it and assume it means "free," which would be a lie, so let us state the real claim precisely, the way the zero-overhead principle does. It is two promises. First: you do not pay for what you do not use. Second, and the part that matters here: for what you do use, you could not reasonably write it better by hand. The generic `largest` compiled for i32 is the same machine code you would have produced if you had written `largest_i32` out yourself in plain, type-specific Rust. The abstraction — writing the algorithm once for all Ord types — vanished at compile time and left no runtime residue.

Be honest about where the cost actually went, because it did not vanish from the universe — it moved. The runtime got faster, and three other things paid for it. The compiler does more work, because it now compiles N copies instead of one. The binary gets larger, because those N copies all take up space — this is code bloat, and on a heavily generic program it is real and measurable. And compile times grow for the same reason. "Zero-cost" is a statement about runtime only. It is the right trade for a hot inner loop and a poor one for a rarely-called function instantiated over fifty types, which is exactly why Rust also keeps `dyn` around.

How the compiler actually decides what to stamp

It helps to walk the pipeline once, because it explains both the speed and the error-message style you will see. Monomorphization is not magic; it is a concrete pass that happens after the generic has already been checked. Here is the order of events for a single generic call.

  1. Check the generic once. The body of `largest<T: Ord>` is type-checked against the bound Ord alone. If it tries to do anything Ord does not promise, you get an error here, with no T pinned down yet.
  2. Collect the real types. The compiler scans every call site reachable in this build and records the concrete types each generic is instantiated with: here, i32 and char.
  3. Stamp a copy per distinct type. For i32 it produces a function with T=i32 substituted throughout; for char, another with T=char. Two source types, two functions.
  4. Optimize each copy independently. Now they are ordinary concrete functions, so the optimizer inlines the comparison, allocates registers, unrolls loops — full speed, per type.
  5. Deduplicate identical copies. If two distinct types produce byte-identical code (common for same-layout types), the linker can fold them back into one, clawing back a little of the bloat.

Two consequences fall out of this order, and both are worth carrying with you. First, only types you actually call with get stamped — an unused generic costs nothing in the binary because no concrete type ever reaches step three. Second, this is why a single generic can blow up your compile time without you writing much code: one short `<T>` function called over twenty types is twenty real functions to optimize. The static-versus-dynamic-dispatch choice is, at bottom, a choice about which of these two costs you would rather pay.

Where generics meet the rest of Rust

Monomorphization is not a corner feature; it is the engine under most of the standard library. `Vec<T>`, `Option<T>`, `HashMap<K, V>`, and the whole iterator machinery are generic, and each is stamped out fresh for the types you fill them with — a `Vec<u8>` and a `Vec<String>` are genuinely different concrete types in the compiled program, not one erased container. This is why a chain of iterator adapters like `.map(...).filter(...).sum()` compiles down to the same tight loop you would write by hand: every closure and adapter is a concrete type, monomorphized and inlined, with nothing left over. The high-level, almost functional style costs nothing at runtime precisely because of the machinery in this guide.

It also explains a pair of features you will keep meeting. A blanket implementation like `impl<T: Display> ToString for T` defines a method for every type that satisfies a bound at once; monomorphization is what then produces a concrete `to_string` for each type you actually use it on. And the design echoes C++ templates on purpose — both stamp out per-type code at compile time and both can bloat the binary — with the difference, from the note earlier, that Rust checks the generic against its bounds up front instead of only at instantiation. Same zero-cost runtime, friendlier compile-time story.

So you now hold the full picture from guide 2's fork. Reach for generics and static dispatch when the code is hot, the type is known at compile time, and you want the optimizer to see through the abstraction — accepting larger binaries and slower builds. Reach for `&dyn Trait` when you need a heterogeneous collection, want to keep the binary small, or genuinely do not know the type until runtime — accepting one indirect call per use. Neither is the 'advanced' choice; they are two honest trade-offs, and a mature Rust program uses both deliberately.