Skip to content

← Closures and Iterators step 1 of 28

Easy Primitives

Closures 101: build a pipeline of boxed closures

Turn a list of operation names into a list of closures, then run every number through the whole pipeline, left to right.

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

The recognised names are "double", "inc", "dec", "neg", "square" and "abs". Any name you do not recognise is skipped, not an error. With nums = [1, 2, 3] and ops = ["double", "inc"] the answer is [3, 5, 7] — each number is doubled, then incremented. An empty ops list is the identity.

The one fact that explains all closure syntax

A closure is written |a, b| expr, or |a, b| { .. } when you want statements. Parameter types and the return type are usually inferred, so you write |x| x * 2 and not |x: i64| -> i64 { x * 2 } — both are legal.

Here is the fact everything else follows from:

Every closure has its own, unique, anonymous type, invented by the compiler at the point you wrote it. Two closures with identical bodies have different types.

There is no Closure type. There is no way to write the type down. You have never seen a closure’s type printed, because rustc renders it as {closure@src/main.rs:4:9} — a source location, not a name.

That single sentence explains a whole family of otherwise-arbitrary ceremonies: why closure parameters are always generic (F: Fn(i64) -> i64), why you cannot put two closures in the same Vec without boxing them, and why the starter code for this problem does not compile.

Read the starter’s error before you change anything

The starter builds the closures the obvious way — a match returning Some(|x: i64| x * 2), Some(|x: i64| x + 1), and so on. Compile it. You will get a stack of E0308 mismatched types, one per arm after the first, saying something close to:

expected closure `{closure@...:4:22}`
   found closure `{closure@...:5:19}`

This is the anonymous-type fact biting. A match must produce one type, and those six closures are six different types. It is not that they have different signatures — they all take i64 and return i64. They are simply not the same type, and never will be.

The fix is to erase the difference: put each closure behind a trait object, Box<dyn Fn(i64) -> i64>. Now every arm has the same type, and the Vec becomes Vec<Box<dyn Fn(i64) -> i64>>. That extra Box is not decoration — it is the price of choosing which function to call at runtime.

You will need to tell the compiler what the boxed type is. The most compact place is an annotation on the binding:

let pipeline: Vec<Box<dyn Fn(i64) -> i64>> = ...;

…though if you build it with filter_map you may find you also need to annotate the closure’s return type, because inference cannot see the destination through the adapter.

The three capture modes

A closure can use variables from the enclosing scope. How it captures them is inferred — the compiler picks the least restrictive mode that makes your body compile, in this order:

Mode What the closure holds Triggered by
ImmBorrow &T reading the variable
UniqueImmBorrow a unique & reassigning through a &mut you captured
MutBorrow &mut T mutating the variable
ByValue T consuming it (or move)

You never write these. You only observe them, in the error message you get when the mode you needed conflicts with something else. E0596 cannot borrow as mutable means the compiler inferred MutBorrow and the binding was not mut. Item 9.2 and 9.3 are entirely about the consequences.

The trap that costs people an afternoon

A closure’s parameter types are inferred from its first use, and then fixed. This compiles:

let id = |x| x;
let a = id(5);

and this does not:

let id = |x| x;
let a = id(5);
let b = id("hi");   // E0308: expected integer, found `&str`

A closure is not generic. fn id<T>(x: T) -> T is generic; |x| x gets one concrete signature, chosen at the first call site. When you see E0308 blaming a closure you “know” is polymorphic, this is why.

Clippy will make you delete wrappers

Two default-on lints police this problem, and both are worth internalising right now:

  • redundant_closure|a| foo(a) is rejected. Pass foo. A function item coerces to Box<dyn Fn(..)> on its own; you do not need to wrap it. (For "abs", i64::abs is a perfectly good closure-shaped thing.)
  • map_clone.map(|x| x.clone()) is rejected in favour of .cloned(). You will meet this one properly in item 9.7.

The grade is compile + tests + clippy -D warnings, so “it works” is not the bar. Write it the way a reviewer would want to read it.

Loading visualization…