Rust Control Flow 101: How 'if', 'match', and 'loop' Shape Your Code

In this article, we’ll explore Rust control flow structures like if, match, and loops. We’ll also learn how to write efficient Rust code with practical examples and tips.

I found control flow statements such as if, match, and loop to offer a much better developer experience compared to other programming languages due to their flexibility. Almost all of them can be written as either expressions or statements. We will discuss this shortly. Unlike some programming languages, Rust doesn’t support do-while, switch, or traditional C-like for loops. However, there are easier ways to achieve the same functionality using Rust’s control flow semantics.

if/else

We use an if statement when we want to perform an action if something we expect is true. This is a vague statement, so let me break it down in terms of implementation.

if <condition> {
    // do some task
}

A normal if statement looks like the one above. The <condition> is a placeholder for a value or an expression that evaluates to a bool value. If this bool value is true, then the code inside {} will be executed. It’s as simple as that.

Unlike some programming languages like C and JavaScript, the if statement in Rust doesn’t require parentheses around the <condition>. If you nevertheless provide them, such as if (<condition>) {...}, they are stripped by the compiler and have no runtime impact.

fn is_even(value: u32) -> bool {
    value % 2 == 0 // same as `return value % 2 == 0;`
}

fn main() {
    let a = true;
    let b = 2;
    let c = 3;
    let d = 4;

    if a {
        println!("Value 'a' is true.");
    }

    if is_even(b) {
        println!("Integer 'b' is even.");
    }

    if !is_even(c) {
        println!("Integer 'c' is odd.")
    }

    if d % 2 == 0 {
        println!("Integer 'd' is even.")
    }
}
$ cargo run
Value 'a' is true.
Integer 'b' is even.
Integer 'c' is odd.
Integer 'd' is even.

In the example above, the code inside all if statements is executed since a, is_even(b), !is_even(c), and d % 2 == 0 all evaluate to true.

fn is_even(value: u32) -> bool {
    value % 2 == 0 // same as `return value % 2 == 0;`
}

fn main() {
    let b = 2;
    let c = 3;

    if is_even(b) {
        println!("Integer 'b' is even.");
    } else {
        println!("Integer 'b' is odd.");
    }

    if is_even(c) {
        println!("Integer 'c' is even.");
    } else {
        println!("Integer 'c' is odd.");
    }
}
$ cargo run
Integer 'b' is even.
Integer 'c' is odd.

If we want to execute some code conditionally when something is not true, we can optionally use the else block.

fn main() {
    let value = 3;

    if value < 0 {
        println!("Value is negative.");
    } else if value > 0 {
        println!("Value is positive.");
    } else {
        println!("Value is zero.");
    }
}
$ cargo run
Value is positive.

We can also have an optional else if statement that will try to match the condition if the previous if or else if condition doesn’t match. In the example above, since the value is greater than 0, the value > 0 expression evaluates to true, and that block is executed. If there are more else if blocks after it, they won’t be evaluated for condition checking.

An if/else statement in Rust can also be an expression. Unlike a statement, an expression evaluates to a value. In the examples so far, the if/else blocks are performing some actions, but they do not evaluate to a value. Let’s see how we can use if/else as an expression.

fn main() {
    let value = 3;

    let square_of_odd = if value % 2 == 0 {
        println!("Value is even.");
        value
    } else {
        println!("Value is odd.");
        value * value
    };

    println!("square_of_odd: {}", square_of_odd);
}
$ cargo run
Value is odd.
square_of_odd: 9

In the example above, we have assigned the if/else statement to a variable square_of_odd. This makes the if/else statement an expression, and the value evaluated by it will be stored in this variable. Notice that we have to put a ; (semicolon) at the end of the else block to qualify it for the let square_of_odd = <expr>; construct to, where <expr> is the expression which is our if/else blocks. But what value does the if/else evaluate to?

In Rust, if a block has an expression at the end, that block evaluates to that expression. The same goes for functions. If you find the is_even function strange above, it’s because instead of writing the return value % 2 == 0; statement, we placed the value % 2 == 0 expression. Since it’s at the end, it’s returned implicitly by the function. The same is happening here with our if/else block. Since we put value (without ;) in the if block and value * value in the else block, they will be returned (evaluated) based on which block is executed.

A block or a function that doesn’t have an expression at the end will return () (unit type) implicitly.

let square_of_odd = if value % 2 == 0 { value } else { value * value };

