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:
Quithas no data associated with it at all.Movehas named fields, like a struct does.Writeincludes a singleString.ChangeColorincludes threei32values.
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 valueT. 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
Optiontype: the compiler can’t infer the type that the correspondingSomevariant will hold by looking only at aNonevalue. Here, we tell Rust that we mean forabsent_numberto be of typeOption<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 nonononoAll 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()
}matchkeyword- 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_maxpattern isSome(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 letas syntax sugar for amatchthat 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:
- On line 2, the
let elsetries assignstatea value from the expression to the right of=. - If it’s a hit, then it binds the value of
cointostate, otherwise theelseblock is executed and returns aNone. - The bound value can then be used in that local scope, the
describe_state_quarterfunction 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
Some(T): Indicates that a value of type T is present.None: Indicates that no value is present.
Use Cases
- Functions that might not return a value (e.g., searching for an element in a collection that might not exist).
- Optional fields in structs.
- 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
- Ok(T): Indicates a successful operation, containing a success value of type T.
- Err(E): Indicates a failed operation, containing an error value of type E.
Use Cases
- Functions that might encounter errors during execution (e.g., file I/O operations, network requests).
- Operations where the reason for failure needs to be explicitly communicated through an error type.
Summary
They are appropriate for different scenarios.
Option::Nonedoesn’t convey information, because nothing can be bound to theNonevariant. So it’s not great for explaining why something’s absent or giving a stack trace…Result::Err(e)does allow binding a valueeto theErrvariant, allowing error messages or stack traces to propagate up the chain and be checked where needed.