Lesson 07 / 15
Expressing Absence with a Type
Carrying the possibility that a value is absent in a type, and measuring whether the fallback body that covers that value — depending on whether it is written eagerly or lazily — runs even while the value already exists.
Published Updated
In this lesson
- Compares how many times an eager and a lazy default body run by executing them
- Explains why the Option type carries a value's absence as a separate type
- Shows that for a cheap default body, the two forms produce the same result but a different count
Contents
The Operator Overloading lesson closed out the Traits and Generics topic by measuring that every use of an operator is actually a call, and that the operator body ran twice in a chained sum. That lesson showed that a body written in the source runs exactly as many times as it is used — every use was an unavoidable run. This lesson carries the same question to a completely different place: if a body runs only when a value is missing, does it always run, or can it sometimes never run at all?
The Interfaces, Errors and Generics course established that an error is itself a return
value, one that can be carried without leaving the normal control flow; what it measured
there was where an error is carried — not in a signature, but in the return type. This
lesson inherits that decision, but looks at it from a different axis: how many times does
the body that covers a value’s absence — the fallback that kicks in — run? This topic’s
(error-handling) five lessons apply this question in turn to absence, failure, the
shorthand, conversion, and the unrecoverable case; this first lesson looks only at
absence, and failure — which carries a reason — comes in the next lesson.
Throughout the Traits, Generics and Error Handling course, the same question stays the
same: how many times does a body written in the source run at execution? The Traits and
Generics topic asked this question of a behavior’s implementations; in this topic the
question goes to the two types that carry an operation’s outcome — Option and
Result — and to the shorthand, conversion, and unrecoverable case that follow them. The
question itself does not change, only where it is applied does.
A Type for Absence
In Rust, Option<T> is used everywhere a value might not be present. It is the standard
library’s own instance of the enum mechanism set up in the previous course: it has two
variants, Some(T), which carries the value, and None, which carries none at all. If a
function’s return type is Option<i32>, the caller must handle both cases at compile
time — accidentally trying to read a value out of None is not structurally possible in
this type.
EH1. This differs from carrying the “no value” case as a separate sentinel value (a
zero, say, or an empty string). A sentinel value can be confused with a real one; None,
by contrast, is a state completely separate from the T type that Some(T) carries, and
the compiler never confuses the two. This lesson does not re-establish that
distinction — it was already covered in the Ordered and Set Structures lesson — it only
looks at how many ways there are to extract a value from an Option, and how many
times each of those ways runs the body written in the source.
Option is mostly not built by hand; it arises as the natural result of a lookup. When an
ordered map is asked for a key’s value, it returns Some(value) if the key is there and
None if it is not — the caller never has to ask “does the key exist” separately,
because the lookup itself produces a value that carries that information. The same
pattern holds for an out-of-bounds index into an array: direct indexing panics on an
invalid index, but a get call returns an Option, and the caller handles the
out-of-bounds case as an ordinary None. The examples used in this lesson do not
simulate these lookups; they build Some and None values directly, because what is
measured is not the lookup itself but the way a value is extracted from its result.
The Pattern Matching lesson established that a match expression must cover every
arm — the exhaustiveness principle. A match written by hand over an Option follows
the same principle: both the Some and None arms must be written, and if either is
left out, the source does not compile at all. unwrap_or and unwrap_or_else are
shortcuts that fold this two-armed match into a single method call — the
Some(value) => value, None => fallback form itself does not appear in the source, but
the code the compiler generates still branches into the same two arms. This lesson
measures how many times those two arms run, directly through these shortcuts, without
writing the match form again.
The same exhaustiveness principle applies to the if let form too, but in a different
way: if let Some(value) = option { ... } writes only the Some arm and silently skips
the None case — this does not violate exhaustiveness, because if let is already
defined by the language itself as a partial form of match. unwrap_or and
unwrap_or_else stand at the opposite end: both cover both arms, and they differ only in
how the None arm’s body is written in the source. This lesson does not measure
if let, because it has no “arm that does not run” in the source to begin with — the
difference being measured is how differently two methods that both write both arms
actually run.
Eager and Lazy Defaults
The two most common ways to pull a value out of an Option both supply a fallback that
kicks in when the value is missing. That fallback can be written in two forms: directly,
as a value (unwrap_or), or as a call that returns a closure (unwrap_or_else). Both
read as if they do the same job, but when their arguments are evaluated differs.
The Rust’s Design Goals lesson established that an expression is evaluated to produce a
value; a function call’s argument is itself an expression, and it is evaluated before the
call happens. The form unwrap_or(eager_default(&ledger)) is not exempt from this rule:
eager_default(&ledger) is an argument expression, and it is computed before unwrap_or
itself runs — even though it looks as if there is a branch inside unwrap_or checking
whether it is Some or None, the argument has already been evaluated before that
branch. unwrap_or_else, on the other hand, takes not a value as its argument but a
closure; the closure itself does not run when it is built, only when
unwrap_or_else’s own internal implementation enters the None arm is it called.
In the run below, full and empty are built by hand, but both represent the two
outcomes a lookup can return: full stands for a lookup whose key was found, empty for
one that was not. The bodies being measured — the eager_default function and two
closures — each report their own run by dropping a record into the counter ledger. The
run below shows this difference directly.
// absence_ledger.rs — body-counting core: the Ledger/record/times slice of times.rs
pub type Ledger = std::rc::Rc<std::cell::RefCell<std::collections::BTreeMap<String, u32>>>;
pub fn record(ledger: &Ledger, name: &str) {
*ledger.borrow_mut().entry(name.to_string()).or_insert(0) += 1;
}
pub fn times(ledger: &Ledger, name: &str) -> u32 {
*ledger.borrow().get(name).unwrap_or(&0)
}
// absence_measurement.rs — how many times the eager and lazy defaults run
mod absence_ledger;
use absence_ledger::{Ledger, record, times};
use std::cell::RefCell;
use std::rc::Rc;
use std::collections::BTreeMap;
fn eager_default(ledger: &Ledger) -> i32 {
record(ledger, "eager-default");
-1
}
fn main() {
let ledger: Ledger = Rc::new(RefCell::new(BTreeMap::new()));
let full: Option<i32> = Some(7);
let empty: Option<i32> = None;
let a = full.unwrap_or(eager_default(&ledger));
let b = full.unwrap_or_else(|| {
record(&ledger, "lazy-default-unneeded");
-1
});
let c = empty.unwrap_or_else(|| {
record(&ledger, "lazy-default-needed");
-1
});
println!("full.unwrap_or(...) result: {}", a);
println!("full.unwrap_or_else(...) result: {}", b);
println!("empty.unwrap_or_else(...) result: {}", c);
println!();
for name in ["eager-default", "lazy-default-unneeded", "lazy-default-needed"] {
println!("{:<28}{} times", name, times(&ledger, name));
}
}
full.unwrap_or(...) result: 7 full.unwrap_or_else(...) result: 7 empty.unwrap_or_else(...) result: -1 eager-default 1 times lazy-default-unneeded 0 times lazy-default-needed 1 times
Even while full is full, eager_default still runs once: unwrap_or’s argument is
evaluated as an ordinary function call before the call itself happens. Whether the
Option is Some or None makes no difference here — because the argument is an
expression, it is computed regardless of whether it will be used. EH2. That is why
the eager-default line falls into the “every time” class: whenever unwrap_or is
called, its argument is unavoidably run as well.
The closure given to unwrap_or_else, by contrast, behaves the opposite way. Called on
full, its body never runs at all — lazy-default-unneeded stays at zero — because the
closure is only called in the None case. Called on empty, that same closure pattern
actually runs, and lazy-default-needed climbs to one. The difference lies in the
argument’s kind: one is a ready-made value, the other a call that produces the value
only when needed. lazy-default-needed falls into the “only when needed” class — it runs
exactly as often as the absence actually occurs.
What the Interfaces, Errors and Generics course measured was where an error is bound:
in a signature, at the call site, or inside the value. This lesson does not ask that
question — Option itself already belongs to the same family as that course’s third
option, a state carried inside a value. What is measured here is how many times the body
that covers that state runs; even though eager-default and lazy-default-needed
produce the same count (one), one runs unconditionally and the other only when it is
truly needed.
The _or and _or_else suffixes in the method names label this distinction: the _or
suffix marks methods where a ready-made value is given directly as the argument; the
_or_else suffix marks methods where a closure is given in its place. This naming
pattern is not unique to Option — the standard library uses the same _or / _or_else
pair on other types too, and the meaning stays the same in both: _or is eager,
_or_else is lazy. The next lesson will meet this naming pattern again on Result.
The Option type’s standard-library methods split along this same axis. Ones like
unwrap_or that want a ready-made value always run; ones like unwrap_or_else that want
a closure run only when needed. A chaining method like and_then stands only on the
closure side: the closure it is given runs only in the Some case and is never called on
None. map_or combines both in a single call — its first argument is a ready-made
value and is evaluated every time, even on None; its second argument is a closure and
runs only in the Some case; inside one single method call, the two classes this lesson
measures can be seen side by side. A method name not ending in _else does not mean this
distinction stops applying; exactly which argument each method runs and when is
documented for every method, and it can be read through the same axis measured in this
lesson.
A Limit on the Measurement
The count difference above does not mean the lazy form is always the better choice. The difference is only in how many times it runs; if the body is cheap, the outcome of both forms is indistinguishable.
// cheapness.rs — when the body is cheap, both forms produce the same result, only the count differs
mod absence_ledger;
use absence_ledger::{Ledger, record, times};
use std::cell::RefCell;
use std::rc::Rc;
use std::collections::BTreeMap;
fn main() {
let ledger: Ledger = Rc::new(RefCell::new(BTreeMap::new()));
let full: Option<i32> = Some(9);
let a = full.unwrap_or({
record(&ledger, "cheap-eager");
0
});
let b = full.unwrap_or_else(|| {
record(&ledger, "cheap-lazy");
0
});
println!("eager form's result: {}", a);
println!("lazy form's result: {}", b);
println!("cheap-eager body: {} times", times(&ledger, "cheap-eager"));
println!("cheap-lazy body: {} times", times(&ledger, "cheap-lazy"));
}
eager form's result: 9 lazy form's result: 9 cheap-eager body: 1 times cheap-lazy body: 0 times
EH3. Both lines give the same result (9); the program is not affected by which form
is chosen. The only thing that diverges is whether the body runs: cheap-eager runs
once, cheap-lazy never runs. Because the body here only returns an integer, this
difference changes nothing; but if the body were an expensive operation — a file read, a
network call, a large computation — the same difference would turn into a cost that can
no longer be overlooked. This lesson does not measure that cost — it only counts how
many times it runs; the size of the cost depends on the body written in the source
itself, and this lesson does not evaluate that.
This is where the measurement’s real limit shows up. If a program fills in a missing
field from a settings record with a fixed integer, there is no visible difference between
the eager and the lazy form — both produce the same number, and the only difference is as
small as the counter record seen in the run above. If that same program instead fills the
missing field by reading a file or computing a sum, the eager form runs that read or
computation even while the value already exists — the result still does not change,
because unwrap_or will not use the computed value anyway, but the body still runs. This
lesson does not decide which body counts as “cheap” and which as “expensive”; it only
counts how many times each form runs and leaves that decision to whoever reads the
source. The count alone does not say that one form is “better” than the other — it only
makes visible what runs and what does not.
In both runs above, the “eager” side was always written as a separate function, but that
is not required. The block in the form unwrap_or({ record(&ledger, "cheap-eager"); 0 })
is itself an expression too; it is evaluated as the argument even without carrying a
separate function name. The limit being measured is therefore not specific to a function
call — no matter how unwrap_or’s argument is written, as long as it is an expression, it
is evaluated before the call happens. The only difference is readability: a separate
function name names what the body does; an inline block leaves the same work unnamed.
These three runs — the eager/lazy comparison, the cheap-body comparison, and the naming
difference between them — together arrive at a single conclusion: which method is chosen
for extracting a value from an Option is a question independent of the result. The
result is the same in both forms; what diverges is only whether the body written in the
source runs, and how many times. This distinction may look like a small detail, but it
will be met again in the same form throughout the next four lessons: depending on which
method of a type is chosen, the same body in the same source sometimes runs unavoidably,
sometimes only when needed, and sometimes not at all.
Summary
Option<T>carries the possibility that a value is absent as a separate type;Some(T)andNoneare handled separately at compile time, and amatchmust cover both under the exhaustiveness principle.- The argument given to
unwrap_oris an expression and runs every time, even while the value already exists — it does not look at which variant theOptionis. - The closure given to
unwrap_or_elseruns only in theNonecase: it runs never while the value exists, and only when needed once absence actually occurs. - Some methods, like
map_or, combine both classes in a single call — one argument runs every time, the other only when needed. - If the body is cheap, the result of both forms is the same; the difference is only in the run count, and it matters more as the body gets more expensive.
- The
_or/_or_elsenaming pattern marks the eager/lazy distinction across the standard library; it will appear again onResultin the next lesson.
Next Step
This lesson measured the case where a value might be absent. But when an operation
fails, saying just “absent” is usually not enough — the reason for the failure has to
be carried too. None has no reason; it does not say why an operation failed, only that
a value was not found. The Failure as a Return Value lesson applies the same “how many
times did it run” question to a type that now also carries a reason: when the success
and failure arms are written as separate bodies, and the same function is called several
times in a row, which of those arms runs in a given run, and how do the arm counts
diverge while the total call count stays fixed? This topic’s name (error-handling)
starts earning its real meaning from that lesson on: what is measured is no longer just a
value’s absence, but a named failure.
To keep your progress and take notes, Log in
My notes
Log in to take notes.