Rust Enums Basics
Introduction
Enumerations, or "enums" for short, are one of Rust's most powerful features. An enum allows you to define a type by enumerating its possible variants. Think of enums as a way to say, "This value can be one of several different things, but only one at a time."
Unlike enums in many other languages which are often just sets of named constants, Rust's enums are much more flexible and powerful, forming the basis for many of Rust's unique patterns and capabilities.
What Are Enums?
In simplest terms, an enum is a type that can have a fixed set of values, called "variants." Each variant in the enum is distinct and can optionally contain data.
Let's start with a basic example:
enum Direction {
North,
South,
East,
West,
}
fn main() {
let heading = Direction::North;
match heading {
Direction::North => println!("Heading north!"),
Direction::South => println!("Heading south!"),
Direction::East => println!("Heading east!"),
Direction::West => println!("Heading west!"),
}
}
Output:
Heading north!
In this example:
- We define an enum named
Direction
with four variants - We create a variable
heading
of typeDirection
with the valueDirection::North
- We use a
match
statement to handle all possible variants
Defining Enums
The syntax for defining an enum is:
enum EnumName {
Variant1,
Variant2,
// ...more variants
}
Rust convention is to use PascalCase for both the enum name and its variants.
Data in Enums
What makes Rust's enums particularly powerful is their ability to store data within each variant. Each variant can:
- Have no data (like our
Direction
example above) - Have unnamed data (tuple-like)
- Have named fields (struct-like)
Let's see an example that combines all three:
enum Message {
Quit, // No data
Move { x: i32, y: i32 }, // Named fields (struct-like)
Write(String), // Single value (tuple-like)
ChangeColor(i32, i32, i32), // Multiple values (tuple-like)
}
fn main() {
let messages = [
Message::Quit,
Message::Move { x: 10, y: 5 },
Message::Write(String::from("Hello, Rust!")),
Message::ChangeColor(255, 0, 255),
];
for msg in messages {
process_message(msg);
}
}
fn process_message(msg: Message) {
match msg {
Message::Quit => println!("Quitting the application"),
Message::Move { x, y } => println!("Moving to position: ({}, {})", x, y),
Message::Write(text) => println!("Text message: {}", text),
Message::ChangeColor(r, g, b) => println!("Changing color to RGB: ({}, {}, {})", r, g, b),
}
}
Output:
Quitting the application
Moving to position: (10, 5)
Text message: Hello, Rust!
Changing color to RGB: (255, 0, 255)
This flexibility allows enums to model a wide variety of scenarios in a type-safe way.
The Option Enum
One of the most important enums in Rust's standard library is Option<T>
. This enum is so commonly used that it's included in the prelude (automatically imported).
enum Option<T> {
None,
Some(T),
}
Option<T>
represents a value that might be present (Some(T)
) or absent (None
). It's Rust's way of handling the concept of nullable values without actually having null
in the language.
Let's see it in action:
fn main() {
let some_number = Some(5);
let some_string = Some("a string");
// To declare a None value, we need to specify the type, since
// the compiler can't infer what type of Option<T> it is
let absent_number: Option<i32> = None;
// Using Option with pattern matching
match some_number {
Some(num) => println!("The number is: {}", num),
None => println!("There is no number"),
}
// Using if let for simpler matching when you only care about one variant
if let Some(text) = some_string {
println!("The string is: {}", text);
}
// Using unwrap_or to provide a default value
let value = absent_number.unwrap_or(0);
println!("Value with default: {}", value);
}
Output:
The number is: 5
The string is: a string
Value with default: 0
The Match Control Flow Operator
As you've seen in the examples, the match
operator is particularly useful with enums. It ensures that you handle all possible cases, making your code more robust.
Here's a more complete example using a weather forecast enum:
enum Weather {
Sunny,
Cloudy,
Rainy(f32), // Amount of rainfall in inches
Snowy(f32), // Amount of snowfall in inches
}
fn get_activity(weather: Weather) -> String {
match weather {
Weather::Sunny => String::from("Go for a hike!"),
Weather::Cloudy => String::from("Perhaps read a book outside."),
Weather::Rainy(amount) if amount < 1.0 => String::from("A light walk with an umbrella."),
Weather::Rainy(_) => String::from("Stay inside and watch a movie."),
Weather::Snowy(amount) if amount < 2.0 => String::from("Build a small snowman."),
Weather::Snowy(_) => String::from("Best to stay warm indoors."),
}
}
fn main() {
let forecasts = [
Weather::Sunny,
Weather::Cloudy,
Weather::Rainy(0.5),
Weather::Rainy(2.3),
Weather::Snowy(1.0),
Weather::Snowy(5.0),
];
for forecast in forecasts {
let activity = get_activity(forecast);
println!("Suggested activity: {}", activity);
}
}
Output:
Suggested activity: Go for a hike!
Suggested activity: Perhaps read a book outside.
Suggested activity: A light walk with an umbrella.
Suggested activity: Stay inside and watch a movie.
Suggested activity: Build a small snowman.
Suggested activity: Best to stay warm indoors.
Notice how we use guards (if amount < 1.0
) to further refine our pattern matching.
Enum Methods
Like structs, enums can have methods implemented using the impl
block:
enum Shape {
Circle(f64), // radius
Rectangle(f64, f64), // width, height
Triangle(f64, f64, f64), // three sides
}
impl Shape {
fn area(&self) -> f64 {
match self {
Shape::Circle(radius) => std::f64::consts::PI * radius * radius,
Shape::Rectangle(width, height) => width * height,
Shape::Triangle(a, b, c) => {
// Heron's formula
let s = (a + b + c) / 2.0;
(s * (s - a) * (s - b) * (s - c)).sqrt()
}
}
}
fn describe(&self) -> String {
match self {
Shape::Circle(_) => String::from("a circle"),
Shape::Rectangle(_, _) => String::from("a rectangle"),
Shape::Triangle(_, _, _) => String::from("a triangle"),
}
}
}
fn main() {
let shapes = [
Shape::Circle(5.0),
Shape::Rectangle(4.0, 6.0),
Shape::Triangle(3.0, 4.0, 5.0),
];
for shape in shapes {
println!("The area of {} is {:.2}", shape.describe(), shape.area());
}
}
Output:
The area of a circle is 78.54
The area of a rectangle is 24.00
The area of a triangle is 6.00
Real-World Applications
Enums are extremely useful in many real-world scenarios. Here are a few common use cases:
1. Representing Results with Error Handling
Rust's standard library includes the Result<T, E>
enum for handling operations that might fail:
enum Result<T, E> {
Ok(T), // Success with value T
Err(E), // Error with value E
}
Here's how you might use it:
fn divide(a: i32, b: i32) -> Result<i32, String> {
if b == 0 {
Err(String::from("Cannot divide by zero"))
} else {
Ok(a / b)
}
}
fn main() {
let results = [
divide(10, 2),
divide(5, 0),
];
for result in results {
match result {
Ok(value) => println!("Result: {}", value),
Err(error) => println!("Error: {}", error),
}
}
}
Output:
Result: 5
Error: Cannot divide by zero
2. State Machines
Enums are perfect for representing states in a state machine:
enum ConnectionState {
Disconnected,
Connecting { retries: u32 },
Connected { server_id: String },
Disconnecting,
}
struct Connection {
state: ConnectionState,
}
impl Connection {
fn new() -> Self {
Self { state: ConnectionState::Disconnected }
}
fn connect(&mut self, server: &str) {
match self.state {
ConnectionState::Disconnected => {
println!("Initiating connection to {}", server);
self.state = ConnectionState::Connecting { retries: 0 };
}
ConnectionState::Connecting { retries } if retries < 3 => {
println!("Retry {} connecting to {}", retries + 1, server);
self.state = ConnectionState::Connecting { retries: retries + 1 };
}
ConnectionState::Connecting { .. } => {
println!("Connection successful to {}", server);
self.state = ConnectionState::Connected { server_id: server.to_string() };
}
_ => println!("Cannot connect in current state")
}
}
fn status(&self) -> String {
match &self.state {
ConnectionState::Disconnected => "Disconnected".to_string(),
ConnectionState::Connecting { retries } => format!("Connecting (attempt {})", retries + 1),
ConnectionState::Connected { server_id } => format!("Connected to {}", server_id),
ConnectionState::Disconnecting => "Disconnecting".to_string(),
}
}
}
fn main() {
let mut conn = Connection::new();
println!("Status: {}", conn.status());
conn.connect("db.example.com");
println!("Status: {}", conn.status());
conn.connect("db.example.com");
println!("Status: {}", conn.status());
conn.connect("db.example.com");
println!("Status: {}", conn.status());
conn.connect("db.example.com");
println!("Status: {}", conn.status());
}
Output:
Status: Disconnected
Initiating connection to db.example.com
Status: Connecting (attempt 1)
Retry 1 connecting to db.example.com
Status: Connecting (attempt 2)
Retry 2 connecting to db.example.com
Status: Connecting (attempt 3)
Connection successful to db.example.com
Status: Connected to db.example.com
3. Typed Configuration
Enums can represent different configuration options in a type-safe way:
enum DatabaseConfig {
MySQL {
host: String,
port: u16,
username: String,
password: String,
database_name: String,
},
PostgreSQL {
connection_string: String,
},
SQLite {
file_path: String,
},
}
fn connect_to_database(config: DatabaseConfig) {
match config {
DatabaseConfig::MySQL { host, port, username, database_name, .. } => {
println!("Connecting to MySQL database '{}' on {}:{} as user '{}'",
database_name, host, port, username);
// MySQL-specific connection code would go here
},
DatabaseConfig::PostgreSQL { connection_string } => {
println!("Connecting to PostgreSQL with connection string: {}", connection_string);
// PostgreSQL-specific connection code would go here
},
DatabaseConfig::SQLite { file_path } => {
println!("Opening SQLite database at: {}", file_path);
// SQLite-specific connection code would go here
},
}
}
fn main() {
let configs = [
DatabaseConfig::MySQL {
host: "localhost".to_string(),
port: 3306,
username: "admin".to_string(),
password: "password123".to_string(),
database_name: "users".to_string(),
},
DatabaseConfig::PostgreSQL {
connection_string: "postgresql://user:pass@localhost/mydb".to_string(),
},
DatabaseConfig::SQLite {
file_path: "/var/data/app.db".to_string(),
},
];
for config in configs {
connect_to_database(config);
}
}
Output:
Connecting to MySQL database 'users' on localhost:3306 as user 'admin'
Connecting to PostgreSQL with connection string: postgresql://user:pass@localhost/mydb
Opening SQLite database at: /var/data/app.db
Visual Representation of Enums
Here's a diagram to help visualize how Rust enums work:
Summary
Rust's enums are incredibly powerful and flexible. They allow you to:
- Define a type that can have different variants
- Store different types and amounts of data in each variant
- Use pattern matching to handle all possible cases
- Build type-safe code that the compiler can verify
Enums form the backbone of many idiomatic Rust patterns, including:
- The
Option<T>
type for handling optional values without null - The
Result<T, E>
type for error handling - State machines and complex data modeling
- Protocol and message definitions
By mastering enums, you're well on your way to writing Rust code that is both expressive and robust.
Exercises
-
Basic Enum: Create an enum
TrafficLight
with variantsRed
,Yellow
, andGreen
. Write a function that takes a traffic light and returns how long a driver should wait (e.g., stop for red, prepare to stop for yellow, go for green). -
Enum with Data: Define an enum
Payment
with variants for different payment methods (e.g.,Cash
,CreditCard
with card number and expiry,PayPal
with email). Write a function to process payments that handles each method differently. -
Recursive Enum: Try implementing a simple binary tree structure using enums where each node is either a leaf or contains a value and two child nodes.
Additional Resources
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)