6.2 模式匹配与穷尽性检查:从 let 解构到 match、if let 与 let else
在上一节末尾,我们留了一个问题:既然 Role::Member(500) 和 Role::Admin { dept_id: 1, security_key: 9999 } 里面都装着数据,为什么 Rust 不允许你直接写 r.0 或 r.security_key?
很多有面向对象(OOP)经验的读者还会接着问:既然不能直接点出字段,那我能不能先写 if r is Member 判断一下,再用 r as Member 把它强转出来访问?
答案是:Rust 既不允许直接 . 访问枚举载荷,也没有提供 r is Member 和 r as Member 这样的类型转换操作。
要理解 Rust 为什么彻底放弃这套做法,我们直接回到上一节拆解的物理内存结构。
为什么不能用 . 访问,也没有 is 与 as 强转?
Section titled “为什么不能用 . 访问,也没有 is 与 as 强转?”在 struct(积类型)中,所有字段同时存在,每个字段在内存里的偏移量固定且始终有效,因此随时可以写 user.name。
但在 enum Role(和类型)的 24 字节内存里,后 16 字节是一块由所有可能性分时复用的共享载荷区:
- 当开头的判别式标签(Tag)是
0(Guest)时,后面 16 字节处于闲置未初始化状态; - 当开头的标签是
1(Member)时,只有前 8 字节装着会员积分,后 8 字节闲置; - 只有当开头的标签是
2(Admin)时,后 16 字节里才真正装着dept_id和security_key。
如果允许直接写 r.security_key,当 r 处于 Guest 状态时就会直接读出未初始化的垃圾字节。
那为什么不采用 OOP 熟悉的「先用 if (r instanceof Member) 检查,再用 ((Member) r).vipPoints 向下转型(Downcast)」呢?原因有两层:
- 变体不是类型,无法转型:正如上一节验证过的
error[E0573],在enum Role中只有外层的Role是编译期类型实体,里面的Member只是变体构造器,根本不是一个类型。既然不是类型,语法上就不存在「把r转换成Member类型(r as Member)」的操作。 - 「先检查
is、再强转as」拆成了两个独立动作,编译器守不住中间的脱节:在传统 OOP 语言里,判断类型和强制转型是分开写的两步。你完全可以在if (r instanceof Guest)的分支里手滑写下(Member) r,编译器在编译期无法拦截,只能等到运行时抛出ClassCastException;或者在第 10 行检查了r是Member,第 12 行不小心把r改成了Guest,第 13 行照样去读积分。
为了从根源上消除这种脱节,Rust 立下了一条铁律:绝不允许把「核对开头的标签」和「取出后面的载荷」拆成两步各写各的,必须通过「模式(Pattern)按形状解构」将两者焊死在同一个语法动作里。
澄清一个常见误解:模式匹配不等于 match,连 let 本身就是模式匹配
Section titled “澄清一个常见误解:模式匹配不等于 match,连 let 本身就是模式匹配”一提到模式匹配(Pattern Matching),很多教程会直接甩出 match 关键字,导致不少开发者误以为「模式匹配就是 match」。
其实在 Rust 中,你从第 2 章写下的第一行变量声明开始,就已经每天都在使用模式匹配了——因为 let 语句的真正语法从来不是 let 变量名 = 表达式;,而是:
let 模式(Pattern) = 表达式(Expression);什么是模式(Pattern)?模式就是写在 = 左边(或 match 分支左边、函数参数位置)、用来按数据形状拆开值并绑定内部变量的模板:
- 当你写
let x = 5;时,x是最朴素的单变量模式,它直接接住整个值; - 当你把值构造成复合结构时,怎么用构造器把它拼起来,就能用同样形状的模式把它拆开(称为解构 Destructuring)。
我们用一段代码,对比一下用 let 模式 = 表达式; 拆解积类型(元组 / struct)与和类型(enum)时会发生什么:
let-pattern-refutable.rs
//! `let PATTERN = EXPR;`: product types (tuple, struct) have only one possibility,//! so a plain `let` destructures them infallibly (irrefutable pattern).//! A sum type (`enum`) has multiple possibilities: uncomment line 24 to observe E0005.
struct User { name: &'static str, age: u32,}
#[allow(dead_code)]enum Role { Guest, Member(u64), Admin { dept_id: u64, security_key: u64 },}
fn main() { // 1. Product types: only 1 possibility -> plain `let` always succeeds. let (x, y) = (10, 20); let User { name, age } = User { name: "Ferris", age: 7 }; println!("point = ({x}, {y}), user = {name} ({age})");
// 2. Sum type: 3 possibilities -> plain `let` may fail! let _role = Role::Member(500); // Uncomment to observe error[E0005]: refutable pattern in local binding // let Role::Member(points) = _role;}运行输出:
运行输出point = (10, 20), user = Ferris (7)仔细看第 19~20 行:
let (x, y) = (10, 20);用元组形状的模式(x, y),直接把右侧元组里的两个整数拆出来绑给了x和y;let User { name, age } = ...;用结构体形状的模式User { name, age },直接把结构体里的两个字段拆出来绑给了同名变量name和age。
既然元组和 struct 都能直接用一行 let 模式 = 值; 拆开,为什么到了第 26 行,我们对枚举写下一模一样的 let Role::Member(points) = _role;(取消第 26 行注释),编译器却当场报错 error[E0005]?
let-pattern-refutable.rs 第 26 行注释后)
error[E0005]: refutable pattern in local binding --> src/bin/let-pattern-refutable.rs:26:9 |26 | let Role::Member(points) = _role; | ^^^^^^^^^^^^^^^^^^^^ patterns `Role::Guest` and `Role::Admin { .. }` not covered | = note: `let` bindings require an "irrefutable pattern", like a `struct` or an `enum` with only one variant看编译器底部那句切中要害的提示:let 绑定要求一个「不可反驳模式(irrefutable pattern)」,比如一个 struct 或只有一个变体的 enum。
这就把上一节学的积类型 vs 和类型与模式匹配家族彻底打通了:
- 积类型(元组 /
struct)只有 1 种可能性 → 模式必然成功(不可反驳模式 Irrefutable Pattern): 一个(i32, i32)在内存里百分之百是两个整数,一个User在内存里百分之百同时装着name和age。用(x, y)或User { name, age }去匹配它们,绝对不可能失败。所以单独一行let 模式 = 表达式;就能安全解构。 - 和类型(
enum)有多种可能性 → 单拿一个变体去匹配可能失败(可反驳模式 Refutable Pattern):Role有Guest、Member、Admin三种合法可能性。如果你只写一行let Role::Member(points) = _role;,万一运行时_role里装的是Role::Guest,这行绑定就失败了,下一行代码去读points就会访问到未初始化的内存。
因此,面对和类型这种可能匹配失败的模式,编译器拒绝放行裸写的 let,强制要求你必须交代清楚:「如果当前不是这一种可能性,程序该往哪走?」
按照你处理「其余可能性」的方式不同,Rust 恰好在这行 let 模式 = 表达式 的基础上生长出了三个语法工具:
| 你想如何处理其余可能性? | 对应的语法工具 | 语法长相的底层逻辑 |
|---|---|---|
| 把所有可能性一条不落地全列出来分别处理 | match |
列出每个变体的 模式 => 表达式,由编译器做穷尽检查 |
| 匹配上了就进花括号处理,没匹配上就算了(静默跳过) | if let |
在 let 模式 = 表达式 前面加个 if |
匹配上了就留在外层继续往下走,没匹配上就进 else 当场退场 |
let else |
在 let 模式 = 表达式 后面补个 else { return ...; } |
理解了这张表,你就再也不会觉得 match、if let、let else 是三套零散的语法糖了——它们全都是在为「可能失败的模式绑定」提供兜底出口。下面我们依次拆解它们的工程规则。
match 表达式:全面分流与穷尽性检查
Section titled “match 表达式:全面分流与穷尽性检查”当你需要对枚举的每一种可能性分别做出处理时,使用 match。我们为 Role 编写一个计算权限额度的函数 quota:
match-role.rs
//! Destructuring `Role` with `match` and checking exhaustiveness.//! Comment out the `Role::Admin` arm to observe error[E0004]: non-exhaustive patterns.
enum Role { Guest, Member(u64), Admin { dept_id: u64, security_key: u64 },}
fn quota(role: &Role) -> u64 { match role { Role::Guest => 10, Role::Member(points) => 100 + points, Role::Admin { dept_id, security_key } => dept_id * 1000 + security_key, }}
fn main() { let r1 = Role::Guest; let r2 = Role::Member(500); let r3 = Role::Admin { dept_id: 2, security_key: 9999, };
println!( "quotas: {}, {}, {}", quota(&r1), quota(&r2), quota(&r3) );}运行输出:
运行输出quotas: 10, 600, 11999对照第 11~15 行,match 在执行时严格遵守三条规则:
- 核对标签并按形状解构:
match role读取内存开头的判别式标签,依次比对花括号里的每一条分支(称为臂 Arm,格式为模式 => 表达式):- 命中
Role::Guest:无载荷,直接求值右侧表达式10; - 命中
Role::Member(points):将前 8 字节载荷绑定给局部变量points(作用域仅在该分支内),计算100 + points; - 命中
Role::Admin { dept_id, security_key }:将 16 字节载荷按具名字段绑定给dept_id和security_key(若某分支只关心dept_id,可写Role::Admin { dept_id, .. } => ...忽略其余字段)。
- 命中
- 所有分支必须产出同一个类型:与第 5 章 5.1 节的
if / else一样,match本身是一个产出值的表达式。每一条分支=>右侧必须交出同一个类型(这里是u64),否则报error[E0308]。 - 编译期穷尽性检查(Exhaustiveness Check,
error[E0004]):如果你注释掉第 14 行的Role::Admin分支,编译器会当场报错:
match-role.rs 第 14 行后)
error[E0004]: non-exhaustive patterns: `&Role::Admin { .. }` not covered --> src/bin/match-role.rs:11:11 |11 | match role { | ^^^^ pattern `&Role::Admin { .. }` not covered穷尽性检查在真实工程重构中的价值
Section titled “穷尽性检查在真实工程重构中的价值”在大型项目中,穷尽性检查是业务迭代时的安全网。
设想你的项目有 50 个模块都在处理 Role。半年后,业务新增了第 4 种角色 Auditor { region: u32 }:
- 在没有穷尽检查的语言里,你在定义处加上
Auditor后代码照样能编译通过,只能靠人工全局搜索去改那 50 个地方,漏改一处就会在线上走入错误分支。 - 在 Rust 里,你在
enum Role里加上Auditor后运行cargo build,编译器会自动扫描全项目所有针对Role的match,把每一处漏掉Auditor的文件名和行号列成error[E0004]清单。顺着报错逐一补齐,编译通过时全项目的分支就已经完整对齐了。
通配符 _ 与代码审查(Code Review)判据
Section titled “通配符 _ 与代码审查(Code Review)判据”如果一个枚举有很多变体,而某段逻辑只需要特殊处理其中一两个,其余变体统一返回默认值,可以在 match 最后一行使用通配符模式 _ 兜底:
fn is_guest(role: &Role) -> bool { match role { Role::Guest => true, _ => false, // 其余所有可能性一律返回 false }}单分支简写:if let 与 let else
Section titled “单分支简写:if let 与 let else”当我们在某段代码里只关心枚举的其中一种可能性时,为了把那行报错 E0005 的 let Role::Member(points) = user; 救活,可以使用 if let 或 let else:
if-let-and-let-else.rs
//! Single-variant ergonomics: `if let` for optional side actions,//! and `let ... else` for early-return guards that keep the main flow flat.
enum Role { Guest, Member(u64), Admin { dept_id: u64, security_key: u64 },}
fn verify_admin(role: &Role) -> u64 { let Role::Admin { dept_id, security_key } = role else { return 0; }; // `dept_id` and `security_key` are available in the outer scope without nesting! dept_id + security_key}
fn main() { let user = Role::Member(500); if let Role::Member(points) = &user { println!("member bonus points = {points}"); }
let admin = Role::Admin { dept_id: 1, security_key: 9999, }; println!("guest check = {}", verify_admin(&Role::Guest)); println!("admin check = {}", verify_admin(&admin));}运行输出:
运行输出member bonus points = 500guest check = 0admin check = 10000观察两者在控制流上的分工:
1. if let:命中时顺带做一件事,其余静默跳过
Section titled “1. if let:命中时顺带做一件事,其余静默跳过”看第 20~22 行:
if let Role::Member(points) = &user { println!("member bonus points = {points}");}它在 let Role::Member(points) = &user 前面加了一个 if:如果右侧的 &user 匹配上了 Role::Member(points),就把载荷绑给 points 并进入 {} 执行(points 的作用域仅限于这个 {} 内);如果不匹配,就静默跳过 {} 继续往下走。
2. let else:守卫主干流程,不命中则当场退场
Section titled “2. let else:守卫主干流程,不命中则当场退场”如果函数的核心逻辑必须拿到某一种可能性里的数据才能继续往下算(例如第 10 行的 verify_admin 要求传入的必须是 Role::Admin),用 if let 会把整段主干业务代码全部缩进到 {} 里面;要是连续校验两三个条件,代码就会层层向右缩进。
这时就用第 11~13 行的 let else(守卫模式):
let Role::Admin { dept_id, security_key } = role else { return 0;};// 匹配成功后,dept_id 和 security_key 直接绑定在当前外层作用域!dept_id + security_key它在 let 模式 = 表达式 后面跟了一个 else { 发散块 };:
- 匹配成功:解构出来的变量(
dept_id和security_key)直接留在当前外层作用域中,后续主干代码保持零缩进平铺直叙; - 匹配失败:立即进入
else { ... }块。因为一旦匹配失败,外层的dept_id和security_key就没有值,所以编译器强制要求else花括号里的代码必须通过return、break、continue或panic!当场离开当前控制流,绝不允许落回外层继续往下走。
模式绑定里的所有权:移动(Move)还是借用(Borrow)?
Section titled “模式绑定里的所有权:移动(Move)还是借用(Borrow)?”既然无论是普通的 let、match 的分支,还是 if let 与 let else,本质上全都是在执行 let 模式变量 = 内部载荷 的绑定动作,那么它们在所有权上自然遵循我们在第 2、4、5 章建立的同一套规则。
当枚举变体里装的是 String 或 Vec<T> 等非 Copy 类型时,你拿去匹配的目标表达式是「枚举值本身 task」还是「枚举的引用 &task」,直接决定了内部载荷是被移走(Move)还是被借用(Borrow):
match-move-and-borrow.rs
//! Ownership in pattern matching: matching `&task` borrows the payload (`&String`),//! whereas matching `task` by value moves the `String` out (uncomment the last line for E0382).
#[allow(dead_code)]#[derive(Debug)]enum Task { Run(String), Stop,}
fn main() { let task = Task::Run(String::from("backup-db"));
// 1. Match on a reference `&task`: `name` is `&String`, `task` stays valid. match &task { Task::Run(name) => println!("inspecting: {name}"), Task::Stop => println!("inspecting: stop"), }
// 2. Match by value `task`: the `String` moves into `name`. match task { Task::Run(name) => println!("consuming: {name}"), Task::Stop => println!("consuming: stop"), }
// Uncomment to observe error[E0382]: borrow of partially moved value: `task` // println!("after move: {task:?}");}运行输出:
运行输出inspecting: backup-dbconsuming: backup-db对比第 14 行和第 20 行的两次匹配:
- 隔着共享引用匹配(
match &task): 因为被匹配的目标是引用&Task,编译器在解构Task::Run(name)时,会自动将模式变量name推导为只读引用&String(与 5.3 节在点号调用点自动补引用同源)。这次匹配只发生了只读借用,匹配结束后原变量task完好无损。 - 按值匹配(
match task): 被匹配的目标是拥有所有权的Task值本身。进入Task::Run(name)分支时,变体内部的String所有权被直接移动(Move)给了局部变量name,并在该分支结束时释放。 这会触发与结构体完全一致的部分移动(Partial Move)——如果取消第 26 行// println!("after move: {task:?}");的注释,编译器立刻报出error[E0382]: borrow of partially moved value: task。同理,如果你在&self只读方法里试图从引用背后按值移出枚举载荷,编译器会报error[E0507]。
把三种匹配目标摆在一起,正好与前几章的三件套完全对位:
| 被匹配的目标表达式 | 模式变量 name 的实际类型 |
所有权行为 | 匹配后原枚举变量状态 |
|---|---|---|---|
&task(只读引用) |
&String |
共享只读借用 | 继续有效(最常用的只读检查写法) |
&mut task(可变引用) |
&mut String |
独占可变借用 | 继续有效,且可在分支内原地修改载荷 |
task(枚举值本身) |
String |
按值移动(若非 Copy) |
已失效(载荷被移走消费) |
- 为什么没有
is / as强转:枚举变体不是独立类型,且将「检查标签」与「转换取值」拆成两步会留下脱节漏洞;Rust 强制通过模式(Pattern)按形状解构,将核对标签与取出载荷焊死在一步之内。 - 模式匹配不等于
match:let 模式 = 表达式;本身就是模式匹配。积类型(元组 /struct)只有 1 种可能性,模式必然成功(不可反驳模式),可直接用单行let解构;和类型(enum)有多种可能性,单拿一个变体匹配可能失败(可反驳模式,裸写let报E0005),由此生长出match(全列穷尽检查E0004)、if let(失败跳过)与let else(失败退场、主干平铺)。 - 模式绑定遵循三件套所有权规则:匹配
&val借出&T,匹配&mut val借出&mut T,匹配val则将非Copy载荷移走(触发部分移动E0382)。
至此,我们已经彻底打通了和类型(enum)的定义、内存布局与模式解构规则。下一节,我们将看到 Rust 如何用这套机制加上一个仅占几行的标准库枚举 Option<T>,正面解决困扰软件工程数十年的「空指针(null)」难题。
动手核对与小练习
Section titled “动手核对与小练习”- 不可反驳模式 vs 可反驳模式:为什么
let (a, b) = (1, 2);可以直接编译通过,而对enum Coin { Penny, Nickel(u8) }写let Coin::Nickel(val) = coin;却会报error[E0005]?如果我们要在不是Nickel时直接从当前函数return,应该怎么改写这行let? - 所有权与模式匹配诊断:阅读下面这段代码,判断第 13 行
println!("{:?}", msg);能否通过编译?如果不能,会报什么错误码,以及如何只改一个字符让它通过编译?#[derive(Debug)]enum Message {Text(String),Ping,}fn main() {let msg = Message::Text(String::from("hello"));if let Message::Text(content) = msg {println!("got: {content}");}println!("{:?}", msg); // 第 13 行}在 Rust Playground 中运行
点击核对参考答案
- 为什么元组能直接
let而枚举不行:- 元组
(i32, i32)是积类型,只有唯一一种合法可能性,模式(a, b)必然匹配成功(不可反驳模式);而Coin是和类型,存在Penny和Nickel两种可能性,单写let Coin::Nickel(val) = coin;在遇到Penny时会匹配失败(可反驳模式),因此被编译器以E0005拦截。 - 要在匹配失败时直接提前返回,只需在后面补上
else发散块(变成let else):let Coin::Nickel(val) = coin else {return;};在 Rust Playground 中运行
- 元组
- 所有权诊断与修复:
- 不能通过编译,第 13 行会报
error[E0382]: borrow of partially moved value: msg。因为第 9 行if let Message::Text(content) = msg是对msg按值匹配,内部的String所有权被移动给了content,导致msg在if let结束后失效。 - 只改一个字符修复:将第 9 行改为隔着引用匹配
if let Message::Text(content) = &msg(加一个&),此时content变为&String,只发生只读借用,msg在第 13 行依然完好可用。
- 不能通过编译,第 13 行会报