While this post (and the title) focuses on
unwrap_or, the same eager evaluation behaviour applies to any method that takes a value T as a fallback argument, such as:or,map_or,ok_or, etc. on bothOptionandResult.
Will the following snippet panic!?
fn main() {
let a = Some(1.0);
let b = None;
let c = a.unwrap_or(match b {
Some(x) => x,
None => panic!(),
});
}
Yes. It will.
Despite a being Some, as there's no conditional evaluation but rather strict / eager,
the default value i.e., what goes after unwrap_or will be evaluated before the
function is called, and hence will panic.
In this case to get the "this, that or fail" behaviour without evaluating
the "that" before the "this", you should use instead either unwrap_or_else which takes a
closure (and closures are lazily evaluated i.e., evaluated when required):
fn main() {
let a = Some(1.0);
let b = None;
let c = a.unwrap_or_else(|| match b {
Some(x) => x,
None => panic!(),
});
}
Or rather a nested match so that the None branch of the inner Option<T> is only
evaluated when outer Option<T> is None.
fn main() {
let a = Some(1.0);
let b = None;
let c = match a {
Some(x) => x,
None => match b {
Some(x) => x,
None => panic!(),
}
};
}
How Is unwrap_or Implemented in std
If we take a closer look into Rust std, we'll see that the documentation of
Option::unwrap_or
says:
Arguments passed to unwrap_or are eagerly evaluated; if you are passing the result of a function call, it is recommended to use unwrap_or_else, which is lazily evaluated.
And, then if we look into the source code, we see that default: T is a fully
owned value, so when unwrap_or executes the match the default value is already
evaluated.
pub const fn unwrap_or(self, default: T) -> T
where
T: [const] Destruct,
{
match self {
Some(x) => x,
None => default,
}
}
Whilst if we look into the Option::unwrap_or_else
source code, we see that as the default value is now a closure with f: F and F: FnOnce(),
then it's only invoked on the None branch of the match.
pub const fn unwrap_or_else<F>(self, f: F) -> T
where
F: [const] FnOnce() -> T + [const] Destruct,
{
match self {
Some(x) => x,
None => f(),
}
}
What Does clippy Have to Say?
Even when the default value T doesn't panic when evaluated, as in the aforementioned example,
you might still want to avoid the unnecessary evaluation and rather use the _else variant of
e.g., unwrap_or being unwrap_or_else, which is exactly what clippy::or_fun_call
catches.
clippy::or_fun_callis underclippy::nurseryi.e., under development, hence not included as default.
Say we have the following snippet:
fn main() {
let a = Some(1.0);
let b = None;
let c = a.unwrap_or(match b {
Some(x) => x,
None => panic!(),
});
}
Then if we were to run clippy with clippy::or_fun_call, it would suggest to
use unwrap_or_else with a closure instead, as there's a function call inside the
default value.
$ clippy-driver -W clippy::or_fun_call main.rs
...
warning: function call inside of `unwrap_or`
--> main.rs:5:15
|
5 | let _ = a.unwrap_or(match b {
| _______________^
6 | | Some(x) => x,
7 | | None => panic!(),
8 | | });
| |______^
|
= help: for further information visit https://rust-lang.github.io/rust-clippy/rust-1.93.0/index.html#or_fun_call
= note: requested on the command line with `-W clippy::or-fun-call`
help: try
|
5 ~ let _ = a.unwrap_or_else(|| match b {
6 + Some(x) => x,
7 + None => panic!(),
8 ~ });
|
...
Given that we're not running
clippywithcargo, we need to use theclippy-driverinstead of thecargo clippycommand that would becargo clippy -- -W clippy::or_fun_call. More information at https://doc.rust-lang.org/stable/clippy/usage.html#using-clippy-without-cargo-clippy-driver.
clippy::or_fun_call is likely still on nursery because there are some known limitations:
If the function has side-effects, not calling it will change the semantic of the program, but you shouldn't rely on that.
The lint also cannot figure out whether the function you call is actually expensive to call or not.
Until then, the _else variant with a closure is the safe default when in doubt.
Conclusion
Eager evaluation in Rust can catch you off guard, specially when the default value i.e.,
the fallback of a method like unwrap_or has side-effects as a panic! or is expensive
to compute, as it'll be evaluated before the match.
The rule of thumb is that if within the or value there's any function call or any non-trivial
expression, then use the or_else variant instead and wrap it in a closure. And, when
in doubt, clippy::or_fun_call has your back.
Even if this post focused on unwrap_or, it applies to any other eager method as:
or, map_or, ok_or, etc., and all those come with their respective _else variant
that accepts a closure, F: FnOnce.