Rust #4: Options and Results (Part 2)

cthutu

Matt Davies

Posted on July 10, 2021

Rust #4: Options and Results (Part 2)

Last week I wrote about basic use of Option, Result and creating and using errors derived from std::error::Error. But both Option and Result have a host of methods that I wanted to explore and describe in today's post. Below is an overview of some of the Option methods I want to talk about:

Method Use Description Return Type
and Testing two Options are not None Option<U>
and_then Chaining Options Option<U>
expect Panic if None T
filter Filter the Option with a predicate Option<T>
flatten Removes nested Options Option<T>
is_none Test the Option type bool
is_some Test the Option type bool
iter Iterate over its single or no value an iterator
iter_mut Iterate over its single or no value an iterator
map Transform the value into another Option<U>
map_or Transform the value into another U
map_or_else Transform the value into another U
ok_or Transform the Option to a Result Result
ok_or_else Transform the Option to a Result Result
or Provide a new value if None Option<T>
or_else Provide a value if None Option<T>
replace Change the value to a Some while returning previous value Option<T>
take Change the value to None while returning original Option<T>
transpose Change Option of Result to Result of Option Result, E>
unwrap Extract value T
unwrap_or Extract value T
unwrap_or_default Extract value T
unwrap_or_else Extract value T
xor Return one of the contained values Option<T>
zip Merge Options Option<(T, U)>

This is a non-exhaustive list. And below is an overview of the Result methods I want to talk about:

Method Use Description Return Type
and Testing two Results are not errors Result
and_then Chaining Results Result
err Extract the error Option<E>
expect Panic if Err T
expect_err Panic if Ok E
is_err Test the Result type bool
is_ok Test the Result type bool
iter Iterate over its single or no value an iterator
iter_mut Iterate over its single or no vlaue an iterator
map Transform the value into another Result
map_err Transform the value into another Result
map_or Transform the value into another U
map_or_else Transform the value into another U
ok Converts Result into Option Option<T>
or Provide a new Result if Err Result
or_else Provide a new Result if Err Result
transpose Change Result of Option to Option of Result Option<Result<T,E>>
unwrap Extract value T
unwrap_err Extract error E
unwrap_or Extract value T
unwrap_or_default Extract value T
unwrap_or_else Extract value T

You can see from the above tables thit's the absense of a result Option and Result have similar methods and act in similar ways. This is not surprising if you think about it because both can return results or a non-result. In Option's case, the non-result is an absence of a result (None) and in Result's case, the non-result is an error.

Both have logical operations that combine each other: and, or and in Option's case, xor.

Both can be treated like an iterator with one or no values.

Both can transform their values via the map family of methods and extract values via the unwrap methods.

Also, you may notice some common suffixes on the functions, particularly or versus or_else. The difference between these functions is how they provide a default value. The or signifies that if there is no result (i.e. None or Err), then here is one I provide for you! The else part signifies that I will provide the default value, but via a function. This allows a default result to be provided lazily. Passing a value to an or type method will mean it is evaluated regardless of the original value. This is because all parameters in Rust are evaluated before or is called. Because or_else uses a function to provide the default value, this means it is not evaluated unless the original value is an None or Err.

Good and Bad

Because I will be talking about Option and Result generically as they share so much in common, I will use different terminology for the types of values they can have.

For Some and Ok values, I will call them good values.

For None and Err values, I will call them bad values.

There is no semantic meaning to the terms other than to distinguish between them. I could use positive and negative, or result and non-result. But the words good and bad are easier to type!

Extracting Good Values and Result Errors

A common use pattern is to extract the good value regardless of whether it is good. This is done by the unwrap family of methods. How they differ is what happens when the value is not good. If it is a good value, they just convert the value into the contained value. That is, an Option<T> or Result<T,E> becomes a T.

If you want to panic on a bad value, just use unwrap. I wouldn't recommend using this as panicking is so user unfriendly, but it's good for prototype work and for during development.

If you want to provide a default value, then there are two ways to do this: eagerly; and lazily. The unwrap_or and unwrap_or_else do this and I've already explained what or and or_else means above.

If you want to provide the type's default value via the Default trait, use unwrap_or_default.

Finally, Result has a couple more unwrapping methods for errors: err and unwrap_err. err will return an Option<E> value, which is None if no error occurred. unwrap_err extracts the error value.

let maybe_name: Option<String> = get_dog_name();

let name1: String = maybe_name.unwrap();
let name2: String = maybe_name.unwrap_or(String::from("Fido"));
let name3: String = maybe_name.unwrap_or_else(|| String::from("Fido"));
let name4: String = maybe_name.unwrap_or_default();

let maybe_file: Result<std::fs::File, std::io::Error> = File::open("foo.txt");
let file_error: std::io::Error = maybe_file.unwrap_err();
let maybe_file_error: Option<std::io::Error> = maybe_file.err();
Enter fullscreen mode Exit fullscreen mode

