Rust Options in Practice

Rust doesn't have a nil value commonly found in other languages. This prevents annoying run-time errors like dereferencing a nil pointer, but the need to express a "nothing" value remains. Rust provides an Option<T> type to represent these optional values. They can either be something or nothing. These two possible states correspond to Some(T) and None. There's plenty of good introductory and high-level documentation on how to use Option<T> available in the standard places. This article presents a few practical examples of how to use Option<T> that you may encounter.

Do Something with it if it's Something

We often have an option, and we want to perform some additional work on the value of that option. But if the option is None the work doesn't need to be done and it can simply remain None.

map is a method of Option<T> that facilitates this. It has some slight similarities with map on an iterator in that it allows for modifying what's in the option, but other than that its best to think of it as a completely different thing. If the original option is Some, map always returns Some, wrapping whatever is returned from the callback function with Some. The callback can return a different type than the original option, allowing for changing from an Option<T> to an Option<U>.

let maybe = Some(1);
let changed = maybe.map(|val| val * 2).map(|val| val * 2);
// changed is Some(4) because 1 * 2 * 2 = 4
println!("{}", changed.unwrap()); // 4

let maybe = Some(1);
let changed: Option<&str> = maybe.map(|_| 1).map(|_| "a");
println!("{}", changed.unwrap()); // "a"

and_then is similar in some ways to map. and_then doesn't always return Some: The callback function itself returns an option. If the returned value is None the chain stops and results in None. and_then is useful for processing an option with operations that can themselves produce options.

let maybe = Some(1);
let changed: Option<&str> = maybe
    .and_then(|_| Some("hello"))
    .and_then(|_| None::<Option<&str>>)
    .and_then(|_| Some("goodbye")); // changed is None

Borrow it if it's Something

Borrowing the value contained in an option is useful if something should be done with the value (unless it's None) without taking ownership of it, leaving the value available for later use.

First consider the alternative: Taking ownership of the value in the option, which makes it unable to be used later:

let maybe = Some("hello".to_string());
if let Some(maybe) = maybe {
    println!("{}", maybe);
}
println!("{}", maybe.unwrap()); // Compiler error! Use of partially moved value.

as_ref provides a way to get a reference to the value (aka: a borrow) instead:

let maybe = Some("hello".to_string());
if let Some(maybe) = maybe.as_ref() {
    // Do something with the borrowed Some value, without taking it out.
    println!("{}", maybe);
}
println!("{}", maybe.unwrap()); // The original value in the option is still there.

Using the ref keyword is equivalent:

let maybe = Some("hello".to_string());
if let Some(ref maybe) = maybe {
    // Do something with the borrowed Some value, without taking it out.
    println!("{}", maybe);
}
println!("{}", maybe.unwrap()); // The original value in the option is still there.

For a mutable borrow, use as_mut or ref mut:

let mut maybe = Some(1);
if let Some(my_mut) = maybe.as_mut() {
    *my_mut = 2;
}
println!("{}", maybe.unwrap()) // 2
let mut maybe = Some(1);
if let Some(ref mut my_mut) = maybe {
    *my_mut = 2;
}
println!("{}", maybe.unwrap()) // 2

Borrow and Dereference if it's Something

It can be handy to borrow and dereference the value contained in an option depending on how it needs to be used. This may apply when dealing with options and smart pointers. Depending on what you need out of the option, as_deref (and as_deref_mut) can be a nice convenience:

fn main() {
    let maybe = Some(Box::new(42));
    first(maybe.as_deref());
    second(maybe.as_ref());
    println!("{}", maybe.unwrap()); // 42
}

fn first(s: Option<&i32>) {
    println!("{}", s.unwrap()) // 42
}

fn second(s: Option<&Box<i32>>) {
    println!("{}", s.unwrap()) // 42
}

as_deref isn't doing anything magical. The same thing can be done with the dereference operator, but it starts to get ugly:

fn main() {
    // ...
    first(maybe.as_ref().map(|v| &**v));
    // ...
}

Re-using Options

Rather than borrowing the value from an option so that it can be used again, sometimes we want to take ownership of the value but leave the option conceptually in place so that it can be used again as an option. The option can't possibly be Some(original_thing) anymore after the value is taken over, so it needs to be replaced by something else.

An example is a structure containing work to be done. If the program takes work from it, there is no work left and that is represented by None. If there's work to be done it's Some(work). The structure can be continuously re-used in this way to take and hold work.

As a first pass, we might try to do something like this:

struct Thing {
    work: Option<String>,
}

impl Thing {
    fn do_work(&mut self) -> Option<String> {
        self.work.map(|s| { // Compiler error! Can't move out of self.work
            self.work = None;
            s
        })
    }

    fn create_work(&mut self, work: String) -> Result<(), &'static str> {
        match self.work {
            Some(_) => Err("cannot create work when there is already work to do!"),
            None => {
                self.work = Some(work);
                Ok(())
            }
        }
    }
}

The error in this initial implementation is because we can't move the value out and then put a new value back in, even if putting the new value back in happens right away. This would leave self.work in an invalid state, if only very briefly, which is a violation of exception safety. To take a value and replace it in a single operation, use std::mem::replace or (more commonly) the more convenient take method:

fn do_work(&mut self) -> Option<String> {
    self.work.take()
}

// ... equivalent to ...

fn do_work(&mut self) -> Option<String> {
    std::mem::replace(&mut self.work, None)
}

There's also a replace method that can be used to replacing the value with something other than None.

Interchanging Options and Results

Functions frequently need to return Result<T> but end up dealing with Option<T> or the other way around.

ok_or and ok_or_else can be used to return an error if an option is None. This works nicely with the ? operator for handling these cases where encountering a None should be treated as a run-time error. ok_or_else takes a callback function which allows for lazy-evaluation of the code it contains: Use this to avoid unnecessary processing or unwanted side effects unless the option truly is None:

fn fallible() -> anyhow::Result<()> {
    let maybe = Some(2);
    maybe.ok_or_else(|| anyhow::anyhow!("some error"))?;
    Ok(())
}

An error result can be changed to an optional value using ok. This applies to cases where a potentially fallible operation is done and the outcome should not be treated specifically as an error, but rather as None:

fn infallible1() -> Option<i32> {
    Err("something").ok() // Returns None
}

fn infallible2() -> Option<i32> {
    Ok::<i32, anyhow::Error>(1).ok() // Returns Some(1)
}

transpose is another useful method for more complicated cases. On its own it makes an option of a result into a result of an option. Combined with map and the ? operator this can express otherwise cumbersome operations in a succinct way. A pattern where this applies is shown below where an optional value is subject to some fallible processing:

  • If the input is None, the output is Ok(None).
  • If the input is Some(val) and val cannot be processed, the output reflects the processing error.
  • If the Input is Some(val) and val can be processed, the output is Ok(processed_value).
fn str_to_int(parse_this: Option<&str>) -> anyhow::Result<Option<i32>> {
    Ok(parse_this.map(|str_val| str_val.parse()).transpose()?)
}

rust

1262 Words

2023-03-14