Skip to content
academia.sh

Lesson 09 / 15

The Shorthand for Error Propagation

Measuring that the shorthand which reduces checking an error and returning early to a single mark gives exactly the same counts as the same job written by hand with a branch — that it shortens the source without changing the run.

Published Updated

In this lesson

  • Shows by executing that the body after the question mark runs once on a successful resolve and never on a failed one
  • Verifies by comparison that the shorthand gives the same counts as the hand-written branch
  • Distinguishes with an example that early return silently skips the remaining body, and that the skipped step produces no side effect
Contents

The Failure as a Return Value lesson showed that when the same function is called repeatedly, the success and failure arms run independently of each other, and both repeat the same three-line pattern inside the same match body: parse the value, report if there is an error, otherwise continue. If a function does more than one parse, this pattern repeats in the same way each time — the same two-armed match after every parse. This lesson measures the shorthand that reduces this repetition to a single mark: the question mark (?).

As this topic’s third lesson, this lesson now assumes that success and failure are separate bodies — that distinction was established in the previous lesson. The question asked here goes one step further: how much can the match skeleton that separates these two bodies be shortened in the source, and does that shortening change the counts in the run? The answer emerges by comparing the two runs below.

The Interfaces, Errors and Generics course established that an error is carried as a return value and does not leave the normal control flow. The question mark does not break that decision either — it still produces a return value, still uses an ordinary function return — it only shortens how long it takes to write that return in the source. This lesson does not return to the where question that course measured; what it measures is whether the shorthand changes the run.

The Functions and Expression Orientation lesson measured when a function needs to return early before its body finishes: if the rest of the body will not be computed, an explicit return made that fact visible in the source. The question mark uses exactly this early return — only instead of writing the return form by hand, it places a single mark right after a Result call. This lesson does not re-establish the early-return concept that lesson set up; it only measures whether this particular shorthand gives the same counts as a hand-written early return.

Same Counts, Shorter Source

The question mark is written right after a call that returns a Result, inside a function that itself returns a Result. If the call returns Ok, the value inside it is unwrapped and the body continues from where it left off; if it returns Err, the function returns immediately with its own Err, and the rest of the body never runs.

This shortcut has one condition: the question mark can only be written when the function it is inside also returns a Result (or Option). If main’s return type is (), ? cannot be placed directly after a call that produces a Result inside main’s body — there is no Result slot for the early return to return into. This lesson does not violate that condition: resolve carries its own Result return type, and ? is used only inside its body; the calls inside main, meanwhile, bind the returned Result to a variable and print it with {:?}, without using ? themselves.

In the resolve function below, text.parse() returns a Result<i32, ParseIntError>, but the function’s own return type is Result<i32, NumberError> — two different error types. The question mark resolves this difference on its own: thanks to the impl From<ParseIntError> implementation written for NumberError, the error parse produces is automatically converted into NumberError. This conversion itself is the subject of the Custom Error Types lesson; in this lesson, only the fact that ? uses this conversion is visible, and it does not affect the count measured in this lesson.

// with_question.rs — the resolve slice of times.rs: the body after the question runs once on success, never on failure
use std::cell::RefCell;
use std::collections::BTreeMap;
use std::rc::Rc;

type Ledger = Rc<RefCell<BTreeMap<String, u32>>>;

fn record(ledger: &Ledger, name: &str) {
    *ledger.borrow_mut().entry(name.to_string()).or_insert(0) += 1;
}
fn times(ledger: &Ledger, name: &str) -> u32 {
    *ledger.borrow().get(name).unwrap_or(&0)
}

#[derive(Debug)]
struct NumberError;

impl From<std::num::ParseIntError> for NumberError {
    fn from(_: std::num::ParseIntError) -> NumberError {
        NumberError
    }
}

fn resolve(ledger: &Ledger, text: &str, label: &str) -> Result<i32, NumberError> {
    let number: i32 = text.parse()?;
    record(ledger, label);
    Ok(number * 2)
}

fn main() {
    let ledger: Ledger = Rc::new(RefCell::new(BTreeMap::new()));

    let success = resolve(&ledger, "21", "after-question-success");
    let failure = resolve(&ledger, "twenty-one", "after-question-failure");

    println!("success call: {:?}", success);
    println!("failure call: {:?}", failure);
    println!();
    for name in ["after-question-success", "after-question-failure"] {
        println!("{:<24}{} times", name, times(&ledger, name));
    }
}
success call: Ok(42)
failure call: Err(NumberError)

