🚧 We are still building the lighthouse. Hang tight!

projectlighthouse logoprojectlighthouse

Sign in with your Google or GitHub account to get started

Functions and Control Flow

Master functions with ownership semantics, expressions vs statements, and control flow constructs. Learn how to pass values by ownership, reference, or mutable reference.

Programs execute instructions sequentially by default. Functions break code into reusable pieces. Control flow statements alter execution order based on conditions. Together, they let you express complex logic clearly.

Rust's approach to functions and control flow combines familiar syntax with ownership semantics. Every function parameter has ownership semanticsβ€”either taking ownership, borrowing immutably, or borrowing mutably. Control flow constructs are expressions that return values, enabling concise code without sacrificing clarity.

Defining Functions

Functions in Rust use the fn keyword, followed by a name, parameters in parentheses, and an optional return type:

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

The function add takes two i32 parameters and returns an i32. The final expression x + y becomes the return value. No return keyword needed for the final expression.

Parameter types are mandatory. Rust doesn't infer parameter types. Return types are mandatory when the function returns something other than the unit type ():

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

The function greet takes a string slice and returns nothingβ€”implicitly returning (), the unit type.

Expressions vs Statements

Rust distinguishes between expressions and statements. This distinction matters because it affects how you write code and what syntax is valid.

Statements perform actions but don't return values. Variable bindings, function definitions, and item declarations are statements:

let x = 5;  // Statement
fn foo() {} // Statement

Statements end with semicolons. They can't be assigned to variables because they don't produce values:

let x = (let y = 6); // Error: expected expression, found statement

Expressions evaluate to values. Arithmetic operations, function calls, and blocks are expressions:

let x = 5 + 6;        // 5 + 6 is an expression
let y = add(3, 4);    // add(3, 4) is an expression
let z = {
    let a = 3;
    a + 1
};  // The block is an expression evaluating to 4

Notice the block assigned to z. The block contains a statement let a = 3 and an expression a + 1. The final expression in the block (without a semicolon) becomes the block's value.

Adding a semicolon to an expression turns it into a statement:

fn returns_five() -> i32 {
    5  // Expression: returns 5
}

fn returns_nothing() {
    5; // Statement: returns ()
}

The semicolon suppresses the return value. The function returns_nothing returns () because the final statement doesn't produce a value.

This expression-based syntax enables concise code:

let result = if condition { 10 } else { 20 };

The entire if expression evaluates to a value, which binds to result.

Parameters and Ownership

Function parameters follow ownership rules. When you pass a value to a function, you either transfer ownership or pass a reference.

Taking ownership means the function consumes the parameter:

fn consume(s: String) {
    println!("{}", s);
} // s is dropped here

let s = String::from("hello");
consume(s);
// s is invalid here

After calling consume(s), the variable s is invalid. The function owns the string and drops it when returning.

Borrowing immutably lets the function read the value without taking ownership:

fn read(s: &String) {
    println!("{}", s);
}

let s = String::from("hello");
read(&s);
// s is still valid here

The function read takes a reference &String. It can read the string but can't modify it. After the function returns, s remains valid because ownership never transferred.

Borrowing mutably lets the function modify the value:

fn modify(s: &mut String) {
    s.push_str(" world");
}

let mut s = String::from("hello");
modify(&mut s);
println!("{}", s); // Prints "hello world"

The function modify takes a mutable reference &mut String. It can modify the string. The caller must declare the variable mutable with let mut.

Choosing the right parameter type determines how the function interacts with its inputs. Taking ownership makes sense when the function consumes or transforms the value. Borrowing makes sense when the function just needs to read or temporarily modify the value.

Return Values

Functions return values by writing a final expression without a semicolon:

fn double(x: i32) -> i32 {
    x * 2
}

The expression x * 2 becomes the return value.

You can return early with the return keyword:

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

Early returns require the return keyword. The final expression Some(numerator / denominator) doesn't need it.

Returning ownership transfers ownership to the caller:

fn create() -> String {
    String::from("hello")
}

let s = create();
// s owns the string

The function allocates a string and returns ownership. The caller now owns it.

The Unit Type

Functions that don't return a meaningful value return the unit type (). This is analogous to void in other languages, but () is an actual type with one value: ().

fn log(message: &str) {
    println!("{}", message);
}

The function log implicitly returns (). You can write the return type explicitly:

fn log(message: &str) -> () {
    println!("{}", message);
}

But this is redundant. Omitting the return type implies -> ().

Control Flow: if Expressions

The if expression evaluates a boolean condition and executes code based on the result:

let number = 5;

if number < 10 {
    println!("small");
} else {
    println!("large");
}

Conditions must be boolean expressions. Rust doesn't implicitly convert integers or other types to booleans:

let number = 3;

