卷 III · 用类型说话CH 10深度 10/24

枚举与模式匹配:让分支无处可逃

卷 II 讲的是编译器「拦着你别犯错」。从这一卷起,画风变成编译器「帮你把错消灭在源头」。第一件超能力:枚举。它让「一个值恰好是这几种之一」变成类型的一部分,再让 match 逼你面对每一种可能 —— 少处理一个情况,代码就编译不过。那类「忘了处理某个 case」的 bug,在 Rust 里根本没机会长到运行时。

enummatch穷尽检查非法状态不可表达

枚举:会带数据的「几选一」

你在别的语言里见过枚举,多半是「一串花名常量」—— 本质是整数。Rust 的枚举强得多:每个变体可以带上自己的数据,而且各变体带的数据可以完全不同。它表达的是「一个值,恰好是下面几种情况之一」:

enum Shape {
    Circle { radius: f64 },              // 圆,带半径
    Rectangle { w: f64, h: f64 },        // 矩形,带宽高
    Triangle(f64, f64, f64),             // 三角,带三条边
}

let s = Shape::Circle { radius: 2.0 };   // 这个值「就是」一个圆

这类型叫「和类型」(sum type):一个 Shape 要么是圆、要么是矩形、要么是三角,不会同时是两种,也不会是第四种。这句「不会」是关键 —— 它把一条业务约束,写进了类型,编译器替你守。

顺带说,你已经熟得不能再熟的两个类型 —— OptionResult(下两章的主角)—— 其实就是标准库里的普通枚举,没有任何魔法:

enum Option<T> { Some(T), None }              // 要么有个 T,要么没有
enum Result<T, E> { Ok(T), Err(E) }          // 要么成功带 T,要么失败带 E

看懂了枚举,你就看懂了半个标准库。

match:逼你面对每一种

有了「几选一」的类型,就需要一个「按情况分别处理」的工具,这就是 match。它一边分支、一边把变体里的数据解构出来

fn area(s: &Shape) -> f64 {
    match s {
        Shape::Circle { radius } => 3.14159 * radius * radius,
        Shape::Rectangle { w, h } => w * h,
        Shape::Triangle(a, b, c) => {
            let p = (a + b + c) / 2.0;
            (p * (p - a) * (p - b) * (p - c)).sqrt()
        }
    }
}

关键在于 match穷尽的:如果你漏掉了任何一个变体、又没写兜底,编译不过,报 E0004「没有覆盖所有情况」,还告诉你漏了哪个。

▶ 亲手漏一个分支

下面这个 match,你可以逐个开关它的分支(模拟「你写没写这一臂」)。少覆盖一个变体、又没加 _ 兜底,它立刻报 E0004,列出漏掉的。这个判定是真跑的。

这个「穷尽」看着朴素,威力却在代码演化时才显现:假设某天有人往 Shape 里加了个 Shape::Ellipse,那么项目里所有没写兜底的 match立刻、全部编译报错,编译器领着你走遍每一处「这里你还没处理新情况」。你不可能漏。而在用 switch + default 或一堆 if-else 的语言里,新增一种情况后,那些忘了更新的分支会静悄悄地走进 default、或者什么都不做,然后在生产环境给你惊喜。

模式的几种花样

match 里的「模式」远不止匹配变体,它是一门小小的解构语言。常用的几样:

  • 解构 + 绑定Shape::Circle { radius } 一边判断是圆、一边把 radius 取出来命名,一步到位。
  • 守卫(guard)Some(n) if n > 0 => ... 在模式后加条件。
  • 范围 / 多值1..=5 => ...'a' | 'e' | 'i' => ...
  • 通配 _:兜底,「其余全归这里」。用了它就不再要求穷尽 —— 所以别随手加,它会关掉穷尽检查这层保护。

当你只关心「是不是某一种」、不想为所有情况写分支时,用 if let(match 的轻量版):

if let Shape::Circle { radius } = s {
    println!("是个圆,半径 {}", radius);
}   // 不是圆就啥也不做,不用穷尽

// 还有个搭档 let-else:不匹配就走「逃生」分支
let Shape::Circle { radius } = s else {
    return;      // 不是圆,直接返回
};
// 到这里,radius 已经在手上了

这一卷的主题句:让非法状态无法被表达

枚举 + match 只是手段,真正的思想是一句话,请记牢,它会改变你的设计习惯:

◆ Make illegal states unrepresentable

把业务约束编进类型,让「不该出现的状态」根本构造不出来 —— 那样你就不用在运行时到处写检查去防它,因为它压根不存在。

举个例子。一个网络连接,要么「未连接」,要么「已连接并带着一个 session id」。用别的语言常见的写法是一个 boolean connected 加一个可空的 String sessionId —— 但这允许四种组合,其中「未连接却有 session」和「已连接却没 session」是非法的,你得靠运行时断言去防。用枚举:

enum Connection {
    Disconnected,
    Connected { session: String },   // 已连接就一定有 session,绑死了
}

现在那两个非法状态根本无法被构造出来 —— 类型系统不允许「Connected 但没 session」存在。你省掉了所有相关的防御性检查,因为编译器保证它们永远为真。这就是「用类型说话」:把正确性从「运行时靠你记得检查」,前移到「编译时由类型保证」。接下来两章的 Option 和 Result,正是这个思想在「可能没有」和「可能失败」这两件最常见的事上的应用。

回流

⟲ 回流 · 你的语言可能刚补上这个,Rust 生来就有

Kotlin:sealed class + when 是 Rust 枚举 + match 的最近亲 —— sealed 限定子类型有限,when 作为表达式时也强制穷尽。如果你写 Kotlin 用熟了 sealed class,那你对 Rust 枚举的直觉已经八成到位了。区别是 Rust 的枚举更轻量(不是一堆类)、且是值类型(不装箱)。

Java:直到近几年才有 sealed interface + switch 模式匹配 + 穷尽检查 —— 也就是说,Java 是在Rust(和更早的 ML / Haskell)从第一天就有的东西。这不是巧合:「和类型 + 穷尽匹配」被证明是消除一整类 bug 的利器,各语言都在往这个方向走。

TypeScript:可辨识联合(discriminated union)+ switch,配合 never 类型做穷尽检查,是同一个思想的 TS 版。你在 TS 里手动用 never 逼穷尽的那点仪式,Rust 的 match 天生就有。

这一章的一句话

枚举让「恰好是几种之一」成为类型,match 逼你面对每一种、漏一个就编译不过 —— 顺着这条路,把非法状态设计成「无法被表达」,你就不用再花运行时去防它。

下一章,我们把这套思想用在编程界最著名的一个 bug 源头上:null。Rust 干脆没有它。