after-question-success  1 times
after-question-failure  0 times

EH7. In the resolve(&ledger, "21", ...) call, text.parse() successfully produces 21; the question mark unwraps this value, binds it to number, and the body continues from where it left off — the record call runs, and the after-question-success line increases by one. In the resolve(&ledger, "twenty-one", ...) call, by contrast, parse produces an error; the question mark converts this error into NumberError and returns from the function immediately. The record call never gets its turn — after-question-failure stays at zero. This is the same behavior as the Ok/Err arms in the previous lesson: one runs, the other does not. The difference is that these two arms are no longer written in a separate match, but are now hidden behind a single mark.

Looking at the resolve function’s return type, only one line in its body (let number: i32 = text.parse()?;) looks related to Result; the remaining two lines (the record call and Ok(number * 2)) read like ordinary code. This shows how small a trace the question mark leaves in the source — no matter how many separate parses a function does, it completes the error check by adding a single character after each one. The label parameter carrying the name passed to record lets this lesson track two different calls (success and failure) through the same function but with separate counter keys; in real usage this parameter would not be present, it was added only for measurement.

The same function can also be built with a hand-written branch. The run below replaces the question mark with a match and an early return, and is called with the same two inputs.

// manual_branch.rs — the same job written with a hand-written branch gives the same counts
use std::cell::RefCell;
use std::collections::BTreeMap;
use std::rc::Rc;

type Ledger = Rc<RefCell<BTreeMap<String, u32>>>;

fn record(ledger: &Ledger, name: &str) {
    *ledger.borrow_mut().entry(name.to_string()).or_insert(0) += 1;
}
fn times(ledger: &Ledger, name: &str) -> u32 {
    *ledger.borrow().get(name).unwrap_or(&0)
}

#[derive(Debug)]
struct NumberError;

fn resolve_by_hand(ledger: &Ledger, text: &str, label: &str) -> Result<i32, NumberError> {
    let parsed = text.parse::<i32>();
    let number = match parsed {
        Ok(value) => value,
        Err(_) => return Err(NumberError),
    };
    record(ledger, label);
    Ok(number * 2)
}

fn main() {
    let ledger: Ledger = Rc::new(RefCell::new(BTreeMap::new()));

    let success = resolve_by_hand(&ledger, "21", "after-question-success");
    let failure = resolve_by_hand(&ledger, "twenty-one", "after-question-failure");

    println!("success call: {:?}", success);
    println!("failure call: {:?}", failure);
    println!();
    for name in ["after-question-success", "after-question-failure"] {
        println!("{:<24}{} times", name, times(&ledger, name));
    }
}
success call: Ok(42)
failure call: Err(NumberError)

after-question-success  1 times
after-question-failure  0 times

EH8. The two runs give exactly the same counts: after-question-success one, after-question-failure zero. The source differs — one line (text.parse()?) in the first, a five-line match in the second — but the run is the same. The question mark does not add new behavior; it does the same job as the compiler-generated code of a hand-written match and early return, by writing less. This is direct proof that the shorthand shortens the source, not the run: the two programs have source of different length, but produce the same binary behavior.

The match in resolve_by_hand differs from the match in the previous lesson in one respect: the Err arm does not produce a value and hand it off to the rest of the body, it exits the function directly with return Err(NumberError). This does not contradict the principle established in the Control Flow lesson that match is an expression — return is not an expression but a statement, and a statement can be written inside a match arm; the “value” that arm produces never reaches the rest of the body, because the function itself is exited. The Ok arm, on the other hand, produces an ordinary value (value), and that value is bound to number, and the body continues from there. The two arms’ different natures — one exits the function, the other yields a value — remain exactly the same behind the question mark too, just invisible.

The real payoff of the shortening in the source shows up when a function does more than one parse. A function that parses three separate fields in turn would need a five-line match, repeated three times by hand (fifteen lines in total); the same function drops to three lines with the question mark — a single mark after each field. The two runs measured in this lesson (a one-line shorthand, a five-line hand-written form) are the smallest example of this difference; as the number of fields grows, the difference in the source grows too, but the after-question-success / after-question-failure pair both forms produce stays the same. The shorthand’s growth is not linear — while the number of source lines drops, the run itself never changes.

