Skip to main content
Rust intermediate Lesson 15 of 30

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]
}

Frequently Asked Questions

What is monomorphization?
Monomorphization is the process by which the compiler generates a concrete version of a generic function or struct for each type it is used with. It produces zero-cost abstractions — generics have no runtime overhead.
What is a where clause?
A where clause lists trait bounds after the function signature. It is equivalent to inline bounds but is easier to read when there are many bounds or complex types.
Can a generic type have multiple trait bounds?
Yes. Use + to combine bounds: T: Display + Clone + PartialOrd.