$elementary.dev
[Courses][About]|
$elementary.dev

Text-first, project-driven courses for developers who want to level up by building real things.

CoursesAbout
Stay Updated

Get notified about new courses and articles.

// No spam, unsubscribe anytime

© 2026 elementary.dev
PrivacyTerms
// Course

Elementary Rust

3/11
  • 01Hello, Rust
  • 02Variables and Types
  • 03Functions and Control Flow
  • 04Why Memory Management Matters
  • 05Ownership: Rust's Solution
  • 06Borrowing and References
  • 07Slices
  • 08Structs
  • 09Enums and Pattern Matching
  • 10Organizing Rust Projects
  • 11Collections
courses/elementary-rust/03-functions-control

Functions and Control Flow

Learn how functions work in Rust and master its expressive control flow constructs.

~20 min|#Lesson 3/11

Functions and Control Flow

Rust is an expression-based language. This single fact changes how you write code in ways that feel strange at first, then become second nature.

Let me show you what I mean.

Statements vs Expressions

fn main() {
    let y = {
        let x = 3;
        x + 1  // No semicolon = expression, returns value
    };
    println!("The value of y is: {y}"); // Prints: 4
}

That block { let x = 3; x + 1 } evaluates to 4. The entire block is an expression. This is different from most languages you have used.

// Info-The Core Distinction

Statements perform an action and return nothing. Expressions evaluate to a value. In Rust, almost everything is an expression - including blocks, if statements, and loops.

Here is the critical rule: adding a semicolon turns an expression into a statement.

let x = 3;    // Statement (declaration)
x + 1         // Expression (returns 4)
x + 1;        // Statement (returns nothing)

Why does this matter? Because unlike many other languages, you cannot do this in Rust:

// This works in C, JavaScript, Python...
let x = y = 4;  // ERROR in Rust!

Assignment is a statement in Rust, not an expression. It returns () (the unit type), not the assigned value.

// Warning-Semicolons Change Everything

The difference between x + 1 and x + 1; is whether your function returns a value or returns nothing. A single character determines your return type.

Defining Functions

fn add(a: i32, b: i32) -> i32 {
    a + b  // Implicit return - no semicolon!
}

fn print_sum(a: i32, b: i32) {
    println!("Sum: {}", a + b);
    // No return value, no -> in signature
}

Rust functions use fn, require type annotations on parameters, and specify return types after ->. Notice add has no return keyword - the last expression becomes the return value automatically.

// Tip-When to Use return

Use explicit return only for early returns. For the final value, omit the semicolon and let the expression be the implicit return. This is idiomatic Rust.

You can use return explicitly, but most Rustaceans reserve it for early exits:

fn divide(a: f64, b: f64) -> f64 {
    if b == 0.0 {
        return 0.0;  // Early return with explicit keyword
    }
    a / b  // Implicit return for the "normal" case
}
// Challenge

Expression or Statement?

Does this line return a value: `let x = 5;`

$

Control Flow: if as an Expression

Since if is an expression in Rust, you can use it on the right side of a let:

let number = 6;
let description = if number % 2 == 0 { "even" } else { "odd" };
println!("{number} is {description}");

No ternary operator needed. The if expression handles it elegantly.

// Danger-Type Consistency Required

All arms of an if expression must return the same type. You cannot return a string from one branch and an integer from another. Rust checks this at compile time.

// This will NOT compile
let value = if condition { 5 } else { "six" };
// ERROR: expected integer, found &str

What happens when there is no else? If the condition is false and there is no else branch, the expression returns () (unit type). This means the if branch must also return ():

// This works - both branches return ()
let x = 5;
if x > 3 {
    println!("big");
}

// This does NOT work
let result = if x > 3 { 10 };  // ERROR: else branch returns ()

Loops: Three Ways to Repeat

Rust has three loop constructs, each with distinct use cases.

The loop Expression

loop creates an infinite loop that you break out of explicitly. The powerful part: break can return a value.

let mut counter = 0;

let result = loop {
    counter += 1;
    if counter == 10 {
        break counter * 2;  // Returns 20 from the loop!
    }
};

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

This is unique to loop. Neither while nor for can break with a value.

// Info-Why loop Can Return Values

Only loop guarantees execution of the loop body at least once (if reached). The compiler knows a break with a value will eventually execute. With while and for, the body might never run, making return values impossible to guarantee.

