Rust · lesson 15 of 30
Beginner
15- 1 Introduction to Rust
- 2 Setting Up Rust
- 3 Variables in Rust
- 4 Data Types in Rust
- 5 Operators in Rust
- 6 Control Flow in Rust
- 7 Functions in Rust
- 8 Ownership in Rust
- 9 Borrowing and References
- 11 Structs in Rust
- 12 Enums in Rust
- 13 Pattern Matching in Rust
- 17 Collections in Rust
- 25 Testing in Rust
- 26 File I/O in Rust
Intermediate
9Advanced
6Generics in Rust
Write generic Rust functions, structs and enums: trait bounds, where clauses, const generics, blanket impls, and monomorphization, with real output.
By SkillByExample · Updated
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_i32(&ints));
println!("{}", largest(&ints));
println!("{}", largest(&floats));
println!("{}", largest(&chars));
}
$ rustc generics_generic_functions.rs && ./generics_generic_functions
100
100
9.1
y
largest_i32 and largest::<i32> agree on 100, but only the generic version also handles f64 and char — largest_i32(&floats) would be a type error. Note largest panics on an empty slice (&list[0]); returning Option<&T> is the robust version.
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();
let swapped = p.swap();
println!("{:?}", swapped);
let s = Pair::new("hello", "world");
s.display_largest();
}
$ rustc generics_generic_structs.rs && ./generics_generic_structs
largest: 10
Pair { first: 10, second: 5 }
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();
let sums = zip_with(vec![1, 2, 3], vec![10, 20, 30], |a, b| a + b);
println!("{:?}", sums);
}
$ rustc generics_multiple_generic_parameters.rs && ./generics_multiple_generic_parameters
name = Alice
[11, 22, 33]
where Clauses
where clauses move bounds out of the angle brackets so the signature stays readable, and they are the natural place for bounds on associated types such as I::Item. 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 reads best for bounds on associated types
fn process<I>(iter: I)
where
I: Iterator,
I::Item: Display, // a bound on the iterator's associated Item type
{
for item in iter {
println!("{}", item);
}
}
fn main() {
debug_display_inline("inline", Some(1));
debug_display(42, vec![1, 2, 3]);
process(vec!["a", "b", "c"].into_iter());
}
$ rustc generics_where_clauses.rs && ./generics_where_clauses
inline Some(1)
42 [1, 2, 3]
a
b
c
debug_display_inline and debug_display are the same function with the bounds written in two places — the compiler treats them identically. Since Rust 1.79 the associated-type bound can also be written inline as I: Iterator<Item: Display>; where I::Item: Display works on every Rust version.
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());
}
$ rustc generics_generic_enums.rs && ./generics_generic_enums
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 push(&mut self, byte: u8) -> bool {
if self.len == SIZE { return false; } // full — no reallocation possible
self.data[self.len] = byte;
self.len += 1;
true
}
fn contents(&self) -> &[u8] { &self.data[..self.len] }
}
fn main() {
println!("{}", sum_array([1, 2, 3, 4, 5]));
println!("{}", sum_array([10, 20]));
let mut buf = FixedBuffer::<2>::new();
println!("capacity: {}", buf.capacity());
for b in [7, 8, 9] {
println!("push {}: {}", b, buf.push(b));
}
println!("contents: {:?}", buf.contents());
}
$ rustc generics_const_generics.rs && ./generics_const_generics
15
30
capacity: 2
push 7: true
push 8: true
push 9: false
contents: [7, 8]
FixedBuffer::<2> and FixedBuffer::<1024> are different types, each with its capacity baked in; the third push returns false because a fixed array cannot grow. sum_array([10, 20]) and sum_array([1, 2, 3, 4, 5]) likewise instantiate two copies of sum_array, one for N = 2 and one for N = 5.
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:
println!("{}", add(1_i32, 2_i32)); // effectively fn add_i32(a: i32, b: i32) -> i32
println!("{}", add(1.0_f64, 2.5_f64)); // effectively fn add_f64(a: f64, b: f64) -> f64
// Each instantiation is its own function item with its own type
println!("{}", std::any::type_name_of_val(&add::<i32>));
println!("{}", std::any::type_name_of_val(&add::<f64>));
// No boxing, no vtable, no indirection — same speed as hand-written code
}
$ rustc generics_monomorphization_zero_cost_generics.rs && ./generics_monomorphization_zero_cost_generics
3
3.5
generics_monomorphization_zero_cost_generics::add<i32>
generics_monomorphization_zero_cost_generics::add<f64>
type_name_of_val shows that add::<i32> and add::<f64> are two distinct function items, each prefixed with the crate name (here the file name). It reports types, not machine code, but each distinct instantiation that is called is compiled separately — that is monomorphization.
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();
"hello".print();
3.14_f64.print();
}
$ rustc generics_blanket_implementations.rs && ./generics_blanket_implementations
42
hello
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!("{:?} {:?} {} {:?}", v, empty, parsed + 1, collected);
}
$ rustc generics_type_inference_with_generics.rs && ./generics_type_inference_with_generics
[1, 2] [] 43 [0, 1, 2, 3, 4]
v became Vec<i32> from its first push. empty would have been an error without its annotation — nothing in the program says what it holds — and parse needs the turbofish because str::parse can produce dozens of types.
Practice
-
Call
largest(&empty)withlet empty: Vec<i32> = Vec::new();. What happens? Then rewritelargestto returnOption<&T>.Solution
It compiles, then panics at runtime:
thread 'main' (7012) panicked at generics_empty.rs:2:24: index out of bounds: the len is 0 but the index is 0An
Option-returning version handles the empty case with?on the first element:fn largest<T: PartialOrd>(list: &[T]) -> Option<&T> { let mut iter = list.iter(); let mut largest = iter.next()?; for item in iter { if item > largest { largest = item; } } Some(largest) }largest(&empty)is nowNoneandlargest(&[3, 9, 4])isSome(9). -
Build
Pair { first: vec![1], second: vec![2] }and calldisplay_largest()on it. Why is the method “not found” when it plainly exists?Solution
error[E0599]: the method `display_largest` exists for struct `Pair<Vec<{integer}>>`, but its trait bounds were not satisfied ... note: trait bound `Vec<{integer}>: std::fmt::Display` was not satisfiedVecisPartialOrdbut notDisplay, and the method lives in animplblock that requires both.newandswapwould still work — they’re in the unboundedimpl<T> Pair<T>block. -
Rewrite
processwith the bound inline:fn process<I: Iterator<Item: Display>>(iter: I). Does it compile?Solution
Yes, on Rust 1.79 or newer —
process(vec![1.5, 2.5].into_iter())prints1.5and2.5. This “associated type bound” syntax is equivalent to thewhere I::Item: Displayversion. -
Call
sum_array(vec![1, 2, 3]). Why doesn’t a const-generic array parameter accept aVec?Solution
error[E0308]: mismatched types ... = note: expected array `[i32; _]` found struct `Vec<{integer}>`Nmust be known at compile time, and aVec’s length is only known at runtime. Take a slice (&[i32]) if you need to accept both.