Understanding Generics in Rust
Generics in Rust enable writing code that works with multiple types without sacrificing performance or safety. If you're familiar with Java, C#, or C++, the concept is similar—though Rust's implementation has its own nuances.
What Are Generics?
Generics allow functions, structs, enums, and methods to operate over a variety of types while maintaining type safety. Instead of writing separate implementations for each type (e.g., one for i32, another for char), you write a single generic version that adapts at compile time.
Using Generics in Functions
Consider a function that finds the largest element in a slice. Without generics, you'd need separate functions:
fn max_i32(values: &[i32]) -> &i32 {
let mut current_max = &values[0];
for val in values {
if val > current_max {
current_max = val;
}
}
current_max
}
fn max_char(chars: &[char]) -> &char {
let mut current_max = &chars[0];
for ch in chars {
if ch > current_max {
current_max = ch;
}
}
current_max
}
With generics, a single function suffices—but only if the type supports comparison:
fn find_max<T: std::cmp::PartialOrd>(items: &[T]) -> &T {
let mut candidate = &items[0];
for item in items {
if item > candidate {
candidate = item;
}
}
candidate
}
The trait bound T: PartialOrd ensures the > operator is valid for T.
Generic Structs
Structs can also be parameterized by types:
#[derive(Debug)]
struct Inventory<Count, Label> {
quantity: Count,
description: Label,
}
When implementing methods for such a struct, repeat the ganeric parameters:
impl<C, L> Inventory<C, L> {
fn describe(&self) {
// method body
}
}
Alternatively, implement for a concrete instantiation:
impl Inventory<i32, String> {
fn restock(&mut self, amount: i32) {
self.quantity += amount;
}
}
Generic Enums
Enums support generics too:
#[derive(Debug)]
enum Role<A, B, C> {
Member(A),
Lead(B),
Manager(C),
Executive(A, B, C),
}
When creating an instance that uses only some variants, explicit type annotations may be required:
let r: Role<i32, i32, i32> = Role::Member(42);
Otherwise, the compiler cannot infer unused generic parameters.
Type Constraints and Trait Bounds
Not all operations are valid for every type. Rust requires explicit constraints via trait bounds:
fn process<T: Clone + std::fmt::Display>(value: T) {
println!("{}", value.clone());
}
This ensures T can be cloned and formatted as a string.
Monomorphization and Performance
Rust eliminates runtime overhead through monomorphization. During compilation, the generic function is duplicated for each concrete type used, producing specialized machine code:
// Source
fn find_max<T: PartialOrd>(items: &[T]) -> &T { ... }
// After monomorphization (conceptually):
fn find_max_i32(items: &[i32]) -> &i32 { ... }
fn find_max_char(items: &[char]) -> &char { ... }
This yields performance identical to hand-written type-specific functions, with no virtual dispatch or heap allocation.
Key Observations
- Generics apply to parameters and fields—not the identity of the type itself.
- Any valid identifier can name a generic parameter (e.g.,
Item, not justT). - Multiple generic parameters are allowed with no hard limit.
- Return types using generics must declare the parameter in the signature (e.g.,
-> Timplies<T>in the declaration). - Rust enforces type constraints at compile time; there’s no runtime type inspection like Java’s
instanceof. - Unlike Java, Rust does not support wildcard generics (e.g.,
? extends T).