Rust Structs, Enums & Pattern Matching
In this tutorial, you'll learn about Rust Structs, Enums & Pattern Matching. We cover key concepts, practical examples, and best practices.
Rust structs and enums let you model complex data with compile-time safety, and pattern matching provides exhaustive handling that eliminates null pointer errors and invalid states.
What You'll Learn
In this tutorial, you'll learn how Rust structs define data shapes, how enums encode multiple variants with associated data, how pattern matching exhaustively handles all cases, and how these features combine for safe systems programming.
Why It Matters
Null pointer references are called the "billion-dollar mistake." Rust eliminates null entirely with Option<T>. Pattern matching forces you to handle every case, preventing the runtime crashes that plague C and C++ programs. This is essential in systems where failure is not an option.
Real-World Use
The Tokio async runtime uses enums to represent task states. Network protocol parsers use enums for packet types. File system implementations use structs for inodes and directory entries. Durga Antivirus Pro uses enums to represent threat classifications without undefined states.
flowchart TD
D[Data Model] --> S[Struct: Product Type]
D --> E[Enum: Sum Type]
S --> F[All fields present]
E --> G[One variant chosen]
G --> M[Match: exhaustive check]
F --> M
M --> S1[Safety: no invalid states]
Prerequisites: Rust Ownership and Borrowing. Familiarity with lifetimes helps.
Defining Structs
Structs group related data into a single type. Each field has a name and type.
struct Process {
pid: u32,
name: String,
memory_used: u64,
cpu_percent: f32,
}
fn main() {
let process = Process {
pid: 1234,
name: String::from("firefox"),
memory_used: 1_024_000_000,
cpu_percent: 12.5,
};
println!("Process {} (PID {}): {} MB, {}% CPU",
process.name, process.pid,
process.memory_used / 1_000_000,
process.cpu_percent);
}
Expected output:
Process firefox (PID 1234): 1024 MB, 12.5% CPU
Tuple Structs
Tuple structs are named tuples without field names, useful for newtype patterns.
struct Color(u8, u8, u8);
struct Millimeters(u32);
fn main() {
let red = Color(255, 0, 0);
let distance = Millimeters(1000);
println!("RGB: ({}, {}, {})", red.0, red.1, red.2);
println!("Distance: {} mm", distance.0);
// Destructuring
let Color(r, g, b) = red;
println!("Red channel: {}", r);
}
Expected output:
RGB: (255, 0, 0)
Distance: 1000 mm
Red channel: 255
Enums for Type-Safe States
Enums define types that can be one of several variants. Each variant can carry data.
enum FileState {
Closed,
Open { read_mode: bool },
Error(String),
}
fn describe_state(state: &FileState) -> &str {
match state {
FileState::Closed => "File is closed",
FileState::Open { read_mode: true } => "File open for reading",
FileState::Open { read_mode: false } => "File open for writing",
FileState::Error(msg) => {
// match arms can have blocks
println!("Error details: {}", msg);
"File has error"
}
}
}
fn main() {
let states = vec![
FileState::Closed,
FileState::Open { read_mode: true },
FileState::Error(String::from("Permission denied")),
];
for state in &states {
println!("{}", describe_state(state));
}
}
Expected output:
File is closed
File open for reading
Error details: Permission denied
File has error
The Option Enum
Rust has no null. Instead, it uses Option<T> for values that may or may not exist.
fn find_process(name: &str) -> Option<u32> {
match name {
"init" => Some(1),
"firefox" => Some(1234),
_ => None,
}
}
fn main() {
let pid = find_process("firefox");
match pid {
Some(p) => println!("Found process with PID {}", p),
None => println!("Process not found"),
}
// if let for single-arm matching
let pid = find_process("unknown");
if let Some(p) = pid {
println!("PID: {}", p);
} else {
println!("No such process");
}
// Common combinators
let pid = find_process("firefox");
let pid_str = pid.map(|p| p.to_string()).unwrap_or("unknown".to_string());
println!("PID string: {}", pid_str);
}
Expected output:
Found process with PID 1234
No such process
PID string: 1234
Pattern Matching with match
The match expression is exhaustive â every possible case must be handled.
enum NetworkPacket {
Tcp { src_port: u16, dst_port: u16, payload: Vec<u8> },
Udp { src_port: u16, dst_port: u16, payload: Vec<u8> },
Icmp { type_code: u8, data: Vec<u8> },
}
fn handle_packet(packet: NetworkPacket) {
match packet {
NetworkPacket::Tcp { src_port, dst_port, .. } => {
println!("TCP packet: port {} -> {}", src_port, dst_port);
}
NetworkPacket::Udp { src_port, dst_port, .. } => {
println!("UDP packet: port {} -> {}", src_port, dst_port);
}
NetworkPacket::Icmp { type_code, .. } if type_code == 8 => {
println!("ICMP Echo Request");
}
NetworkPacket::Icmp { .. } => {
println!("Other ICMP packet");
}
}
}
fn main() {
handle_packet(NetworkPacket::Tcp {
src_port: 54321, dst_port: 80, payload: vec![]
});
handle_packet(NetworkPacket::Icmp { type_code: 8, data: vec![] });
}
Expected output:
TCP packet: port 54321 -> 80
ICMP Echo Request
Struct Methods with impl
Methods are defined in impl blocks. They can take self, &self, or &mut self.
struct CircularBuffer {
buffer: Vec<u8>,
write_pos: usize,
read_pos: usize,
capacity: usize,
}
impl CircularBuffer {
fn new(capacity: usize) -> Self {
CircularBuffer {
buffer: vec![0; capacity],
write_pos: 0,
read_pos: 0,
capacity,
}
}
fn write(&mut self, byte: u8) -> Result<(), &str> {
let next = (self.write_pos + 1) % self.capacity;
if next == self.read_pos {
return Err("Buffer full");
}
self.buffer[self.write_pos] = byte;
self.write_pos = next;
Ok(())
}
fn read(&mut self) -> Option<u8> {
if self.read_pos == self.write_pos {
return None;
}
let byte = self.buffer[self.read_pos];
self.read_pos = (self.read_pos + 1) % self.capacity;
Some(byte)
}
}
fn main() {
let mut buf = CircularBuffer::new(5);
buf.write(10).unwrap();
buf.write(20).unwrap();
buf.write(30).unwrap();
while let Some(byte) = buf.read() {
println!("Read: {}", byte);
}
}
Expected output:
Read: 10
Read: 20
Read: 30
Common Mistakes
1. Forgetting a match Arm
If a match does not cover all variants, the compiler refuses to compile. Use a wildcard _ arm for the remaining cases if needed.
2. Overusing if let
if let is convenient but can hide unmatched cases. Prefer match when you need exhaustive handling.
3. Using Bare null Instead of Option
Never use raw pointers or null checks. Use Option<T> which is type-safe and forces handling of the None case.
4. Confusing Struct Update Syntax
The .. syntax copies remaining fields from another instance. Ensure you understand move semantics when using struct update.
5. Making Enums Too Large
Enum variants with large data cause large enum sizes. Box large variants to keep enum size small.
Practice Questions
1. What is the difference between structs and enums? Structs hold all fields simultaneously (product type). Enums hold exactly one variant at a time (sum type). Enums are ideal for state machines and conditional data.
2. How does Rust eliminate null pointer errors?
Rust uses Option<T> instead of null. You must explicitly handle both Some and None cases, either with match, if let, or combinators like unwrap_or.
3. What does exhaustive pattern matching mean? Every possible variant of a type must be handled in a match expression. The compiler verifies this, preventing unhandled cases that would cause runtime errors.
4. When should you use if let vs match?
Use if let when you care about one specific variant and want to ignore all others. Use match when you need to handle multiple variants or want exhaustive checking.
5. Challenge: Model a TCP connection state machine using an enum with states: Closed, Listen, SynSent, Established, FinWait1, FinWait2, TimeWait. Implement a next method that transitions states based on events.
Mini Project: System Resource Monitor
enum ResourceType {
Cpu { usage: f32 },
Memory { used: u64, total: u64 },
Disk { path: String, used_gb: f64, total_gb: f64 },
}
struct ResourceSample {
timestamp: u64,
resource: ResourceType,
}
fn print_sample(sample: &ResourceSample) {
match &sample.resource {
ResourceType::Cpu { usage } => {
println!("[{}s] CPU: {}%", sample.timestamp, usage);
}
ResourceType::Memory { used, total } => {
let pct = (*used as f64 / *total as f64) * 100.0;
println!("[{}s] Memory: {}MB / {}MB ({:.1}%)",
sample.timestamp, used / 1_000_000,
total / 1_000_000, pct);
}
ResourceType::Disk { path, used_gb, total_gb } => {
println!("[{}s] Disk {}: {:.1}GB / {:.1}GB",
sample.timestamp, path, used_gb, total_gb);
}
}
}
fn main() {
let samples = vec![
ResourceSample { timestamp: 1, resource: ResourceType::Cpu { usage: 45.2 } },
ResourceSample { timestamp: 2, resource: ResourceType::Memory { used: 8_000_000_000, total: 16_000_000_000 } },
ResourceSample { timestamp: 3, resource: ResourceType::Disk { path: "/".into(), used_gb: 120.5, total_gb: 256.0 } },
];
for sample in &samples {
print_sample(sample);
}
}
FAQ
Related Concepts
What's Next
Learn Rust Error Handling for Result, Option, and panic, then explore Traits & Generics for polymorphism.
Built by the developers of Doda Browser, DodaZIP, and Durga Antivirus Pro.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro