Generics and monomorphization — zero-cost polymorphism
Lesson 11 made "this type can do what the code needs" a trait, a contract checked at both ends. But a contract is not machine code: an i32, an f64 and a String compare with different instructions, so one generic function must become several machine functions. The compiler checks the generic body once, against its bounds alone, then stamps out a specialized copy for every concrete type the program uses. That adds no mechanism at run time; the bill, code size and compile time, is measured here, not asserted.
New idea: a generic definition is type-checked once, against its bounds only, then monomorphized: from
main, the compiler follows every call and stamps out one specialized copy per concrete type — no mechanism at run time, paid for in code size and compile time.Forces next: Generics fix each type at compile time: fast, but one concrete type per instantiation. When the type is chosen at run time — plugins, mixed collections, a list of different shapes — what is the alternative, and what is its price?
1 · Machine code cannot be generic
Lesson 11 used one largest for slices of i32, f64 or anything comparable, without asking what runs. A type parameter says so: fn largest<T: PartialOrd + Copy> declares a placeholder type T, and the bound after the colon says what T must do — compare (Lesson 11's PartialOrd) and duplicate bit for bit (Lesson 04's Copy, since the body copies elements into best).
fn largest<T: PartialOrd + Copy>(items: &[T]) -> T {
let mut best = items[0];
for &x in items {
if x > best { best = x; } // one source line, three machine behaviors
}
best
}
fn main() {
println!("{}", largest(&[3, 9, 4])); // T = i32
println!("{}", largest(&[2.5, -1.0, 7.25])); // T = f64
println!("{}", largest(&['q', 'z', 'a'])); // T = char
}
9 7.25 z
The machine has no comparison that fits all three. A two-line gt, compiled at full optimization for three types:
#[inline(never)]
pub fn gt<T: PartialOrd>(a: &T, b: &T) -> bool { a > b }
pub fn gt_i32(a: &i32, b: &i32) -> bool { gt(a, b) }
pub fn gt_f64(a: &f64, b: &f64) -> bool { gt(a, b) }
pub fn gt_string(a: &String, b: &String) -> bool { gt(a, b) }
gt::<i32> gt::<f64> gt::<String> (18 instructions)
ldr w8, [x0] ldr d0, [x0] ldp x8, x10, [x0, #8]
ldr w9, [x1] ldr d1, [x1] ldp x9, x11, [x1, #8]
cmp w8, w9 fcmp d0, d1 ...
cset w0, gt cset w0, gt bl _memcmp ; bytes first,
ret ret ... ; then lengths
Integers use cmp, floats fcmp, and a String calls memcmp and breaks ties on length. Something must connect one source function to several machine behaviors; there are three candidates:
| Design | What the program contains | Its price |
|---|---|---|
| (a) Erase the type | one body; every value behind a pointer, every > an indirect call | a pointer (often a heap box) per value, an indirect call per operation |
| (b) Pass a dictionary | one body per memory layout, plus a hidden table of the type's operations | an extra argument, an indirect call per operation |
| (c) Copy per type | one specialized copy for each concrete type | no mechanism at run time; code size and compile time |
So Rust's default is (c), monomorphization: specialized code per concrete type, which the Book says performs like duplicates written by hand (§5 tests that). The copy is of code — rustc calls it an instance — not Lesson 04's Copy of a value. Copying a body per type raises a question copying cannot answer: what may the body do with T?
2 · Checked once, at the definition
A C++ template answers by not asking (C++20 concepts let you ask): it is checked when instantiated, duck typing at compile time in the C++ track's words. largest with no stated requirement compiles, and the error appears when someone instantiates it with a type lacking >, reported from inside the template:
template <typename T>
T largest(const std::vector<T>& v) { // no stated requirement on T
T best = v[0];
for (const T& x : v) if (x > best) best = x; // checked per instantiation
return best;
}
struct Point { int x, y; }; // no operator>
std::vector<Point> pts{{1, 2}};
auto p = largest(pts); // the error points INTO largest's body
Nothing undefined happens, but the contract between a generic library and its users becomes the body: the library passes its tests while users whose type lacks > fail to build in code they did not write, and a later version can add one innocent x.clone() and break every caller whose type is not Clone without touching the signature. The cure is Lesson 08's: the signature is the whole contract, and each side is checked against it.
So Rust type-checks a generic body once, at its definition, against its bounds only, for every type that could meet them. Without a bound the body is rejected before any caller exists:
fn largest<T>(items: &[T]) -> &T {
let mut best = &items[0];
for x in items {
if x > best { best = x; } // what does T promise about `>`?
}
best
}
error[E0369]: binary operation `>` cannot be applied to type `&T`
help: consider restricting type parameter `T` with trait `PartialOrd`
The caller is checked against the same contract. §1's largest demands Copy, which String lacks, so the call is rejected — at the call, naming the bound:
fn largest<T: PartialOrd + Copy>(items: &[T]) -> T {
let mut best = items[0];
for &x in items { if x > best { best = x; } }
best
}
fn main() {
let names = [String::from("ada"), String::from("grace")];
println!("{}", largest(&names)); // the trait bound `String: Copy` is not satisfied
}
The fix is a different contract: return a reference to the winner, and with nothing copied, Copy goes and String qualifies.
fn largest<T: PartialOrd>(items: &[T]) -> &T { // needs comparison, nothing else
let mut best = &items[0];
for x in items { if x > best { best = x; } }
best
}
fn main() {
let names = [String::from("ada"), String::from("grace")];
println!("{}", largest(&names));
}
grace
So errors land where the mistake is: in the body for its author, at the call for the caller. The signature answers Lesson 00's third question — what does the signature promise? — completely, with the modularity Lesson 08 bought for lifetimes. And a library can neither be broken by a type that "almost" works nor silently demand more: a new requirement is a new, visible bound. The cost is cognitive: bounds must be written, and they grow (§6).
String and &str both have, but the bound does not promise:
use std::fmt::Display;
fn label_width<T: Display>(label: &T) -> usize {
label.len() // String has len(), &str has len()... T does not
}
fn main() {
println!("{}", label_width(&String::from("disk")));
}
error[E0599]: no method named `len` found for reference `&T` in the current scope
= help: items from traits can only be used if the type parameter is bounded by the trait
help: the following trait defines an item `len`, perhaps you need to restrict type parameter `T` with it:
The message names the missing capability and the rule: a trait's items are usable only through a bound. It cannot know your intent — a bound, a different algorithm, a concrete type — so it proposes some trait with a len: ExactSizeIterator. That makes the body type-check and nothing more; taken literally, it moves the failure to every caller:
use std::fmt::Display;
fn label_width<T: Display + ExactSizeIterator>(label: &T) -> usize { label.len() }
fn main() { println!("{}", label_width(&String::from("disk"))); } // String is no iterator
Fixes that match the intent use only what the bound promises (label.to_string().len()) or ask for the capability meant (T: AsRef<str>, then label.as_ref().len()).Once every body is checked, generating code needs no further judgment — only bookkeeping.
3 · The mechanism: a worklist
The bookkeeping is a worklist. main is not generic, so its calls name concrete types, and each is a request for an instance, written pair::<f64, i32>. An existing copy is reused; otherwise the compiler stamps one out, substituting the types for the parameters throughout the body — so the body's own calls become new requests. When nothing new is requested, the list is final. The rustc developer guide calls this pass the collector; monomorphization, the backend's first step, starts with it.
std::any::type_name::<T>() returns a type's name, so each body can print which copy is running:
use std::any::type_name;
use std::fmt::Debug;
fn show<T: Debug>(x: &T) -> String {
println!("show::<{}>", type_name::<T>()); // which copy is running?
format!("{x:?}")
}
fn pair<A: Debug, B: Debug>(a: &A, b: &B) -> String {
println!("pair::<{}, {}>", type_name::<A>(), type_name::<B>());
format!("({}, {})", show(a), show(b)) // requests show::<A>, show::<B>
}
fn main() {
pair(&1, &3);
pair(&2.5, &3);
pair(&'q', &3);
}
pair::<i32, i32> show::<i32> show::<i32> pair::<f64, i32> show::<f64> show::<i32> pair::<char, i32> show::<char> show::<i32>
Nine calls, six copies. In the copy pair::<f64, i32> the line show(a) is a call to show::<f64>: A was replaced first. show::<i32>, requested by all three pairs, is stamped out once. Recursion at the same T is harmless — it requests the copy it is already in. The worklist fails when each new copy requests one at a type never seen before:
use std::fmt::Debug;
fn nest<T: Debug>(x: T, depth: u32) -> String {
if depth == 0 { return format!("{x:?}"); }
nest(vec![x], depth - 1) // requests nest::<Vec<T>>
}
fn main() {
println!("{}", nest(7, 3));
}
error: reached the recursion limit while instantiating `nest::<Vec<Vec<Vec<Vec<Vec<Vec<Vec<Vec<Vec<Vec<Vec<...>>>>>>>>>>>>`
note: `nest` defined here
At run time depth stops after three steps, but the collector does not run the program: it must produce every copy the program could call, and there is no last one. rustc gives up when a function recurs on its own chain of requests beyond the crate's recursion limit. The full type name, which rustc saves to a file, nests 129 Vecs; #![recursion_limit = "16"] makes it 17, and "128" leaves it at 129 (observed), so the default is 128. The error has no code, and cargo check cannot see it: checking stops before code generation.
$ cargo check
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.06s
$ cargo build
error: reached the recursion limit while instantiating `nest::<Vec<Vec<Vec<Vec<…
That is the whole mechanism — substitute, request, reuse or stamp out — small enough to run yourself.
4 · Run the collector
The widget's programs use these functions and §3's nest; each main calls one of them with the first k of i32, f64, char, String, u8, &str:
use std::fmt::Debug;
fn largest<T: PartialOrd>(items: &[T]) -> &T {
let mut best = &items[0];
for x in items { if x > best { best = x; } }
best
}
fn show<T: Debug>(x: &T) -> String { format!("{x:?}") }
fn summary<T: PartialOrd + Debug>(items: &[T]) -> String {
format!("max = {}", show(largest(items))) // largest::<T>, show::<T>
}
fn pair<A: Debug, B: Debug>(a: &A, b: &B) -> String {
format!("({}, {})", show(a), show(b)) // show::<A>, show::<B>
}
fn wrap<T: Clone>(x: &T) -> Vec<T> { vec![x.clone(), x.clone()] }
fn nested<T: Clone + Debug>(x: &T) -> String {
show(&wrap(&wrap(x))) // wrap::<T>, wrap::<Vec<T>>, show::<Vec<Vec<T>>>
}
fn count<T>(items: &[T]) -> usize {
if items.is_empty() { 0 } else { 1 + count(&items[1..]) } // count::<T> again
}
What to try. "Pair shares a helper" at 3 types is §3's program: nine requests, six copies, three reuses — the nine lines and six names it printed; step back to 0 and forward to watch the dashed reuses of show::<i32>. "Summary calls two helpers" makes three copies per call from main — 3 at one type, 18 at six — and never reuses. "Types grow inside a body" at 3 types stamps out 12 copies, among them show::<Vec<Vec<char>>>, a type main never wrote, in 11 bodies: wrap::<Vec<i32>> and wrap::<Vec<char>> share one address (§5). "Recursion at the same type" at 6 types makes 12 requests for 6 copies; "recursion at a growing type" makes 130 and hits the limit at any setting. The last two cards multiply the copies by per-copy costs measured for this lesson; §5 shows them.
5 · The bill, in four currencies
Run time. A copy is an ordinary function: gt::<i32> is five instructions, with no table or hidden argument. To price the indirect call that roads (a) and (b) both put in every operation, erase the one thing the body needs — the comparison — behind a function pointer (an address called through), and time four versions of §1's loop over a million i32s:
use std::{hint::black_box, time::Instant};
#[inline(never)] fn largest<T: PartialOrd + Copy>(xs: &[T]) -> T { // §1, as written
let mut best = xs[0]; for &x in xs { if x > best { best = x; } } best }
#[inline(never)] fn largest_i32(xs: &[i32]) -> i32 { // same loop, by hand
let mut best = xs[0]; for &x in xs { if x > best { best = x; } } best }
#[inline(never)] fn largest_sel<T: PartialOrd + Copy>(xs: &[T]) -> T { // generic, as a select
let mut best = xs[0]; for &x in xs { best = if x > best { x } else { best }; } best }
#[inline(never)] fn largest_via(xs: &[i32], gt: fn(&i32, &i32) -> bool) -> i32 { // comparison erased
let mut best = xs[0]; for x in xs { if gt(x, &best) { best = *x; } } best }
fn gt_i32(a: &i32, b: &i32) -> bool { a > b }
fn main() {
let v: Vec<i32> = (0..1_000_000i64).map(|i| (i * 7919 % 1_000_003) as i32).collect();
let gt = black_box(gt_i32 as fn(&i32, &i32) -> bool);
let mut ns = [const { Vec::new() }; 4];
for _ in 0..101 { for w in 0..4 { // 101 interleaved rounds
let t = Instant::now();
black_box(match w { 0 => largest(black_box(&v)), 1 => largest_i32(black_box(&v)),
2 => largest_sel(black_box(&v)), _ => largest_via(black_box(&v), gt) });
ns[w].push(t.elapsed().as_nanos());
} }
for (name, t) in ["generic", "by hand", "select", "erased"].iter().zip(ns.iter_mut()) {
t.sort(); println!("{name:8} {:.3} ns per element", t[50] as f64 / 1e6);
}
}
generic 0.236 ns per element 0.233 – 0.257
by hand 0.033 ns per element 0.033 – 0.036
select 0.033 ns per element 0.033 – 0.036
erased 0.685 ns per element 0.675 – 0.746
On this machine, for this loop, the erased comparison costs about twenty times the hand-written loop and three times the generic one: an indirect call per element, in a loop that can no longer be specialized. That is the indirect-call part of the bill for roads (a) and (b). The generic copy's own number is the reason to measure:
largest_i32 is 68 instructions built on the vector instruction smax.4s, four lanes at a time; largest::<i32> is a 19-instruction scalar loop. Written as a select, the generic body's i32 copy is instruction-for-instruction the hand-written loop (diffed). Zero-cost is a statement about mechanism — nothing runs because the code was generic — not a promise that the optimizer treats two spellings of a loop alike. For a hot loop, measure.Code size. One copy per instance, each as big as its type makes it — the widget's "machine code":
copy of i32 f64 char String u8 &str
largest<T> 80 80 80 136 80 144
wrap<T> 84 84 84 228 88 88
wrap<Vec<T>> 320* 320 320* 228 308 320
nested<T> 236 236 236 368 224 236
summary<T> 136, pair<T, i32> 212, show<T> and show<Vec<Vec<T>>> 60, count<T> 8*: the same for every T
method: one crate with every copy the widget can produce, generic fns #[inline(never)];
size = distance to the next symbol in `nm -n -C`. * i32 and char copies share one address
This undercuts "monomorphization always bloats". A String copy is bigger because comparing strings is more work; every count compiled to mov x0, x1; ret, the recursion turned into "return the length"; identical copies are sometimes kept once (*), not always: the other four counts sit at four addresses. Now multiply the copies: 128 of largest over types whose code differs ([u8; 1] … [u8; 128]) and over types whose code is identical (128 structs wrapping a u32):
import re, statistics, subprocess, time
def crate(n, same): # n copies of largest: over K0..Kn-1 (identical code) or [u8; 1]..[u8; n] (all different)
s = ["#[inline(never)] pub fn largest<T: PartialOrd>(xs: &[T]) -> &T {",
" let mut best = &xs[0]; for x in xs { if x > best { best = x; } } best }"]
for i in range(n):
t = f"K{i}" if same else f"[u8; {i + 1}]"
if same: s.append(f"#[derive(PartialEq, PartialOrd)] pub struct K{i}(pub u32);")
s.append(f"#[inline(never)] pub fn r{i}(x: &[{t}]) -> &{t} {{ largest(x) }}")
open("c.rs", "w").write("\n".join(s) + "\n")
def measure(opt): # median of 21 compile wall times; bytes of machine code in the object file
cmd = f"rustc --edition 2024 --crate-type lib -C opt-level={opt} -C codegen-units=1 --emit=obj -o c.o c.rs".split()
ms = []
for _ in range(21):
t = time.perf_counter(); subprocess.run(cmd, check=True); ms.append(1000 * (time.perf_counter() - t))
size = subprocess.run(["size", "-m", "c.o"], capture_output=True, text=True).stdout
return statistics.median(ms), int(re.search(r"__text\): (\d+)", size).group(1))
for same in (False, True):
for n in (0, 32, 128):
crate(n, same); (t3, b3), (t0, b0) = measure(3), measure(0)
print(f"{'identical' if same else 'different'} {n:4d} -O3 {t3:6.1f} ms {b3:6d} B -O0 {t0:6.1f} ms {b0:6d} B")
different 0 -O3 23.9 ms 0 B -O0 22.6 ms 0 B
different 32 -O3 59.0 ms 5192 B -O0 36.4 ms 14372 B
different 128 -O3 148.7 ms 21144 B -O0 68.7 ms 56980 B
identical 0 -O3 23.7 ms 0 B -O0 22.6 ms 0 B
identical 32 -O3 54.2 ms 84 B -O0 43.7 ms 16032 B
identical 128 -O3 128.6 ms 84 B -O0 93.8 ms 64032 B
At -O3 the 128 different copies take 21,144 bytes, about 165 each; the 128 identical ones take 84 bytes in all: one 80-byte body under 128 names, and one 4-byte wrapper under 128 more. At -O0 nothing merges: 64,032 bytes, 500 per copy.
Compile time is paid per copy, identical or not: at -O3, (148.7 − 23.9) / 128 = 0.975 ms per different copy, (128.6 − 23.7) / 128 = 0.82 ms per identical one. The widget multiplies its copies by 0.975 ms: one machine, one small function, an estimate. The rustc developer guide names the same trade: fast programs for compile time and binary size.
Cognitive load. Bounds are the contract, so they must be written, read and kept honest. When bytes matter, §3 gives the remedy: only generic code is copied, so keep it thin. If load_config<P: AsRef<Path>>(path: P) only calls path.as_ref() and passes the &Path to a non-generic inner function, each copy is a call and the real body is compiled once. Where speed does not matter, leave road (c) on purpose: Lesson 13. What remains is the notation that writes the contract down.
6 · The syntax you now have a reason for
Bounds, where, generic types, const generics. T: A + B requires both traits; a where clause moves crowded bounds after the signature and can bound non-parameters (T::Item: Copy). An impl block can demand more of a struct's parameters than the struct does, so its methods exist only where the extra bounds hold. A parameter can be a value — a const generic like const N: usize (stable since Rust 1.51; integers, char, bool) — so Ring<u8, 3> and Ring<u8, 4> are two types, each with its own copies:
use std::fmt::Debug;
struct Ring<T, const N: usize> { // a type parameter and a const parameter
slots: [Option<T>; N],
next: usize,
}
impl<T: Copy, const N: usize> Ring<T, N> { // these methods exist only for T: Copy
fn new() -> Self { Ring { slots: [None; N], next: 0 } }
fn push(&mut self, x: T) { self.slots[self.next % N] = Some(x); self.next += 1; }
}
fn report<T, const N: usize>(r: &Ring<T, N>) -> String
where
T: Debug, // the bounds, out of the signature line
{
format!("{:?} after {} pushes", r.slots, r.next)
}
fn main() {
let mut r: Ring<u8, 3> = Ring::new();
for x in 1..=4 { r.push(x); }
println!("{}", report(&r));
println!("{} bytes", std::mem::size_of::<Ring<u8, 3>>());
}
[Some(4), Some(2), Some(3)] after 4 pushes 16 bytes
impl Trait in argument position is a type parameter without a name: fn show(x: impl Debug) means fn show<T: Debug>(x: T), monomorphized the same way; lacking a name, it cannot be given with a turbofish, and switching a published function between the spellings can break callers. In return position the callee picks one hidden concrete type and the caller sees only its bounds — still one type, fixed at compile time, so two return paths with different types are rejected, and the suggestion points at Lesson 13:
fn countdown(n: u32, up: bool) -> impl Iterator<Item = u32> {
if up { 0..n } else { (0..n).rev() } // two different concrete types
}
fn main() { for i in countdown(3, false) { println!("{i}"); } }
error[E0308]: `if` and `else` have incompatible types
help: you could change the return type to be a boxed trait object
In edition 2024 a returned impl Trait is assumed to capture every generic parameter in scope, lifetimes included (edition 2021 captured a lifetime only when the bounds named it). So this signature claims the iterator may hold the borrow of v — its region in Lesson 06's model, its lifetime in the source — and Lesson 05's law forbids writing v while the iterator lives, though the range borrows nothing:
fn indices(v: &[i32]) -> impl Iterator<Item = usize> {
0..v.len() // the range borrows nothing from v
}
fn main() {
let mut v = vec![1, 2, 3];
for i in indices(&v) {
v[i] *= 2; // but the signature says it might
}
}
note: this call may capture more lifetimes than intended, because Rust 2024 has adjusted the `impl Trait` lifetime capture rules
help: use the precise capturing `use<...>` syntax to make the captures explicit
A use<..> bound (stable since Rust 1.82) lists what the hidden type may capture; use<> says "nothing", the compiler holds the body to it, and the caller is free:
fn indices(v: &[i32]) -> impl Iterator<Item = usize> + use<> {
0..v.len() // use<>: the result captures no lifetime
}
fn main() {
let mut v = vec![1, 2, 3];
for i in indices(&v) { v[i] *= 2; }
println!("{v:?}");
}
[2, 4, 6]
The turbofish, ::<…>, supplies type arguments by hand. It is needed when neither signature nor program fixes a parameter — the result of parse or collect, the element type of an empty Vec — and inference stops with one of three errors, one per function:
fn roster() { let names = Vec::new(); } // a Vec of what?
fn config() { let port = "8080".parse().unwrap(); } // parsed into what?
fn filter() { let evens = (0..10).collect(); } // collected into what?
fn main() {
let names: Vec<String> = Vec::new(); // say it on the binding...
let port = "8080".parse::<u16>().unwrap(); // ...or with the turbofish
let evens = (0..10).collect::<Vec<_>>(); // `_`: infer the part it can
println!("{} {port} {evens:?}", names.len());
}
0 8080 [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
T: Sized: a copy must know how big a T is. That excludes str, [T] and dyn Trait, whose size is decided at run time (the fat pointer carries a length or a vtable address, Lesson 02), even behind a reference:
use std::fmt::Display;
fn banner<T: Display>(text: &T) -> String { // T: Sized is implied
format!("== {text} ==")
}
fn main() {
let name: &str = "cache";
println!("{}", banner(name)); // T would be `str`: no size known
}
error[E0277]: the size for values of type `str` cannot be known at compilation time
note: required by an implicit `Sized` bound in `banner`
help: consider relaxing the implicit `Sized` restriction
The body only holds a &T, whose size is known, so T: Display + ?Sized ("need not be Sized") is the right contract:
use std::fmt::Display;
fn banner<T: Display + ?Sized>(text: &T) -> String { format!("== {text} ==") }
fn main() { println!("{}\n{}", banner("cache"), banner(&42)); } // T = str, then T = i32
== cache == == 42 ==
All of this says one thing: each concrete type is settled at compile time — by the caller, the callee, or you — and gets its own copy. The E0308 hint shows what that leaves out.
Common mistakes / failure modes
len() without a bound that provides it is E0599 — and exactly what every caller must meet: String against Copy is E0277 (§2).impl Trait in argument position is dynamic"dyn, Lesson 13.count), and 128 identical copies measured 84 bytes at -O3 (§5). What it always costs is compile time: an identical copy still took 0.82 ms.T: 'static means the value lives forever"T holds no borrow of anything that ends, so an owned String qualifies (Lesson 08). On a generic it is one more bound the caller must meet.parse and collect are generic in their result, so without it (or an annotation) the program stops at E0284 or E0283 (§6).Checkpoint exercise
main stamps out, and how many requests are reuses: summary(&[1u8, 2]); pair(&7u8, &3); nested(&1u8);. Check by printing type_name at the top of each function, as in §3, and counting distinct lines. (Answer: 9 copies — summary::<u8>, largest::<u8>, show::<u8>, pair::<u8, i32>, show::<i32>, nested::<u8>, wrap::<u8>, wrap::<Vec<u8>>, show::<Vec<Vec<u8>>> — from 10 requests, one a reuse: pair asks for the show::<u8> that summary already caused.) Then change one line so a call requests infinitely many copies; cargo check should accept it and cargo build reject it (§3).Where this points next
Everything here happens before the program runs: the collector's list is complete at compile time, and every call jumps to a function that knows its exact types — the source of the speed and of the limit. A Vec<T> holds one T. A drawing program whose list holds a circle, a square and a shape from a plugin written after compilation has no single T to give the collector: each element's type is decided at run time. Road (a) — erase the type, call through a table — becomes the design, as the boxed-trait-object hint suggested. When the type is only known at run time, what replaces the copy per type, and what does each call then pay?
main as a worklist — substitute, request, reuse or stamp out — and rejects a program with no last copy, at the recursion limit, only when code is built. Measured on one machine: no run-time mechanism (erasing the comparison cost three to twenty times as much), though the optimizer may treat two spellings of a loop differently; bytes per copy, though identical copies are sometimes kept once; compile time for every copy. The syntax — bounds, where, impl Trait, turbofish, const generics, ?Sized — says which type each copy gets.Interview prompts
- What is monomorphization, and what does it cost? (§3, §5 — one specialized copy per concrete type, found by a worklist from
main; no run-time mechanism, but code size per distinct copy and compile time per copy.) - Why does Rust type-check a generic body at its definition, not per instantiation as C++ does? (§2 — the signature becomes the whole contract: errors land where the mistake is, and no user's type can break the library's body, nor the body demand more without a visible bound.)
- What are the alternatives to monomorphization, and why is it Rust's default? (§1 — erasing the type behind pointers and indirect calls, or passing a dictionary; both cost something on every call, which the budget forbids.)
- How does
impl Traitdiffer in argument and return position? (§6 — as an argument it is an unnamed type parameter the caller picks; as a return type the callee picks one hidden concrete type, so two different ones are rejected.) - Why does
fn f<T: Display>(x: &T)reject a&str, and what is the fix? (§6 — type parameters are implicitlySizedandstris not; the body only holds&T, soT: ?Sizedis the right contract.) - How do you keep generics from bloating a binary? (§5 — keep the generic part thin and delegate to a non-generic inner function; use dynamic dispatch where speed does not matter; the optimizer merges some identical copies, but each is still compiled.)
- Is a generic function as fast as the hand-written one? (§5 — there is no run-time mechanism, and a select-shaped body compiled to the identical loop; but the if-shaped copy stayed scalar where the hand-written one vectorized. Measure hot loops.)
Companion reads: C++ · 12 Templates (copy per type, checked at instantiation), Go · 14 Generics (the dictionary road), C++ · 09 Classes and zero overhead (what "zero-cost" promised), and C++ · 10 Polymorphism and the vtable (Lesson 13's road).