Skip to content

03 · Control Flow

🎥 Video walkthrough

Rust's control flow will feel familiar if you've used any C-family language, with one big difference: if and loop are expressions — they can produce a value you bind directly to a variable, not just branch execution.

if / else if / else

fn main() {
    let temperature = 15;

    if temperature > 30 {
        println!("It's hot");
    } else if temperature > 10 {
        println!("It's mild");
    } else {
        println!("It's cold");
    }
    // It's mild
}

Note there are no parentheses required around the condition (unlike C/Java), but the braces are mandatory even for a single statement.

if as an expression

Because if/else evaluates to a value, you can use it directly in a let binding — as long as every branch produces the same type:

fn main() {
    let temperature = 15;

    let description = if temperature > 30 {
        "hot"
    } else if temperature > 10 {
        "mild"
    } else {
        "cold"
    };

    println!("It's {description}");   // It's mild
}

Each branch is an expression with no trailing semicolon — that's what makes its value "returned" from the block into description.

loop — an infinite loop that can return a value

loop repeats forever until you break out of it. break can carry a value, which becomes the result of the whole loop expression.

fn main() {
    let mut counter = 0;

    let result = loop {
        counter += 1;
        if counter == 10 {
            break counter * 2;   // stops the loop and yields this value
        }
    };

    println!("result = {result}");   // result = 20
}

This pattern is handy for retry logic or computations that need a few iterations before a value is ready.

while

while repeats as long as a condition holds — no value production, just repetition.

fn main() {
    let mut countdown = 3;

    while countdown > 0 {
        println!("{countdown}...");
        countdown -= 1;
    }
    println!("Liftoff!");
    // 3...
    // 2...
    // 1...
    // Liftoff!
}

for with ranges

for is the idiomatic way to iterate a known number of times or over a collection. 0..5 is a half-open range (excludes 5); 0..=5 is inclusive (includes 5).

fn main() {
    for i in 0..5 {
        print!("{i} ");
    }
    println!();
    // 0 1 2 3 4

    for i in 0..=5 {
        print!("{i} ");
    }
    println!();
    // 0 1 2 3 4 5
}

for over a collection

Iterating a Vec or array with .iter() gives you references to each element without taking ownership of the collection.

fn main() {
    let scores = vec![90, 85, 77, 100];

    for score in scores.iter() {
        println!("Score: {score}");
    }
    // Score: 90
    // Score: 85
    // Score: 77
    // Score: 100

    // `for x in &collection` is equivalent shorthand for `.iter()`
    let names = ["Alice", "Bob", "Cara"];
    for name in &names {
        println!("Hi, {name}");
    }
    // Hi, Alice
    // Hi, Bob
    // Hi, Cara
}

Vec<T> and other collection basics get their own module — see Module 6 — but this much is enough to loop over one.

continue

continue skips the rest of the current iteration and moves to the next one.

fn main() {
    for i in 1..=10 {
        if i % 2 != 0 {
            continue;   // skip odd numbers
        }
        print!("{i} ");
    }
    println!();
    // 2 4 6 8 10
}

How It Actually Works

if/else and loop producing a value (let x = if cond { 1 } else { 2 }; or let x = loop { break 5; };) is not syntactic sugar bolted on top — in Rust's grammar, if and loop are expressions, full stop, the same category as 1 + 1 or a function call. The compiler type-checks every arm of an if/else and requires them to unify to the same type precisely because the whole construct has to produce one value of one type, just like any other expression; an if with no else is only legal without a trailing value because it implicitly evaluates to () (unit) on the untaken path. This is why there's no ternary operator in Rust — if/else already does that job directly.

for i in 1..=10 compiles down to calling .into_iter() on the range and then repeatedly calling .next() on the resulting iterator inside a loop, checking each Option it returns and breaking on None — the desugared form is roughly let mut it = (1..=10).into_iter(); loop { match it.next() { Some(i) => { ... }, None => break } }. There is no hidden index variable or bounds check inserted by the compiler the way a C for loop needs one; LLVM typically inlines this iterator machinery away entirely in release builds, so the compiled loop is often identical to hand-written pointer arithmetic — this is the "zero-cost abstraction" idea in action: the high-level, safe for loop and manual iteration produce the same machine code.

Cheat sheet

Construct Produces a value? Typical use
if/else if/else Yes, can be used in let Branching, conditional value
loop Yes, via break value Repeat until a condition inside is met
while No Repeat while a condition holds
for x in 0..n No Repeat a known number of times
for x in collection.iter() No Visit every element of a collection
continue Skip to the next loop iteration
break Optional value (in loop) Exit a loop early

🔀 See this in another language

Exercise

Write a program that uses a for loop over 1..=20 to print "Fizz" for multiples of 3, "Buzz" for multiples of 5, "FizzBuzz" for multiples of both, and the number itself otherwise (skip printing anything extra using continue where it helps you keep the logic clean). Then write a loop that starts a counter at 1, doubles it each iteration, and breaks with the first value greater than 100, printing that final value.