27
Rust #3: Options, Results and Errors (Part 1)
The Option
and Result
types in Rust will be two of the most used types you will have at your disposal when writing your programs. Their concepts are simple but their use can be confusing at times for beginners. It was for me. This blog entry is an attempt to help explain how to use them effectively.
A Result
can also wrap a Rust error and this blog article will cover how to create those easily too.
Let's look at the basics.
The Option
type allows you to have a variable that may or may not contain a value. This is useful for passing optional parameters or as a return value from a function that may or may not succeed.
Its definition is
enum Option<T> {
Some(T),
None,
}
So it can either contain a single value via the Some
variant or no value at all via the None
variant.
Enums in Rust are implemented with a discriminant value that tells Rust what type of variant is stored and a union of all the data in the variants. So, if you were to implement the same thing in C, it would look something like:
struct Option
{
int type;
union
{
struct Some
{
T t;
};
struct None {};
};
};
So, the size of the enum is usually the size of the largest variant plus the size of the type value.
But Rust has a neat optimisation. If one variant has no data and the other has a single value that is a non-null pointer (such as references, boxes, function pointers), Rust will optimise the enum type so that its size is the same as the type T. It accomplishes this by representing the no-value variant (e.g. None) as a null pointer. This means something like Option<&T>
is the same size as &T
. Effectively, this is like normal C pointers with the extra type safety.
For more information about this check out the documentation of Option
here under the section titled 'Representation'.
Below is an example of how we can use Option
for a generic function that returns the first item:
fn first_item<T>(v: &Vec<T>) -> Option<T>
where T: Clone {
if v.len() > 0 {
Some(v[0].clone())
} else {
None
}
}
The function first_item
can only return a value if the vector being passed is not empty. This is a good candidate for Option
. If the vector is empty, we return None
, otherwise we return a copy of the value via Some
.
The None
variant forces the programmer to consider the case where the information required is not forthcoming.
Result
is similar to Option
in that it can either return a value or it doesn't and is usually used as a return value from a function. But instead of returning a None
value, it returns an error value that hopefully encapsulates the information of why it went wrong.
Its form is:
enum Result<T, E> {
Ok(T),
Err(E),
}
If everything goes well, a function can return an Ok
variant along with the final result of the function. However, if something fails within the function it can return an Err
variant along with the error value.
Let's look at an example:
use std::fs::File;
use std::io::{BufRead, BufReader, Error};
fn first_line(path: &str) -> Result<String, Error> {
let f = File::open(path);
match f {
Ok(f) => {
let mut buf = BufReader::new(f);
let mut line = String::new();
match buf.read_line(&mut line) {
Ok(_) => Ok(line),
Err(e) => Err(e),
}
}
Err(e) => Err(e),
}
}
std::fs::File::open
will return a Result<std::fs::File, std::io::Error>
. That is, it will either return a file handle if everything goes OK, or it will return an I/O error if it doesn't. We can match on this. If it's an error, we just return it immediately. Otherwise, we try to read the first line of that file via the std::io::BufReader
type.
The read_line
method returns a Result<String, std::io:Error>
and once again we match on this. If it was an error, we return it immediately. Notice that the error type for both the open
and read_line
methods is std::io::Error
. If they were different, this function wouldn't compile. We will deal with differing error types later.
However, if we were successful, we return the first line as a string via the Ok
variant.
Rust introduced an operator ?
that made handling errors less verbose. Basically, it turns code like this:
let x = function_that_may_fail();
let value = match x {
Ok(v) => value,
Err(e) => return Err(e);
}
into:
let value = function_that_may_fail()?;
The ?
operator changes the Result<T,E>
value into one of type T
. However, if the result was an error, the current function exits immediately with the same 'Err' variant. It unwraps the result if everything went OK, or it causes the function to return with an error if not.
With this in mind, we can simplify the first_line
demo function above:
use std::fs::File;
use std::io::{BufRead, BufReader, Error};
fn first_line(path: &str) -> Result<String, Error> {
let f = File::open(path)?;
let mut buf = BufReader::new(f);
let mut line = String::new();
buf.read_line(&mut line)?;
Ok(line)
}
I think we can all agree this is a lot easier to read.
The error type in a Result
can be any type, like, for example, a String
. However, it is recommended to use a type that implements the trait std::error::Error
. By using this standard trait, users can handle your errors better and even aggregate them.
Traits are interfaces that structures can implement as methods to extend them. I might write a blog article about traits in the future, but if you are not sure what they are, please read this article.
Here is the Error
trait in all its glory:
trait Error: Debug + Display {
fn source(&self) -> Option<&(dyn Error + 'static)> { None };
fn backtrace(&self) -> Option<&Backtrace> { None };
}
The backtrace
method is only defined in the nightly version of the compiler, at time of writing, and so only source
is defined for the stable version. source
can be implemented to return an earlier error that this current error would be chained to. But if there is no previous error, None
is returned. Returning None
is the default implementation of this method.
A type that implements Error
must also implement Debug
and Display
traits.
Errors can be enums too. Below is an example of possible errors that can occur when reading from a file-based database:
use std::fmt::{Result, Formatter};
use std::fs::File;
#[derive(Debug)]
enum MyError {
DatabaseNotFound(String),
CannotOpenDatabase(String),
CannotReadDatabase(String, File),
}
impl std::error::Error for MyError{}
impl std::fmt::Display for MyError {
fn fmt(&self, f: &mut Formatter<'_>) -> Result {
match self {
Self::DatabaseNotFound(ref str) => write!(f, "File `{}` not found", str),
Self::CannotOpenDatabase(ref String) => write!(f, "Cannot open database: {}", str),
Self::CannotReadDatabase(ref String, _) => write!(f, "Cannot read database: {}", str),
}
}
}
First we define the enum with the possible error states and their associative data (e.g. filename of file that was not found). Notice the derive
macro that implements the Debug
trait for us. Unfortunately, we cannot do that for Display
traits.
Secondly, we implement the Error
trait for compatibility with other error systems. Since we're not chaining errors, the default implementation will do.
Finally, we implement the Display
trait, which is a requirement of the Error
trait.
This is a lot to write for an error type, but fortunately there are some popular crates that allow us to write and use errors more easily.
As just shown, implementing an error type to be passed with a Result
's Err
variant can be tedious to write. Some consider the Error
trait lacking in functionality too. Various crates have been written to combat the boilerplate and to increase the usefulness of the types of error values you can generate.
I will explore a few of them in this article that I have found to be the most popular.
Let us imagine we want to implement this function:
fn first_line(path: &str) -> Result<String, FirstLineError> { ... }
We will investigate how we implement FirstLineError
in each of these crates. The basic foundation of the error will be this enum:
enum FirstLineError {
CannotOpenFile { name: String },
NoLines,
}
failure
provides 2 major concepts: the Fail
trait and an Error
type.
The Fail
trait is a new custom error type specifically to hold better error information. This trait is used by libraries to define new error types.
The Error
trait is a wrapper around the Fail
types that can be used to compose higher-level errors. For example, a file open error can be linked to a database open error. The user would deal with the database open error, and could dig down further and obtain the original file error if they wanted.
Generally, crate writers would use Fail
and crate users would interact with the Error
types.
Failure
also supports backtraces if the crate feature backtrace
is enabled and the RUST_BACKTRACE
environment variable is set to 1
.
This is how we would create the FirstLineError
error type using this crate:
use std::fs::File;
use std::io::{BufRead, BufReader};
use failure::Fail;
#[derive(Fail, Debug)]
enum FirstLineError {
#[fail(display = "Cannot open file `{}`", name)]
CannotOpenFile { name: String },
#[fail(display = "No lines found")]
NoLines,
}
fn first_line(path: &str) -> Result<String, FirstLineError> {
let f = File::open(path).map_err(|_| FirstLineError::CannotOpenFile {
name: String::from(path),
})?;
let mut buf = BufReader::new(f);
let mut line = String::new();
buf.read_line(&mut line)
.map_err(|_| FirstLineError::NoLines)?;
Ok(line)
}
The derive macro implements the Fail
and Display
traits automatically for us. It uses the fail
attributes to help it construct those traits.
But we do have another problem. The File::open
and BufRead::read_line
methods return a result based on the std::io::Error
type and not the FirstLineError
type that we require. We use the Result
's map_err
method to convert one error type to another.
I will cover map_err
and other methods for Option
and Result
in my next blog article, but for now I will describe this one. If the result is an error, map_err
will call the closure given with the error value allowing us an opportunity to replace it with a different error value.
So, recall that File::open
returns a Result<(), std::io::Error
value. By calling map_err
we now return a Result<(), FirstLineError>
value. This is because the closure given returns a FirstLineError
value and through type inference, we get the new result type. If the result is an error, that closure will provide the value to associate with the Err
variant.
But the value returned from File::open
is still a Result
type so we use the ?
operator to exit immediately if an error occurs.
Now we can do things like:
match first_line("foo.txt") {
Ok(line) => println!("First line: {}", line),
Err(e) => println!("Error occurred: {}", e),
}
Failure
can even allow you to create errors on the fly that are compatible with failure::Error
. For example,
use failure::{ensure, Error};
fn check_even(num: i32) -> Result<(), Error> {
ensure!(num % 2 == 0, "Number is not even");
Ok(())
}
fn main() {
match check_even(41) {
Ok(()) => println!("It's even!"),
Err(e) => println!("{}", e),
}
}
This program will output Number is not even
as expected via the Display
trait of the error.
There are other ways to create errors on the fly too with failure
. format_err!
will create a string based error:
let err = format_err!("File not found: {}", file_name);
And finally, there's a macro that combines format_err!
with a return:
bail!("File not found: {}", file_name);
This is similar to failure
but solves the issue where the actual error that occurred is not the error we want to report.
If you recall above, we use map_err
to convert the std::io::Error
into one of our FirstLineError
variants. snafu
makes this easier by providing a context
method that allows the programmer to pass in the actual error they wish to report.
Let's redefine our error type and function using snafu
:
use std::fs::File;
use std::io::{BufRead, BufReader};
use snafu::{Snafu, ResultExt};
#[derive(Snafu, Debug)]
enum FirstLineError {
#[snafu(display("Cannot open file {} because: {}", name, source))]
CannotOpenFile {
name: String,
source: std::io::Error,
},
#[snafu(display("No lines found because: {}", source))]
NoLines { source: std::io::Error },
}
fn first_line(path: &str) -> Result<String, FirstLineError> {
let f = File::open(path).context(CannotOpenFile {
name: String::from(path),
})?;
let mut buf = BufReader::new(f);
let mut line = String::new();
buf.read_line(&mut line)
.context(NoLines)?;
Ok(line)
}
To use context()
, there needs to be a source
field in the variant. Notice that the enum type FirstLineError
is not included. We wrote CannotOpenFile
, not FirstLineError::CannotOpenFile
. And the source field is automatically set! There's some black magic going on there!
If you don't want to use the name source
for your underlying cause, you can rename it by marking the field you do want to be the source with #[snafu(source)]
. Also, if there is a field called source
that you don't want to be treated as snafu
's source field, mark it with #[snafu(source(false))]
.
Similarly, snafu
supports the backtrace
field too to store a backtrace at point of error. #[snafu(backtrace)]
et al. controls those fields like the source.
On top of this, you have the ensure!
macro that functions like failure
's.
This crate provides dynamic error support via its anyhow::Result<T>
. This type can receive any error. It can create an ad-hoc error from a string using anyhow!
:
let err = anyhow!("File not found: {}", file_name);
It also defines bail!
and ensure!
like other crates. anyhow
results can extend the errors using a context()
method:
let err = anyhow!("File not found: {}", file_name)
.context("Tried to load the configuration file");
Here's the first_line
method implemented using anyhow
:
use anyhow::Result;
#[derive(Debug)]
enum FirstLineError {
CannotOpenFile {
name: String,
source: std::io::Error,
},
NoLines {
source: std::io::Error,
},
}
impl std::error::Error for FirstLineError {}
impl std::fmt::Display for FirstLineError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
FirstLineError::CannotOpenFile { name, source } => {
write!(f, "Cannot open file `{}` because: {}", name, source)
}
FirstLineError::NoLines { source } => {
write!(f, "Cannot find line in file because: {}", source)
}
}
}
}
fn first_line(path: &str) -> Result<String> {
let f = File::open(path).map_err(|e| FirstLineError::CannotOpenFile {
name: String::from(path),
source: e,
})?;
let mut buf = BufReader::new(f);
let mut line = String::new();
buf.read_line(&mut line)
.map_err(|e| FirstLineError::NoLines { source: e })?;
Ok(line)
}
anyhow
doesn't define the Display
trait for us so we have to do that ourselves. Also map_err
has to come back if we want to convert error values from one domain to another. But, this time we use Result<String>
and we don't need to define which error is returned.
This crate makes it easier to define the error type, and can be used in conjunction with anyhow
. It uses #[derive(thiserror::Error)]
to generate all the Display
and std::error::Error
boilerplate like other crates do.
But thiserror
makes it easier to chain lower-level errors using the #[from]
attribute. For example:
#[derive(Error, Debug)]
enum MyError {
#[error("Everything blew up!")]
BlewUp,
#[error(transparent)]
IoError(#[from] std::io::Error)
}
This will allow auto-casting from std::io::Error
to MyError::IoError
.
Let's look at our demo with anyhow
for results, and thiserror
for errors:
use std::fs::File;
use std::io::{BufRead, BufReader};
use anyhow::Result;
use thiserror::Error;
#[derive(Debug, Error)]
enum FirstLineError {
#[error("Cannot open file `{name}` because: {source}")]
CannotOpenFile {
name: String,
source: std::io::Error,
},
#[error("Cannot find line in file because: {source}")]
NoLines {
source: std::io::Error,
},
}
fn first_line(path: &str) -> Result<String> {
let f = File::open(path).map_err(|e| FirstLineError::CannotOpenFile {
name: String::from(path),
source: e,
})?;
let mut buf = BufReader::new(f);
let mut line = String::new();
buf.read_line(&mut line)
.map_err(|e| FirstLineError::NoLines { source: e })?;
Ok(line)
}
Notice the neat embedded field names in the strings on the #[error(...)]
lines.
A quick note on the main function. Rust Edition 2018 as added a feature that allows main
to return a Result
. If main returns an Err
variant, it will return an error code other than 0 to the operating system (signifying a fail condition), and output the error using the Debug
trait.
If you wanted the Display
trait, then you have to put your main function in another, then have your new main
call it and println!
the result:
fn main() -> i32 {
if let Err(e) = run() {
println!("{}", e);
return 1;
}
return 0;
}
fn run() -> Result<(), Error> { ... }
If the Debug
trait is good enough for printing out your error, you can use:
fn main() -> Result<(), Error> { ... }
How does the program know what error code to return? It uses the new Termination
trait:
trait Termination {
fn report(self) -> i32;
}
The compiler will call report()
on the type you return from main
.
But not this week...
I wanted to talk about the methods on Option
and Result
, like map_err
but this article is already too long. I will cover them next time.
27