Skip to content

← Closures and Iterators step 27 of 28

Medium Primitives

The FnMut borrow gauntlet: a log you can read afterwards

Run a pipeline that records what it did, and return both the results and the log.

pub fn instrument(nums: Vec<i64>) -> (Vec<i64>, Vec<String>)

For each number, in input order:

  • if it is negative, log "skip:{n}" and drop it;
  • otherwise log "keep:{n}" and emit n * 3.

Then append one final line to the log: "events:K", where K is the number of lines logged before it.

[1, -2, 3]  ->  ([3, 9], ["keep:1", "skip:-2", "keep:3", "events:3"])
[]          ->  ([],     ["events:0"])
[0]         ->  ([0],    ["keep:0", "events:1"])

Zero is not negative, so it is kept.

The logging must be done by an FnMut closure that captures the log vector, and the log must be readable afterwards. Sequencing those two requirements is the entire exercise.

The error, and why it is where it is

The starter does the obvious thing and fails:

error[E0502]: cannot borrow `log` as immutable because it is also borrowed as mutable

The line it blames is let events = log.len(); — a read. Not a write. Reading a vector you own, and being told you may not.

Here is the rule, and it is worth stating precisely because it is the one that catches everybody:

A closure that mutates a captured variable holds a &mut to it, from the moment the closure is created until the closure’s last use. While that loan is live, the variable may not be accessed in any way — not read, not written, not even by its owner.

In the starter, record is created near the top and used again after the log.len() call. So the loan spans the read, and the read is rejected. The same code with the final record(..) deleted would compile, because non-lexical lifetimes end a loan at its last use — the borrow checker does not care about the closing brace, it cares about the last use.

This is a distilled version of the standard demonstration:

let mut count = 0;
let mut inc = || count += 1;
println!("{count}");   // E0502 — the read sits between creation and last use
inc();

Move the println! after inc() and it compiles. Nothing about the closure changed. The shape of your control flow changed, and that is what the borrow checker was reading all along.

The fix is to reorder, not to reach for a smart pointer

Two spellings, both good:

  • Let the closure die. Put the pipeline in a block that owns the closure; at the closing brace the closure is dropped, the loan ends, and log is yours again. Then read the length and push the final line directly.
  • Just stop using it. NLL ends the loan at the closure’s last use, so if the last thing you do with record is inside the chain, everything after the collect() is already free. The explicit block is clearer, but not strictly required.

Either way, the final "events:K" line is pushed onto log directly, not through the closure. That is the whole fix, and it is worth noticing how small it is compared to how stuck it feels.

About RefCell

Most people reach for RefCell<Vec<String>> here within about ninety seconds, and it does work. Let us be honest about when it is the right answer.

It is not the right answer here. You have one closure, one piece of state, single-threaded, and a natural point at which the closure is finished. RefCell would move a check the compiler was doing for free at compile time into a runtime check that can panic, and buy you nothing.

It is the right answer when:

  • the closure must be Fn, not FnMut — some APIs demand it, and a Fn closure cannot hold a &mut. This is the common real case.
  • two or more closures share the same state and are alive at once. Item 9.8 is exactly that: a map closure and a filter closure both appending to one log. No reordering can help, because both must exist simultaneously. RefCell is correct there and the runtime cost is a counter increment.

So: reorder when you can, RefCell when the ownership shape genuinely demands it, and be able to say which one you are doing.

While you are there

A related lint you will meet the moment you start instrumenting pipelines:

error: using `map` over `inspect`
  = note: `-D clippy::manual-inspect` implied by `-D warnings`

.map(|x| { log(x); x }) is .inspect(|x| log(x)). inspect exists exactly for “look at each item as it goes past without changing it”, and it says so at the call site. This problem’s side effects live in a filter predicate rather than a map, so the lint does not apply — but inspect is the tool when they live in the middle of a chain.

Grade is compile + tests + clippy -D warnings.

Loading visualization…