第六章 枚举与模式匹配:Rust 的 enum,是能”装数据”的代数数据类型

你有没有点过外卖后盯着订单状态发呆:待支付、已支付、配送中、已送达、已退款——这五种状态,每一种该带的信息都不一样。待支付带个下单时间就行;配送中可能要带个骑手电话;已退款得带个退款原因。要是按别的语言那套,你多半会弄个 status 整数字段 + 一大堆可有可无的附属字段,运行时自己祈祷”状态是配送中时骑手电话一定填了”。Rust 的枚举(enum)直接把这件事焊死在类型里:每个状态自带它该带的数据,编译器逼你处理每一种可能。

墨叔先抛结论:Rust 的 enum 不是 C 那种”给几个整数起别名”的玩具,而是代数数据类型(ADT)——每个变体(variant)可以携带任意结构的数据,变体之间互斥。配上的 match 是”穷尽匹配”:编译器逼你把每一种变体都处理掉,漏一个就编译不过。 这一章我们把”为什么 enum 这么强”和”match 怎么替你兜底”讲透,并顺手点出 Option 的枚举本质,给第七章错误处理埋个伏笔。

一、Rust 的 enum 是能携带数据的”带数据联合体”

先戳破一个常见误会。C 语言的 enum 是什么?是给 0、1、2、3 起几个好记的名字,本质是整数,每个标签背后啥数据也挂不了。Rust 的 enum 完全是另一回事:它的每个变体,是一个独立的小结构,可以随身携带数据。

enum 订单状态 {
    待支付 { 下单时间: String },
    已支付,                          // 也可以啥都不带
    配送中 { 骑手电话: String, 预计分钟: u32 },
    已送达,
    已退款 { 原因: String },
}

你看,待支付 自带下单时间,配送中 自带骑手电话和预计时长,已退款 自带原因。一个 订单状态 值,要么是”待支付且带着某个时间”,要么是”配送中且带着某电话”——它同时只能是一种,且携带的数据正好贴合那种状态的需要。这叫”标签联合体”(tagged union):每个值自带一个”我是哪个变体”的标签,外加该变体专属的数据。

为什么这比”整数 + 一堆可选字段”强?因为后者是”形状开放”的:编译器不知道”配送中时骑手电话一定存在”,你只能运行时 if (status == 配送中 && 骑手电话 == null) 报错。而 Rust 的 enum 把”配送中必有骑手电话”变成了类型层面的真理——你构造 配送中 时,编译器强制你填骑手电话,填不出就编不过。可能的非法组合(“配送中但没电话”)从语言层面根本不存在。这就是”让非法状态无法被表达”的设计哲学,是 Rust 枚举最值钱的地方。

二、代数数据类型:从”或”与”且”看 enum 和 struct

术语”代数数据类型”听着吓人,其实就两个运算。墨叔用生活话翻一下:

  • 乘积类型(product type)= “且”:struct 是乘积类型。一个 用户 { 姓名, 年龄 } 同时拥有姓名”且”拥有年龄,可能的状态数 = 姓名的取值数 × 年龄的取值数(笛卡尔积)。所以叫”积”。
  • 和类型(sum type)= “或”:enum 是和类型。一个 订单状态 是”待支付 或 已支付 或 配送中…”,可能的状态数 = 各变体取值数之和。所以叫”和”。

这个”或”字是灵魂。enum 表达的是”这东西是 A 或 B 或 C,绝不同时是多个”。对比 struct 的”且”,你会发现 Rust 用两种类型,正好把”且”和”或”两种逻辑关系都编码进了类型系统。当你建模”一个请求要么成功带数据、要么失败带错误”时,“或”是你最自然的需要——而这正是第七章 Result 的底层形状(成功或失败)。先记住:enum = 或,struct = 且。你建模时在脑子里先问”这是且还是或”,类型就选对了。

如果你写过 Java 或 C#,这里的心智要重装:你过去用 enum 多半是”一组常量名”(比如 enum Color { RED, GREEN }),或者靠”类继承”去表达”几种不同的东西”——写一堆 class 配送中 extends 订单状态。Rust 不用继承那套,它用 enum 在一个类型里把”几种互斥的带数据状态”收拢干净,还顺带让 match 能静态穷尽检查。你失去的是”给每个变体挂方法靠继承树”的习惯,换来的是”状态和它携带的数据、以及必须处理所有状态”三者被编译器一次性焊死。

三、match:穷尽匹配,编译器逼你处理每一种可能

有了”或”类型,自然要”根据是哪种,做不同的事”。match 就是干这个的,而且它有个别的语言没有的杀手锏:穷尽性检查(exhaustiveness)——你必须把所有变体都写上,漏一个编译不过。

