DEV Community

loading...

Rust #3: Options, Results and Errors (Part 1)

Matt Davies
A video game programmer for over 25 years.
・11 min read

Options and Results

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.

Option

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,
}
Enter fullscreen mode Exit fullscreen mode

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 {};
    };
};
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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

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),
}
Enter fullscreen mode Exit fullscreen mode

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),
    }
}
Enter fullscreen mode Exit fullscreen mode

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.

The ? operator

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);
}
Enter fullscreen mode Exit fullscreen mode

into:

let value = function_that_may_fail()?;
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

I think we can all agree this is a lot easier to read.

Errors

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 };
}
Enter fullscreen mode Exit fullscreen mode

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),
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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 Errortrait.

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.

Error Crates

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> { ... }
Enter fullscreen mode Exit fullscreen mode

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,
}
Enter fullscreen mode Exit fullscreen mode

failure crate

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)
}
Enter fullscreen mode Exit fullscreen mode

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),
}
Enter fullscreen mode Exit fullscreen mode

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),
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

And finally, there's a macro that combines format_err! with a return:

bail!("File not found: {}", file_name);
Enter fullscreen mode Exit fullscreen mode

snafu crate

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)
}
Enter fullscreen mode Exit fullscreen mode

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.

anyhow crate

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);
Enter fullscreen mode Exit fullscreen mode

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");
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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.

thiserror crate

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)
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

Notice the neat embedded field names in the strings on the #[error(...)] lines.

Main function

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> { ... }
Enter fullscreen mode Exit fullscreen mode

If the Debug trait is good enough for printing out your error, you can use:

fn main() -> Result<(), Error> { ...  }
Enter fullscreen mode Exit fullscreen mode

How does the program know what error code to return? It uses the new Termination trait:

trait Termination {
    fn report(self) -> i32;
}
Enter fullscreen mode Exit fullscreen mode

The compiler will call report() on the type you return from main.

And there's much more...

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.

Discussion (0)