Generics in Rust
Write generic functions and structs, use where clauses, and understand monomorphization.
Generic Functions
Without generics, you need a separate function for every type you want to support. With generics, you write the logic once and let the compiler generate the type-specific versions for you. The type parameter <T> is a placeholder that gets filled in at each call site, and trait bounds like T: PartialOrd constrain which types are allowed so your function can rely on specific operations.
// Without generics — only works for i32
fn largest_i32(list: &[i32]) -> &i32 {
let mut largest = &list[0];
for item in list {
if item > largest { largest = item; }
}
largest
}
// With generics — works for any type that supports comparison
fn largest<T: PartialOrd>(list: &[T]) -> &T {
let mut largest = &list[0];
for item in list {
if item > largest { largest = item; }
}
largest
}
fn main() {
let ints = vec![34, 50, 25, 100, 65];
let floats = vec![1.5, 9.1, 2.3, 0.8];
let chars = vec!['y', 'm', 'a', 'q'];
println!("{}", largest(&ints)); // 100
println!("{}", largest(&floats)); // 9.1
println!("{}", largest(&chars)); // y
}
Generic Structs
Structs can be generic over one or more type parameters, making them reusable data containers. You can also add impl blocks with extra bounds that make certain methods available only when the type parameter satisfies additional constraints — so a method like display_largest is only callable when T implements Display.
#[derive(Debug)]
struct Pair<T> {
first: T,
second: T,
}
impl<T> Pair<T> {
fn new(first: T, second: T) -> Self {
Self { first, second }
}
fn swap(self) -> Pair<T> {
Pair { first: self.second, second: self.first }
}
}
// Conditional method — only available when T: Display + PartialOrd
impl<T: std::fmt::Display + PartialOrd> Pair<T> {
fn display_largest(&self) {
if self.first >= self.second {
println!("largest: {}", self.first);
} else {
println!("largest: {}", self.second);
}
}
}
fn main() {
let p = Pair::new(5, 10);
p.display_largest(); // largest: 10
let swapped = p.swap();
println!("{:?}", swapped); // Pair { first: 10, second: 5 }
let s = Pair::new("hello", "world");
s.display_largest(); // largest: world
}
Multiple Generic Parameters
A struct or function can have as many type parameters as needed. This is how the standard library’s HashMap<K, V> and Result<T, E> work — each parameter is independently constrained and serves a distinct role. The where clause becomes especially valuable here to keep the signature readable.
#[derive(Debug)]
struct KeyValue<K, V> {
key: K,
value: V,
}
impl<K: std::fmt::Display, V: std::fmt::Display> KeyValue<K, V> {
fn new(key: K, value: V) -> Self { Self { key, value } }
fn display(&self) {
println!("{} = {}", self.key, self.value);
}
}
// zip_with combines two vectors element-by-element using a closure
fn zip_with<A, B, C, F>(a: Vec<A>, b: Vec<B>, f: F) -> Vec<C>
where
F: Fn(A, B) -> C, // where clause keeps the signature clean
{
a.into_iter().zip(b.into_iter()).map(|(x, y)| f(x, y)).collect()
}
fn main() {
let kv = KeyValue::new("name", "Alice");
kv.display(); // name = Alice
let sums = zip_with(vec![1, 2, 3], vec![10, 20, 30], |a, b| a + b);
println!("{:?}", sums); // [11, 22, 33]
}
where Clauses
where clauses are not just cosmetic — they are the only way to write certain bounds that involve associated types. When a bound refers to I::Item (an associated type of I), the where syntax is required. As a rule of thumb, use inline bounds for simple single-bound cases and where for anything with two or more bounds per parameter, or associated type constraints.
use std::fmt::{Debug, Display};
// Hard to read inline — bounds crowd the function name off screen
fn debug_display_inline<T: Debug + Display, U: Debug>(t: T, u: U) {
println!("{} {:?}", t, u);
}
// Easier to read with where — bounds are separated from the signature
fn debug_display<T, U>(t: T, u: U)
where
T: Debug + Display,
U: Debug,
{
println!("{} {:?}", t, u);
}
// where is essential for associated type bounds — no inline alternative
fn process<I>(iter: I)
where
I: Iterator,
I::Item: Display, // cannot write this as an inline bound
{
for item in iter {
println!("{}", item);
}
}
fn main() {
debug_display(42, vec![1, 2, 3]);
process(vec!["a", "b", "c"].into_iter());
}
Generic Enums
Enums can be generic too. You have already used the two most important ones — Option<T> represents a value that may or may not exist, and Result<T, E> represents a computation that may succeed or fail. Writing your own generic enum follows the same pattern and is useful for recursive data structures like trees where you need the type to refer to itself.
// From the standard library:
// enum Option<T> { Some(T), None }
// enum Result<T, E> { Ok(T), Err(E) }
// A binary tree generic over any node value type
#[derive(Debug)]
enum Tree<T> {
Leaf(T),
Node(Box<Tree<T>>, Box<Tree<T>>),
}
impl<T: std::fmt::Display> Tree<T> {
fn depth(&self) -> usize {
match self {
Tree::Leaf(_) => 0,
// Recursion works because Box<Tree<T>> is a known-size pointer
Tree::Node(left, right) => 1 + left.depth().max(right.depth()),
}
}
}
fn main() {
let tree = Tree::Node(
Box::new(Tree::Node(
Box::new(Tree::Leaf(1)),
Box::new(Tree::Leaf(2)),
)),
Box::new(Tree::Leaf(3)),
);
println!("depth: {}", tree.depth()); // 2
}
Const Generics
Since Rust 1.51, constant values like array lengths can be generic parameters. This lets you write functions and types that are parameterized by a size known at compile time, enabling stack-allocated fixed-size buffers with no heap allocation and no runtime bounds checking overhead.
// Works for arrays of any length — the length is a compile-time constant
fn sum_array<const N: usize>(arr: [i32; N]) -> i32 {
arr.iter().sum()
}
// A fixed-size buffer where SIZE is determined by the caller at compile time
struct FixedBuffer<const SIZE: usize> {
data: [u8; SIZE],
len: usize,
}
impl<const SIZE: usize> FixedBuffer<SIZE> {
fn new() -> Self {
Self { data: [0u8; SIZE], len: 0 }
}
fn capacity(&self) -> usize { SIZE }
}
fn main() {
println!("{}", sum_array([1, 2, 3, 4, 5])); // 15
println!("{}", sum_array([10, 20])); // 30
let buf = FixedBuffer::<1024>::new();
println!("capacity: {}", buf.capacity()); // 1024
}
Monomorphization — Zero-Cost Generics
The reason generics in Rust have no runtime overhead is monomorphization: the compiler reads each use site of a generic function or struct, notes the concrete types involved, and emits a separate specialized copy of the machine code for each combination. The resulting binary contains code equivalent to what you would have written by hand for each specific type. The trade-off is longer compile times and potentially larger binaries when a generic is instantiated with many different types.
fn add<T: std::ops::Add<Output = T>>(a: T, b: T) -> T {
a + b
}
fn main() {
// The compiler generates two separate functions in the binary:
add(1_i32, 2_i32); // → effectively: fn add_i32(a: i32, b: i32) -> i32
add(1.0_f64, 2.0_f64); // → effectively: fn add_f64(a: f64, b: f64) -> f64
// No boxing, no vtable, no indirection — same speed as hand-written code
}
Blanket Implementations
A blanket implementation applies a trait to every type that satisfies a given bound, rather than to one specific type. This is how the standard library propagates behavior automatically — impl<T: Display> ToString for T is why every type that implements Display also gets a .to_string() method for free. You can use the same technique to extend your own traits.
use std::fmt::Display;
trait Printable {
fn print(&self);
}
// Blanket impl: every type that implements Display also gets Printable
// This one impl covers i32, f64, &str, String, and any future Display type
impl<T: Display> Printable for T {
fn print(&self) {
println!("{}", self);
}
}
fn main() {
42.print(); // 42
"hello".print(); // hello
3.14_f64.print(); // 3.14
}
Type Inference with Generics
Rust’s type inference works across generic boundaries. The compiler tracks how values are used and resolves type parameters without you needing to spell them out. You only need explicit annotations when the compiler has multiple valid choices and cannot determine which one you intend — such as an empty collection or a method that could return different types.
fn main() {
// Type inferred from the push calls — no annotation needed
let mut v = Vec::new();
v.push(1_i32);
v.push(2);
// Annotation required when inference doesn't have enough information
let empty: Vec<String> = Vec::new();
let parsed = "42".parse::<i32>().unwrap(); // turbofish syntax
let collected = (0..5).collect::<Vec<_>>(); // _ lets compiler infer element type
println!("{:?}", collected); // [0, 1, 2, 3, 4]
}