Error Handling in Rust

I’ve been doing some programming in Rust recently. First was a flake generator and then the second is a rudimentary implementation of the ls command (dir to Windows people) – I submitted a pull request to uutils/coreutils: a replacement for the UNIX coreutils suite of software. Error handling plays a big role in both of these chunks of code – making sure users don’t get a nasty surprise requires making sure you let them know when things go wrong.

Handle this error, program!

Handle this error, program!

Result – When Something Could Go Wrong

Rust has a special type for handling situations when something could go wrong. Functions that might experience some kind of programmatic head trauma should return a Result.

Typically, in something like C#, you’d use try/catch programming to make sure than any exceptions are caught by your logic. Exceptions, though, are kind of heavy things – they carry around a stack dump with them; even if you don’t use that stack dump, it’s still there. Like all of your code’s emotional baggage.

A Result lets use either say something is Ok or that an error occurred. You can see an example of how this would work in the flaker update function. The relevant lines, though, look like:

fn update(&mut self) -> Result<(), FlakeError> {
    let current_time_in_ms = Flaker::current_time_in_ms();
    if self.last_generated_time_ms > current_time_in_ms {
        println!("\t{} > {}", self.last_generated_time_ms, current_time_in_ms);
        return Result::Err(FlakeError::ClockIsRunningBackwards);
    }
    if self.last_generated_time_ms < current_time_in_ms {
        self.counter = 0;
    } else {
        self.counter += 1;
    }
    self.last_generated_time_ms = current_time_in_ms;
    Ok(())
}

The first line of the function definition says we can return a Result<(), FlakeError> – either everything is Ok() or we’ve encountered some kind of error. This error is exceptional, but it’s not so exceptional as to make someone panic!(), that’ll come later.

Lines 3 – 6 are our error handling – if the clock has gone backwards for some reason, we want to abort and let the user know why. In this case, we send a specific kind of error that tells the user the clock is running backwards. And, really, this isn’t exceptional – there’s a good chance your computers do this from time to time.

If there’s no error, we return Ok(()) – which is just the Ok value of Result wrapped around an empty tuple (). Don’t worry about it, just pretend the double parens are hugs.

At this point, we’ve got a function that safely tells us there’s an error or tells us everything’s OK. How do we handle that?

pub fn get_id(&mut self) -> Result<BigUint, FlakeError> {
    let update_result = self.update();
    match update_result {
        Ok(_) => Ok(self.construct_id()),
        Err(err) => Err(err),
    }
}

The get_id function is what gives us a new flake identifier. It returns a Result<BigUint, FlakeError>. The Result type contains an everything’s OK or an error; in our case we’ll either get a large unsigned number or a FlakeError. How do we work with that output?

Basically, we check and see if things were either Ok or a problem and then take the right action. If everything’s Ok (we updated our internal clock), we go ahead and construct a new ID and return it to the user . If things aren’t Ok (the clock is moving backward), we bubble the error up as our return value.

Result<T, E> lets us avoid having to write tedious try/catch statements and, instead, we can just poke in and check on the returned type. If it’s some kind of Ok, we can move forward. Otherwise, if it’s an Err, we can do something about it. The whole idea is that our program isn’t exploding whenever there’s a bad operation – we, the programmers, get to decide how to deal with the result of a function.

Result expresses the possibility of an error and respond accordingly, rather than litter code with try/catch blocks.

Result – Bonus Round

Thanks to /u/masklinn on Reddit, I was made aware of a more concise way to write our error handling. The get_id function can end up looking like this:

pub fn get_id(&mut self) -> Result<BigUint, FlakeError> {
    self.update().map(|_| self.construct_id())
}

While it looks like we’ve removed the error handling, we haven’t. Result’s map function applies a function to the value inside the Result‘s Ok value. If there’s an Err instead… the error bubbles on up. Because the error handling is so simple in this case, the error is just passed up the chain, it’s safe to use map in this instance. It won’t work everywhere, but where it does work, it can simply the code flow.

Option – Dealing with Nothing

Result expresses the possibility of error, while Option expresses the possibility of nothing.

Veteran developers are used to checking for null return values from functions. In C#, we have access to nullable types, and developers can write chunks of code that deal with the possibility of null:

