Rust for C++ developers part 5: Option and Result

There are two interesting types in Rust which are implemented with enums (check the previous post for more about enums). They are Option and Result. Let's start with Option.

Option

Option represents some value which might or might not exist. This is achieved via an enum with two variants (a bit simplified definiton of Option from std):

enum Option<T> {
    /// No value.
    None,
    /// Some value of type `T`.
    Some(T),
}

<T> is a syntax for generics which are similar to the templates in C++. We haven't talked about them so far but you can think about T as some type which the compiler sets during compilation. For example Option<u32> will be expanded to:

enum Option<u32> {
    /// No value.
    None,
    /// Some value of type `u32`.
    Some(u32),
}

Now back to Option. Option<u32> means that there is a u32 value which might or might not exist. If it doesn't exist the enum's variant will be None. If it does exist - it will be Some and the actual value will be bundled with the variant. What does a "value doesn't exist" mean in practice? Imagine you are writing a function which looks up a key in a hash map and returns it value. This is a perfect candidate for Option<T>. If the key exists in the hash map the function will return Some(value). If it doesn't it will return None.

Semantically None is not considered an error. A value not found in a container is a valid case but yet it needs to be handled. And Option does exactly that.

Now let's see how we can use Option<T>. Here is a very naive function, finding the square root of the numbers up to 100:

fn find_square_root(input: u32) -> Option<u32> {
    for i in 1..10 {
        if i * i == input {
            return Some(i);
        }
    }

    None
}

The function is really naive and inefficient but let's focus on how Option is used. First check the signature - input is u32 and the function returns Option<u32>. Ideally this function should be generic but again it is just a demonstration.

Then our naive algorithm calculates the squares of the numbers of 1 to 10. If any of them matches the input we return Some(i) because this is our square root. If there is no match the loop ends and we return None.

Note that we use Some(i) not Option::Some(i). Of course we can use the latter but Options are widely used so their variants are directly included by default.

Now let's see how we can use our dummy function:

fn main() {
    match find_square_root(25) {
        Some(sqrt) => println!("Found square root: {}", sqrt),
        None => println!("No square root found"),
    }
}

We examine the return value of the function with a match expression and handle both cases. In the match arm handling Some we use destructuring to extract the actual result from the enum. With Option we can hardly forget to handle the missing value case. A typical error might be to call the function and try use the return value without examining it. For example:

fn main() {
    let sqrt = find_square_root(25);
    println!("SQRT x 2 = {}", sqrt * 2);
}

This won't compile:

error[E0369]: cannot multiply `Option<u32>` by `{integer}`
  --> src/main.rs:13:36
   |
13 |     println!("SQRT x 2 = {}", sqrt * 2);
   |                               ---- ^ - {integer}
   |                               |
   |                               Option<u32>

For more information about this error, try `rustc --explain E0369`.

Of course the compiler doesn't know what we want to do but the error message should be enough to figure out we are dealing with an Option instead of an actual value.

Furthermore Option type has got a lot of convenience methods implemented but I won't cover them now. Have a look at the Option documentation here for more details.

Result

Error handling in Rust is usually accomplished via the Result<T, E> type which is also an enum. Its purpose is to represent an operation which might fail. The enum has got two variants - Ok (for the success case) and Err for the error case. Its simplified definition looks like this:

pub enum Result<T, E> {
    /// Contains the success value
    Ok(T),

    /// Contains the error value
    Err(E),
}

T represents the result type and can be anything. E is the error type which usually implements std::error::Error but this is not mandatory. You can represent the error with a whatever type you want - a string, an integer, other custom type. For the compiler it doesn't matter.

Let's see Result in action. Our square root example is not ideal in terms of error handling. For example if we pass a number which has got a square root but it is above 100 we will return None. This is bad because the caller will think that the number hasn't got a square root which might not be true. Let's handle this case better with a Result:

fn find_square_root(input: u32) -> Result<Option<u32>, String> {
    if input > 100 {
        return Err("Only numbers between 1 and 100 are supported".to_string());
    }

    for i in 1..10 {
        if i * i == input {
            return Ok(Some(i));
        }
    }

    Ok(None)
}

fn main() {
    match find_square_root(25) {
        Ok(Some(sqrt)) => println!("Found square root: {}", sqrt),
        Ok(None) => println!("No square root found"),
        Err(e) => println!("Error: {}", e),
    }

    match find_square_root(400) {
        Ok(Some(sqrt)) => println!("Found square root: {}", sqrt),
        Ok(None) => println!("No square root found"),
        Err(e) => println!("Error: {}", e),
    }
}

The first change is the signature of find_square_root. Now it returns Result<Option<u32>, String>. You might wonder why? What's the point in returning an Option wrapped in a Result? Just semantics. Remember that Option doesn't represent an error but a missing value. Result on the other hand represents an error condition. So mixing them makes perfect sense. You will see this pattern a lot in Rust code bases.

Next let's talk about the error type we use. In our case it is just a string because a simple error message is enough for our example. In a bigger project you will probably see a type implementing std::error::Error but that would be an overkill here.

Now let's move to the function body. First we validate the input. If the number is bigger than 100 we return Err("Only numbers between 1 and 100 are supported".to_string()). This is the Err variant of the enum which has got a bundled String. We don't need to explicitly specify the enum type (Result::Err(..)). Like Option, Result is commonly used so its enum variants are already imported.