fn 描述状态(s: &订单状态) -> String {
    match s {
        订单状态::待支付 { 下单时间 } => format!("等付款呢,{}下单", 下单时间),
        订单状态::已支付 => String::from("钱到啦"),
        订单状态::配送中 { 骑手电话, .. } => format!("骑手{}正在送", 骑手电话),
        订单状态::已送达 => String::from("收到货了"),
        订单状态::已退款 { 原因 } => format!("退了:{}", 原因),
        // 五个变体全写了,编译器才放行;少写一个直接报错
    }
}

你品这个设计的狠劲。假设三个月后你给 订单状态 加了个 已取消 { 手续费: u32 } 变体——所有 match 它的地方立刻编译失败,编译器逐个点名”你这儿漏处理已取消了”。别的语言里,这种”加个状态忘了处理”的遗漏,会一直潜伏到运行时、某个用户真触发已取消时才崩。Rust 把它变成”编译期就列清单点名”。这叫”编译器替你做 case 覆盖测试”。

match 还能解构(destructuring):配送中 { 骑手电话, .. } 里的 .. 表示”其余字段我不在乎,只取骑手电话”。也能用模式绑定拿到值,还能加守卫(guard)| 模式 if 条件。这些细节你写多了自然熟,墨叔只强调核心:match 的穷尽性,是把”所有可能情况都考虑周全”这件容易漏的事,变成了编译器的强制检查。 这是写在类型系统里的”防御性编程”。

四、if let / while let:当一个分支就够时的语法糖

穷尽匹配是好,但有时候你只关心其中一种变体,其余懒得写。比如”如果是配送中,就通知骑手;别的我不管”。为这一个分支写满五个 => 太啰嗦。于是有了 if let:

if let 订单状态::配送中 { 骑手电话, .. } = &s {
    println!("提醒骑手 {}", 骑手电话);
}
// 不是配送中?那啥也不做,相当于 else 分支为空

if let 读作”如果 s 匹配这个模式,就进大括号、并把模式里的字段绑定出来”。它本质是 match 的语法糖,专治”只关心一个变体”的场景。代价呢?你放弃了穷尽检查——因为 if let 不要求覆盖全部,所以编译器不会提醒你”还有已退款没处理”。墨叔的建议:当你确实只处理一种、其余明确忽略时,用 if let 清爽;当你”必须处理所有可能”时,老老实实写 match,让穷尽检查替你兜底。糖可以吃,但别拿糖当饭,把该有的安全检查也吃了。

while let 同理,用在”只要还匹配某模式就循环”。典型如遍历一个不断 pop 的出队循环:

while let Some(任务) = 队列.pop() {
    println!("处理 {}", 任务);
}
// 队列空了 pop 返回 None,模式不匹配,循环自然结束

五、Option:一个藏在眼前的枚举,为第七章埋伏笔

现在墨叔点一个关键的东西,你后面会天天见它——Option<T>。它其实就是标准库用 enum 定义的:

enum Option<T> {
    Some(T),   // 有值,且带着一个 T
    None,      // 没有值
}

看到了吗?Option 就是”要么有一个 T、要么什么都没有”的和类型。它的存在,是为了在类型层面消灭”空指针/空引用”。在别的语言里,String s = null 是合法的,你随时可能因为忘了判空而崩在 s.length() 上——那是几十年来最贵的一类 bug 之一。Rust 里没有 null:一个可能缺失的值,必须写成 Option<String>,你拿它之前必须用 match 或 if let 区分 Some 和 None,编译器逼你正视”可能没有”这件事。

fn 找用户(id: u32) -> Option<String> {
    if id == 42 { Some(String::from("墨叔")) } else { None }
}
// 调用方拿到 Option,必须处理 None 分支,没法"假装一定有"
match 找用户(7) {
    Some(名) => println!("找到 {}", 名),
    None => println!("查无此人"),
}

这就是 Option 的枚举本质,也是它强大的根源:它把”缺失”做成了一个显式的、必须被匹配的变体,而不是一个偷偷埋在指针里的 null。无缝衔接第七章——错误处理用的 Result<T, E> 也是同一个思路的 enum:Ok(T) 或 Err(E),要么成功带值、要么失败带错。你今天吃透 enum 和 match,第七章的 Result 就是”换了个名字的同一套武器”。墨叔不在这里展开 Result/Option 的细节(包括那个让错误处理丝滑的 ? 运算符),那是第七章的主场,但请你记住:它们俩都是 enum,都靠 match 的穷尽检查逼你处理”坏情况”。 这层认知先种下,第七章你会有”原来如此”的顿悟。

所以全章串起来:Rust 的 enum 是带数据的代数数据类型(和类型),变体互斥且各携数据,让非法状态无法被表达;match 用穷尽检查把”处理所有可能”变成编译强制;if let/while let 是单分支时的糖;而 Option/Result 正是这套机制的集大成者,替你在类型层面消灭 null 和未处理错误。理解 enum,你就握住了 Rust 表达”可能性”的钥匙。

