课程概览 · 第 9 章

上一篇:枚举:把状态、缺失和失败建模为类型

下一篇:泛型与 trait:复用行为,同时保持接口清晰

模式匹配不仅是更好看的条件判断。它能把枚举、结构体和嵌套数据拆开,同时要求代码处理所有状态,是 Rust 业务代码安全演进的关键工具。第 8 章的状态机把非法转移挡在了方法里,本章负责消费这些状态:把每个 TaskState 变成可显示的文本、可执行的分支,并且保证新增状态时编译器会逼你审查每处处理代码。

match 穷尽性对枚举演进的保护、不同模式语法的控制流边界,以及按值和按引用解构
图:完整 match 守住状态演进;更窄的模式语法解决单分支、消费和早退,解构方式决定所有权。

学习目标与默认选择

学完本章你应当能够:

  • 用穷尽的 match(无 _)把 TaskState 转换为显示文本,并解释为什么这是演进保护网。
  • 区分按值匹配与按引用匹配(match &task),避免无意移动 StringVec 字段。
  • 在正确场景选择 if letwhile letlet else@ 绑定、范围模式与匹配守卫。

默认选择:处理完整状态机用 match 且不写 _;只关心一个分支用 if let;失败即退出用 let else_ 通配留给真正"其余情况语义相同"的场景(如日志),核心业务分支不要用它兜底。

穷尽 match:状态到文本

match 是表达式,所有分支返回同一类型。核心状态机的 match 不写 _,让新增变体直接变成编译错误:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
#[derive(Debug)]
pub enum TaskState {
Todo,
InProgress { started_at: u64 },
Done { finished_at: u64 },
Cancelled { reason: String },
}

/// 把任务状态转换为可显示的文本。穷尽列出全部变体,不用 `_`。
fn status_text(state: &TaskState) -> String {
match state {
TaskState::Todo => String::from("todo"),
TaskState::InProgress { started_at } => {
format!("in progress since t={started_at}")
}
TaskState::Done { finished_at } => {
format!("done at t={finished_at}")
}
TaskState::Cancelled { reason } => {
format!("cancelled: {reason}")
}
}
}

fn main() {
assert_eq!(status_text(&TaskState::Todo), "todo");
assert_eq!(
status_text(&TaskState::InProgress { started_at: 100 }),
"in progress since t=100"
);
assert_eq!(
status_text(&TaskState::Done { finished_at: 250 }),
"done at t=250"
);
assert_eq!(
status_text(&TaskState::Cancelled { reason: String::from("duplicate") }),
"cancelled: duplicate"
);
}

匹配引用时,绑定自动成为引用:started_at&u64reason&String,直接用于 format! 即可。这里没有任何字段被移动。

为什么不用 _

假设给 TaskState 新增一个 Paused { since: u64 } 变体:

  • 穷尽的 match 立即编译失败:non-exhaustive patterns,错误信息精确指出缺 TaskState::Paused { .. }。你被迫停下来,决定暂停中的任务该显示什么、能否被取消、能否完成。
  • _match 继续编译,新状态静默落进兜底分支,显示成错误的文本、走错误的流程。

编译错误就是演进保护网:新增变体迫使你审查所有状态处理器。丢掉 _,等于丢掉这张网。

引用模式:匹配之后数据仍可用

match &task 只借用:模式绑定的是引用,原值保持完整。对比按值匹配会移动非 Copy 字段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
#[derive(Debug)]
pub struct Task {
pub id: u64,
pub title: String,
pub state: TaskState,
}

#[derive(Debug)]
pub enum TaskState {
Todo,
InProgress { started_at: u64 },
Done { finished_at: u64 },
Cancelled { reason: String },
}

fn main() {
let task = Task {
id: 7,
title: String::from("ship release notes"),
state: TaskState::Cancelled { reason: String::from("duplicate of #3") },
};

// 匹配引用:绑定是 &String / &u64,task 之后仍完整可用
match &task {
Task { id, title, state: TaskState::Cancelled { reason } } => {
assert_eq!(*id, 7);
assert_eq!(title, "ship release notes");
assert_eq!(reason, "duplicate of #3");
}
_ => unreachable!(),
}

// task 未被移动:仍然可以整体使用
assert_eq!(task.title, "ship release notes");
println!("{task:?}");
}

