课程概览 · 第 8 章

上一篇:结构体:组织数据、封装状态与编写方法

下一篇:模式匹配:用穷尽分支处理业务状态

当一个值只可能处于若干互斥状态时,用 enum 把状态和该状态需要的数据放在一起。它排除“同时完成又取消”这样的组合,但时间先后、输入格式和合法转移仍需要代码检查。

互斥状态、保留原状态的转移、正常缺失、可恢复失败与递归类型
图:枚举表达状态;本章转移借用旧状态,成功返回新状态,失败保留原状态。

前置知识与学习目标

本章需要结构体、借用、Result 与 ?;匹配分支先按示例阅读,第 9 章系统解释模式。学完后应能:

  • 用变体绑定状态所需数据,并说明类型仍未验证哪些约束。
  • 用借用接收者构造新状态,验证失败后旧状态仍可用。
  • 区分 Option、Result 与 Result<Option<T>, E>。
  • 用 Box 为递归类型提供固定大小的间接层。

所有源码仅依赖标准库、Edition 2024,独立示例各自保存为一份完整 .rs 文件。它们不要求拼装成一个应用。

状态与数据绑定

枚举有单元变体、元组变体和带命名字段的结构体变体。字段含义需要明确时,优先命名字段:Done 必有完成时间,Cancelled 必有原因;构造 Done { finished_at: 0 } 仍能通过编译,是否接受零时间是另一个业务规则。

转移方法:失败保留原状态

下面的 start 和 finish 都接收 &self,不会取走或修改旧状态。成功返回新的状态,是否替换原值由调用者决定;失败后可以继续读取旧状态、展示错误或重试。没有新增取消 API,Cancelled 仅表示已经取消的状态。

独立示例(标准库;完整文件 state_transitions.rs):状态、错误、转移方法与边界断言全部在本块。

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
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
#[derive(Debug, PartialEq, Eq)]
enum TaskState {
Todo,
InProgress { started_at: u64 },
Done { finished_at: u64 },
Cancelled { reason: String },
}

#[derive(Debug, PartialEq, Eq)]
enum TransitionError {
NotStarted,
FinishedBeforeStarted,
InvalidTransition,
}

impl TaskState {
fn start(&self, started_at: u64) -> Result<Self, TransitionError> {
match self {
Self::Todo => Ok(Self::InProgress { started_at }),
Self::InProgress { .. }
| Self::Done { .. }
| Self::Cancelled { .. } => {
Err(TransitionError::InvalidTransition)
}
}
}

fn finish(&self, finished_at: u64) -> Result<Self, TransitionError> {
match self {
Self::InProgress { started_at }
if finished_at >= *started_at => {
Ok(Self::Done { finished_at })
}
Self::InProgress { .. } => {
Err(TransitionError::FinishedBeforeStarted)
}
Self::Todo => Err(TransitionError::NotStarted),
Self::Done { .. } | Self::Cancelled { .. } => {
Err(TransitionError::InvalidTransition)
}
}
}
}

fn main() {
let todo = TaskState::Todo;
let running = todo.start(100).unwrap();
assert_eq!(todo, TaskState::Todo);
assert_eq!(
running.finish(50), Err(TransitionError::FinishedBeforeStarted)
);
assert_eq!(running, TaskState::InProgress { started_at: 100 });
let done = running.finish(250).unwrap();
assert_eq!(done, TaskState::Done { finished_at: 250 });
assert_eq!(running, TaskState::InProgress { started_at: 100 });
assert_eq!(todo.finish(250), Err(TransitionError::NotStarted));
assert_eq!(done.start(300), Err(TransitionError::InvalidTransition));
let cancelled = TaskState::Cancelled {
reason: String::from("duplicate"),
};
assert_eq!(
cancelled.finish(300), Err(TransitionError::InvalidTransition)
);
}

匹配 &self 时,started_at 绑定为 &u64,比较要解引用。新状态只包含当前转移需要的时间,不需要克隆旧字符串。若要原地替换,可在成功后显式赋值,例如 state = state.start(100)?;失败时右侧提前返回,赋值不会发生。这里的转移规则由分支实现,穷尽检查只能确保未遗漏变体,不能证明业务规则正确。

Option:正常缺失

Option<T> 有 Some(T) 与 None。查询不到记录、可选配置未填写、迭代器结束可以是正常缺失;读取失败、输入损坏等需要失败原因时使用 Result。

独立示例(标准库;完整文件 optional_title.rs):查询借用标题,不复制文本。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
struct Task { id: u64, title: String }

fn find_title(tasks: &[Task], id: u64) -> Option<&str> {
tasks.iter().find(|task| task.id == id)
.map(|task| task.title.as_str())
}

fn main() {
let tasks = vec![Task {
id: 7,
title: String::from("ship release notes"),
}];
assert_eq!(find_title(&tasks, 7), Some("ship release notes"));
assert_eq!(find_title(&tasks, 99), None);
assert_eq!(find_title(&[], 7), None);
}

观察 Option<String> 时,as_ref() 得到 Option<&String>,as_deref() 得到 Option<&str>;想取走内容并留下 None,用 take()。map 适合单步转换,返回 Option 的转换可以用 and_then 串联;业务分支较多时直接 match 更清楚。

