Generics in Rust: Monomorphization & vs dyn Trait

Max WellsMax WellsFounder of Rustify

TL;DR: Generics in Rust let you write functions, structs, enums, and traits that work over many concrete types, specified as type parameters like <T>. At compile time, Rust performs monomorphization: for each concrete type T is used with, it generates a specialized copy of the code. The result is zero-cost generics, the flexibility of generic code with the performance of hand-written type-specific code. No runtime overhead, no boxing, no virtual dispatch (unless you explicitly use dyn Trait).


What Are Generics in Rust?

Generics let you parameterize types and functions over other types, writing one implementation that works for many concrete types, resolved at compile time.

// Without generics; need separate functions for each type
fn largest_i32(list: &[i32]) -> i32 { /* ... */ }
fn largest_f64(list: &[f64]) -> f64 { /* ... */ }
 
// With generics; one function, any comparable type
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 numbers = vec![34, 50, 25, 100, 65];
    let chars   = vec!['y', 'm', 'a', 'q'];
 
    println!("{}", largest(&numbers)); // 100
    println!("{}", largest(&chars));   // y
}

<T: PartialOrd> is a trait bound, it constrains T to types that support >. Without it, the compiler rejects item > largest because it doesn't know T supports comparison.


How Do Generics Work in Structs and Enums?

Structs and enums can be parameterized over one or more type parameters, the standard library's Vec<T>, Option<T>, Result<T, E>, and HashMap<K, V> are all generic types.

// Generic struct
struct Pair<T> {
    first: T,
    second: T,
}
 
impl<T: std::fmt::Display + PartialOrd> Pair<T> {
    fn cmp_display(&self) {
        if self.first >= self.second {
            println!("Largest: {}", self.first);
        } else {
            println!("Largest: {}", self.second);
        }
    }
}
 
// Generic enum; Option and Result are defined exactly like this:
enum MyOption<T> {
    Some(T),
    None,
}
 
enum MyResult<T, E> {
    Ok(T),
    Err(E),
}

What Are Trait Bounds and Where Clauses?

Trait bounds constrain what types a type parameter T can be, they tell the compiler what capabilities T must have for the generic code to be valid.

use std::fmt::{Debug, Display};
 
// Inline bounds; T must implement both Display and Debug
fn print_both<T: Display + Debug>(value: T) {
    println!("{value}; {:?}", value);
}
 
// Where clause; cleaner for complex bounds
fn compare_and_log<T, U>(t: T, u: U) -> String
where
    T: Display + Clone,
    U: Debug + Clone,
{
    format!("{t} vs {u:?}")
}

where clauses are stylistically preferred when there are multiple type parameters or complex bounds, they keep the function signature readable.


What Is Monomorphization?

Monomorphization is the compiler process of generating a separate, specialized version of generic code for each concrete type it is called with, producing zero-overhead abstractions.

fn add<T: std::ops::Add<Output = T>>(a: T, b: T) -> T {
    a + b
}
 
// After monomorphization, the compiler generates (conceptually):
fn add_i32(a: i32, b: i32) -> i32 { a + b }
fn add_f64(a: f64, b: f64) -> f64 { a + b }

This is in contrast to Java/C# generics which use type erasure (boxing at runtime) or Go generics which have a dictionary-passing overhead. Rust generics are as fast as C++ templates.


What Is the Difference Between Generics and dyn Trait?

Generics (impl Trait / <T: Trait>) resolve at compile time, one type per call site, inlined, zero-cost. dyn Trait uses runtime dynamic dispatch, a single pointer to any type, with virtual call overhead.

// Static dispatch; monomorphized, zero overhead
fn process_static<T: Iterator<Item = i32>>(iter: T) -> i32 {
    iter.sum()
}
 
// Dynamic dispatch; single function, runtime vtable lookup
fn process_dynamic(iter: &mut dyn Iterator<Item = i32>) -> i32 {
    iter.sum()
}
 
fn main() {
    // Static: different compiled versions for each iterator type
    process_static(vec![1, 2, 3].into_iter());
    process_static(0..10);
 
    // Dynamic: one compiled version, accepts any iterator at runtime
    let mut v = vec![1, 2, 3].into_iter();
    process_dynamic(&mut v);
}

Use generics by default. Use dyn Trait (trait objects) when you need to store heterogeneous types in a collection or return different types from a function without generics making the API unwieldy.


Frequently Asked Questions

Both use static dispatch. <T: Trait> is a type parameter, callers can specify T explicitly and the function can use T in multiple places. impl Trait in argument position is shorthand for an unnamed generic. impl Trait in return position means "returns some type implementing Trait", useful for returning closures and iterators without naming the concrete type.

Const generics allow type parameters to be constant values (not just types): fn zeros<const N: usize>() -> [f64; N]. This enables array sizes and other integer parameters to be generic, used heavily in numerical and embedded code.

Yes: struct Map<K, V>, fn zip<A, B>(a: A, b: B) -> (A, B). There is no practical limit. Trait bounds apply to each independently or jointly.

Some traits cannot be used as dyn Trait, for example, traits with methods returning Self or with generic methods. These can still be used with static generics (<T: Trait>), just not as runtime trait objects.


Sources


  • Trait: Trait bounds are what make generics useful
  • Iterator: Iterator adapters are all generic over closure types
  • Struct: Structs can be parameterized with generics
  • Enum: Option<T> and Result<T, E> are generic enums
  • Const Generics: Compile-time numeric parameters extend Rust's generic system
  • HashMap: HashMap<K, V> is one of the most common generic collections in Rust

Keep Reading

Ready to Land a $80-120k Rust Job?