Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

3. Control Flow

Rust’s control structures will look familiar for about five minutes. The shapes are the ones you know, with parentheses dropped and braces required. What is new is match, which does the work that switch, if-chains, destructuring, and type tests do in other languages, and does it with the compiler checking that you have not forgotten a case.

3.1 if and else

#![allow(unused)]
fn main() {
let n = 7;
if n % 2 == 0 {
    println!("even");
} else if n % 3 == 0 {
    println!("divisible by three");
} else {
    println!("neither");
}
}

The condition must be a bool. No parentheses, no truthiness, and the braces are required even for a single statement, which retires the dangling-else problem and C’s misindented-second-line bug.

Because if is an expression it can produce a value, and both arms must then agree on the type:

#![allow(unused)]
fn main() {
let n = 7;
let parity = if n % 2 == 0 { "even" } else { "odd" };
}

Leave off the else and the value is (), so an if without an else can only be used as a statement. That is the rule; the compiler’s phrasing of it, when you trip, is that the arms have incompatible types.

3.2 match

match compares a value against patterns in order and takes the first that fits. FizzBuzz, written against the pair of remainders:

fn main() {
    for n in 1..=15 {
        let line = match (n % 3, n % 5) {
            (0, 0) => "FizzBuzz".to_string(),
            (0, _) => "Fizz".to_string(),
            (_, 0) => "Buzz".to_string(),
            _ => n.to_string(),
        };
        println!("{line}");
    }
}

The patterns here are tuples, _ matches anything, and the arms are expressions separated by commas. Every arm has to produce the same type, which is why the numbers are converted with .to_string(): three arms offer text, so the fourth must too.

The property that makes match more than a switch is exhaustiveness. Leave a case out and the program does not compile:

error[E0004]: non-exhaustive patterns: `i32::MIN..=0_i32` and `3_i32..=i32::MAX` not covered
 --> non_exhaustive.rs:3:22
  |
3 |     let name = match n {
  |                      ^ patterns `i32::MIN..=0_i32` and `3_i32..=i32::MAX` not covered

For an integer that is mostly a nuisance and you write _ at the end. For the types in Chapter 6, where a value is one of a fixed set of alternatives, it is the feature the language is built around: add a new alternative and the compiler lists every place in the program that now has a hole in it.

Patterns

A pattern can be a literal, a range, an alternation, a binding, or a structure taken apart. All of these are arms:

0 => ...,                      // a literal
1..=9 => ...,                  // a range, both ends included
'a' | 'e' | 'i' | 'o' | 'u' => // alternation
n => ...,                      // binds anything to the name n
_ => ...,                      // matches anything, binds nothing
(x, 0) => ...,                 // a tuple, second element zero
[first, .., last] => ...,      // a slice, first and last elements

An arm can carry a guard, an if that runs after the pattern fits:

n if n < 0 => "negative",

and a binding can be attached to a pattern with @ so you can both test a value and keep it:

n @ 1..=9 => println!("single digit {n}"),

Here is all of it at once. The program reads words and says what each looks like:

// Read words from standard input and say what each one looks like.
use std::io::{self, Read};

fn classify(word: &str) -> String {
    match word {
        "" => "empty".to_string(),
        "yes" | "no" => format!("an answer: {word}"),
        w if w.parse::<i64>().is_ok() => format!("the number {w}"),
        w if w.parse::<f64>().is_ok() => format!("the float {w}"),
        w if w.chars().all(|c| c.is_ascii_alphabetic()) => match w.len() {
            1 => "a single letter".to_string(),
            2..=4 => "a short word".to_string(),
            _ => "a long word".to_string(),
        },
        w => format!("something else ({} chars)", w.chars().count()),
    }
}

fn main() {
    let mut text = String::new();
    io::stdin().read_to_string(&mut text).unwrap();

    for word in text.split_whitespace() {
        println!("{word}: {}", classify(word));
    }
}
$ printf 'no 42 3.5 x cat elephant hello! 7up\n' | cargo run -q -p ch03 --bin classify
no: an answer: no
42: the number 42
3.5: the float 3.5
x: a single letter
cat: a short word
elephant: a long word
hello!: something else (6 chars)
7up: something else (3 chars)

Order matters: "no" reaches the second arm before the guards get a chance, and 7up fails the numeric parse and the all-alphabetic test and falls to the end. Arms can nest, as the word-length match does. format! builds a String the way println! builds a line of output.

w if ... binds the word and then tests it. The closure |c| c.is_ascii_alphabetic() is Chapter 4’s business; read it for now as a small unnamed function passed to all.

3.3 Loops

Three loops, and they divide up cleanly. while runs while a condition holds. for walks a sequence. loop runs until something breaks out of it, and is the one C spells for (;;).

// How many steps does each starting value take to reach 1?
fn steps(mut n: u64) -> u32 {
    let mut count = 0;
    while n != 1 {
        n = if n % 2 == 0 { n / 2 } else { 3 * n + 1 };
        count += 1;
    }
    count
}

fn main() {
    let mut worst = (0, 0);
    for start in 1..1000 {
        let s = steps(start);
        if s > worst.1 {
            worst = (start, s);
        }
    }
    println!("longest under 1000: {} takes {} steps", worst.0, worst.1);
}
longest under 1000: 871 takes 178 steps

Note mut n: u64 in the parameter list. The function gets its own copy of the number and is free to modify it, and the caller’s value is untouched. That is the same call-by-value C has, said out loud.

break leaves the innermost loop and continue starts its next round. A loop can hand a value out through its break, the tidy way to write a search that has to run at least once:

#![allow(unused)]
fn main() {
let mut n = 1;
let first_big_square = loop {
    if n * n > 1000 {
        break n * n;
    }
    n += 1;
};
println!("{first_big_square}");
}

When loops nest and you need to leave more than one, label them:

#![allow(unused)]
fn main() {
let grid = [[1, 2, 3], [4, 5, 6], [7, 8, 9]];
let target = 5;
let mut found = None;

'rows: for (r, row) in grid.iter().enumerate() {
    for (c, value) in row.iter().enumerate() {
        if *value == target {
            found = Some((r, c));
            break 'rows;
        }
    }
}
println!("{found:?}");
}

