EthicsComputerCourses
Join training +91 90532 10052

Generics & Trait Bounds

Rust · Lesson 22 of 43 Intermediate 6 min read Updated

Imagine you write a function that counts how many marks in a list are above a limit. Next week you need the same thing for heights (decimals), then for names in alphabetical order. Copying the function three times with different types would work, but every bug fix would have to be made three times.

Generics let you write the function once with a placeholder type, and trait bounds say what that type must be able to do. Rust then produces fast, type-specific code for each use. You have already used generic types: Vec<T>, Option<T> and Result<T, E>.

Generic functions

A generic function declares type parameters in angle brackets after its name. By convention they are single capital letters, usually T:

Rust
use std::fmt::Display;

fn count_above<T: PartialOrd>(items: &[T], limit: T) -> usize {
    let mut count = 0;
    for item in items {
        if *item > limit {
            count += 1;
        }
    }
    count
}

fn show_all<T: Display>(label: &str, items: &[T]) {
    print!("{label}:");
    for item in items {
        print!(" {item}");
    }
    println!();
}

fn main() {
    let marks = [45, 82, 67, 91];
    let heights = [1.62, 1.75, 1.58];
    let names = ["Aarav", "Meera", "Kabir"];

    println!("{}", count_above(&marks, 60));
    println!("{}", count_above(&heights, 1.6));
    println!("{}", count_above(&names, "Isha"));
    show_all("Marks", &marks);
    show_all("Heights", &heights);
}
Output
3
2
2
Marks: 45 82 67 91
Heights: 1.62 1.75 1.58

T: PartialOrd is a trait bound: "T can be any type, as long as it implements PartialOrd", which is the trait behind < and >. Without the bound, the compiler would reject *item > limit, because not every type can be compared. For text, > compares alphabetically, so "Meera" and "Kabir" come after "Isha".

The bound is a promise checked when you call the function. count_above(&[Some(1)], ...) works because Option<i32> implements PartialOrd, but passing a type that does not implement it is a compile error that names the missing trait.

Generic structs and impl blocks

Structs and enums can be generic too. The impl block must declare the type parameter again with impl<T>. You can also write an impl block that only applies when T meets a bound, so some methods exist only for some types:

Rust
use std::fmt::Display;

struct Shelf<T> {
    items: Vec<T>,
    capacity: usize,
}

impl<T> Shelf<T> {
    fn new(capacity: usize) -> Self {
        Self { items: Vec::new(), capacity }
    }

    fn add(&mut self, item: T) -> Result<(), T> {
        if self.items.len() == self.capacity {
            return Err(item); // shelf is full: give the item back
        }
        self.items.push(item);
        Ok(())
    }
}

impl<T: Display> Shelf<T> {
    fn print(&self) {
        for (i, item) in self.items.iter().enumerate() {
            println!("{}: {item}", i + 1);
        }
    }
}

fn main() {
    let mut books: Shelf<String> = Shelf::new(2);
    books.add(String::from("Wings of Fire")).unwrap();
    books.add(String::from("Malgudi Days")).unwrap();
    if let Err(book) = books.add(String::from("Godaan")) {
        println!("No space for {book}");
    }
    books.print();

    let mut jars = Shelf::new(3);
    jars.add(250).unwrap();
    jars.add(500).unwrap();
    jars.print();
}
Output
No space for Godaan
1: Wings of Fire
2: Malgudi Days
1: 250
2: 500

Shelf<String> and Shelf<i32> are two different types made from one definition. add returns Err(item) so that the caller gets the rejected item back instead of losing it. print lives in a separate impl<T: Display> block, so a Shelf of a type that cannot be displayed simply has no print method.

Several bounds and where clauses

Combine bounds with +. When the list gets long, move the bounds into a where clause after the signature, which keeps the first line readable:

Rust
use std::fmt::Debug;

fn best_entry<K, V>(entries: &[(K, V)]) -> Option<&(K, V)>
where
    K: Debug,
    V: PartialOrd,
{
    let mut best: Option<&(K, V)> = None;
    for entry in entries {
        match best {
            Some(current) if current.1 >= entry.1 => {}
            _ => best = Some(entry),
        }
    }
    best
}