【思考题】

  1. 墨叔说”让非法状态无法被表达”是 Rust 枚举最值钱的地方。请对比两种建模:方案 A 用 status: i32 + 一堆可选字段;方案 B 用 enum 订单状态 { 配送中 { 骑手电话 }, 已退款 { 原因 }, ... }。请你推一个具体的、用方案 A 会在运行时炸、但用方案 B 在编译期就被拦下的 bug,讲清”为什么 B 让这个 bug 根本不存在”。不要只说”B 更安全”。

  2. enum 是”和类型(或)“,struct 是”积类型(且)“。请你设计一个稍微复杂点的数据,比如”一次网络请求的结果”,它需要同时用到”且”(请求本身含多个必填字段)和”或”(结果要么成功带数据、要么失败带错误)。用 struct + enum 的组合把它写出来(伪代码或 Rust 皆可),并说明哪部分是且、哪部分是或。

  3. match 有穷尽检查,if let 没有。请你站在一个代码维护者的角度推:假设一个函数用 if let 只处理了 配送中,三个月后同事给 订单状态 加了个新变体 已取消。会发生什么?对比如果当初写的是 match,会发生什么?由此说明”穷尽检查”在团队协作、长期维护里到底值多少钱。

【参考答案】

  1. 具体 bug:方案 A 里,订单 有 status 和可选字段 骑手电话、退款原因,都是独立字段、默认 null。某个分支逻辑只把 status 设成”配送中”,但忘了填 骑手电话(因为语言不强制”设配送中时必须填电话”)。前端拿到这个订单,渲染配送中页面、调 骑手电话.length()——啪,空指针崩在用户手机上。这是运行时才暴露的事故。方案 B 里,配送中 这个变体在类型上就要求携带 骑手电话:你想构造一个 配送中 值,构造函数就逼你提供电话,缺了编译不过。于是”配送中但没电话”这种状态在方案 B 里根本无法被构造出来——不是”容易漏”,是”语法上不存在”。所以 bug 不是”被提前发现了”,而是”从源头就造不出”。区别在根上:A 是”开放形状 + 运行时约定”,B 是”封闭形状 + 编译期强制”。非法状态无法表达,自然无法发生。

  2. 设计:用 struct 表达”请求本身”(且),用 enum 表达”结果”(或)。

struct 请求 {        // 积类型:同时拥有 方法、路径、超时 三个必填字段(且)
    method: String,
    路径: String,
    超时秒: u32,
}
enum 响应 {          // 和类型:要么成功带数据,要么失败带错误(或)
    OK { 数据: String, 状态码: u32 },
    错误 { 原因: String, 重试: bool },
}
fn 发送(req: 请求) -> 响应 { ... }

说明:请求 是”且”——一个合法请求必须 method、路径、超时三者都有,缺一个不完整,这正是 product(各字段取值的笛卡尔积)。响应 是”或”——一次发送的结果,要么是成功(带着数据和状态码),要么是错误(带着原因和是否重试),不可能同时是两者、也不可能”啥都不是”。必要时还能嵌套:请求 的字段里某个再是 enum,响应 的 OK 变体里再嵌 struct。struct 负责”一个东西由哪些部分同时组成”,enum 负责”一个东西可能是哪几种互斥情况之一”。建模时先问且/或,类型选错的概率直线下降。

  1. 维护者视角推:若当初用 if let 配送中 {..},三个月后加 已取消 变体——因为 if let 不做穷尽检查,代码照常编译通过,没有任何警告。同事开开心心合了代码,上线。直到某天真实出现”已取消”的订单,那段逻辑因为它只 match 配送中、对其余静默忽略,于是什么都没做、或走到别的默认分支、埋下逻辑漏洞。bug 潜伏,等用户触发才暴露。若当初写的是 match 覆盖五个变体:加 已取消 的瞬间,所有 match 订单状态 的地方编译失败,编译器逐文件报错”pattern 已取消 not covered”。维护者被迫在每一处显式决定”已取消时怎么办”——是忽略、提示、还是报错。区别是:match 把”新增状态忘了处理”变成编译期必须解决的待办清单;if let 让同样的遗漏无声溜进生产。穷尽检查 在长期维护、多人协作里的价值,就是用编译器的强制力,保证”任何新增的可能情况,都被迫走到台前被人处理”,而不是靠人的记性。它把”case 覆盖”从运行时事故提前成编译期清单,这笔钱,值。

小结

Rust 的 enum 是能携带数据的代数数据类型(和类型),变体互斥且各带专属数据,让非法状态无法被表达;match 的穷尽检查把”处理所有可能”变成编译强制,if let/while let 是单分支语法糖;而 Option/Result 正是这套机制的集大成者,替你在类型层面消灭 null 与未处理错误。