Level 2 · The Rust Way

Enums & Pattern Matching 🎭

Structs say "this AND that." Enums say "this OR that." Together with match, they end one of computing's most expensive mistakes: the null pointer.

One of several possibilities

An enum (enumeration) is a type whose value is exactly one of a fixed set of variants:

enum Weather {
    Sunny,
    Cloudy,
    Rainy,
    Snowy,
}

fn main() {
    let today = Weather::Rainy;
}

A Weather is never two of these, never none of these, never "Purple": the compiler guarantees it. That already kills a whole species of bug (the stray string: "rainy" vs "Rainy" vs "RAINY"…).

Variants can carry data 🎒

Here's where Rust enums leave other languages behind. Each variant can hold its own cargo:

enum Message {
    Quit,                       // no data
    Move { x: i32, y: i32 },    // struct-like data
    Write(String),              // one value
    ChangeColor(u8, u8, u8),    // three values
}

A Message is one shape or another, each with exactly the data that shape needs. Modeling "it's this or that, with different details" is most of programming; enums make it native.

match: the pattern-matching powerhouse

enum Weather {
    Sunny,
    Rainy,
    Windy(u32),   // wind speed in km/h
}

fn advice(w: Weather) -> String {
    match w {
        Weather::Sunny => String::from("Sunscreen. You're a crab."),
        Weather::Rainy => String::from("You live in water. Proceed."),
        Weather::Windy(speed) if speed > 80 => {
            String::from("Grip the rock. GRIP THE ROCK.")
        }
        Weather::Windy(speed) => format!("Breezy at {speed} km/h. Enjoy."),
    }
}

fn main() {
    println!("{}", advice(Weather::Windy(95)));
}

Three superpowers on display:

The _ pattern means "anything else". It's useful, but use it sparingly: every _ is a place the compiler stops checking for you.

The billion-dollar bug, and Rust's answer 💸

Tony Hoare, who invented the null reference in 1965, calls it his "billion-dollar mistake": decades of crashes from programs using a value that turned out to be nothing. Most languages still live with it. Rust simply… doesn't have null.

Instead, "maybe a value" is an ordinary enum from the standard library:

enum Option<T> {    // T = any type (generics, Lesson 12)
    Some(T),         // there IS a value, here it is
    None,            // there is no value
}

Anything that might be absent is an Option, and the compiler forces you to handle both cases before you can touch the value:

fn find_nickname(name: &str) -> Option<String> {
    if name == "Christopher" {
        Some(String::from("Chris"))
    } else {
        None
    }
}

fn main() {
    match find_nickname("Christopher") {
        Some(nick) => println!("Call them {nick}"),
        None => println!("No nickname on file."),
    }
}

You cannot forget the None case; it won't compile. The null-pointer crash isn't "less likely" in Rust; it's grammatically impossible.

The one-case shortcut: if let

When you only care about one variant, a full match is ceremony. if let is the shorthand:

let nickname = find_nickname("Christopher");

if let Some(nick) = nickname {
    println!("Call them {nick}");
}
// (optionally: } else { ... } for the None side)
⚠️ Common stumbles
  • Reaching for .unwrap() to skip the None case: it crashes the program if the value is absent. Fine in toy code; in real code, match, if let, or unwrap_or(default).
  • Forgetting Some(...) when returning: return nick; where Some(nick) is needed.
  • Overusing _ arms: you're muting your best reviewer.
Exercise 1

Traffic light

Create an enum Light with Red, Yellow, Green. Write fn action(light: Light) -> &'static str using match (that 'static is just "text built into the program"; details in Lesson 13). Then try deleting one arm and read the error.

Reveal solution
enum Light {
    Red,
    Yellow,
    Green,
}

fn action(light: Light) -> &'static str {
    match light {
        Light::Red => "stop",
        Light::Yellow => "hurry up (kidding: slow down)",
        Light::Green => "go",
    }
}

fn main() {
    println!("{}", action(Light::Yellow));
}
Exercise 2

Safe division

Division by zero is nonsense. Model it! Write fn divide(a: f64, b: f64) -> Option<f64> returning None when b == 0.0. Call it twice (once with zero) and match on both results.

Reveal solution
fn divide(a: f64, b: f64) -> Option<f64> {
    if b == 0.0 {
        None
    } else {
        Some(a / b)
    }
}

fn main() {
    for (a, b) in [(10.0, 4.0), (1.0, 0.0)] {
        match divide(a, b) {
            Some(result) => println!("{a} / {b} = {result}"),
            None => println!("{a} / {b} is undefined, friend"),
        }
    }
}
Exercise 3

Message dispatcher

Using the Message enum from above, write a function that takes a Message and describes it, e.g. Move { x, y } prints "moving to (x, y)". Create one of each variant and dispatch them all.

Reveal solution
enum Message {
    Quit,
    Move { x: i32, y: i32 },
    Write(String),
    ChangeColor(u8, u8, u8),
}

fn dispatch(msg: Message) {
    match msg {
        Message::Quit => println!("goodbye!"),
        Message::Move { x, y } => println!("moving to ({x}, {y})"),
        Message::Write(text) => println!("writing: {text}"),
        Message::ChangeColor(r, g, b) => println!("color: #{r:02x}{g:02x}{b:02x}"),
    }
}

fn main() {
    dispatch(Message::Move { x: 3, y: 7 });
    dispatch(Message::Write(String::from("hi")));
    dispatch(Message::ChangeColor(247, 76, 0));
    dispatch(Message::Quit);
}