若把 match &task 换成 match tasktitlereason 会被移动出去,task 变成部分移动状态,最后的 assert_eq!(task.title, ...) 就无法编译。只读场景一律匹配引用。

if let:只关心一个分支

if let 适合"命中一个模式就做事,其余情况确实没有动作":

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#[derive(Debug)]
pub enum TaskState {
Todo,
InProgress { started_at: u64 },
Done { finished_at: u64 },
Cancelled { reason: String },
}

fn main() {
let state = TaskState::InProgress { started_at: 100 };

// 只关心"进行中"这一种情况:取出开始时间做检查
if let TaskState::InProgress { started_at } = &state {
assert_eq!(*started_at, 100);
println!("running since t={started_at}");
}

// 其余变体命中不了,这里不需要 else 分支
if let TaskState::Done { .. } = &state {
unreachable!("state is InProgress");
}
}

判断标准很简单:如果 else 分支也有业务含义,回到完整 matchif let 只在"其余情况统一无动作"时使用。

while let:消费队列

while let 在循环条件里反复匹配,天然适合逐项消费队列:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
use std::collections::VecDeque;

fn main() {
let mut queue: VecDeque<&str> = VecDeque::new();
queue.push_back("ship release notes");
queue.push_back("write postmortem");
queue.push_back("review pull requests");

let mut processed = Vec::new();
// 每轮 pop_front 返回 Some(item) 就继续;返回 None(队列空)就结束
while let Some(item) = queue.pop_front() {
processed.push(item);
}

assert_eq!(processed, ["ship release notes", "write postmortem", "review pull requests"]);
assert!(queue.is_empty()); // 队列被消费干净
}

pop_front 返回 OptionSome 拿走一个元素的所有权,None 表示队列已空。FIFO 顺序由此保证。

let else:守卫式早退

模式不匹配就离开当前作用域,成功路径保持在最左侧,避免层层嵌套:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
#[derive(Debug, PartialEq)]
pub struct Task {
pub id: u64,
pub title: String,
}

/// 从命令行风格参数里解析任务:格式 "id:title"。
fn parse_task(raw: &str) -> Result<Task, String> {
// 守卫式早退:格式不对就立刻返回错误
let Some((id_part, title_part)) = raw.split_once(':') else {
return Err(format!("invalid task format: {raw:?}"));
};

let Ok(id) = id_part.trim().parse::<u64>() else {
return Err(format!("task id is not a number: {id_part:?}"));
};

if title_part.trim().is_empty() {
return Err(format!("task title is empty"));
}

Ok(Task {
id,
title: title_part.trim().to_string(),
})
}

fn main() {
let ok = parse_task("7: ship release notes").unwrap();
assert_eq!(ok.id, 7);
assert_eq!(ok.title, "ship release notes");

assert_eq!(
parse_task("no-colon-here"),
Err(String::from("invalid task format: \"no-colon-here\""))
);
assert_eq!(
parse_task("seven: title"),
Err(String::from("task id is not a number: \"seven\""))
);
assert_eq!(
parse_task("7: "),
Err(String::from("task title is empty"))
);
}

else 分支必须离开当前作用域(returnbreakcontinuepanic!),编译器强制这一点,所以 let 之后主流程可以放心使用解出的值。

@ 绑定、范围模式与匹配守卫

@ 让你在匹配子结构的同时绑定整个值;范围模式匹配数值区间;守卫给分支加布尔条件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#[derive(Debug)]
pub enum TaskState {
Todo,
InProgress { started_at: u64 },
Done { finished_at: u64 },
Cancelled { reason: String },
}

fn priority(state: &TaskState) -> &'static str {
match state {
// 范围模式 + @ 绑定:早于 t=50 开始的任务算历史遗留,其余进行中算高优先
TaskState::InProgress { started_at: t @ ..=50 } => {
let _ = t;
"legacy"
}
TaskState::InProgress { started_at } if *started_at >= 900 => "urgent",
TaskState::InProgress { .. } => "normal",
TaskState::Todo => "queued",
TaskState::Done { .. } => "none",
TaskState::Cancelled { .. } => "none",
}
}

