Lecture 20 - Structs and Methods

Logistics

  • HW3 was graded - you have until Sunday at midnight (11:59pm Sunday) if you want to do corrections
  • Oral exams are today and tomorrow
  • HW4 is due Friday (Joey will cover in discussion tomorrow)

Learning Objectives

By the end of today, you should be able to:

  • Define custom data types using structs
  • Implement methods and associated functions with impl blocks
  • Use different types of self parameters (&self, &mut self, self)

Imagine you're analyzing customer data. You could use separate variables:

#![allow(unused)]
fn main() {
let customer_name = "Alice Smith";
let customer_age = 25;
let customer_state = State::NY;
let customer_member = true;
}

Problem: Easy to mix up, hard to pass around, no guarantee they belong together!

Solution: Group related data into a custom type called a "struct".

#![allow(unused)]
fn main() {
enum State {
    MA,
    NY,
    // ...
}
struct Customer {
    name: String,
    age: u32,
    state: State,
    member: bool,
}

let alice = Customer {
    name: "Alice".to_string(),
    age: 25,
    state: State::NY,
    member: true,
};
}

Benefit: All related data stays together and has clear names.

Using Your Struct

#![allow(unused)]
fn main() {
#[derive(Debug)]
enum State {
    MA,
    NY,
    // ...
}

#[derive(Debug)]
struct Customer {
    name: String,
    age: u32,
    state: State,
    member: bool,
}

let mut alice = Customer {
    name: "Alice".to_string(),
    age: 25,
    state: State::NY,
    member: true,
};

// Access fields with dot notation
println!("{}'s age is {}", alice.name, alice.age);

// Modify fields (if struct is mutable)
alice.age = 26;
println!("{:?}", alice); // since customer (and State!) have Debug
}

Memory insight: How structs store data

     STACK                     HEAP
┌──────student─────┐           
│ name: ptr  ──────┼──────────► ┌─────────────┐
│       len: 11    │            │"Alice Smith"│
│       cap: 11    │            └─────────────┘
│ age: 20          │
│ state: 0 (NY)    │  ← Just a number representing the variant
│ member: 1 (true) │  ← Just 0 or 1
└──────────────────┘

Tuple structs: When you don't need field names (TC 12:30)

Sometimes you want type safety but don't need named fields:

#![allow(unused)]
fn main() {
#[derive(Debug)]
struct Point3D(f64, f64, f64);
#[derive(Debug)]
struct BoxOfDonuts(i32);

let point = Point3D(3.0, 4.0, 5.0);
let temp = BoxOfDonuts(12);

// Access with .0, .1, .2
println!("X: {}, Y: {}, Z: {}", point.0, point.1, point.2);
}

Benefit: Prevents accidentally mixing up similar data types.

Ownership Interlude: Struct Move Quiz

Question: What happens in this code?

#![allow(unused)]
fn main() {
struct Point { x: f64, y: f64 }
struct NamedPoint { name: String, point: Point }

let p1 = Point { x: 1.0, y: 2.0 };
let np1 = NamedPoint { name: "Origin".to_string(), point: p1 };
let np2 = NamedPoint { name: "Copy".to_string(), point: p1 };
}

A) Compiles fine - Point is copied
B) Compiler error - p1 was moved
C) Runtime panic

Creating similar structs with update syntax

Sometimes you want to create a new struct that's mostly the same as an existing one, but with a few fields changed.

The long way (repetitive!):

#![allow(unused)]
fn main() {
#[derive(Debug)]
enum State {
    MA,
    NY,
    // ...
}
struct Customer {
    name: String,
    age: u32,
    state: State,
    member: bool,
}

let alice = Customer {
    name: "Alice".to_string(),
    age: 25,
    state: State::NY,
    member: true,
};

// Want to create another NY member? Copy all the fields!
let bob = Customer {
    name: "Bob".to_string(),
    age: 30,
    state: State::NY,      // Same as alice
    member: true,          // Same as alice
};
}

The better way (using ..):

#![allow(unused)]
fn main() {
// Create bob with only the fields that differ
let bob = Customer {
    name: "Bob".to_string(),
    age: 30,
    ..alice  // Copy remaining fields (state, member) from alice
};

// Create another NY member
let charlie = Customer {
    name: "Charlie".to_string(),
    age: 28,
    ..alice  // Gets state: NY and member: true from alice
};
// alice is still valid! The copied fields (state, member) are Copy types
}

Important ownership note: The .. syntax will move any non-Copy fields that aren't explicitly specified. In our example, state and member are both Copy types, so alice remains valid. But watch out:

#![allow(unused)]
fn main() {
// A different struct with a non-Copy field at the end
struct Order {
    customer_name: String,
    quantity: u32,
    notes: String,  // Not a Copy type!
}

let order1 = Order {
    customer_name: "Alice".to_string(),
    quantity: 5,
    notes: "Rush delivery".to_string(),
};

let order2 = Order {
    customer_name: "Bob".to_string(),
    ..order1  // This MOVES order1.notes! order1 is now invalid
};

// but this is safe!
let order3 = Order {
    customer_name: "Bob".to_string(),
    ..order1.clone()
};
}

