Skip to content

← Errors Are Values step 4 of 24

Easy Primitives

Eager vs lazy defaults

Fill in a default for every None in a list — and prove the default was computed only for the Nones.

pub fn resolve(inputs: Vec<Option<i32>>) -> (Vec<i32>, Vec<String>)

The starter already gives you expensive, which stands in for a database round trip or a file read. Every time it runs it appends a line to a log, and it returns -1. resolve returns two things: the resolved values, and that log.

The log is the assertion. For [Some(5), None, Some(7), None] the values are [5, -1, 7, -1] and the log has exactly two entries. For [Some(1), Some(2), Some(3)] the log is emptyexpensive must never have been called at all.

Why the starter is wrong

values.push(input.unwrap_or(expensive(&mut log)));

Read it as Rust and not as English. unwrap_or is an ordinary method call, so its argument is evaluated before the call happens, the same way it would be in C or Python. expensive(&mut log) runs on every iteration — including the iterations where input is Some and the result is immediately thrown away.

The returned values are still correct. That is precisely what makes this bug nasty: nothing about the output is wrong, the function is just doing work it promised not to do. In production that shows up as a mysterious query per row.

unwrap_or_else takes a closure instead of a value. A closure is a value you can pass around without running it; unwrap_or_else runs it only on the None branch. Same for ok_or vs ok_or_else, or vs or_else, unwrap_or_default (which is lazy — it calls Default::default() only when needed).

The gate here is doubled on purpose: the log assertion catches the behaviour, and #![deny(clippy::or_fun_call)] catches the shape.

error: function call inside of `unwrap_or`
   |
   |         values.push(input.unwrap_or(expensive(&mut log)));
   |                           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
   = help: try: `unwrap_or_else(|| expensive(&mut log))`

or_fun_call is allow-by-default, which is why the #![deny(…)] line at the top of the file is part of the problem. Leave it there.

The rule is not “always lazy”

Clippy yells in both directions, and this is where people who memorised one half get burned. unnecessary_lazy_evaluations is on by default:

error: unnecessary closure used to substitute value for `Option::None`
   |
   |     x.unwrap_or_else(|| 0)
   |       ^^^^^^^^^^^^^^^^^^^^ help: use `unwrap_or(..)` instead

So a learner who reacts to this lesson by making everything lazy fails the gate too, without even opting in.

The criterion is not a preference, it is a cost question:

Use the lazy form if and only if producing the default costs something.

Allocation (String::new(), vec![], to_string()), I/O, a function call, a loop: lazy. A literal, a const, a copy of a variable already in hand: eager. unwrap_or(0) is better than unwrap_or_else(|| 0) — it is shorter, and building a closure to return a zero is pure ceremony.

The related lint expect_fun_call says the same thing about opt.expect(&format!("missing {key}")): the format! allocates on every call, including the overwhelming majority where nothing is missing.

Notes

  • The closure captures log mutably. That is fine — unwrap_or_else(|| expensive(&mut log)) borrows log only for the duration of the call, and values is a different variable, so there is no conflict.
  • If you find yourself writing unwrap_or_else(|| expensive(&mut log)) and the borrow checker complains, look at what else in the same statement is touching log.
  • redundant_closure is the third lint in this family: map(|x| f(x)) should be map(f). It does not apply here, because expensive needs an argument the combinator cannot supply.

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

Loading visualization…