if number {  // Error: expected bool, found integer
    println!("number is three");
}

You must write explicit conditions:

if number != 0 {
    println!("number is non-zero");
}

The if expression evaluates to a value:

let number = 5;
let size = if number < 10 { "small" } else { "large" };

Both branches must return the same type:

let result = if condition { 5 } else { "hello" }; // Error: mismatched types

The compiler rejects this because one branch returns i32 and the other returns &str.

else if Chains

Multiple conditions chain with else if:

let number = 6;

if number % 4 == 0 {
    println!("divisible by 4");
} else if number % 3 == 0 {
    println!("divisible by 3");
} else if number % 2 == 0 {
    println!("divisible by 2");
} else {
    println!("not divisible by 4, 3, or 2");
}

Rust evaluates conditions in order and executes the first matching branch. Subsequent branches don't execute even if their conditions are true.

Long else if chains become hard to read. Pattern matching with match often provides clearer alternatives.

Loops: loop

The loop keyword creates an infinite loop:

loop {
    println!("looping forever");
}

This runs until explicitly broken with break or the program terminates:

let mut count = 0;

loop {
    count += 1;
    if count == 10 {
        break;
    }
}

Loops can return values with break:

let mut counter = 0;

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

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

The break keyword can specify a value to return from the loop. This value becomes the loop expression's value.

Loops: while

The while loop executes while a condition remains true:

let mut number = 3;

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

This is equivalent to a loop with a conditional break, but more concise for this pattern.

Loops: for

The for loop iterates over collections:

let numbers = [1, 2, 3, 4, 5];

for number in numbers {
    println!("{}", number);
}

The loop variable number takes ownership or a reference depending on how you iterate. Iterating directly over an array moves ownership:

let numbers = vec![1, 2, 3];

for n in numbers {
    println!("{}", n);
}
// numbers is invalid here

Iterating over a reference borrows elements:

let numbers = vec![1, 2, 3];

for n in &numbers {
    println!("{}", n);
}
// numbers is still valid here

Range syntax creates iterators over numeric ranges:

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

The range 0..5 is exclusive of the end. For inclusive ranges, use 0..=5:

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

Loop Control: break and continue

The break keyword exits the innermost loop immediately:

for i in 0..10 {
    if i == 5 {
        break;
    }
    println!("{}", i);
}
// Prints 0, 1, 2, 3, 4

The continue keyword skips to the next iteration:

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

Loop labels disambiguate nested loops:

'outer: for i in 0..3 {
    for j in 0..3 {
        if i == 1 && j == 1 {
            break 'outer;
        }
        println!("i: {}, j: {}", i, j);
    }
}

The label 'outer lets break exit the outer loop from within the inner loop.

Function Scope

Variables defined in a function exist only within that function:

fn scope_example() {
    let x = 5;
    {
        let y = 10;
        println!("{} {}", x, y);
    }
    // y is invalid here
    println!("{}", x);
}

The inner block creates a new scope. Variables defined in that scope (y) are invalid outside it. Variables from outer scopes (x) remain accessible in inner scopes.

This applies to ownership:

fn ownership_scope() {
    let s1 = String::from("hello");
    {
        let s2 = s1; // s1's ownership moves to s2
    } // s2 goes out of scope, string is dropped

    // println!("{}", s1); // Error: s1 is invalid
}

Moving ownership into an inner scope transfers ownership. When that scope ends, the value drops if nothing moved ownership back out.

Why Expressions Matter

Expression-based syntax reduces boilerplate. Instead of declaring variables and assigning them in different branches:

let result;
if condition {
    result = 10;
} else {
    result = 20;
}

You write a single binding:

let result = if condition { 10 } else { 20 };

This guarantees initialization. The first example allows result to remain uninitialized if you forget a branch. The second example forces both branches to provide values.

Pattern matching, covered later, extends this further with match expressions that handle multiple cases and ensure exhaustiveness.

Functions as Building Blocks

Functions with clear ownership semantics compose reliably. A function that takes ownership consumes its input. A function that borrows reads without consuming. A function that borrows mutably modifies in place.

These patterns communicate intent:

fn consume_and_transform(mut s: String) -> String {
    s.push_str(" transformed");
    s
}

fn read_only(s: &String) -> usize {
    s.len()
}

fn modify_in_place(s: &mut String) {
    s.push_str(" modified");
}

Calling code sees immediately what each function does to its inputs. The type signatures document behavior that comments might miss or get out of sync with code changes.

Control flow combined with ownership enables expressing complex logic safely. The compiler ensures you handle all cases, initialize variables before use, and don't violate ownership rules. When your code compiles, you've satisfied these constraints. When it runs, it behaves predictably without the undefined behavior that plagues languages with weaker type systems.

Notes & Highlights

Β© 2025 projectlighthouse. All rights reserved.