9. Structs, enums, and pattern matching
9.1 Structs and impl blocks
#[derive(Debug, Clone, PartialEq)]
struct Endpoint {
host: String,
port: u16,
tls: bool,
}
impl Endpoint {
// An associated function (no `self`): Rust's constructor convention is `new`.
fn new(host: impl Into<String>, port: u16) -> Self {
Self { host: host.into(), port, tls: port == 443 }
}
// &self: read-only method. &mut self: mutating. self: consuming.
fn url(&self) -> String {
let scheme = if self.tls { "https" } else { "http" };
format!("{scheme}://{}:{}", self.host, self.port)
}
fn with_tls(mut self) -> Self { self.tls = true; self }
}
struct Meters(f64); // tuple struct: the NEWTYPE pattern (Ch 53, 62)
struct Marker; // unit struct: zero bytes
fn main() {
let e = Endpoint::new("example.com", 443);
let local = Endpoint { host: "localhost".into(), ..e.clone() }; // struct update syntax
let dev = Endpoint::new("dev.local", 8443).with_tls();
println!("{} {} {}", e.url(), local.url(), dev.url());
let Meters(m) = Meters(3.5);
println!("{m} {}", std::mem::size_of::<Marker>());
}
Receiver choice is the Rust version of
Go's value vs. pointer receiver rule,
with a third option. self by value consumes the struct, which builders
and state machines use.
9.2 Enums are sum types
A Rust enum is a tagged union, the
C guide's §8.3 pattern,
except the compiler checks the tag for you:
#[derive(Debug)]
enum Message {
Ping, // no data
Data { stream: u32, payload: Vec<u8> }, // named fields
Close(u16, String), // tuple-like
}
fn handle(m: &Message) -> String {
match m {
Message::Ping => "pong".into(),
Message::Data { stream, payload } if payload.is_empty() => format!("empty frame on {stream}"),
Message::Data { stream, payload } => format!("{} bytes on {stream}", payload.len()),
Message::Close(code, reason) => format!("close {code}: {reason}"),
}
// Remove any arm and you get error[E0004]: non-exhaustive patterns.
}
fn main() {
let msgs = [
Message::Ping,
Message::Data { stream: 3, payload: vec![1, 2, 3] },
Message::Data { stream: 5, payload: vec![] },
Message::Close(1000, "bye".into()),
];
for m in &msgs { println!("{}", handle(m)); }
}
Exhaustiveness is the main benefit. When you add a Message::Resume
variant, every match that does not handle it stops compiling, so the
compiler gives you the list of places to update.
9.3 Option<T> instead of null
fn find_user(id: u32) -> Option<&'static str> {
match id {
1 => Some("ada"),
2 => Some("grace"),
_ => None,
}
}
fn main() {
// You cannot use an Option<&str> as a &str. Handle the None case first.
let name = find_user(3).unwrap_or("anonymous");
let upper = find_user(1).map(|n| n.to_uppercase()); // Some("ADA")
let len = find_user(2).map_or(0, |n| n.len()); // 5
let both = find_user(1).zip(find_user(2)); // Some(("ada","grace"))
println!("{name} {upper:?} {len} {both:?}");
// Option<&T> costs no extra space: the null pointer is used as the None "niche".
println!("{}", std::mem::size_of::<Option<&u64>>()); // 8, same as &u64 (Ch 57)
}
Tony Hoare called null his "billion-dollar mistake". Go's nil-pointer panic
and C's NULL dereference are two versions of it. Option costs nothing at
runtime for references and boxes. The only cost is that you have to handle
None before you use the value.
9.4 Patterns everywhere
fn main() {
let packet = (4u8, [192u8, 168, 1, 10], 443u16);
match packet {
(4, [10, ..], _) => println!("private 10/8"),
(4, [192, 168, ..], port @ (80 | 443)) => println!("LAN web on {port}"),
(4, [a, b, c, d], p) => println!("{a}.{b}.{c}.{d}:{p}"),
(v, ..) => println!("IPv{v}"),
}
let point = (3, -3);
if let (x, y) = point && x == -y { // let-chains: edition 2024
println!("on the anti-diagonal");
}
}
.. skips the remaining elements, @ binds a value while testing it, |
matches alternatives, and if adds a guard. Patterns also work in let,
function parameters, for, and closures.
Real-world example. HTTP routers, protocol state machines, and
compilers' AST passes are where Rust's enums are most useful. Ch 63's RESP
parser is one enum Frame plus a match.