Enums and Pattern Matching
枚举与模式匹配
What you’ll learn: How Java’s
sealed interface,record, andswitchexpressions map to Rustenumandmatch, and when a closed domain should be modeled as an enum instead of a hierarchy.
本章将学习: Java 里的sealed interface、record、switch表达式如何迁到 Rust 的enum与match,以及什么时候应该把封闭领域建模成枚举,而不是继承层级。Difficulty: 🟡 Intermediate
难度: 🟡 中级
Java developers often reach for classes and interfaces when a domain has a few fixed variants.
当一个领域只有少数几种固定变体时,Java 开发者通常会本能地想到 class 和 interface。
Rust’s default answer is different: if the set of cases is closed, define an enum and let match force complete handling.
Rust 的默认答案不一样:如果变体集合是封闭的,就定义 enum,再让 match 强制把所有情况处理完整。
The Familiar Java Shape
Java 里熟悉的建模方式
public sealed interface PaymentCommand
permits Charge, Refund, Cancel { }
public record Charge(String orderId, long cents) implements PaymentCommand { }
public record Refund(String paymentId, long cents) implements PaymentCommand { }
public record Cancel(String orderId) implements PaymentCommand { }
public final class PaymentService {
public String handle(PaymentCommand command) {
return switch (command) {
case Charge charge -> "charge " + charge.orderId();
case Refund refund -> "refund " + refund.paymentId();
case Cancel cancel -> "cancel " + cancel.orderId();
};
}
}
This is already the better side of modern Java, but the model is still spread across multiple declarations.
这已经是现代 Java 里相对更好的写法了,但模型依然分散在多个声明里。
The Native Rust Shape
Rust 原生的写法
#![allow(unused)]
fn main() {
#[derive(Debug, Clone)]
enum PaymentCommand {
Charge { order_id: String, cents: u64 },
Refund { payment_id: String, cents: u64 },
Cancel { order_id: String },
}
fn handle(command: PaymentCommand) -> String {
match command {
PaymentCommand::Charge { order_id, cents } => {
format!("charge {order_id} for {cents} cents")
}
PaymentCommand::Refund { payment_id, cents } => {
format!("refund {payment_id} for {cents} cents")
}
PaymentCommand::Cancel { order_id } => {
format!("cancel order {order_id}")
}
}
}
}
Rust keeps the whole closed set in one place, which makes the model easier to audit and change.
Rust 会把整个封闭集合放在一处定义,阅读、审查和修改都会更直接。
Two consequences matter a lot:
这里有两个很关键的后果:
- the full set of variants is visible at once
所有变体一眼就能看全。 - adding a new variant forces every relevant
matchto be revisited
一旦新增变体,所有相关match都会被编译器强制重新检查。
match Is More Than a Safer switch
match 不只是更安全的 switch
Rust match brings four habits that Java teams quickly learn to rely on:
Rust 的 match 会带来四种很快就离不开的习惯:
- exhaustive handling
穷尽处理。 - pattern destructuring
模式解构。 - guards with
if
带if的守卫。 - expression-oriented branching
以表达式为中心的分支返回。
#![allow(unused)]
fn main() {
#[derive(Debug)]
enum UserEvent {
SignedUp { user_id: u64, email: String },
LoginFailed { user_id: u64, attempts: u32 },
SubscriptionChanged { plan: String, seats: u32 },
}
fn describe(event: UserEvent) -> String {
match event {
UserEvent::SignedUp { user_id, email } => {
format!("user {user_id} signed up with {email}")
}
UserEvent::LoginFailed { user_id, attempts } if attempts >= 3 => {
format!("user {user_id} is locked after {attempts} failures")
}
UserEvent::LoginFailed { user_id, attempts } => {
format!("user {user_id} failed login attempt {attempts}")
}
UserEvent::SubscriptionChanged { plan, seats } => {
format!("subscription moved to {plan} with {seats} seats")
}
}
}
}
The guard branch is the kind of thing Java developers often write as a type check followed by nested if logic.
上面这个 guard 分支,正是 Java 开发者经常会写成“类型判断 + 里层 if”的那类逻辑。
Destructuring Replaces Boilerplate Getters
解构会取代很多样板 getter
if (command instanceof Charge charge) {
return charge.orderId() + ":" + charge.cents();
}
#![allow(unused)]
fn main() {
fn audit(command: &PaymentCommand) -> String {
match command {
PaymentCommand::Charge { order_id, cents } => {
format!("charge:{order_id}:{cents}")
}
PaymentCommand::Refund { payment_id, cents } => {
format!("refund:{payment_id}:{cents}")
}
PaymentCommand::Cancel { order_id } => {
format!("cancel:{order_id}")
}
}
}
}
In Rust, unpacking the payload at the point of use is the natural style, not an advanced trick.
在 Rust 里,在哪里使用,就在哪里把载荷拆开,这是自然写法,不是什么高级技巧。
Option and Result Are the Same Modeling Idea
Option 和 Result 其实是同一套建模思想
Java teams often learn sum types as a special topic, but Rust applies the same idea to ordinary absence and failure.
Java 团队经常把 sum type 当成专题知识来看,但 Rust 会把同样的思想直接用于“值可能不存在”和“调用可能失败”这些日常场景。
#![allow(unused)]
fn main() {
fn maybe_discount(code: &str) -> Option<u8> {
match code {
"VIP" => Some(20),
"WELCOME" => Some(10),
_ => None,
}
}
fn parse_port(raw: &str) -> Result<u16, String> {
raw.parse::<u16>()
.map_err(|_| format!("invalid port: {raw}"))
}
}
After a while, enum stops feeling like a separate chapter and becomes the default tool for domain states, optional data, and typed failures.
学到后面,enum 就不会再像一章单独知识点,而会变成建模状态、可选值、类型化失败时的默认工具。
Enum or Trait?
该用 Enum 还是 Trait
| Situation 场景 | Better Rust tool 更适合的 Rust 工具 | Why 原因 |
|---|---|---|
| fixed set of domain states 固定的领域状态集合 | enum | compiler can enforce completeness 编译器可以强制完整处理 |
| plugin-style extension 插件式可扩展实现 | trait | downstream code may add implementations 下游代码以后还能新增实现 |
| commands or events across a boundary 跨边界的命令或事件 | enum | easy to serialize and match 更适合序列化和匹配 |
| shared behavior over unrelated types 多个无关类型共享行为 | trait | behavior is the changing axis 变化轴是行为,不是状态集合 |
If the team already knows every variant today, enum is usually the honest model.
如果团队今天就已经知道所有变体是什么,enum 往往就是更诚实的模型。
Migration Example: Order State
迁移示例:订单状态
public enum OrderStatus {
PENDING, PAID, SHIPPED, CANCELLED
}
In Java, the trouble starts when only some states need extra data, and then nullable fields begin to spread.
在 Java 里,麻烦通常从“只有部分状态需要额外数据”开始,然后各种可空字段慢慢扩散。
#![allow(unused)]
fn main() {
#[derive(Debug)]
enum OrderState {
Pending,
Paid { receipt_id: String },
Shipped { tracking_number: String },
Cancelled { reason: String },
}
fn can_refund(state: &OrderState) -> bool {
match state {
OrderState::Paid { .. } => true,
OrderState::Shipped { .. } => false,
OrderState::Cancelled { .. } => false,
OrderState::Pending => false,
}
}
}
The data lives exactly on the variant that owns it.
数据会准确地挂在真正拥有它的那个变体上。
Common Mistakes
常见误区
- using
structplus a manualkind: Stringfield
用struct再手写一个kind: String字段冒充变体。 - rebuilding abstract base classes for a closed domain
明明是封闭领域,还去重建抽象基类体系。 - adding wildcard arms too early
太早加通配分支,直接把穷尽检查价值打掉。 - storing optional fields that belong to only one case
把只属于某一种状态的字段做成一堆Option塞在公共结构里。
Exercises
练习
🏋️ Exercise: Replace a Java Sealed Hierarchy 练习:把 Java sealed 层级改写成 Rust 枚举
Model a billing workflow with these cases:
请用下面这些状态建模一个账单流程:
Draft
草稿。Issued { invoice_id: String, total_cents: u64 }
已开单,带账单号和金额。Paid { invoice_id: String, paid_at: String }
已支付,带支付时间。Failed { invoice_id: String, reason: String }
失败,带失败原因。
Then write these functions:
然后实现下面几个函数:
fn status_label(state: &BillingState) -> &'static str
返回状态标签。fn can_send_receipt(state: &BillingState) -> bool
判断是否可以发送回执。fn invoice_id(state: &BillingState) -> Option<&str>
返回可选的账单号。