As you start to write Rust code, you find yourself regularly using the match
syntax. This comes up due to Rust’s error handling pattern, specifically the Result
and Option
types. Personally, I found that writing matches got old quite quickly. Rust is a concise language in general, so the match-blocks feels verbose, and I start to feel uncomfortable with the indented blocks of code that often end up inside the match
.
While I’m very conscious of the paradigm of verbose code often being easier to read than concise code, there are a few tools provided by Rust that once you are comfortable with the idea behind matches, can make your code more concise while (hopefully) remaining readable.
Step One: Panicking
Of course the simplest way to avoid match blocks is to simply ignore that an error might have occurred (Result
), or that you might not have something when you think you do (Option
). This can be done by calling unwrap
or expect
. If an Option
has a value or a Result
is OK
, the value or result is returned. Otherwise Rust panics.
Similar is the expect
function, which does the same thing but allows you to wave to the user before their program blows up (or maybe write a message to stderr).
But I general you don’t really want to deliberately panic. It sounds bad. It looks bad. It’s bad.
Step Two: The ?
operator
The first and most obvious improvement on panicking is the ?
syntax, which is used to evaluate a Result
, proceed if OK or return the Result up the call stack if an error occurred. This is well documented and common, but is worth a quick example.
So we go from this:
fn write_to_file(value: &str) -> io::Result<()> {
// Match to see if the file was created
let mut file = match File::create("file.txt") {
Ok(f) => f,
Err(e) => return Err(e),
};
// Only handle the error case if the file write failed
if let Err(e) = file.write_all(value.as_bytes()) {
return Err(e)
}
Ok(())
}
To this:
fn write_to_file(value: &str) -> io::Result<()> {
let mut file = File::create("file.txt")?;
file.write_all(value.as_bytes())?;
Ok(())
}
Step Three: Combinators
The ?
syntax is great, but it starts to become less useful, particularly when you want to respond to error cases rather than pass them up the stack, or when you’re mixing Options and Results, or different types of Results. However in many of these cases, there are combinators that can really help, a few examples of which are covered below.
Turning an Option
into a Result
A common situation is evaluating an Option
which effectively defines the result of a function. I.e. if the option is None
, then the function should return. For example:
fn get_language_name_by_id(id: i64) -> Result<&'static str, String> {
let result = match LANGUAGES.get(id as usize) {
Some(value) => value,
None => return Err(format!("No language found for id: {}", id).to_string()),
};
Ok(result)
}
Here, a useful combinator is ok_or()
, which effectively turns the Option
into a Result
, and allows you to specify the error message:
fn get_language_name_by_id(id: i64) -> Result<&'static str, String> {
let result = LANGUAGES.get(id as usize)
.ok_or(format!("No language found for id: {}", id)
.to_string())?;
Ok(result)
}
Yield a default value in case of None
Similarly, another nice tool is the unwrap_or
combinator that returns a default value when an Option is None
:
fn get_language_name_by_id(id: i64) -> Result<&'static str, String> {
let result = LANGUAGES.get(id as usize).unwrap_or(&UNKNOWN_LANGUAGE_NAME);
Ok(result)
}
If at first you don’t succeed
Note that you can also use unwrap_or
on a Result
to “have another go” in case of an error. Here, if the initial file creation fails, we attempt to create the file with another name (terrible looking code I know, but sometimes real-world examples are tricky to come up with):
fn write_to_file(value: &str) -> io::Result<()> {
let mut file = File::create("log.txt").unwrap_or(File::create("log01.txt")?);
file.write_all(value.as_bytes())?;
Ok(())
}
Closure versions of combinators
Some combinators, including those above also have an _else version, so ok_or_else
, unwrap_or_else
, etc. These do the same thing as their counterparts, but take a closure instead of a value. The important detail with these is that the logic in the closures are lazily-evaluated. Thus, were you to perform any serious memory allocation or processing in the error cases, these are a useful option to avoid that unless necessary:
fn get_language_name_by_id(id: i64) -> Result<&'static str, String> {
let result = LANGUAGES
.get(id as usize)
.unwrap_or_else(|| return &UNKNOWN_LANGUAGE_NAME);
Ok(result)
}