The question mark works the same way on Option too: if the function it is inside returns Option, it unwraps the value inside Some, and in the None case it returns None from the function immediately. This lesson does not exercise that second use — the Option methods measured in the Expressing Absence with a Type lesson were built around producing a value (unwrap_or, unwrap_or_else), while what is measured here is early return. Even though both uses carry the same mark, one produces a value (the way unwrap_or_else does), the other exits the function; what this lesson measures is only its form on Result, the one that exits the function.

A Limit on the Measurement

In the run above, after-question-success and after-question-failure always move together: when one is one, the other is zero, and when one is zero, the other is one. This is not the result of a single observation, but of the state of the same line across two different calls — both actually represent the same record(ledger, label) call, only the label argument changes with the caller.

EH9. Early return silently skips the body that follows it — it prints no warning, leaves no mark. In the call made with the "twenty-one" input, the record call never runs, so no record is dropped into the counter ledger; this leaves no trace saying “a step was skipped,” it only shows up through the absence of the expected record. If the skipped step itself had produced a side effect — writing to a file, incrementing a counter, printing a log line — that side effect would not have happened either. This lesson shows this exactly through the record call itself: record is the skipped step’s only side effect, and because that side effect does not happen, the count stays at zero.

This is a different situation from the previous lesson’s Err arm being written in the source but not run. There, both arms stood explicitly inside the same match, and both were visible. Here, the “skipped body” is not written as a separate arm — every line after the question mark is implicitly skipped in the error case. Someone reading the source can see under what condition the lines after ? will run, but does not see in the source that the compiler produces a separate arm for this; that arm only surfaces in the run, when measured with a counter.

This silence is the result of a design decision, not a flaw. The question mark prints no warning, because the error itself is already carried as a return value — as long as the caller checks that value, the “why it was skipped” information is not lost, it just lives in the return value rather than in the rest of the body. This lesson does not track that information; it only counts whether the rest of the body ran. If the code calling resolve does not check the returned Result, it loses both that information and the chance to see what was skipped — but that is the result of the caller’s decision not to check, not of the question mark.

The same silent skipping repeats in the same way through a chain of question marks that crosses function boundaries in succession. If a function calls another function internally with a question mark, and that function in turn calls a third function with a question mark, the failure at the bottom rises all the way to the top by silently skipping all three bodies — none of them leaving a trace. This lesson does not exercise this multi-layered chain; it only shows, at a single boundary, that the skipping itself is silent.

Thinking through this three-layer chain makes the limit even clearer. Say the bottom function does a parse and fails; the middle layer calling it would have run three more lines in its own body, but never runs them; the top caller would have run five more lines, and it does not run them either. In total, an eight-line body is silently skipped because of a single failure — but nothing in the source, no comment, no special syntax, marks these eight lines as “skippable.” Only the fact that all three layers return Result and that each layer’s call is followed by a question mark shows that this silent skipping is possible.

The distinction measured in this topic’s first two lessons still holds here: the difference between eager and lazy bodies in Option, and between the success and failure arms in Result, stands in the same way here too — only now the match arms are no longer written by hand, they are hidden behind a single mark. The shorthand does not eliminate any of the three classes measured (every time, only when needed, never); it only shrinks the source that carries those classes.

Summary

  • The question mark reduces the early return written after a Result call to a single mark; if it returns Ok, it unwraps the value and the body continues, if it returns Err, the function returns immediately.
  • The body after the question mark runs only when needed, when the call succeeds — it runs never when the call fails.
  • The same function, built with a hand-written match and an early return, gives the same counts: the shorthand shortens the source, not the run — and this difference grows as a function does more than one parse.
  • Early return silently skips the remaining body; the skipped step’s side effect does not happen at all either — no warning, no partial record.
  • The skipped body does not appear as a separate arm in the source; it is noticed only through the missing record, when measured with a counter.
  • The question mark automatically converts different error types with From; who calls this conversion is the subject of the next lesson.

Next Step

The NumberError above carried a From implementation converting std::num::ParseIntError into its own type — how the question mark calls this conversion was not shown in this lesson, only used. There is no explicit line in the resolve function’s body calling this conversion; the text.parse()? form itself both checks the error and converts it when needed. The Custom Error Types lesson measures who calls this conversion: does the shorthand make this call without the caller writing anything, or is a hand-written conversion needed — and can both approaches carry the same information, or does the implicit conversion lose something?

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close