Table of Contents
- What is Pattern Matching?
- The
matchStatement: Rust’s Exhaustive Workhorse - Types of Patterns in Rust
- Advanced Pattern Matching Features
- Beyond
match:if let,while let, andforLoops - Real-World Use Cases
- Best Practices and Common Pitfalls
- Conclusion
- References
What is Pattern Matching?
At its core, pattern matching is a control flow mechanism that compares a value against a set of “patterns” and executes code based on which pattern matches. Unlike simple if-else chains or switch statements (which often rely on integer or string literals), Rust’s pattern matching is expressive (supports complex data structures) and safe (enforces exhaustiveness for critical cases).
Patterns can range from simple literals (e.g., 5, "hello") to complex nested structures (e.g., enum variants with associated data, structs, or slices). Rust uses pattern matching in match expressions, if let, while let, function parameters, and even variable declarations.
What makes Rust’s implementation stand out?
- Exhaustiveness Checking: The compiler ensures all possible cases are handled (for enums, structs, etc.), eliminating bugs from missing edge cases.
- Destructuring: Patterns can “break apart” complex data types (e.g., structs, tuples) to access nested values directly.
- Integration with Ownership: Patterns respect Rust’s ownership rules, allowing borrowing (
ref,mut) instead of moving values.
The match Statement: Rust’s Exhaustive Workhorse
The match statement is Rust’s most powerful pattern matching construct. It takes a value, compares it against a series of arms (patterns + code), and executes the first matching arm.
Basic Syntax
match value {
pattern1 => code1,
pattern2 => code2,
// ... more arms
_ => default_code, // Wildcard for "all other cases"
}
Key Trait: Exhaustiveness
For enums and other types with fixed variants, match requires all possible cases to be covered. This prevents bugs from unhandled edge cases.
Example: Matching an Enum
enum Message {
Quit,
Move { x: i32, y: i32 },
Write(String),
ChangeColor(i32, i32, i32),
}
fn process_message(msg: Message) {
match msg {
Message::Quit => println!("Quit message received"),
Message::Move { x, y } => println!("Move to ({}, {})", x, y), // Destructure x and y
Message::Write(text) => println!("Wrote: {}", text), // Bind text to the String
Message::ChangeColor(r, g, b) => println!("Change color to RGB({}, {}, {})", r, g, b),
}
}
fn main() {
let msg = Message::Move { x: 10, y: 20 };
process_message(msg); // Output: "Move to (10, 20)"
}
Here, match ensures all Message variants are handled. If we omitted Message::Quit, the compiler would throw an error:
error[E0004]: non-exhaustive patterns: `Quit` not covered
Types of Patterns in Rust
Rust supports a rich set of patterns. Let’s explore the most common ones.
Literal Patterns
Match specific values like integers, booleans, strings, or chars.
let x = 5;
match x {
1 => println!("One"),
2 => println!("Two"),
5 => println!("Five"), // Matches!
_ => println!("Other"),
}
Strings and chars work too:
let s = "hello";
match s {
"hello" => println!("Greeting"),
"goodbye" => println!("Farewell"),
_ => println!("Unknown"),
}
Variable Patterns and Shadowing
A variable name in a pattern binds the matched value to that variable. Be cautious: this shadows outer variables!
let x = 10;
match x {
x => println!("Matched x = {}", x), // x is now 10 (shadows outer x)
}
To avoid shadowing, use distinct names or the wildcard (_).
Wildcard Pattern (_)
The wildcard _ matches any value and ignores it. Use it for “don’t care” cases or to handle remaining possibilities.
let (a, b) = (1, 2);
match (a, b) {
(1, _) => println!("a is 1, b is irrelevant"), // Matches (1, 2)
(_, 3) => println!("b is 3, a is irrelevant"),
_ => println!("Other"),
}
For structs, use .. to ignore remaining fields:
struct Point { x: i32, y: i32, z: i32 }
let p = Point { x: 1, y: 2, z: 3 };
match p {
Point { x, .. } => println!("x is {}", x), // Ignore y and z
}
Struct and Tuple Patterns
Patterns can destructure structs and tuples to access their fields directly.
Tuples
let coords = (3, 4);
match coords {
(0, 0) => println!("Origin"),
(x, 0) => println!("On x-axis: {}", x),
(0, y) => println!("On y-axis: {}", y),
(x, y) => println!("({}, {})", x, y),
}
Structs
struct User {
name: String,
age: u32,
active: bool,
}
let user = User {
name: "Alice".to_string(),
age: 30,
active: true,
};
match user {
User { name, age: 18..=30, active: true } => {
println!("Active user {} (age {})", name, age);
}
User { name, active: false, .. } => {
println!("Inactive user: {}", name);
}
_ => println!("Other user"),
}
Enum Variant Patterns
Enums are where match shines, as the compiler enforces exhaustiveness. Let’s use Rust’s Option<T> enum (which represents a value that may be Some(T) or None):
let maybe_number: Option<i32> = Some(42);
match maybe_number {
Some(n) => println!("Found a number: {}", n),
None => println!("No number found"),
}
For enums with complex associated data, destructure nested values:
enum Event {
KeyPress(char),
MouseClick { x: i32, y: i32 },
Resize { width: u32, height: u32 },
}
let event = Event::MouseClick { x: 100, y: 200 };
match event {
Event::KeyPress(c) => println!("Key pressed: {}", c),
Event::MouseClick { x, y } => println!("Mouse click at ({}, {})", x, y),
Event::Resize { width, height } => println!("Resized to {}x{}", width, height),
}
Range Patterns (..=)
Use start..=end to match inclusive ranges of integers or chars.
let score = 85;
match score {
0..=59 => println!("Fail"),
60..=79 => println!("Pass"),
80..=100 => println!("Excellent"),
_ => println!("Invalid score"),
}
let c = 'm';
match c {
'a'..='z' => println!("Lowercase letter"),
'A'..='Z' => println!("Uppercase letter"),
_ => println!("Not a letter"),
}
Slice Patterns
Match parts of a slice (e.g., arrays, vectors) with slice patterns. Use .. to ignore the rest.
let numbers = [1, 2, 3, 4, 5];
match numbers {
[1, 2, 3, ..] => println!("Starts with 1, 2, 3"), // Matches
[.., 4, 5] => println!("Ends with 4, 5"),
[a, b, c] => println!("Three elements: {}, {}, {}", a, b, c),
}
Path Patterns
Match against constants or enum variants using their full path.
const MAX_SCORE: i32 = 100;
match 100 {
MAX_SCORE => println!("Perfect score!"), // Matches
_ => println!("Not perfect"),
}
// Enum variant path (from earlier example)
match Event::KeyPress('q') {
Event::KeyPress('q') => println!("Quit key pressed"),
_ => (),
}
Advanced Pattern Matching Features
Match Guards
A match guard is an if condition attached to a pattern, adding extra logic to refine matches.
let num = Some(7);
match num {
Some(n) if n % 2 == 0 => println!("Even number: {}", n),
Some(n) if n % 2 == 1 => println!("Odd number: {}", n),
None => println!("No number"),
}
Guards can use variables from outer scopes:
let limit = 10;
let num = 15;
match num {
n if n > limit => println!("{} exceeds limit of {}", n, limit),
n => println!("{} is within limit", n),
}
Borrowing with ref and mut
By default, patterns move values (for non-Copy types like String). Use ref to borrow immutably, or ref mut to borrow mutably.
struct Person { name: String, age: u32 }
let person = Person { name: "Bob".to_string(), age: 25 };
// Borrow `name` instead of moving it
match person {
Person { ref name, age } => println!("Name: {}, Age: {}", name, age),
}
// `person` is still valid here (since we borrowed, not moved)
println!("Person: {:?}", person.name);
For mutable borrowing:
let mut person = Person { name: "Bob".to_string(), age: 25 };
match person {
Person { ref mut name, .. } => *name = "Alice".to_string(), // Modify the name
}
println!("New name: {}", person.name); // Output: "Alice"
Or-Patterns (|)
Combine patterns with | to match multiple cases in one arm.
let color = "red";
match color {
"red" | "blue" | "green" => println!("Primary color"),
"yellow" | "purple" | "orange" => println!("Secondary color"),
_ => println!("Unknown color"),
}
// With enums
enum Shape { Circle, Square, Triangle }
let shape = Shape::Circle;
match shape {
Shape::Circle | Shape::Square => println!("Round or square"),
Shape::Triangle => println!("Triangle"),
}
@ Bindings
Use @ to bind a value to a variable while matching a pattern. Useful for capturing a value and validating it.
let number = 5;
match number {
n @ 1..=10 => println!("n is in 1-10: {}", n), // Bind n to 5
_ => println!("Out of range"),
}
// With enums
enum Message {
Hello { id: i32 },
}
let msg = Message::Hello { id: 123 };
match msg {
Message::Hello { id: id @ 100..=200 } => println!("ID in range: {}", id),
Message::Hello { id } => println!("ID out of range: {}", id),
}
Destructuring Nested Data
Patterns can destructure deeply nested data structures (enums, structs, tuples) in a single match arm.
struct Point { x: i32, y: i32 }
enum Nested {
A(i32),
B(Point),
C { p: Point, z: i32 },
}
let data = Nested::C { p: Point { x: 10, y: 20 }, z: 30 };
match data {
Nested::A(n) => println!("A: {}", n),
Nested::B(Point { x, y }) => println!("B: ({}, {})", x, y),
Nested::C { p: Point { x, y }, z } => println!("C: ({}, {}), z={}", x, y, z),
}
Beyond match: if let, while let, and for Loops
While match is exhaustive, Rust provides lighter-weight constructs for common cases where you don’t need to handle all possibilities.
if let: Concise Single-Case Matching
Use if let to match a single pattern and ignore others. Shorter than match for simple cases.
let maybe_name: Option<&str> = Some("Alice");
// Instead of:
match maybe_name {
Some(name) => println!("Hello, {}", name),
None => (), // Do nothing
}
// Use if let:
if let Some(name) = maybe_name {
println!("Hello, {}", name);
}
// With an else clause:
if let Some(name) = maybe_name {
println!("Hello, {}", name);
} else {
println!("No name");
}
while let: Looping While a Pattern Matches
while let repeatedly executes a loop while a pattern matches, useful for processing iterators or queues.
let mut stack = vec![1, 2, 3];
// Pop elements until None (stack is empty)
while let Some(top) = stack.pop() {
println!("Popped: {}", top); // Output: 3, 2, 1
}
Destructuring in for Loops
Destructure iterated values directly in for loops.
let users = vec![
("Alice", 30),
("Bob", 25),
("Charlie", 35),
];
for (name, age) in users {
println!("{} is {} years old", name, age);
}
Real-World Use Cases
Parsing and Validation
Pattern matching simplifies parsing structured data (e.g., JSON, config files) by destructureing nested fields and validating formats.
// Simplified JSON-like parsing
enum JsonValue {
Null,
Bool(bool),
Number(f64),
String(String),
Array(Vec<JsonValue>),
}
fn print_json(value: &JsonValue) {
match value {
JsonValue::Null => print!("null"),
JsonValue::Bool(b) => print!("{}", b),
JsonValue::Number(n) => print!("{}", n),
JsonValue::String(s) => print!("\"{}\"", s),
JsonValue::Array(items) => {
print!("[");
for (i, item) in items.iter().enumerate() {
if i > 0 { print!(", "); }
print_json(item);
}
print!("]");
}
}
}
State Machines
Model state transitions with enums and match to ensure valid state changes.
enum VendingMachineState {
Idle,
SelectingProduct,
Dispensing(String),
OutOfStock,
}
fn transition(state: VendingMachineState, input: &str) -> VendingMachineState {
match (state, input) {
(VendingMachineState::Idle, "insert_coin") => VendingMachineState::SelectingProduct,
(VendingMachineState::SelectingProduct, "coke") => VendingMachineState::Dispensing("Coke".to_string()),
(VendingMachineState::Dispensing(product), "done") => {
println!("Dispensed: {}", product);
VendingMachineState::Idle
}
(_, "restock") => VendingMachineState::Idle,
(state, _) => state, // Ignore invalid inputs
}
}
Error Handling with Result
Rust’s Result<T, E> enum uses pattern matching to handle success/error cases cleanly.
use std::fs::read_to_string;
fn read_file(path: &str) -> Result<String, String> {
match read_to_string(path) {
Ok(content) => Ok(content),
Err(e) => Err(format!("Failed to read file: {}", e)),
}
}
// Using if let for error handling:
if let Ok(content) = read_file("data.txt") {
println!("File content: {}", content);
} else {
println!("Error reading file");
}
Best Practices and Common Pitfalls
Best Practices
- Prefer Exhaustiveness: Use
matchfor enums or critical logic to let the compiler catch missing cases. - Keep Patterns Specific: Avoid overusing
_; specific patterns make code self-documenting. - Use
@for Clarity: Bind values with@when you need both the value and a pattern check (e.g.,n @ 1..=10). - Destructure Early: Use patterns in function parameters or
letbindings to avoid nestedmatchstatements.
Common Pitfalls
- Shadowing Variables: Accidentally shadowing outer variables in
matcharms (use distinct names orref). - Forgetting
ref/mut: Moving non-Copyvalues (e.g.,String) in patterns leads to use-after-move errors. Userefto borrow instead. - Overcomplicating Patterns: Deeply nested patterns can reduce readability—split into helper functions if needed.
Conclusion
Rust’s pattern matching is a cornerstone of its expressiveness and safety. By integrating destructuring, exhaustiveness checking, and ownership-aware borrowing, it simplifies handling complex data and control flow. Whether you’re parsing JSON, modeling state machines, or handling errors, pattern matching reduces boilerplate and eliminates bugs from missing cases.
Start small with match and enums, then explore if let, while let, and advanced features like guards and @ bindings. The compiler will guide you, and soon you’ll wonder how you coded without it!