Generics

Generics

1. Why generics

fn largest_i32(list: &[i32]) -> i32 {
    let mut max = list[0];
    for &x in list {
        if x > max { max = x; }
    }
    max
}

fn largest_char(list: &[char]) -> char {
    let mut max = list[0];
    for &x in list {
        if x > max { max = x; }
    }
    max
}

The two functions are identical except for one type. A generic replaces them with a single one:

fn largest<T: PartialOrd + Copy>(list: &[T]) -> T {
    let mut max = list[0];
    for &x in list {
        if x > max { max = x; }
    }
    max
}

let a = largest(&[3, 7, 2]);       // T = i32
let b = largest(&['a', 'z']);      // T = char

T is a type parameter. PartialOrd + Copy says what T must be able to do: compare itself (>) and copy itself (let mut max = list[0]).


2. Generic functions

2.1 — Syntax

fn identity<T>(x: T) -> T {
    x
}

fn pair<A, B>(a: A, b: B) -> (A, B) {
    (a, b)
}

fn first<T>(v: &[T]) -> Option<&T> {
    v.first()
}

Without a bound, you can do almost nothing with a T: move it, return it, store it. Not print it, compare it or copy it.

2.2 — Specifying the type at the call

let a = identity(5);                  // inferred: i32
let b = identity::<u8>(5);            // turbofish
let c: u64 = identity(5);             // inferred from the expected type

let v = Vec::<String>::new();         // turbofish on a type
let n = "42".parse::<i32>();

The turbofish (::<T>) is used when nothing lets the compiler guess the type.


3. Bounds (trait bounds)

A bound says: "T must implement this trait". It opens up the operations of that trait in the body of the function.

use std::fmt::Display;

fn show<T: Display>(x: T) {
    println!("{x}");              // allowed thanks to Display
}

fn show_twice<T: Display + Clone>(x: T) {
    let copy = x.clone();         // allowed thanks to Clone
    println!("{x} {copy}");
}

Common bounds and what they allow

BoundAllows
Clone.clone()
Copycopying by assignment (let a = x; let b = x;)
Debug{:?}
Display{}, .to_string()
PartialEq==, !=
PartialOrd<, >, <=, >=
Ord.cmp(), .max(), sorting
Hash + EqHashMap key
DefaultT::default()
Add<Output = T>a + b
Into<String>x.into() to a String
AsRef<str>x.as_ref() to a &str
IntoIterator<Item = U>for x in t
Send, Syncpass or share between threads
'staticcontains no temporary reference

4. The three ways to write a bound

4.1 — The same function written three times

use std::fmt::{Debug, Display};

// 1. Inline
fn summary_v1<T: Display + Clone, U: Debug>(a: T, b: U) -> String {
    format!("{a} / {b:?}")
}

// 2. where clause: more readable when the bounds are long
fn summary_v2<T, U>(a: T, b: U) -> String
where
    T: Display + Clone,
    U: Debug,
{
    format!("{a} / {b:?}")
}

// 3. impl Trait as an argument: without naming the type
fn summary_v3(a: impl Display + Clone, b: impl Debug) -> String {
    format!("{a} / {b:?}")
}

4.2 — The differences

Inline / whereimpl Trait as an argument
Name the type in the body or elsewhereyes (T)no
Two arguments of the same typeyes (a: T, b: T)no: each impl is a separate type
Turbofish at the callpossibleimpossible
Bounds on something other than the parametersyes with where (Vec<T>: Debug)no
// Two parameters MUST have the same type: only the named form allows it
fn equal<T: PartialEq>(a: T, b: T) -> bool { a == b }

// Here a and b can be of different types
fn two(a: impl Display, b: impl Display) {}

4.3 — `where` on constructed types

use std::collections::HashMap;
use std::hash::Hash;

fn count<T>(items: &[T]) -> HashMap<&T, usize>
where
    T: Hash + Eq,
{
    let mut m = HashMap::new();
    for e in items {
        *m.entry(e).or_insert(0) += 1;
    }
    m
}

fn show_all<I>(it: I)
where
    I: IntoIterator,
    I::Item: std::fmt::Display,       // bound on the type of the elements
{
    for x in it {
        println!("{x}");
    }
}

4.4 — `impl Trait` as a return type

fn evens(limit: u32) -> impl Iterator<Item = u32> {
    (0..limit).filter(|x| x % 2 == 0)
}

As a return type, impl Trait means "one precise type, which I do not name". It is not a generic chosen by the caller: the function chooses, and it must always return the same type.


5. Generic structs and enums

5.1 — Struct

struct Point<T> {
    x: T,
    y: T,
}

