枚举与 match:一个值只能是几种情况之一
用 enum 表达"几种之一"并让变体携带数据,靠 match 取值与穷尽性检查,理解 Option 如何取代 null
上一篇的 struct 有一个隐含前提:一个 User 永远同时拥有 email、username、active 这几个字段,一个不缺。但程序里还有另一类需求------一个值只能是几种情况之一,而且不同情况附带的数据还不一样。IP 地址要么是 IPv4 要么是 IPv6;一枚硬币要么是 1 分、5 分、10 分、25 分。这不是"字段的组合",是"从若干可能里选一个"。
Rust 用枚举(enum)表达这件事,用 match 把里面的数据取出来。这一篇讲这两件事,以及由它们撑起来的、你之后几乎每天都会用到的类型:Option。
一个值只能是几种之一
先定义一个 IP 地址种类的枚举:
rust
enum IpAddrKind {
V4,
V6,
}
let four = IpAddrKind::V4;
let six = IpAddrKind::V6;
fn route(ip_kind: IpAddrKind) {}
route(IpAddrKind::V4);
route(IpAddrKind::V6);
两个细节值得留意。第一,变体名字被收在类型名下,用 :: 访问------你之前见过 String::from,那次是关联函数,这次是变体,语法相同。第二,V4 和 V6 是同一个类型 IpAddrKind 的两个值,所以 route 能同时接收它们。这正是"枚举"这个词的来源:把一个类型所有可能的取值列出来。
Java 的 enum 做的就是这件事,用起来几乎一样。真正的区别在下一步。
变体可以携带数据
上面的 IpAddrKind 只知道"是哪种",不知道具体地址。想连数据一起带上,最直接的办法是每个变体后面加括号:
rust
enum Message {
Quit, // 不带数据
Move { x: i32, y: i32 }, // 具名字段,形式像 struct
Write(String), // 带一个 String
ChangeColor(i32, i32, i32), // 带三个 i32
}
四个变体,各自携带的数据类型和数量都不同。为什么不用 struct 拼出来?你完全可以定义四个 struct------QuitMessage、MoveMessage、WriteMessage、ChangeColorMessage,它们能装下同样的数据。但你手里就只剩四个互不相关的类型了:还得额外加一个标签字段记住"这一次到底是哪一个",并且靠你自己保证标签和数据对得上。enum 把这些收进一个类型里:一个 Message 值一定且只能是四种之一,附带什么数据在类型定义里就写清了。
enum 也能挂方法,写法和 struct 完全一样------impl Message { fn call(&self) { ... } },上一篇讲的三种 self 接收者照旧适用。
match:把数据取出来
有了 enum,就需要一种方式按变体分支、并把变体里的数据取出来。这就是 match:
rust
enum Coin { Penny, Nickel, Dime, Quarter }
fn value_in_cents(coin: Coin) -> u8 {
match coin {
Coin::Penny => 1,
Coin::Nickel => 5,
Coin::Dime => 10,
Coin::Quarter => 25,
}
}
三个要点。第一,match 是一个表达式 ,不是语句:每个分支箭头 => 右边求值的结果就是整个 match 的值,所以上面不用写 return------函数体最后一个表达式就是返回值,这条规则你在第一篇里见过。
第二,分支按书写顺序依次比较,第一个匹配上的执行,后面的不再看。
第三,模式里可以绑定变量,顺手把变体里的数据取出来:
rust
fn plus_one(x: Option<i32>) -> Option<i32> {
match x {
None => None,
Some(i) => Some(i + 1),
}
}
Some(5) 匹配 Some(i),i 就绑定到里面的 5,于是这一支算出 Some(6)。这一点解释了为什么"变体带数据"和"match"要放在同一篇里:数据存进去和取出来用的是同一套模式语法。Coin 里的 Quarter 如果带一个州名(Quarter(UsState)),分支就写成 Coin::Quarter(state) => ...,state 直接可用。
必须穷尽,这是最有价值的部分
把 plus_one 里的 None => None 删掉,编译直接失败:
text
error[E0004]: non-exhaustive patterns: `None` not covered
Rust 不但知道你漏了情况,还告诉你是哪一个,甚至会给出补上它的建议。match 的分支必须覆盖所有可能,这是硬性要求。
这条规则值钱在哪?设想 Coin 后来加了一个 HalfDollar 变体。所有没有兜底分支的 match 会立刻编译失败,编译器挨个把你带到需要更新的地方。对照一下:Java 的传统 switch 对枚举不要求穷尽,漏掉的分支静默落空,加了 default 又把未来新增变体的 bug 藏起来;Python 的 match 语句是运行期匹配,没匹配上就继续往下走,没有任何静态检查。Rust 把"我是不是漏了情况"从人的记性变成了编译器的义务。
补一句公平的话:Java 21 起,sealed interface 加 record pattern 的 switch 也能做穷尽性检查,这是同一思路在新版 Java 里的落地。Rust 的不同在于它从第一个版本就这么设计。
需要兜底时用 _,但注意它必须放最后------分支按顺序比较,_ 写在前面会把后面的分支全部挡死,Rust 也会为此给出警告。
Option:用枚举取代 null
现在讲标准库里最重要的 enum。
Rust 没有 null。The Rust Book 的说法很点题:语言设计常被看成"加了哪些特性",但排除了哪些特性同样重要。null 的问题不是"表示没有值"这个想法本身(这想法很必要),而是它的实现方式------nullable 不是某个类型独有的性质,它渗透在所有引用类型里:任何引用都可能是 null,所以使用任何引用前都该检查,而忘记检查就是运行时崩溃。
Tony Hoare 1965 年在 ALGOL W 里引入 null 引用,后来在演讲里称之为"我那个十亿美元的错误"------原话是当时无法抗拒诱惑,因为实现起来太容易了,结果导致无数错误、漏洞和系统崩溃。
Rust 的替代品就是普通枚举:
rust
enum Option<T> {
None,
Some(T),
}
T 是泛型参数(第十章细讲),现在只需知道 Some 能装任意类型的一个值,而 Option<i32> 和 Option<char> 是两个不同的类型。Option 和它的两个变体都在 prelude 里,直接写 Some、None 就行,不用加 Option:: 前缀。
关键差别在这一句:Option<T> 和 T 是两个不同的类型。
rust
let x: i8 = 5;
let y: Option<i8> = Some(5);
let sum = x + y; // 编译错误:不能把 i8 和 Option<i8> 相加
编译器不认识这种加法,因为两边类型不同。这带来一个很实在的结果:只要一个值的类型是 i8,你就知道它一定是个有效的 i8,用之前不必检查 null。只有类型写成 Option<i8> 时,你才需要操心"可能没有值"------而这时编译器会盯着你先把 None 那一种情况处理掉,才能拿到里面的 T。
换个说法:想让一个值可能缺失,你得在类型上显式声明;一旦声明,用它之前就必须显式处理缺失。
对照 Java:Integer 字段可以为 null,但类型上完全看不出它会不会是 null,编译器不提醒,只有运行时抛 NPE。Java 也有 Optional,但没有机制强制你用,混用 Optional 和裸 null 的代码库反而更难排查。Python 更彻底------任何变量都可能是 None,只能靠类型标注(Optional[...])和外部工具自觉,解释器不管。Rust 的做法是把这份责任整个挪到类型系统和编译器身上。
顺带说明:Option 只是普通枚举,上面讲的 match 规则原样适用,所以"忘了处理没值的情况"这件事,被 E0004 拦在了编译期。这就是 Book 那句话的意思------编译器阻止你忘记处理 None,从而让"误以为有值其实没有"这类 bug 不可能发生。
if let:只关心一个变体时
如果只在乎 Some,match 就得加一条凑数的分支来满足穷尽性:
rust
match config_max {
Some(max) => println!("The maximum is configured to be {max}"),
_ => (), // 纯粹为了凑数
}
if let 就是这种场景的简写:
rust
if let Some(max) = config_max {
println!("The maximum is configured to be {max}");
}
等号左边是模式,右边是表达式;匹配上才执行块,max 绑定到里面的值。可以加 else,它的地位相当于 match 里原来那条 _ 分支。
代价要记住:if let 放弃了穷尽性检查。所以判断标准很简单------其他情况我确实不关心,用它;我要保证一个不漏,用 match。Rust 还有一个 let...else,专门处理"不匹配就提前返回"的写法,这里先不展开。
struct 说的是"这个值由这些字段组成",enum 说的是"这个值只能是其中之一"。match 是把 enum 里的数据取出来、并保证每种情况都被处理的那把刀。而 Option 是这套机制最有价值的产物:它把"可能没有值"变成了一个必须在类型上声明、必须显式处理的状态。
顺着同一思路往下,还有一个你很快就会遇到的 enum:Result<T, E>,用来表示"成功或者失败"。它就是 Option 的加强版------同样靠 enum 和 match,只是失败时还带上了出错的原因。下一篇讲它。
参考来源
- The Rust Book 第 6.1 章「Defining an Enum」--- Message 变体携带数据、enum 可 impl、Option 定义、为何用 Option 取代 null:https://doc.rust-lang.org/book/ch06-01-defining-an-enum.html
- The Rust Book 第 6.2 章「The match Control Flow Construct」--- plus_one 例子、分支按顺序比较、穷尽性与 E0004 报错原文:https://doc.rust-lang.org/book/ch06-02-match.html
- The Rust Book 第 6.3 章「Concise Control Flow with if let and let...else」--- if let 语法、与 match 的取舍:https://doc.rust-lang.org/book/ch06-03-if-let.html
- std::option 官方文档 --- Option 的典型用途、模式匹配取值、? 运算符与 unwrap 系列方法:https://doc.rust-lang.org/std/option/
- Tony Hoare 2009 年 QCon London 演讲「Null References: The Billion Dollar Mistake」--- "十亿美元的错误"原话出处:https://web.archive.org/web/20100529160544/qconlondon.com/london-2009/presentation/Null+References:+The+Billion+Dollar+Mistake