Core Concepts: Structs, Enums, and Pattern Matching

Core Concepts: Structs, Enums, and Pattern Matching

As your programs grow, you’ll need ways to define custom data types that logically group related pieces of data. Rust provides structs and enums for this purpose. Combined with pattern matching, these features allow you to write expressive, robust, and type-safe code.

Structs

Structs are custom data types that let you name and package together multiple related values into a meaningful group. Each piece of data in a struct is called a field.

Defining and Instantiating Structs

// Define a struct to hold user account information
struct User {
    active: bool,
    username: String,
    email: String,
    sign_in_count: u64,
}

fn main() {
    // Instantiate a struct
    let user1 = User {
        active: true,
        username: String::from("alice123"),
        email: String::from("alice@example.com"),
        sign_in_count: 1,
    };

    println!("User 1 email: {}", user1.email);

    // To make a struct instance mutable, the entire instance must be mutable.
    // Rust doesn't allow marking individual fields as mutable.
    let mut user2 = User {
        active: false,
        username: String::from("bob456"),
        email: String::from("bob@example.com"),
        sign_in_count: 5,
    };

    user2.email = String::from("another_bob@example.com");
    println!("User 2 new email: {}", user2.email);
}

Creating Structs with a Function

It’s common to define a function that takes some parameters and returns a new struct instance.

struct User {
    active: bool,
    username: String,
    email: String,
    sign_in_count: u64,
}

fn build_user(email: String, username: String) -> User {
    User {
        active: true,
        username, // Field init shorthand: same name for field and variable
        email,    // same as username: username
        sign_in_count: 1,
    }
}

fn main() {
    let user3 = build_user(String::from("charlie@example.com"), String::from("charlie_dev"));
    println!("User 3 username: {}", user3.username);
}

Struct Update Syntax

You can create new instances from existing ones using some of the old values.

struct User {
    active: bool,
    username: String,
    email: String,
    sign_in_count: u64,
}

fn main() {
    let user1 = User {
        active: true,
        username: String::from("alice123"),
        email: String::from("alice@example.com"),
        sign_in_count: 1,
    };

    // Create a new User instance using some values from user1
    let user4 = User {
        email: String::from("another_alice@example.com"),
        ..user1 // Takes remaining fields from user1.
                // Note: user1.username and user1.email are String, so they will be MOVED.
                // After this, user1 is partially moved and can't be used (e.g., user1.username is invalid)
                // If fields were Copy types (like active: bool, sign_in_count: u64), they would be copied.
    };

    println!("User 4 username: {}", user4.username);
    println!("User 4 active: {}", user4.active);
    // println!("User 1 username: {}", user1.username); // ERROR: value moved
}

Tuple Structs

Tuple structs are like tuples but have a name. They’re useful when you want to give a whole tuple a name but don’t care about naming individual fields.

struct Color(i32, i32, i32);
struct Point(i32, i32, i32);

fn main() {
    let black = Color(0, 0, 0);
    let origin = Point(0, 0, 0);

    // black and origin are different types, even if their inner types are the same.
    // let x = black.0; // Access like a tuple
    // origin is not a Color, so you can't compare them directly (without specific traits)
}

Unit-Like Structs

These are structs without any fields. They’re useful when you need to implement a trait on some type but don’t have any data that you want to store in the type itself.

struct AlwaysEqual;

fn main() {
    let subject = AlwaysEqual;
    // We can't store data in `subject`, but it can implement traits.
}

Method Syntax

Functions associated with a struct are called methods. Methods’ first parameter is always self, which represents the instance of the struct the method is being called on.

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

impl Rectangle { // `impl` block for Rectangle
    // This is a method that takes an immutable reference to self
    fn area(&self) -> u32 {
        self.width * self.height
    }

    // This is a method that takes a mutable reference to self
    fn scale(&mut self, factor: u32) {
        self.width *= factor;
        self.height *= factor;
    }

