Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Enums and Pattern Matching
枚举与模式匹配

What you’ll learn: How Java’s sealed interface, record, and switch expressions map to Rust enum and match, and when a closed domain should be modeled as an enum instead of a hierarchy.
本章将学习: Java 里的 sealed interfacerecordswitch 表达式如何迁到 Rust 的 enummatch,以及什么时候应该把封闭领域建模成枚举,而不是继承层级。

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 match to 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
OptionResult 其实是同一套建模思想

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
固定的领域状态集合
enumcompiler can enforce completeness
编译器可以强制完整处理
plugin-style extension
插件式可扩展实现
traitdownstream code may add implementations
下游代码以后还能新增实现
commands or events across a boundary
跨边界的命令或事件
enumeasy to serialize and match
更适合序列化和匹配
shared behavior over unrelated types
多个无关类型共享行为
traitbehavior 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 struct plus a manual kind: String field
    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:
然后实现下面几个函数:

  1. fn status_label(state: &BillingState) -> &'static str
    返回状态标签。
  2. fn can_send_receipt(state: &BillingState) -> bool
    判断是否可以发送回执。
  3. fn invoice_id(state: &BillingState) -> Option<&str>
    返回可选的账单号。