Rust is strict, whether you like it or not

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 both Option and Result.

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_call is under clippy::nursery i.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 clippy with cargo, we need to use the clippy-driver instead of the cargo clippy command that would be cargo 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.