The problem with eager defaults
You write a function to load a user profile. You check a local cache first. If the cache is empty, you fetch from the database. You write the code like this:
let profile = cache.get(id).unwrap_or(db.fetch(id));
You run the application. The logs show a database query for every single request, even when the cache hits. You've created a performance bug that looks like correct logic. The db.fetch(id) call happens before unwrap_or runs. The database round-trip occurs regardless of whether the cache has the value. The result is computed eagerly. You need to defer the work until you know it's necessary.
Lazy evaluation with closures
Lazy evaluation means delaying computation until the value is actually required. In Rust, you achieve this by passing a closure instead of a value. A closure is a snippet of code that captures its environment. It sits idle until invoked. Methods like unwrap_or_else accept a closure. They check the Option. If the Option is Some, they drop the closure without calling it. If the Option is None, they invoke the closure and use its return value.
Think of a restaurant kitchen. The waiter takes the order. If the table wants the special, the chef cooks it. If the table orders a sandwich from the pantry, the chef does nothing. The order is the closure. The chef only acts if the pantry is empty. You pass the recipe, not the meal.
Minimal example
Compare eager and lazy evaluation side by side. The eager version computes the default immediately. The lazy version computes it only when needed.
fn main() {
let cached: Option<i32> = Some(42);
// unwrap_or takes a value. The argument is evaluated before the call.
// compute_default runs even though cached is Some.
let eager = cached.unwrap_or(compute_default());
// unwrap_or_else takes a closure. The closure is stored, not called.
// compute_default runs only if cached is None.
let lazy = cached.unwrap_or_else(|| compute_default());
println!("Eager result: {}", eager);
println!("Lazy result: {}", lazy);
}
fn compute_default() -> i32 {
println!("Computing default value...");
0
}
Run this code. You see "Computing default value..." printed once. The unwrap_or branch triggers the computation. The unwrap_or_else branch skips it. If you change cached to None, both branches print the message. The behavior matches the intent. The closure protects the expensive work.
How the compiler handles it
unwrap_or_else is a generic method. Its signature looks like this:
impl<T> Option<T> {
pub fn unwrap_or_else<F>(self, f: F) -> T
where
F: FnOnce() -> T,
{
match self {
Some(val) => val,
None => f(),
}
}
}
The method takes self by value. It consumes the Option. The second parameter f implements the FnOnce trait. FnOnce means the closure can be called once. The compiler generates code specific to your closure. If the closure captures variables, they are moved into a hidden struct. When unwrap_or_else runs, it matches on the Option. If Some, it returns the inner value. The closure is dropped. If None, it calls f(). The closure executes, returns T, and that value is returned.
There is no runtime overhead for the closure itself. Rust uses monomorphization. The compiler inlines the closure body into the call site. The abstraction is zero-cost. The only cost is code size, which the linker often optimizes away. You get the flexibility of higher-order functions with the performance of hand-written code.
Realistic scenario: Config with fallback
Configuration systems often need fallbacks. You read from environment variables. If missing, you read from a file. If that fails, you generate a default based on system metrics. Each step is expensive. You chain lazy evaluations.
use std::env;
struct AppConfig {
max_connections: u32,
}
fn load_from_env() -> Option<AppConfig> {
// Simulate reading an environment variable.
// Returns None if the variable is not set.
env::var("MAX_CONNECTIONS")
.ok()
.and_then(|s| s.parse().ok())
.map(|n| AppConfig { max_connections: n })
}
fn generate_default_config() -> AppConfig {
// Expensive operation: reads CPU count, calculates limits.
println!("Generating default config based on hardware...");
let cores = num_cpus::get();
AppConfig {
max_connections: cores as u32 * 10,
}
}
fn main() {
// Pass the function pointer directly.
// Rust coerces the function to a closure implementing FnOnce.
// This is idiomatic and cleaner than || generate_default_config().
let config = load_from_env().unwrap_or_else(generate_default_config);
println!("Using {} connections", config.max_connections);
}
Convention aside: you can pass a function pointer directly to unwrap_or_else when the signature matches. You don't need the || syntax. Function pointers implement FnOnce. The compiler handles the coercion. This is the preferred style in the Rust community. It signals that the closure captures nothing and is a pure function.
Pitfalls and errors
Lazy evaluation introduces specific failure modes. Watch for these patterns.
Type mismatch. The closure must return the same type as the Option's inner type. If the types differ, the compiler rejects the code with E0308 (mismatched types). This often happens when you mix Option<T> and Result<T, E> or when you forget to map a value.
let opt: Option<&str> = None;
// E0308: expected &str, found String
let bad = opt.unwrap_or_else(|| String::from("default"));
Fix this by ensuring the return types align. Use .to_string() on the Option side or as_str() on the closure side.
Result confusion. Result also has an unwrap_or_else method. Its signature is different. Result<T, E>::unwrap_or_else takes a closure that receives the error. The closure must return T.
fn read_file() -> Result<String, std::io::Error> {
Err(std::io::Error::new(std::io::ErrorKind::NotFound, "file missing"))
}
fn fallback_content(err: std::io::Error) -> String {
// The closure receives the error. You can log it or inspect it.
println!("File read failed: {}", err);
"default content".to_string()
}
fn main() {
// The closure must accept the error type.
let content = read_file().unwrap_or_else(fallback_content);
}
If you try to use an empty closure || on a Result, you get E0277 (trait bound not satisfied). The closure doesn't implement FnOnce(E) -> T. You must accept the error parameter.
Capturing mutable state. unwrap_or_else takes FnOnce. The closure can capture and mutate variables, but it can only be called once. If you try to use the closure after the call, the compiler stops you. This is usually safe because unwrap_or_else consumes the closure. However, be careful if you store the closure elsewhere. FnOnce closures cannot be called twice.
Decision matrix
Choose the right tool based on cost and structure.
Use unwrap_or when the default is a literal, a constant, or a trivial calculation. The cost of calling a closure is higher than the cost of the computation. Passing a value is faster and clearer.
Use unwrap_or_else when the default involves I/O, allocation, or complex logic. Defer the work until you know the value is needed. This saves resources and avoids side effects.
Use map_or_else when you need to transform the Some value and also provide a lazy default. This method replaces a match block that maps one branch and computes the other. It keeps the code concise.
Use and_then when the fallback returns another Option or Result. This chains computations without nesting. It flattens the result automatically.
Use if let when you need complex control flow inside the fallback. If the default logic involves loops, multiple branches, or early returns, a closure becomes hard to read. An if let block is more maintainable.
Where to go next
Pass the recipe, not the meal. Trust the zero-cost abstraction. The compiler inlines the work and drops the waste.