    // This method consumes self (takes ownership)
    fn describe(self) {
        println!("Rectangle dimensions: {}x{}", self.width, self.height);
        // After this, the Rectangle instance cannot be used anymore as ownership was moved.
    }

    // This is an *associated function* (not a method) because it doesn't take &self, &mut self, or self
    // It's often used as a constructor.
    fn square(size: u32) -> Rectangle {
        Rectangle {
            width: size,
            height: size,
        }
    }

    // A method that takes another Rectangle by immutable reference
    fn can_hold(&self, other: &Rectangle) -> bool {
        self.width > other.width && self.height > other.height
    }
}

fn main() {
    let mut rect1 = Rectangle { width: 30, height: 50 };
    println!("Initial area: {}", rect1.area()); // Calling a method

    rect1.scale(2); // Calling a mutable method
    println!("Scaled area: {}", rect1.area()); // Area is now 120 * 50 = 6000

    let rect2 = Rectangle::square(25); // Calling an associated function
    println!("Square area: {}", rect2.area());

    println!("Can rect1 hold rect2? {}", rect1.can_hold(&rect2)); // true
    
    rect1.describe(); // Consumes rect1
    // println!("Area after describe: {}", rect1.area()); // ERROR: rect1 moved
}

Rust automatically references and dereferences (via . operator) when calling methods, so &self works seamlessly.

Enums

Enums (enumerations) allow you to define a type by enumerating its possible variants.

Defining Enums

// An enum for different types of messages a system might receive
enum Message {
    Quit, // A variant without any data
    Move { x: i32, y: i32 }, // A struct-like variant with named fields
    Write(String), // A tuple-like variant with a single String
    ChangeColor(i32, i32, i32), // A tuple-like variant with three i32 values
}

fn process_message(msg: Message) {
    match msg { // We'll learn `match` next, it's perfect for enums!
        Message::Quit => {
            println!("The Quit message has no data.");
        }
        Message::Move { x, y } => {
            println!("Move to x = {}, y = {}", x, y);
        }
        Message::Write(text) => {
            println!("Text message: {}", text);
        }
        Message::ChangeColor(r, g, b) => {
            println!("Change color to R:{}, G:{}, B:{}", r, g, b);
        }
    }
}

fn main() {
    let quit_msg = Message::Quit;
    let move_msg = Message::Move { x: 10, y: 20 };
    let write_msg = Message::Write(String::from("hello from Rust"));
    let change_color_msg = Message::ChangeColor(255, 0, 100);

    process_message(quit_msg);
    process_message(move_msg);
    process_message(write_msg);
    process_message(change_color_msg);
}

Enums are incredibly powerful because each variant can hold different types and amounts of data.

The Option Enum

Rust doesn’t have null. Instead, it has the Option<T> enum, which is defined in the standard library:

enum Option<T> {
    None, // Represents no value
    Some(T), // Represents a value of type T
}

Option<T> is used everywhere in Rust to represent a value that might be present or might not be present.

fn main() {
    let some_number = Some(5);
    let some_string = Some("a string");
    let absent_number: Option<i32> = None; // Must explicitly type annotation if None

    println!("{:?}", some_number); // Output: Some(5)
    println!("{:?}", some_string); // Output: Some("a string")
    println!("{:?}", absent_number); // Output: None

    // You cannot directly use a value from Option<T> without handling the `None` case:
    // let x = some_number + 1; // This would be a compile error!

    // To get the value out, you must handle the variants, typically with `match` or `if let`.
}

The Result Enum

Similar to Option, the Result<T, E> enum is used for error handling:

enum Result<T, E> {
    Ok(T), // Represents a successful outcome with a value of type T
    Err(E), // Represents an error with an error value of type E
}

We’ll delve deeper into Result and error handling in a later chapter.

Pattern Matching

The match control flow operator allows you to compare a value against a series of patterns and then execute code based on which pattern the value matches. It’s exhaustive, meaning you must cover all possible cases.

#[derive(Debug)] // This allows us to print the enum variants with {:?}
enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter(UsState), // Quarter can hold data
}

