Generics let you write code that works over many types without sacrificing performance or type safety. Instead of duplicating logic for i32, then f64, then String, you write it once with a type parameter T. The Rust compiler then generates the concrete versions at compile time — a process called monomorphization — so generics cost nothing at runtime.
Why Generics
Without generics, you’d write a separate max_i32, max_f64, and max_char function. With generics, one function handles all:
fn largest<T: PartialOrd>(list: &[T]) -> &T {
let mut largest = &list[0];
for item in list.iter() {
if item > largest {
largest = item;
}
}
largest
}
fn main() {
println!("{}", largest(&[34, 50, 25, 100, 65])); // 100
println!("{}", largest(&['y', 'm', 'a', 'q'])); // y
println!("{}", largest(&[3.14, 2.72, 1.41])); // 3.14
}
The T: PartialOrd bound tells the compiler that T must support the > operator. Without it, the compiler would reject the item > largest comparison — it can’t know whether T supports ordering.
Generic Structs
// Single type parameter
#[derive(Debug)]
struct Point<T> {
x: T,
y: T,
}
// Two type parameters — x and y can differ
#[derive(Debug)]
struct Pair<T, U> {
first: T,
second: U,
}
fn main() {
let int_point = Point { x: 5, y: 10 };
let float_point = Point { x: 1.5, y: 4.0 };
let mixed = Pair { first: "hello", second: 42 };
println!("{:?}", int_point); // Point { x: 5, y: 10 }
println!("{:?}", float_point);
println!("{:?}", mixed); // Pair { first: "hello", second: 42 }
}
Methods on Generic Structs
impl<T> methods are available for all T. You can also add impl blocks that only apply when T meets a bound:
use std::fmt::Display;
#[derive(Debug)]
struct Wrapper<T> {
value: T,
label: String,
}
impl<T> Wrapper<T> {
fn new(value: T, label: &str) -> Self {
Wrapper { value, label: label.to_string() }
}
fn value(&self) -> &T {
&self.value
}
}
// Only available when T implements Display
impl<T: Display> Wrapper<T> {
fn print(&self) {
println!("{}: {}", self.label, self.value);
}
}
// Only available for Wrapper<f64> specifically
impl Wrapper<f64> {
fn sqrt(&self) -> f64 {
self.value.sqrt()
}
}
fn main() {
let w = Wrapper::new(3.14f64, "pi");
w.print(); // pi: 3.14
println!("{}", w.sqrt()); // 1.7724...
let wi = Wrapper::new(42u32, "answer");
wi.print(); // answer: 42
// wi.sqrt(); // Error: not defined for Wrapper<u32>
}
Generic Enums
Option<T> and Result<T, E> are the most-used generic enums in all of Rust:
enum Option<T> {
Some(T),
None,
}
enum Result<T, E> {
Ok(T),
Err(E),
}
You can define your own:
#[derive(Debug)]
enum Either<L, R> {
Left(L),
Right(R),
}
impl<L: std::fmt::Display, R: std::fmt::Display> Either<L, R> {
fn value_str(&self) -> String {
match self {
Either::Left(l) => format!("Left({})", l),
Either::Right(r) => format!("Right({})", r),
}
}
}
fn divide(a: f64, b: f64) -> Either<f64, &'static str> {
if b == 0.0 {
Either::Right("division by zero")
} else {
Either::Left(a / b)
}
}
fn main() {
println!("{}", divide(10.0, 2.0).value_str()); // Left(5)
println!("{}", divide(1.0, 0.0).value_str()); // Right(division by zero)
}
Trait Bounds
Trait bounds constrain which types are allowed for a generic parameter. There are three syntaxes:
use std::fmt::{Debug, Display};
// Inline bound
fn print_all<T: Display + Debug>(items: &[T]) {
for item in items {
println!("{:?} → {}", item, item);
}
}
// `impl Trait` syntax (shorter for simple cases)
fn print_one(item: &impl Display) {
println!("{}", item);
}
// `where` clause (cleaner for complex bounds)
fn complex_function<T, U, V>(t: T, u: U, v: V) -> String
where
T: Display + Clone,
U: Debug + PartialOrd,
V: Into<String>,
{
format!("{} {:?}", t, u)
}
Bounds on Associated Types
When a trait has an associated type, you can bound it:
use std::iter::Iterator;
fn sum_positive<I>(iter: I) -> i64
where
I: Iterator<Item = i64>,
{
iter.filter(|&x| x > 0).sum()
}
fn main() {
let v = vec![-3i64, 1, -1, 4, -1, 5, 9, -2, 6];
println!("{}", sum_positive(v.into_iter())); // 25
}
Generic Functions Returning References: Lifetimes
When a generic function returns a reference, you often need lifetime annotations to tell the compiler how inputs and outputs relate:
// The returned reference lives at least as long as both inputs
fn longest<'a>(s1: &'a str, s2: &'a str) -> &'a str {
if s1.len() >= s2.len() { s1 } else { s2 }
}
fn main() {
let s1 = String::from("long string");
let result;
{
let s2 = String::from("xyz");
result = longest(s1.as_str(), s2.as_str());
println!("Longest: {}", result);
}
}
A Generic Stack Implementation
A practical example showing generics in action:
#[derive(Debug)]
struct Stack<T> {
elements: Vec<T>,
}
impl<T> Stack<T> {
fn new() -> Self {
Stack { elements: Vec::new() }
}
fn push(&mut self, item: T) {
self.elements.push(item);
}
fn pop(&mut self) -> Option<T> {
self.elements.pop()
}
fn peek(&self) -> Option<&T> {
self.elements.last()
}
fn is_empty(&self) -> bool {
self.elements.is_empty()
}
fn size(&self) -> usize {
self.elements.len()
}
}
// Additional method only when T: Display
impl<T: std::fmt::Display> Stack<T> {
fn print_all(&self) {
for (i, item) in self.elements.iter().enumerate() {
println!("[{}] {}", i, item);
}
}
}
fn main() {
let mut stack: Stack<i32> = Stack::new();
stack.push(1);
stack.push(2);
stack.push(3);
println!("Top: {:?}", stack.peek()); // Some(3)
println!("Size: {}", stack.size()); // 3
stack.print_all();
while let Some(val) = stack.pop() {
println!("Popped: {}", val);
}
}
Generic Type Aliases
Type aliases simplify complex generic types:
use std::collections::HashMap;
type Registry<T> = HashMap<String, Vec<T>>;
type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
fn load_registry() -> Result<Registry<u32>> {
let mut r: Registry<u32> = HashMap::new();
r.insert("admin".to_string(), vec![1, 2, 3]);
Ok(r)
}
Const Generics
Rust also supports const generics — type parameters that are values, not types. This is how fixed-size arrays work:
// A fixed-size buffer generic over its capacity
struct Buffer<const N: usize> {
data: [u8; N],
len: usize,
}
impl<const N: usize> Buffer<N> {
fn new() -> Self {
Buffer { data: [0u8; N], len: 0 }
}
fn push(&mut self, byte: u8) -> bool {
if self.len < N {
self.data[self.len] = byte;
self.len += 1;
true
} else {
false // Buffer full
}
}
fn as_slice(&self) -> &[u8] {
&self.data[..self.len]
}
fn capacity(&self) -> usize {
N
}
}
fn main() {
let mut buf: Buffer<4> = Buffer::new();
buf.push(1); buf.push(2); buf.push(3);
println!("{:?}", buf.as_slice()); // [1, 2, 3]
println!("capacity: {}", buf.capacity()); // 4
}
This is used extensively in embedded Rust where heap allocation is unavailable.
Monomorphization — Zero-Cost Generics
The compiler generates a separate, specialized copy of every generic function for each concrete type it’s called with:
// You write:
fn identity<T>(x: T) -> T { x }
// Compiler generates (conceptually):
fn identity_i32(x: i32) -> i32 { x }
fn identity_str(x: &str) -> &str { x }
fn identity_string(x: String) -> String { x }
This means:
- Generic code runs at full speed — no boxing, no virtual dispatch
- Binary size increases proportionally to the number of distinct type instantiations
- Compilation time increases with more instantiations
When binary size matters (embedded, WASM), use dyn Trait instead of generics for rarely-called code paths.
When to Use Generics vs Trait Objects
Generics (<T: Trait>) |
Trait objects (dyn Trait) |
|
|---|---|---|
| Dispatch | Static, compile-time | Dynamic, runtime vtable |
| Performance | Zero overhead | Small indirection cost |
| Mixed types in collection | No | Yes |
| Binary size | Larger (monomorphization) | Smaller (single impl) |
| Best for | Performance-critical paths | Flexibility, heterogeneous data |
Summary
- Generic functions, structs, and enums work over multiple types without code duplication
- Trait bounds (
T: Trait) constrain which types are valid whereclauses improve readability for complex bounds- Const generics allow size/value parameters in addition to type parameters
- Monomorphization makes generics zero-cost — identical performance to hand-written specific code
- Generics add binary size; use
dyn Traitwhen that matters more than raw speed
Comments