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:

Pre-lecture Reflections

Before class, consider these questions:

  1. How do enums help make code more expressive and type-safe?
  2. What advantages does pattern matching provide over traditional if-else chains?
  3. How might enums be useful for error handling in programs?
  4. What is the difference between enums in Rust and in other languages you know?
  5. When would you use match versus if let for 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 match expressions for exhaustive pattern matching
  • Apply if let for 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 an enum module that let's do something similar by subclassing an Enum class.

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.

ItemConvention
Cratessnake_case (but prefer single word)
Modulessnake_case
Types (e.g. enums)UpperCamelCase
TraitsUpperCamelCase
Enum variantsUpperCamelCase
Functionssnake_case
Methodssnake_case
General constructorsnew or with_more_details
Conversion constructorsfrom_some_other_type
Local variablessnake_case
Static variablesSCREAMING_SNAKE_CASE
Constant variablesSCREAMING_SNAKE_CASE
Type parametersconcise UpperCamelCase, usually single uppercase letter: T
Lifetimesshort, 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 let for 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_light that takes a TrafficLight and 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_color that takes a reference to a TrafficLight (&TrafficLight) and returns a string slice representation (&str) of the current light state
  • Create a function get_time_remaining that takes a reference to a TrafficLight (&TrafficLight) and returns the time remaining till the next light as a u32
  • 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?