</>PatchNote
Back to posts

2024-07-20

HSPACE Rust Lectures #6 — Error Handling

RustHSPACE 2024Error HandlingResult

Rust splits error handling in two: panic and Result. The core of this session was what that split is based on.

  • Panic — something that should never happen. A bug. Out-of-bounds indexing, division by zero, unwrap() on a None.
  • Result — something that can happen. Missing file, dropped connection, failed parse.

C++ and Java exceptions handle both with one mechanism. Rust treats them as different kinds of problem and gives you different tools.

Panic

When a panic fires:

  1. an error message is printed to the terminal
  2. the stack is unwound — destructors of stack values run in reverse order
  3. the thread terminates
fn pirate_share(total: u64, crew_size: usize) -> u64 {
    let half = total / 2;
    half / crew_size as u64      // panics if crew_size is 0
}

The important part is that a panic is safe. Unwinding runs every destructor, so nothing leaks. It's not undefined behavior — it's a defined failure.

You can pick the panic strategy in Cargo.toml:

[profile.release]
panic = "abort"     # terminate immediately, no unwinding

abort removes the unwinding machinery and shrinks the binary, which matters in embedded and kernel contexts.

One thread panicking doesn't take the process down. A thread spawned with std::thread::spawn ends alone, and join() returns an Err. There's also std::panic::catch_unwind, but that's not an exception-handling substitute — its purpose is keeping panics from leaking across an FFI boundary.

Result

A function that can fail says so in its return type.

enum Result<T, E> { Ok(T), Err(E) }

fn get_weather(location: LatLng) -> Result<WeatherReport, io::Error>

The signature alone tells you it can fail and what you get when it does. Ignoring that takes deliberate effort — Result is #[must_use], so dropping one produces a warning.

Ways to handle a Result

1. match — most explicit

match get_weather(hometown) {
    Ok(report) => display(&report),
    Err(err)   => println!("error: {}", err),
}

2. Methods — shorter in most cases

result.is_ok()              // did it succeed
result.is_err()             // did it fail
result.ok()                 // to Option<T> (discards the error)
result.err()                // to Option<E>
result.unwrap_or(fallback)  // default on failure
result.unwrap_or_else(|e| ...)  // computed default on failure
result.unwrap()             // panic on failure
result.expect("message")    // panic with this message
result.map(|v| ...)         // transform the Ok value
result.map_err(|e| ...)     // transform the Err value

unwrap() and expect() declare "if this fails, it's a bug." Fine in prototypes; in real code the advice was to write expect("why this can't fail") so the reason is recorded.

3. The ? operator — propagation

This is the one you actually use.

fn read_config() -> Result<Config, io::Error> {
    let mut file = File::open("config.toml")?;
    let mut text = String::new();
    file.read_to_string(&mut text)?;
    Ok(parse(&text))
}

? expands to roughly:

let mut file = match File::open("config.toml") {
    Ok(f)  => f,
    Err(e) => return Err(From::from(e)),
};

So: unwrap on success, return immediately on failure. The easy part to miss is From::from(e) — ? automatically converts the error into the return type's error type. That's the key to combining error types below.

? also works on Option, where it returns None on None.

Errors in main

main can return a Result too.

fn main() -> Result<(), Box<dyn Error>> {
    let config = read_config()?;
    run(config)?;
    Ok(())
}

Ending in Err prints the error and exits with a nonzero status. Using this instead of scattering unwrap() through main gives far more readable failure output.

Error type aliases

If the same error type recurs, alias it.

pub type Result<T> = std::result::Result<T, MyError>;

// now you can write
fn parse_line(s: &str) -> Result<Token> { ... }

The standard library defines std::io::Result<T> exactly this way — the type alias material from an earlier session paying off.

Handling multiple error types

The problem you hit constantly in practice. One function reads a file and parses a number, producing both io::Error and ParseIntError. What goes in the return type?

Option 1: Box<dyn Error>

