Functions and Control Flow

Learn to define functions, add comments, and direct program execution using conditionals and loops in Rust.

Overview

Every Rust program you write will be built from three things: functions that organize your logic, comments that explain your intent, and control flow that decides which code runs next. They are not separate concepts you learn in isolation — they work together every time you sit down to code.

Functions let you name a block of instructions and reuse it. Instead of copying the same five lines everywhere, you write them once and call the function by name. Comments let you leave notes for your future self (and for anyone else reading your code) without affecting how the program runs. Control flow is how you make decisions (if/else) and repeat actions (loop, while, for). Rust also gives you match, a structured way to branch that is safer than a long chain of if checks.

This section covers all three. You will learn the syntax for defining functions, writing useful comments, and steering your program's execution. By the end, you will be able to write small, well-structured Rust programs that do more than print a single line.

Functions

Functions are the primary way you group code in Rust. They have a specific syntax, rules about parameters and return types, and a behavior around how the last expression becomes the return value that is central to Rust's design.

Defining a Function

You create a function with the fn keyword, followed by the function name, a pair of parentheses, and a pair of curly braces. The code the function executes goes inside the braces. Rust uses snake_case for function names: all lowercase letters, words separated by underscores.

fn main() {
    println!("Hello from main!");
}

The main function is special — it is the entry point of every executable Rust program. You can define other functions anywhere in the same file (above or below main) and call them from main or from each other.

fn greet() {
    println!("Hi there!");
}
fn main() {
    greet(); // prints "Hi there!"
}

Function Order:

Rust does not require functions to be defined before they are called. You can place greet after main and the code will still compile. The compiler scans the whole file before checking calls.

Parameters and Return Types

Parameters are inputs you pass into a function. In the function signature, you must declare the type of every parameter. Rust does not infer parameter types — it insists on explicit types because they form a contract between the caller and the function.

fn show_price(item: &str, quantity: u32) {
    println!("{} units of {} selected.", quantity, item);
}

To return a value from a function, write an arrow -> followed by the return type. The value the function hands back is the last expression in the body, written without a trailing semicolon.

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

When you add a semicolon, you turn the expression into a statement that returns the unit type (). If the function signature promised an i32, the compiler will refuse with a type-mismatch error.

fn broken_square(x: i32) -> i32 {
    x * x; // error: expected i32, found ()
}

Semicolons Change Return Type:

A trailing semicolon on the last line of a function that declares a return type converts the expression into a statement that evaluates to (). This is one of the most common compile errors beginners hit. If the error message says “expected i32, found (),” check for an unintended semicolon.

Functions with no declared return type implicitly return (), the unit type. That matches the case of functions like println! wrappers that only produce side effects.

Statements vs. Expressions

This difference is the engine under Rust's function returns. Statements are instructions that perform an action and do not produce a value. The let binding is a statement:

let y = 6; // statement, returns nothing

You cannot write let x = (let y = 6); — that fails because the right side yields no value to assign.

Expressions evaluate to a value. 5 + 5 is an expression. Calling a function that returns something is an expression. A block {} that ends with an expression is itself an expression whose value is the final expression inside.

let x = {
    let a = 3;
    a + 1 // expression; block evaluates to 4
};

In a function, the body is a block. The last expression in that block automatically becomes the return value — no return keyword needed. This is why square worked.

Early Returns with return

The return keyword exits the function immediately with the given value. It is useful for guard clauses where continuing doesn't make sense.

fn safe_divide(numerator: f64, denominator: f64) -> Option<f64> {
    if denominator == 0.0 {
        return None;
    }
    Some(numerator / denominator)
}

Idiomatic Rust uses the final expression for the normal return path and reserves return for early exits. This makes the happy path clear: you read straight down to the last expression and know what the function returns in the common case.

Comments

Comments are text the compiler ignores. They exist solely for humans. A program that runs correctly but nobody can understand is a maintenance disaster, so comments are a first-class tool, not an afterthought.

Line Comments

A line comment starts with // and runs to the end of the line. Use them to explain why something is done a certain way, not just to restate the code.

fn main() {
    // The welcome message uses the user's configured language.
    println!("Welcome!");
}

Block Comments

For longer explanations or temporarily disabling chunks of code, block comments span multiple lines between /* and */. They can appear anywhere whitespace is allowed.

fn main() {
    /*
        This loop processes incoming events
        and dispatches them to the correct handler.
        The dispatch logic lives in the `events` module.
    */
    process_events();
}

Nesting block comments is not supported in Rust. If you place /* ... /* ... */ ... */, the compiler will close the comment at the first */ and the rest becomes compile errors.

Nested Block Comments Fail:

