Skip to content
academia.sh

Lesson 12 / 15

Closures

A closure's body does not run on the line where it is defined but at the moment it is called: the same job is written as a direct call, a deferred call, and a closure that is never called, and the run counts are compared; what moving versus capturing by reference does to the source value is also measured.

Published Updated

In this lesson

  • Compares, by running it, how many times the same job runs when written as a direct call, a deferred closure, and a closure that is never called
  • Shows that the lifetime of a value captured by move is bound to the closure itself, independent of whether the body ever ran
  • Distinguishes a closure's own definition from a separate act that never enters the run ledger
Contents

The previous topic measured that the rest of a body might never run at all: an early return or an unrecoverable error silently skipped the steps written in the source. That measurement looked at which branch ran — the body itself was always in the same call. This lesson takes the same “how many times did it run” question down to a smaller unit: not the branch, but the way the body is stored. Whether a body runs even once now depends on how it was stored before being called. A closure is a value that can capture something from its surroundings and defer a body to later; that definition was established in the Programming Fundamentals course and is not rewritten here. What this lesson measures is how many times the same job runs across three storage forms, how many times calling the same closure repeatedly runs its body, and what capturing does to the source value. All three measurements use the same run ledger: when a body runs, it writes its own name into this ledger; when it does not run, it leaves no trace. The count is a record read from the run itself, not a guess.

The Same Job, Three Ways of Being Called

A body can be written in three forms: as an ordinary function that is defined and called immediately; wrapped in a closure, stored under a name, and called later; or wrapped in a closure and never called at all. The source text is nearly identical in all three — the difference lies in the distance between where it is defined and where it is called. For an ordinary function call this distance is zero. Once a closure is stored, this distance opens up, and the body becomes callable whenever its name is held — or never called at all. The distance itself is not what is measured; what is measured is whether the body ends up being called at all.

In the run below, direct_body is not a closure but an ordinary function defined inside another function; this is deliberate, because a nested function in Rust cannot capture anything from its surroundings — ledger is passed to it explicitly. Closure syntax (||), by contrast, turns a body into a value bound to the names around it; this difference is noted so the comparison stays fair. What is being compared is not “is it a closure” but the distance between definition and call.

CI1. — the claim that “the same job runs on a direct call, does not run until called on a deferred closure, and never runs by the end of the run on a closure that is never called” is confirmed by the run below.

// closure_body.rs — the same job in three forms: direct call, deferred call, closure never called
use std::cell::RefCell;
use std::collections::BTreeMap;
use std::rc::Rc;

type Ledger = Rc<RefCell<BTreeMap<String, u32>>>;

fn record(ledger: &Ledger, name: &str) {
    *ledger.borrow_mut().entry(name.to_string()).or_insert(0) += 1;
}

fn times(ledger: &Ledger, name: &str) -> u32 {
    *ledger.borrow().get(name).unwrap_or(&0)
}

fn main() {
    let ledger: Ledger = Rc::new(RefCell::new(BTreeMap::new()));

    // direct body: not a closure, an ordinary function defined and called right away
    fn direct_body(ledger: &Ledger) {
        record(ledger, "direct-body");
    }
    direct_body(&ledger);

    // deferred body: stored in a closure, does not yet run on the line where it is defined
    let deferred = || record(&ledger, "deferred-body");
    println!("right after being defined: {}", times(&ledger, "deferred-body"));
    deferred();
    println!("after calling: {}", times(&ledger, "deferred-body"));

    // never-called closure: the same slice as the course's measurement core, never called
    let _closure = || record(&ledger, "uncalled-closure");

    println!("direct body: {}", times(&ledger, "direct-body"));
    println!("uncalled closure: {}", times(&ledger, "uncalled-closure"));
}
right after being defined: 0
after calling: 1
direct body: 1
uncalled closure: 0

The deferred body has not yet run on the line where it is defined: the counter read right after the closure is defined is zero. Once it is explicitly called, the counter jumps to one — no matter how many lines, calls, or branches sit between definition and call, the counter would stay at zero until deferred is called. The third body, uncalled-closure, is the exact slice from the course’s measurement core: it is stored under the name _closure and is never called anywhere else in the program. This line falls into the never class in the course’s binding table — being written in the source does not mean having run. This measurement is not to be confused with the question the JVM, Concurrency and Performance course asks — which thread runs a given step: there is only one thread here, and the only question is how many times the body ran.

A Closure’s Body Runs As Many Times As It Is Called

The previous measurement looked at whether the same body ran even once. But a closure is not limited to a single call: the same closure stored under one name can be called repeatedly, and every call re-runs its body — the previous result is never remembered, stored, or reused. What sets a closure apart from an ordinary value is that its body is bound to a name and can be called as many times as wanted. This is a different question from the gap between “never” and “at least once”: a closure may never be called, but the same closure may also be called many times, and in that second case the counter tracks the call count exactly.