A label is a name with a leading apostrophe, and break 'rows leaves the loop wearing it. This replaces the goto that C reaches for here; Rust has no goto.

.enumerate() pairs each item with its position, and .iter() walks a collection by reference. Chapter 7 covers both properly.

3.4 if let, let else, and while let

Much of the time a match has one arm you care about and a _ => {} for everything else. if let is the short form:

#![allow(unused)]
fn main() {
let maybe_port: Option<u16> = "8080".parse().ok();

if let Some(port) = maybe_port {
    println!("listening on {port}");
} else {
    println!("no port given");
}
}

let ... else is for the other common shape, where a failure means this function has nothing more to do:

#![allow(unused)]
fn main() {
fn show(text: &str) {
    let Ok(n) = text.parse::<i32>() else {
        println!("{text} is not a number");
        return;
    };
    println!("{}", n * 2);
}
}

The pattern binds for the rest of the enclosing block, and the else branch has to leave: return, break, continue, or a panic. It is how you keep the happy path flat instead of drifting rightwards with every check.

while let loops as long as the pattern keeps fitting, and is how you drain something:

#![allow(unused)]
fn main() {
let mut stack = vec![1, 2, 3];

while let Some(top) = stack.pop() {
    println!("{top}");
}
}

pop hands back Some(value) until the vector is empty and then None, which ends the loop. Chapter 1’s byte-copying program used the same construction on a Result.

3.5 What Is Missing

There is no goto, no do ... while (write loop with the test at the bottom), no fallthrough between match arms, no ternary operator, and no exceptions. The last of those is the significant one: a Rust function that can fail says so in its return type, and Chapter 8 is about what you do with that.

Exercise 3-1. Write the FizzBuzz program again using if/else if. Which version would you rather modify when a third divisor is added?

Exercise 3-2. Print the Collatz sequence itself for a starting value read from the command line, rather than its length. std::env::args().nth(1) gives you the first argument as an Option<String>.

Exercise 3-3. Write a function that takes a char and returns its category as a &'static str: vowel, consonant, digit, white space, or other. Use one match.

Exercise 3-4. The classify program tests is_ok() on a parse and throws the result away, then parses again. Rewrite it so each word is parsed at most once.

Exercise 3-5. Write a loop that finds the smallest positive integer n for which n * n has at least six digits, using loop and break with a value.