fn main() {
// @ 绑定把整个 started_at 值绑定到 t
assert_eq!(priority(&TaskState::InProgress { started_at: 30 }), "legacy");
// 匹配守卫:附加布尔条件(注意绑定是引用,比较要解引用)
assert_eq!(priority(&TaskState::InProgress { started_at: 950 }), "urgent");
// 守卫不满足时落入后面的分支,而不是整个 match 失败
assert_eq!(priority(&TaskState::InProgress { started_at: 100 }), "normal");
assert_eq!(priority(&TaskState::Todo), "queued");
assert_eq!(priority(&TaskState::Done { finished_at: 999 }), "none");
}

三点注意:

  • @ 绑定t @ ..=50):既测试范围,又把值绑定到 t,两步合一。
  • 范围模式..=50900..):只适用于整数和字符这类可枚举范围。
  • 守卫if *started_at >= 900):给分支附加运行时条件;守卫失败时匹配继续尝试后面的分支,且守卫不能破坏穷尽性–priority 的核心 TaskState 分支依然全部列出。

穷尽性是演进保护网

把本章所有工具放回状态机的语境:第 8 章的 start / finish / cancelmatch 分派每个变体,本章的 status_text / prioritymatch 消费每个变体。它们共同的特点是没有 _

将来给 TaskStatePaused { since: u64 } 时,编译器会把这些 match 全部标红。被标红的每一处都是一个真实的产品决策点:暂停的任务显示什么?能直接完成吗?优先级算多少?穷尽性检查把这些决策从"上线后才发现漏了一个分支"提前到编译时刻。这就是"用穷尽分支处理业务状态"的含义:新增变体的成本是一次编译错误 + 一次有意识的审查,而不是一次生产事故。

边界与失败场景

  • 按值匹配会移动字段。 match task(无 &)把非 Copy 字段移进分支绑定;只读场景匹配 &task
  • 守卫不参与穷尽性。 if 条件失败只是"这个分支不适用",继续匹配后续分支;不要用守卫承担本该由模式变体表达的区分。
  • let else 必须发散。 else 分支只能 return / break / continue / panic!,写普通表达式无法编译。
  • while let 会消费数据。 匹配的是 pop_front 这类取走所有权的 Option;只想遍历不改容器时用 for 循环。

为什么可行

match 在编译期检查两件事:是否有遗漏的可能值,以及前面的分支是否已覆盖后面不可达的分支。枚举的变体集合在编译期完全已知,所以穷尽性可以精确判定;Option/Result 也一样–这就是第 8 章"调用方必须处理失败"在机制上的来源。

绑定模式的类型推导同样精确:匹配 &TaskState 时,模式里的 reason 自动是 &String;匹配 TaskState(按值)时是 String。模式在"拆数据"的同时完成了"决定数据怎么流动",这也是第 6 章所有权规则在语法层的直接体现。

常见误区

  1. 核心状态机用 _ 兜底。 新增变体静默落进兜底分支,穷尽性保护网失效;兜底只用于"其余情况语义确实相同"的场合。
  2. 只读场景按值匹配。 match task 移动 titlereason 等字段,后续使用原值时才发现;先想清楚要不要保留原值。
  3. if let 表达双分支业务。 else 也有含义时用 matchif let ... else,不要写成两个互补的 if let
  4. 在守卫里做重活。 守卫应是廉价的布尔判断;需要复杂逻辑时先收敛数据形态再匹配。
  5. while letfor 混淆。 前者逐项消费(容器被清空),后者只借用遍历;按是否要保留数据选择。

自测

  1. status_text 里删掉 TaskState::Cancelled 分支会发生什么?
    答案方向: 编译错误 non-exhaustive patterns,指出缺 Cancelled { .. };这是穷尽性检查在阻止你遗漏状态处理器。
  2. match &taskmatch task 的绑定类型有什么区别?
    答案方向: 前者绑定 &String&u64(借用,原值完整);后者绑定 Stringu64(非 Copy 字段被移动,原值部分失效)。
  3. 什么时候选 if let 而不是 match
    答案方向: 只关心一个变体、其余情况确实无动作时;else 有业务含义就回到 match
  4. let elseelse 分支有什么限制?为什么?
    答案方向: 必须发散(return/break/continue/panic);否则解构失败后变量未初始化却被后续代码使用,编译器用这条规则保证主流程变量一定有值。
  5. 匹配守卫失败后,匹配去向哪里?
    答案方向: 继续尝试后续分支,不会整个 match 失败;守卫只是给单个分支附加条件,不改变穷尽性结构。

下一篇把这些具体类型抽象成可复用的接口:泛型与 trait:复用行为,同时保持接口清晰