Skip to main content
Rust · lesson 7 of 30
Rust beginner Lesson 7 of 30

Functions in Rust

Learn fn syntax, return values, closures, and higher-order functions in Rust.

By SkillByExample · Updated

Defining Functions

Functions are the primary unit of code reuse in Rust. Every Rust program starts at main, and you build complexity by composing functions. The fn keyword introduces a function definition; parameter types and the return type are always explicit in the signature, so the function’s contract is always clear without needing to read the body.

fn add(a: i32, b: i32) -> i32 {
    a + b  // no semicolon — this expression is the return value
}

fn main() {
    let result = add(3, 4);
    println!("{}", result);
}
$ rustc functions_add.rs && ./functions_add
7

Key rules:

  • Parameter types are always required — no inference in function signatures.
  • Return type is declared after ->.
  • The last expression (without ;) is the implicit return value.
  • return exits early with an explicit value.

Implicit vs Explicit Return

Rust uses the last expression in a function body as the return value when no semicolon is present. This expression-based approach means functions can be written concisely, but it also means that accidentally adding a semicolon changes the return type to () (unit), which the compiler will catch.

fn max(a: i32, b: i32) -> i32 {
    if a > b {
        return a; // early return with explicit keyword
    }
    b // implicit return — last expression, no semicolon
}

fn main() {
    println!("{}", max(10, 7));
    println!("{}", max(3, 9));
}
$ rustc functions_max.rs && ./functions_max
10
9

Adding a semicolon after the last expression changes its type to () (unit), which causes a compile error if the function declares a non-unit return type:

fn broken() -> i32 {
    42; // ERROR: expected i32, found () — the semicolon makes this a statement, not an expression
}
$ rustc functions_broken.rs
error[E0308]: mismatched types
 --> functions_broken.rs:1:16
  |
