2024-07-20
HSPACE Rust Lectures #6 — Error Handling
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 aNone. 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:
- an error message is printed to the terminal
- the stack is unwound — destructors of stack values run in reverse order
- 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:
| Exceptions | Result | |
|---|---|---|
| Failure visible in signature | No (except Java checked exceptions) | Yes, in the return type |
| Ignoring it | propagates silently | produces a warning |
| Control flow | non-local jump | an ordinary return |
| Cost | expensive on throw, free otherwise | a 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,
Resultis 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 customenum(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.