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 derived
  • Display ({}): 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 == &copy {
        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 is Clone when T is Clone"

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_name entry
  • 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.