Search code examples
rustlazy-evaluationeager-execution

In Rust `map_or` and `map_or_else` method of `Option`/`Result`, what's lazy/eager evaluation?


I know Rust iterator (adaptors) are "lazy" since they doesn't actually do anything until we access them. To me iterators are like some sort of telescopes to peek stars, and that iterator adaptors are like adding more filters to make the telescope more "fun to use", e.g. a red glass s.t. everything would be red. Of course one see neither original nor all red distant view unless we actually grabbed the telescope and sees through it.

playground

fn main(){
    let distant_things = &[4, 2, 0, 6, 9];
    let telescope = distant_things.iter();
    let telescope_with_red_lens = distant_things.iter().map(|i| {
        print!("Here's red version of ");
        i
    });
    for i in telescope {
        println!("{i}");
    }
    for red in telescope_with_red_lens {
        println!("{red}");
    }
}

But I can't wrap my head around this statement in rust doc, which states that arguments passed to Result::map_or is eagerly evaluated, while Result::map_or_else is lazily evaluated; similar applies to Option::map_or and Option::map_or_else. To me if there's a difference on lazy/eager evaluation, than in the following code the (stdout) output should show some major time difference instead of all being near 1 second, in particular I'd expect map_or to report time near 0 when I called experiment_laziness(Ok(()));. I imagine since eager evaluation of Result::map_or on Ok variant, the closure is actually called before the thread sleeps for 1 second and joins, and hence the Ok(_) from join() is mapped to a closure which is evaluated some time near 1 second before, resulting elapsed time to be near zero. But the outputs are all near 1 second.

playground

use std::thread;
use std::time::{Duration, Instant};

fn experiment_laziness(variant: Result<(), ()>) {
    let start = Instant::now();
    let variant_0 = variant.clone();
    let variant_1 = variant.clone();
    let map_or_handle = thread::spawn(move || {
        thread::sleep(Duration::from_millis(1000));
        if let Err(_) = variant_0 {
            panic!();
        }
    });
    let map_or_else_handle = thread::spawn(move || {
        thread::sleep(Duration::from_millis(1000));
        if let Err(_) = variant_1 {
            panic!();
        }
    });
    let map_or = map_or_handle
        .join()
        .map_or(start.elapsed(), |_| start.elapsed());
    let map_or_else = map_or_else_handle
        .join()
        .map_or_else(|_| start.elapsed(), |_| start.elapsed());
    report_stats("map_or", &variant, &map_or);
    report_stats("map_or_else", &variant, &map_or_else);
}

fn report_stats(method_name: &str, variant: &Result<(), ()>, t: &std::time::Duration) {
    println!(
        "Experiment {}, requested {}to panic, elapsed {:?}",
        method_name,
        match variant {
            Err(_) => "",
            Ok(_) => "NOT ",
        },
        t
    );
}

fn main() {
    experiment_laziness(Err(()));
    experiment_laziness(Ok(()));
}

It seems to me here they are all kinda "lazy". What exactly does it mean to be eager/lazy when it comes to Result::map_or_else, Result::map_or, and alike in Option? Or examples where either lazy/eager should be utilized/avoided?


Solution

  • It seems to me here they are all kinda "lazy". What exactly does it mean to be eager/lazy when it comes to Result::map_or_else, Result::map_or, and alike in Option?

    map_or takes a value, this means the "or" has to be evaluated before the method is even called, that is in a.map_or(b, fn) Rust has to first fully evaluate a and b before it can call map_or.

    Because map_or_else takes a function however, it is able to only call said function in the "failure" case, and thus whatever is inside the function will be evaluated lazily, in a.map_or_else(|| b, fn) since "b" is wrapped in a function it only has to be evaluated if a is Err.

    Or examples where either lazy/eager should be utilized/avoided?

    This is mostly a function of the fallback's complexity. If the fallback is simple value e.g.

    a.map_or(42, fn)
    

    then its evaluation being eager does not matter, because it's essentially free. However if the fallback is expensive like copying a string or performing an allocation, then you probably want to only do it if necessary.

    clippy's heuristic on the subject is both simple and pretty effective: if the fallback involves a function call, then it recommends using a lazy version. It's sometimes not necessary (e.g. Rust's collections generally don't allocate on initialisation, so an eager String::new() or HashMap::new() doesn't really matter), but it doesn't hurt much either.