#[derive(Debug)]
enum UsState {
    Alabama,
    Alaska,
    // ... many more states
    Texas,
}

fn value_in_cents(coin: Coin) -> u8 {
    match coin {
        Coin::Penny => {
            println!("Lucky penny!");
            1
        },
        Coin::Nickel => 5,
        Coin::Dime => 10,
        Coin::Quarter(state) => { // Destructure the data inside Quarter
            println!("State quarter from {:?}", state);
            25
        },
    }
}

fn main() {
    println!("Value: {}", value_in_cents(Coin::Penny));
    println!("Value: {}", value_in_cents(Coin::Quarter(UsState::Alaska)));
}

The match expression is exhaustive: if you forget to handle a variant, the compiler will tell you.

Matching with Option<T>

match is the primary way to extract values from Option<T>.

fn plus_one(x: Option<i32>) -> Option<i32> {
    match x {
        None => None,
        Some(i) => Some(i + 1), // Destructure Some variant, get i, then put i+1 back into Some
    }
}

fn main() {
    let five = Some(5);
    let six = plus_one(five);
    let none = plus_one(None);

    println!("{:?}", six); // Output: Some(6)
    println!("{:?}", none); // Output: None
}

The _ Placeholder

The _ pattern acts as a catch-all that matches any value not explicitly specified. It’s often used for cases where you don’t care about the specific value.

fn main() {
    let some_u8_value = 0u8; // 0 with explicit u8 type

    match some_u8_value {
        1 => println!("one"),
        3 => println!("three"),
        5 => println!("five"),
        7 => println!("seven"),
        _ => (), // Match any other value, do nothing (unit value ())
    }
}

if let

A common pattern is to only care about one variant of an enum and ignore the rest. if let provides a shorter way to write this than a match expression.

fn main() {
    let config_max = Some(3u8);

    // Using match
    match config_max {
        Some(max) => println!("The maximum is: {}", max),
        _ => (),
    }

    // Using if let (equivalent to the above match)
    if let Some(max) = config_max { // If config_max is Some, bind its value to `max`
        println!("The maximum is: {}", max);
    } else {
        println!("No maximum was configured."); // Optional else block
    }

    // Example with another enum
    enum State {
        Running,
        Paused,
        Stopped(String),
    }

    let current_state = State::Stopped(String::from("User requested stop"));

    if let State::Stopped(reason) = current_state {
        println!("System stopped because: {}", reason);
    } else {
        println!("System is not in stopped state.");
    }
    // Note: `current_state` is moved into the `if let` binding if it contains non-Copy data.
    // If it were a reference, it would be borrowed.
}

if let is syntactic sugar for a match that handles one pattern and ignores the rest.

while let

Similar to if let, while let allows you to repeatedly execute a block of code as long as a pattern matches. It’s commonly used with iterators or Option/Result when processing a sequence of values that might eventually become None or Err.

fn main() {
    let mut stack = Vec::new(); // A growable list (vector)

    stack.push(1);
    stack.push(2);
    stack.push(3);

    while let Some(top) = stack.pop() { // `pop()` returns Option<T>
        println!("{}", top);
    }
    // Output:
    // 3
    // 2
    // 1
}

This loop continues as long as stack.pop() returns Some(value), binding value to top. When pop() returns None (meaning the stack is empty), the loop terminates.

Exercises / Mini-Challenges

Exercise 5.1: Student Struct and Methods

Define a Student struct with fields for name (String), id (u32), and grades (a Vec<u32>). Implement the following methods for the Student struct:

  1. new(name: String, id: u32) -> Student: An associated function (constructor) that creates a new Student with the given name and ID, and an empty grades vector.
  2. add_grade(&mut self, grade: u32): A method that adds a new grade to the student’s grades vector.
  3. average_grade(&self) -> Option<f64>: A method that calculates the average of the student’s grades. It should return None if the student has no grades, and Some(average) otherwise.

In main, create a student, add some grades, and print their name, ID, and average grade.