1 | fn broken() -> i32 {
  |    ------      ^^^ expected `i32`, found `()`
  |    |
  |    implicitly returns `()` as its body has no tail or `return` expression
2 |     42;
  |       - help: remove this semicolon to return this value

Functions with No Return Value

Functions that exist purely for side effects — printing, modifying state, writing to a file — return the unit type (). You do not need to write -> () explicitly; it is the default when no return type is declared.

fn print_separator(ch: char, count: usize) {
    // std::iter::repeat builds a repeating iterator; collect() assembles it into a String
    let line: String = std::iter::repeat(ch).take(count).collect();
    println!("{}", line);
}

fn main() {
    print_separator('-', 40);
    println!("Hello, Rust!");
    print_separator('=', 40);
}
$ rustc functions_separator.rs && ./functions_separator
----------------------------------------
Hello, Rust!
========================================

Returning Multiple Values via Tuples

Rust functions return a single value, but that value can be a tuple carrying multiple pieces of data. This is the standard pattern when a function needs to return a result along with some status information, before reaching for a dedicated struct.

fn divide(a: f64, b: f64) -> (f64, bool) {
    if b == 0.0 {
        (0.0, false) // return a sentinel value and a success flag
    } else {
        (a / b, true)
    }
}

fn main() {
    let (result, ok) = divide(10.0, 3.0);
    if ok {
        println!("{:.4}", result);
    }

    let (_, ok) = divide(5.0, 0.0); // _ ignores the value we don't need
    println!("valid: {}", ok);
}
$ rustc functions_tuple.rs && ./functions_tuple
3.3333
valid: false

Function Pointers

Functions are first-class values in Rust. You can store them in variables, pass them as arguments, and collect them in arrays. The type of a function pointer is written fn(ParamType) -> ReturnType.

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

// accept any function with signature fn(i32) -> i32
fn apply(f: fn(i32) -> i32, value: i32) -> i32 {
    f(value)
}

fn main() {
    println!("{}", apply(double, 5));
    println!("{}", apply(square, 5));

    // Store function pointers in a Vec and iterate over them
    let ops: Vec<fn(i32) -> i32> = vec![double, square];
    for op in &ops {
        println!("{}", op(4));
    }
}
$ rustc functions_pointers.rs && ./functions_pointers
10
25
8
16

Closures

Closures are anonymous functions that capture variables from their surrounding scope. They are defined with |params| body and are central to idiomatic Rust — most iterator methods take closures as arguments. Because closures capture context, they are more flexible than plain function pointers for callbacks and transformations.

fn main() {
    let multiplier = 3;

    // The closure captures `multiplier` by reference from the enclosing scope
    let triple = |x: i32| x * multiplier;

    println!("{}", triple(5));
    println!("{}", triple(10));
    // multiplier is still usable here because the closure only borrowed it
}
$ rustc functions_closure1.rs && ./functions_closure1
15
30

Closures infer their parameter and return types from usage, which keeps them concise:

fn main() {
    let add = |a, b| a + b;                      // types inferred as i32
    let greet = |name| format!("Hello, {}!", name); // name inferred as &str

    println!("{}", add(2, 3));
    println!("{}", greet("Alice"));
}
$ rustc functions_closure2.rs && ./functions_closure2
5
Hello, Alice!

Closure Traits: Fn, FnMut, FnOnce

Rust categorises closures by how they capture their environment, and each category maps to a trait. This matters when you write generic functions that accept closures — the trait bound you choose determines how callers can use the closure.

TraitCapturesCan call
FnOnceTakes ownership of captured variablesOnce only
FnMutMutably borrows captured variablesMultiple times
FnImmutably borrows captured variablesMultiple times
fn call_once<F: FnOnce() -> String>(f: F) {
    println!("{}", f()); // f is consumed here — cannot call again
}

fn call_mut<F: FnMut()>(mut f: F) {
    f(); f(); f(); // called three times — f must be FnMut or stronger
}

fn call_fn<F: Fn(i32) -> i32>(f: F, x: i32) -> i32 {
    f(x) // can call any number of times — f must be Fn
}

fn main() {
    let name = String::from("Rust");
    // move transfers ownership of name into the closure — it becomes FnOnce
    call_once(move || format!("Hello from {}!", name));

    let mut count = 0;
    // Captures count by mutable reference — FnMut
    call_mut(|| count += 1);
    println!("count: {}", count);

    // Captures factor by immutable reference — Fn
    let factor = 5;
    println!("{}", call_fn(|x| x * factor, 6));
}
$ rustc functions_traits.rs && ./functions_traits
Hello from Rust!
count: 3
30

Higher-Order Functions

Functions that take or return other functions are called higher-order functions. They are a powerful abstraction tool — apply_twice below works with any transformation, and make_adder produces specialised functions on demand. The move keyword transfers ownership of captured variables into the returned closure so the closure can outlive the function that created it.

fn apply_twice<F: Fn(i32) -> i32>(f: F, x: i32) -> i32 {
    f(f(x)) // applies the function to x, then applies it again to the result
}

fn make_adder(n: i32) -> impl Fn(i32) -> i32 {
    move |x| x + n  // `move` transfers n into the closure so it owns its data
}

fn main() {
    let double = |x| x * 2;
    println!("{}", apply_twice(double, 3)); // 3*2=6, 6*2=12

    let add5  = make_adder(5);
    let add10 = make_adder(10);
    println!("{}", add5(3));
    println!("{}", add10(3));
}
$ rustc functions_higher.rs && ./functions_higher
12
8
13

Iterator Methods with Closures

The most common use of closures in real Rust code is with iterator adapters. The iterator pipeline pattern — chaining filter, map, fold, and similar methods — produces code that is both concise and efficiently compiled: the chain is fused into a single loop with no intermediate allocations.

fn main() {
    let numbers = vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10];

    // Chain: keep only even numbers, square each, collect into a Vec
    let result: Vec<i32> = numbers
        .iter()
        .filter(|&&x| x % 2 == 0) // keep evens: 2, 4, 6, 8, 10
        .map(|&x| x * x)           // square each: 4, 16, 36, 64, 100
        .collect();                // materialise into a Vec

    println!("{:?}", result);

    // fold reduces a sequence to a single value
    let sum = numbers.iter().fold(0, |acc, &x| acc + x);
    println!("sum: {}", sum);

    // any / all — short-circuit predicates
    let has_even    = numbers.iter().any(|&x| x % 2 == 0);
    let all_positive = numbers.iter().all(|&x| x > 0);
    println!("{} {}", has_even, all_positive);
}
$ rustc functions_iter.rs && ./functions_iter
[4, 16, 36, 64, 100]
sum: 55
true true

Diverging Functions

A function that never returns has the return type !, called “never”. The compiler treats ! as compatible with any type, so you can use a diverging function in any expression position — including inside if arms or match arms that need to produce a value. panic!, todo!, and std::process::exit all have type !.

