Lecture 23 - Traits
Logistics
- HW5-7 opt in emails due tonight if you're interested
- I moved a couple topics around for next week (you probably won't notice unless you're reading far ahead)
Learning Objectives
By the end of today, you should be able to:
- Define and implement traits for custom types
- Understand what
#[derive(...)]really does - More on trait bounds for writing flexible functions
- Recognize more traits you've been using all along (Debug, Clone, PartialEq)
The Problem: Code Duplication Across Types
Let's say we want to print information about different types:
#![allow(unused)] fn main() { struct SoccerPlayer { name: String, age: u32, team: String, } struct Dataset { name: String, rows: usize, columns: usize, } }
#![allow(unused)] fn main() { // Without traits, we need separate functions: fn describe_player(p: &SoccerPlayer) { println!("{}, age {}, plays for {}", p.name, p.age, p.team); } fn describe_dataset(d: &Dataset) { println!("{}: {} rows × {} columns", d.name, d.rows, d.columns); } }
Problem: We're duplicating the pattern of "describe this thing" for each type!
Solution: Traits let us define shared behavior across different types.
What Are Traits?
A trait defines shared behavior - a set of methods that types can implement. Let's define a custom trait for the behavior we want:
#![allow(unused)] fn main() { trait Describable { fn describe(&self) -> String; } }
This says: "Any type that implements Describable must provide a describe method that takes an immutable self reference and returns a String."
Now we can implement this trait for our types:
#![allow(unused)] fn main() { impl Describable for SoccerPlayer { fn describe(&self) -> String { format!("{}, age {}, plays for {}", self.name, self.age, self.team) } } impl Describable for Dataset { fn describe(&self) -> String { format!("{}: {} rows × {} columns", self.name, self.rows, self.columns) } } }
From other languages: Similar to interfaces in Java or protocols in Swift
Using traits in function parameters
Now we can write one function that works with any type that implements Describable:
#![allow(unused)] fn main() { fn print_description(item: &impl Describable) { println!("{}", item.describe()); } // Works with both types! let player = SoccerPlayer { name: "Messi".to_string(), age: 36, team: "Inter Miami".to_string() }; let data = Dataset { name: "iris".to_string(), rows: 150, columns: 4 }; print_description(&player); // Messi, age 36, plays for Inter Miami print_description(&data); // iris: 150 rows × 4 columns }
Key insight: The function doesn't care about the specific type, only that it can be described!
Didn't we do this last time kinda?
The &impl Describable syntax is shorthand for what you saw last lecture. Here's the equivalent using generics:
#![allow(unused)] fn main() { // Short form (what we just saw) fn print_description(item: &impl Describable) { println!("{}", item.describe()); } // Long form (using generic type parameter) fn print_description<T: Describable>(item: &T) { println!("{}", item.describe()); } }
Both do exactly the same thing! They both say: "accepts a reference to any type T that implements Describable"
When to use which?
&impl Trait- simpler, good for single parameters<T: Trait>- better when you need multiple parameters of the same type or have complex, multi-trait criteria
#![allow(unused)] fn main() { // This ensures both parameters are the SAME type fn compare<T: Describable>(item1: &T, item2: &T) { println!("{}", item1.describe()); println!("{}", item2.describe()); } }
A More Complete Example: The Person Trait (TC 12:30)
Let's define a trait with multiple methods:
#![allow(unused)] fn main() { trait Person { // Required methods - must be implemented fn get_name(&self) -> String; fn get_age(&self) -> u32; // Default method - can be overridden fn description(&self) -> String { format!("{} ({})", self.get_name(), self.get_age()) } } }
New feature: Default implementations! Types get this method for free unless they override it.
Implementing Person for SoccerPlayer
#![allow(unused)] fn main() { struct SoccerPlayer { name: String, age: u32, team: String, } impl Person for SoccerPlayer { fn get_name(&self) -> String { self.name.clone() } fn get_age(&self) -> u32 { self.age } // We get description() for free from the default! } }
#![allow(unused)] fn main() { let messi = SoccerPlayer { name: "Lionel Messi".to_string(), age: 36, team: "Inter Miami".to_string(), }; println!("{}", messi.description()); // Lionel Messi (36) }
Implementing Person for Another Type
#![allow(unused)] fn main() { struct Student { first_name: String, last_name: String, year_born: u32, } impl Person for Student { fn get_name(&self) -> String { format!("{} {}", self.first_name, self.last_name) } fn get_age(&self) -> u32 { 2024 - self.year_born } // Again, description() comes for free! } let student = Student { first_name: "Alice".to_string(), last_name: "Chen".to_string(), year_born: 2003, }; println!("{}", student.description()); // Alice Chen (21) }
Using Traits in Functions
#![allow(unused)] fn main() { fn greet(person: &impl Person) { println!("Hello, {}! I see you're {} years old.", person.get_name(), person.get_age()); } greet(&messi); // Hello, Lionel Messi! I see you're 36 years old. greet(&student); // Hello, Alice Chen! I see you're 21 years old. }
Alternative syntax (same meaning):
#![allow(unused)] fn main() { fn greet<T: Person>(person: &T) { println!("Hello, {}!", person.get_name()); } }
Both mean: "This function works with any type T that implements Person"
Trait Extension: Building on Other Traits
Sometimes you want one trait to require another trait. This is called trait extension or supertraits.
#![allow(unused)] fn main() { // Employee extends Person - any Employee must also be a Person! trait Employee: Person { fn employee_id(&self) -> u32; fn department(&self) -> String; // Can use Person methods in default implementations fn badge_name(&self) -> String { format!("{} - #{}", self.get_name(), self.employee_id()) } } }
Syntax: Employee: Person means "to implement Employee, you must also implement Person"
Implementing Extended Traits
#![allow(unused)] fn main() { struct Engineer { first_name: String, last_name: String, age: u32, emp_id: u32, } // First, implement the base trait (Person) impl Person for Engineer { fn get_name(&self) -> String { format!("{} {}", self.first_name, self.last_name) } fn get_age(&self) -> u32 { self.age } } // Then, implement the extended trait (Employee) impl Employee for Engineer { fn employee_id(&self) -> u32 { self.emp_id } fn department(&self) -> String { "Engineering".to_string() } // badge_name() uses the default implementation } }
The Debug Trait
Debug is a trait that enables printing with {:?}:
#![allow(unused)] fn main() { trait Debug { fn fmt(&self, f: &mut Formatter) -> Result; } }
When you write #[derive(Debug)], Rust automatically implements this trait for you!
Manually implementing Debug (you usually don't need to):
#![allow(unused)] fn main() { use std::fmt; enum Direction { North, South, East, West, } impl fmt::Debug for Direction { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { match self { Direction::North => write!(f, "North"), Direction::South => write!(f, "South"), Direction::East => write!(f, "East"), Direction::West => write!(f, "West"), } } } let dir = Direction::North; println!("{:?}", dir); // North }
Takeaway: #[derive(Debug)] automatically generates this code for you!
The Display Trait
Display is like Debug, but for user-friendly output with {}:
#![allow(unused)] fn main() { use std::fmt; impl fmt::Display for Direction { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { match self { Direction::North => write!(f, "→ Going North"), Direction::South => write!(f, "↓ Going South"), Direction::East => write!(f, "→ Going East"), Direction::West => write!(f, "← Going West"), } } } let dir = Direction::North; println!("{}", dir); // Going North println!("{:?}", dir); // North }
Debug vs Display:
Debug({:?}): For developers/debugging - can be derivedDisplay({}): For end users - must be manually implemented
The Clone and PartialEq Traits
Clone: Enables explicit duplication with .clone()
#![allow(unused)] fn main() { trait Clone { fn clone(&self) -> Self; } }
When you derive it: let copy = original.clone(); works!
PartialEq: Enables comparison with == and !=
#![allow(unused)] fn main() { trait PartialEq { fn eq(&self, other: &Self) -> bool; } }
When you derive it: if point1 == point2 { ... } works!
#![allow(unused)] fn main() { #[derive(Clone, PartialEq)] enum Status { Active, Inactive, } let s1 = Status::Active; let s2 = s1.clone(); // Clone trait if s1 == s2 { // PartialEq trait println!("Same status!"); } }
So what Does #[derive(...)] Actually Do?
#[derive(...)] is a macro that auto-generates trait implementations.
#![allow(unused)] fn main() { // What you write: #[derive(Debug, Clone, PartialEq)] struct Point { x: i32, y: i32, } // What Rust generates (conceptually): impl Debug for Point { /* ... */ } impl Clone for Point { /* ... */ } impl PartialEq for Point { /* ... */ } }
Our common derivable traits:
Debug- debug printing with{:?}Clone- explicit copying with.clone()Copy- implicit copying (for simple types)PartialEq- equality comparison with==Eq- full equality (rare, requires PartialEq)PartialOrd- ordering with<,>, etc.Ord- total ordering (rare, requires PartialOrd)
When to derive vs. implement manually?
- Derive: When the default behavior is what you want (most cases!)
- Manual: When you need custom behavior (like hiding sensitive data in Debug)
Multiple trait bounds - three ways
Sometimes you need a type to implement multiple traits:
#![allow(unused)] fn main() { use std::fmt::Debug; // Option 1: Using + with impl fn analyze_1(item: &(impl Debug + Clone)) { println!("Debug: {:?}", item); let copy = item.clone(); } // Option 2: Using + with generics fn analyze_2<T: Debug + Clone>(item: &T) { println!("Debug: {:?}", item); let copy = item.clone(); } }
#![allow(unused)] fn main() { // Option 3: Using where clause (more readable for many bounds) fn analyze_3<T>(item: &T) where T: Debug + Clone + PartialEq { println!("Debug: {:?}", item); let copy = item.clone(); if item == © { println!("Clone worked correctly!"); } } }
Bringing it together - implementing traits on generics
#![allow(unused)] fn main() { #[derive(Debug)] struct Point<T> { x: T, y: T, } // Implement Clone for Point<T>, but only if T is Clone impl<T: Clone> Clone for Point<T> { fn clone(&self) -> Self { Point { x: self.x.clone(), y: self.y.clone(), } } } let p1 = Point { x: 1, y: 2 }; let p2 = p1.clone(); // Works because i32 implements Clone }
impl<T: Clone> Clone for Point<T> means "Point
Circling back on a question from Friday
Some traits are in the prelude (automatically available):
Clone,Copy,PartialEq,Drop,Iterator
Others we need to import:
#![allow(unused)] fn main() { use std::cmp::{PartialOrd, Ord, PartialEq, Eq}; use std::fmt::{Debug, Display}; use std::ops::{Add, Sub, Mul, Div}; }
No there's no easy way to include all of these at once out of the box. If you need them all you'd have to say
#![allow(unused)] fn main() { use std::ops::{Add, Sub, Mul, Div}; fn calculate<T>(a: T, b: T) -> T where T: Add<Output = T> + Sub<Output = T> + Mul<Output = T> + Div<Output = T> { // ... can use +, -, *, / on T } }
but there's an external library you can add that can do this:
#![allow(unused)] fn main() { use num_traits::Num; fn calculate<T: Num>(a: T, b: T, c: T, d: T) -> T { (a + b) * (c - d) // Can use +, -, *, / and more } // Works with any numeric type let result_int = calculate(1, 2, 3, 4); // i32 let result_float = calculate(1.5, 2.5, 3.0, 1.0); // f64 }
For your awareness: dynamic dispatch in Rust (TC 12:55)
Sometimes you need to store different types together. Rust supports this with trait objects:
#![allow(unused)] fn main() { let items: Vec<Box<dyn Person>> = vec![ Box::new(messi), Box::new(student), ]; for item in &items { println!("{}", item.description()); } }
How it works (simplified):
┌─ Box<dyn Person> pointing to a SoccerPlayer ─┐
│ │
│ Stack: Box contains two pointers │
│ ├─ data_ptr ──────────────┐ │
│ └─ vtable_ptr ────┐ │ │
└──────────────────────┼───────┼───────────────┘ (Heap)
│ │ ┌──────────────────────────┐
│ └──→│ SoccerPlayer { │
│ │ name: "Messi", │
│ │ age: 36, │
│ │ team: "Inter Miami" │
│ │ } │
│ └──────────────────────────┘
└─→ vtable for Person on SoccerPlayer (compiled binary):
┌────────────────────────────────┐
│ get_name: 0x1234 │
│ get_age: 0x5678 │
│ description: 0xABCD │
│ drop: 0xDEF0 │
└────────────────────────────────┘
When you call item.get_name():
- Follow the vtable pointer
- Look up the
get_nameentry - Call that function pointer with the data
That's why it's called dynamic dispatch - the decision of which method to call happens at runtime, not compile time. (And that's why it's slower than static dispatch / what we covered before!)
Trade-off:
- Static dispatch (generics): Fast, but all items must be the same type
- Dynamic dispatch (trait objects): Slightly slower, but can mix types
For this course: You'll mostly use static dispatch with generics. Just know dynamic dispatch exists.
Activity 23
We'll start by live-coding together and then you'll continue on gradescope / rust playground.