Class 3: Enums

This week, we'll be exploring tagged unions, which are a common language feature among functional languages and are part of Rust under the name of enums. They allow us to define a type that can take on one of multiple possible variants.

Basic syntax

Let's start with a demonstration by defining a Shape enum that can be one of two possible variants: a Rectangle or a Circle:

enum Shape {
    Circle,
    Rectangle,
}

We can then use a match expression to writing code that branches based on which variant an enum is:

#![allow(unused)]
fn main() {
enum Shape {
    Circle,
    Rectangle,
}
let circle = Shape::Circle;

match circle {
    Shape::Circle => println!("Circle"),
    Shape::Rectangle => println!("Rectangle"),
}
}

Just like if else expressions, match is also an expression, and since expressions must always evaluate to something, the compiler will check that all possible things to match on are present. For example, the following won't compile because values of type Shape could also be Shape::Rectangle.

#![allow(unused)]
fn main() {
enum Shape {
    Circle,
    Rectangle,
}
let circle: Shape = Shape::Circle;

let what_shape: String = match circle {
    Shape::Circle => "circle".to_string(),
};
}

This is an example of Rust's strong type system, since it won't allow our program to compile unless all our bases are covered.

One strategy to overcome this is to add what's called a wildcard pattern, which is just an identifier or _. This is the equivalent of the else branch in a conditional expression, and it serves to match on anything that didn't get matched on above:

#![allow(unused)]
fn main() {
enum Shape {
    Circle,
    Rectangle,
}
let circle: Shape = Shape::Circle;

let what_shape: String = match circle {
    Shape::Circle => "circle".to_string(),
    _ => "not a circle".to_string(),
};
}

Stateful variants

We can also add data to each variant, either by making it a tuple variant or a struct variant. Tuple variants (like Circle below) have unnamed fields, while struct variants (like Rectangle below) have named fields. Although they have different syntax in some places, the actual semantics is essentially the same for tuple and struct variants.

enum Shape {
    Circle(f64),
    Rectangle {
        width: f64,
        height: f64,
    }
}

Now when matching, we can put identifiers to bind the data to:

#![allow(unused)]
fn main() {
enum Shape {
    Circle(f64),
    Rectangle {
        width: f64,
        height: f64,
    }
}
let circle = Shape::Circle(5.0);

match circle {
    Shape::Circle(radius) => {
        println!("Circle with radius {radius}");
    }
    Shape::Rectangle { width: w, height: h } => {
        println!("Rectangle that is {w} by {h}");
    }
}
}

When we say width: w, we're saying "assign the width field to a variable named w". However, Rust has some nice syntactic sugar to make this easier for us: if we instead wanted to assign the width field to a variable of the same name (so width), we can just write width once. For example, we could have written Shape::Rectangle { width, height } => ..., which would assign the width and height fields of the Rectangle variant to variables width and height respectively.

Enums are types

Lastly, it's important to remember that just like structs, enums are also just a type, meaning we can have different Shape variants be passed into functions expecting a Shape:

#![allow(unused)]
fn main() {
enum Shape {
    Circle {
        radius: f32,
    }
    Rectangle {
        width: f32,
        height: f32,
    },
}
let rect: Shape = Shape::Rectangle { width: 3.0, height: 4.0 };

let circle: Shape = Shape::Circle(5.0);

fn takes_shape(shape: Shape) {
    // do stuff
}

// valid
takes_shape(rect);
// also valid
takes_shape(circle);
}

Building on this idea further, we can also have impl blocks for enums, just like we did for structs in lab 2:

#![allow(unused)]
fn main() {
enum Shape {
    Circle(f64),
    Rectangle {
        width: f64,
        height: f64,
    },
}
impl Shape {
    fn area(&self) -> f64 {
        match self {
            Shape::Rectangle { width, height } => {
                width * height
            }
            Shape::Circle(radius) => {
                3.141 * radius * radius
            }
        }
    }
}

let circle: Shape = Shape::Circle(5.0);
let area = circle.area();
println!("Area: {}", area);
}

Now that we've seen how to use enums, let's look at how enums can be used to solve some major problems in software.

See Chapter 6.2 for more details.

Problem 1: Nullability

The concept of a "null" value is prolific across many languages:

  • Python None
  • C/C++ NULL/nullptr
  • Java null
  • Go nil
  • and many more.

