Lecture 10 - Enums and Pattern Matching in Rust

Logistics

  • Actitvity 9 solutions are posted on the site
  • HW3 will be due next Thursday - the night before the exam
  • Exam 1 is a week from Friday
    • Format: similar to activities (a bit easier) and mid-lecture quizes - match/define, fill-in, find bugs, short answer, one short hand-coding problem
    • No notes / reference sheets
    • Monday will cover new material that will NOT be on the exam
    • On Monday I will give you a final list of topics to review
    • Wednesday will be a review session with practice problems
  • HW2 is due TONIGHT at midnight
    • I have office hours today and we'll answer questions on piazza until ~6pm
    • Your submission is done when you've merged your work into main and then pushed to GitHub
    • You can check to make everything looks good by navigating to GitHub.

Grading rubric

Homework grading

  • On the syllabus: 1/3 correctness, 1/3 process, 1/3 style / best practices
  • In practice: 1/2 autograder (passing tests), 1/2 qualitative review (by CAs and TAs)

How this intersects with the lateness and corrections policies

  • If you push your last commit before the deadline, you get full credit. If you push again within the 48-hour late submission period, your grade will get scaled to 80%, and corrections can only bring you up to 80%.
  • At the 48-hour mark you will be locked out and won't be able to push until after your homework is graded.
  • After your homework is graded, you have one week to submit corrections:
    • Corrections for correctness / test-passing can recover half-credit
    • Corrections from feedback (the rubric) can recover full credit
  • A week after the homework is initially graded, we will shut down editing for good and record final grades (if you made corrections).

Some examples

Person 1

  • You submit your homework on time and get 100% on the autograder and 70% on the rubric, so your initial grade is 85%.
  • You push a new version of your homework within a week of receiving your grade, accounting for all the feedback you received, making your final grade 100%.

Person 2

  • You submit your homework 24 hours late and get 100% on the autograder and 90% on the rubric, so your initial grade is 95% * 0.8 = 76%.
  • You are capped at 80% for turning the assignment in late and decide not to push corrections, making your final grade 76%.

Person 3

  • You submit on time passing 6/10 tests for an autograder score of 60%, and get 70% on the rubric, so your initial grade is 65%.
  • You make corrections to pass all tests and fix all points of feedback, improving your autograde score to 80% and rubric score to 100%, making your final grade 90%.

Grading rubric for HW2 (will be similar for future HWs)

  • We will add a "code best practices" category later (including "idiomatic" Rust, error handling, efficiency/memory usage, ownership/borrowing) but we're not ready for it yet.

activity_preferences.png

Learning Objectives

By the end of this lecture, students should be able to:

  • Define custom enum types with variants and associated data
  • Use #[derive(Debug)] and #[derive(PartialEq)] for displaying and comparing enums
  • Use match statements for exhaustive pattern matching on enums
  • Work with Rust's built-in Option<T> and Result<T, E> enums

Enums

  • enum for "enumeration"
  • allows you to define a type (like i32 or bool) by enumerating its possible variants
  • use let to create instances of the enum variants
#![allow(unused)]
fn main() {
// define the enum and its variants
enum Direction {
    North,
    East,
    South,
    West,
    SouthWest,
}

// 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
}

Using "use" as a shortcut

enum Direction {
    North,
    East,
    South,
    West,
    SouthWest,
}
// Bring the variant `East` into scope
use Direction::East;
// Bringing two options into the current scope
use Direction::{South,West};

// we didn't have to specify "Direction::"
let dir_3 = East;
// Bringing all options in - THIS WON'T WORK IF THE ENUM IS IN THE SAME FILE
use Direction::*;
let dir_4 = North;

Using enums as parameters

We can also define a function that takes our new type as an argument.

fn turn(dir: Direction) { ... }