独立示例(标准库;完整文件 option_views.rs):先借用观察,再移出内容。

1
2
3
4
5
6
7
8
fn main() {
let mut title = Some(String::from("ship release notes"));
assert_eq!(title.as_ref().map(String::len), Some(18));
assert_eq!(title.as_deref(), Some("ship release notes"));
let owned = title.take().unwrap();
assert_eq!(owned, "ship release notes");
assert_eq!(title, None);
}

unwrap 遇到 None 会 panic,适合示例断言或已证明的不变量;运行期可缺失的输入应显式处理或用有意义的默认值,昂贵默认值可通过 unwrap_or_else 延迟构造。

Result 与 Result<Option, E>

Result<T, E> 把成功值与失败原因分开。不要把调用者能处理的错误全压成 None,也不要把正常查询缺失强行叫作失败。

独立示例(标准库;完整文件 port_result.rs):保留具名解析错误。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#[derive(Debug, PartialEq, Eq)]
enum PortError { NotANumber, OutOfRange }

fn parse_port(raw: &str) -> Result<u16, PortError> {
let value = raw.parse::<u32>()
.map_err(|_| PortError::NotANumber)?;
u16::try_from(value).map_err(|_| PortError::OutOfRange)
}

fn main() {
assert_eq!(parse_port("8080"), Ok(8080));
assert_eq!(parse_port("bad"), Err(PortError::NotANumber));
assert_eq!(parse_port("70000"), Err(PortError::OutOfRange));
}

? 在成功时取出值,在失败时从当前函数提前返回,必要时通过 From 转换错误类型。Result 通常带有 must_use 诊断,但调用者仍可以有意丢弃,不能说类型强制业务上正确处理每个失败。第 13 章负责完整错误类型与上下文设计。

独立示例(标准库;完整文件 optional_port.rs):可选端口既可能缺失又可能格式错误,与上一块无代码依赖。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
fn optional_port(raw: Option<&str>)
-> Result<Option<u16>, std::num::ParseIntError>
{
match raw {
None => Ok(None),
Some(text) => Ok(Some(text.parse::<u16>()?)),
}
}

fn main() {
assert_eq!(optional_port(None).unwrap(), None);
assert_eq!(optional_port(Some("8080")).unwrap(), Some(8080));
assert!(optional_port(Some("bad")).is_err());
}

这里 Ok(None) 表示调用成功且没配置端口,Ok(Some(8080)) 表示存在合法值,Err 表示提供了无效值。三种语义不应靠约定混在一个整数或空字符串里。

递归枚举与 Box

类型直接按值包含自身,会让大小无法确定。Box<T> 提供固定大小的拥有指针,在递归边上切断这种展开;这与普通枚举是否很大是不同问题。

独立示例(标准库;完整文件 recursive_expr.rs):完整表达式与求值入口。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
enum Expr {
Number(i64),
Add(Box<Expr>, Box<Expr>),
}

fn eval(expr: &Expr) -> i64 {
match expr {
Expr::Number(value) => *value,
Expr::Add(left, right) => eval(left) + eval(right),
}
}

fn main() {
let expr = Expr::Add(
Box::new(Expr::Number(20)),
Box::new(Expr::Number(22)),
);
assert_eq!(eval(&expr), 42);
}

此例 Box::new 为非零大小的表达式分配堆空间。普通业务枚举不必预先装箱;是否优化较大变体的移动成本应依据测量。

深入阅读:枚举布局的保证范围

枚举当前变体占用共享槽位及 Option 引用的 niche 布局
图:只保存当前有效 payload;示意位置与字段顺序不是默认 ABI 承诺。

概念上,枚举存储当前变体的判别信息和 payload;实现可利用 niche 等方式合并判别信息,并不保证一定有单独的标签字段。Option、Result 本身不要求堆分配;Option<String> 中字符缓冲区的分配来自内部 String。

对符合文档条件的引用、NonZero* 等类型,Option 的表示保证允许 Option<T> 与 T 具有相同大小和对齐等属性。不要推广到所有 T,也不要凭默认枚举的布局定义 FFI 或二进制协议。类型结构排除不了所有无效时间和空字符串,这些输入约束仍需校验。

自测

  1. Done { finished_at: 0 } 能构造吗?“类型绑定数据”保证了什么、没有保证什么?
  2. running.finish(50) 失败后还能读取 running 吗?成功后原状态是否自动改变?
  3. 查询没有记录与文件读取失败分别用什么类型表达?
  4. Result<Option<u16>, E> 的三种结果分别意味着什么?
  5. 为什么递归表达式需要间接层?所有 Option<T> 都与 T 一样大吗?

参考答案

  1. 能;类型保证完成状态携带时间,但时间数值是否有效需要业务校验。
  2. 能,方法只借用旧值;成功返回新值,也不会自动替换旧值。
  3. 正常未找到用 Option,可恢复失败用带原因的 Result。
  4. 成功缺失、成功存在、操作失败;None 不等于 Err。
  5. 间接层让递归类型大小有限;相同布局只适用于文档明确保证的类型。