Mastering Enums and Pattern Matching in Rust
Published: Feb 11, 2024
Introduction to Enums in Rust
Enums are a powerful feature in Rust that allow you to define custom data types to represent a fixed set of possible values. Think of them like supercharged version of enums in other languages - they can store data and behavior associated with each variant.
In this section we’ll learn:
- How to define and use basic enums in Rust
- Different ways to associate data with enum variants
- How pattern matching makes working with enums easy
- How enums relate to other major features like structs and error handling
So let’s dive in and see how enums can help you write clean, scalable Rust programs!
Declaring Enums
Declaring an enum is easy - just use the enum
keyword:
enum WebEvent {
PageLoad,
Click,
KeyPress(char),
Paste(String),
}
Enum variants can optionally have data associated with them. For example, the KeyPress
variant here contains a char
.
You can also specify explicit discriminant values for each variant:
enum Number {
Zero = 0,
One = 1,
Two = 2
}
But usually allowing Rust to assign default values is fine.
Creating Enum Instances
Once an enum is declared, we can create instances by specifying the variant we want:
let page_load = WebEvent::PageLoad;
let key_press = WebEvent::KeyPress('x');
If a variant has associated data, we provide that data when creating an instance:
let paste_event = WebEvent::Paste("Some text".to_string());
Enum instances are statically typed - the compiler knows this is a WebEvent
:
fn log(event: WebEvent) {
// ...
}
So enums allow you to define custom data types and safely use them in your code.
Implementing Methods on Enums
In addition to storing data, we can also implement methods directly on enums to encapsulate behavior for each variant.
For example:
enum WebEvent {
Click,
KeyPress(char),
Paste(String),
}
impl WebEvent {
fn log(&self) {
match self {
Self::Click => println!("Clicked"),
Self::KeyPress(c) => println!("Key {} pressed", c),
Self::Paste(text) => println!("Pasted: {}", text),
}
}
}
let event = WebEvent::KeyPress('x');
event.log(); // Prints "Key x pressed"
Now we can add reusable logic that applies to all instances of an enum.
Putting Methods to Use
Some ways we might leverage enum methods:
- Common formatting/output logic
- Validation checking
- Helper calculations/transformations
- Integration with external services
Methods help encapsulate behavior related to enums in one tidy place!
Associated Data
We’ve seen basic associated data with enums like this:
enum WebEvent {
KeyPress(char),
Paste(String),
}
But we can also use more complex types like structs and tuples:
struct PasteData {
text: String,
source_app: String,
}
enum WebEvent {
Paste(PasteData),
}
let event = WebEvent::Paste(PasteData {
text: "example text".to_string(),
source_app: "Notes".to_string(),
});
This allows us to bundle related data with enum variants.
Tuple and Struct Enums
We can also use tuples and structs directly as enum variants:
enum Color {
Rgb(u8, u8, u8),
Hsv(u8, u8, u8)
}
enum Shape {
Circle { radius: f64 },
Rect { width: f64, height: f64 }
}
Tuples are great for simple related values, and structs allow us to name the associated values.
This helps eliminate duplication - we don’t need a separate Circle
struct anymore, for example.
Introduction to Pattern Matching
Pattern matching allows us to inspect an enum instance and take different actions depending on which variant it is. Here’s an example:
fn inspect(event: WebEvent) {
match event {
WebEvent::PageLoad => println!("Page loaded"),
WebEvent::Paste(text) => println!("Pasted: {}", text),
_ => ()
}
}
The match
expression checks the event
value against the different patterns we specify - almost like a switch statement in other languages.
This allows us to handle each enum variant differently in a concise, exhaustive way.
Matching Enum Variants
Let’s look closer at matching variants:
enum WebEvent {
Click,
KeyPress(char),
}
fn inspect(event: WebEvent) {
match event {
WebEvent::Click => {
// Handle click
},
WebEvent::KeyPress(c) => {
// Handle key press of character c
},
}
}
We can directly match against each variant, and any associated data is extracted for us to use!
This avoids messy conditional logic, and the compiler ensures every variant is handled.
Matching enums is essential for modeling complex systems efficiently and safely in Rust.
Using Non-Exhaustive Patterns
When matching enum variants, we can also use a catch-all _
pattern to handle multiple variants at once:
fn inspect(event: WebEvent) {
match event {
WebEvent::KeyPress(c) => {
println!("Pressed {}", c)
}
_ => {
println!("Some other event")
}
}
}
This _
pattern will match any WebEvent
that isn’t a KeyPress
.
We can specify multiple non-exhaustive patterns:
match event {
WebEvent::Paste(_) | WebEvent::Click => {
// Handle pastes and clicks
}
_ => { /* Other events */ }
}
It’s best practice to have a catch-all pattern so that any new variants added later don’t break functionality.
The compiler will warn us about truly non-exhaustive matches without a _
fallback.
While handling variants individually is ideal, non-exhaustive patterns are useful for simplifying logic across multiple variants when needed.
Destructuring Enum Variants
Pattern matching allows us to destructure enums to extract their internal data:
enum Shape {
Rectangle { width: u32, height: u32 },
Circle(u32),
}
fn inspect(shape: Shape) {
match shape {
Shape::Rectangle { width, height } => {
println!("A rectangle with dimensions {} x {}", width, height);
},
Shape::Circle(radius) => {
println!("A circle with radius {}", radius);
}
}
}
Instead of using the whole Rectangle
variant, we match on just the width
and height
fields. This avoids repetitive code.
Destructuring works similarly for tuple variants:
fn rgb_to_hex(color: (u8, u8, u8)) {
match color {
(r, g, b) => {
println!("{}{}{}", r, g, b); // convert to hex
}
}
}
Matching Complex Enums
We can match enums with complex associated data too:
enum Event {
MouseClick { x: i64, y: i64 },
KeyPress(char),
}
fn inspect(event: Event) {
match event {
Event::MouseClick { x, y } => {
println!("Clicked at x:{}, y:{}", x, y);
},
Event::KeyPress(c) => {
println!("Key pressed: {}", c);
}
}
}
This flexibility helps model intricate domains cleanly.
So destructuring and nested matching makes working with elaborate enums concise and easy!
Using Pattern Matching in Conditional Statements
In addition to match
expressions, we can also use pattern matching within if let
and while let
conditional statements.
For example:
enum Shape {
Circle(u32),
Rectangle(u32, u32)
}
fn print_shape(shape: Shape) {
if let Shape::Circle(radius) = shape {
println!("Circle with radius {}", radius);
} else if let Shape::Rectangle(width, height) = shape {
println!("Rectangle with dimensions {} x {}", width, height);
}
}
The if let
checks if shape
matches the Circle
variant, extracting the radius. A similar approach works for while let
.
This can be cleaner than cascaded match
statements when we only need to check one variant at a time.
Looping with Pattern Matching
We can also use pattern matching variants directly in loops:
let shapes = vec![Shape::Circle(3), Shape::Rectangle(4, 5)];
for shape in shapes {
match shape {
Shape::Circle(radius) => println!("Radius {}", radius),
Shape::Rectangle(width, height) => {
println!("{} x {}", width, height);
}
}
}
So pattern matching is useful well beyond match
expressions!
Leveraging it in conditionals and loops helps simplify control flow when working with enums.
Looping While an Option Has a Value
We can also use while let
to loop over an option enum as long as it has a value:
let mut events = Some(vec![WebEvent::Click]);
// Loop while events has some value
while let Some(e) = events {
match e.pop() {
Some(event) => {
event.log(); // Log event
}
None => {
events = None; // No more events
}
}
}
This constructs a loop that:
- Checks if
events
matchesSome(e)
, binding the vector toe
- Pops off an event from the vector
- Logs the event if there was one
- Sets
events
toNone
when empty to exit
We could expand this to fetch new batches of events to keep the loop going.
The Option
enum allows this style of looping over a value that may or may not be present.
So while let bindings are very useful for processing option enums!
Creating Complex Data Structures
We can combine enums and structs to create more intricate data models.
For example, we can represent web events happening within pages and sessions:
struct WebSession {
id: String,
events: Vec<WebEvent>,
}
struct WebPage {
url: String,
sessions: Vec<WebSession>,
}
enum WebEvent {
Click,
KeyPress(char),
Paste(String),
}
Now we can express relationships like “a key press event happened during session 123 on the homepage”:
let home_page = WebPage {
url: "/".to_string(),
sessions: vec![
WebSession {
id: "123".to_string(),
events: vec![
WebEvent::KeyPress('x')
]
}
]
}
This composition allows us to model complex systems and hierarchies!
Incorporating Behavior Through Traits
To add polymorphic behavior to such structures, we can utilize Rust’s trait system.
For example, we can define a trait to encapsulate logic for logging events:
trait Log {
fn log(&self);
}
impl Log for WebEvent {
fn log(&self) {
match self {
WebEvent::Click => println!("Clicked"),
WebEvent::KeyPress(c) => println!("Key {} pressed", c),
WebEvent::Paste(text) => println!("Pasted text: {}", text),
}
}
}
impl Log for WebSession {
fn log(&self) {
println!("Session {}:", self.id);
for event in &self.events {
event.log();
}
}
}
Now WebEvent
and WebSession
both implement the Log
behavior, keeping logging code reused and maintained in one place.
We can log an entire structure:
let session = WebSession {
// ...
};
session.log(); // Logs session and events
This demonstrates how enums provide the building blocks to grow reusable, modular code in Rust!
Error Handling with Default Enums
Creating and Using Some and None Option Enums
Rust’s Option
enum allows us to represent optional values that may or may not exist. The variants are:
Some(T)
: Wraps a value of typeT
(the option holds something)None
: No value is present (the option is empty)
Let’s look at some examples:
// User ID from a database lookup
let user_id: Option<i32> = Some(5);
// Results from a search query
let search_results: Option<Vec<String>> = Some(vec![]);
// Empty string parsed
let parsed_string: Option<String> = None;
We can use if let
to easily check if an Option contains data:
if let Some(id) = user_id {
println!("User ID: {}", id);
}
// Error - requires handling None case
if let None = parsed_string {
// Handle missing value
}
And match
to handle both Some
and None
:
match search_results {
Some(results) => { /* Show results */ },
None => { /* No results */ },
}
So Option
allows type-safenullable values - the compiler ensures we handle both defined and undefined states properly. This prevents bugs!
Result and Option Enums
Rust’s standard library provides two helpful enums for error handling - Result
and Option
:
enum Result<T, E> {
Ok(T),
Err(E),
}
enum Option<T> {
Some(T),
None,
}
These express two common scenarios - an operation that may succeed (Ok
) or fail (Err
), and a value that may be present (Some
) or missing (None
).
For example, we could use them in our web event code:
fn parse_paste(text: &str) -> Result<WebEvent, ParseError> {
// Try to parse...
if valid {
Ok(WebEvent::Paste(text.to_string()))
} else {
Err(ParseError)
}
}
let events: Option<Vec<WebEvent>> = Some(vec![]);
Pattern matching on Result
and Option
allows clean handling of errors and missing values.
Overall they are a great demonstration of using enums for domain modeling and error handling in Rust!
Custom Error Enums
We can also create custom error enums for our domains:
enum ParseEventError {
InvalidJson,
MissingFields,
}
enum EventSourceError {
IoError(io::Error),
RequestTimeout,
}
These can be used with Result
to return structured errors:
fn parse_event(json: &str) -> Result<WebEvent, ParseEventError> {
if missing_fields {
Err(ParseEventError::MissingFields)
} else {
// parse ok
Ok(event)
}
}
fn fetch_events() -> Result<Vec<WebEvent>, EventSourceError> {
match resp {
Ok(resp) => {
// parse events
},
Err(e) => Err(EventSourceError::IoError(e))
}
}
Custom errors provide more context and structure than simple strings. And enums allow matching to handle errors differently:
match fetch_events() {
Ok(events) => { /* use events */ },
Err(EventSourceError::IoError(e)) => { /* retry */ },
Err(EventSourceError::RequestTimeout) => { /* increase timeout */ },
}
So error enums are immensely useful in Rust programs!
Summary of Enums and Pattern Matching
Let’s recap what we learned about enums:
- Enums allow you to define custom data types to elegantly model domains
- Variants can store associated data like strings, structs, etc
- Pattern matching is an idiomatic way to handle enums
- Enums combine great with other features like structs and error handling
Overall, Rust’s enums are far more advanced than other languages. When combined with features like pattern matching they become an indispensable tool for clean system modeling and safe control flow.
Some key benefits in system programming are:
- Representing systemic states, errors, and resource types
- Enforcing validity of values at compile time
- Encouraging exhaustive handling of edge cases
Exercises and Next Steps
To become proficient with enums, I recommend:
- Replacing duplicate structs with enum variants
- Modeling custom errors and results with enums
- Practicing pattern matching expressions and destructuring
- Reading user stories about enums on Rust community sites
Next we’ll explore Rust’s powerful trait system for polymorphism and abstraction. Traits allow us to extend the behavior of enums and structs in versatile ways.
But for now feel free to ask any follow up questions about enums below!
Help Improve This Page
Spot an error or have a suggestion? This page is a Markdown file, making it easy to contribute:
- Click the "Edit on GitHub" button below
- Look for the pencil icon in the GitHub interface
- Make your changes and submit a pull request
Can't edit directly? Share your feedback or report issues in the comments below. We appreciate your input!