Skip to content

Rust Closures and Function Pointers

A Rust closure is callable code together with the environment it captures. Its call traits depend on what its body does with that environment; move changes how values are captured, not automatically how many times the closure can be called.

Prerequisites and outcome

Complete trait objects and dispatch. You will pass predicates and stateful callbacks, transfer an owned value through a one-shot closure, and choose between a generic callable and a function pointer. All readings are fixtures, not hardware measurements.

Match the Rust closure bound to the caller's needs

Bound What the caller can require Example here
Fn Calls through a shared reference A predicate that reads a threshold
FnMut Calls with mutable access to the callable A callback that updates its call count
FnOnce A consuming call is permitted A closure that returns its captured String

Fn implies FnMut, and FnMut implies FnOnce. Every closure implements FnOnce; a closure may also implement the other call traits. A FnOnce bound does not prove the actual callable is only callable once, but it does not promise repeatability. See FnOnce.

Build the complete closure program

cargo new pi_closures
cd pi_closures

Keep edition = "2024" in Cargo.toml and replace src/main.rs with:

fn count_matching<F>(readings: &[i32], predicate: F) -> usize
where
    F: Fn(i32) -> bool,
{
    let mut count = 0;
    for &reading in readings {
        if predicate(reading) {
            count += 1;
        }
    }
    count
}

fn apply_twice<F>(mut operation: F, start: i32) -> i32
where
    F: FnMut(i32) -> i32,
{
    let first = operation(start);
    operation(first)
}

fn run_once<F>(operation: F) -> String
where
    F: FnOnce() -> String,
{
    operation()
}

fn measure_twice<F>(operation: &F) -> (usize, usize)
where
    F: Fn() -> usize,
{
    (operation(), operation())
}

fn add_one(value: i32) -> i32 {
    value + 1
}

fn apply_pointer(operation: fn(i32) -> i32, value: i32) -> i32 {
    operation(value)
}

fn make_offset(offset: i32) -> impl Fn(i32) -> i32 {
    move |value| value + offset
}

// The callback must accept the local borrow created inside this function.
fn with_local_label<F>(callback: F) -> usize
where
    F: for<'a> Fn(&'a str) -> usize,
{
    let label = String::from("Pi 4B");
    callback(&label)
}

fn main() {
    let threshold = 60_000;
    let predicate = |reading| reading >= threshold;
    println!(
        "matching={}",
        count_matching(&[46_700, 60_000, 61_000], predicate)
    );

    let mut calls = 0;
    let mut update = |value| {
        calls += 1;
        value + calls
    };
    let result = apply_twice(&mut update, 0);
    println!("stateful={result}, calls={calls}");

    let owned = String::from("Pi 4B");
    let finish = move || owned;
    println!("once={}", run_once(finish));

    // Own the String, but only inspect it: this move closure implements Fn.
    let label = String::from("Pi 4B");
    let inspect = move || label.len();
    println!("move read-only={:?}", measure_twice(&inspect));

    println!(
        "pointers={},{}",
        apply_pointer(add_one, 5),
        apply_pointer(|value| value + 2, 5)
    );
    let offset = make_offset(2);
    println!("returned={}", offset(5));
    println!("local bytes={}", with_local_label(|text| text.len()));
}

#[cfg(test)]
mod tests {
    use super::{add_one, apply_pointer, apply_twice, count_matching};
    use super::{make_offset, measure_twice, run_once, with_local_label};

    #[test]
    fn predicate_captures_threshold_and_includes_the_boundary() {
        let threshold = 60_000;
        assert_eq!(count_matching(&[59_999, 60_000], |v| v >= threshold), 1);
        assert_eq!(count_matching(&[], |v| v >= threshold), 0);
    }

    #[test]
    fn mutable_capture_records_both_calls() {
        let mut calls = 0;
        let mut update = |value| {
            calls += 1;
            value + calls
        };
        assert_eq!(apply_twice(&mut update, 0), 3);
        assert_eq!(calls, 2);
    }

    #[test]
    fn once_can_transfer_a_non_copy_capture() {
        let label = String::from("Pi 4B");
        assert_eq!(run_once(move || label), "Pi 4B");
    }

    #[test]
    fn move_read_only_capture_still_satisfies_fn() {
        let label = String::from("Pi 4B");
        let inspect = move || label.len();
        assert_eq!(measure_twice(&inspect), (5, 5));
        assert_eq!(inspect(), 5);
    }

    #[test]
    fn pointer_accepts_function_and_non_capturing_closure() {
        assert_eq!(apply_pointer(add_one, 0), 1);
        assert_eq!(apply_pointer(|value| value + 2, -2), 0);
    }

    #[test]
    fn returned_closure_owns_its_offset() {
        let operation = make_offset(-500);
        assert_eq!(operation(500), 0);
        assert_eq!(operation(0), -500);
    }

    #[test]
    fn callback_accepts_local_borrow_without_retaining_it() {
        assert_eq!(with_local_label(|label| label.len()), 5);
        assert_eq!(with_local_label(str::len), 5);
    }
}
1
2
3
4
cargo check
cargo test
cargo fmt --check
cargo run --quiet

Expected output:

1
2
3
4
5
6
7
matching=2
stateful=3, calls=2
once=Pi 4B
move read-only=(5, 5)
pointers=6,7
returned=7
local bytes=5

There are seven tests. The value 3 is a deliberately stateful result: the first callback adds 1 and the second adds 2. If the callback did not retain its count, this would be a different algorithm.

Capture mode and call behaviour are separate

The predicate borrows the threshold to read it. The update closure mutably borrows calls and changes it each time. Its binding is mutable because calling this FnMut closure needs mutable access. The borrow can end after the last relevant use of update, allowing the final count to be printed.