Part 2: Methods - Adding behavior to your data

What Are Methods?

Methods let you add behavior (functions) that belong to your struct:

#![allow(unused)]
fn main() {
struct Rectangle {
    width: f64,
    height: f64,
}

// Instead of separate functions:
fn calculate_area(rect: &Rectangle) -> f64 { ... }
fn calculate_perimeter(rect: &Rectangle) -> f64 { ... }

// You can attach them to the struct:
impl Rectangle {
    fn area(&self) -> f64 { ... }        // Method
    fn perimeter(&self) -> f64 { ... }   // Method
}

// Usage: rect.area() instead of calculate_area(&rect)
}

Benefit: Methods keep related functionality together with the data.

Basic Method Example

#![allow(unused)]
fn main() {
#[derive(Debug)]
struct Rectangle {
    width: f64,
    height: f64,
}

impl Rectangle {
    fn area(&self) -> f64 {
        self.width * self.height
    }
}

let rect = Rectangle { width: 10.0, height: 5.0 };
println!("Area: {}", rect.area());  // Much cleaner than area(&rect)
}

Self?

What is self?

self is a special parameter that refers to the instance of the struct the method is called on.

#![allow(unused)]
fn main() {
let rect = Rectangle { width: 10.0, height: 5.0 };
rect.area();  // When you call area(), "self" inside area() refers to rect
}

Think of it like: "the rectangle that I'm calculating the area of."

Understanding &self (Borrowed Reference)

#![allow(unused)]
fn main() {
#[derive(Debug)]
struct Rectangle {
    width: f64,
    height: f64,
}

impl Rectangle {
    fn area(&self) -> f64 { 
        self.width * self.height
    }
}

let rect = Rectangle { width: 10.0, height: 5.0 };
let a = rect.area();  // rect.area() is like calling area(&rect)
println!("{}", rect.width);  // rect is still usable!
}

Why &self?

  • We just need to read the data, not change it
  • The rectangle is still usable after the method call
  • Most methods use &self - it's the safest default

Understanding &mut self (Mutable Reference)

#![allow(unused)]
fn main() {
#[derive(Debug)]
struct Rectangle {
    width: f64,
    height: f64,
}

impl Rectangle {
    fn scale(&mut self, factor: f64) {  
        self.width *= factor;
        self.height *= factor;
    }
}

let mut rect = Rectangle { width: 10.0, height: 5.0 };
rect.scale(2.0);  // Changes rect's width and height
println!("{}", rect.width);  // Now 20.0 - rect was modified!
}

Why &mut self?

  • We need to change the struct's data
  • The struct must be declared mut to call these methods
  • Use when the method modifies internal state

Passing self itself (taking ownership)

#[derive(Debug)]
struct Rectangle {
    width: f64,
    height: f64,
}

impl Rectangle {
    fn into_area(self) -> f64 {  
        self.width * self.height
        // Rectangle is consumed here!
    }
}

fn main(){
    let rect = Rectangle { width: 10.0, height: 5.0 };
    let a = rect.into_area();
    // println!("{}", rect.width);  // ERROR! rect was moved
}

Why self?

  • The method consumes the struct
  • Use for conversions or when the struct shouldn't be used again
  • Less common - only use when you truly need to consume

Quick Reference

ParameterMeaningWhen to useAfter calling
&selfBorrow (read-only)Reading data, calculationsStruct still usable
&mut selfBorrow mutablyModifying struct dataStruct still usable
selfTake ownershipConverting, consumingStruct is moved

We've seen lots of these before! (dot methods) (TC 12:40)

You've been using methods all semester - now you know what they really are!

#![allow(unused)]
fn main() {
let mut numbers = vec![1, 2, 3];
numbers.push(4);           // What's really happening?
let size = numbers.len();  // What about this?
}

Under the hood, these are methods implemented on the Vec struct:

#![allow(unused)]
fn main() {
impl<T> Vec<T> {
    // push takes &mut self - it needs to modify the vector
    fn push(&mut self, value: T) {
        // ... add value to the vector
    }

    // len takes &self - it just reads the length
    fn len(&self) -> usize {
        // ... return the length
    }

    // new doesn't take self at all - it creates a new Vec
    fn new() -> Vec<T> {
        // ... create empty vector
    }
}
}

Now it makes sense!

  • numbers.push(4) calls push(&mut numbers, 4) (needs &mut self to modify)
  • numbers.len() calls len(&numbers) (needs &self to read)
  • Vec::new() has no instance yet so no self parameter!

More Examples You've Used