int? num = null;
if (num.HasValue) {
    // fix it
} else {
    // do something
}

Rust lets us do something similar using the Option<T> type. Option is used when we might not get a result back. It’s like C#’s nullable types that way.

An Option contains either something or nothing, it’s up to us to check it. This is part of Rust’s philosophy of safety – Rust will do its best to behave safely and it’s up to the programmer to do what they want to do with that safe result.

The Option is either Some and contains a value or it’s None and does not contain a value. This is easy enough for developers to work with – just like we used match earlier in this post, we can use match to work with an Option.

use std::str::FromStr;

fn parse_name<T: FromStr>(s: &str, separator: char) -> Option<(T, T)> {
    match s.find(separator) {
        None => None,
        Some(index) => {
            match (T::from_str(&s[..index]), T::from_str(&s[index + 1..])) {
                (Ok(l), Ok(r)) => Some((l, r)),
                _ => None
            }
        }
    }
}

fn main() {
    match parse_name::<String>("Jeremiah Peschka", ' ') {
        Some(t) => println!("name parts are {} {}", t.0, t.1),
        None    => println!("Name has no spaces, what's up with that?"),
    }
}

First up – parse_name – it takes a name (s) as a string-ish thing, a single character separator, and returns an Option that could contain two things are string-ish <T: FromStr>. In several places, we check for potential errors (a missing separator character, or not getting back a pair of string-ish things) and return None if we don’t have valid data. If we do have valid data, then we send back Some containing a tuple with both strings.

What’s this buy us?

By using Option, we’re not likely to assign a null value to a valid chunk of data. This pattern can also cut down on error handling code in our application code which further reduces the likelihood of the wrong data getting moved around inside the program.

unwrap() – Pretending that results are gifts

When you’re first writing Rust code, handling all of those potential errors seamlessly can become a chore, especially when you’re writing a blog post or putting together throwaway code to test an idea. We can use unwrap() when working with an Option to get at the value stored inside. It’s really easy, check it out. In our previous example, we can omit the match and just try to do something with my name:

fn main() {
    let name_parts = parse_name::<String>("Jeremiah Peschka", ' ').unwrap();
 
    println!("name parts are '{}' and '{}'", name_parts.0, name_parts.1);
}

And that’s great! It works, as long as the name being parsed contains the separator. What if we change the separator to something else? Changing the space to a comma ends up giving us an error!

fn main() {
    let name_parts = parse_name::<String>("Jeremiah Peschka", ',').unwrap();
 
    println!("name parts are '{}' and '{}'", name_parts.0, name_parts.1);
}

This compiles and when executed we get:

thread '<main>' panicked at 'called `Option::unwrap()` on a `None` value', ../src/libcore/option.rs:330

Well, that didn’t work at all! The problem is that unwrap() attempts to open the Some value and if it’s not present, it panics. (A panic is used in Rust to indicate a serious bug in a program – your disks have gone missing, the network card is on fire, etc.)

Unwrap can be helpful when you’re writing sample code or when you want to simplify code for a newcomer, but it’s better to check your results the majority of the time.

There’s another option, though – what if we want to supply a default value if no value is supplied? There’s an unwrap_or function that we can use!

fn main() {
    let default = (String::from("Big Old"), String::from("Dummy Head"));
    let name_parts = parse_name::<String>("Jeremiah Peschka", ',').unwrap_or(default);
 
    println!("name parts are '{}' and '{}'", name_parts.0, name_parts.1);
}

This produces the output:

name parts are 'Big Old' and 'Dummy Head'

Well, look at that, I’m a big old dummy head.

Unwrapping up

Rust has a lot of options for safely handling errors in a program. Option is helpful when we don’t want to worry about null values, Result is helpful when an action might fail, and we can use unwrap() when we just want success or an explosion. There’s more to Rust’s error handling, but these basics should get you a lot of the way through your first programs.

If you’re interested in digging into more, the Rust book goes into great detail in the Error Handling chapter.


Error” by Nick Webb is licensed with CC BY 2.0

 

Leave a Reply

Your email address will not be published. Required fields are marked *

Fill out this field
Fill out this field
Please enter a valid email address.

This site uses Akismet to reduce spam. Learn how your comment data is processed.

Menu