let integers = Point { x: 5, y: 10 };
let floats = Point { x: 1.0, y: 4.0 };
// let mixed = Point { x: 5, y: 4.0 };   // ERROR: x and y must have the same T

struct Point2<T, U> {
    x: T,
    y: U,
}
let mixed = Point2 { x: 5, y: 4.0 };     // OK

5.2 — Enum

The two most used enums in Rust are generic:

enum Option<T> { Some(T), None }
enum Result<T, E> { Ok(T), Err(E) }
enum Tree<T> {
    Leaf(T),
    Node(Box<Tree<T>>, Box<Tree<T>>),
}

5.3 — Where to put the bound

// 1. On the struct: every use of SortedVec1<T> requires Ord, even those that do not sort
struct SortedVec1<T: Ord> { v: Vec<T> }

// 2. On the impl block only (recommended): the struct stays freely usable
struct SortedVec2<T> { v: Vec<T> }

impl<T: Ord> SortedVec2<T> {
    fn insert(&mut self, x: T) {
        let pos = self.v.binary_search(&x).unwrap_or_else(|p| p);
        self.v.insert(pos, x);
    }
}

Putting the bound on the struct forces you to repeat it everywhere the type appears (every impl, every function that receives it). Put it on the struct only if the type makes no sense without it.


6. Generic `impl` blocks

6.1 — For all `T`

impl<T> Point<T> {
    fn x(&self) -> &T {
        &self.x
    }
}

impl<T> declares T, Point<T> uses it. Without the <T> after impl, Rust would look for a type that is really named T.

6.2 — For a specific type

impl Point<f64> {
    fn distance_from_origin(&self) -> f64 {
        (self.x * self.x + self.y * self.y).sqrt()
    }
}

let p = Point { x: 3.0, y: 4.0 };
p.distance_from_origin();            // 5.0
let q = Point { x: 3, y: 4 };
// q.distance_from_origin();         // ERROR: only exists for Point<f64>

6.3 — A method with its own type parameters

impl<T> Point2<T, T> {
    fn mix<V>(self, other: Point2<V, V>) -> Point2<T, V> {
        Point2 { x: self.x, y: other.y }
    }
}

T comes from the type, V belongs to the method and is chosen at each call.


7. Conditional methods and blanket implementations

7.1 — Methods that exist only if `T` allows it

use std::fmt::Display;

struct Pair<T> { a: T, b: T }

impl<T> Pair<T> {
    fn new(a: T, b: T) -> Self { Self { a, b } }       // for all T
}

impl<T: Display + PartialOrd> Pair<T> {
    fn print_largest(&self) {                           // only if T is comparable and displayable
        if self.a >= self.b {
            println!("{}", self.a);
        } else {
            println!("{}", self.b);
        }
    }
}

Pair<Vec<i32>> exists, but does not have print_largest (Vec is not Display).

7.2 — Implementing a trait for every type that has another one

trait Summary {
    fn summary(&self) -> String;
}

impl<T: std::fmt::Display> Summary for T {
    fn summary(&self) -> String {
        let s = self.to_string();
        if s.chars().count() > 10 {
            format!("{}…", s.chars().take(10).collect::<String>())   // in characters, not bytes
        } else {
            s
        }
    }
}

println!("{}", 12345.summary());
println!("{}", "a fairly long text".summary());

This is a blanket implementation (blanket impl). The standard library does the same: impl<T: Display> ToString for T gives .to_string() to every displayable type. You can write only one per trait and per form: it then prevents you from implementing the same trait by hand for a Display type.


8. Monomorphization

8.1 — What the compiler does

fn double<T: std::ops::Add<Output = T> + Copy>(x: T) -> T { x + x }

double(2_i32);
double(2.5_f64);

The compiler generates one copy of the function per type used, as if you had written:

fn double_i32(x: i32) -> i32 { x + x }
fn double_f64(x: f64) -> f64 { x + x }

8.2 — Consequences

AdvantageCost
no runtime cost: each copy is optimised for its typebigger binary (one copy per type)
direct calls, often inlinedlonger compilation

8.3 — Reducing duplication

// The whole function is duplicated for each type that implements AsRef<Path>
pub fn read_v1<P: AsRef<std::path::Path>>(path: P) -> std::io::Result<String> {
    let path = path.as_ref();
    // ... fifty lines ...
    std::fs::read_to_string(path)
}

// Only the conversion is generic; the body is compiled once
pub fn read_v2<P: AsRef<std::path::Path>>(path: P) -> std::io::Result<String> {
    fn inner(path: &std::path::Path) -> std::io::Result<String> {
        // ... fifty lines ...
        std::fs::read_to_string(path)
    }
    inner(path.as_ref())
}

