Working with Result quickly becomes verbose if every call needs a
match-then-return:
fn parse_two(a: &str, b: &str) -> Result<i32, std::num::ParseIntError> {
let x = match a.parse::<i32>() {
Ok(n) => n,
Err(e) => return Err(e),
};
let y = match b.parse::<i32>() {
Ok(n) => n,
Err(e) => return Err(e),
};
Ok(x + y)
}
The ? operator is shorthand for that pattern. Slap it onto any Result
expression: if it's Ok, the value is unwrapped and execution continues;
if it's Err, the function returns the error immediately.
fn parse_two(a: &str, b: &str) -> Result<i32, std::num::ParseIntError> {
let x = a.parse::<i32>()?;
let y = b.parse::<i32>()?;
Ok(x + y)
}
? only works inside a function whose return type is also a Result (or
Option). It doesn't work in fn main() unless main itself returns a
Result.
What if one call returns ParseIntError and another returns io::Error?
The compiler needs them to agree. The two common solutions:
Box<dyn Error>: a trait-object error type that accepts almost
any error. Quick and cheap, great for small programs and prototypes.
fn run() -> Result<i32, Box<dyn std::error::Error>> {
let text = std::fs::read_to_string("nums.txt")?; // io::Error
let n: i32 = text.trim().parse()?; // ParseIntError
Ok(n * 2)
}
This is where Box<dyn Error> finally pays off. You met both
halves of that signature in the previous two chapters: Box<T> is
the heap-owning smart pointer from chapter 15, and dyn Error is
the trait-object syntax from chapter 14 ("any type that implements
the Error trait"). Putting them together gives you "a heap-
allocated, any-error-goes return type" that ? can convert into
from almost any concrete error.
A custom error enum with a variant per underlying error. More
work, but the type system tells callers exactly what can go wrong.
You'll see the thiserror crate used for
this in real projects.
? knows how to convert between error types if there's a From
implementation. Box<dyn Error> works because nearly every error type
has From<E> for Box<dyn Error>.
The tests below write real files (test.txt, numbers.txt, …) into the
current working directory before they run. Cargo runs tests in parallel
by default, so two tests writing to the same path can race each other
and cause spurious failures. If you see flaky Errs here, force the
harness to run them one at a time:
cargo test -- --test-threads=1
Or give each test its own filename if you're feeling tidy. In production
code you'd reach for tempfile::NamedTempFile
so the OS hands you a guaranteed-unique path and cleans up after itself.
? is Rust's shortcut for "if this is Err, return it from the
current function; otherwise, unwrap the value and continue." It
compresses a lot of match boilerplate into one character.
Here both calls to parse() return the same error type
(ParseIntError),
so ? works directly without any conversion. Compare to writing this
out with a match on each parse() result. That's the boilerplate
? is replacing.
Useful from the standard library
str::parsereturnsResult<T, T::Err>. Combined with?you get the parsed number on the happy path and an early-return on failure.std::num::ParseIntErroris the error type for integer parses. The function signature declares it directly, so?doesn't need to convert anything.- The function returns one expression:
Ok(a.parse::<i32>()? + b.parse::<i32>()?). Each?unwraps an integer, then+adds them, thenOk(...)wraps the sum back up.
Same operator, different error type. File I/O returns
std::io::Error;
the function signature has to declare it as the error type so ? is
happy passing it through.
Notice that ? doesn't care which error type is involved as long as
the function it's used in returns the same error type (or one
convertible from it via From, which is the next step).
Useful from the standard library
std::fs::read_to_stringreads the whole file into aString. ReturnsResult<String, io::Error>, which is exactly what?wants.str::linesiterates over the file's lines without keeping the trailing newlines.Iterator::countconsumes the iterator and returns how many lines there were.- The full body fits on one line:
Ok(std::fs::read_to_string(filename)?.lines().count()).
Now the function combines two distinct error sources: parsing a
string into a number (ParseIntError)
and reading a file (std::io::Error).
With a fixed error type you'd need to convert each one by hand.
Box<dyn std::error::Error> is the laziest catch-all: any type that
implements the Error
trait can be boxed into it, and ? does the conversion automatically
because both ParseIntError and io::Error implement Error.
In real code you'd typically define your own error enum (or reach for
a crate like anyhow or thiserror). Box<dyn Error> is the
standard-library escape hatch.
Useful from the standard library
std::fs::read_to_stringreads the file. Use?to propagate the I/O error.str::lineswalks the lines. Pair withstr::trimto drop stray whitespace before parsing.- For the per-line parse, an iterator pipeline with
.map(|l| l.parse::<i32>()).sum::<Result<i32, _>>()collects the total in one call:sumon aResult-yielding iterator returns the first error or the total. The?after that reduces it back toi32.- Returning
Box<dyn std::error::Error>works because both error types implement theErrortrait and?knows how to box them.
You replaced repetitive match chains with ?, used it across
matching error types, then mixed two error sources in one function
via Box<dyn Error>.
What we learned
?is shorthand for "if this isErr, return it from the current function; if it'sOk, unwrap the value and keep going." It works onOptiontoo (returningNoneearly).- The function using
?must return aResult(orOption) whose error type matches, or one that the failing error converts into viaFrom.- When all calls produce the same error type,
?just propagates. When they don't, you need a common error type. The two standard options areBox<dyn std::error::Error>(cheap, lossy on type info) and a custom enum (more work, more clarity).?chains compose nicely with iterator pipelines: a.parse()that returnsResultslots straight in, andsum::<Result<_, _>>()short-circuits on the first error.- For real applications the
anyhowandthiserrorcrates are the usual upgrades overBox<dyn Error>and hand-rolled enums respectively.- Tests that touch the filesystem can race when the harness runs in parallel. Use unique filenames or
cargo test -- --test-threads=1if you see flaky failures.