Understanding Enums in Rust
Introduction
Enumerations (enums) in Rust are a powerful feature that allows you to define a type by enumerating its possible variants. Unlike enums in many other programming languages that are limited to simple value lists, Rust enums can contain data and even implement methods, making them a versatile tool for modeling domain concepts.
Basic Enum Definition
Simple Enum
#[derive(Debug)]
enum Direction {
North,
South,
East,
West,
}
let heading = Direction::North;
println!("We're heading {:?}", heading);
Enums with Data
enum WebEvent {
PageLoad, // No data
KeyPress(char), // Tuple variant
Click { x: i64, y: i64 }, // Struct variant
}
let event = WebEvent::KeyPress('x');
let click = WebEvent::Click { x: 20, y: 80 };
Enum Variants with Data
Tuple Variants
enum Message {
Quit, // No data
Move { x: i32, y: i32 }, // Named fields
Write(String), // Single value
ChangeColor(i32, i32, i32), // Tuple
}
let m1 = Message::Write(String::from("Hello"));
let m2 = Message::ChangeColor(0, 160, 255);
Struct Variants
enum Shape {
Circle {
radius: f64,
center: (f64, f64),
},
Rectangle {
width: f64,
height: f64,
position: (f64, f64),
},
}
let circle = Shape::Circle {
radius: 2.0,
center: (0.0, 0.0),
};
The Option Enum
Understanding Option
The Option
enum is a core type in Rust that represents the presence or absence of a value:
enum Option<T> {
Some(T),
None,
}
// Examples
let some_number = Some(5);
let some_string = Some("a string");
let absent_number: Option<i32> = None;
Working with Option
fn divide(numerator: f64, denominator: f64) -> Option<f64> {
if denominator == 0.0 {
None
} else {
Some(numerator / denominator)
}
}
// Using the result
match divide(4.0, 2.0) {
Some(value) => println!("Result: {}", value),
None => println!("Cannot divide by zero"),
}
Pattern Matching with match
Basic match Usage
enum Coin {
Penny,
Nickel,
Dime,
Quarter(UsState),
}
enum UsState {
Alabama,
Alaska,
// ... other states
}
fn value_in_cents(coin: Coin) -> u8 {
match coin {
Coin::Penny => {
println!("Lucky penny!");
1
}
Coin::Nickel => 5,
Coin::Dime => 10,
Coin::Quarter(state) => {
println!("State quarter from {:?}!", state);
25
}
}
}
Match with Option
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);
if let Syntax
Basic if let
let some_value = Some(3);
if let Some(3) = some_value {
println!("three");
}
if let with else
let mut count = 0;
if let Coin::Quarter(state) = coin {
println!("State quarter from {:?}!", state);
} else {
count += 1;
}
Methods on Enums
Just like structs, enums can have methods:
enum Message {
Write(String),
Move { x: i32, y: i32 },
Quit,
}
impl Message {
fn call(&self) {
match self {
Message::Write(text) => println!("Text message: {}", text),
Message::Move { x, y } => println!("Move to ({}, {})", x, y),
Message::Quit => println!("Quit"),
}
}
}
let m = Message::Write(String::from("hello"));
m.call();
Best Practices
1. Use Enums for Mutually Exclusive States
// Good
enum ConnectionState {
Connected(String), // IP address
Disconnected,
Connecting,
Failed(String), // Error message
}
// Less ideal (using boolean flags)
struct Connection {
is_connected: bool,
is_connecting: bool,
error: Option<String>,
ip: Option<String>,
}
2. Leverage Type Safety
// Good
enum Temperature {
Celsius(f64),
Fahrenheit(f64),
}
impl Temperature {
fn to_celsius(&self) -> f64 {
match self {
Temperature::Celsius(c) => *c,
Temperature::Fahrenheit(f) => (f - 32.0) * 5.0 / 9.0,
}
}
}
// Less safe
struct Temperature {
degrees: f64,
is_fahrenheit: bool,
}
Common Patterns
1. Result Type for Error Handling
enum Result<T, E> {
Ok(T),
Err(E),
}
fn parse_number(str: &str) -> Result<i32, String> {
match str.parse() {
Ok(num) => Ok(num),
Err(_) => Err(String::from("Failed to parse number")),
}
}
2. State Pattern
enum State {
Draft,
PendingReview,
Published,
}
struct Post {
state: State,
content: String,
}
impl Post {
fn new() -> Post {
Post {
state: State::Draft,
content: String::new(),
}
}
fn publish(&mut self) {
self.state = State::Published;
}
}
Performance Considerations
-
Memory Efficiency
- Enums use only as much memory as needed for their largest variant
- The size of an enum is predictable and optimized
- Zero-variant enums take zero bytes
-
Pattern Matching
- Match expressions are optimized by the compiler
- The compiler ensures all cases are handled
- No runtime overhead for pattern matching
Common Pitfalls
1. Forgetting to Handle All Cases
// Bad: Missing cases
fn process_option(opt: Option<i32>) {
match opt {
Some(x) => println!("Got {}", x),
// Missing None case - won't compile!
}
}
// Good: Handle all cases
fn process_option(opt: Option<i32>) {
match opt {
Some(x) => println!("Got {}", x),
None => println!("Got nothing"),
}
}
2. Overusing Enums
// Probably overkill
enum DatabaseConfig {
Hostname(String),
Port(u16),
Username(String),
Password(String),
}
// Better as a struct
struct DatabaseConfig {
hostname: String,
port: u16,
username: String,
password: String,
}
Next Steps
After mastering enums:
- Learn about pattern matching in more detail
- Explore error handling with Result
- Study how to combine enums with other data structures
- Understand how to use enums in generic types
Remember: Enums are one of Rust's most powerful features for modeling domain concepts and handling different cases in your code. They combine well with pattern matching to create safe, expressive code.