CI2. — the claim that “calling the same closure three times in a row runs its body three times, not once” is confirmed by the run below, where three separate calls raise the counter to three.

// multi_call.rs — a closure whose body only reads a captured value can be called repeatedly
use std::cell::RefCell;
use std::collections::BTreeMap;
use std::rc::Rc;

type Ledger = Rc<RefCell<BTreeMap<String, u32>>>;

fn record(ledger: &Ledger, name: &str) {
    *ledger.borrow_mut().entry(name.to_string()).or_insert(0) += 1;
}

fn times(ledger: &Ledger, name: &str) -> u32 {
    *ledger.borrow().get(name).unwrap_or(&0)
}

fn main() {
    let ledger: Ledger = Rc::new(RefCell::new(BTreeMap::new()));
    let label = String::from("example-label");

    let multi_called = || {
        record(&ledger, "multi-called-body");
        label.len()
    };

    let a = multi_called();
    let b = multi_called();
    let c = multi_called();

    println!("lengths after three calls: {a}, {b}, {c}");
    println!("how many times the body ran: {}", times(&ledger, "multi-called-body"));
}
lengths after three calls: 13, 13, 13
how many times the body ran: 3

multi_called only reads the label it captured — it returns its length, never carrying the value itself out of its body. This is why the same closure can be called three times, and each call raises the counter by one: three calls, three runs. Had this body instead carried the value’s ownership out — for instance, returning label itself — the first call would have let the value slip away, and the compiler would have rejected the second call: a body can no longer hand out a value it no longer holds. This rejection is not put into the source, because a source that does not compile falls outside the lesson’s scope; but the result stands — a closure that only reads its captured value can be called as often as wanted, while one that consumes it can only be called once. Both are the same ownership rule, reflected differently in the call count. These three degrees of callability — as often as wanted, only by mutating, only once — correspond to the three closure traits in Rust’s standard library; their names and the boundary between them fall outside this lesson’s scope, since reading a contract’s boundary is a separate lesson’s subject. What is measured here is only the call count’s reflection back onto the source.

What Capturing Does to the Source Value

Whether closures run is one half of the question; what they capture is the other. A closure can take a value from its surroundings in two ways: by borrowing (by reference) or by moving. This choice is not explicit in the body’s source text — the compiler decides for itself by looking at what the body does with each captured name; the move keyword forces this decision by hand. Moving is the closures’ version of the ownership rule built in the Ownership and the Memory Model course: once a value is moved into a closure, that closure itself becomes the value’s owner, and the value’s lifetime becomes bound to the closure’s lifetime — even if its body never runs.

CI3. — the claim that “a value captured by moving is dropped together with the closure even if the closure’s body never runs” is confirmed by the run below, where the captured-value-dropped counter stays at zero until the closure is dropped and then jumps to one, while the body’s own counter stays at zero throughout the run.

// capture_lifetime.rs — a value captured by moving has its lifetime bound to the closure itself
use std::cell::RefCell;
use std::collections::BTreeMap;
use std::rc::Rc;

type Ledger = Rc<RefCell<BTreeMap<String, u32>>>;

fn record(ledger: &Ledger, name: &str) {
    *ledger.borrow_mut().entry(name.to_string()).or_insert(0) += 1;
}

fn times(ledger: &Ledger, name: &str) -> u32 {
    *ledger.borrow().get(name).unwrap_or(&0)
}

struct Tracked {
    ledger: Ledger,
}

impl Drop for Tracked {
    fn drop(&mut self) {
        record(&self.ledger, "captured-value-dropped");
    }
}

fn main() {
    let ledger: Ledger = Rc::new(RefCell::new(BTreeMap::new()));
    let tracked = Tracked { ledger: Rc::clone(&ledger) };

    // the closure captures "tracked" by moving it: the name "tracked" cannot be used after this line
    let never_called = move || {
        record(&tracked.ledger, "uncalled-closure-2");
    };
    println!(
        "after the closure is defined, before ever being called: {}",
        times(&ledger, "captured-value-dropped")
    );

    drop(never_called);
    println!("after the closure is dropped: {}", times(&ledger, "captured-value-dropped"));
    println!("its body never ran: {}", times(&ledger, "uncalled-closure-2"));
}
after the closure is defined, before ever being called: 0
after the closure is dropped: 1
its body never ran: 0

