Introduction
Without generics, you would need to write separate functions for every type: one for i32, one for f64, one for String, and so on. Generics let you write a single piece of code that works across many types, and Rust compiles it into type-specific code with zero runtime cost.
Key Concepts
- Type parameter: A placeholder (conventionally
T,U, etc.) that represents any type. It appears in angle brackets after the function, struct, or enum name. - Trait bound: A constraint on a type parameter that says "T must implement this trait." Written as
T: TraitNameor with awhereclause. - Monomorphization: The compiler's process of replacing generic code with concrete, type-specific versions at compile time. This is why generics have zero runtime cost.
Real World Context
Generics are the backbone of Rust's standard library. Vec<T>, Option<T>, Result<T, E>, and HashMap<K, V> are all generic types. Every time you write Vec<String> or Result<User, DbError>, you are using generics. Understanding them is essential for reading library APIs and writing reusable code.
Deep Dive
A generic function declares type parameters in angle brackets. To do something useful like comparison, you add a trait bound:
rustfn largest<T: PartialOrd>(list: &[T]) -> &T { let mut largest = &list[0]; for item in list { if item > largest { largest = item; } } largest }
Structs can also be generic. A single type parameter means both fields share the same type:
ruststruct Point<T> { x: T, y: T } let int_point = Point { x: 5, y: 10 }; // Point<i32> let float_point = Point { x: 1.0, y: 4.0 }; // Point<f64>
You can implement methods on generic structs, and also implement methods only for specific concrete types:
rustimpl<T> Point<T> { fn x(&self) -> &T { &self.x } } impl Point<f64> { fn distance_from_origin(&self) -> f64 { (self.x.powi(2) + self.y.powi(2)).sqrt() } }
The distance_from_origin method only exists on Point<f64>, not on any other instantiation.
Common Pitfalls
- Forgetting trait bounds — A bare
Thas no capabilities beyond move/borrow/drop. If you try to compare, print, or clone aTwithout the appropriate trait bound, the compiler will reject it. - Over-constraining — Adding unnecessary trait bounds (e.g., requiring
Clone + Debug + Display) when the function only needsDisplaylimits reusability. Only require what you use.
Best Practices
- Use
whereclauses for readability — When you have multiple type parameters with multiple bounds, awhereclause after the return type is cleaner than inline bounds. - Start concrete, then generalize — Write the function for a specific type first, then replace the concrete type with a type parameter and add the necessary bounds. This avoids over-engineering.
Summary
- Generics let you write code that works across multiple types without duplication.
- Type parameters need trait bounds to perform any operation beyond moving or borrowing.
- Monomorphization means generics have zero runtime overhead.
- Use
whereclauses for complex bounds and start with concrete types before generalizing.
Code Examples
// Generic function with multiple bounds
fn print_debug<T: std::fmt::Debug + Clone>(item: T) {
let cloned = item.clone();
println!("{:?}", cloned);
}
// Using where clause for readability
fn some_function<T, U>(t: &T, u: &U) -> i32
where
T: std::fmt::Display + Clone,
U: Clone + std::fmt::Debug,
{
// ...
0
}