Displaying enums (#[derive(Debug)])

By default Rust doesn't know how to display a new enum type. We actually have to tell Rust we want to be able to do this first by adding the Debug "trait"

// #[derive(Debug)]
enum Direction {
    North,
    East,
    South,
    West,
}
use Direction::*;

fn main(){
    let dir = North;
    println!("{:?}",dir);
}

Comparing enums (#[derive(PartialEq)])

By default Rust doesn't know how to compare enum values for equality. We need to add the PartialEq "trait" to enable == and != comparisons.

// #[derive(PartialEq)]
enum Direction {
    North,
    East,
    South,
    West,
}
use Direction::*;

fn main(){
    let dir1 = North;
    let dir2 = North;
    let dir3 = South;

    println!("{}", dir1 == dir2); 
    println!("{}", dir1 != dir3); 
}

Control Flow with match

The match statement is used to control flow based on the value of an enum.

let dir = East;

match dir {
    North => println!("N"),
    South => println!("S"),
    West => {  // can do more than one thing
        println!("Go west!");
        println!("W")
    }
    East => println!("E"),
};

If we tried doing this with if/else statements it would have to look like:

// The ugly if/else version:
if dir.as_u8() == Direction::North.as_u8() {
    println!("N");
} else if dir.as_u8() == Direction::East.as_u8() {
    println!("E");
} else if dir.as_u8() == Direction::South.as_u8() {
    println!("S");
} else if dir.as_u8() == Direction::West.as_u8() {
    println!("Go west!");
    println!("W");
} else {
    // This should never happen, but compiler doesn't know that!
    unreachable!();
}

Covering all variants with match

match is exhaustive, so we must cover all the variants!

If we didn't...

enum Direction {
    North,
    East,
    South,
    West,
}
use Direction::*;
fn main() {
let dir_2: Direction = South;

match dir_2 {
    North => println!("N"),
    South => println!("S"),
    // East and West not covered
};
}

But there is a way to match anything left.

enum Direction {
    North,
    East,
    South,
    West,
}
use Direction::*;
fn main() {
let dir_2: Direction = Direction::North;

match dir_2 {
    North => println!("N"),
    South => println!("S"),
    
    // match anything left
    _ => (),  // covers all the other variants but doesn't do anything
}
}

WARNING - your catch-all has to go last or it'll gobble everything up!

match dir_2 {
    _ => println!("anything else"),
    
    // will never get here!!
    North => println!("N"),
    South => println!("S"),
}

Putting Data in an Enum Variant

  • Each variant can come with additional information
#![allow(unused)]
fn main() {
#[derive(Debug)] 
enum DivisionResult {
    Answer(u32), 
    DivisionByZero,
}

fn divide(x:u32, y:u32) -> DivisionResult {
    if y == 0 {
        return DivisionResult::DivisionByZero;
    } else {
        return DivisionResult::Answer(x / y); 
    }
}

let (a,b) = (9,3);  // this is just short-hand for let a = 9; let b = 3;

match divide(a,b) {
    DivisionResult::Answer(result)  // assigns the variant value to result
        => println!("This result is {}",result),
    DivisionResult::DivisionByZero
        => println!("noooooo!!!!"),
};
}

Variants with multiple values

#![allow(unused)]
fn main() {
enum DivisionResultWithRemainder {
    Answer(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 {
        DivisionResultWithRemainder::Answer(x / y, x % y) // Return the integer division and the remainder
    }
}

let (a,b) = (9,4);
match divide_with_remainder(a,b) {
    DivisionResultWithRemainder::Answer(result,remainder) => {
            println!("the result is {}",result);
            println!("the remainder is {}",remainder);
    }
    DivisionResultWithRemainder::DivisionByZero
        => println!("noooooo!!!!"),
};
}

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_facing = North;
println!("{:?}", dir_facing);

let after_turning_left = match dir_facing {
    North => West,
    West => South,
    South => East,
    East => North
};

println!("{:?}", after_turning_left);
}

Beyond enums - pattern matching other types (FYI)

match works on more than just enums:

Matching tuples:

#![allow(unused)]
fn main() {
let point = (3, 5);
match point {
    (0, 0) => println!("Origin"),
    (0, y) => println!("On Y-axis at {}", y),
    (x, 0) => println!("On X-axis at {}", x),
    (x, y) => println!("Point at ({}, {})", x, y),
}
}

Matching ranges:

#![allow(unused)]
fn main() {
let age = 25;
match age {
    0..=12 => println!("Child"),
    13..=19 => println!("Teenager"),
    20..=64 => println!("Adult"),
    65.. => println!("Senior"),
}
}

Matching conditions:

#![allow(unused)]
fn main() {
let number = 42;
match number {
    x if x % 2 == 0 => println!("{} is even", x),
    x => println!("{} is odd", x),
}
}

Destructuring arrays:

#![allow(unused)]
fn main() {
let arr = [1, 2, 3];
match arr {
    [1, 2, 3] => println!("Exact match"),
    [1, _, _] => println!("Starts with 1"),
    [_, _, 3] => println!("Ends with 3"),
    _ => println!("Something else"),
}
}

Quick Review: Lectures 7-9

1. Variables & Types (Lecture 7)

#![allow(unused)]
fn main() {
let x = 5;
x = 10;  // What happens here?
}
  1. Works fine
  2. Compiler error
  3. Runtime error

2. Functions (Lecture 8)

#![allow(unused)]
fn main() {
fn calculate(a: i32, b: i32) -> i32 {
    a + b; 
}
}

What does this function return?

  1. The sum of a and b
  2. The unit type ()
  3. A compiler error

3. Loops & Arrays (Lecture 9)

#![allow(unused)]
fn main() {
let arr = [1, 2, 3, 4, 5];
for (index, value) in arr.iter().enumerate() {
    if value % 2 == 0 {
        ________ 
    }
    println!("Index: {}, Value: {}", index, value);
}
}

What goes in the blank to skip to next iteration without printing?

Enum Option<T>

There is a built-in enum Option<T> with two variants:

  • Some(T) -- The variant Some contains a value of type T
  • None

Useful for when there may be no output

  • Like None or null in other languages
  • Rust makes you explicitly handle them, preventing bugs that are extremely common in other languages
  • This might look a little like optional parameters in python (def myfn(arg: Optional[int] = None): but functions differently)

An Option<T> example

Here's example prime number finding code that returns Option<u32> if a prime number is found, or None if not.

If a prime number is found, it returns Some(u32) variant with the prime number.

If the prime number is not found, it returns None.

fn main(){
    fn is_prime(x:u32) -> bool {
        if x <= 1 { return false;}
        for i in 2..=((x as f64).sqrt() as u32) {
            if x % i == 0 { return false; }
        } 
        true
    }

    fn prime_in_range(a:u32,b:u32) -> Option<u32> {  // returns an Option<u32>
        for i in a..=b {
            if is_prime(i) {return Some(i);}
        }
        None
    }

    let tmp : Option<u32> = prime_in_range(90,906);
    // let tmp : Option<u32> =  prime_in_range(20,22);
    println!("{:?}",tmp);
}

Extracting the contents of an Option with match

fn main() {
let tmp : Option<u32> = Some(3);
// let tmp: Option<u32> = None;

match tmp {
    Some(x) => println!("A second way: {}",x),
    None => println!("None"),
};
}

FYI - other ways to extract values (but we'll generally prefer match):

fn main() {
let tmp : Option<u32> = Some(3);
// let tmp: Option<u32> = None;

if let Some(x) = tmp {
    println!("One way: {}",x);
}

println!("Another way: {}", tmp.unwrap()); // this will panic if tmp is None!
}

Enum Option<T>: useful methods

Check the variant

  • .is_some() -> bool
  • .is_none() -> bool

Get the value in Some or terminate with an error

  • .unwrap() -> T
  • .expect(message) -> T

Get the value in Some or a default value

  • .unwrap_or(default_value:T) -> T

Enum Result<T, E>

We saw this one with guessing game. Another built-in enum with two variants:

  • Ok(T)
  • Err(E)

Similar to Option except we have a value associated with both cases.

Useful when you want to pass a solution or information about an error.

For example:

#![allow(unused)]
fn main() {
fn divide_safely(x: f64, y: f64) -> Result<f64, String> {
    if y == 0.0 {
        Err("Cannot divide by zero!".to_string())
    } else {
        Ok(x / y)
    }
}
}

Enum Result<T, E>: useful methods

Check the variant

  • .is_ok() -> bool
  • .is_err() -> bool

Get the value in Ok or terminate with an error

  • .unwrap() -> T
  • .expect(message) -> T

Get the value in Ok or a default value

  • .unwrap_or(default_value:T) -> T

Summary of Option<T> and Result<T,E>

  • Option<T> has variants that look like Some(value of type T) and None
  • Result<T,E> has variants that look like Ok(value of type T) and Err(error_info of type E)

Activity Time

Bonus - Simplified matching with if let (FYI)

We can't do a quick if on an enum like this:

enum Direction {
    North,
    East,
    South,
    West,
}
use Direction::*;
fn main() {
let dir: Direction = North;
if dir == North {
    println!("North");
}
}

Instead we would have to:

enum Direction {
    North,
    East,
    South,
    West,
}
use Direction::*;
fn main() {
let dir: Direction = North;
match dir {
    North => println!("North"),
    _ => (),
};
}

But this happens often enough that there's a shorthand:

enum Direction {
    North,
    East,
    South,
    West,
}
use Direction::*;
fn main() {
let dir: Direction = North;
if let North = dir { // YES THIS LOOKS BACKWARDS! It's a more like match than if
    println!("North");
};
}

You can use else to match anything else like a regular if statement

if let North = dir { 
    println!("North");
} else {
    println!("Going somewhere else");
};