The Tracked value is moved into the closure with the move keyword; after this line the name tracked can no longer be used — the closure has become the value’s owner. When the closure is dropped without ever being called, the moved Tracked value ends along with it, and the drop leaves its mark in the ledger. This is an event entirely separate from the body’s run counter: uncalled-closure-2 stays at zero for the whole run, while the counter for the captured value’s drop jumps to one. The closure’s own definition, and its holding onto the moved value, leaves a real effect on the source even though its body is never called — only this effect is not what the run counter measures. Even without the explicit drop call, the same drop would have happened once the closure’s scope ended; the explicit call here only made the moment observable, and did not change the outcome.

Capturing by reference, unlike moving, keeps the source name alive; the borrowing rules established in the Ownership and the Memory Model course apply here too, and this lesson does not rebuild them.

CI4. — the claim that “a closure that captures by reference leaves the name it captured usable both after the closure is defined and after the closure is called” is confirmed by the run below, where the name label is readable in all three lines.

// capture_by_reference.rs — capturing by reference keeps the source name alive, unlike moving
use std::cell::RefCell;
use std::collections::BTreeMap;
use std::rc::Rc;

type Ledger = Rc<RefCell<BTreeMap<String, u32>>>;

fn record(ledger: &Ledger, name: &str) {
    *ledger.borrow_mut().entry(name.to_string()).or_insert(0) += 1;
}

fn times(ledger: &Ledger, name: &str) -> u32 {
    *ledger.borrow().get(name).unwrap_or(&0)
}

fn main() {
    let ledger: Ledger = Rc::new(RefCell::new(BTreeMap::new()));
    let label = String::from("measurement-label");

    let captures_by_reference = || {
        record(&ledger, "reference-captured-body");
        label.len()
    };

    println!("after the closure is defined, label is still readable: {label}");
    let length = captures_by_reference();
    println!("after the call: {}", times(&ledger, "reference-captured-body"));
    println!("label is still reachable after the closure: {label} (length {length})");
}
after the closure is defined, label is still readable: measurement-label
after the call: 1
label is still reachable after the closure: measurement-label (length 17)

captures_by_reference is defined without the move keyword, and its body only reads label; this is why capturing reduces to a borrow rather than a move. The name label can be read directly both right after the closure is defined and after it is called — a full contrast with the previous run, where tracked became unusable right after being moved. The same capture syntax (|| { ... }) produces two different ownership outcomes depending on what the body does with the value it captures: one closes off the source name, the other leaves it open. This lesson does not rebuild these two outcomes, it only measures that both are independent of the body’s run count. Which path gets chosen is read from whether move is written in the source; how many times the body runs is a separate axis, set only by how many times the closure is called.

A Bounding Measurement

The run ledger only counts body calls; a closure being defined is also work, but this work never enters the ledger. When a closure is defined, the values it captures (if any) are copied or moved right then, and the closure itself, as a value, takes up a place in memory — real work, but none of it involves a counter call, because what triggers a call is not the body we wrote but the closure representation the compiler produces. This is why uncalled-closure’s counter being zero does not mean the closure costs nothing; it only means its body never ran. What it costs to build the closure falls outside the scope of the number this lesson measures.

This boundary looks small for a single closure but grows once several are built one after another. If a closure is itself a value, that value can be placed in another structure, passed as a parameter, or become a link in a chain — and every link takes up a place at the moment it is built, even if it never runs. This will matter directly in the next topic: once several closures are chained into a single traversal contract, the contract itself gets built, but its bodies do not run unbidden. This lesson measured the first link of that chain — a single closure.

Put together, these four runs sketch the following picture: whether a body runs depends on how it is stored, how many times it runs depends on the call count, and which value ends when depends on how it was captured — and none of these three questions determines the others. A closure can hold a value and drop it without ever being called; it can be called many times without ever moving any value; or it can consume its captured value and be limited to a single call. The source can suggest which answer each question will give, but the exact number is only ever read from the run.

Summary

  • The same job can be written in three forms: a direct call, a deferred closure, a closure that is never called; among closures, only the ones that are called actually run.
  • The uncalled-closure body from the course’s measurement core did not run in this run at all — being written in the source does not mean having run.
  • If the same closure is called several times, its body re-runs on every call (three calls, three runs); a closure that only reads its captured value can be called as often as wanted, while one that carries the value out of its body can only be called once.
  • The lifetime of a value captured by moving is bound to the closure itself; even if the body never runs, the captured value is dropped together with the closure.
  • Capturing by reference, unlike moving, keeps the source name alive — unlike moving, it does not change the value’s ownership.
  • A closure’s own definition is a separate act that does not enter the run ledger; the count only ever measures body calls.

Next Step

In this lesson, a closure’s body was stored on its own and ran or did not run depending on whether it was called; how many times it was called was reflected directly in the run count. The next lesson carries the same question from a single closure to a chain: when many closures join around the same contract, the contract itself — the iterator — decides when this chain advances. That a built chain never enters the run ledger without a single call to trigger it will be measured as a direct extension of this lesson’s uncalled-closure example.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close