Without move, capture choices follow how the body uses the environment; move requests capture by value. Copy values can be copied into that environment, while a captured String transfers ownership. If the captured value is already a reference, moving that reference does not move its referent or extend its validity. The Book's closure capture examples introduce these choices.

finish returns the captured String by value, consuming that capture when called. inspect owns its String but only borrows it internally to compute len(), so it supports repeated shared calls. This is why inspecting the body matters more than looking for the move keyword.

Captures may be precise parts of a value rather than an entire surrounding struct, subject to the language's capture rules. A closure's Send, Sync or lifetime requirements depend on what it captures; move alone is not a thread-safety or static-lifetime guarantee. See the closure-type reference. Threads come later.

A generic callable can own or borrow its environment

apply_twice takes F by value. Passing &mut update makes F a mutable reference to the existing closure, rather than transferring the closure itself. run_once consumes its callable and may return owned text; that result can outlive the callable because it owns the String.

Choose the least restrictive bound needed by the algorithm. A one-call consumer can accept FnOnce; a repeated stateful algorithm needs FnMut; shared calls require Fn. Fn does not mean mathematically pure: interior mutability or other side effects can exist behind an apparently shared call. We will study those mechanisms later rather than equate Fn with “no effects”.

Function items and function pointers are not captured environments

The type fn(i32) -> i32 is a safe Rust function pointer. A named function item coerces to that pointer type; a non-capturing closure can also coerce. A closure that captures offset cannot be put into this pointer type, because the pointer has no captured environment to carry.

Every closure expression has its own anonymous type, even when two expressions have identical signatures. Inference also chooses concrete parameter types; let identity = |x| x is not a generic function you can freely call with both String and i32. Generic callable bounds avoid needing to name the closure type. Consult the function-pointer rules.

Function pointers and capturing closures both support ordinary call syntax, but do not promise the same representation or dispatch cost. No timing comparison is claimed. Foreign ABI and unsafe function pointers are postponed to the unsafe/FFI lesson.

Returning a closure and borrowing through a callback

make_offset returns one hidden concrete closure type via impl Fn. Its move capture stores offset in the returned environment, so the original local parameter can end. Omitting move would try to return a closure borrowing that local parameter and fail. A factory returning different closure expressions from two branches must still respect the opaque return's one-concrete-type rule.

with_local_label constructs its own String. The bound for<'a> Fn(&'a str) -> usize accepts a callback for every applicable input lifetime, including the short borrow created inside the function. The callback returns an owned usize, not that borrowed label. The shorter spelling Fn(&str) -> usize expresses the same lifetime generality in this context; see higher-ranked bounds.

A callback requiring only &'static str cannot satisfy this contract. This is different from moving a String into a callback: input lifetime requirements and captured environment ownership are separate questions.

Deliberately failing: consuming a capture twice

In a separate scratch project, replace src/main.rs with:

1
2
3
4
5
6
7
fn main() {
    let label = String::from("Pi 4B");
    let finish = move || label;
    let first = finish();
    let second = finish();
    println!("{first}, {second}");
}

cargo check reports E0382. The first call consumes this closure because its body moves the non-Copy String out. If two independently owned strings are required, a closure returning label.clone() can be repeatable, with the explicit copying cost. If only inspection is required, borrow the captured data instead.

Exercises and troubleshooting

  1. Change apply_twice's bound from FnMut to Fn: passing &mut update produces E0277 because that mutable callable reference does not implement Fn. Passing update by value instead produces E0525 because the closure mutates its capture and is only FnMut. Keep FnMut for this algorithm.
  2. Remove mut from update's binding while passing &mut update: expect E0596. The mut parameter inside apply_twice does not make the caller's binding mutable.
  3. Print label immediately after creating the move read-only inspect closure: expect E0382 because the String moved into inspect, even though inspect itself implements Fn.
  4. Pass a closure capturing a local offset to apply_pointer: expect E0308. Use a generic Fn bound when a captured environment is required.
  5. Remove move from make_offset's returned closure: expect E0373 because the returned environment would borrow the function's local offset.
  6. Repair finish to return label.clone() and call it twice: expect Pi 4B, Pi 4B. Explain why Clone changes this body's consuming behaviour rather than treating it as a free ownership repair.
  7. Define fn static_length(text: &'static str) -> usize { text.len() } and pass it to with_local_label: expect a “not general enough” error. An inline closure with a static input annotation can instead report a lifetime error. Replacing the higher-ranked bound with a static input would not make the locally constructed label static.

When a call-trait bound fails, inspect mutations and moves in the body. When a function-pointer conversion fails, check for captured values. When a closure outlives its captures, choose valid borrowing or transfer ownership rather than adding lifetime annotations that cannot extend storage.

Verification and next step

On October 10, 2026, the lesson was verified on a Raspberry Pi 4B with 64-bit user space, kernel 6.18.50+rpt-rpi-v8, Rust and Cargo 1.99.0, and edition 2024. Cargo check, all seven tests, formatting and debug/release output comparisons passed. The repeatable Clone repair and elided higher-ranked callback bound also passed. Consumed/moved captures, an immutable closure binding, a capturing function-pointer conversion, a returned local borrow, insufficient callback lifetime generality and both forms of the inappropriate Fn bound failed as expected (E0382, E0596, E0308, E0373, a generality diagnostic, E0277 and E0525 as applicable). The inline static callback was separately observed to report a lifetime error. No thread-safety or performance claim is inferred from move.

Next: iterators and lazy adapters, following item ownership and deferred processing.

Previous: trait objects and dispatch · Course overview

Donate