Skip to content

05 - Structs and Enums

What this session is

About an hour. You'll learn Rust's two ways to define your own types: structs (a bundle of named fields) and enums (a closed list of variants - much more powerful than enums in most languages). You'll also see impl blocks, where you attach methods to types.

Structs

The bundle-of-fields type:

struct Person {
    name: String,
    age: u32,
    city: String,
}

fn main() {
    let alice = Person {
        name: String::from("Alice"),
        age: 30,
        city: String::from("Lagos"),
    };

    println!("{}, {}, {}", alice.name, alice.age, alice.city);
}

Notes: - struct Person { ... } declares the type. - Fields have explicit types. Field names use snake_case by convention. - Person { name: ..., age: ..., city: ... } is a struct literal - creates an instance. - alice.name accesses a field. - String::from("Alice") makes an owned String from a &str literal. We met this in page 02.

By default, struct fields are immutable. To mutate, the variable holding the struct must be mut:

let mut alice = Person { ... };
alice.age = 31;        // OK because `alice` is mut

You can't mutate just one field independently - mutability is on the binding (the variable), not the field.

Field shorthand

If a local variable has the same name as the field, you can omit the name: name pair:

fn new_person(name: String, age: u32) -> Person {
    Person {
        name,         // shorthand for `name: name`
        age,          // shorthand for `age: age`
        city: String::from("unknown"),
    }
}

Cleaner; you'll see it everywhere.

Methods: impl blocks

To attach methods to a struct, use an impl block:

struct Rectangle {
    width: f64,
    height: f64,
}

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

    fn scale(&self, factor: f64) -> Rectangle {
        Rectangle {
            width: self.width * factor,
            height: self.height * factor,
        }
    }
}

fn main() {
    let r = Rectangle { width: 5.0, height: 3.0 };
    println!("{}", r.area());            // 15
    let big = r.scale(2.0);
    println!("{}", big.area());          // 60
}

What's new: - impl Rectangle { ... } - block of methods for Rectangle. - fn area(&self) - &self means "borrow self immutably." The method reads but doesn't mutate. (Java's this is implicit; Rust's self is explicit.) - self.width - access a field of the value being operated on.

Three receiver forms you'll see:

Receiver Meaning
&self borrow immutably - read-only access
&mut self borrow mutably - can modify fields
self take ownership - the method consumes the value

(Ownership is page 06. For now: &self for read, &mut self for write.)

impl Rectangle {
    fn grow(&mut self, amount: f64) {     // &mut self - can mutate
        self.width += amount;
        self.height += amount;
    }
}

let mut r = Rectangle { width: 5.0, height: 3.0 };
r.grow(1.0);
println!("{} {}", r.width, r.height);    // 6.0 4.0

Associated functions (no self)

Methods without &self are called associated functions - they belong to the type but don't operate on a specific instance. Used for constructors:

impl Rectangle {
    fn new(width: f64, height: f64) -> Rectangle {
        Rectangle { width, height }
    }

    fn square(side: f64) -> Rectangle {
        Rectangle { width: side, height: side }
    }
}

let r = Rectangle::new(5.0, 3.0);    // call as Type::function
let s = Rectangle::square(4.0);

Notice Rectangle::new - the :: is for associated functions (and types-on-types in general). Compare to r.area() - the . is for methods on instances.

There's no built-in "constructor" keyword. The convention is a new associated function (or other named factory like square).

Tuple structs

A struct without field names - just types in order:

struct Point(f64, f64);

let p = Point(3.0, 4.0);
println!("{}, {}", p.0, p.1);

Useful when fields don't need names (or when you want a type-safe wrapper around a single primitive: struct UserId(u64);).

Unit structs

A struct with no fields. Used as marker types.

struct Marker;

Rare; recognize when you see it.

Enums: the powerful kind

A Rust enum is more than a list of names - it can hold data, different per variant. This is one of Rust's defining features.

Simple enum:

enum Direction {
    North,
    South,
    East,
    West,
}

let d = Direction::North;

Use match (page 03):

fn describe(d: Direction) -> &'static str {
    match d {
        Direction::North => "up",
        Direction::South => "down",
        Direction::East => "right",
        Direction::West => "left",
    }
}

No _ needed because the four variants cover everything (exhaustive).

