Enums and Pattern Matching in Rust
About This Module
This module introduces Rust's enum (enumeration) types and pattern matching with match
and if let. Enums allow you to define custom types by enumerating possible variants,
and pattern matching provides powerful control flow based on enum values.
Prework
Prework Readings
Read the following sections from "The Rust Programming Language" book:
- Chapter 6.1: Defining an Enum
- Chapter 6.2: The match Control Flow Construct
- Chapter 6.3: Concise Control Flow with if let
Pre-lecture Reflections
Before class, consider these questions:
- How do enums help make code more expressive and type-safe?
- What advantages does pattern matching provide over traditional if-else chains?
- How might enums be useful for error handling in programs?
- What is the difference between enums in Rust and in other languages you know?
- When would you use
matchversusif letfor pattern matching?
Learning Objectives
By the end of this module, you should be able to:
- Define custom enum types with variants
- Create instances of enum variants
- Use
matchexpressions for exhaustive pattern matching - Apply
if letfor simplified pattern matching - Store data in enum variants
- Understand memory layout of enums
- Use the
#[derive(Debug)]attribute for enum display
Enums
enum is short for "enumeration" and allows you to define a type by enumerating
its possible variants.
The type you define can only take on one of the variants you have defined.
Allows you to encode meaning along with data.
Pattern matching using match and if let allows you to run different code depending on the value of the enum.
Python doesn't have native support for
enum, but it does have anenummodule that let's do something similar by subclassing anEnumclass.
Basic Enums
Let's start with a simple example:
// define the enum and its variants enum Direction { North, // <---- enum _variant_ East, South, West, SouthWest, } fn main() { // create instances of the enum variants let dir_1 = Direction::North; // dir is inferred to be of type Direction let dir_2: Direction = Direction::South; // dir_2 is explicitly of type Direction }
The enum declaration is defining our new type, so now a type called Direction is in scope,
similar to i32, f64, bool, etc., but it instances can only be one of the variants we have defined.
The let declarations are creating instances of the Direction type.
Aside: Rust Naming Conventions
Rust has a set of naming conventions that are used to make the code more readable and consistent.
You should follow these conventions when naming your enums, variants, functions, and other items in your code.
| Item | Convention |
|---|---|
| Crates | snake_case (but prefer single word) |
| Modules | snake_case |
| Types (e.g. enums) | UpperCamelCase |
| Traits | UpperCamelCase |
| Enum variants | UpperCamelCase |
| Functions | snake_case |
| Methods | snake_case |
| General constructors | new or with_more_details |
| Conversion constructors | from_some_other_type |
| Local variables | snake_case |
| Static variables | SCREAMING_SNAKE_CASE |
| Constant variables | SCREAMING_SNAKE_CASE |
| Type parameters | concise UpperCamelCase, usually single uppercase letter: T |
| Lifetimes | short, lowercase: 'a |
Using "use" as a shortcut
You can bring the variants into scope using use statements.
// define the enum and its variants enum Direction { North, East, South, West, SouthWest, } // Bring the variant `East` into scope use Direction::East; fn main() { // we didn't have to specify "Direction::" let dir_3 = East; }
Using "use" as a shortcut
You can bring multiple variants into scope using use statements.
// define the enum and its variants enum Direction { North, East, South, West, SouthWest, } // Bringing two options into the current scope use Direction::{East,West}; fn main() { let dir_4 = West; }
Using "use" as a shortcut
You can bring all the variants into scope using use statements.
enum Direction { North, East, South, West, } // Bringing all options in use Direction::*; fn main() { let dir_5 = South; }
Question: Why might we not always want to bring all the variants into scope?
Name clashes
use <enum_name>::*; will bring all the variants into scope, but if you have a variable with the same name as a variant, it will clash.
Uncomment the use Prohibited::*; line to see the error.
enum Prohibited { MyVar, YourVar, } // what happens if we bring all the variants into scope? // use Prohibited::*; fn main() { let MyVar = "my string"; let another_var = Prohibited::MyVar; println!("{MyVar}"); }
Aside: Quick Recap on Member Access
Different data structures have different ways of accessing their members.
fn main() { // Accessing an element of an array let arr = [1, 2, 3]; println!("{}", arr[0]); // Accessing an element of a tuple let tuple = (1, 2, 3); println!("{}", tuple.0); let (a, b, c) = tuple; println!("{}, {}, {}", a, b, c); // Accessing a variant of an enum enum Direction { North, East, South, West, } let dir = Direction::East; }
Using enums as parameters
We can also define a function that takes our new type as an argument.
enum Direction { North, East, South, West, } fn turn(dir: Direction) { return; } // this function doesn't do anything fn main() { let dir = Direction::East; turn(dir); }
Control Flow with match
Enums: Control Flow with match
The match statement is used to control flow based on the value of an enum.
enum Direction { North, East, South, West, } fn main() { let dir = Direction::East; // print the direction match dir { Direction::North => println!("N"), Direction::South => println!("S"), Direction::West => { // can do more than one thing println!("Go west!"); println!("W") } Direction::East => println!("E"), }; }
Take a close look at the match syntax.
Covering all variants with match
match is exhaustive, so we must cover all the variants.
// Won't compile enum Direction { North, East, South, West, } fn main() { let dir_2: Direction = Direction::South; // won't work match dir_2 { Direction::North => println!("N"), Direction::South => println!("S"), // East and West not covered }; }
But there is a way to match anything left.
Covering all variants with match
There's a special pattern, _, that matches anything.
enum Direction { North, East, South, West, } fn main() { let dir_2: Direction = Direction::North; match dir_2 { Direction::North => println!("N"), Direction::South => println!("S"), // match anything left _ => (), // covers all the other variants but doesn't do anything } }
Covering all variants with match
WARNING!!
The _ pattern has to be the last pattern in the match statement.
enum Direction { North, East, South, West, } fn main() { let dir_2: Direction = Direction::North; match dir_2 { _ => println!("anything else"), // will never get here!! Direction::North => println!("N"), Direction::South => println!("S"), } }
Recap of match
- Type of a switch statement like in C/C++ (Python doesn't have an equivalent)
- Must be exhaustive though there is a way to specify default (
_ =>)
Putting Data in an Enum Variant
- Each variant can come with additional information
- Let's put a few things together with an example
#[derive(Debug)] // allows us to print the enum by having Rust automatically // implement a Debug trait (more later) enum DivisionResult { Ok(f32), // This variant has an associated value of type f32 DivisionByZero, } // Return a DivisionResult that handles the case where the division is by zero. fn divide(x:f32, y:f32) -> DivisionResult { if y == 0.0 { return DivisionResult::DivisionByZero; } else { return DivisionResult::Ok(x / y); // Prove a value with the variant } } fn show_result(result: DivisionResult) { match result { DivisionResult::Ok(result) => println!("the result is {}",result), DivisionResult::DivisionByZero => println!("noooooo!!!!"), } } fn main() { let (a,b) = (9.0,3.0); // this is just short hand for let a = 9.0; let b = 3.0; println!("Dividing 9 by 3:"); show_result(divide(a,b)); println!("Dividing 6 by 0:"); show_result(divide(6.0,0.0)); // we can also call `divide`, store the result and print it let z = divide(5.0, 4.0); println!("The result of 5.0 / 4.0 is {:?}", z); }
Variants with multiple values
We can have more than one associated value in a variant.
enum DivisionResultWithRemainder { Ok(u32,u32), // Store the result of the integer division and the remainder DivisionByZero, } fn divide_with_remainder(x:u32, y:u32) -> DivisionResultWithRemainder { if y == 0 { DivisionResultWithRemainder::DivisionByZero } else { // Return the integer division and the remainder DivisionResultWithRemainder::Ok(x / y, x % y) } } fn main() { let (a,b) = (9,4); println!("Dividing 9 by 4:"); match divide_with_remainder(a,b) { DivisionResultWithRemainder::Ok(result,remainder) => { println!("the result is {} with a remainder of {}",result,remainder); } DivisionResultWithRemainder::DivisionByZero => println!("noooooo!!!!"), }; }
Getting the value out of an enum variant
We can use match to get the value out of an enum variant.
#[derive(Debug)] enum Message { Quit, Move { x: i32, y: i32 }, Write(String), ChangeColor(i32, i32, i32), } fn main() { let msg = Message::Write(String::from("Hello, world!")); // Extract values using match match msg { Message::Quit => println!("Quit message"), Message::Move { x, y } => println!("Move to ({}, {})", x, y), Message::Write(text) => println!("Write: {}", text), Message::ChangeColor(r, g, b) => println!("Color: RGB({}, {}, {})", r, g, b), } // Using if let for single variant extraction let msg2 = Message::Move { x: 10, y: 20 }; if let Message::Move { x, y } = msg2 { println!("Extracted coordinates: x={}, y={}", x, y); } }
A Note on the Memory Size of Enums
The size of the enum is related to the size of its largest variant, not the sum of the sizes.
Also stores a discriminant (tag) to identify which variant is stored.
use std::mem; enum SuperSimpleEnum { First, Second, Third } enum SimpleEnum { A, // No data B(i32), // Contains an i32 (4 bytes) C(i32, i32), // Contains two i32s (8 bytes) D(i64) // Contains an i64 (8 bytes) } fn main() { println!("Size of SuperSimpleEnum: {} bytes\n", mem::size_of::<SuperSimpleEnum>()); println!("Size of SimpleEnum: {} bytes\n", mem::size_of::<SimpleEnum>()); println!("Size of i32: {} bytes", mem::size_of::<i32>()); println!("Size of (i32, i32): {} bytes", mem::size_of::<(i32, i32)>()); println!("Size of (i64): {} bytes", mem::size_of::<(i64)>()); }
For variant C, it's possible that the compiler is aligning each i32 on an 8-byte boundary,
so the total size is 16 bytes. Common for modern 64-bit machines.
More on memory size of enums
use std::mem::size_of; enum Message { Quit, ChangeColor(u8, u8, u8), Move { x: i32, y: i32 }, Write(String), } enum Status { Pending, InProgress, Completed, Failed, } fn main() { // General case (on a 64-bit machine) println!("Size of Message: {} bytes", size_of::<Message>()); // C-like enum println!("Size of Status: {} bytes", size_of::<Status>()); // Prints 1 // References are addresses which are 64-bit (8 bytes) println!("Size of &i32: {} bytes", size_of::<&i32>()); // Prints 8 }
Displaying enums
By default Rust doesn't know how to display a new enum type.
Here we try to debug print the Direction enum.
// won't compile enum Direction { North, East, South, West, } fn main() { let dir = Direction::North; println!("{:?}",dir); }
Displaying enums (#[derive(Debug)])
Adding the #[derive(Debug)] attribute to the enum definition allows Rust to automatically implement the Debug trait.
#[derive(Debug)] enum Direction { North, East, South, West, } use Direction::*; fn main() { let dir = Direction::North; println!("{:?}",dir); }
match as expression
The result of a match can be used as an expression.
Each branch (arm) returns a value.
#[derive(Debug)] enum Direction { North, East, South, West, } use Direction::*; fn main() { // swap east and west let mut dir_4 = North; println!("{:?}", dir_4); dir_4 = match dir_4 { East => West, West => { println!("Switching West to East"); East } // variable mathching anything else _ => West, }; println!("{:?}", dir_4); }
Simplifying matching
Consider the following example (in which we want to use just one branch):
#[derive(Debug)] enum DivisionResult { Ok(u32,u32), DivisionByZero, } fn divide(x:u32, y:u32) -> DivisionResult { if y == 0 { DivisionResult::DivisionByZero } else { DivisionResult::Ok(x / y, x % y) } } fn main() { match divide(8,3) { DivisionResult::Ok(result,remainder) => println!("{} (remainder {})",result,remainder), _ => (), // <--- how to avoid this? }; }
This is a common enough pattern that Rust provides a shortcut for it.
Simplified matching with if let
if let allows for matching just one branch (arm)
#[derive(Debug)] enum DivisionResult { Ok(u32,u32), DivisionByZero, } fn divide(x:u32, y:u32) -> DivisionResult { if y == 0 { DivisionResult::DivisionByZero } else { DivisionResult::Ok(x / y, x % y) } } fn main() { if let DivisionResult::Ok(result,reminder) = divide(8,7) { println!("{} (remainder {})",result,reminder); }; }
Simplified matching with if let
Caution!
The single = is both an assignment and a pattern matching operator.
#[derive(Debug)] enum Direction { North, East, South, West, } use Direction::*; fn main() { let dir = North; if let North = dir { println!("North"); } }
if let with else
You can use else to match anything else.
#[derive(Debug)] enum Direction { North, East, South, West, } use Direction::*; fn main() { let dir = North; if let West = dir { println!("North"); } else { println!("Something else"); }; }
Enum variant goes on the left side
Caution!
You don't get a compile error, you get different behavior!
#[derive(Debug)] enum Direction { North, East, South, West, } use Direction::*; fn main() { let dir = North; // But it is important to have the enum // on the left hand side // if let West = dir { if let dir = West { println!("West"); } else { println!("Something else"); }; }
Single = for pattern matching
Remember to use the single = for pattern matching, not the double == for equality.
#[derive(Debug)] enum Direction { North, East, South, West, } use Direction::*; fn main() { let dir = North; // Don't do this. if let North == dir { println!("North"); } }
Best Practices
When to Use Enums:
- State representation: Modeling different states of a system
- Error handling: Representing success/failure with associated data
- Variant data: When you need a type that can be one of several things
- API design: Making invalid states unrepresentable
Design Guidelines:
- Use descriptive names: Make variants self-documenting
- Leverage associated data: Store relevant information with variants
- Prefer exhaustive matching: Avoid catch-all patterns when possible
- Use
if letfor single variant: When you only care about one case
In-Class Activity: "Traffic Light State Machine"
Activity Overview
Work in snall teams to create a simple traffic light system using enums and pattern matching.
Activity Instructions
You're given a TrafficLight enum.
Task:
- Create a function
next_lightthat takes aTrafficLightand returns the next state in the sequence: Red → Green(30) → Yellow(5) → Red(45) with the seconds remaining till the next light. - Create a function
get_light_colorthat takes a reference to aTrafficLight(&TrafficLight) and returns a string slice representation (&str) of the current light state - Create a function
get_time_remainingthat takes a reference to aTrafficLight(&TrafficLight) and returns the time remaining till the next light as au32 - Call next_light, and print the light color and the time remaining till the next light.
- Repeat this process 3 times.
#![allow(unused_variables)] #![allow(dead_code)] #[derive(Debug)] enum TrafficLight { Red(u32), // seconds remaining Yellow(u32), // seconds remaining Green(u32), // seconds remaining } // Your code here
Discussion Points
- How do we get the value out of the enum variants?
- How do we match on the enum variants?