Skip to main content

· 5 min read

Being embedded on a large Rust team, I've witnessed many experienced programmers' first touch points with Rust (myself included). From this vantage point, a few common stumbling blocks stand out: Rust's memory model is the obvious one. Being novel and unique, it deservedly gets a lot of airtime. However, if you've only spent your time in imperative languages (i.e. the ones that usually come with a paycheck) the Option type is unfamiliar and causes many fumbles.

This article assumes no prior knowledge and is good for newcomers and language tourists. I'll try to build up your intuition for how Option works in a head-first way (i.e. pictures)! A follow-up article for intermediate rust developers will explain the many different strategies for writing idiomatic option-handling code.

The Option Type

If you're unfamiliar, Option<T> is essentially what you get in the absence of null (or nil as some know it).

The problem with null

In programming lore, null is sometimes referred to as the billion-dollar mistake. The first time I heard this, the notion that a programming language could do anything about it was wild. Sure, we all know the pain of crash reports containing null reference exceptions. But any meaningful program will always have resources that are either uninitialised or disposed. To pretend otherwise would surely make for some boring applications?

With use, I began to understand that a Rust program isn't pretending - it will have just as many resources in just as many possible states. However, the key difference is how Rust lets you access those resources. It goes out of its way to strictly guarantee you know which state it's in before you can do anything with it.

So the problem isn't that things can be null. It's that your binding to them can be null. If that makes you squint, let's instead imagine a cat:

class Cat {} 

var x : Cat = new Cat();
var y : Cat = null;

We easily understand that x exists and y doesn’t. But at any moment it’s possible for x to transition to an ambiguous existence:

var x : Cat = Cat.new();
x = null;
x.meow(); //Trouble!

Obviously, we're good programmers, and we'll perform null checks to prevent exceptions. However, in a large code base, or the presence of threads, it can be hard for even the paranoid to defend against. For now, this is all we need to know, but there are further arguments against null too.

So what's the alternative?

Schrödinger's Optional Cat

Option (also known as Maybe in some parts) is a functional programming concept:

pub enum Option<T> {
Some(T),
None,
}

As you can see, the type is actually very simple - it's just an enum. In rust, enums are special: variants can have associated data (i.e. only Some has a T). When we resolve the generic placeholder T with our Cat this becomes more obvious:

pub enum Option<Cat> {
Some(Cat),
None,
}

Option acts as a container or package, and until you open it - you don't know what's inside:

A subtle but important difference from Schrödinger's famous box is that None doesn't contain a dead cat. It's simply "empty" (cat lovers rejoice). This is an important clue as to how Rust prevents you from binding to any dead cats.

Our original example now looks like this:

struct Cat;

let x : Option<Cat> = Some(Cat);
let y : Option<Cat> = None;

Again, intuitively we know x has a cat while y is empty. But, crucially, our code can’t know this. All it sees are two packages, both of which might contain a cat.

In order to peer inside, we first need to "open" it:

let maybe_cat : Option<Cat> = x;

if let Some(cat) = maybe_cat {
cat.meow();
} else {
println!("The cat escaped!");
}

if let assigns the variable cat to the contents of the package, but only when the surrounding clause matches, i.e. we are in the Some(cat) state. For the duration of the code block, we now have a reference to our cat that we know is valid.

The cat binding no longer exists exists once the code block is finished, any attempts to use it again will earn you a compile error instead.

We now have enough to understand the billion-dollar idea. The compiler sees Option<Cat> and Cat as two distinct and independent types. By making Option responsible for "does this thing exist", it means any references to the thing itself are guaranteed to be valid.

To drive home the example, you can never accidentally "nullify" a Cat because the compiler makes it impossible:

let x : Cat = None; //Compiler error!

error[E0308]: mismatched types
--> src\main.rs:12:18
|
| let x: Cat = None;
| --- ^^^^ expected struct `Cat`, found enum `Option`

The compiler literally doesn't understand what you're on about. Instead, we can only combine like with like:

let x : Option<Cat> = None; 

Summary

In essence, we've created a version of null that's impossible to use without a corresponding null check.

And just like that, Option vanquishes an entire category of errors!