Item 3: Prefer Option
and Result
transforms over explicit match
expressions
Item 1 expounded the virtues of enum
and showed how match
expressions force the programmer to take all
possibilities into account. Item 1 also introduced the two ubiquitous enum
s that the Rust standard library provides:
Option<T>
: To express that a value (of typeT
) may or may not be presentResult<T, E>
: For when an operation to return a value (of typeT
) may not succeed and may instead return an error (of typeE
)
This Item explores situations where you should try to avoid explicit match
expressions for these particular enum
s,
preferring instead to use various transformation methods that the standard library provides for these types. Using these
transformation methods (which are typically themselves implemented as match
expressions under the covers) leads to
code that is more compact and idiomatic and has clearer intent.
The first situation where a match
is unnecessary is when only the value is relevant and the absence of value (and any
associated error) can just be ignored:
#![allow(unused)] fn main() { struct S { field: Option<i32>, } let s = S { field: Some(42) }; match &s.field { Some(i) => println!("field is {i}"), None => {} } }
For this situation, an if let
expression is one line shorter and, more importantly, clearer:
if let Some(i) = &s.field {
println!("field is {i}");
}
However, most of the time the programmer needs to provide the corresponding else
arm: the absence of a value
(Option::None
), possibly with an associated error (Result::Err(e)
), is something that the programmer needs to deal
with. Designing software to cope with failure paths is hard, and most of that is essential complexity that no amount of
syntactic support can help with—specifically, deciding what should happen if an operation fails.
In some situations, the right decision is to perform an ostrich maneuver—put our heads in the sand and
explicitly not cope with failure. You can't completely ignore the error arm, because Rust requires that the code deal
with both variants of the Error
enum
, but you can choose to treat a failure as fatal. Performing a panic!
on
failure means that the program terminates, but the rest of the code can then be written with the assumption of success.
Doing this with an explicit match
would be needlessly verbose:
#![allow(unused)] fn main() { let result = std::fs::File::open("/etc/passwd"); let f = match result { Ok(f) => f, Err(_e) => panic!("Failed to open /etc/passwd!"), }; // Assume `f` is a valid `std::fs::File` from here onward. }
Both Option
and Result
provide a pair of methods that extract their inner value and panic!
if it's absent:
unwrap
and
expect
. The latter allows the error
message on failure to be personalized, but in either case, the resulting code is shorter and simpler—error
handling is delegated to the .unwrap()
suffix (but is still present):
#![allow(unused)] fn main() { let f = std::fs::File::open("/etc/passwd").unwrap(); }
Be clear, though: these helper functions still panic!
, so choosing to use them is the same as choosing to panic!
(Item 18).
However, in many situations, the right decision for error handling is to defer the decision to somebody else. This is
particularly true when writing a library, where the code may be used in all sorts of different environments that can't
be foreseen by the library author. To make that somebody else's job easier, prefer Result
to Option
for
expressing errors, even though this may involve conversions between different error types (Item 4).
Of course, this opens up the question, What counts as an error? In this example, failing to open a file is definitely
an error, and the details of that error (no such file? permission denied?) can help the user decide what to do next. On
the other hand, failing to retrieve the first()
element of a slice because that slice is empty isn't really an error, and so it is expressed as an Option
return type in
the standard library. Choosing between the two possibilities requires judgment, but lean toward Result
if
an error might communicate anything useful.
Result
also has a #[must_use]
attribute to nudge library
users in the right direction—if the code using the returned Result
ignores it, the compiler will generate a
warning:
warning: unused `Result` that must be used
--> src/main.rs:63:5
|
63 | f.set_len(0); // Truncate the file
| ^^^^^^^^^^^^
|
= note: this `Result` may be an `Err` variant, which should be handled
= note: `#[warn(unused_must_use)]` on by default
help: use `let _ = ...` to ignore the resulting value
|
63 | let _ = f.set_len(0); // Truncate the file
| +++++++
Explicitly using a match
allows an error to propagate, but at the cost of some visible boilerplate (reminiscent of
Go):
pub fn find_user(username: &str) -> Result<UserId, std::io::Error> {
let f = match std::fs::File::open("/etc/passwd") {
Ok(f) => f,
Err(e) => return Err(From::from(e)),
};
// ...
}
The key ingredient for reducing boilerplate is Rust's question mark operator,
?
. This piece of
syntactic sugar takes care of matching the Err
arm, transforming the error type if necessary, and building
the return Err(...)
expression, all in a single character:
pub fn find_user(username: &str) -> Result<UserId, std::io::Error> {
let f = std::fs::File::open("/etc/passwd")?;
// ...
}
Newcomers to Rust sometimes find this disconcerting: the question mark can be hard to spot on first glance, leading to disquiet as to how the code can possibly work. However, even with a single character, the type system is still at work, ensuring that all of the possibilities expressed in the relevant types (Item 1) are covered—leaving the programmer to focus on the mainline code path without distractions.
What's more, there's generally no cost to these apparent method invocations: they are all generic functions marked
as #[inline]
, so
the generated code will typically compile to machine code that's identical to the manual version.
These two factors taken together mean that you should prefer Option
and Result
transforms over explicit match
expressions.
In the previous example, the error types lined up: both the inner and outer methods expressed errors as
std::io::Error
. That's often not the case: one function may
accumulate errors from a variety of different sublibraries, each of which uses different error types.
Error mapping in general is discussed in Item 4, but for now, just be aware that a manual mapping:
pub fn find_user(username: &str) -> Result<UserId, String> {
let f = match std::fs::File::open("/etc/passwd") {
Ok(f) => f,
Err(e) => {
return Err(format!("Failed to open password file: {:?}", e))
}
};
// ...
}
can be more succinctly and idiomatically expressed with the following
.map_err()
transformation:
pub fn find_user(username: &str) -> Result<UserId, String> {
let f = std::fs::File::open("/etc/passwd")
.map_err(|e| format!("Failed to open password file: {:?}", e))?;
// ...
}
Better still, even this may not be necessary—if the outer error type can be created from the inner error type
via an implementation of the From
standard trait (Item 10), then the compiler will automatically perform the
conversion without the need for a call to .map_err()
.
These kinds of transformations generalize more widely. The question mark operator is a big hammer; use transformation
methods on Option
and Result
types to maneuver them into a position where they can be a nail.
The standard library provides a wide variety of these transformation methods to make this possible. Figure 1-1
shows some of the most common methods (rounded white rectangles) that transform between the relevant types (gray
rectangles).1
In line with Item 18, methods that can panic!
are marked with an asterisk.
One common situation the diagram doesn't cover deals with references. For example, consider a structure that optionally holds some data:
#![allow(unused)] fn main() { struct InputData { payload: Option<Vec<u8>>, } }
A method on this struct
that tries to pass the payload to an encryption function with signature (&[u8]) -> Vec<u8>
fails if there's a naive attempt to take a reference:
impl InputData {
pub fn encrypted(&self) -> Vec<u8> {
encrypt(&self.payload.unwrap_or(vec![]))
}
}
error[E0507]: cannot move out of `self.payload` which is behind a shared
reference
--> src/main.rs:15:18
|
15 | encrypt(&self.payload.unwrap_or(vec![]))
| ^^^^^^^^^^^^ move occurs because `self.payload` has type
| `Option<Vec<u8>>`, which does not implement the
| `Copy` trait
The right tool for this is the
as_ref()
method on Option
.2 This method converts a reference-to-an-Option
into an Option
-of-a-reference:
pub fn encrypted(&self) -> Vec<u8> {
encrypt(self.payload.as_ref().unwrap_or(&vec![]))
}
Things to Remember
- Get used to the transformations of
Option
andResult
, and preferResult
toOption
. Use.as_ref()
as needed when transformations involve references. - Use these transformations in preference to explicit
match
operations onOption
andResult
. - In particular, use these transformations to convert result types into a form where the
?
operator applies.
The online version of this diagram is clickable; each box links to the relevant documentation.