Rust doesn’t have a conditional ternary operator, which is quite common in C, JavaScript, or Python. However, since in Rust, if/else is also an expression, it’s quite easy to format it like a ternary conditional operator, as above.

match

The match statement is quite powerful in Rust. It’s similar to the switch statement in other programming languages, but it can do more, such as pattern matching, which is quite important when it comes to handling enums. Let’s uncover some interesting capabilities of the match statement.

fn main() {
    let value = 3;

    match value {
        1 => println!("value is 1."),
        2 => println!("value is 2."),
        3 => println!("value is 3."),
        _ => println!("Value is something else."),
    }
}
$ cargo run
value is 3.

In the program above, we have a match statement that is trying to match one of its arms with the value.

match <expr> {
    <pattern1> => <action1>,
    <pattern2> => <action2>,
    <pattern3> => <action3>,
}

A match arm has the <pattern> => <action> syntax, where <pattern> is a value or an expression that could match the value, and <action> is an expression (or a block {}) that will be evaluated if the pattern matches. It’s quite similar to the if/else statement but more flexible.

Here, the value is 3, and our match arms specify all possibilities of what the value could be. We say all possibilities because the _ pattern would match everything else not exclusively matched by the other match arms. If the match arms do not match all possibilities or if match doesn’t have _ (also called catch-all or wildcard pattern) arm, then we get a non-exhaustive patterns compile-time error.

fn main() {
    let value = 3;

    match value {
        1 => println!("value is 1."),
        2 => println!("value is 2."),
        3 => println!("value is 3."),
    }
}
$ cargo run
error[E0004]: non-exhaustive patterns: `i32::MIN..=0_i32` and `4_i32..=i32::MAX` not covered
 --> src/main.rs:4:11
  |
