</>PatchNote
Back to posts

2024-08-18

HSPACE Rust Lectures #8 — Closures

RustHSPACE 2024ClosuresTraits

Every language has closures, but in Rust they get much more interesting where they meet ownership. The session boils down to one question: who owns the variables a closure captured?

Capturing variables

A closure captures variables from the surrounding scope, and it does so in one of two ways.

Closures that borrow

The default is borrowing.

let mut cities = vec!["Seoul", "Busan", "Incheon"];

let print_first = || println!("{}", cities[0]);   // borrows cities as &
print_first();

println!("{:?}", cities);   // still usable

The compiler looks at how the body uses each variable and captures with the weakest form that works: &T for reads, &mut T for writes.

let mut counter = 0;

let mut increment = || counter += 1;   // borrows counter as &mut
increment();
increment();

println!("{counter}");   // 2

Having to declare increment itself as mut looked odd at first. But the closure holds a mutable borrow, so calling it is itself a mutating action.

And while it holds that borrow, the original is off limits:

let mut counter = 0;
let mut increment = || counter += 1;

// println!("{counter}");   // error: increment holds a mutable borrow

increment();
println!("{counter}");      // OK — increment's borrow ended here

The borrow rules apply to closures unchanged. No special cases.

Closures that steal

Add move and the closure takes ownership of what it captures.

let cities = vec!["Seoul", "Busan", "Incheon"];

let print_all = move || println!("{:?}", cities);   // cities moves in
print_all();

// println!("{:?}", cities);   // error: cities was moved into the closure

Why would you need that? When the closure must outlive the original variable — threads being the canonical case.

use std::thread;

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

thread::spawn(move || {
    println!("{:?}", data);     // used on the new thread
}).join().unwrap();

Borrowing instead would let the original stack frame disappear first, so the compiler rejects it. That's why thread::spawn demands 'static.

For Copy types, move still leaves the original usable — it copies rather than moves.

Fn, FnMut, FnOnce

A closure's type is an anonymous struct the compiler generates, so accepting one as a parameter means accepting it via a trait bound. There are three.

TraitHow it uses capturesCalls
FnOnceconsumes themexactly once
FnMutmutates themmany times (caller must hold it mut)
Fnonly reads themmany times

The containment matters: Fn ⊂ FnMut ⊂ FnOnce. A read-only closure demands neither mutation nor consumption, so it satisfies all three — which is why, when writing a function, you should ask for the loosest bound that works.

The example from session one is now fully explained.

fn call_twice<F>(closure: F) where F: Fn() {
    closure();
    closure();
}

let name = String::from("Chris");
let hello = || {
    println!("Hello, {name}");
    drop(name);          // consumes name → FnOnce
};

call_twice(hello);       // error: FnOnce doesn't satisfy Fn

drop(name) consumes the capture, so the closure can't be called twice. The compiler classifies it as FnOnce and call_twice, which requires Fn, rejects it. "Calling it twice would touch freed memory" is caught as a type error.

One thing that confused me and is worth stating: move and FnOnce are independent axes.

let s = String::from("hi");
let f = move || println!("{s}");   // move, but only reads s → Fn
f();
f();    // callable repeatedly

move is about how it captures; Fn/FnMut/FnOnce is about how the body uses what it captured. Owning the value and only reading it still gives you Fn.

Closures that drop

If a closure owns a value, dropping the closure drops the value.

let name = String::from("Chris");
let f = move || println!("{name}");
drop(f);            // name is freed here

Obvious once you see a closure as "a struct holding values" — which is exactly what the compiler builds.

Closure performance

Are closures expensive? Short answer: almost always free.

A closure is an anonymous struct whose fields are the captured variables. No heap allocation — that only appears when you wrap one in Box<dyn Fn>.

Take one generically (fn call<F: Fn()>(f: F)) and it's monomorphized, so the call site is resolved statically and usually inlined.

let v: Vec<i32> = (1..=100).collect();
let sum: i32 = v.iter().filter(|&&x| x % 2 == 0).map(|&x| x * x).sum();

That chain uses two closures and three adapters, and in an optimized build it produces the same assembly as a hand-written for loop. That's Rust's zero-cost abstraction claim — and it's why the intro post pointed at the Playground's ASM output. You don't have to take it on faith.

Closure safety

The contrast with C++ lambdas is sharp.

// C++ — compiles, and dangles at runtime
auto make_counter() {
    int count = 0;
    return [&count]() { return ++count; };   // count dies with the function
}

The equivalent in Rust does not compile:

fn make_counter() -> impl FnMut() -> i32 {
    let mut count = 0;
    move || { count += 1; count }   // without move: error
}

You must move count into the closure, and then the closure owns it, so it survives the function's return. The C++ problem of carefully choosing between [&] and [=] becomes a problem that fails to compile when you get it wrong.