(&'static str is a string slice that lives "forever" - string literals do. The 'static is a lifetime. Don't sweat the syntax now.)

Enums with data

This is where Rust's enums shine. Each variant can carry different data:

enum Shape {
    Circle(f64),                       // a radius
    Square(f64),                       // a side
    Rectangle { width: f64, height: f64 },   // named fields
    Empty,                             // no data
}

fn area(s: &Shape) -> f64 {
    match s {
        Shape::Circle(r) => std::f64::consts::PI * r * r,
        Shape::Square(side) => side * side,
        Shape::Rectangle { width, height } => width * height,
        Shape::Empty => 0.0,
    }
}

fn main() {
    let shapes = [
        Shape::Circle(2.0),
        Shape::Square(3.0),
        Shape::Rectangle { width: 4.0, height: 5.0 },
        Shape::Empty,
    ];
    for s in &shapes {
        println!("{}", area(s));
    }
}

The match arms destructure the variants - binding the inner data to names you can use. Powerful and beautiful.

You also see this in Java's pattern-matching switch (page 08 of Java from Scratch), but Java only got it recently; in Rust it's been the way to model "one of several shapes" since day one.

Option<T>: the built-in nullable

Rust doesn't have null. Instead, it has Option<T> - a built-in enum:

enum Option<T> {
    Some(T),
    None,
}

A function that "might or might not return a value" returns Option<T>:

fn find(list: &[i32], target: i32) -> Option<usize> {
    for (i, &x) in list.iter().enumerate() {
        if x == target { return Some(i); }
    }
    None
}

fn main() {
    let nums = [10, 20, 30, 40];
    match find(&nums, 30) {
        Some(i) => println!("found at {i}"),
        None => println!("not found"),
    }
}

The compiler forces you to handle the None case - if you forget, the match isn't exhaustive and you get a compile error. No more null pointer exceptions.

Option is used everywhere in Rust standard library - anywhere a value might be absent.

A complete example

struct Account {
    owner: String,
    balance: f64,
}

impl Account {
    fn new(owner: String, initial: f64) -> Account {
        Account { owner, balance: initial }
    }

    fn deposit(&mut self, amount: f64) {
        self.balance += amount;
    }

    fn withdraw(&mut self, amount: f64) -> Result<(), String> {
        if amount > self.balance {
            Err(format!("insufficient: have {}, want {}", self.balance, amount))
        } else {
            self.balance -= amount;
            Ok(())
        }
    }
}

Don't worry about Result<(), String> - that's error handling, page 08. The pattern of fields + impl + methods is what you should recognize.

Exercise

In your project, replace src/main.rs:

  1. Define struct Rectangle with width: f64 and height: f64.

  2. In an impl block, add:

  3. fn new(width: f64, height: f64) -> Rectangle (constructor).
  4. fn area(&self) -> f64.
  5. fn perimeter(&self) -> f64.
  6. fn scale(&self, factor: f64) -> Rectangle (returns a new Rectangle).

  7. In main, create a Rectangle::new(5.0, 3.0). Print area, perimeter. Then create a scaled version (factor 2) and print its area (should be 60).

  8. Stretch: define enum Shape { Circle(f64), Rectangle(f64, f64), Triangle(f64, f64) }. Write a function fn area(s: &Shape) -> f64 using match. Test with an array of three shapes.

What you might wonder

"Why so many ways to define types (struct, tuple struct, unit struct, enum)?" Because different kinds of data want different shapes. Named-field structs for most things, tuple structs for newtypes, enums for "one of several variants." The variety means you can pick the right one for each case.

"What's &'static?" A lifetime. &'static str means "a string slice that lives for the entire program." String literals are &'static str. We don't cover lifetimes deeply here; recognize and skim past.

"How is enum-with-data different from inheritance?" Inheritance: "Dog is an Animal; you have an Animal, it's secretly a Dog or Cat." Enums: "Shape IS one of {Circle, Square, Rectangle}; the compiler knows the full list and forces you to handle every case." Enums are closed and exhaustive; inheritance is open. Rust uses enums where other languages use inheritance.

"Why no nulls?" The most-quoted decision of the language. Tony Hoare called null "my billion-dollar mistake." Rust replaces null with Option<T> - the absence is in the type, the compiler forces handling. Net effect: NullPointerException doesn't exist.

Done

  • Define structs with named fields, tuple structs, unit structs.
  • Attach methods via impl blocks (&self, &mut self).
  • Define enums - simple and with data.
  • Use match to destructure enum variants.
  • Recognize Option<T> as Rust's null-replacement.

Now you have basic data modeling. Next page is the Rust page: ownership and borrowing.

Next: Ownership and borrowing →

Comments