11
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 Option s are not None
|
Option<U> |
and_then | Chaining Option s |
Option<U> |
expect | Panic if None | T |
filter | Filter the Option with a predicate |
Option<T> |
flatten | Removes nested Option s |
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
.
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!
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();
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 Option
s and Result
s 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));_
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()
}
};
They are really only useful if you need to convert an option or result to a boolean.
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)
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)
});
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());
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.
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.
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.
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()),
}
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());
Now we have a result and we want to convert to an option. Again with match
:
match res {
Ok(t) => Some(t),
Err(_) => None,
}
Result
uses a method ok
to do the conversion:
let opt = res.ok();
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.
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)
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.
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.
11