Skip to content

04 · Functions

🎥 Video walkthrough

Functions are declared with fn, and Rust's distinction between statements and expressions shapes how you write them — understanding that distinction makes the rest of the language click into place.

Basic syntax

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

fn main() {
    greet("Alice");   // Hello, Alice!
    greet("Bob");     // Hello, Bob!
}

Parameters always require explicit type annotations (name: &str) — Rust never infers a function's parameter or return types from how it's called, unlike local variables.

Return values

The idiomatic way to return a value is to make it the last expression in the function body, with no semicolon:

fn square(x: i32) -> i32 {
    x * x   // no semicolon -- this expression's value is returned
}

fn main() {
    let result = square(6);
    println!("{result}");   // 36
}

-> i32 declares the return type. You can also return early with the explicit return keyword, typically for early exits:

fn absolute_value(x: i32) -> i32 {
    if x < 0 {
        return -x;   // early return, semicolon required here
    }
    x   // falls through if x >= 0, still no semicolon
}

fn main() {
    println!("{}", absolute_value(-7));   // 7
    println!("{}", absolute_value(4));    // 4
}

Statements vs. expressions

This is one of Rust's most important structural ideas: a statement performs an action and produces nothing (no value), while an expression evaluates to a value. Adding a semicolon turns an expression into a statement by discarding its value.

fn main() {
    let x = 5;          // `let x = 5;` is a statement -- it produces no value itself

    let y = {
        let a = 3;
        let b = 4;
        a * a + b * b   // no semicolon -- this is the block's value
    };

    println!("{y}");   // 25
}

That { ... } block is itself an expression — it evaluates to whatever its last unterminated line produces, and that's exactly the mechanism if as an expression (see Module 3) relies on. Add a semicolon after a * a + b * b and the block would instead evaluate to (), the unit value, and y would no longer be 25.

Nested / helper functions

Functions can be defined inside other functions, scoped to where they're used. This is handy for small helpers that don't need to be visible anywhere else.

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

    let values = [1, 2, 3, 4];
    for v in values.iter() {
        println!("{}", double(*v));
    }
    // 2
    // 4
    // 6
    // 8
}

*v dereferences the reference .iter() hands back to get the underlying i32 — you'll see &/* a lot more once borrowing is covered in depth in Level 2.

Multiple parameters and returning early from logic

fn max_of_three(a: i32, b: i32, c: i32) -> i32 {
    let mut largest = a;
    if b > largest {
        largest = b;
    }
    if c > largest {
        largest = c;
    }
    largest   // expression, returned
}

fn main() {
    println!("{}", max_of_three(4, 9, 2));   // 9
}

Functions that return nothing

A function with no -> Type implicitly returns (), the unit type — the same value the discarded block above produced. It's Rust's equivalent of "void," except it's an actual (zero-sized) type rather than a special case.

fn log_message(message: &str) {
    println!("[LOG] {message}");
    // implicitly returns () here
}

fn main() {
    log_message("starting up");   // [LOG] starting up
}

You may also encounter the ! ("never") type on some functions — it marks a function that never returns at all (for example, one that always panics or loops forever). That's a niche detail you won't need to write yourself yet, but it's why a function like std::process::exit can be used in places expecting any type: it never returns, so it's compatible with everything.

How It Actually Works

Function calls in Rust use the platform's normal C-like calling convention under the hood (arguments in registers or on the stack, a return address pushed, a stack frame allocated) — there's no hidden runtime, virtual dispatch, or interpreter loop involved for a plain fn. (), the unit type, is genuinely zero-sized: it occupies no memory at all, so a function returning () costs nothing extra at the ABI level compared to one that returns "nothing" in a language without such a type. The ! never type is special in the type checker rather than at runtime — it's defined as a subtype-like bottom type that coerces to any other type, which is exactly why std::process::exit(1) or a panic!() can appear in a branch of a match whose other arms return i32: the compiler unifies ! with i32 by treating the panicking branch as "this arm never actually produces a value to disagree with," not by inserting a runtime check.

Monomorphization matters here too: every generic or trait-bound function you write gets a fully separate compiled copy per concrete type it's called with, rather than one shared implementation that dispatches at runtime. A fn max_of_three<T: PartialOrd>(a: T, b: T, c: T) -> T called with i32 and with f64 produces two distinct machine-code functions, each with the comparison inlined directly against the concrete type — no vtable lookup, no boxing. That's the mechanism behind "generics are zero-cost": the abstraction exists only in the source code you write, not in the binary that runs.

Cheat sheet

Concept Example Notes
Declaration fn add(a: i32, b: i32) -> i32 { a + b } Parameters and return type always explicit
Implicit return a + b (no ;) Last expression in the body is the return value
Explicit return return a + b; Needed for early exits, requires a semicolon
No return value fn log(msg: &str) { ... } Implicitly returns (), the unit type
Block as expression let y = { ...; last_expr }; A { } block evaluates to its last unterminated expression

🔀 See this in another language

Exercise

Write a function is_prime(n: u32) -> bool that returns whether n is prime (use a helper loop checking divisibility up to n). Write a second function classify(n: u32) -> &'static str that returns "prime" or "composite" by calling is_prime. In main, loop over the numbers 2 through 20 and print each number alongside its classification.