Series: Learn Rust from Scratch | Post 9 of 11
Introduction
So far, every function and struct we've written works with one specific type. But what if you want to write a function that finds the largest element in any list — whether it's a list of integers, floats, or characters?
That's where generics and traits come in.
- Generics let you write code that works for multiple types
- Traits define shared behavior that types can implement
Together, they're the foundation of Rust's type system and allow you to write code that is both flexible and safe.
Generics — Writing Code for Any Type
The Problem Without Generics
Imagine needing a largest function for both i32 and f64:
// Without generics — you'd need to write this twice!
fn largest_i32(list: &[i32]) -> i32 {
let mut largest = list[0];
for &item in list {
if item > largest {
largest = item;
}
}
largest
}
fn largest_f64(list: &[f64]) -> f64 {
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![34.5, 50.1, 25.0, 100.9];
println!("Largest int: {}", largest_i32(&ints));
println!("Largest float: {}", largest_f64(&floats));
}
Output:
Largest int: 100
Largest float: 100.9
This works, but it's repetitive. Generics let us write one version.
Generic Functions
Use <T> to declare a type parameter:
// T is a generic type parameter — it can be any type
// PartialOrd is a trait bound: T must support comparison with `>`
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![34.5, 50.1, 25.0, 100.9];
let chars = vec!['y', 'm', 'a', 'q'];
println!("Largest int: {}", largest(&ints));
println!("Largest float: {}", largest(&floats));
println!("Largest char: {}", largest(&chars));
}
Output:
Largest int: 100
Largest float: 100.9
Largest char: y
One function — works for all comparable types!
Generic Structs
#[derive(Debug)]
struct Pair<T> {
first: T,
second: T,
}
impl<T> Pair<T> {
fn new(first: T, second: T) -> Self {
Pair { first, second }
}
fn swap(&self) -> Pair<T>
where
T: Clone, // T must implement Clone
{
Pair {
first: self.second.clone(),
second: self.first.clone(),
}
}
}
// Implement a method only when T supports printing and comparison
impl<T: std::fmt::Display + PartialOrd> Pair<T> {
fn larger(&self) -> &T {
if self.first >= self.second {
&self.first
} else {
&self.second
}
}
}
fn main() {
let pair_ints = Pair::new(5, 10);
let pair_strings = Pair::new(String::from("hello"), String::from("world"));
println!("{:?}", pair_ints);
println!("Larger: {}", pair_ints.larger());
let swapped = pair_ints.swap();
println!("Swapped: {:?}", swapped);
println!("Larger string: {}", pair_strings.larger());
}
Output:
Pair { first: 5, second: 10 }
Larger: 10
Swapped: Pair { first: 10, second: 5 }
Larger string: world
Traits — Defining Shared Behavior
A trait defines a set of methods that a type must implement. Think of it as an interface or a contract.
Defining and Implementing a Trait
// Define a trait
trait Greet {
fn hello(&self) -> String;
// Traits can also have default implementations
fn goodbye(&self) -> String {
format!("Goodbye from {}!", self.hello())
}
}
struct English;
struct Spanish;
struct French;
// Implement the trait for each type
impl Greet for English {
fn hello(&self) -> String {
String::from("Hello!")
}
}
impl Greet for Spanish {
fn hello(&self) -> String {
String::from("¡Hola!")
}
// Uses the default goodbye() — no need to re-implement it
}
impl Greet for French {
fn hello(&self) -> String {
String::from("Bonjour!")
}
// Override the default goodbye()
fn goodbye(&self) -> String {
String::from("Au revoir!")
}
}
fn main() {
let languages: Vec<Box<dyn Greet>> = vec![
Box::new(English),
Box::new(Spanish),
Box::new(French),
];
for lang in &languages {
println!("{} / {}", lang.hello(), lang.goodbye());
}
}
Output:
Hello! / Goodbye from Hello!!
¡Hola! / Goodbye from ¡Hola!!
Bonjour! / Au revoir!
A Practical Trait: Summary
trait Summary {
fn summarize_author(&self) -> String;
// Default method using another trait method
fn summarize(&self) -> String {
format!("(Read more from {}...)", self.summarize_author())
}
}
struct Article {
title: String,
author: String,
content: String,
}
struct Tweet {
username: String,
content: String,
}
impl Summary for Article {
fn summarize_author(&self) -> String {
self.author.clone()
}
fn summarize(&self) -> String {
format!("{}, by {} — {}", self.title, self.author, &self.content[..50.min(self.content.len())])
}
}
impl Summary for Tweet {
fn summarize_author(&self) -> String {
format!("@{}", self.username)
}
// Uses default summarize()
}
fn main() {
let article = Article {
title: String::from("Rust is Amazing"),
author: String::from("Neo"),
content: String::from("Rust combines safety and performance in a unique way."),
};
let tweet = Tweet {
username: String::from("rustlang"),
content: String::from("Rust 2024 edition is out!"),
};
println!("{}", article.summarize());
println!("{}", tweet.summarize());
}
Output:
Rust is Amazing, by Neo — Rust combines safety and performance in a unique
(Read more from @rustlang...)
Trait Bounds — Requiring Traits on Generics
Trait bounds let you say "this generic type must implement these traits":
use std::fmt::Display;
// Syntax 1: Inline trait bound
fn print_largest<T: PartialOrd + Display>(list: &[T]) {
let mut largest = &list[0];
for item in list {
if item > largest {
largest = item;
}
}
println!("The largest is: {}", largest);
}
// Syntax 2: `where` clause (cleaner for multiple bounds)
fn compare_and_display<T, U>(t: &T, u: &U)
where
T: Display + PartialOrd,
U: Display + Clone,
{
println!("t = {}, u = {}", t, u);
}
fn main() {
let numbers = vec![15, 3, 42, 7, 27];
print_largest(&numbers);
let words = vec!["hello", "world", "rust"];
print_largest(&words);
compare_and_display(&100, &"hello");
}
Output:
The largest is: 42
The largest is: world
t = 100, u = hello
Trait Objects — Dynamic Dispatch
Trait bounds resolve at compile time (static dispatch). Sometimes you need to work with different types at runtime — that's where trait objects (dyn Trait) come in:
trait Draw {
fn draw(&self);
fn area(&self) -> f64;
}
struct Circle { radius: f64 }
struct Rectangle { width: f64, height: f64 }
struct Triangle { base: f64, height: f64 }
impl Draw for Circle {
fn draw(&self) { println!("Drawing circle (r={})", self.radius); }
fn area(&self) -> f64 { std::f64::consts::PI * self.radius * self.radius }
}
impl Draw for Rectangle {
fn draw(&self) { println!("Drawing rectangle ({}x{})", self.width, self.height); }
fn area(&self) -> f64 { self.width * self.height }
}
impl Draw for Triangle {
fn draw(&self) { println!("Drawing triangle (b={}, h={})", self.base, self.height); }
fn area(&self) -> f64 { 0.5 * self.base * self.height }
}
fn main() {
// Vec of trait objects — can hold different types!
let shapes: Vec<Box<dyn Draw>> = vec![
Box::new(Circle { radius: 5.0 }),
Box::new(Rectangle { width: 4.0, height: 6.0 }),
Box::new(Triangle { base: 3.0, height: 8.0 }),
];
let mut total_area = 0.0;
for shape in &shapes {
shape.draw();
let a = shape.area();
println!(" Area: {:.4}", a);
total_area += a;
}
println!("Total area: {:.4}", total_area);
}
Output:
Drawing circle (r=5)
Area: 78.5398
Drawing rectangle (4x6)
Area: 24.0000
Drawing triangle (b=3, h=8)
Area: 12.0000
Total area: 114.5398
Commonly Used Standard Library Traits
| Trait | Purpose | Example |
|---|---|---|
Display |
Human-readable formatting with {} |
Implement for custom types |
Debug |
Debug formatting with {:?} |
Usually derived with #[derive(Debug)] |
Clone |
Deep copy of a value | let b = a.clone() |
Copy |
Implicit copy (for stack types) | Integers, booleans, floats |
PartialEq / Eq |
Equality comparison (==, !=) |
#[derive(PartialEq)] |
PartialOrd / Ord |
Ordering comparison (<, >) |
Required for sorting |
Iterator |
Defines custom iterators | Implement next() |
From / Into |
Type conversion | String::from("hello") |
Default |
A "zero" or default value | Vec::new(), 0, false |
Implementing Display for a Custom Type
use std::fmt;
struct Matrix {
data: [[f64; 2]; 2],
}
impl fmt::Display for Matrix {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"[ {:.2} {:.2} ]\n[ {:.2} {:.2} ]",
self.data[0][0], self.data[0][1],
self.data[1][0], self.data[1][1]
)
}
}
fn main() {
let m = Matrix {
data: [[1.0, 2.5], [3.0, 4.75]],
};
println!("Matrix:\n{}", m);
}
Output:
Matrix:
[ 1.00 2.50 ]
[ 3.00 4.75 ]
A Complete Example: A Generic Stack
#[derive(Debug)]
struct Stack<T> {
elements: Vec<T>,
}
impl<T> Stack<T> {
fn new() -> Self {
Stack { elements: Vec::new() }
}
fn push(&mut self, item: T) {
self.elements.push(item);
}
fn pop(&mut self) -> Option<T> {
self.elements.pop()
}
fn peek(&self) -> Option<&T> {
self.elements.last()
}
fn is_empty(&self) -> bool {
self.elements.is_empty()
}
fn size(&self) -> usize {
self.elements.len()
}
}
// Implement Display only for stacks where T implements Display
impl<T: std::fmt::Display> Stack<T> {
fn print(&self) {
print!("Stack (top→bottom): ");
for item in self.elements.iter().rev() {
print!("[{}] ", item);
}
println!();
}
}
fn main() {
let mut int_stack: Stack<i32> = Stack::new();
int_stack.push(1);
int_stack.push(2);
int_stack.push(3);
int_stack.print();
println!("Peek: {:?}", int_stack.peek());
println!("Pop: {:?}", int_stack.pop());
int_stack.print();
let mut str_stack: Stack<&str> = Stack::new();
str_stack.push("hello");
str_stack.push("world");
str_stack.push("rust");
str_stack.print();
println!("Size: {}", str_stack.size());
}
Output:
Stack (top→bottom): [3] [2] [1]
Peek: Some(3)
Pop: Some(3)
Stack (top→bottom): [2] [1]
Stack (top→bottom): [rust] [world] [hello]
Size: 3
Summary
In this post you learned:
- ✅ Generics (
<T>) let you write code that works for multiple types - ✅ Trait bounds (
T: Trait) restrict generics to types with specific capabilities - ✅ Traits define shared behavior — like interfaces or contracts
- ✅ Default implementations in traits reduce boilerplate
- ✅ Trait objects (
Box<dyn Trait>) enable dynamic dispatch at runtime - ✅ Common standard library traits:
Display,Debug,Clone,PartialOrd,From
What's Next?
In Post 10, we'll explore Closures and Iterators — Rust's functional programming toolkit that makes data transformation elegant and efficient.
Next Post: Closures and Iterators in Rust →
Part of the Learn Rust from Scratch series on CodeWithNeo


Leave a Reply