Rust · lesson 7 of 30
Beginner
15- 1 Introduction to Rust
- 2 Setting Up Rust
- 3 Variables in Rust
- 4 Data Types in Rust
- 5 Operators in Rust
- 6 Control Flow in Rust
- 7 Functions in Rust
- 8 Ownership in Rust
- 9 Borrowing and References
- 11 Structs in Rust
- 12 Enums in Rust
- 13 Pattern Matching in Rust
- 17 Collections in Rust
- 25 Testing in Rust
- 26 File I/O in Rust
Intermediate
9Advanced
6Functions 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. returnexits 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.
| Trait | Captures | Can call |
|---|---|---|
FnOnce | Takes ownership of captured variables | Once only |
FnMut | Mutably borrows captured variables | Multiple times |
Fn | Immutably borrows captured variables | Multiple 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
-
Remove
movefrommake_adder: changemove |x| x + nto 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 borrownby 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. -
Call the
FnOnceclosure insidecall_oncetwice instead of once:println!("{}", f()); println!("{}", f());. What happens?Solution
error[E0382]: use of moved value: \f`—`f` moved due to this call, thenvalue used here after moveon the secondf(). Calling anFnOnceclosure 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. -
broken()’s fix is to remove the trailing semicolon from42;. Apply that fix, then predict — doesmax’s earlyreturn a;also need its semicolon removed to compile?Solution
No —
return a;is a complete statement using thereturnkeyword, 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 explicitreturnstatements. -
applytakes 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 afn(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. -
Predict what
factorial(0)returns from theconst fn, then check whether callingfactorialwith a non-constant argument (a value read at runtime, e.g. from aVec) still compiles as a regular function call.Solution
factorial(0)returns1— the0 | 1 => 1arm matches0directly (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 (as10is inconst FACT_10). Callfactorial(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.