The standard library uses this technique (std::fs::read_to_string itself).


9. Generic or `dyn Trait`

The same function, which adds up a list of shapes, written both ways.

trait Shape {
    fn area(&self) -> f64;
}

struct Square(f64);
struct Circle(f64);
impl Shape for Square { fn area(&self) -> f64 { self.0 * self.0 } }
impl Shape for Circle { fn area(&self) -> f64 { 3.14159 * self.0 * self.0 } }

// 1. Generic: all the elements have THE SAME type
fn total_generic<S: Shape>(shapes: &[S]) -> f64 {
    shapes.iter().map(|s| s.area()).sum()
}

// 2. dyn Trait: each element can have a different type
fn total_dyn(shapes: &[Box<dyn Shape>]) -> f64 {
    shapes.iter().map(|s| s.area()).sum()
}

fn main() {
    total_generic(&[Square(1.0), Square(2.0)]);
    // total_generic(&[Square(1.0), Circle(2.0)]);   // ERROR: different types
    total_dyn(&[Box::new(Square(1.0)), Box::new(Circle(2.0))]);
}
Generic <T: Trait>dyn Trait
Choice of typeat compile timeat runtime
Collection of mixed typesnoyes
Call costdirect, inlinableindirect (method table)
Binary sizeone copy per typea single copy
Constraints on the traitnonethe trait must be "dyn-compatible" (see box-dynamiques)

Default: generic. Use dyn when types are mixed, chosen at runtime, or when binary size matters.


10. Const generics

A generic parameter can be a constant value (integer, bool, char) instead of a type.

fn sum<const N: usize>(t: [i32; N]) -> i32 {
    t.iter().sum()
}

sum([1, 2, 3]);           // N = 3
sum([1, 2, 3, 4, 5]);     // N = 5

struct Buffer<const SIZE: usize> {
    data: [u8; SIZE],
    length: usize,
}

impl<const SIZE: usize> Buffer<SIZE> {
    fn new() -> Self {
        Self { data: [0; SIZE], length: 0 }
    }
    fn capacity(&self) -> usize { SIZE }
}

let t = Buffer::<64>::new();

What it is for: fixed-size arrays without allocation, matrices whose dimensions are checked at compile time, buffers of known size in embedded code. Before const generics, the standard library only implemented some traits for arrays of up to 32 elements.


11. Default parameters and `PhantomData`

11.1 — Default type

struct Counter<T = u32> {
    value: T,
}

let a: Counter = Counter { value: 5 };          // T = u32
let b: Counter<u64> = Counter { value: 5 };

This is how HashMap<K, V, S = RandomState> lets you choose the hashing algorithm without imposing it on everyone.

11.2 — `PhantomData`: a parameter without data

use std::marker::PhantomData;

struct Id<T> {
    value: i64,
    _type: PhantomData<T>,       // Id<T> "carries" T without storing it
}

struct User;
struct Order;

impl<T> Id<T> {
    fn new(value: i64) -> Self { Self { value, _type: PhantomData } }
}

fn cancel(_id: Id<Order>) {}

fn main() {
    let u: Id<User> = Id::new(1);
    // cancel(u);                   // ERROR: Id<User> is not Id<Order>
    cancel(Id::new(7));
}

Rust rejects an unused type parameter (parameter T is never used). PhantomData<T> uses it without taking up any memory. What it is for: telling apart at compile time values with the same representation (identifiers, units of measure, builder states).


12. `?Sized`

By default, every type parameter must have a size known at compile time (implicit T: Sized). str, [T] and dyn Trait do not.

fn length_v1<T: AsRef<str>>(x: T) -> usize { x.as_ref().len() }

// Accept &str directly as a &T with T = str
fn show<T: std::fmt::Display + ?Sized>(x: &T) {
    println!("{x}");
}

show("text");          // T = str: possible thanks to ?Sized
show(&5);

?Sized lifts the implicit bound. You can then only handle T behind a reference or a pointer (&T, Box<T>).


13. The same function written in several ways

Find the largest element of a list.

// 1. Copy: returns a copy, reserved for small types
fn max_v1<T: PartialOrd + Copy>(v: &[T]) -> Option<T> {
    let mut iter = v.iter().copied();
    let mut max = iter.next()?;
    for x in iter {
        if x > max { max = x; }
    }
    Some(max)
}

// 2. Reference: also works with String, without copying
fn max_v2<T: PartialOrd>(v: &[T]) -> Option<&T> {
    let mut max = v.first()?;
    for x in v {
        if x > max { max = x; }
    }
    Some(max)
}

// 3. Ord + standard library
fn max_v3<T: Ord>(v: &[T]) -> Option<&T> {
    v.iter().max()
}