Rust does not allow nesting /* inside /*. If you need to comment out a large section that contains existing block comments, use line comments instead, or restructure the code so the inner block comment is not a problem.

Documentation Comments

Rust has special comments that generate HTML documentation: /// for documenting the item that follows, and //! for documenting the enclosing item (like a module or crate). They support Markdown and are used by cargo doc.

/// Returns the square of a number.
///
/// # Examples
/// ```
/// let result = square(4);
/// assert_eq!(result, 16);
/// ```
fn square(x: i32) -> i32 {
    x * x
}

These doc comments are not just for libraries — they help teammates (and your future self) understand what a function does, what arguments it expects, and what edge cases exist.

Doc Comments Are Checked:

When you run cargo test, Rust compiles and runs the code examples inside doc comments as tests. This means your documentation won't silently rot; the compiler tells you when the examples are no longer accurate.

Control Flow

Control flow decides which parts of your code run and in what order. Rust gives you conditional branching and several looping constructs, each with its own strengths. Importantly, most control flow structures in Rust are expressions — they can produce values, which makes them more useful than in statement-based languages.

if Expressions

An if block lets you execute code only when a condition is true. The condition must be a bool. Rust will not coerce numbers or other types to booleans the way C or Python do.

let temperature = 30;
if temperature > 25 {
    println!("It's warm outside.");
} else if temperature > 15 {
    println!("Mild weather.");
} else {
    println!("Chilly!");
}

Because if is an expression, you can use it on the right side of a let statement, replacing the need for a ternary operator.

let status = if temperature > 25 {
    "warm"
} else {
    "cool"
};

All Branches Must Return the Same Type:

The type of the if expression is the type of its branches. Every branch must produce a value of the same type. Returning "warm" in one branch and 42 in another will cause a compile-time error, because Rust needs to know a single type for status at compile time.

Looping with loop

The loop keyword creates an infinite loop. The only way out is a break statement. Because loop is an expression, break can carry a value out of the loop.

let mut retries = 0;
let result = loop {
    retries += 1;
    if retries == 3 {
        break retries * 10;
    }
};
println!("Result: {}", result); // 30

loop is the right tool for retry logic, long-running server accept loops, or any situation where you need to keep executing until a condition is met and you also want to produce a value once it is.

Conditional Loops with while

A while loop runs as long as its condition holds. It is a concise way to write a loop that checks a condition at the top.

let mut countdown = 3;
while countdown > 0 {
    println!("{}!", countdown);
    countdown -= 1;
}
println!("Liftoff!");

If you need to return a value from the loop, while does not support break-with-value. Use loop for that.

Iterating with for

The for loop is the most common way to step through elements of a collection or a range. It works with anything that implements the Iterator trait, which includes arrays, vectors, and range expressions.

let items = [10, 20, 30];
for item in items.iter() {
    println!("Item: {}", item);
}
for number in 1..4 {
    println!("{}!", number);
}

Range syntax a..b produces values from a inclusive up to b exclusive. To include the upper bound, use a..=b.

for loops are safer than manually indexing into an array with a while loop. The compiler ensures the iterator stays within bounds, eliminating off-by-one errors and index-out-of-range panics.

Ownership and Iteration:

Calling iter() on a collection borrows its elements. The collection remains usable after the loop. Using into_iter() consumes the collection, transferring ownership of each element. The choice depends on whether you need the collection after the loop.

Pattern Matching with match

match compares a value against a series of patterns and runs the code for the first pattern that fits. Unlike if/else if chains, match is exhaustive: the compiler checks that every possible value is covered. This catches missing cases at compile time instead of at 3 a.m. in production.

let code = 200;
match code {
    200 => println!("OK"),
    404 => println!("Not Found"),
    500..=599 => println!("Server Error"),
    _ => println!("Unknown code"),
}

The _ pattern is a catch-all that matches anything not already handled. Without it, the compiler would reject the code because code could be, for example, 301, which has no pattern.

match is an expression, so you can use it to assign a value.

let description = match code {
    200 => "OK",
    404 => "Not Found",
    _ => "Other",
};

match works with more than just integers. It can match on enums, tuples, strings, and destructured structs. Later chapters will dive deep into those patterns, but the core idea remains the same: list the possibilities, handle each one, and let the compiler verify you didn't forget anything.

For situations where you only care about one pattern and want to ignore everything else, Rust offers if let as a shorthand.

let maybe_name = Some("Alice");
if let Some(name) = maybe_name {
    println!("Name is {}", name);
}

if let is syntactic sugar for a match with a single arm and a catch-all _ => {}. It keeps the code compact when you genuinely only need to act on one variant.

Exhaustiveness Catches Bugs Early:

The compiler's insistence that match covers every case is one of Rust's most practical safety features. If you add a new variant to an enum later, every match on that enum becomes a compile error until you handle the new case. This turns a whole class of runtime bugs into compile-time failures.


Summary

Functions, comments, and control flow form the skeleton of every Rust program you write from now on. Functions with explicit parameter types and expression-based returns encourage code that reads like a clear pipeline of data transformations. Comments, especially doc comments, keep that pipeline understandable. Control flow constructs, all expressions themselves, let you make decisions and repeat actions while still producing values — reducing the need for mutable state and temporary variables.

The expression-oriented nature of if, loop, and match is not a quirk to memorize; it is the design principle that will shape how you solve problems in Rust. A block that produces a value, a loop that breaks with a result, a match that returns a string — these patterns become natural as you write more code.