In his 2009 presentation “Null References: The Billion Dollar Mistake,” Tony Hoare, the inventor of null, has this to say:

I call it my billion-dollar mistake. At that time, I was designing the first comprehensive type system for references in an object-oriented language. My goal was to ensure that all use of references should be absolutely safe, with checking performed automatically by the compiler. But I couldn’t resist the temptation to put in a null reference, simply because it was so easy to implement. This has led to innumerable errors, vulnerabilities, and system crashes, which have probably caused a billion dollars of pain and damage in the last forty years.

Consider all the times you've segfaulted or had a NullPointerException and had to put a check in. What if there was some way we could say "hey, this value might be null" and "here's a value, I guarantee you that it's not null" via the type system? A way where we are forced to check potentially null values, and where there is no need to check for guaranteed non null values?

Solution 1: Option

In Rust, we can do this using the Option enum, which is defined in the standard library.

It's definition is extremely simple:

#![allow(unused)]
fn main() {
// The `T` is a generic type, ignore for now.
enum Option<T> {
    // no value
    None,
    // just the `T` value
    Some(T),
}
}

To get an understanding for how it works, consider the following example:

#![allow(unused)]
fn main() {
fn divide(numerator: f32, denominator: f32) -> Option<f32> {
    // Check for div by zero
    if denominator == 0.0 {
        // We can't divide by zero, no float can be returned

        // The Rust prelude brings `Option::None` and `Option::Some` into scope,
        // so we can just say `None`
        None
    } else {
        // denominator is nonzero, we can do the operation
        let quotient: f32 = numerator / denominator;

        // Can't just return `quotient` because it's `f32`
        // Instead, we need to return an `Option<f32>` containing the quotient,
        // which we can do by wrapping `quotient` with `Some`.
        Some(quotient)
    }
}

let quotient: Option<f32> = divide(10.0, 2.0);
println!("{:?}", quotient);

let zero_div: Option<f32> = divide(10.0, 0.0);
println!("{:?}", zero_div);
}

This is great because for functions that might return nothing, we can make them return an Option. Without this check, a divide-by-zero will cause the program to panic, which is Rust terminology for crash with a report of what went wrong (depending on your configuration).

Even better, it means that Rust can guarantee that all references are valid. So if you have a greet function:

#![allow(unused)]
fn main() {
fn greet(name: &str) {
    println!("Hello, {}", name);
}

greet("Quinn");
}

You never have to worry about name being a null pointer. If you want nullability, use an Option instead:

#![allow(unused)]
fn main() {
fn greet(maybe_name: Option<&str>) {
    match maybe_name {
        Some(name) => println!("Hello, {}", name),
        None => println!("Who's there?"),
    }
}

greet(Some("William"));
greet(None);
}

Since enum variants must be checked via a match statement before access, this enforces that all optional values are checked, and that no non-optional values have to be checked because everything is encoded as part of the type system. Neat!

See Chapter 6.1 for more details.

Problem 2: Error handling

On a somewhat related topic, how do different languages go about error handling?

Error flags

The C programming language has a special errno, which is a static int that is set by certain syscalls. This can only carry a single integer though which has to be decoded by a special strerror function:

#include <stdio.h>
#include <errno.h>
#include <string.h>

// in static memory
extern int errno;

int main () {
    FILE *fp = fopen("file.txt", "r"); // writes to `errno` on failure
    if (fp == NULL) {
        printf("Value of errno: %d\n", errno);
        printf("Error opening file: %s\n", strerror(errno));
    } else {
        fclose(fp);
    }

    return 0;
}
Value of errno: 2
Error opening file: No such file or directory

This method is extremely minimal and fast, but clearly not scalable to larger programs. Furthermore, this does nothing to ensure that programmers actually check the errors.

Errors as exceptions

Although C++ has exceptions, they have a long and controversial history. At one point, functions could be marked with the throw annotation:

void do_something(int a, int b) throw (std::exception) {
    // ...
}

... but this has been deprecated since C++ 11, and we encourage you to take a quick look at A pragmatic Look at Exception Specifications which describes why at a high level. One of the first sentences summarizes the current state of C++ exceptions nicely:

The idea behind exception specifications is easy to understand: In a C++ program, unless otherwise specified, any function might conceivably emit any type of exception.