Instructions:

  1. Define the Student struct.
  2. Implement the impl Student block with the three methods.
  3. In main():
    • Create a Student instance using Student::new().
    • Add at least 3 grades using add_grade().
    • Call average_grade() and use an if let or match statement to print the average if available, or a message indicating no grades.
// Solution Hint:
struct Student {
    name: String,
    id: u32,
    grades: Vec<u32>,
}

impl Student {
    fn new(name: String, id: u32) -> Student {
        // ...
    }

    fn add_grade(&mut self, grade: u32) {
        // ...
    }

    fn average_grade(&self) -> Option<f64> {
        if self.grades.is_empty() {
            None
        } else {
            let sum: u32 = self.grades.iter().sum();
            Some(sum as f64 / self.grades.len() as f64)
        }
    }
}

fn main() {
    let mut student1 = Student::new(String::from("Alice"), 101);
    student1.add_grade(90);
    student1.add_grade(85);
    student1.add_grade(92);

    println!("Student: {} (ID: {})", student1.name, student1.id);
    if let Some(avg) = student1.average_grade() {
        println!("Average grade: {:.2}", avg); // Format to 2 decimal places
    } else {
        println!("No grades recorded.");
    }
}

Exercise 5.2: Traffic Light Enum and Behavior

Define an enum TrafficLight with variants Red, Yellow, and Green.

Implement an associated function time_in_seconds(&self) -> u8 for TrafficLight that returns the number of seconds each light typically stays on.

  • Red: 30 seconds
  • Yellow: 5 seconds
  • Green: 45 seconds

In main, create instances of each TrafficLight variant and print how long each light stays on.

Instructions:

  1. Define the TrafficLight enum.
  2. Implement the impl TrafficLight block with the time_in_seconds method, using a match expression to determine the return value based on the variant.
  3. In main(), create let red_light = TrafficLight::Red; and similar for other colors.
  4. Print the results: The Red light lasts for 30 seconds.
// Solution Hint:
/*
enum TrafficLight {
    Red,
    Yellow,
    Green,
}

impl TrafficLight {
    fn time_in_seconds(&self) -> u8 {
        match self {
            TrafficLight::Red => 30,
            TrafficLight::Yellow => 5,
            TrafficLight::Green => 45,
        }
    }
}

fn main() {
    let red_light = TrafficLight::Red;
    let yellow_light = TrafficLight::Yellow;
    let green_light = TrafficLight::Green;

    println!("The {:?} light lasts for {} seconds.", red_light, red_light.time_in_seconds());
    // Note: You might need to add `#[derive(Debug)]` to TrafficLight for `{:?}`
}
*/

Exercise 5.3: Processing User Input with Option and if let

Write a simple program that asks the user to input a number. Use Option to represent whether a valid number was entered. If a valid number is received, double it and print the result. Otherwise, print an error message.

Instructions:

  1. In main, use std::io::stdin().read_line() to get input from the user.
  2. Use .trim().parse() to attempt to convert the input to an i32. This method returns a Result<i32, ParseIntError>.
  3. Convert the Result to an Option<i32> for easier handling in this exercise. Hint: result.ok() converts Result::Ok(T) to Some(T) and Result::Err(E) to None.
  4. Use an if let statement to check if the Option contains a number.
  5. If Some(num): print You entered {}, doubled is {}.
  6. If None: print Invalid input. Please enter a valid integer.
// Solution Hint:
/*
use std::io;

fn main() {
    println!("Please enter a number:");

    let mut input = String::new();
    io::stdin().read_line(&mut input)
        .expect("Failed to read line");

    let number_option: Option<i32> = input.trim().parse().ok();

    if let Some(num) = number_option {
        println!("You entered {}, doubled is {}.", num, num * 2);
    } else {
        println!("Invalid input. Please enter a valid integer.");
    }
}
*/

This exercise combines input/output, type conversion (which can fail, hence Result/Option), and pattern matching, all crucial skills in Rust.