Post

I Finally Get Rust

After years of half-hearted attempts, I finally decided to grit my teeth and force myself to learn Rust. (I found it pretty easy after I just found a small chunk of code I wanted to modify, rather than writing a program from scratch.) After an annoying initial hump, Rust finally clicked for me.

The biggest stumbling block for people learning Rust is almost unanimously the borrow checker. As someone (un)fortunate enough to have written enough C++ that I started agonizing over the same questions that Rust’s borrow checker solves, the system clicked with me more or less immediately. I also have a strong background in functional programming from undergrad at Northeastern, so the hardest part was really just digesting the control flow and standard library.

The thing I actually appreciate the most is the way Rust does null and error handling. It’s so elegant that I’m kind of shocked other strictly-typed languages don’t use the same model. Rust does its null handling with an enum called Option<T> and its error handling with an enum called Result<T, E>. (Just like in C++ and C#, the T and E between the angle brackets < > are type parameters for generic code.) Let’s dive in.

Option<T>

To start, null (or None, nil, etc. in other languages) isn’t really it’s own data type in Rust like it is in Python (None), C/C++ (NULL and nullptr), or Java (null). C# is at least polite enough to give the option to require data to be non-nullable by default, so you can distinguish between data of type T (never allowed to be null) and data of type T? (which can be null, but implicitly converts to type T after a null check with if).

Rust takes a different approach, where nullable data is represented as

1
2
3
4
enum Option<T> {
    None,
    Some(T),
}

where “no data” is None and valid data of type T is stored in the Some field. Rust’s type system requires null checking by making all the ways to go from Option<T> to T require handling the case where the Option is None, and provides plenty of methods to do that:

  • .unwrap_or(value), which gives the value in Some if it exists, or if the Option is None, gives the specified value of type T.
  • You can also do unwrap_or_else(function), which is the same as .unwrap_or, but gets the value from executing the function you give it
  • .expect("Custom error message"), if you are reasonably sure that the Option will never be None, which will panic with a custom error message if it ever actually is None
  • The basic .unwrap is the same thing as .expect, but which will complain with a more generic message if the Option is None
  • .or, which is a member of one the “or family” of boolean functions. Essentially, it allows you to chain together Options, like option1.or(option2).or(option3), and evaluates to the first successful Option. There’s also the “and family” of boolean functions, which can be chained like option1.and(option2).and(option3), and evaluates to the last option only if all Options are Some; otherwise, it evaluates to None.

You can also roll your own logic by using match on the Option:

1
2
3
4
match my_option {
    Some(x) =>  ... //(do something with x, which is automatically unwrapped by the match)
    None => ... // do something else
}

Result<T, E>

Result<T, E> is basically the same thing as an Option<T>, except instead of None, it holds an error of type E:

1
2
3
4
enum Result<T, E> {
   Ok(T),
   Err(E),
}

This is how Rust allows you to do error handling, and there are similar ways to handle it, like .unwrap, .expect, match, the boolean functions, etc, as well as functions like .map(function: T -> U), which works something like this:

1
2
3
4
match my_result {
    Ok(x) => function(x),
    Err(e) => e,
}

There are of course plenty of functional programming methods like .filter that do the same thing for a filter instead of a map, and so on.

While the case for Option<T> is pretty clear, it might not be obvious why Result<T, E> is worth using over another scheme. After all, if you can’t do error handling on a result and need it to bubble up before being handled, then you’re just stuck with

1
2
3
4
5
6
7
8
9
10
fn do_something() -> Result<T, E> {

    let my_result = other_function();

    let val = match my_result {
        Err(e) => return Err(e) // or panic, etc
        Ok(x) => x
    };
    // continue the function if Ok(x)
}

This will get tedious fast (just ask Go programmers); fortunately, if you have no intention of handling the error here, you can just write

1
2
3
4
5
fn do_something() -> Result<T, E> {

    let val = other_function()?;
    // continue the function if other_function is Ok
}

If other_function()’s Result is an error, then the question mark will propagate the error as a new error of type E. This basically gives you the same behavior of Python or C#, where exceptions just jump to the first place where they’re handled, but it forces you to be explicit about two things:

  1. The exact lines where an error might come up, which are marked with the ?
  2. The exact functions where an error might come up, because ? only works in functions that return Result

So this not only makes it easy to find places that can error by just searching for ?s, but it also requires that functions that can fail actually say so. I’ve written plenty of Java where a function decided to throw an error despite not being annotated with throws because Java only required checked exceptions to be annotated. Java code like

1
2
3
4
5
6
public void foo(T argument) { // no "throws IllegalArgumentException" annotation required!
    if (argument == null) {
        throw new IllegalArgumentException("argument cannot be null");
    }
    ...
}

can’t exit in Rust.

Conclusion

The larger picture here is basically Rust’s selling point: it requires you to be explicit, but it makes being explicit convenient. It doesn’t sweep any complexity under the rug like Go, which is also trying to move into C/C++’s space, has been accused of.

Python is the language that I have written the most code in by far, and the thing that I wanted the most from it is a serious type system that can provide static analysis and eliminate a lot of bugs before runtime. I’ve achieved something kinda close with mypy, but mypy is a third-party tool and can be frustratingly obtuse sometimes. The other language that I enjoy writing is C#, but even as much as it cuts down on object-oriented boilerplate that is so common in Java, object-oriented code is kind of inherently verbose.

Rust strikes a great balance, where it requires you to confront complexity, but actually seems interested in helping navigate that complexity. (Which is unlike C++ in almost every regard.) The error messages are sleek, the functional programming features are extraordinarily breezy and expressive, and Cargo is one of the best tools I’ve ever used. I understand why Rust couldn’t exist earlier — borrow checking wasn’t invented yet, and compilers only recently got to the level where borrow checking can even be done in a reasonable amount of time. But Rust exists now, and for that, I’m happy.

This post is licensed under CC BY 4.0 by the author.