Rust error handling is perfect actually

Rust error handling is perfect actually

Things are always going wrong, as I’m sure you’ve noticed, and that applies to our programs too. Sometimes when a function call asks a question, there’s no answer to return—either because some error happened, or because the correct answer is, simply, “no results”.

When there’s no answer

So, as a matter of good API design, what should we do in these cases? For example, suppose we’re trying to write a function first, that returns the first element of a given list. If there’s at least one element in the list, there’s no problem. But what if the list is empty?

In my book The Secrets of Rust: Tools, I show you how to use the built-in types Option and Result to design friendly, easy-to-use, idiomatic Rust APIs. It turns out that practically every function we ever write in Rust either uses or produces Option and Result values, so they’re important to know about. Let’s take a look.

In our “first element of an empty list” example, the Rustaceous way to solve this problem is to have the function return an Option of whatever the list element type is:

fn first(list: &[i32]) -> Option<i32> {

Options

As you probably guessed, an Option type indicates that there may or may not be an answer. The return value from this function can be one of two things—two variants. It can either be None, meaning “no data to return”, or it can be Some(x), meaning “the result is x”.

It’s now up to the caller to decide what to do. We could use a match expression to check whether or not there’s an answer:

match first(&list) {
    Some(x) => println!("The first element is {x}"),
    None => println!("No result"),
}

if let expressions

Commonly, though, we just want to do something if the option is Some, but nothing otherwise. It would be annoying to write an empty match arm for the None case, and fortunately we don’t have to.

Instead, we can use an if let expression to execute some code only if the option is Some, and otherwise just continue:

if let Some(x) = first(&list) {
    println!("The first element is {x}")
}
// moving on...

Conversely, if we want to do something (like bail out of the function) when the option is None, we can use the let ... else form:

let Some(x) = first(&list) else {
    println!("Looks like the list is empty!");
    return
}
// do something with `x`...

The ? operator

Sometimes if the answer is None, there’s nothing else useful we can do, so it’s best to just return from the function straight away. We could do this explicitly with match or if let, but there’s a better way.

We can simply propagate the None value back to our caller, by appending the question mark operator (?) to it:

fn first_plus_1(list: &[i32]) -> Option<i32> {
    Some(first(list)? + 1)
}

Here, if the value of first(list) is Some, then we add 1 to it and return the answer as Some. On the other hand, if first(list) instead returns None, then the ? operator short-circuits this function and automatically returns None as its answer. Neat!

unwrap / expect

If we can pretty much guarantee, because of the program’s internal logic, that there must be Some answer, we can enforce that using the unwrap method:

fn first_plus_1_or_die(list: &[i32]) -> i32 {
    first(list).unwrap() + 1 // panics if list is empty
}

Calling unwrap is a big move, though. It means the program will crash with a very rude error if first(list) is ever None:

thread 'main' panicked at src/main.rs:10:17:
called `Option::unwrap()` on a `None` value

Ouch! We can make that error message slightly more informative by using expect instead of unwrap:

first(list).expect("list should not be empty")

The name is a little confusing: expect doesn’t mean “expect the result to be this string”, it means “if the result is None, panic with this message”.

But, since good programs don’t panic, and neither do good programmers, it’s very rare that using unwrap or expect is actually the right thing to do. Usually, we should either use match and handle the None case explicitly, or propagate the Option using ?.

Results

As handy as Option is for signalling when there’s no answer, it doesn’t give us any way to tell the caller why there isn’t an answer. With a function like first, it’s fairly obvious, so we don’t need to explain. But with a function that can fail for many different reasons, it’s useful to be able to distinguish between them.

That’s where Rust’s Result type comes in. Just like Option, a Result can be one of two possible variants. It can be either Ok(x), meaning “the answer is x”, or it can be Err(e), meaning “couldn’t get the answer because error e happened”.

Here’s how we might define a function that returns a Result:

fn sqrt(n: f64) -> Result<f64, String> {
    if n >= 0.0 {
        Ok(n.sqrt())
    } else {
        Err("can't take square root of negative number".into())
    }
}

Handling Result values

Again, we can deal explicitly with the two possibilities using a match expression:

match sqrt(-5.7) {
    Ok(x) => println!("The answer is {x}"),
    Err(e) => println!("Whoops: {e}"),
}

Or we can use ? to propagate any error back to our own caller:

let answer = sqrt(9.0)?;

Here, if the result is Ok, then we assign the answer to answer and continue. If it’s Err, though, the ? operator causes this function to return the error, provided that its return type is also some kind of Result.

Error-only results

Sometimes the function’s job is just to do something, so there’s no actual answer. But maybe there can still be an error, so in that case we’d use a Result where the Ok variant doesn’t contain anything:

fn print_sqrt(x: i32) -> Result<(), String> {
    let answer = sqrt(x)?;
    println!("{answer}");
    Ok(())
}

The Rust type () just means, in effect, “nothing goes here”. So in this example the print_sqrt function either returns Ok(()), meaning “everything went fine”, or, implicitly, some string indicating an error (“can’t take square root of negative number”).

Optionality and resultitude

Some languages let you ignore possible errors altogether, automatically propagating them as exceptions, and crashing the program if they’re not handled somewhere. Other languages, like Go, make error handling explicit, at the expense of a certain amount of boilerplate code to check and handle errors everywhere they can happen.

Rust’s solution, on the other hand, is rather elegant. Returning a single Option or Result from a function indicates that the answer can be “no data”, or an error. That “optionality” or “resultitude”, if you like, is part of the answer, and it can be passed around our program from place to place, or stored and retrieved, right along with the data it applies to.

Sooner or later, we’ll want to extract the actual answer, if present, and that’s the point where we have to deal with the possibilities of errors or non-answers. Rust gives us the choice to deal with it right away, or defer it for later, but we have to confront the issue at some point in our program. We can’t just ignore it and hope for the best.

Type checking is better than hope

And, since Option and Result are distinct types, Rust can detect at compile time when we’re failing to properly address issues of optionality and resultitude.

If you try to use an Option<i32> as though it were a plain old i32 value, for example, Rust will swiftly puncture your unwarranted optimism:

let answer: i32 = first(&list);
            ---   ^^^^^^^^^^^^ expected `i32`, found `Option<i32>`

Which is completely reasonable, and we know what to do instead: use match or ? to deal with the None case, just as we do with Result values.

The fact that Rust can catch issues of forgetfulness like this for us is helpful, and the Option and Result types are a very appealing feature of the language.

In practice, a lot of our code will be about handling errors and “no data” situations, so having dedicated types and the ? shorthand to deal with them is a real boost to programmer happiness. Here’s to Rust!


And you can read more about it in my early access book, The Secrets of Rust: Tools!

 
$34.95
Add To Cart
 
Shameless green: TDD in Go

Shameless green: TDD in Go

Cryptography in Go: AES implementation

Cryptography in Go: AES implementation

0