Rust tip and trick — 2 Link to heading

Consider the following code snippet below (Rust playground link).

Rust tip and trick — ok_or Link to heading

Consider the following code snippet below (Rust playground link).

use std::collections::HashMap;

fn lookup(map: &HashMap<String, i32>, key: &str) -> Result<i32, String> {
    map.get(key).copied().ok_or(format!("key {} not found", key))
}

fn main() {
    let mut map = HashMap::new();
    map.insert("x".to_string(), 1);
    assert_eq!(lookup(&map, "x"), Ok(1));
    assert_eq!(lookup(&map, "y"), Err("key y not found".to_string()));
}

Can you spot any issue with the code? If you aren’t able to find any issue, let me provide you some hint

Prefer lazy evaluation over eager execution.

The idea is simple: we want to avoid doing unnecessary work. However, in practice, it is not always obvious, as in the code snippet above.

In most programming languages, the arguments to functions are evaluated before they are passed to the function. That means, the argument to Option::ok_or() is evaluated regardless of whether the option is Some or None. In our case, it is the format!(…) macro, which is rather expensive, as it needs to allocate memory for the string dynamically.

In other words, we are calling the format!(…) macro regardless of the option value. This is eager execution. When the option is Some, this is wasteful and unnecessary, making the lookup() function inefficient. To make it efficient, we could use an if let Some() … else statement, or use the Result::ok_or_else() method instead.

--- eager
+++ lazy
@@ -4 +4 @@
-    map.get(key).copied().ok_or(format!("key {} not found", key))
+    map.get(key).copied().ok_or_else(|| format!("key {} not found", key))

The difference between Result::ok_or() and Result::ok_or_else() methods is that the former evaluates the argument eagerly while the latter evaluates it lazily. To achieve lazy evaluation, it must take a function as an argument, rather than a value.

There are many other such variants in Rust, such as Result::or() vs Result::or_else() or Option::or() vs Option::or_else(). Typically, these lazy versions have an _else suffix added to the eager version. More importantly, if the argument is a value, it has to be evaluated eagerly, while if it is a function, it can be evaluated lazily.

In a very simple synthetic benchmark (code below) where I repeated calling lookup() method 1 million times, the eager version ran in 2.7s while the lazy version ran in 1.6s.

// benchmark
use std::collections::HashMap;

fn lookup_eager(map: &HashMap<i32, i32>, key: i32) -> Result<i32, String> {
    map.get(&key).copied().ok_or(format!("key {} not found", key))
}

fn lookup_lazy(map: &HashMap<i32, i32>, key: i32) -> Result<i32, String> {
    map.get(&key).copied().ok_or_else(|| format!("key {} not found", key))
}

fn main() -> Result<(), String> {
    let n = 10000000;
    let mut map = HashMap::new();
    for key in 0..n {
        map.insert(key, key);
    }

    let mut sum = 0;
    for key in 0..n {
        sum += lookup_eager(&map, key)? as i64;
        // sum += lookup_lazy(&map, key)? as i64;
    }

    println!("sum: {}", sum);
    Ok(())
}

With Flamegraph, we can clearly observe execution of format!() macro even if every lookup() results in Ok variant.

Flamegraph showing format!() macro execution

Obviously, the real-world performance difference will depend on your application, but this just demonstrates how a trivial change can potentially have a significant impact on performance.