Generics
Generics let you write code that works across multiple types without duplicating logic. The Rust compiler turns generic code into specialized code for each concrete type used — a process called monomorphization. This means generics are zero-cost at runtime: you get the flexibility of polymorphism with the performance of hand-written specialized functions.
Generic Functions
A generic function declares type parameters in angle brackets:
fn largest<T: PartialOrd>(list: &[T]) -> &T {
let mut largest = &list[0];
for item in &list[1..] {
if item > largest {
largest = item;
}
}
largest
}
fn main() {
let numbers = vec![34, 50, 25, 100, 65];
println!("Largest number: {}", largest(&numbers));
let chars = vec!['y', 'm', 'a', 'q'];
println!("Largest char: {}", largest(&chars));
}
Largest number: 100
Largest char: y
The T: PartialOrd is a trait bound — it tells the compiler that T must support comparison. Without it, the > operator would not compile.
Generic Structs
Structs can be generic over one or more type parameters:
struct Pair<T, U> {
first: T,
second: U,
}
impl<T, U> Pair<T, U> {
fn new(first: T, second: U) -> Self {
Pair { first, second }
}
}
// Methods only available when T implements Display
impl<T: std::fmt::Display, U: std::fmt::Display> Pair<T, U> {
fn show(&self) {
println!("({}, {})", self.first, self.second);
}
}
fn main() {
let p = Pair::new("hello", 42);
p.show();
}
(hello, 42)
The second impl block demonstrates conditional method availability. show() only exists when both T and U implement Display. The compiler checks this at the call site, not the definition site.
Trait Bounds
Trait bounds constrain what a generic type must be capable of. You can combine multiple bounds with +:
use std::fmt::{Display, Debug};
fn log_and_return<T: Display + Clone + Debug>(value: &T) -> T {
println!("[LOG] {:?}", value);
value.clone()
}
fn main() {
let name = String::from("production-server");
let copy = log_and_return(&name);
println!("Got: {}", copy);
}
[LOG] "production-server"
Got: production-server
Common bound combinations in real code:
// Hashable keys
fn count_occurrences<T: Eq + std::hash::Hash>(items: &[T]) -> std::collections::HashMap<&T, usize> {
let mut counts = std::collections::HashMap::new();
for item in items {
*counts.entry(item).or_insert(0) += 1;
}
counts
}
// Serializable data
// T: Serialize + DeserializeOwned (with serde)
// Error types
// E: std::error::Error + Send + Sync + 'static (for anyhow compatibility)
Where Clauses
When trait bounds get long, move them to a where clause for readability:
use std::fmt::Display;
// This is hard to read
fn process<T: Display + Clone + Send + 'static, U: Display + Into<String>>(t: T, u: U) -> String {
format!("{}: {}", t, u)
}
// This is much clearer
fn process_clean<T, U>(t: T, u: U) -> String
where
T: Display + Clone + Send + 'static,
U: Display + Into<String>,
{
format!("{}: {}", t, u)
}
Where clauses can also express bounds that inline syntax cannot:
fn apply<F, T>(f: F, value: T) -> T
where
F: Fn(T) -> T,
T: Display,
{
let result = f(value);
println!("Result: {}", result);
result
}
In practice, use where clauses whenever you have more than two bounds or more than two type parameters.
Monomorphization: Zero-Cost Generics
When you write a generic function, the compiler generates a separate, specialized version for each concrete type used. This is monomorphization:
fn double<T: std::ops::Mul<Output = T> + Copy>(x: T) -> T {
x * x
}
fn main() {
let a = double(3_i32); // compiler generates double_i32
let b = double(2.5_f64); // compiler generates double_f64
println!("{}, {}", a, b);
}
9, 6.25
After monomorphization, the generated code is identical to what you would write by hand for each type. There is no vtable lookup, no indirection, no runtime cost. The tradeoff is compile time and binary size — each instantiation produces its own machine code.
A Real-World Generic: Repository Pattern
Generics shine when building reusable abstractions over domain types:
use std::collections::HashMap;
trait Entity {
type Id: Eq + std::hash::Hash + Clone;
fn id(&self) -> &Self::Id;
}
struct InMemoryRepo<T: Entity> {
store: HashMap<T::Id, T>,
}
impl<T: Entity> InMemoryRepo<T> {
fn new() -> Self {
InMemoryRepo {
store: HashMap::new(),
}
}
fn insert(&mut self, entity: T) {
self.store.insert(entity.id().clone(), entity);
}
fn get(&self, id: &T::Id) -> Option<&T> {
self.store.get(id)
}
fn count(&self) -> usize {
self.store.len()
}
}
#[derive(Debug)]
struct User {
id: u64,
name: String,
}
impl Entity for User {
type Id = u64;
fn id(&self) -> &u64 {
&self.id
}
}
fn main() {
let mut repo = InMemoryRepo::new();
repo.insert(User { id: 1, name: "Alice".into() });
repo.insert(User { id: 2, name: "Bob".into() });
println!("Users: {}", repo.count());
if let Some(user) = repo.get(&1) {
println!("Found: {:?}", user);
}
}
Users: 2
Found: User { id: 1, name: "Alice" }
This InMemoryRepo works for any type implementing Entity. The associated type Id lets each entity define its own key type.
When Generics Make Sense vs When They Over-Complicate
Use generics when:
- Multiple types share the same behavior and you want one implementation
- You need zero-cost abstraction in a hot path
- You are building a library that users will instantiate with their own types
- The trait bounds naturally describe the required capabilities
Avoid generics when:
- Only one or two concrete types will ever be used — just write concrete code
- The trait bounds become a maze of
whereclauses that nobody can read - You are writing application code, not library code, and flexibility is not needed
- Dynamic dispatch (
dyn Trait) would be simpler and the performance difference is irrelevant
A common anti-pattern in Rust codebases is premature generalization: making a function generic over five type parameters when it will only ever be called with String. Write concrete code first. Generalize when you see real duplication.
Common Pitfalls
- Bound explosion — adding bounds one at a time until the signature is unreadable. If you need
T: Display + Debug + Clone + Send + Sync + 'static, consider whether a concrete type or a trait object would be simpler. - Forgetting
Sized— all generic parameters are implicitlySized. If you need to accept dynamically-sized types (likedyn Trait), add?Sized:fn foo<T: ?Sized + Display>(t: &T). - Turbofish confusion — sometimes type inference fails and you need
function::<ConcreteType>(arg). This is normal, not a design smell. - Generic return types —
fn parse<T: FromStr>(s: &str) -> Trequires the caller to specifyT, either via turbofish or type annotation. Forgetting this leads to confusing "type annotations needed" errors. - Monomorphization bloat — in hot libraries, excessive generics can inflate binary size. Profile before worrying about this.
Key Takeaways
- Generics let you write one function or struct that works for many types, with trait bounds specifying the required capabilities.
whereclauses keep complex bounds readable. Use them liberally.- Monomorphization means generics are zero-cost at runtime — the compiler generates specialized code for each concrete type.
- Write concrete code first. Generalize only when you see real duplication or are building a library API.
- If trait bounds are becoming unwieldy, consider whether
dyn Traitor a concrete type is the better tool.