New: Try Voli The Bear, Fast package manager (and not only) for Windows
Updated Jun 19, 2026 Edit on GitHub

Control Flow & Functions

So far your programs run straight down, top to bottom. Real programs branch ("if it's hot, say so"), repeat ("for each score, print it"), and split work into named, reusable pieces (functions). Along the way you'll meet two ideas that make Rust feel different from most languages: almost everything is an expression that produces a value, and match forces you to handle every possible case. That second one is the star of the phase - the feature Rust programmers miss most when they go back to other languages.

if / else - and it's an expression

You know if/else. The twist in Rust: an if doesn't just do something, it produces a value, so you can assign its result directly to a variable.

fn main() {
    let temp = 30;
    let label = if temp > 25 { "hot" } else { "mild" };
    println!("{label}");
}
$ cargo run
hot

What just happened: if temp > 25 { "hot" } else { "mild" } evaluated to one of the two strings and let label = caught it. There's no ternary ? : in Rust because plain if already does that job. (Both branches must produce the same type.)

📝 Terminology. An expression produces a value (2 + 2, if ... { } else { }). A statement does something but produces no usable value (let x = 5;). Rust leans hard on expressions - keep this distinction in mind; it explains how functions return, below.

The loops: for, while, loop

Rust has three ways to repeat, each for a different shape of problem.

for ... in - walking over a collection or a range. This is the one you'll use most:

fn main() {
    for n in [10, 20, 30] {
        println!("{n}");
    }
    for i in 0..3 {
        println!("i = {i}");
    }
}
$ cargo run
10
20
30
i = 0
i = 1
i = 2

What just happened: The first loop walked the array. The second walked a range, 0..3, meaning "0 up to but not including 3" - so 0, 1, 2. (To include the end, use 0..=3 for 0, 1, 2, 3.)

while - repeats as long as a condition holds:

fn main() {
    let mut count = 0;
    while count < 3 {
        println!("count {count}");
        count += 1;
    }
}
$ cargo run
count 0
count 1
count 2

What just happened: The loop ran while count < 3, printing and incrementing each pass, then stopped once count reached 3. (Forgetting mut on count bites people here, as warned in Phase 2.)

loop - repeats forever until you break out. Useful when the exit condition is in the middle, not the top:

fn main() {
    let mut n = 1;
    loop {
        if n > 3 {
            break;
        }
        println!("n = {n}");
        n += 1;
    }
}
$ cargo run
n = 1
n = 2
n = 3

What just happened: loop runs unconditionally; break is the only way out (here, once n > 3). Reach for it when "keep going until something happens" reads more naturally than a while condition up top.

match - the star: handle every case, exhaustively

What it actually is. match compares a value against a list of patterns and runs the first one that fits. It's like a switch from other languages, but with a superpower: it must cover every possible case. Forget one and the program won't compile - sounds strict, but it's one of Rust's best bug-prevention features.

fn classify(n: i32) -> &'static str {
    match n {
        0 => "zero",
        1 | 2 | 3 => "small",
        4..=9 => "medium",
        _ => "large",
    }
}

fn main() {
    for n in [0, 2, 7, 100] {
        println!("{n} is {}", classify(n));
    }
}
$ cargo run
0 is zero
2 is small
7 is medium
100 is large

What just happened: Each arm (pattern => result) is checked top to bottom; the first match wins. 1 | 2 | 3 matches any of those values; 4..=9 matches the inclusive range 4 through 9; _ is the catch-all. Like if, match is an expression, which is why classify can hand the whole match back as its answer.

Here's the flow of evaluating a single value through that match:

Why "exhaustive" is a gift, not a chore

Say you have a type with a fixed set of options (an enum - a value that's exactly one of several named variants), and you forget to handle one:

enum Light { Red, Yellow, Green }

fn action(l: Light) -> &'static str {
    match l {
        Light::Red => "stop",
        Light::Green => "go",
        // forgot Yellow!
    }
}
$ cargo run
error[E0004]: non-exhaustive patterns: `Light::Yellow` not covered
 --> src/main.rs:4:11
  |
4 |     match l {
  |           ^ pattern `Light::Yellow` not covered
  |
note: `Light` defined here
 --> src/main.rs:1:6
  |
1 | enum Light { Red, Yellow, Green }
  |      ^^^^^        ------ not covered
help: ensure that all possible cases are being handled by adding a match arm with a wildcard pattern or an explicit pattern as shown

What just happened: The compiler refused to build because Light::Yellow isn't handled. In most languages this would compile fine and quietly do nothing for yellow lights, a bug you'd find in production. Rust catches it at compile time and names exactly which case you missed.

💡 Key point. Exhaustive match means: add a new variant to an enum later, and the compiler walks you to every match that now needs updating. "Handle all the cases" stops being something you remember - the compiler remembers for you. Why refactoring Rust feels safe.

⚠️ Gotcha - _ can hide bugs. The catch-all _ also satisfies exhaustiveness. Perfect for genuinely-infinite types like i32 (as in classify), but on an enum it switches off the helpful "you forgot a case" check. So on enums, prefer listing variants explicitly when you reasonably can - you want the compiler to nag you when a new variant appears.

Functions - and returning without return

What it actually is. A function is a named, reusable block of logic that takes inputs (parameters) and optionally produces an output. Define one with fn, name the parameter types (always required), and name the return type after ->.

The Rust-flavored part: a function returns the value of its last expression, which has no semicolon.

fn square(x: i32) -> i32 {
    x * x
}

fn main() {
    println!("square of 5 is {}", square(5));
}
$ cargo run
square of 5 is 25

What just happened: square takes an i32 named x and returns an i32 (-> i32). Its body is the single expression x * x - no semicolon - so that value becomes the return value. You don't need to write return x * x;, though you can; return exists mainly for returning early mid-function.

⚠️ The semicolon gotcha that bites everyone once. A semicolon turns an expression into a statement, which produces no value. Writing x * x; as the last line means the function returns "nothing" - and if you promised an i32, the compiler stops you with a "mismatched types" error, expecting i32 but finding () (Rust's "no value," the unit type). The fix is almost always: delete the trailing semicolon. Once "last line, no semicolon = the return value" clicks, this stops happening.

📝 Terminology. () is the unit type - Rust's way of saying "no meaningful value." A function with no -> Type returns () (it's run for its side effects, like printing).

Recap

  1. if/else is an expression - it produces a value you can assign; there's no ternary because if covers it.
  2. for ... in walks collections and ranges (0..3 excludes the end; 0..=3 includes it); while loops on a condition; loop runs until break.
  3. match compares a value to patterns and is exhaustive - forget a case and it won't compile. Like if, it's an expression that produces a value.
  4. Exhaustiveness is a gift: add an enum variant and the compiler points you to every match to fix.
  5. Functions use fn, require parameter types, and return after ->. The last expression with no semicolon is the return value - a stray semicolon there is the classic beginner bug.

You can now branch, loop, and factor logic into functions. As programs grow past one file, you'll need a way to organize them - that's next: modules, crates, and how a real Rust project is laid out. Right after that comes the phase everything has been building toward: ownership.


← Phase 3: Collections · Guide overview · Phase 5: Modules & Project Layout →

Before the quiz: without looking back, say (or jot down) the core idea of this phase in your own words.

Check your understanding 3 questions

1. Why does Rust have no ternary `? :` operator?

2. What makes `match` special in Rust?

3. How does a Rust function return its value?