// 4. Any iterator, not only a slice
fn max_v4<I>(it: I) -> Option<I::Item>
where
    I: IntoIterator,
    I::Item: Ord,
{
    it.into_iter().max()
}

fn main() {
    max_v1(&[3, 9, 2]);                     // Some(9)
    max_v2(&[String::from("a"), String::from("b")]);
    max_v3(&["apple", "kiwi"]);
    max_v4(vec![1, 5, 3]);
    max_v4((1..10).map(|x| x * x % 7));
    max_v2(&[1.5, 0.2]);                    // f64: PartialOrd is enough for max_v2
    // max_v3(&[1.5, 0.2]);                 // ERROR: f64 is not Ord
}

All of them return an Option: an empty list has no maximum, whereas the version in section 1 panicked on list[0].


14. Common errors

14.1 — Operation without a bound

// fn sum<T>(a: T, b: T) -> T { a + b }
// ERROR E0369: cannot add `T` to `T`
use std::ops::Add;
fn sum<T: Add<Output = T>>(a: T, b: T) -> T { a + b }

The compiler often suggests the missing bound in its message (consider restricting type parameter T).

14.2 — Method not found

// fn show<T>(x: T) { println!("{}", x); }
// ERROR E0277: `T` doesn't implement `std::fmt::Display`
fn show<T: std::fmt::Display>(x: T) { println!("{x}"); }

14.3 — Type that cannot be guessed

// let v = Vec::new();            // ERROR E0282: type annotations needed
let v: Vec<i32> = Vec::new();
let w = Vec::<i32>::new();

14.4 — Moving out of a slice

// fn first<T>(v: &[T]) -> T { v[0] }   // ERROR: cannot move out of index
fn first_ref<T>(v: &[T]) -> &T { &v[0] }
fn first_copy<T: Copy>(v: &[T]) -> T { v[0] }
fn first_clone<T: Clone>(v: &[T]) -> T { v[0].clone() }

15. Exercises

Exercise 1 — generic stack

Write Stack<T> with new, push, pop -> Option<T>, peek -> Option<&T> and is_empty.

// Solution:
struct Stack<T> {
    items: Vec<T>,
}

impl<T> Stack<T> {
    fn new() -> Self { Self { items: Vec::new() } }
    fn push(&mut self, x: T) { self.items.push(x); }
    fn pop(&mut self) -> Option<T> { self.items.pop() }
    fn peek(&self) -> Option<&T> { self.items.last() }
    fn is_empty(&self) -> bool { self.items.is_empty() }
}

Exercise 2 — conditional method

Add to Stack<T> a print method that only exists if T: Debug.

// Solution:
impl<T: std::fmt::Debug> Stack<T> {
    fn print(&self) {
        println!("{:?}", self.items);
    }
}

Exercise 3 — generic average

Write an average for any type convertible to f64 without loss (i32, u8, f32...).

// Solution:
fn average<T: Copy + Into<f64>>(v: &[T]) -> Option<f64> {
    if v.is_empty() {
        return None;
    }
    let total: f64 = v.iter().map(|&x| -> f64 { x.into() }).sum();   // target type written out: sum would have two choices
    Some(total / v.len() as f64)
}

average(&[1_i32, 2, 3]);      // Some(2.0)
average(&[1.5_f32, 2.5]);     // Some(2.0)
// average(&[1_i64]);         // ERROR: i64 -> f64 can lose precision, no From

16. Cheat sheet

fn f<T>(x: T) -> T                         // generic
fn f<T: A + B>(x: T)                       // inline bounds
fn f<T>(x: T) where T: A + B               // where
fn f(x: impl A + B)                        // impl Trait as an argument
fn f() -> impl Iterator<Item = u32>        // impl Trait as a return type
struct S<T> { x: T }                       // generic struct
impl<T> S<T> { }                           // for all T
impl S<f64> { }                            // for a specific type
impl<T: Display> S<T> { }                  // conditional methods
impl<T: Display> MyTrait for T { }         // blanket implementation
fn f<const N: usize>(t: [u8; N])           // const generic
struct S<T = u32>                          // default type
PhantomData<T>                             // parameter without data
fn f<T: ?Sized>(x: &T)                     // accepts str, [T], dyn Trait
f::<u8>(5)                                 // turbofish

Key points

  • A bound opens up operations: without it, a T can do almost nothing.
  • where for readability, impl Trait when you do not need to name the type.
  • Put bounds on the impl block, not on the struct.
  • A generic is copied for each type: fast at runtime, heavier to compile.
  • Generic by default, dyn Trait to mix types at runtime.
  • PhantomData tells types apart at no memory cost.