fn run() -> Result<(), Box<dyn Error>> {
    let text = std::fs::read_to_string("nums.txt")?;   // io::Error
    let n: i32 = text.trim().parse()?;                 // ParseIntError
    println!("{}", n * 2);
    Ok(())
}

Both types implement Error, so they convert into Box<dyn Error> automatically (that's the From::from inside ?). Fast and simple, but the caller can't branch on the specific kind of failure.

Option 2: define your own enum

The right choice for a library. The "Rewriting with Result" solution from assignment #2 is exactly this shape.

use thiserror::Error;

#[derive(Debug, Error)]
enum TokenizerError {
    #[error("Unexpected character '{0}' in input")]
    UnexpectedCharacter(char),
}

#[derive(Debug, Error)]
enum ParserError {
    #[error("Tokenizer error: {0}")]
    TokenizerError(#[from] TokenizerError),
    #[error("Unexpected end of input")]
    UnexpectedEOF,
    #[error("Unexpected token {0:?}")]
    UnexpectedToken(Token),
    #[error("Invalid number")]
    InvalidNumber(#[from] std::num::ParseIntError),
}

The thiserror crate generates the Display and From implementations. A variant marked #[from] gets From<TokenizerError> for ParserError for free, which is what lets a single ? promote a lower-level error into the higher-level one.

fn parse_expr<'a>(tokens: &mut Tokenizer<'a>) -> Result<Expression, ParserError> {
    let tok = tokens.next().ok_or(ParserError::UnexpectedEOF)??;
    let expr = match tok {
        Token::Number(num) => {
            let v = num.parse()?;             // ParseIntError → ParserError
            Expression::Number(v)
        }
        Token::Identifier(ident) => Expression::Var(ident),
        Token::Operator(_) => return Err(ParserError::UnexpectedToken(tok)),
    };
    // ...
}

The double ?? on the first line is worth staring at. tokens.next() yields Option<Result<Token, TokenizerError>>. The first ? unwraps the outer Result produced by ok_or(...) (end of input → UnexpectedEOF); the second unwraps the inner one (a tokenizer error → converted to ParserError via #[from]). Two kinds of failure handled in one line.

That's also why the assignment hint says to make Tokenizer implement Iterator<Item = Result<Token, TokenizerError>>. When an iterator can fail per item, making Item itself a Result is the idiomatic move.

The other rule of thumb I picked up: anyhow for applications, thiserror for libraries. An application ultimately shows the error to a human, so collapsing everything into one anyhow::Error is fine. A library's callers need to branch on the failure kind, so it should define a precise enum.

Errors that "can't happen"

Sometimes failure is logically impossible.

// we just built this string out of digits
let n: i32 = digits.parse().unwrap();

Use unwrap() here, but prefer expect("digits only ever contains digits") so the reason it can't fail is written down. If the assumption ever breaks, the panic message points straight at it.

Ignoring errors

If you really want to ignore one, say so:

let _ = writeln!(stderr(), "error: {}", err);   // nothing to do if this fails

Without let _ = you get an unused_must_use warning. Even ignoring is recorded as intent.

Why Result

Compared with exceptions, the tradeoffs are clear:

ExceptionsResult
Failure visible in signatureNo (except Java checked exceptions)Yes, in the return type
Ignoring itpropagates silentlyproduces a warning
Control flownon-local jumpan ordinary return
Costexpensive on throw, free otherwisea small branch, always

The biggest difference is that you can see where a function can fail while reading it. Every ? marks a point where the function may stop. If you've ever traced how one innocuous call unwound three stack frames in an exception-based codebase, you know how much that's worth.

The flip side is that leaning on ? strips away error context, which is exactly why tools like anyhow's .context("failed to read config") exist.

Takeaways

  • panics are bugs, Result is expected failure — different tools for different problems
  • panics still unwind and clean up safely
  • ? means "unwrap on success, return on failure" plus automatic error conversion
  • combine errors with Box<dyn Error> (quick) or a custom enum (precise)
  • thiserror's #[from] is what makes ?'s conversion work for your own types

Next: crates and modules — how to split code up and decide what to expose.