10. Generics
📋 Jump to Takeaways🎁 What if one function signature could accept an integer, a string, or a type nobody has invented yet, and the compiler still checked every call for correctness?
Traits describe what a type can do. Generics let you write code that works across many types at once, with a type variable standing in for "some type we'll pin down later." The best part: it costs nothing at runtime.
Trait Bounds
In the traits lesson you passed &impl Trait. That's shorthand. The fuller form is a trait bound: <T: Trait>. Spelling it out gives you more control.
use std::fmt::Display;
// <T: Display> means "T can be any type that implements Display"
fn announce<T: Display>(item: &T) {
println!("Breaking: {}", item);
}
// Two parameters of the SAME type T:
fn compare<T: Display>(a: &T, b: &T) {
println!("{} vs {}", a, b);
}
fn main() {
announce(&42); // Breaking: 42
compare(&"cats", &"dogs"); // cats vs dogs
}See the difference from &impl Trait? With &impl Display, each parameter could be a different type. With <T: Display>, every T in the signature must be the same type.
Multiple Bounds with +
One bound not enough? Require several at once with +.
use std::fmt::{Display, Debug};
fn print_both<T: Display + Debug>(item: &T) {
println!("Display: {}", item); // Uses Display
println!("Debug: {:?}", item); // Uses Debug
}
fn main() {
print_both(&42);
// Display: 42
// Debug: 42
}The + syntax says "this type must implement Display AND Debug." You can stack as many bounds as needed.
Where Clauses
Once you have three or four bounds, the signature turns into soup. A where clause pulls them out where you can actually read them.
use std::fmt::{Display, Debug};
fn complex_function<T, U>(t: &T, u: &U) -> String
where
T: Display + Clone,
U: Debug + Default,
{
format!("{} and {:?}", t, u)
}
fn main() {
let result = complex_function(&"hello", &42);
println!("{}", result); // hello and 42
}Same constraints, cleaner signature. Reach for where once you have more than a bound or two.
Generic Structs and Enums
Generics aren't just for functions. Your own types can hold any type too. You parameterize a struct or enum with a type variable, and one definition works with all of them.
#[derive(Debug)]
struct Pair<T> {
first: T,
second: T,
}
impl<T: PartialOrd + std::fmt::Display> Pair<T> {
fn larger(&self) -> &T {
if self.first >= self.second {
&self.first
} else {
&self.second
}
}
}
fn main() {
let int_pair = Pair { first: 5, second: 10 };
println!("Larger: {}", int_pair.larger()); // Larger: 10
let str_pair = Pair { first: "apple", second: "banana" };
println!("Larger: {}", str_pair.larger()); // Larger: banana
}You've been using generic enums all along. Option<T> and Result<T, E> are exactly this pattern:
// The standard library defines them roughly like this:
enum Option<T> {
Some(T),
None,
}
enum Result<T, E> {
Ok(T),
Err(E),
}So how is this free at runtime? The compiler generates a specialized version of your code for each concrete type you actually use. That step is called monomorphization. You write one generic Pair<T>, and the compiler stamps out a Pair<i32> and a Pair<&str> as if you'd hand-written both. Flexibility of generics, speed of hand-written code.
Key Takeaways
- A trait bound
<T: Trait>constrains a generic type to ones that implementTrait <T: Trait>forces one shared type;&impl Traitallows a different type per parameter- Use
+to require multiple bounds, and awhereclause when the signature gets busy - Structs and enums can be generic (
Pair<T>), so one definition works with many types Option<T>andResult<T, E>are generic enums you already use- Monomorphization compiles generics into specialized code, so there's zero runtime cost
🎁 Generics let you write flexible, reusable code, but what happens when you return a reference from a function? How does the compiler know the reference is still valid? Next up: lifetimes give the compiler proof that a reference won't outlive the data it points to.