How do generics work in Rust
You just finished writing a function to find the largest number in a list. It works perfectly for i32. Five minutes later, you need the same logic for f64. You copy the function, rename it, and change every i32 to f64. Then a custom Score struct comes along. You're about to paste a third time when you stop. The logic is identical. The types are different. You want to write the code once and let the compiler handle the types. That's where generics come in.
Generics let you write code that works with multiple types without repeating yourself. You define a placeholder, usually called T, and use it wherever you'd normally write a concrete type. The compiler fills in the blanks later. This gives you the flexibility to reuse code while keeping the performance of hand-written, type-specific functions.
The cookie cutter analogy
Think of a generic function like a cookie cutter. The cutter defines the shape and the cutting action. You can press it into chocolate dough, sugar dough, or oatmeal dough. The cutter doesn't care about the flavor. It just needs the dough to hold together.
In Rust, the dough is the type. The requirement that it holds together is a trait bound. If you try to press the cutter into a liquid, it fails. Similarly, if you try to use a generic function with a type that doesn't meet the requirements, the compiler rejects you. The bound ensures the type supports the operations your code needs, like comparison or cloning.
Generics shift the work to compile time. You pay the cost of defining the requirements once, and the compiler generates specialized code for every type you actually use. Trust the monomorphization. The compiler is doing the heavy lifting so your runtime stays fast.
A minimal example
Here is a function that finds the largest element in a slice. It works for any type that supports comparison.
/// Finds the largest element in a slice of comparable items.
fn largest<T: PartialOrd>(list: &[T]) -> &T {
// Start with the first item as the current winner.
let mut largest = &list[0];
// Check every item against the current winner.
for item in list {
if item > largest {
largest = item;
}
}
largest
}
fn main() {
// The compiler infers T is i32 here.
let numbers = vec![10, 5, 20, 3];
println!("Largest: {}", largest(&numbers));
// The compiler infers T is f64 here.
let floats = vec![1.1, 2.2, 0.5];
println!("Largest float: {}", largest(&floats));
}
The syntax <T: PartialOrd> declares a generic type parameter T with a bound. The bound PartialOrd means T must implement the PartialOrd trait. This trait provides comparison operators like > and <. Without the bound, the compiler wouldn't know if item > largest is valid for T.
When you call largest with &numbers, the compiler infers that T is i32. It checks that i32 implements PartialOrd. It then generates a version of the function specifically for i32. When you call it with &floats, it generates a separate version for f64. You never write largest::<i32> explicitly; the compiler infers the type from the arguments.
What happens under the hood
Rust uses a process called monomorphization to handle generics. The compiler doesn't create a single function that dispatches to different types at runtime. It creates a distinct copy of the function for each concrete type you use.
If you use largest with i32, f64, and String, the binary contains three separate functions. Each one is optimized for its specific type. The i32 version uses integer comparison instructions. The f64 version uses floating-point instructions. There is no runtime overhead for the generic abstraction. The code runs as fast as if you had written three separate functions by hand.
This zero-cost abstraction is a core Rust principle. You get the developer experience of writing code once, and the performance of writing code many times. The trade-off is that the compiler does more work, and the binary can grow larger if you use the same generic function with many types.
Monomorphization happens at compile time. The generated code is static. The compiler resolves all trait method calls and inlines aggressively. You don't get dynamic dispatch unless you explicitly ask for it with trait objects.
Realistic usage: A generic wrapper
Generics are common in structs and enums, not just functions. The standard library is full of them. Vec<T>, Option<T>, and Result<T, E> are all generic types. You can define your own.
/// A simple wrapper that holds a value of any type.
struct Wrapper<T> {
value: T,
}
impl<T> Wrapper<T> {
/// Creates a new wrapper with the given value.
fn new(value: T) -> Self {
Wrapper { value }
}
/// Returns a reference to the inner value.
fn get(&self) -> &T {
&self.value
}
}
impl<T: std::fmt::Debug> Wrapper<T> {
/// Prints the inner value if it implements Debug.
fn debug_print(&self) {
println!("Value: {:?}", self.value);
}
}
fn main() {
let int_wrapper = Wrapper::new(42);
println!("Int: {}", int_wrapper.get());
let str_wrapper = Wrapper::new("hello");
str_wrapper.debug_print();
}
This example shows two important patterns. First, impl<T> Wrapper<T> implements methods for all T. The new and get methods work for any type. Second, impl<T: Debug> Wrapper<T> implements methods only when T satisfies a bound. The debug_print method is only available if the wrapped type implements Debug.
You can also use multiple generic parameters. A function that pairs two values might look like fn pair<T, U>(a: T, b: U) -> (T, U). The compiler treats each parameter independently. You can add bounds to some parameters and not others.
Read the trait bound error. It tells you exactly what's missing. If you call debug_print on a Wrapper containing a type without Debug, the compiler rejects you with E0277 (trait bound not satisfied). The error points to the missing trait and suggests implementing it or adding the bound.
Where clauses for readability
When you have many bounds or complex types, the signature can get cluttered. Rust provides where clauses to move bounds to the end of the signature. This improves readability without changing the meaning.
/// Processes items that can be cloned, debugged, and compared.
fn process<T>(items: &[T])
where
T: Clone + std::fmt::Debug + PartialEq,
{
// Clone the first item for inspection.
let first = items[0].clone();
println!("First item: {:?}", first);
// Check for duplicates.
for item in items {
if item == &first {
println!("Found a duplicate of the first item.");
}
}
}
The where clause lists the requirements after the function signature. This keeps the return type and parameters visible at a glance. It's especially helpful when you have multiple generic parameters with different bounds, or when the return type is complex.
Use where clauses when the signature becomes hard to scan. If you have two or three bounds, the angle bracket syntax is fine. If you have four or more, or if the bounds involve associated types, move them to a where clause. It's a style choice that pays off in maintainability.
The trade-off: Code bloat
Monomorphization has a downside. If you write a large generic function and use it with many types, the compiler generates many copies. This can increase the binary size. This is called code bloat.
Code bloat is rarely a problem for small functions. The compiler inlines them, and the size increase is negligible. It becomes a concern when you have large functions with heavy logic, or when you use the same generic type with dozens of concrete types.
If code bloat is a real issue, you can switch to trait objects. Trait objects use dynamic dispatch. The compiler generates a single function that calls methods via a vtable at runtime. This reduces code size but adds a small runtime cost for the indirect call.
Measure before you optimize. Profile your binary size and performance. If bloat is hurting your startup time or memory footprint, consider refactoring to trait objects or splitting the generic function into smaller pieces. Don't guess. Data tells the story.
Conventions and small details
Rust has strong community conventions for generics. Following them makes your code instantly recognizable to other Rustaceans.
Use T for the first generic parameter. Use U for the second. If you're building a map or dictionary, use K for the key and V for the value. If you're implementing a collection with a custom allocator or hasher, use A or S for the third parameter. These names aren't enforced by the compiler, but they carry meaning. T stands for "Type". K and V stand for "Key" and "Value".
Another convention is default generic parameters. The HashMap type has three parameters: K, V, and S for the hasher. The hasher has a default value. You write HashMap<K, V> and the compiler fills in the default hasher. This reduces boilerplate for common cases while allowing customization when needed.
/// A map with a default hasher.
struct MyMap<K, V, S = DefaultHasher> {
// Implementation details...
}
The syntax S = DefaultHasher sets the default. Users can omit S and get the default, or provide a custom hasher explicitly. This pattern is common in the standard library. It balances ergonomics and flexibility.
Convention aside: cargo fmt formats every file the same way. Don't argue style; argue logic. Generic naming conventions are part of that shared style. They reduce cognitive load when reading code.
When to use generics
Generics are a powerful tool, but they aren't the only option. Rust offers trait objects for dynamic dispatch and concrete types for simplicity. Pick the right tool for the job.
Use generics when you need the same logic for multiple types and want zero-cost abstraction. The compiler generates specialized code for each type, avoiding runtime overhead. This is the default choice for most reusable code.
Use trait objects when you need to store heterogeneous types in the same collection. Generics require all items to be the same concrete type; trait objects allow mixing types behind a pointer. This is essential for plugins, message buses, or UI systems where different types share a common interface.
Use concrete types when performance profiling shows the generic overhead is negligible and the code is simpler without parameters. Generics add complexity; don't use them if you only ever pass one type. Keep it simple until you have a reason to generalize.
Use dyn Trait when you need dynamic dispatch. Generics use static dispatch; the compiler resolves calls at compile time. Dynamic dispatch resolves calls at runtime via a vtable. This allows late binding and polymorphism across crate boundaries.
Generics are a contract, not a magic wand. Define the requirements clearly, trust the compiler to enforce them, and measure the impact on your binary. The borrow checker and the type system work together to keep your code safe and fast. Lean on them.