fn main() {
    let sales = [("Rohtak", 340), ("Jaipur", 515), ("Pune", 498)];
    println!("{:?}", best_entry(&sales));
    let empty: [(&str, u32); 0] = [];
    println!("{:?}", best_entry(&empty));
}
Output
Some(("Jaipur", 515))
None

The function returns a reference into the slice, wrapped in an Option for the empty case. K: Debug is needed only so that the caller can print the result with {:?}.

impl Trait as a shortcut

In argument position, item: &impl Display is a shorter way to write a generic parameter with a bound. In return position, -> impl Trait means "this function returns some type that implements the trait, without naming it". This is how functions return iterators and closures, whose real types are long or cannot be written at all:

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

fn main() {
    for n in even_numbers(8) {
        print!("{n} ");
    }
    println!();
}
Output
0 2 4 6 8 

You will learn about iterators and the |n| ... closure syntax in Iterators and Closures.

Const generics

Type parameters stand for types; const generics stand for constant values, most often array lengths. This lets one function accept arrays of any size while still knowing the size at compile time:

Rust
fn average<const N: usize>(marks: [u32; N]) -> f64 {
    let mut total = 0;
    for m in marks {
        total += m;
    }
    total as f64 / N as f64
}

fn main() {
    println!("{:.2}", average([70, 85, 90]));
    println!("{:.2}", average([60, 75, 80, 95, 100]));
}
Output
81.67
82.00

Zero cost: monomorphization

Generics cost nothing at run time. During compilation Rust looks at every concrete type you used and generates a separate copy of the generic code for it, a process called monomorphization. count_above above becomes three functions: one for i32, one for f64 and one for &str, each as fast as if you had written it by hand. The trade-off is longer compile times and a larger program when a generic is used with many types. When you sometimes need to name the type explicitly, use the turbofish: "42".parse::<u8>() or Vec::<u32>::with_capacity(10).

Common mistakes

  • Forgetting a bound. "binary operation > cannot be applied to type T" means you need T: PartialOrd. Add the trait the compiler names.
  • Forgetting <T> after impl. Write impl<T> Shelf<T>, not impl Shelf<T>.
  • Over-generalising. A function used with one type does not need to be generic. Add generics when you have a second real use.
  • Expecting impl Trait to return different types. Every return path of -> impl Trait must return the same concrete type; use Box<dyn Trait> when it varies.

Key points

  • Generics (fn f<T>, struct S<T>) let one definition work for many types.
  • Trait bounds (T: PartialOrd + Display, or a where clause) say what a type must support.
  • impl<T: Bound> Type<T> blocks add methods only for types that meet the bound.
  • impl Trait is shorthand in arguments and hides complex return types such as iterators.
  • Const generics (const N: usize) make array lengths generic, and monomorphization makes all generics zero-cost at run time.

Exercise

Write a generic function fn most_common<T: PartialEq + Copy>(items: &[T]) -> Option<T> that returns the value that appears most often (the first one in case of a tie), or None for an empty slice. Use two nested loops. Test it with the runs scored per ball [1, 4, 1, 0, 6, 1, 4] and with the characters of a word.

Show solution
Rust
fn most_common<T: PartialEq + Copy>(items: &[T]) -> Option<T> {
    let mut best: Option<(T, usize)> = None;
    for a in items {
        let mut count = 0;
        for b in items {
            if a == b {
                count += 1;
            }
        }
        match best {
            Some((_, best_count)) if best_count >= count => {}
            _ => best = Some((*a, count)),
        }
    }
    best.map(|(value, _)| value)
}

fn main() {
    println!("{:?}", most_common(&[1, 4, 1, 0, 6, 1, 4]));
    let letters: Vec<char> = "kanpur".chars().collect();
    println!("{:?}", most_common(&letters));
    let empty: [u8; 0] = [];
    println!("{:?}", most_common(&empty));
}
Output
Some(1)
Some('k')
None

PartialEq allows == and Copy allows returning a copy of the value with *a. In "kanpur" every letter appears once, so the first one, 'k', wins the tie. The final map drops the count and keeps only the value.

Quick Quiz

1. What does the bound in fn f<T: Display>(x: T) mean?

2. How does Rust make generic code as fast as non-generic code?

3. Which line correctly starts an impl block for a generic struct Shelf<T>?

4. What does fn sum<const N: usize>(a: [u32; N]) accept?

Learn Rust with a mentor

Free lessons get you started. Our Programming & Software Development training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call