Iteration and Transforming Values

Both Option and Result can be treated as containers that have one or zero values in it. By iterating over a single Ok or Some value, this opens up Options and Results to all the iterator functionality.

So iter provides the &T and iter_mut provides the &mut T on the "collection".

Some of the iterator methods have been brought into Option and Result, removing the requirement to convert to an iterator first. This is the map family of methods and for Option there is filter.

map calls a function that converts the good value of one type to a good value of another. The function moves the wrapped value into a function that returns a new one, which doesn't even have to share the same type. So it converts an Option<T> to a Option<U> or a Result<T,E> to a Result<U,E>. For bad values, it remains the bad value with no transformation.

map_or and map_or_else extends map's functionality by provided a default value in the case of a bad value. This follows the same or and or_else functionality as described above. However, the function you provide for the default in the Result's case is provided with the error value.

Result has one more version for transforming errors. This is map_err and is often used, for example, to adapt standard errors to your own.

struct Dog {
    name: String,
    breed: Breed,
}

let maybe_name: Option<String> = get_dog_name();

let dog1: Option<Dog> = maybe_name.map(|dog_name| Dog {
    name: dog_name, 
    breed: Breed::Labrador 
});
let dog2 = maybe_name.map_or(
    Dog {
        name: String::from("Fido"),
        breed: Breed::Labrador,
    },
    |dog_name| Dog {
        name: dog_name,
        breed: Breed::Labrador,
    }
);
let dog3 = maybe_name.map_or_else(
    || Dog {    // This lambda is passed an error argument in Result case.
        name: String::from("Fido"),
        breed: Breed::Labrador,
    },
    |dog_name| Dog {
        name: dog_name,
        breed: Breed::Labrador,
    },
);

let maybe_file: Result<File, std::io::Error> = std::fs::File::open("foo.txt");
let new_file: Result<File, MyError> = maybe_file.map_err(|_error| Err(MyError::BadStuff));_
Enter fullscreen mode Exit fullscreen mode

Querying Value Types

Option provides is_some and is_none to quickly determine if it contains a good or bad value.

Result also provides is_ok and is_err for the same reasons.

There really isn't much to say about this. I would add that these are not used that often because the tests are implicit with the if let and match syntaxes.

let maybe_name = get_dog_name();
match maybe_name {
    Some(name) => Dog { name, breed: Breed::Labrador },
    None => Dog::default(),
}

// instead of:
let dog = if maybe_name.is_some() {
    // You still need to deconstruct the value here so the 
    // is_some check is redundant.
    if let Some(name) = maybe_name {
        Dog {
            name,
            breed: Breed::GermanShepherd,
        }
    } else {
        Dog::default()
    }
};
Enter fullscreen mode Exit fullscreen mode

They are really only useful if you need to convert an option or result to a boolean.

Logical combinations

Both Option and Result provide and and or methods that combine two values according to their logical rules.

With and, both values need to be good, otherwise the result is bad. Also, when all values are good, the result is the final good value. This makes and a really good way of chaining operations if you don't care about the results of anything but the last operation. For example:

// All these operations must be good
fn get_name(person: &Person) -> Option<String> { ... }
fn clone_person(person: &Person, name: String) -> Option<Person> { ... }

// We only create a new clone if the first clone has a name.  For some
// reason unnamed clones are not allowed to be cloned again!
let new_person = get_name(old_person).and(clone_person(old_person, "Brad"));

// new_person will either be None or Some(Person)
Enter fullscreen mode Exit fullscreen mode

But even more useful is the method and_then. This allows you to chain operations together but passing the result of one to the function of the next.

For example, perhaps I want to try to open a file, and read the first line. Both of these operations can fail with a bad Result. and_then is perfect for this, because I only care about the first line and none of the intermediate data such as the open file:

use std::result::Result;
use std::fs::File;

fn read_first_line(file: &mut File) -> Result<String> { ... }

let first_line: Result<String> = File::open("foo.txt").and_then(|file| {
    read_first_line(&mut file)
});
Enter fullscreen mode Exit fullscreen mode

The or method will check the first result and will return that result only if it is good. If it is bad, it will return the second result. This is useful for finding alternative operations. If one fails, then let's try the other.

struct Disease { ...  }

fn get_doctors_diagnosis() -> Option<Disease> { ... }
fn get_vets_diagnosis() -> Option<Disease> { ... }

// We're desperate!  Let's ask a vet if the doctor doesn't know.
let disease: Option<Disease> = get_doctors_diagnosis().or(get_vets_diagnosis());
Enter fullscreen mode Exit fullscreen mode

