Generics
In one sentence
Section titled “In one sentence”A generic function or type works for many types; trait bounds say what those types must be able to do, and the compiler makes a separate copy for each type used.
The Java you know
Section titled “The Java you know”Java generics are checked at compile time and then erased. At run time, List<String> and List<Integer> are the same class, and the type argument is gone:
Java
static <T extends Comparable<T>> Optional<T> largest(List<T> items) { T best = null; for (T item : items) { if (best == null || item.compareTo(best) > 0) { best = item; } } return Optional.ofNullable(best);}Rust
/// The largest item, or `None` for an empty slice. Works for any `T` that can be compared.pub fn largest<T: PartialOrd>(items: &[T]) -> Option<&T> { let mut iter = items.iter(); let mut best = iter.next()?; for item in iter { if item > best { best = item; } } Some(best)}The two functions look alike. <T extends Comparable<T>> in Java and <T: PartialOrd> in Rust both say “T must be comparable”. The difference is in what the compiler does with them.
The Rust you will see
Section titled “The Rust you will see”Monomorphization, not erasure
Section titled “Monomorphization, not erasure”Rust makes a separate copy of a generic function for each concrete type that uses it. largest::<i32> and largest::<&str> are two functions in the binary, each compiled and optimized for its type. This is called monomorphization.
The consequences, compared to Java:
| Java (erasure) | Rust (monomorphization) | |
|---|---|---|
| Speed | Calls go through interfaces, values are boxed (Integer) |
Direct calls, no boxing: as fast as hand-written code |
| Binary size | One copy | One copy per type: larger binaries, longer compile times |
| Primitives | Not allowed (List<int> does not exist) |
Allowed (Vec<u8>, largest::<f64>) |
| Type at run time | Erased: no instanceof List<String> |
Known: each copy has its own type |
/// The largest item, or `None` for an empty slice. Works for any `T` that can be compared.pub fn largest<T: PartialOrd>(items: &[T]) -> Option<&T> { let mut iter = items.iter(); let mut best = iter.next()?; for item in iter { if item > best { best = item; } } Some(best)}There is no null for “nothing found”: the function returns Option<&T>, and ? on line 4 returns None for an empty slice.
Trait bounds
Section titled “Trait bounds”A bound says which traits a type parameter must implement. Without a bound, a function can do almost nothing with a T: not compare it, not print it, not copy it. You will see the same bounds written in three ways:
/// Three ways to write the same bounds.pub fn report_a<T: Display + Clone>(items: &[T]) -> String { join(items)}
pub fn report_b<T>(items: &[T]) -> Stringwhere T: Display + Clone,{ join(items)}
pub fn report_c(items: &[impl Display + Clone]) -> String { join(items)}<T: Display + Clone>: inline. Good for short bounds.where T: Display + Clone: awhereclause. Good for long bounds, and for bounds that are not on a plain parameter.impl Display + Clonein argument position: short, but the caller cannot name the type with a turbofish (report_c::<u32>does not work).
All three compile to the same code.
How to read it
Section titled “How to read it”Generic types
Section titled “Generic types”A struct or an enum can have type parameters. The bounds usually go on the impl block, not on the struct, so that the struct can be named without them:
/// Counts how often each key occurs. `K` is chosen by the caller.#[derive(Debug)]pub struct Counter<K> { counts: HashMap<K, usize>,}
impl<K: Eq + Hash> Counter<K> { pub fn new() -> Self { Self { counts: HashMap::new(), } }
pub fn add(&mut self, key: K) { *self.counts.entry(key).or_insert(0) += 1; }
pub fn get(&self, key: &K) -> usize { self.counts.get(key).copied().unwrap_or(0) }}- Line 3:
Counter<K>: a type parameter with no bounds.Option<T>,Vec<T>andHashMap<K, V>are defined the same way. - Line 7:
impl<K: Eq + Hash> Counter<K>: these methods exist only whenKcan be a hash map key. Read it as "for everyKthat isEqandHash". - Line 14:
add(&mut self, key: K)takes the key by value, because the map stores it. - Line 18:
get(&self, key: &K)only borrows the key, because it only looks it up.
Associated types
Section titled “Associated types”Some traits have a type that each implementation chooses once, for example Iterator and its Item. This is an associated type:
/// An iterator over order IDs. `Item` is an associated type: each iterator type has one item type.pub struct OrderIds { next: u64, last: u64,}
impl Iterator for OrderIds { type Item = u64;
fn next(&mut self) -> Option<u64> { if self.next > self.last { return None; } self.next += 1; Some(self.next - 1) }}
pub fn ids(first: u64, last: u64) -> impl Iterator<Item = u64> { OrderIds { next: first, last }}impl Iterator<Item = u64> in the return type means “some iterator type that yields u64, which the caller does not need to name”. A generic parameter (trait Iterator<T>) would let one type implement the trait many times, with a different T each time. An associated type allows one implementation per type, which is what an iterator needs. When you read Item = u64, read it as “the iterator’s element type is u64”.
PhantomData, at a reading level
Section titled “PhantomData, at a reading level”Sometimes a type has a type parameter that none of its fields store. PhantomData<T> is a zero-size marker that “uses” T, so that the compiler accepts the parameter:
/// An ID that is typed by the entity it belongs to. `PhantomData<T>` uses `T`/// without storing a `T`, so `Id<Customer>` and `Id<Order>` are different types.pub struct Id<T> { pub value: u64, entity: PhantomData<T>,}
impl<T> Id<T> { pub fn new(value: u64) -> Self { Self { value, entity: PhantomData, } }}Id<Customer> and Id<Order> are now different types, but both are only a u64 in memory. You need to recognize PhantomData, not write it. When you see it, ask what the type parameter protects.
What good looks like
Section titled “What good looks like”- Generic only where more than one type is used. A type parameter that is always
u64is noise. - The smallest bounds that the body needs. If the body only compares, the bound is
PartialOrd, notClone + Debug + Send + Sync. - Bounds on
implblocks and functions, not on struct definitions. impl Traitin argument position for simple cases, named type parameters when the caller must choose the type, or when two parameters must have the same type.- Concrete types in application code. Generic code is common in libraries. In an application, most functions take concrete types.
Red flags in AI output
Section titled “Red flags in AI output”- A type parameter that is used with one type only.
fn shipping<C: Into<u64>>(country: C), called only withu16. The generic adds compile time and reading time for nothing. - Very long bounds “to be safe”:
T: Clone + Debug + Send + Sync + 'staticon a function that only adds numbers. Each bound limits the callers. Ask which bounds the body needs. - Very long
whereclauses that repeat in many functions. A helper trait, or a simpler design, is often better. Box<dyn Trait>where a generic works. Dynamic dispatch and a heap allocation for one type per call. See S22:Box<dyn Trait>where a generic or enum is sufficient.- Generics to imitate Java’s
Object:T: Anywith downcasts. The code needs an enum or a trait with methods.
- S22Severity: Low
Box<dyn Trait>where a generic or enum is sufficientDynamic dispatch and a heap allocation where the set of types is known.
Better vs worse
Section titled “Better vs worse”The task: sum the lines of an order, and compute shipping by country code.
WorseTwo type parameters with six bounds, most of them unused, and a generic
country_codethat is always au16.pub fn total_cents<T, I>(lines: I) -> u64whereT: Into<u64> + Clone + Debug + Send + Sync + 'static,I: IntoIterator<Item = T> + Clone + Debug,{lines.into_iter().map(Into::into).sum()}pub fn shipping<C: Into<u64>>(country_code: C) -> u64 {if country_code.into() == 41 {900} else {1_500}}Run in Playground (opens the Rust Playground in a new tab) BetterOne
impl IntoIterator<Item = u64>parameter, and a plainu16.pub fn total_cents(lines: impl IntoIterator<Item = u64>) -> u64 {lines.into_iter().sum()}pub fn shipping(country_code: u16) -> u64 {if country_code == 41 { 900 } else { 1_500 }}Run in Playground (opens the Rust Playground in a new tab)
The “worse” total_cents compiles and works. But Clone + Debug + Send + Sync + 'static on the element type rejects callers whose types do not have those traits, and the body uses none of them. I: Clone + Debug on the iterator is also unused. Error messages for a wrong call list all six bounds, which makes them hard to read.
The “worse” shipping makes every call generic for a parameter that is always a country code. A plain u16 says what the value is.
Generic code is correct when callers use different types: a library function that works on any IntoIterator, a container like Counter<K>, or a function that accepts &str, String and &String with impl AsRef<str>.
Check yourself
Section titled “Check yourself”What is the main difference between Java generics and Rust generics at run time?
Show the answer
Java erases the type arguments, so there is one copy of the code. Rust makes a copy for each concrete type (monomorphization). Rust code is faster and can use primitive types directly. The cost is larger binaries and longer compile times.
Why does
fn largest<T>(items: &[T]) -> Option<&T>not compile if the body uses>?Show the answer
Without a bound, the compiler knows nothing about
T, so it cannot compare two values. The boundT: PartialOrdgives the>operator.What does
impl Iterator<Item = u64>in a return type mean?Show the answer
The function returns one specific iterator type that yields
u64values. The caller does not need to know the type’s name, but it is still static dispatch: no box and no vtable.A function has the bound
T: Clone + Debug + Send + Sync + 'static, and only callsto_string(). What do you suggest?Show the answer
Replace the bounds with
T: Display(orToString), which is all that the body needs. Fewer bounds accept more callers and give clearer error messages.