Part 5’s fn show(x: &impl Summary) was a casual sentence — “anything that signed the contract.” But underneath that sentence sits an entire grammar machine: whose sugar is it, exactly? Can you write it the other way? Why can’t the impl Iterator in return position be replaced with a concrete type? And when does dyn — the antagonist of this story — enter the stage? This part disassembles the whole machine: generics and trait bounds, monomorphization, the two directions of impl Trait, the static-versus-dynamic dispatch trade-off — and finally repays the debt from Part 2: what the 2024 edition’s + use<...> actually fixed.
Generics: write it once, the compiler photocopies it N times
It all starts with a duplication every programmer has written: largest_i32 and largest_str, bodies character-for-character identical, only the types differ. Generics promote “the type” into a parameter:
fn largest<T: PartialOrd>(list: &[T]) -> &T {
let mut max = &list[0];
for item in list {
if item > max { max = item; }
}
max
}
println!("{}", largest(&[3, 7, 2])); // 7
println!("{}", largest(&["a", "z", "m"])); // "z"
Read it aloud: “largest is defined for any type T, as long as T has signed PartialOrd.” The <T: PartialOrd> is a trait bound — Part 5’s contract, now part of the function signature. The body may only use abilities the bound promises: here, just >. Want to println! a T? Add + Display. A bound is both constraint and license, two faces of one document.
And the way Rust honors “zero-cost” is monomorphization: at build time the compiler photocopies the code for you — use i32 and it emits a largest::<i32>, use &str and it emits another. At runtime there’s no table lookup, no boxing, no “generic tax” of any kind; every call is a direct call to a concrete function. The only price: binary size grows with the number of instantiations. That’s why Rust dares to carpet the standard library with generics — the abstraction settles its entire bill at compile time.
Meet the turbofish while we’re here: when the compiler can’t infer a type, "42".parse::<u32>() lets you name it with ::<>. It’s deliberately ugly — so you can spot it instantly in code review.
The two directions of impl Trait: who chooses the concrete type
impl Trait appears in two positions that look symmetric and mean opposite things. The entire topic is one sentence: who chooses the concrete type.
Argument position — the caller chooses. fn show(x: &impl Display) is sugar for generics, character-for-character equivalent to fn show<T: Display>(x: &T). The caller passes i32 today, String tomorrow; the function accepts all comers, and monomorphization happens as usual. When to use the sugar versus full generics? Simple bound appearing once → sugar; when the type parameter needs a name (because it reappears in the return type or across parameters, like fn merge<T>(a: T, b: T) -> T) → angle brackets.
Return position — the function chooses. This is where impl Trait is irreplaceable:
fn evens_up_to(n: u32) -> impl Iterator<Item = u32> {
(0..=n).filter(|x| x % 2 == 0)
}
let v: Vec<u32> = evens_up_to(10).collect(); // [0, 2, 4, 6, 8, 10]
The type this function actually returns is Filter<RangeInclusive<u32>, closure> — and closure types have no name. You couldn’t write it if you wanted to, and you don’t need to. impl Iterator says: “I’m handing you something that signed the Iterator contract; who exactly it is, don’t ask.” That’s both necessity (closure types are unnameable) and design (the return type becomes an implementation detail — swap the internals later without breaking the API). Note that both positions are static dispatch — the concrete type is always known at compile time, just not always to you.
dyn: when “one type” won’t fit
Static dispatch has a hard boundary: one call site serves one concrete type. A &[impl Shape] can hold a thousand Circles but not “one Circle and one Rect” — and plugin systems, event-handler tables, and draw lists need exactly that kind of heterogeneous collection. Enter dyn:
trait Shape { fn area(&self) -> f64; }
fn total_static(shapes: &[impl Shape]) -> f64 { // one type per call
shapes.iter().map(|s| s.area()).sum()
}
fn total_dynamic(shapes: &[Box<dyn Shape>]) -> f64 { // mixed types, one collection
shapes.iter().map(|s| s.area()).sum()
}
A Box<dyn Shape> is a fat pointer: two pointers wide — one to the data, one to a vtable, a lookup table of “where each method of this concrete type lives.” The call goes from “direct, decided at compile time” to “one table lookup at runtime,” and all types share a single copy of the machine code. The bill flips accordingly: static dispatch pays binary size and earns zero runtime overhead; dyn pays one pointer jump per call and earns heterogeneous collections and smaller code. One more limit worth knowing: not every trait can become dyn (“object safety” — generic methods or returning Self disqualify it), and the compiler will tell you plainly.
Default to static. You’ll know when you need dyn: the moment a signature says “this slot must hold types I haven’t met yet,” that’s the moment.
The 2024 edition’s capture fix: + use<...>
Time to repay the debt. Part 2 mentioned that return-position impl Trait used to produce a whole category of confusing lifetime errors. The history goes like this —
2021 edition: a returned impl Trait captured only the lifetimes written out in the bound. fn f(s: &str) -> impl Iterator<Item = char> { s.chars() } was rejected, because the hidden type secretly borrowed s while the signature admitted nothing. The fix was the incantation + '_, acknowledging the borrow. Countless afternoons were lost here.
2024 edition: the rule inverted — return position now captures all input lifetimes by default. The code above just compiles. But the correction occasionally overcorrects:
fn process<'a>(data: &'a str, buf: &mut Vec<u8>) -> impl Iterator<Item = &'a str> {
buf.clear();
data.split(',')
}
let mut buf = vec![1u8, 2];
let it = process("a,b,c", &mut buf);
buf.push(3); // error[E0499]: cannot borrow `buf` as mutable more than once
buf was merely used by the function — the returned iterator holds nothing of it — but default capture counts buf’s borrow into the return type’s lifespan, freezing buf until the iterator dies. The compiler itself suggests the solution in the error, and that’s the use<> syntax: declare precisely which lifetimes may escape into the return value.
fn process<'a>(data: &'a str, buf: &mut Vec<u8>) -> impl Iterator<Item = &'a str> + use<'a> {
buf.clear();
data.split(',')
}
let it = process("a,b,c", &mut buf);
buf.push(3); // legal now — the iterator borrows only data
let parts: Vec<&str> = it.collect(); // ["a", "b", "c"]
use<'a> is a statement of honesty: “the return value owes only 'a.” Every other parameter is returned the moment it’s used. It’s the same philosophy as Part 3’s lifetime annotations — the source of validity must be explicitly traceable — except now even “who it does not trace to” can be written down.
Practice, then Part 8
- Write
fn sort_and_dedup<T: Ord>(v: &mut Vec<T>)and test it withi32andString. Then add aprintln!("{v:?}")— watch the compiler force you to change the bound toT: Ord + Debug, and feel the “a bound is a license” side of the document. - Rewrite
evens_up_toto return an iterator of all divisors ofn, with the constraint that noVecmay be used as an intermediate. You’ll be forced to stay inimpl Iteratorland — feel how everyday the unnameability of closure types really is. - Reproduce this part’s E0499: write
processwithoutuse<>, read the full error (note that the compiler itself suggests+ use<'a>), then add it. This is an error you’ll genuinely meet in the 2024 edition — one encounter in practice beats ten pages of documentation.
Part 8 turns from language features to engineering reality: Cargo and the module system — crates, mod and the real rules of visibility, how workspaces organize large projects, feature flags for conditional compilation, and the cargo commands you’ll use every workday that tutorials never dwell on. However beautiful the type system, it only counts when packed into a maintainable project.