This obviously generates unnecessary machine code, and so C++ also has the noexcept keyword annotation which can enforce at compile time that a function won't throw anything. But for functions that are fallible, C++ programmers must live with the extra bloat of error handling, or come up with their own efficient way to support fallibility that is not a language feature.

Similarly to C++, Java uses exceptions to handle errors, except that skipping the throws annotation makes the program crash if an exception is unhandled instead of propagating it. This is better because it means that any exceptions that don't terminate the program must be annotated in the function signature, making it clear on what may need to be handled when calling a function, and the JVM can probably optimize this fairly well. However, it doesn't bring any new ideas to the table and is therefore mostly uninteresting to us.

The strength in the Java and C++ solutions is brevity: propagating errors is extremely simple because it is the default behavior. Perhaps a little too simple.

int do_operation(int i, float f, std::string s) {
    int a = foo(i, f); // does this throw?
    int b = bar(a, s); // this might throw too
    return more_stuff(b); // any of these could throw and you wouldn't know.
}

Is do_operation fallible, and if so, which function call might throw an exception? It's impossible to tell without consulting documentation or source code.

Errors as return values

Languages like Go switch things up by solving many of the previous problems we've seen. Instead of having exceptions be a special language feature, they are treated as ordinary values that a function may return. For example, the standard library function for opening a file has the following signature:

func Open(name string) (*File, error)

On success, it could return something like (handle, nil). On failure, it could return (nil, some_error). This means calling fallible functions in Go usually looks like the following:

f, err := os.Open("filename.ext")
if err != nil {
    // do something in the error case
}
// proceed as normal

This provides several advantages:

  • Less special language features, errors are values just like everything else.
  • Fallible functions are clear by their type signature.
  • Arbitrary data may be passed through errors.

The biggest downside is error propagation, which is now the opposite of implicit:

if err != nil {
    return nil, err
}

Even though this gives you maximum control over how to handle an error, the fact is that 95% of the time most programs just instantly propagate using the above code snippet, and this verbosity can quickly inflate the amount of code in a function.

On top of this, what would it mean for a function to return both a value and an error? This scenario doesn't make sense, but there's nothing stopping you from doing it, unlike how C++ and Java either return a value or throw an exception.

Solution 2: Result

Rust solves this problem with the Result enum, which is defined like the following:

#![allow(unused)]
fn main() {
enum Result<T, E> {
    // success
    Ok(T),
    // failure
    Err(E),
}
}

Note that, similar to how Go does fallibility, these variants have no special meaning to the compiler for the most part; they are just ordinary types, unlike how Java and C++ require exceptions to extend some base exception class. Since this approach is so general, there are some clever things we can do. For example, the standard library's binary_search function, which searches a sorted array for a particular value. Here is a simplified version of the function signature:

fn binary_search<T>(slice: &[T], x: T) -> Result<usize, usize>
{ Ok(0) }

The documentation explains the strange return type:

If the value is found then Result::Ok is returned, containing the index of the matching element. (...) If the value is not found then Result::Err is returned, containing the index where a matching element could be inserted while maintaining sorted order.

A more traditional example, however, is a simplified version of Rust's standard library function for reading a file to a string:

use std::io;
fn read_to_string(path: &str) -> Result<String, io::Error>
{ Ok(String::new()) }

A program might use this function like the following:

use std::{fs, io};

fn main() -> Result<(), io::Error> {
    let string: String = match fs::read_to_string("names.txt") {
        Ok(string) => string,
        Err(err) => return Err(err),
    };

    println!("{}", string);
    Ok(())
}

Woah woah woah. This is worse than Go's error propagation!

Yeah, but we have purity! Look how elegantly we're able to express that the function either succeeds or fails!

...

Propagation with the ? operator

The ? operator allows for error propagation without the verbose syntax. Check this out:

use std::{fs, io};

// the `Ok` value is `()`, the unit type.
fn main() -> Result<(), io::Error> {
    let string: String = fs::read_to_string("names.txt")?; // <-- here

    println!("{}", string);
    Ok(())
}

Adding ? after an expression that has type Result in a function that returns a Result will extract the Ok value, or short circuit from the function and return the Err value. It's (nearly) equivalent to writing the following instead:

use std::{fs, io};

// the `Ok` value is `()`, the unit type.
fn main() -> Result<(), io::Error> {
    let string: String = match fs::read_to_string("names.txt") {
        Ok(string) => string,
        Err(e) return Err(e),
    };

    println!("{}", string);
    Ok(())
}