fn fatal_error(msg: &str) -> ! {
    eprintln!("Fatal: {}", msg);
    std::process::exit(1); // process exits — this function truly never returns
}

fn divide(a: f64, b: f64) -> f64 {
    if b == 0.0 {
        fatal_error("division by zero"); // OK: ! coerces to f64 in this branch
    }
    a / b
}

fn main() {
    println!("{}", divide(10.0, 2.0));
    println!("{}", divide(1.0, 0.0)); // never returns — the process exits here
    println!("unreachable");
}
$ rustc functions_diverge.rs && ./functions_diverge
5
Fatal: division by zero

5 prints (10.0 / 2.0), then the second call hits the b == 0.0 branch, fatal_error prints its message to stderr and calls std::process::exit(1) — the process ends right there, so println!("unreachable") genuinely never runs and the exit code is 1, not 0.

Const Functions

Functions marked const fn can be evaluated at compile time when called with constant arguments. This lets you compute lookup tables, magic constants, or validation checks without any runtime cost — the result is baked directly into the binary.

const fn factorial(n: u64) -> u64 {
    match n {
        0 | 1 => 1,
        _ => n * factorial(n - 1),
    }
}

// Computed entirely at compile time — no runtime computation needed
const FACT_10: u64 = factorial(10);

fn main() {
    println!("{}", FACT_10);
}
$ rustc functions_const.rs && ./functions_const
3628800

Practice

  1. Remove move from make_adder: change move |x| x + n to plain |x| x + n. What does the compiler say?

    Solution

    error[E0373]: closure may outlive the current function, but it borrows \n`, which is owned by the current function— withoutmove, the closure tries to borrow nby reference, butnis a local parameter that's dropped the momentmake_adderreturns. The returned closure would then hold a dangling reference.movemakes the closure take ownership ofn` instead, so it carries its own copy that outlives the function that created it — exactly why the lesson’s version needs it.

  2. Call the FnOnce closure inside call_once twice instead of once: println!("{}", f()); println!("{}", f());. What happens?

    Solution

    error[E0382]: use of moved value: \f`—`f` moved due to this call, then value used here after moveon the secondf(). Calling an FnOnceclosure consumes it (it may move its captured variables out, herenameinto the returnedString`), so the type system enforces “once only” at compile time rather than trusting the name alone.

  3. broken()’s fix is to remove the trailing semicolon from 42;. Apply that fix, then predict — does max’s early return a; also need its semicolon removed to compile?

    Solution

    No — return a; is a complete statement using the return keyword, which explicitly exits the function with a value regardless of semicolon; the semicolon there just ends the statement as usual. The “last expression, no semicolon” rule only applies to implicit returns (the final expression naturally evaluated by falling off the end of the function body), not to explicit return statements.

  4. apply takes a plain function pointer: fn(i32) -> i32. Try passing a closure that captures a variable, e.g. let n = 3; apply(|x| x + n, 5). Does it compile?

    Solution

    No — expected fn pointer \fn(i32) -> i32`, found closure. A capturing closure is not a bare function pointer; it carries its captured environment (here, n) as hidden state, which a fn(i32) -> i32type has no room to store. Only non-capturing closures coerce tofnpointers. To accept both,applywould need to be generic overF: Fn(i32) -> i32instead, likeapply_twice` further down the lesson.

  5. Predict what factorial(0) returns from the const fn, then check whether calling factorial with a non-constant argument (a value read at runtime, e.g. from a Vec) still compiles as a regular function call.

    Solution

    factorial(0) returns 1 — the 0 | 1 => 1 arm matches 0 directly (this is the base case for the recursion, not an off-by-one). const fns remain callable at runtime just like any other function; the “evaluated at compile time” behavior only kicks in when every argument is itself a compile-time constant (as 10 is in const FACT_10). Call factorial(some_vec.len() as u64) at runtime and it works exactly like a normal recursive function call — it does not require or force compile-time evaluation.

Frequently Asked Questions

Does Rust require explicit return types?
Yes, for named functions. The return type is declared after -> in the signature. The last expression in a function body is implicitly returned without a semicolon.
What is the difference between a function and a closure in Rust?
Functions are defined with fn and cannot capture variables from their environment. Closures are anonymous functions that can capture variables from the surrounding scope.
Can functions return multiple values in Rust?
Rust functions return a single value, but that value can be a tuple, struct, or enum holding multiple pieces of data.