11.2 Generics in Rust
Generics let you write code that can handle various data types without sacrificing compile-time safety. They help you avoid code duplication by parameterizing functions, structs, enums, and methods over abstract type parameters.
Key Points
- Type Parameters: Expressed using angle brackets (
<>
), often namedT
,U
,V
, etc. - Zero-Cost Abstractions: Rust enforces type checks at compile time, and generics compile to specialized, efficient machine code.
- Flexibility: The same generic definition can accommodate multiple concrete types.
- Contrast with C: In C, a similar effect might be achieved via macros or void pointers, but neither approach provides the robust type checking Rust offers.
11.2.1 Generic Functions
Functions can accept or return generic types:
fn function_name<T>(param: T) {
// ...
}
Example: A Generic max
Function
Instead of writing nearly identical functions for i32
and f64
, we can unify them:
#![allow(unused)] fn main() { fn max<T: PartialOrd>(a: T, b: T) -> T { if a > b { a } else { b } } }
T: PartialOrd
specifies that T
must support comparisons.
Example: A Generic size_of_val
Function
use std::mem; fn size_of_val<T>(_: &T) -> usize { mem::size_of::<T>() } fn main() { let x = 5; let y = 3.14; println!("Size of x: {}", size_of_val(&x)); println!("Size of y: {}", size_of_val(&y)); }
This function determines the size of any type you pass in. Because mem::size_of
works for all types, we do not require a specific trait bound here.
11.2.2 Generic Structs and Enums
You can define structs and enums with generics:
struct Pair<T, U> { first: T, second: U, } fn main() { let pair = Pair { first: 5, second: 3.14 }; println!("Pair: ({}, {})", pair.first, pair.second); }
Examples in the Standard Library:
Vec<T>
: A dynamic growable list whose elements are of typeT
.HashMap<K, V>
: A map of keysK
to valuesV
.
11.2.3 Generic Methods
Generic parameters apply to methods as well:
impl<T, U> Pair<T, U> {
fn swap(self) -> Pair<U, T> {
Pair {
first: self.second,
second: self.first,
}
}
}
11.2.4 Trait Bounds in Generics
It’s common to require that generic parameters implement certain traits:
use std::fmt::Display;
fn print_pair<T: Display, U: Display>(pair: &Pair<T, U>) {
println!("Pair: ({}, {})", pair.first, pair.second);
}
11.2.5 Multiple Trait Bounds Using +
You can require multiple traits on a single parameter:
#![allow(unused)] fn main() { fn compare_and_display<T: PartialOrd + Display>(a: T, b: T) { if a > b { println!("{} is greater than {}", a, b); } else { println!("{} is less than or equal to {}", a, b); } } }
11.2.6 Using where
Clauses for Clarity
When constraints are numerous or lengthy, where
clauses help readability:
#![allow(unused)] fn main() { fn compare_and_display<T, U>(a: T, b: U) where T: PartialOrd<U> + Display, U: Display, { if a > b { println!("{} is greater than {}", a, b); } else { println!("{} is less than or equal to {}", a, b); } } }
11.2.7 Generics and Code Bloat
Because Rust monomorphizes generic code (creating specialized versions for each concrete type), your binary may grow when you heavily instantiate generics:
- Trade-Off: In exchange for potential code-size increases, you gain compile-time safety and optimized code for each specialized version.
11.2.8 Comparing Rust Generics to C++ Templates
Rust generics resemble C++ templates in that both are expanded at compile time. However, Rust’s approach is more stringent in terms of type checking:
- Stricter Bounds: Rust ensures all required traits are satisfied at compile time, reducing surprises.
- No Specialization: Rust does not currently support template specialization, although associated traits and types often achieve similar outcomes.
- Seamless Integration with Lifetimes: Rust extends type parameters to encompass lifetime parameters, providing memory safety features.
- Zero-Cost Abstraction: Monomorphization yields efficient code akin to specialized C++ templates.