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:
new(name: String, id: u32) -> Student: An associated function (constructor) that creates a newStudentwith the given name and ID, and an emptygradesvector.add_grade(&mut self, grade: u32): A method that adds a new grade to the student’sgradesvector.average_grade(&self) -> Option<f64>: A method that calculates the average of the student’s grades. It should returnNoneif the student has no grades, andSome(average)otherwise.
In main, create a student, add some grades, and print their name, ID, and average grade.
Instructions:
- Define the
Studentstruct. - Implement the
impl Studentblock with the three methods. - In
main():- Create a
Studentinstance usingStudent::new(). - Add at least 3 grades using
add_grade(). - Call
average_grade()and use anif letormatchstatement to print the average if available, or a message indicating no grades.
- Create a
// 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:
- Define the
TrafficLightenum. - Implement the
impl TrafficLightblock with thetime_in_secondsmethod, using amatchexpression to determine the return value based on the variant. - In
main(), createlet red_light = TrafficLight::Red;and similar for other colors. - 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:
- In
main, usestd::io::stdin().read_line()to get input from the user. - Use
.trim().parse()to attempt to convert the input to ani32. This method returns aResult<i32, ParseIntError>. - Convert the
Resultto anOption<i32>for easier handling in this exercise. Hint:result.ok()convertsResult::Ok(T)toSome(T)andResult::Err(E)toNone. - Use an
if letstatement to check if theOptioncontains a number. - If
Some(num): printYou entered {}, doubled is {}. - If
None: printInvalid 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.