Rust, part 2: Result, the ? operator, and errors a caller can act on
Part 2from the Rust series · 15 parts in all
Rust has no exceptions. It has a type, Result<T, E>, and an operator,
?, and the pair are why the language's error handling is the part other
ecosystems keep copying in spirit. Part 2 is the model and the small ecosystem around it.
Errors are values, and the compiler makes you look
use std::fs;
use std::num::ParseIntError;
fn read_port(path: &str) -> Result<u16, ParseIntError> {
let text = fs::read_to_string(path).unwrap_or_default();
text.trim().parse::<u16>() // the only ? worth propagating here
}
? is the whole trick: if the value is Err, return it from the
function immediately; if it is Ok, unwrap it and carry on. The enclosing
function must return a compatible Result, which is the compiler forcing the
error path to be part of the signature — a caller cannot forget that failure is possible
because the type says so.
The corollary is that unwrap() and expect() are not "bad
style"; they are a claim that failure is impossible. Each one is a panic waiting for an
assumption to break, so put a reason in every expect().
Two crates, one convention
The ecosystem settled on a split that is worth copying in your own code:
// A LIBRARY: enumerate the failure modes as a type, so callers can match on them.
use thiserror::Error;
#[derive(Debug, Error)]
pub enum LoadError {
#[error("could not read {path}")]
Io { path: String, #[source] source: std::io::Error },
#[error("invalid config file")]
Parse(#[from] toml::de::Error),
}
// An APPLICATION: you only ever log or report, so one opaque error is enough.
use anyhow::{Context, Result};
fn bootstrap() -> Result<()> {
let cfg = std::fs::read_to_string("app.toml")
.context("reading app.toml")?;
let cfg: Config = toml::from_str(&cfg)?;
start(cfg).context("starting the server")?;
Ok(())
}
thiserror writes the boilerplate Display, Error and
From impls for an enum of failure modes. anyhow gives you a single
boxed error type with a context chain, which is what you want in a binary where nothing
downstream is going to match on the variant. Using anyhow in a published library
is the common mistake: your callers lose the ability to distinguish your failures.
Panics and aborting are for bugs
Rust separates two things most languages blend: recoverable failure, which is a
Result, and a broken invariant, which is panic! — an
unreachable match arm, an index out of bounds, a failed assertion in a test. Indexing
v[i] panics; v.get(i) returns Option. Both exist
because both needs exist. Next: the abstraction that makes Rust's iterators as fast as a
hand-written loop.