Storing callbacks: two approaches

To put closures in a data structure you need a known size, but every closure has its own type. The router example makes the problem concrete.

type BoxedCallback = Box<dyn Fn(&Request) -> Response>;

struct BasicRouter {
    routes: HashMap<String, BoxedCallback>,
}

impl BasicRouter {
    fn new() -> BasicRouter {
        BasicRouter { routes: HashMap::new() }
    }

    fn add_route<C>(&mut self, url: &str, callback: C)
    where
        C: Fn(&Request) -> Response + 'static,
    {
        self.routes.insert(url.to_string(), Box::new(callback));
    }

    fn handle_request(&self, request: &Request) -> Response {
        match self.routes.get(&request.url) {
            None => not_found_response(),
            Some(callback) => callback(request),
        }
    }
}
let mut router = BasicRouter::new();
router.add_route("/", |_| get_form_response());
router.add_route("/gcd", |req| get_gcd_response(req));

assert_eq!(router.handle_request(&req("/piano")).code, 404);
assert_eq!(router.handle_request(&req("/")).code, 200);
assert_eq!(router.handle_request(&req("/gcd")).code, 500);

Two techniques work together here:

  • add_route<C> is generic — it accepts each closure at its own concrete type
  • storage is Box<dyn Fn(...)> — the type is erased to a trait object so different closures fit in one HashMap

The 'static bound says "this closure holds no borrowed references." The router's lifetime is unknown, so nothing it captured may die before it does.

Summarized:

  • returning impl Fn(...) / generic parameters — static dispatch, fast and inlinable, for a single type
  • Box<dyn Fn(...)> — dynamic dispatch, heap allocation plus an indirect call, for heterogeneous collections

Assignments

These target the closure traits precisely.

1. Cache System — build a closure that remembers results.

fn check_cache<F>(func: F) -> impl FnMut(i64) -> (i64, bool)
where
    F: Fn(i64) -> i64,
{
    let mut cache = HashMap::new();

    move |x| {
        if let Some(&ret) = cache.get(&x) {
            (ret, true)             // cache hit
        } else {
            let ret = func(x);
            cache.insert(x, ret);   // mutates the cache → FnMut
            (ret, false)
        }
    }
}
let mut compute_func = check_cache(|x| x * x);

assert_eq!(compute_func(4), (16, false));   // computed
assert_eq!(compute_func(4), (16, true));    // from cache
assert_eq!(compute_func(5), (25, false));   // computed

The whole session fits in this small function. The return type is impl FnMut because the closure mutates cache; move is needed because cache must outlive the function; and the parameter func only needs Fn — read-only, so take the loosest bound.

It also explains why the caller writes let mut compute_func: calling an FnMut is a mutating operation.

2. Order Filtering System — filter orders through closures supplied as predicates.

3. State-Tracking Task Scheduler — register stateful tasks and run them in order. The requirements are the interesting part.

type SharedState = Rc<RefCell<i32>>;
type Task = Box<dyn FnMut() -> String>;

pub struct TaskScheduler {
    tasks: Vec<Task>,
    shared_state: SharedState,
}

impl TaskScheduler {
    pub fn register_task<F>(&mut self, task: F)
    where
        F: FnMut() -> String + 'static,
    {
        self.tasks.push(Box::new(task));
    }

    pub fn execute_tasks(&mut self) -> Vec<String> {
        let mut ret = Vec::new();

        for task in self.tasks.iter_mut() {
            let result = task();
            ret.push(result.clone());

            if result == "fail" {
                break;      // stop on failure
            }
        }

        ret
    }

    pub fn restart(&mut self) -> Vec<String> {
        *self.shared_state.borrow_mut() = 0;
        self.execute_tasks()
    }
}

Different closures live in one Vec<Box<dyn FnMut() -> String>>, iterated with iter_mut() — it has to be iter_mut(), not iter(), precisely because these are FnMut.

Sharing state across tasks is Rc<RefCell<i32>>: Rc lets several closures own the same state, and RefCell allows mutation through a shared reference (interior mutability). Borrow checking moves from compile time to runtime, panicking on violation.

Why is RefCell needed? Rc<T> only hands out &T, but the tasks must mutate. The compiler can't know which Rc holder touches the value when, so it can't allow it statically — hence deferring the check. The important framing: this isn't bypassing the ownership rules, it's changing when they're checked.

Takeaways

  • closures borrow by default; move transfers ownership
  • Fn/FnMut/FnOnce is decided by how the body uses the captures
  • move and FnOnce are separate axes
  • closures are anonymous structs: no heap allocation, and inlined when taken generically
  • to hold many closures in one container, use Box<dyn Fn...>
  • to share state, use Rc<RefCell<T>> and accept runtime borrow checking

Next: concurrency — threads, channels, mutexes, and Send/Sync.