Skip to content
academia.sh

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) and None are handled separately at compile time, and a match must cover both under the exhaustiveness principle.
  • The argument given to unwrap_or is an expression and runs every time, even while the value already exists — it does not look at which variant the Option is.
  • The closure given to unwrap_or_else runs only in the None case: 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_else naming pattern marks the eager/lazy distinction across the standard library; it will appear again on Result in 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.

Start typing to search.

↑↓ ↵ Esc navigate · open · close