This also works for function that return Option:

fn sum_first_two(vec: &[i32]) -> Option<i32> {
    // `.get(x)` might return `None` if the vec is empty.
    // If either of these `.get(x)`s fail, the function will
    // short circuit and return `None`.
    Some(vec.get(0)? + vec.get(1)?)
}

See Chapter 9.2 for more details.

Errors as enums

This leads us to another prime example of where Rust enums shine: error values themselves. As we saw in earlier, Java requires the throws keyword followed by a sequence of exception types to denote that one of those exceptions can be thrown. The key words here are "one of", as this encapsulates an enum perfectly. It's extremely common to define Rust enums that represent kinds of things that can go wrong in a program.

For example, say we want to read a file, parse an i32 from the contents, and then return that. There are two things that could go wrong:

  • We might have trouble reading from the file.
  • The contents might not be parsable as an i32.

Let's define our error type first:

#![allow(unused)]
fn main() {
use std::{io, num};

enum Error {
    Io(io::Error),
    Parse(num::ParseIntError),
}
}

Here, we've chosen to store the io::Error and num::ParseIntError.

Then, we can write our function:

#![allow(unused)]
fn main() {
enum Error {
    Io(io::Error),
    Parse(num::ParseIntError),
}
use std::{fs, io, num};

fn open_and_parse_file(file: &str) -> Result<i32, Error> {
    let contents = match fs::read_to_string(file) {
        Ok(contents) => contents,
        Err(err) => return Err(Error::Io(err)),
    };

    let num = match contents.trim().parse() {
        Ok(num) => num,
        Err(err) => return Err(Error::Parse(err)),
    }

    Ok(num)
}
}

Hey, what happened to using the ? operator?

The ? operator (continued)

In this example, fs::read_to_string would propagate io::Error, and str::parse would propagate ParseIntError. The issue is that our function expects the Err variant of the Result to be our custom Error type, which is neither of these.

The ? operator won't work because these are all different types, but we can tell it how to convert from these types into our Error type using the From trait. Traits are the topic for next week so we won't explore too much into how they work now, but the core idea is that they allow us to define interfaces, and the From trait will allow us to tell the compiler how to convert from these other types into our Error type.

For converting an io::Error to our own Error, we can add the following:

#![allow(unused)]
fn main() {
use std::{io, num};
enum Error {
    Io(io::Error),
    Parse(num::ParseIntError),
}
// Implement the trait telling Rust how to get `Error` from an `io::Error`.
impl From<io::Error> for Error {
    // This trait has 1 method that we're required to implement
    fn from(value: io::Error) -> Error {
        // We made the `Io` variant to represent `io::Error`s, so
        // we can just create one here. Easy!
        Error::Io(value)
    }
}

impl From<num::ParseIntError> for Error {
    fn from(value: num::ParseIntError) -> Error {
        Error::Parse(value)
    }
}
}

Now we can write the function again, but cleanly:

#![allow(unused)]
fn main() {
use std::{fs, io, num};
enum Error {
    Io(io::Error),
    ParseInt,
}
impl From<io::Error> for Error {
    fn from(value: io::Error) -> Error {
        Error::Io(value)
    }
}
impl From<num::ParseIntError> for Error {
    fn from(_value: ParseIntError) -> Error {
        Error::ParseInt
    }
}
fn open_and_parse_file(file: &str) -> Result<i32, Error> {
    let content = fs::read_to_string(file)?;

    let num = content.trim().parse()?;

    Ok(num)
}
}

See the documentation for more examples.

These tools allow Rust programmers to both be extremely precise while also having the choice between explicit error handling and minimal error propagation, and will be helpful to understand for completing the next lab.

~

Summary

  • Rust enums are types that can be one of several variants which may contain different types.
  • We use match statements to determine which variant an enum is.
  • The problem of null pointers and references can be solved with enums like Option<T>.
  • Different languages have their own ways to mark a function as "fallible", Rust has the Result<T, E> enum.
  • The ? operator can be used to propagate errors with minimal syntactic overhead.
  • Enums are excellent for representing possible kinds of errors.
  • The ? operator can perform implicit conversion using the From<T> trait.

See Chapter 6 and Chapter 9.2 for more details.