Skip to content

← Ownership II: Borrowing and the Borrow Checker step 21 of 24

Medium Primitives

Closures hold their borrows

pub fn apply_ops(xs: &mut Vec<i64>, ops: &[String]) -> Vec<String>

Apply each op to the vector in order, and return one log line per op.

Recognised ops, exactly:

op effect
"push <n>" append the integer n
"pop" remove the last element, if any
"double" multiply every element by 2

After a recognised op, log "<op> -> len=<new length>". Anything else — an unknown word, or a "push " whose argument does not parse as an integer — is logged as an error and does not panic: log "<op> -> error" and leave the vector alone.

"pop" on an empty vector is not an error. It is a recognised op that removes nothing, and it logs "pop -> len=0".

xs [1],  ops ["push 5"]     -> xs [1,5], log ["push 5 -> len=2"]
xs [1],  ops ["frobnicate"] -> xs [1],   log ["frobnicate -> error"]
xs [2],  ops ["push x"]     -> xs [2],   log ["push x -> error"]
xs [],   ops ["pop","push 3","double"]
                            -> xs [6],   log ["pop -> len=0","push 3 -> len=1","double -> len=1"]

The starter does not compile.

The borrow you cannot see

let mut push = |n: i64| xs.push(n);

There is no &, no &mut, no type annotation anywhere on that line. It reads like a definition. It is a borrow: the closure captured xs exclusively, and holds that loan for its entire lifetime as a value — not for the duration of a call, for as long as push is alive.

So every later use of xs collides:

error[E0501]: cannot borrow `*xs` as mutable because previous closure
              requires unique access

Closures are where borrow errors are least legible, precisely because the borrow is invisible. Learners routinely conclude that closures are broken, or that Rust “doesn’t let you use closures”, when in fact the closure is doing exactly what a let r = &mut xs; would do — with none of the syntax that would have made it obvious.

The four codes you will meet around closures, and what each means:

  • E0500 — a closure requires unique access to something already borrowed.
  • E0501 — the reverse: you tried to borrow something a live closure already has uniquely. This is the starter’s error.
  • E0373 — a closure outlives the function but captures a local by reference. The usual fix really is move.
  • E0524 — two closures both want unique access to the same thing.

What a closure captures, and how

Rust picks the weakest capture that makes the body work, per variable:

  • reads only → &T
  • writes → &mut T (and the closure must itself be mut to be called)
  • consumes the value → by move

move forces the last one for everything. That is why move is not the universal fix people reach for: here it would take ownership of xs, and xs is a &mut Vec<i64> parameter that the caller needs back. Moving a &mut into a closure means nothing outside can ever touch the vector again — which is worse, not better.

The fix

Stop capturing. Pass the data in as a parameter.

let apply = |v: &mut Vec<i64>, op: &str| -> bool { /* ... */ };
// ...
if apply(xs, op) { /* xs is free again the instant the call returns */ }

A parameter is borrowed for the duration of the call and not one instruction longer. Between calls there is no outstanding loan and xs.len() is fine.

This is the lesson, and it comes with a teaching problem: a learner who starts by writing the parameter version never sees the error at all. Hence the fix-it shape — the broken closure is written for you so that you meet E0501 once, deliberately, and recognise it forever.

(Nothing forces you to keep a closure at all. Inlining the whole thing into the loop is perfectly good Rust. The parameter version is shown because “capture it” versus “pass it in” is the distinction worth carrying away.)

RFC 2229: closures capture fields, since edition 2021

One modernity check, because it changes what old answers mean.

Before edition 2021, a closure that touched a.y captured the whole of a. Since 2021 (RFC 2229, “disjoint closure captures”) it captures the place a.y — so this now compiles:

let mut a = Pair { x: vec![1], y: 0 };
let f = || println!("{}", a.y);
a.x.push(2);            // fine in 2021+; E0502 before
f();

Your code compiles as edition 2024, so you get the new behaviour. Stack Overflow answers written before 2021 may describe errors you will never see, and workarounds (let y = &a.y; before the closure) that are now redundant.

Two migration caveats, since they are real and occasionally bite:

  • Drop order changed. Capturing fewer things means the un-captured parts are dropped at a different time than they used to be.
  • Auto-trait impls can change. A closure that no longer captures a non-Send field is now Send. Usually a gift; occasionally it changes which overload or bound applies.

Watch for

redundant_closure (a closure that just forwards to one function — pass the function) and needless_borrow (&x where x already coerces).

Remember the grade is compile + tests + clippy -D warnings.

Loading visualization…