Level 2 · The Rust Way

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. 🕵️

⚠️ Common stumbles
  • Forgetting #[derive(Debug)] then wondering why {:?} errors.
  • Writing self instead 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(...).
Exercise 1

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:#?}");
}
Exercise 2

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 💀
}
Exercise 3

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.