枚举与模式匹配:让分支无处可逃
卷 II 讲的是编译器「拦着你别犯错」。从这一卷起,画风变成编译器「帮你把错消灭在源头」。第一件超能力:枚举。它让「一个值恰好是这几种之一」变成类型的一部分,再让 match 逼你面对每一种可能 —— 少处理一个情况,代码就编译不过。那类「忘了处理某个 case」的 bug,在 Rust 里根本没机会长到运行时。
枚举:会带数据的「几选一」
你在别的语言里见过枚举,多半是「一串花名常量」—— 本质是整数。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 要么是圆、要么是矩形、要么是三角,不会同时是两种,也不会是第四种。这句「不会」是关键 —— 它把一条业务约束,写进了类型,编译器替你守。
顺带说,你已经熟得不能再熟的两个类型 —— Option 和 Result(下两章的主角)—— 其实就是标准库里的普通枚举,没有任何魔法:
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 只是手段,真正的思想是一句话,请记牢,它会改变你的设计习惯:
把业务约束编进类型,让「不该出现的状态」根本构造不出来 —— 那样你就不用在运行时到处写检查去防它,因为它压根不存在。
举个例子。一个网络连接,要么「未连接」,要么「已连接并带着一个 session id」。用别的语言常见的写法是一个 boolean connected 加一个可空的 String sessionId —— 但这允许四种组合,其中「未连接却有 session」和「已连接却没 session」是非法的,你得靠运行时断言去防。用枚举:
enum Connection {
Disconnected,
Connected { session: String }, // 已连接就一定有 session,绑死了
}
现在那两个非法状态根本无法被构造出来 —— 类型系统不允许「Connected 但没 session」存在。你省掉了所有相关的防御性检查,因为编译器保证它们永远为真。这就是「用类型说话」:把正确性从「运行时靠你记得检查」,前移到「编译时由类型保证」。接下来两章的 Option 和 Result,正是这个思想在「可能没有」和「可能失败」这两件最常见的事上的应用。
回流
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 干脆没有它。