Structs & Methods 🏗️
Tuples bundle values by position. Structs bundle them by name, and let you invent your own types that mean something.
Design your own type
Real programs aren't about loose numbers and strings; they're about players, invoices, crabs. A struct (structure) groups related data under one roof, each field with a name and a type:
struct Crab {
name: String,
weight_kg: f64,
claws: u8,
is_rusty: bool,
}
fn main() {
let hero = Crab {
name: String::from("Rusty"),
weight_kg: 1.2,
claws: 2,
is_rusty: true,
};
println!("{} weighs {}kg", hero.name, hero.weight_kg);
}
Dot syntax reads fields: hero.name. Mutating works if the whole
variable is mut: hero.claws = 1; (crab fights are rough).
Printing a whole struct: derive
println!("{hero}") won't work: Rust doesn't presume how your type
should look. But ask nicely and the compiler writes a debug-printer for you:
#[derive(Debug)]
struct Crab {
name: String,
weight_kg: f64,
}
fn main() {
let hero = Crab { name: String::from("Rusty"), weight_kg: 1.2 };
println!("{hero:?}"); // Crab { name: "Rusty", weight_kg: 1.2 }
println!("{hero:#?}"); // same, but pretty multi-line
}
#[derive(Debug)] is an attribute: an instruction to the
compiler. You'll sprinkle this one on almost every struct you write; it's the
difference between debugging blind and debugging with eyes.
Teaching your type tricks: impl
Data is half the story. Methods are functions that belong to your
type, written in an impl (implementation) block. Their first parameter
is &self, a borrow of the struct itself (Lesson 7 paying off already):
#[derive(Debug)]
struct Rectangle {
width: f64,
height: f64,
}
impl Rectangle {
// &self = read-only borrow of this rectangle
fn area(&self) -> f64 {
self.width * self.height
}
fn is_square(&self) -> bool {
self.width == self.height
}
// &mut self = this method modifies the rectangle
fn scale(&mut self, factor: f64) {
self.width *= factor;
self.height *= factor;
}
}
fn main() {
let mut r = Rectangle { width: 3.0, height: 4.0 };
println!("area: {}", r.area()); // 12
println!("square? {}", r.is_square()); // false
r.scale(2.0);
println!("after scaling: {r:?}"); // 6 x 8
}
The ownership rules you already know apply verbatim: &self to
look, &mut self to change, plain self (rare) to consume.
Constructors by convention: new
Functions in an impl block without self are
associated functions, called on the type itself. The classic is a
constructor named new:
impl Rectangle {
fn new(width: f64, height: f64) -> Rectangle {
Rectangle { width, height } // field shorthand: names match!
}
fn square(side: f64) -> Rectangle {
Rectangle { width: side, height: side }
}
}
fn main() {
let r = Rectangle::new(3.0, 4.0); // :: calls type-level functions
let s = Rectangle::square(5.0);
println!("{} and {}", r.area(), s.area());
}
You've seen this shape before: String::from("hi") is exactly an
associated function on String. Mystery retroactively solved. 🕵️
- Forgetting
#[derive(Debug)]then wondering why{:?}errors. - Writing
selfinstead of&self: that consumes the struct; the caller loses it. Borrow unless you mean it. - Calling a method with
Rectangle.area(). Methods use a value:r.area(); associated functions use the type:Rectangle::new(...).
Player card
Define a Player struct with name: String,
level: u32, and hp: i32. Derive Debug, create one,
and print it prettily with {:#?}.
Reveal solution
#[derive(Debug)]
struct Player {
name: String,
level: u32,
hp: i32,
}
fn main() {
let p = Player {
name: String::from("CrabLord99"),
level: 7,
hp: 100,
};
println!("{p:#?}");
}
Give it behavior
Add an impl Player block with: take_damage(&mut self, amount: i32)
that subtracts from hp, and is_alive(&self) -> bool. Deal some
damage and report survival.
Reveal solution
impl Player {
fn take_damage(&mut self, amount: i32) {
self.hp -= amount;
}
fn is_alive(&self) -> bool {
self.hp > 0
}
}
fn main() {
let mut p = Player {
name: String::from("CrabLord99"),
level: 7,
hp: 100,
};
p.take_damage(120);
println!("{} alive? {}", p.name, p.is_alive()); // false 💀
}
Constructor polish
Add Player::new(name: &str) -> Player that starts every player
at level 1 with 100 hp. (Hint: convert with name.to_string().)
Why is &str the better parameter type here? (Lesson 7 knows.)
Reveal solution
impl Player {
fn new(name: &str) -> Player {
Player {
name: name.to_string(),
level: 1,
hp: 100,
}
}
}
fn main() {
let p = Player::new("Rusty");
println!("{p:?}");
}
&str accepts string literals and borrowed Strings:
the flexible doorway. The struct still stores an owned String,
because the Player must own its own name.