Control Flow, Functions and Pattern Matching

Article · 12:00

Almost everything in Rust is an expression, including if and match, so control flow produces values rather than assigning to them. With match that the compiler proves exhaustive, and if let, let else and while let for the one case you care about.

Control Flow, Functions and Pattern Matching

Almost everything in Rust is an expression, including if and match, so control flow produces values rather than assigning to them.

fn main() {
    let scores = [88, 42, 95, 61];

    for score in scores {
        println!("{score}: {}", grade(score));
    }

    for (i, score) in scores.iter().enumerate() {
        if *score == 42 {
            continue;
        }
        if *score > 90 {
            println!("  stopping at index {i}");
            break;
        }
    }

    let mut countdown = 3;
    while countdown > 0 {
        println!("t-minus {countdown}");
        countdown -= 1;
    }

    // `loop` is the infinite one, and it can return a value with break.
    let mut n = 0;
    let doubled = loop {
        n += 1;
        if n > 2 {
            break n * 2;
        }
    };
    println!("loop produced {doubled}");

    // if is an expression, so this is Rust's ternary.
    let label = if doubled > 5 { "big" } else { "small" };
    println!("{label}");
}

fn grade(score: i32) -> &'static str {
    if score >= 90 {
        "excellent"
    } else if score >= 60 {
        "pass"           // no semicolon: this is the value of the block
    } else {
        "resit"
    }
}

A block's last expression, written without a semicolon, is its value. Adding a semicolon turns it into a statement and the block produces () - which is the single most common beginner error, and the compiler explains it well.

match is exhaustive, and that is the point

fn main() {
    for code in [200, 302, 404, 500] {
        println!("{code} -> {}", describe(code));
    }
    for n in [-1, 0, 5, 500] {
        println!("{n} -> {}", categorise(n));
    }
}

fn describe(code: u16) -> &'static str {
    match code {
        200 => "ok",
        301 | 302 => "redirected",          // several patterns, one arm
        404 => "not found",
        500..=599 => "server error",        // an inclusive range
        _ => "something else",              // the catch-all
    }
}

fn categorise(n: i32) -> String {
    match n {
        n if n < 0 => format!("{n} is negative"),   // a guard
        0 => "zero".to_string(),
        1..=9 => "small".to_string(),
        10..=99 => "medium".to_string(),
        _ => "large".to_string(),
    }
}

match must cover every possible value. Remove the _ arm from describe and it will not compile - which is a feature, because it means adding a variant to an enum tells you every place that needs updating.

Destructuring

struct Point {
    x: i32,
    y: i32,
}

enum Shape {
    Circle { radius: f64 },
    Rect { width: f64, height: f64 },
    Unit,
}

fn main() {
    let (a, b) = (1, "two");
    println!("{a} {b}");

    let point = Point { x: 3, y: 4 };
    let Point { x, y } = point;             // destructure a struct
    println!("{x},{y}");

    for shape in [
        Shape::Circle { radius: 1.0 },
        Shape::Rect { width: 2.0, height: 3.0 },
        Shape::Unit,
    ] {
        println!("{}", area(&shape));
    }

    // if let: one pattern, when a full match would be noise.
    let maybe: Option<i32> = Some(7);
    if let Some(n) = maybe {
        println!("got {n}");
    }

    // let else: bind or leave, with no nesting.
    let Some(value) = maybe else {
        println!("nothing");
        return;
    };
    println!("value is {value}");

    // while let: keep going until the pattern stops matching.
    let mut stack = vec![1, 2, 3];
    while let Some(top) = stack.pop() {
        print!("{top} ");
    }
    println!();
}

fn area(shape: &Shape) -> String {
    match shape {
        Shape::Circle { radius } => format!("circle: {:.2}", std::f64::consts::PI * radius * radius),
        Shape::Rect { width, height } => format!("rect: {:.2}", width * height),
        Shape::Unit => "unit: 1.00".to_string(),
    }
}

Functions, and the expression at the end

fn main() {
    println!("{}", add(2, 3));
    println!("{:?}", min_max(&[5, 2, 9, 1]));
    println!("{}", apply_twice(|n| n * 2, 3));

    let mut counter = make_counter();
    println!("{} {} {}", counter(), counter(), counter());

    // A closure that borrows, and one that takes ownership with `move`.
    let name = String::from("Ada");
    let borrowing = || println!("borrowed {name}");
    borrowing();
    println!("{name} is still usable");

    let owned = String::from("Grace");
    let taking = move || println!("owned {owned}");
    taking();
    // println!("{owned}");     // moved into the closure
}

fn add(a: i32, b: i32) -> i32 {
    a + b                         // no `return`, no semicolon
}

fn min_max(values: &[i32]) -> (i32, i32) {
    let mut lo = values[0];
    let mut hi = values[0];
    for &v in values {
        if v < lo {
            lo = v;
        }
        if v > hi {
            hi = v;
        }
    }
    (lo, hi)
}

fn apply_twice<F: Fn(i32) -> i32>(f: F, start: i32) -> i32 {
    f(f(start))
}

fn make_counter() -> impl FnMut() -> i32 {
    let mut count = 0;
    move || {
        count += 1;
        count
    }
}

The three closure traits say how the closure uses what it captures: Fn reads, FnMut mutates, FnOnce consumes. You rarely write them by hand - the compiler infers which one a closure is - but they appear in signatures constantly.

What to take away

  • Almost everything is an expression; a block's last expression, without a semicolon, is its value.
  • match is exhaustive, so adding an enum variant finds every place to update.
  • Guards (n if n < 0), ranges (1..=9) and | alternatives compose in patterns.
  • if let, let else and while let handle the single-pattern cases without nesting.
  • Closures capture by reference by default; move takes ownership.
  • Fn, FnMut and FnOnce describe how a closure uses what it captured.