4 |     match value {
  |           ^^^^^ patterns `i32::MIN..=0_i32` and `4_i32..=i32::MAX` not covered
  |
  = note: the matched value is of type `i32`
help: ensure that all possible cases are being handled by adding a match arm with a wildcard pattern, a match arm with multiple or-patterns as shown, or multiple match arms

In Rust, match statement is designed to enforce exhaustiveness. It requires us to cover all possible cases to prevent missing any inputs, which helps avoid unexpected errors. By forcing us to handle every possible outcome, it makes our code more reliable and secure, reducing the risk of bugs or crashes. It also ensures that as our program evolves, we won’t accidentally forget to address new cases.

fn main() {
    let value = 3;

    let message = match value {
        1 => "value is 1.",
        2 => "value is 2.",
        3 => "value is 3.",
        _ => "Value is something else.",
    };

    println!("{}", message);
}

// value is 3.

Like if/else, match can also be expressed as an expression. However, you must ensure that all arms evaluate to the same data type, &str in this case; otherwise, you would get a “match arms have incompatible types” compilation error.

fn main() {
    let value = 3;

    let message = match value {
        1 | 2 | 3 => "value is 1 or 2 or 3.",
        _ => "Value is something else.",
    };

    println!("{}", message);
}

// value is 1 or 2 or 3.

We can also use the | (pipe) operator to match multiple patterns in a single match arm and execute a single action, as shown above.

Match Guards

fn main() {
    let value = 3;

    match value {
        n if n > 0 => println!("Value is positive."),
        n if n < 0 => println!("Value is negative."),
        _ => println!("Value is zero."),
    }
}

// Value is positive.

A match guard is an additional condition used within a match arm that allows us to further refine or filter when a particular arm should be executed. With the match guard, the match arm pattern becomes <pattern> if <condition> => <expr>. A limitation in Rust with this is that we always need to provide _ (catch-all) arms since the conditions are evaluate at runtime and the compiler won’t take guard conditions into account when checking if all patterns are covered by the match expression.

Match Range

fn main() {
    let value = 3;

    match value {
        1..10 => println!("Value is between [0,10)."),
        10..=100 => println!("Value is between [10,100]."),
        _ => println!("Value is something else."),
    }
}

// Value is between [0,10).

We can also match the a value in the match statement using ranges. In the example above, the match arm pattern uses a range to match the value. Since value 3 is in the range of 1..10, that arm is matched.

fn main() {
    let value = 300;

    match value {
        n @ 1..10 => println!("Value {} is between [0,10).", n),
        n @ 10..=100 => println!("Value {} is between [10,100].", n),
        n @ _ => println!("Value {} is something else.", n),
    }
}

// Value 300 is something else.

If we would like to use the value that was used by the range pattern, we can use the @ symbol to bind the matched value to the name n in the case above. Similar to a match statement with match guards, a match statement with range patterns must also have the _ (catch-all) arm.

for loop

Unlike the traditional C-like for loop syntax for i = 0; i < ..., the for loop in Rust provides a simpler syntax that matches Python’s for in loop syntax.

fn main() {
    for i in 0..3 {
        println!("i: {i}");
    }
}
$ cargo run
i: 0
i: 1
i: 2

In the example above, the for loop is iterating over values in the range 0..3. If we also want to include 3 in the range, we would use the 0..=3 expression.

fn main() {
    let v: Vec<i32> = vec![0, 1, 2];

    // i: i32
    for i in v {
        println!("i: {}", i);
    }

    // Error: borrow of moved value: `v`
    // println!("Vector v: {:?}", v); // <-- uncomment
}
$ cargo run
i: 0
i: 1
i: 2

In the example above, we are iterating over a vector v using the for loop. If we try to use the vector v again after the loop, such as in the println! statement above, the Rust compiler complains about it. This happens because the value of v is moved into the for loop and can no longer be used. The for loop takes ownership of the data in v, and v no longer has it, which is why we get this error. The type of i in the for loop is i32 because the for loop now has the data of the vector, allowing it to access its elements directly. We will discuss ownership and borrowing in detail in a separate lesson. For now, if you don’t want the for loop to take ownership of the data, we can instead use a reference to that data to iterate over it.

fn main() {
    let v: Vec<i32> = vec![0, 1, 2];

    // i: &i32
    for i in &v {
        println!("i: {}", i);
    }

    println!("Vector v: {:?}", v);
}
$ cargo run
i: 0
i: 1
i: 2
Vector v: [0, 1, 2]

In the example above, we have made a subtle modification. Instead of passing the value v, we passed a reference to it &v in the for loop. Now the for loop will iterate over references to the elements it contains, which is why i has the type &i32.

We saw in earlier lessons that data types such as array, vector, String, &str, and slices can be iterated over using the for loop. But what qualifies them to be used in the for loop? The answer is the Iterator trait. These types implement Iterator trait, which enforce that certain methods must be implemented on these types, which are needed by the for loop.

We are going to talk about iterators in detail in another dedicated lesson.

First of all, a type must implement the Iterator trait to be iterated over. This trait enforces the type to implement the next method. This method should return an Option enum that either contains Some(item), where item is the value in the iteration, or None, marking that there are no more values to be iterated over. Let’s imagine we have a custom struct type:

struct MyIterator {
    current: u8,
    max: u8,
}

Here, the MyIterator type is a struct that has current and max fields. The current field will be incremented on each iteration until it reaches the max value, which will mark the termination of the iteration.

impl Iterator for MyIterator {
    type Item = u8;

    fn next(&mut self) -> Option<Self::Item> {
        if self.current < self.max {
            let return_val = self.current;
            self.current += 1;

            Some(return_val)
        } else {
            None
        }
    }
}

The Iterator trait is part of Rust’s standard library and is included in the prelude, so we don’t need to import it explicitly. We implement the Iterator trait for our type MyIterator using the impl Iterator for MyIterator syntax. Once you do that, normally your IDE will intervene and autocomplete the rest of the boilerplate. For this trait, we need to provide a type for the values that will be yielded during the iteration, which is u8 in our case, along with the implementation of the next method that should return an Option enum for each iteration.

In the program above, the next method returns Option<Self::Item>, which translates to Option<u8>. When we return None from this method, the iteration ends. Otherwise, we need to return a Some(u8) value. Using the if/else statement, we check if self.current has reached self.max. If not, we return Some(u8); otherwise, we return None, which indicates the end of the iteration. self here is the mutable reference to the value we are iterating over, which is of type MyIterator.

fn main() {
    let mi = MyIterator { current: 0, max: 3 };

    // i: u8
    for i in mi {
        println!("i: {}", i);
    }
}
$ cargo run
i: 0
i: 1
i: 2

In the example above, mi is a struct of type MyIterator with field value current = 0 and max = 3. When we iterate over it using the for loop, the for loop calls the .next() method unless this method returns None. The value of i here is u8 which is extracted by the for loop from Some(u8).

fn main() {
    let v: Vec<i32> = vec![0, 1, 2];

    // i: (usize, &i32)
    for i in v.iter().enumerate() {
        println!("index: {}, value: {}", i.0, i.1);
    }
}
$ cargo run
index: 0, value: 0
index: 1, value: 1
index: 2, value: 2

If you would like to get the index of the element in the current iteration, most iterators support the .enumerate() method. This method returns an iterator that produces a tuple in each iteration, containing the current iteration index/count (usize) and the value returned by the .next() method of the iterator. In the example above, we had to use the .iter() method, which returns an iterator that does not consume or move the original vector data. This is the same as the &v reference we used before in the for loop; it internally calls the .iter() method in this case. Here, the .enumerate() returns an iterator that yields a reference to the value (usize, &i32). If you want the for loop to take ownership of v, you can use the .into_iter() method instead of .iter(), in which case the type of i would be (usize, i32).

fn main() {
    let v: Vec<i32> = vec![0, 1, 2];

    // (usize, &i32)
    for (index, value) in v.iter().enumerate() {
        println!("index: {}, value: {}", index, value);
    }
}
$ cargo run
index: 0, value: 0
index: 1, value: 1
index: 2, value: 2

We can also destructure complex types like tuples, structs, or enums directly within a for loop. In the example above, instead of receiving the value in the iteration in a variable i, we destructure it in place and receive it in index and value separately.

fn main() {
    for i in 1..10 {
        if i % 2 == 0 {
            continue;
        }

        if i == 9 {
            break;
        }

        println!("i: {}", i);
    }
}
$ cargo run
i: 1
i: 3
i: 5
i: 7

We can use the break and continue statements in the for loop to control the flow. In the example above, when i is even, the continue; statement is executed. When Rust encounters this statement in the for loop, it stops the current iteration and moves to the next one. When i = 9, Rust encounters the break; statement, which abruptly exits the for loop.

while loop

Rust also provides a while loop, which allows us to execute a piece of code indefinitely until a certain condition becomes false. In the while <condition> syntax, condition is a value or an expression that should evaluate to a bool. The while loop continues executing until it evaluates to false.

fn main() {
    let mut counter = 0;

    while counter < 10 {
        let curr_counter = counter;
        counter += 1;

        if curr_counter % 2 == 0 {
            continue;
        }

        if curr_counter == 9 {
            break;
        }

        println!("curr_counter: {}", curr_counter);
    }
}
$ cargo run
curr_counter: 1
curr_counter: 3
curr_counter: 5
curr_counter: 7

In the example above, our while loop continues executing until the counter reaches 10. Like the for loop, we can also use the break; and continue; statements to further adjust the control flow.

loop

Rust also provides us with a generic loop statement that doesn’t exit until we deliberately break it using the break statement. This is why loop is called an infinite loop, while while is referred to as a predicate loop.

fn main() {
    let mut counter = 0;

    loop {
        let curr_counter = counter;
        counter += 1;

        if curr_counter % 2 == 0 {
            continue;
        }

        if curr_counter >= 10 {
            break;
        }

        println!("curr_counter: {}", curr_counter);
    }
}
$ cargo run
curr_counter: 1
curr_counter: 3
curr_counter: 5
curr_counter: 7
curr_counter: 9

In the example above, the loop doesn’t have any conditions. It will loop infinitely, but as soon as the counter reaches 10, we break out of it.

fn main() {
    let mut counter = 0;

    let result: i32 = loop {
        let curr_counter = counter;
        counter += 1;

        if curr_counter % 2 == 0 {
            continue;
        }

        if curr_counter >= 10 {
            break 100;
        }
    };

    println!("Result: {}", result);
}

// Result: 100

One interesting thing about loop is that it can also be an expression. The value returned by loop has to be specified by the break statement; otherwise, it will return () (unit type). In the example above, the loop breaks when counter reaches 10, and break 100; is executed, which will return the value 100.

fn main() {
    let result: i32 = 'outer: loop {
        'inner: loop {
            break 'outer 100;
        }
    };

    println!("Result: {}", result);
}

// Result: 100

The break statement in a loop always breaks the parent loop. If we have nested loops, how can we break the outer loop from the inner loop using the break statement? For that, we need to provide labels to the loops. We provide labels using the '<label>: prefix and placing it before the loop keyword. In the example above, we have 'outer and 'inner labels for the outer and inner loops, respectively. When we use the break statement, we can specify which loop to break using the break '<label>; or break '<label> <value>; statements.

You can also use the break and continue expressions in the arms of a match statement that is inside a loop. This is a common control flow practice.

#rust #if #match #loops