What you wroteWhat it really isself type
my_string.len()String::len(&my_string)&self (just reading)
my_string.push('!')String::push(&mut my_string, '!')&mut self (modifying)
my_vec.iter()Vec::iter(&my_vec)&self (just reading)
Some(5).unwrap()Option::unwrap(Some(5))self (consuming!)

The pattern: If a method can be called multiple times on the same value, it uses &self or &mut self. If it can only be called once (like unwrap()), it takes self.

Constructor Functions

You can create your own "constructor" functions like Vec::new to make building structs easier:

#![allow(unused)]
fn main() {
#[derive(Debug)]
struct DataSet {
    name: String,
    values: Vec<f64>,
}

impl DataSet {
    // Constructor - no self parameter, returns new instance
    fn new(name: String) -> DataSet {
        DataSet {
            name, // shorthand for name: name 
            values: Vec::new(),
        }
    }
}

// Much easier than writing out the whole struct:
let dataset = DataSet::new("Experiment".to_string());
}

Enums vs Structs

Remember the temperature problem from homework? Let's redo it with impl (which works for enums too!) and then structs

Approach 1: Enum (what you've seen before)

#![allow(unused)]
fn main() {
#[derive(Debug, Clone, Copy)]
enum Temperature {
    Celsius(f64),
    Fahrenheit(f64),
}

impl Temperature {
    fn to_celsius(&self) -> f64 {
        match self {
            Temperature::Celsius(val) => *val,
            Temperature::Fahrenheit(val) => (val - 32.0) * 5.0 / 9.0,
        }
    }

    fn to_fahrenheit(&self) -> f64 {
        match self {
            Temperature::Celsius(val) => val * 9.0 / 5.0 + 32.0,
            Temperature::Fahrenheit(val) => *val,
        }
    }
}

let temp = Temperature::Celsius(25.0);
println!("{}°F", temp.to_fahrenheit());  // 77°F
}

Key idea: A temperature is either Celsius or Fahrenheit. The enum says "this value IS one of these variants."

Approach 2: Struct (more flexible)

#![allow(unused)]
fn main() {
#[derive(Debug, Clone, Copy)]
enum Scale {
    Celsius,
    Fahrenheit,
}

#[derive(Debug)]
struct Temperature {
    value: f64,
    scale: Scale,
}

impl Temperature {
    fn new(value: f64, scale: Scale) -> Temperature {
        Temperature { value, scale }
    }

    fn to_celsius(&self) -> f64 {
        match self.scale {
            Scale::Celsius => self.value,
            Scale::Fahrenheit => (self.value - 32.0) * 5.0 / 9.0,
        }
    }

    fn to_fahrenheit(&self) -> f64 {
        match self.scale {
            Scale::Celsius => self.value * 9.0 / 5.0 + 32.0,
            Scale::Fahrenheit => self.value,
        }
    }
}

let temp = Temperature::new(25.0, Scale::Celsius);
println!("{}°F", temp.to_fahrenheit());  // 77°F
}

Key idea: A temperature has a value and a scale. The struct groups related data together.

When to Use Each?

Use Enum when:Use Struct when:
Data can be one of several alternativesData has multiple attributes that all exist together
The variants are fundamentally differentThe fields work together as a unit
Example: Result<T, E> (Ok or Err)Example: Customer (has name and age and state)
Example: Option<T> (Some or None)Example: Rectangle (has width and height)

Combining Them is Powerful!

Notice in the struct version, we used both:

  • Struct (Temperature) to group value and scale together
  • Enum (Scale) to represent that scale is one of two alternatives

This is a very common pattern in Rust! Use structs to group related data, and enums inside structs to represent choices.

Pre-activity example: Student grade tracker

#![allow(unused)]
fn main() {
#[derive(Debug)]
struct Student {
    name: String,
    grades: Vec<f64>,
}

impl Student {
    fn new(name: String) -> Student {
        Student {
            name,
            grades: Vec::new(),
        }
    }

    fn add_grade(&mut self, grade: f64) {
        self.grades.push(grade);
    }

    fn average(&self) -> f64 {
        if self.grades.is_empty() {
            0.0
        } else {
            self.grades.iter().sum() / self.grades.len() as f64
        }
    }
}

// Usage
let mut alice = Student::new("Alice".to_string());
alice.add_grade(85.0);
alice.add_grade(92.0);
println!("{}'s average: {:.1}", alice.name, alice.average());
}

Activity time

In groups of 5-6, you'll design a struct-based system for a real-world scenario.

Focus on:

  • What fields belong in your structs
  • What enums represent choices in your domain
  • What methods you need and what type of self parameter each uses
  • How structs and enums work together

Write as much proper code as you can

  • Use enum, struct, and impl
  • Use self, &self and &mut self in your method signatures
  • But feel free to leave the inside of each method unimplemented()

Be ready to present:

  • Choose one person who will come to the front to explain your design
  • We'll go by task, so we'll hear two approaches to each problem