Next note that we return Ok(Some(i)) in the happy case (when there is a square root). Ok is the success variant of Result, Some is "there is a value" variant of Option. If there is no square root the loop finishes and we return Ok(None). Ok(Some(i)) means "There was no error AND the input has a square root". While Ok(None) means "There was no error BUT the input hasn't got a square root". These are just semantics. For your application you might decide that an input without a square root is an error and use Result<u32, String> as a return type. Then you can return Err if the square root doesn't exist. This is fine as long as it makes sense for your program. But otherwise you should go for Result<Option<>> because it clearly separates the error case.

Finally let's see how we can handle errors in main. It's done with a match expression of course. We have got three arms for the thee cases - happy case, no square root case and error case. We handle the error case in the third match arm - Err(e) where e is the error message (the bundled String).

Also note how we destructure the Option encapsulated within a Result - Ok(Some(sqrt)). Another example for the convenience of the match expressions.

Result also has got a lot of convenience methods implemented. To learn more about them check out its documentation.

Operator ?

Let's pretend that the input validation of our square root example is not that simple and we want to extract it in a separate function. It will be something like this:

fn sanitize_input(input: u32) -> Result<u32, String> {
    if input > 100 {
        return Err("Only numbers between 1 and 100 are supported".to_string());
    }

    Ok(input)
}

Note that here we have got a missing value to handle so we don't need an Option. We just return Result<u32, String>. Now find_square_root needs to call this function and handle the error. An unoptimal way to do it is:

fn find_square_root(input: u32) -> Result<Option<u32>, String> {
    let input = match sanitize_input(input) {
        Ok(input) => input,
        Err(e) => return Err(e),
    };

    for i in 1..10 {
        if i * i == input {
            return Ok(Some(i));
        }
    }

    Ok(None)
}

We call sanitize_input and use a match expression to extract the result if there are no errors. If there is an error - we pass it up to the caller. This is clunky - we don't do anything with the error but to bubble it up we write four lines of boilerplate code. Luckily there is a better way with the operator ?. We can replace the whole match expression with a single line and the logic will remain the same:

fn find_square_root(input: u32) -> Result<Option<u32>, String> {
    let input = sanitize_input(input)?;

    for i in 1..10 {
        if i * i == input {
            return Ok(Some(i));
        }
    }

    Ok(None)
}

What does the ? operator does? It works with functions returning a Result (and Option but more on this later). The operator is put just after the function call and if the function returns Ok(something) it returns the something. If the function returns Err(something) the operator does return Err(something) or in other words "bubbles up the error to the caller".

There is one important caveat though. The error types of the function being called (in our case sanitize_input) and the function containing the call (find_square_root) should be the same. Which indeed is valid for our example - they are both String. Note that just the error types (E) should match, the result ones (T) can be different. Which is also the case in our example - find_square_root returns Result<Option<u32>, String> while sanitize_input - Result<u32, String>.

Here is the whole example rewritten with operator ?:

fn find_square_root(input: u32) -> Result<Option<u32>, String> {
    let input = sanitize_input(input)?;

    for i in 1..10 {
        if i * i == input {
            return Ok(Some(i));
        }
    }

    Ok(None)
}

fn sanitize_input(input: u32) -> Result<u32, String> {
    if input > 100 {
        return Err("Only numbers between 1 and 100 are supported".to_string());
    }

    Ok(input)
}

fn main() {
    match find_square_root(25) {
        Ok(Some(sqrt)) => println!("Found square root: {}", sqrt),
        Ok(None) => println!("No square root found"),
        Err(e) => println!("Error: {}", e),
    }

    match find_square_root(400) {
        Ok(Some(sqrt)) => println!("Found square root: {}", sqrt),
        Ok(None) => println!("No square root found"),
        Err(e) => println!("Error: {}", e),
    }
}

? and Option

It's less common but ? can be used with Option too. Let's see a pointless example:

fn caller() -> Option<u32> {
    let res = callee()?;

    println!("Got {}", res);
    Some(res)
}

fn callee() -> Option<u32> {
    Some(42)
}

fn main() {
    assert_eq!(caller(), Some(42));
}

Here caller returns Option<u32>. callee also returns Option<u32> and is called by caller. In caller ? will extract the wrapped u32 if calee has returned Some or return None otherwise. This is less common but still you can be useful somewhere.

When ? is used with Option, caller and callee should have the same return types. If for example caller returns just u32 ? can't be used because there is no way to handle the None variant. This error will be caught by the compiler of course.

Conclusion

Option and Result are fundamental types in Rust. Their functionality might look overlapping but don't forget that the main difference between them is purely semantical. Communicating intent is very important in both small and big programs. Furthermore they force you to make error handling explicit. There is no way to forget to handle an Option or a Result because extracting the values from them requires handling.

Don't forget to have a look at the documentation of Result and Option and have a look at the convenience methods I have mentioned before. It's a good exercise to review them so that you know what you can do with the types.

And finally to learn more about error handling in Rust read Chapter 9 from the Rust book. Section 9.2 covers Result.

Comments

Comments powered by Disqus