Intro

The RustEnum aka Enumerations allow us to define types by defining the possible variants it can be, i, e, enumerating these variants. Think of these enumerations as possible values this Enum type can be, a common example is seasons of the year, colors, etc.

Rust has an important Enum type known as Option, if you’ve done some FunctionalProgramming you’ll likely recognize this, and we’ll be exploring how it’s used in Rust with match expressions and the if let construct.

Definition

Similar to how a Struct allows the grouping of related data and methods together, an Enum allows us to define a set of values the type can be, and it can only be one of the values in this set.

To define an Enum:

enum Meal{
	Pizza,
	Bazeen,
	Couscous,
	Dolma,
}

To create an instance:

let item_two = Meal::Bazeen;

Since the variants are namespaced under Menu we can define functions that take Menu Enums as parameters. Example: fn make_order(order: Menu){}

Combining Enums with Structs

You can use an enum as a field type for a Struct.

let item_two = Meal::Bazeen;
 
struct Order{
        item: Meal,
        quantity: i32,
        mods: String,
}
 
let ord_one = Order {
        item: Meal::Couscous,
        quantity: 2,
 
        mods: String::from("Make it spicy"),
};

Enums with field types

Besides defining a variant, we can associate that variant with another value, say you want every Meal to also have a description…

We define this like so:

enum Meal{
	Pizza(String),
	Bazeen(String),
	//...
}
 
 
let my_order = Meal::Pizza(String::from("Traditional Neapolitan Margherita pizza with buffalo mozzarella and basil."));

Here’s another example:

    enum IpAddr {
        V4(u8, u8, u8, u8),
        V6(String),
    }
 
    let home = IpAddr::V4(127, 0, 0, 1);
 
    let loopback = IpAddr::V6(String::from("::1"));

This guarantees that all V4 IP addresses will have four u8 values which means each will have a range of [0, 255].

Different types per variant

I imagine that when designing a function that takes an Enum as a param, you must know what each variant’s associated with, to handle all the different types and combinations of them…

// Interestingly, representing IP addresses using Enums is so common that the Std library has a definition built in already.

Another example:

enum Message {
    Quit,
    Move { x: i32, y: i32 },
    Write(String),
    ChangeColor(i32, i32, i32),
}

Message has four variants with different types:

  • Quit has no data associated with it at all.
  • Move has named fields, like a struct does.
  • Write includes a single String.
  • ChangeColor includes three i32 values.

So why not use Structs? Because if we do, each different Struct is its own type, whereas here they’re all the same type and as such it’s easy to define functions to work on these variants.

Defining methods

Similar to Structs and their methods, Enums can also have methods defined on them using impl.

impl Meal{
	fn eat(&self){
		// eat the meal...
	}
}
 
let pizza_slice = Meal::Pizza("Quattro formaggi");
pizza_slice.eat();

The Option Enum

An important Enum in the Std library that’s used to encode the scenario when a value is something or nothing. Great for when an expression might return an error instead of the expected value. E.g. requesting the first value in a list; if it’s non-empty that’s fine but what if it empty…

Rust and no null

Unlike other languages, Rust doesn’t have a null value, this is to prevent the chance that a null value is ever used as a non-null value, leading to errors. It’s SO easy to make this error, even as a seasoned developer.

So how does Rust represent an invalid or absent value? using the Option enum!

Definition

This Enum encodes the concept of a value representing something or nothing using two variants.

enum Option<T> {
	None, 
	Some(T),
}
  • None : this is NOT a null-like value it’s just an enum variant.
  • Some(T) : also a variant, but one that’s associated with the value T. which is generic here.

The importance of the Option Enum is why it’s included in the prelude without even bringing it into scope explicitly, it’s a core feature. We can even use the Option Enum directly to define variables:

let some_number = Some(5);
let some_char = Some('e');
 
let absent_number: Option<i32> = None;

“Rust requires us to annotate the overall Option type: the compiler can’t infer the type that the corresponding Some variant will hold by looking only at a None value. Here, we tell Rust that we mean for absent_number to be of type Option<i32>

So why’s Option<T> better than T if it can be None ? Because in the compiler’s eyes, these two are different, T can be anything, and it won’t let us use Option<T> as if it’s definitely a valid value, we need to make sure and check.

The following example code will not pass the compiler. Try it!

let x: i8: 5;
let y: Option<i8> = None;
 
let sum = x + y; // This won't fly nononono

All this eliminates the chance of using null values, because you NEED to unpack the Option type to get the value and when doing so you check if it’s a valid value before using it. It’s best to have code that’ll handle each variant, whether it’s Some<T> or None and then even based on the T you handle the cases differently…

// Become robust.

Match Control Flow

The match construct allows programmers to compare the value being matched against several different patterns and then execute code based on the pattern it matches. ==// Like a Switch statement, but better.==

Match Definition

The match construct syntax is as follows:

match expression{
		pattern_one => exec_block_one(),
		_ => default()
}
  • match keyword
  • expression
  • match arms featuring patterns and blocks to execute if a hit, the resulting value is returned if there’s any.
  • default case

