03. Functions and Control Flow

📋 Jump to Takeaways

🎁 Why doesn't Rust have a ternary operator (condition ? a : b)? Because if/else already IS an expression that returns a value. Every control flow construct in Rust produces something — and that changes how you write code.

Function Signatures

Every function in Rust starts with fn, followed by a name, parameters with explicit types, and an optional return type. Rust never infers parameter types — you must be explicit.

fn add(x: i32, y: i32) -> i32 {
    x + y
}

fn greet(name: &str) {
    println!("Hello, {}!", name);
}

fn main() {
    let result = add(5, 3);
    println!("{}", result); // 8
    greet("Rustacean");     // Hello, Rustacean!
}

Functions without a -> Type annotation implicitly return the unit type () — Rust's way of saying "nothing meaningful."

Implicit Return

The last expression in a function body becomes the return value — no return keyword needed. The key: no semicolon on the final expression. A semicolon turns an expression into a statement, which returns ().

fn square(n: i32) -> i32 {
    n * n  // no semicolon = this is the return value
}

fn square_broken(n: i32) -> i32 {
    n * n; // semicolon = returns (), compiler error!
}

fn main() {
    println!("{}", square(4)); // 16
}

You can still use return for early exits:

fn absolute(n: i32) -> i32 {
    if n < 0 {
        return -n; // early return
    }
    n // implicit return
}

fn main() {
    println!("{}", absolute(-7)); // 7
    println!("{}", absolute(3));  // 3
}

If/Else as an Expression

Since if/else returns a value, you can assign it directly to a variable. Both branches must return the same type.

fn main() {
    let age = 20;

    let status = if age >= 18 {
        "adult"
    } else {
        "minor"
    };

    println!("{}", status); // adult

    // Use it inline
    let price = if age >= 65 { 0 } else { 10 };
    println!("Ticket: ${}", price); // Ticket: $10
}

This is why Rust doesn't need a ternary operator — if/else already does the job, and it's more readable.

Loops: loop, while, for

Rust has three loop types. loop runs forever until you break. while runs while a condition is true. for iterates over a range or collection.

fn main() {
    // loop — infinite until break
    let mut count = 0;
    loop {
        count += 1;
        if count == 3 {
            break;
        }
    }
    println!("count: {}", count); // count: 3

    // while
    let mut n = 5;
    while n > 0 {
        print!("{} ", n);
        n -= 1;
    }
    println!(); // 5 4 3 2 1

    // for with a range
    for i in 1..4 {
        print!("{} ", i);
    }
    println!(); // 1 2 3
}

Loop with Break Returning a Value

loop is also an expression. You can return a value from break, which makes it perfect for retry logic or searching.

fn main() {
    let mut counter = 0;

    let result = loop {
        counter += 1;
        if counter == 10 {
            break counter * 2; // returns 20
        }
    };

    println!("result: {}", result); // result: 20
}

No other language does this quite as cleanly. The break keyword carries the value out of the loop.

Ranges

Rust ranges come in two flavors: exclusive (..) and inclusive (..=).

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

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

    // Ranges work with characters too
    for c in 'a'..='d' {
        print!("{} ", c);
    }
    println!(); // a b c d
}

Use ..= when you want to include the upper bound. Off-by-one errors become impossible when you pick the right range type.

Match: Pattern Matching

match is Rust's superpower for control flow. It's like a switch statement, but the compiler guarantees you handle every possible case.

fn main() {
    let number = 3;

    let word = match number {
        1 => "one",
        2 => "two",
        3 => "three",
        _ => "other", // _ catches everything else
    };

    println!("{}", word); // three
}

match must be exhaustive — miss a case and the compiler rejects your code. The _ wildcard handles "everything else."

Match with Enums

match truly shines with enums. The compiler ensures you handle every variant.

enum Direction {
    North,
    South,
    East,
    West,
}

fn describe(dir: Direction) -> &'static str {
    match dir {
        Direction::North => "going up",
        Direction::South => "going down",
        Direction::East => "going right",
        Direction::West => "going left",
    }
}

fn main() {
    println!("{}", describe(Direction::East)); // going right
}

Remove one arm from that match and the compiler will refuse to compile — it knows you forgot a case. This eliminates an entire category of bugs.

Match with Multiple Patterns and Guards

You can combine patterns with | and add conditions with if guards.

fn main() {
    let score = 85;

    let grade = match score {
        90..=100 => "A",
        80..=89 => "B",
        70..=79 => "C",
        60..=69 => "D",
        _ => "F",
    };

    println!("Grade: {}", grade); // Grade: B

    // Multiple patterns with |
    let day = 6;
    let kind = match day {
        1..=5 => "weekday",
        6 | 7 => "weekend",
        _ => "invalid",
    };

    println!("{}", kind); // weekend
}

Match guards add an if condition to an arm:

fn main() {
    let temperature = 105;

    let status = match temperature {
        t if t > 100 => "boiling",
        t if t < 0 => "freezing",
        _ => "normal",
    };

    println!("{}", status); // boiling
}

The guard (if t > 100) only runs if the pattern matches first. This lets you filter within a match arm without nesting if/else inside it.

Key Takeaways

  • Every function parameter needs an explicit type annotation
  • The last expression without a semicolon is the implicit return value
  • if/else is an expression — assign it directly to variables
  • loop can return values via break value
  • .. excludes the upper bound, ..= includes it
  • match must be exhaustive — the compiler enforces complete coverage
  • Pattern matching with enums catches missing cases at compile time
  • Rust has no ternary operator because if/else already returns a value

🎁 What happens when you assign one variable to another in Rust and then try to use the original? The compiler just... deletes it. No runtime error — it refuses to compile. Next lesson: Ownership and Borrowing — the rule that makes Rust memory-safe without a garbage collector.

📝 Ready to test your knowledge?

Answer the quiz below to mark this lesson complete.

Spot something off? Report an issue

© 2026 ByteLearn.dev. Free courses for developers. · Privacy