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
implblocks - Use different types of
selfparameters (&self,&mut self,self)
The Problem: Organizing Related Data
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
mutto 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
| Parameter | Meaning | When to use | After calling |
|---|---|---|---|
&self | Borrow (read-only) | Reading data, calculations | Struct still usable |
&mut self | Borrow mutably | Modifying struct data | Struct still usable |
self | Take ownership | Converting, consuming | Struct 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)callspush(&mut numbers, 4)(needs&mut selfto modify)numbers.len()callslen(&numbers)(needs&selfto read)Vec::new()has no instance yet so noselfparameter!
More Examples You've Used
| What you wrote | What it really is | self 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 alternatives | Data has multiple attributes that all exist together |
| The variants are fundamentally different | The 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 groupvalueandscaletogether - 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
selfparameter each uses - How structs and enums work together
Write as much proper code as you can
- Use
enum,struct, andimpl - Use
self,&selfand&mut selfin 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