Loop Labels for Nested Loops

When you have nested loops, use labels to control which loop break or continue affects:

fn main() {
    let mut count = 0;

    'counting_up: loop {
        println!("count = {count}");
        let mut remaining = 10;

        loop {
            println!("remaining = {remaining}");
            if remaining == 9 {
                break;  // Exits inner loop only
            }
            if count == 2 {
                break 'counting_up;  // Exits outer loop
            }
            remaining -= 1;
        }

        count += 1;
    }
    println!("End count = {count}");
}

Labels start with a single quote ('counting_up). They let you break out of deeply nested loops without boolean flags or other workarounds.

The while Loop

Use while when you want to loop until a condition becomes false:

fn main() {
    let mut number = 3;

    while number != 0 {
        println!("{number}!");
        number -= 1;
    }

    println!("LIFTOFF!");
}
// Warning-No Return Values from while

Unlike loop, you cannot use break value; in a while loop. The compiler cannot guarantee the loop body executes, so it cannot guarantee a return value.

The for Loop

for is the most common loop in Rust. It iterates over collections and ranges:

fn main() {
    let a = [10, 20, 30, 40, 50];

    for element in a {
        println!("Value: {element}");
    }
}

For counting or iterating numbers, use ranges:

// Prints 1, 2, 3 (exclusive end)
for number in 1..4 {
    println!("{number}");
}

// Prints 1, 2, 3, 4 (inclusive end)
for number in 1..=4 {
    println!("{number}");
}

// Countdown: 3, 2, 1
for number in (1..4).rev() {
    println!("{number}!");
}
println!("LIFTOFF!");
// Tip-Range Syntax

1..4 is exclusive (1, 2, 3). 1..=4 is inclusive (1, 2, 3, 4). The = makes it include the end value. Use .rev() to reverse any range.

Why for is Preferred

Consider iterating over an array. You could use while:

let a = [10, 20, 30, 40, 50];
let mut index = 0;

while index < 5 {
    println!("Value: {}", a[index]);
    index += 1;
}

But this has problems. If you change the array size and forget to update 5, you get a panic at runtime. With for, this cannot happen:

let a = [10, 20, 30, 40, 50];

for element in a {
    println!("Value: {element}");
}

The for loop handles bounds automatically. It is safer, clearer, and more idiomatic.

Putting It Together

Let us combine these concepts. Here is a function that demonstrates Rust's expression-based nature:

fn classify_number(n: i32) -> &'static str {
    if n < 0 {
        "negative"
    } else if n == 0 {
        "zero"
    } else if n % 2 == 0 {
        "positive even"
    } else {
        "positive odd"
    }
}

fn sum_range(start: i32, end: i32) -> i32 {
    let mut total = 0;
    for n in start..=end {
        total += n;
    }
    total  // Implicit return
}

fn main() {
    println!("{}", classify_number(7));  // "positive odd"
    println!("Sum 1-10: {}", sum_range(1, 10));  // 55
}

Notice how classify_number has no return keywords. Each branch of the if is an expression that becomes the return value. This is clean, idiomatic Rust.

// Challenge

FizzBuzz Teaser

In Rust's range syntax, what would iterate through the numbers 1 through 100 inclusive?

$

Summary

Rust's expression-based design changes how you write code:

  • Blocks are expressions: { let x = 3; x + 1 } evaluates to 4
  • Semicolons matter: x + 1 returns a value, x + 1; does not
  • if is an expression: Use it directly in assignments
  • loop can return values: break value; exits and returns
  • for is preferred: Safer and cleaner than manual indexing
  • Ranges are flexible: 1..10 (exclusive) or 1..=10 (inclusive)

This expression-oriented style leads to cleaner code with fewer intermediate variables. It takes adjustment, but soon you will find it hard to go back to statement-heavy languages.

Next, we will explore why memory management matters and set the stage for Rust's most distinctive feature: ownership.

<-[prev]Variables and Types[next]->Why Memory Management Matters
Progress: 0%
// On this page
  • Statements vs Expressions
  • Defining Functions
  • Control Flow: if as an Expression
  • Loops: Three Ways to Repeat
  • The loop Expression
  • Loop Labels for Nested Loops
  • The while Loop
  • The for Loop
  • Why for is Preferred
  • Putting It Together
  • Summary