Rust - Generics (Basics)
Overview
Estimated time: 45–60 minutes
Master Rust's generic programming features to write flexible, reusable code without sacrificing performance. Learn generic functions, structs, enums, and how Rust achieves zero-cost abstractions.
Learning Objectives
- Write generic functions with type parameters
- Create generic structs and enums
- Understand monomorphization and zero-cost abstractions
- Use generic constraints with trait bounds
- Apply generics to solve real-world problems
Prerequisites
Generic Functions
Generics allow you to write code that works with multiple types:
// Without generics - specific to 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 with 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 result = largest(&numbers);
println!("The largest number is {}", result);
let chars = vec!['y', 'm', 'a', 'q'];
let result = largest(&chars);
println!("The largest char is {}", result);
let strings = vec!["apple", "banana", "cherry"];
let result = largest(&strings);
println!("The largest string is {}", result);
}
Expected output:
The largest number is 100
The largest char is y
The largest string is cherry
Multiple Type Parameters
fn compare_and_display<T, U>(x: T, y: U)
where
T: std::fmt::Display + PartialOrd<U>,
U: std::fmt::Display,
{
if x > y {
println!("{} is greater than {}", x, y);
} else if x < y {
println!("{} is less than {}", x, y);
} else {
println!("{} is equal to {}", x, y);
}
}
fn pair<T, U>(x: T, y: U) -> (T, U) {
(x, y)
}
fn main() {
compare_and_display(5, 3);
compare_and_display(2.5, 3);
let integer_pair = pair(10, 20);
let mixed_pair = pair("hello", 42);
let string_pair = pair("world".to_string(), "rust".to_string());
println!("Integer pair: {:?}", integer_pair);
println!("Mixed pair: {:?}", mixed_pair);
println!("String pair: {:?}", string_pair);
}
Expected output:
5 is greater than 3
2.5 is less than 3
Integer pair: (10, 20)
Mixed pair: ("hello", 42)
String pair: ("world", "rust")
Generic Structs
// Generic struct with one type parameter
struct Point<T> {
x: T,
y: T,
}
impl<T> Point<T> {
fn new(x: T, y: T) -> Self {
Point { x, y }
}
fn x(&self) -> &T {
&self.x
}
fn y(&self) -> &T {
&self.y
}
}
// Specific implementation for f64
impl Point<f64> {
fn distance_from_origin(&self) -> f64 {
(self.x.powi(2) + self.y.powi(2)).sqrt()
}
}
// Generic struct with multiple type parameters
struct Pair<T, U> {
first: T,
second: U,
}
impl<T, U> Pair<T, U> {
fn new(first: T, second: U) -> Self {
Pair { first, second }
}
fn into_tuple(self) -> (T, U) {
(self.first, self.second)
}
}
// Method with different generic types
impl<T, U> Pair<T, U> {
fn mixup<V, W>(self, other: Pair<V, W>) -> Pair<T, W> {
Pair {
first: self.first,
second: other.second,
}
}
}
fn main() {
let integer_point = Point::new(5, 10);
let float_point = Point::new(3.0, 4.0);
println!("Integer point: ({}, {})", integer_point.x(), integer_point.y());
println!("Float point: ({}, {})", float_point.x(), float_point.y());
println!("Distance from origin: {:.2}", float_point.distance_from_origin());
let pair1 = Pair::new("Hello", 42);
let pair2 = Pair::new(true, 3.14);
let mixed = pair1.mixup(pair2);
println!("Mixed pair: {:?}", mixed.into_tuple());
}
Expected output:
Integer point: (5, 10)
Float point: (3, 4)
Distance from origin: 5.00
Mixed pair: ("Hello", 3.14)
Generic Enums
You've already seen generic enums like Option<T>
and Result<T,E>
:
// Custom generic enum
enum Container<T> {
Empty,
Single(T),
Multiple(Vec<T>),
}
impl<T> Container<T> {
fn new() -> Self {
Container::Empty
}
fn add_single(item: T) -> Self {
Container::Single(item)
}
fn add_multiple(items: Vec<T>) -> Self {
Container::Multiple(items)
}
fn count(&self) -> usize {
match self {
Container::Empty => 0,
Container::Single(_) => 1,
Container::Multiple(items) => items.len(),
}
}
}
impl<T: std::fmt::Display> Container<T> {
fn display(&self) {
match self {
Container::Empty => println!("Container is empty"),
Container::Single(item) => println!("Container has one item: {}", item),
Container::Multiple(items) => {
print!("Container has {} items: ", items.len());
for (i, item) in items.iter().enumerate() {
if i > 0 { print!(", "); }
print!("{}", item);
}
println!();
}
}
}
}
fn main() {
let empty: Container<i32> = Container::new();
let single = Container::add_single("Hello");
let multiple = Container::add_multiple(vec![1, 2, 3, 4, 5]);
empty.display();
single.display();
multiple.display();
println!("Counts: {}, {}, {}", empty.count(), single.count(), multiple.count());
}
Expected output:
Container is empty
Container has one item: Hello
Container has 5 items: 1, 2, 3, 4, 5
Counts: 0, 1, 5
Generic Constraints and Trait Bounds
use std::fmt::Display;
// Generic function with trait bounds
fn print_and_compare<T>(a: &T, b: &T)
where
T: Display + PartialOrd,
{
println!("Comparing {} and {}", a, b);
if a > b {
println!("{} is greater", a);
} else if a < b {
println!("{} is greater", b);
} else {
println!("They are equal");
}
}
// Generic struct with constraints
struct Comparator<T>
where
T: PartialOrd + Clone,
{
items: Vec<T>,
}
impl<T> Comparator<T>
where
T: PartialOrd + Clone,
{
fn new() -> Self {
Comparator { items: Vec::new() }
}
fn add(&mut self, item: T) {
self.items.push(item);
}
fn find_max(&self) -> Option<T> {
if self.items.is_empty() {
None
} else {
let mut max = self.items[0].clone();
for item in &self.items[1..] {
if item > &max {
max = item.clone();
}
}
Some(max)
}
}
fn sorted(&self) -> Vec<T> {
let mut sorted = self.items.clone();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
sorted
}
}
fn main() {
print_and_compare(&10, &20);
print_and_compare(&"apple", &"banana");
let mut int_comparator = Comparator::new();
int_comparator.add(5);
int_comparator.add(2);
int_comparator.add(8);
int_comparator.add(1);
if let Some(max) = int_comparator.find_max() {
println!("Maximum: {}", max);
}
println!("Sorted: {:?}", int_comparator.sorted());
let mut float_comparator = Comparator::new();
float_comparator.add(3.14);
float_comparator.add(2.71);
float_comparator.add(1.41);
println!("Float sorted: {:?}", float_comparator.sorted());
}
Expected output:
Comparing 10 and 20
20 is greater
Comparing apple and banana
banana is greater
Maximum: 8
Sorted: [1, 2, 5, 8]
Float sorted: [1.41, 2.71, 3.14]
Real-World Example: Generic Data Structures
use std::fmt::Display;
// Generic stack implementation
struct Stack<T> {
items: Vec<T>,
}
impl<T> Stack<T> {
fn new() -> Self {
Stack { items: Vec::new() }
}
fn push(&mut self, item: T) {
self.items.push(item);
}
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()
}
fn len(&self) -> usize {
self.items.len()
}
}
impl<T: Display> Stack<T> {
fn display(&self) {
if self.is_empty() {
println!("Stack is empty");
} else {
print!("Stack (top to bottom): ");
for (i, item) in self.items.iter().rev().enumerate() {
if i > 0 { print!(" -> "); }
print!("{}", item);
}
println!();
}
}
}
// Generic cache with limited capacity
struct Cache<K, V>
where
K: Eq + std::hash::Hash + Clone,
V: Clone,
{
map: std::collections::HashMap<K, V>,
capacity: usize,
access_order: Vec<K>,
}
impl<K, V> Cache<K, V>
where
K: Eq + std::hash::Hash + Clone,
V: Clone,
{
fn new(capacity: usize) -> Self {
Cache {
map: std::collections::HashMap::new(),
capacity,
access_order: Vec::new(),
}
}
fn get(&mut self, key: &K) -> Option<&V> {
if let Some(value) = self.map.get(key) {
// Move to end (most recently used)
if let Some(pos) = self.access_order.iter().position(|k| k == key) {
let key = self.access_order.remove(pos);
self.access_order.push(key);
}
Some(value)
} else {
None
}
}
fn put(&mut self, key: K, value: V) {
if self.map.contains_key(&key) {
// Update existing
self.map.insert(key.clone(), value);
// Move to end
if let Some(pos) = self.access_order.iter().position(|k| k == &key) {
self.access_order.remove(pos);
}
self.access_order.push(key);
} else {
// Add new
if self.map.len() >= self.capacity {
// Remove least recently used
if let Some(oldest_key) = self.access_order.first().cloned() {
self.map.remove(&oldest_key);
self.access_order.remove(0);
}
}
self.map.insert(key.clone(), value);
self.access_order.push(key);
}
}
}
fn main() {
// Generic stack usage
let mut int_stack = Stack::new();
int_stack.push(1);
int_stack.push(2);
int_stack.push(3);
int_stack.display();
println!("Popped: {:?}", int_stack.pop());
int_stack.display();
let mut string_stack = Stack::new();
string_stack.push("first".to_string());
string_stack.push("second".to_string());
string_stack.display();
// Generic cache usage
let mut cache = Cache::new(3);
cache.put("key1", "value1");
cache.put("key2", "value2");
cache.put("key3", "value3");
println!("Cache get key1: {:?}", cache.get(&"key1"));
// This will evict key2 (least recently used)
cache.put("key4", "value4");
println!("Cache get key2 (evicted): {:?}", cache.get(&"key2"));
println!("Cache get key4: {:?}", cache.get(&"key4"));
}
Expected output:
Stack (top to bottom): 3 -> 2 -> 1
Popped: Some(3)
Stack (top to bottom): 2 -> 1
Stack (top to bottom): second -> first
Cache get key1: Some("value1")
Cache get key2 (evicted): None
Cache get key4: Some("value4")
Performance: Zero-Cost Abstractions
Rust generics are compiled away through monomorphization - no runtime cost:
// This generic function...
fn add<T: std::ops::Add<Output = T>>(a: T, b: T) -> T {
a + b
}
fn main() {
let int_result = add(5, 10);
let float_result = add(3.14, 2.86);
println!("Int: {}, Float: {}", int_result, float_result);
}
// ...is compiled to something equivalent to:
// fn add_i32(a: i32, b: i32) -> i32 { a + b }
// fn add_f64(a: f64, b: f64) -> f64 { a + b }
Common Pitfalls
1. Missing Trait Bounds
// This won't compile
fn print_max<T>(a: T, b: T) {
println!("{}", if a > b { a } else { b }); // Error: can't compare T
}
// Correct version
fn print_max<T: PartialOrd + Display>(a: T, b: T) {
println!("{}", if a > b { a } else { b });
}
2. Overly Complex Constraints
// Hard to read
fn complex<T: Clone + Debug + Display + PartialEq + PartialOrd>(item: T) {
// ...
}
// Better with where clause
fn complex<T>(item: T)
where
T: Clone + Debug + Display + PartialEq + PartialOrd,
{
// ...
}
Best Practices
- Use meaningful names -
T
for type,K
for key,V
for value - Add constraints only when needed - Don't over-constrain your generics
- Use where clauses - For complex trait bounds
- Consider lifetime parameters - When your generics hold references
- Test with multiple types - Ensure your generic code works as expected
Checks for Understanding
Question 1: Generic vs Specific
When would you choose a generic function over multiple specific functions?
Answer
Use generics when the algorithm is the same for different types, reducing code duplication and maintenance burden. Use specific functions when types need different implementations or when the generic constraints become too complex.
Question 2: Performance
Do Rust generics have runtime performance costs?
Answer
No, Rust generics have zero runtime cost due to monomorphization - the compiler generates specific versions for each type used, eliminating any indirection or type checking at runtime.
Question 3: Trait Bounds
Why are trait bounds necessary for generic parameters?
Answer
Trait bounds tell the compiler what operations are available on the generic type. Without them, you can only use operations available to all types (like moving and dropping), not type-specific operations like comparison or display.