For example:

enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter,
}
 
fn value_in_cents(coin: Coin) -> u8 {
    match coin {
        Coin::Penny => 1,
        Coin::Nickel => 5,
        Coin::Dime => 10,
        Coin::Quarter => 25,
    }
}

A match arm can have a scope using {} but that’s often omitted if it’s a short one liner.

Matching with Option<T>

We can use match to unpack the Option Enum while checking its value, thus handling all variants.

Example

fn plus_one(x: Option<i32>) -> Option<i32> {
	match x {
		None => None,
		Some(i) => Some(i + 1),
	}
}
 
let five = Some(5);
let six = plus_one(five);
let none = plus_one(None);

Exhaustiveness

A Match expression should cover all patterns possible from the given expression. The compiler will be your friend here, in case you forget a pattern, it’ll point that out and even suggest what’s missing. However, it’s on you to think of these patterns, the same way you’d think of edge cases.

Default catch-all

In the Match Definition example, I included a default arm using _ => { // do something } this serves as a catch-all for cases that don’t match the other arms, so it’s great for a default case to handle the unexpected.

This catch-all pattern meets the requirement that match must be exhaustive, and it’s placed last because the arms are evaluated sequentially so this ensures the other arms are checked before settling on this default. Rust will warn you about this.

When we don’t want to use the default’s value we can omit it entirely and use a placeholder _ which is what I did in the mentioned example. It’s a special pattern that doesn’t bind to a value, telling Rust we’re not using it.

Control Flow with If let and let else

Sometimes when using match we only care about a single pattern and delegate the rest to the catch-all. Writing this boilerplate for a single case becomes tedious and annoying.

If let

Instead we can use the if let syntax to take a pattern and expression and works the same as a single arm match.

    let config_max = Some(3u8);
    if let Some(max) = config_max {
        println!("The maximum is configured to be {max}");
    }

Breakdown:

  • Notice here the pattern precedes the expression and are separated by =.
  • If the expression config_max pattern is Some(max) it executes the block.
  • The value pattern’s value, Some(max), which is bound to it, can only be used in the block.

The benefits are that this syntax is less verbose, less indented, and faster to write. However you do lose the exhaustiveness, It depends on what you’re doing and what you need…

“In other words, you can think of if let as syntax sugar for a match that runs code when the value matches one pattern and then ignores all other values.”

Adding else

Like any if statement, the if let can have an else block that handles all other cases the way the _ does in a match.

Here’s a comparison between both expression: Match

    let mut count = 0;
    match coin {
        Coin::Quarter(state) => println!("State quarter from {state:?}!"),
        _ => count += 1,
    }

Or use an if let and else expression, like this: if let-else

    let mut count = 0;
    if let Coin::Quarter(state) = coin {
        println!("State quarter from {state:?}!");
    } else {
        count += 1;
    }

let else

If let else can end being another ugly pattern that in some cases is also hard to read.

Instead, If the pattern matches, it will bind the value from the pattern in the outer scope. If the pattern does not match, the program will flow into the else arm, which must return from the function.

Instead, we can use let else to check an expression, if it matches the left side, then it’s assigned to the variable, otherwise it executes the else block.

With the let else expression takes a pattern on the left side and an expression on the right, similar to if let, but not if.

Example:

fn describe_state_quarter(coin: Coin) -> Option<String> {
    let Coin::Quarter(state) = coin else {
        return None;
    };
 
    if state.existed_in(1900) {
        Some(format!("{state:?} is pretty old, for America!"))
    } else {
        Some(format!("{state:?} is relatively new."))
    }
}

Breakdown:

  1. On line 2, the let else tries assign state a value from the expression to the right of =.
  2. If it’s a hit, then it binds the value of coin to state, otherwise the else block is executed and returns a None.
  3. The bound value can then be used in that local scope, the describe_state_quarter function in this example.

Option vs Result

A quick comparison between the two Enums, as sometimes it may be slightly confusing which to use in a given situation.

Option<T>

Purpose

Represents the possibility of a value being present or absent.

Variants

  1. Some(T): Indicates that a value of type T is present.
  2. None: Indicates that no value is present.

Use Cases

  1. Functions that might not return a value (e.g., searching for an element in a collection that might not exist).
  2. Optional fields in structs.
  3. Representing the absence of a value where None is not considered an error condition.

Result<T, E>

Purpose

Represents the outcome of an operation that can either succeed or fail.

Variants

  1. Ok(T): Indicates a successful operation, containing a success value of type T.
  2. Err(E): Indicates a failed operation, containing an error value of type E.

Use Cases

  1. Functions that might encounter errors during execution (e.g., file I/O operations, network requests).
  2. Operations where the reason for failure needs to be explicitly communicated through an error type.

Summary

They are appropriate for different scenarios.

  • Option::None doesn’t convey information, because nothing can be bound to the None variant. So it’s not great for explaining why something’s absent or giving a stack trace…
  • Result::Err(e) does allow binding a value e to the Err variant, allowing error messages or stack traces to propagate up the chain and be checked where needed.