Notice how or differs to and in that all operations must wrap the same type. In the and example, the first operation wrapped a File, then resulte in a wrapped String. This is possible due to the nature of the logical AND. As soon as one operation is bad, all results are bad. This is not so for logical OR. Any operation could be bad and we will still get a good result if at least one of them is good. This means that all wrapped values must be the same. In the example, all wrapped values were of type Disease.

Option provides one more logical combination and that is the logical XOR operation. The result is good if and only if only one of the operations is good. You can either take this result or the other, but not a combination of both.

and_then versus map

It may not be obvious, but and_then and map do very similar things. They can transform a wrapped value into another. In the example above, and_then changed an Option<File> into an Option<String>. With the map example, we changed an Option<String> into an Option<Dog>. But they do differ and I want to talk about how they differ.

Both methods take a function that receives the wrapped value if good but the difference is in what those functions return.

In the case of and_then it returns a value of type Option<U>, whereas map returns a value of type U. This means that and_then can change a value from a good one to a bad one but map cannot; once good always good. map is purely for the transformation of a wrapped value to another value and even type but not goodness. That is, that transformation step cannot fail by becoming Err in the case of Result, or None in the case of Option. With and_then the transformation can fail. The function passed can return None or an Err.

It took a while for it to click for me why you use one over the other. I hopes this helps to clarify the reasons.

Converting between Option and Results

Because these types are used in very similar circumstances, it is often useful to convert between them. Let's talk about how we do this.

Some(T) -> Ok(T), None -> Err(E)

We have an option and we want to convert to a result. You could use a match:

match opt {
    Some(t) => Ok(t),
    None => Err(MyError::new()),
}
Enter fullscreen mode Exit fullscreen mode

That's a little verbose, but you can use ok_or and ok_or_else to provide the error if the option is None:

let res = opt.ok_or(MyError::new());
let res = opt.ok_or_else(|| MyError::new());
Enter fullscreen mode Exit fullscreen mode

Ok(T) -> Some(T), Err(E) -> None

Now we have a result and we want to convert to an option. Again with match:

match res {
    Ok(t) => Some(t),
    Err(_) => None,
}
Enter fullscreen mode Exit fullscreen mode

Result uses a method ok to do the conversion:

let opt = res.ok();
Enter fullscreen mode Exit fullscreen mode

Transposition and Flattening

Both Result and Option are container types that wrap a value of type T. But that type T can just as well be a Result and an Option too.

Transposition is the operation to swap a Result and Option in a nested type. So, for example, a Result<Option<T>> becomes a Option<Result<T>> or vice versa. Both Result and Option offer a transpose method to do that swapping.

Also, it is useful to convert a Result<Result<T,E>,E> into a Result<T,E>, or convert an Option<Option<T>> into an Option<T>. This operation is called flattening and both methods offer a flatten method to do so. But, as of writing, Result::flatten is only available in nightly builds and so therefore, it has not been stablised yet. This is why this article only shows flatten in the Option table at the beginning.

Miscellaneous Operations

We've covered the lion's share of the methods that are used with Option.

Firstly, replace and take are used to move values in and out of the Option. replace transfers ownership of a value into the Option and returns the old value so something else can own it. This is useful to make sure the option is not in a valid state. You cannot just move its value out without replacing it with a new value. The borrow checker will not let you do that. Quite often this is used for options within structures.

It is very common that when you do a replace, you set the option to None as you extract the value you want. That is, you want to do something like this:

let mut x = Some(42);

// Extract the 42 to another owner, but now make x own `None`.
let extracted_value = x.replace(None)
Enter fullscreen mode Exit fullscreen mode

But, unfortunately, replace takes a value of type T, meaning you can only replace it with a Some variant. Option provides another method take to do this:

let mut x = Some(42);
let extracted_value = x.take();  // x will be None after this.
Enter fullscreen mode Exit fullscreen mode

Summary

I hope I have provided a reasonable guide to using Option and Result effectively over the past two articles. It's a lot to take in and I will certainly be referring to my own articles to help me remember how I should use them.

We looked at what they are and how to create and use errors. We looked at methods to transform values, extract values, logically combine, manipulate structure and convert between them. Rust standard library provides a lot of functionality. There are more methods that I didn't go into, mainly because they were with advanced or unstable.

Next week I will write about naming conventions in the Rust Standard Library. As you have seen here, there are common suffixes that describe what the function may do. In the Standard Library there are prefixes too that help convey concepts to functions that share them. I'd like to understand them better and so I can understand what the function might be doing at a glance and perhaps reused them for my own code so that others can understand it too.

💖 💪 🙅 🚩
cthutu
Matt Davies

Posted on July 10, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related

Daemons on macOS with Rust
undefined Daemons on macOS with Rust

November 29, 2024

Baby Steps with Rust
programming Baby Steps with Rust

November 29, 2024