课程概览 · 第 8 章

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

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

当一个值只可能处于若干互斥状态时,用 enum 表达它。它比"状态码 + 若干可空字段"更可靠:每个状态拥有自己的数据,处理者必须覆盖全部可能。第 7 章的 Task 里已经出现了一个 TaskState 字段,本章把它变成主角:带数据的状态、可观测的非法转移,以及 OptionResultBox 各自的适用边界。

枚举对互斥业务状态、Option 正常缺失、Result 可恢复失败和 Box 递归结构的建模选择
图:状态与数据绑定在同一变体上;是否缺失、如何失败、是否递归都由相应类型明示。

学习目标与默认选择

学完本章你应当能够:

  • 用带 payload 的枚举变体表达互斥状态,避免"不可能的布尔组合"。
  • 把合法状态转移收进返回 Result<_, TransitionError> 的方法,让非法转移可观测。
  • 区分三种建模工具:Option 表正常缺失、Result 表可行动失败、自定义枚举表业务状态。
  • 知道递归枚举为什么需要 Box,且只在递归数据上使用它。

默认选择:互斥状态用自定义枚举;"查无此项"用 Option;"这次调用失败了、调用方要处理"用 Result。不要用 BoxRc 或外部 crate 作为枚举的默认配件。

带数据的状态:TaskState

结构体要求字段同时存在,是"积类型";枚举在多个变体中恰好选一个,是"和类型"。把状态和它的数据绑进同一个变体,非法组合从类型上就不存在了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TaskState {
/// 待办:还没有任何时间戳。
Todo,
/// 进行中:必须携带开始时间。
InProgress { started_at: u64 },
/// 已完成:必须携带完成时间。
Done { finished_at: u64 },
/// 已取消:必须携带原因。
Cancelled { reason: String },
}

fn main() {
let states = vec![
TaskState::Todo,
TaskState::InProgress { started_at: 100 },
TaskState::Done { finished_at: 250 },
TaskState::Cancelled { reason: String::from("duplicate of #7") },
];
// 四个状态互斥:一个 TaskState 值同一时刻只能是其中之一
assert_eq!(states.len(), 4);
assert_eq!(states[3], TaskState::Cancelled { reason: String::from("duplicate of #7") });
}

对比布尔组合的写法:

1
2
3
4
5
6
7
8
9
10
11
12
// 反例(无法编译是有意的展示,这里是设计层面的反例):
// struct TaskFlags {
// in_progress: bool,
// done: bool,
// cancelled: bool,
// started_at: u64, // Todo 状态下没有意义
// finished_at: u64, // Todo 状态下没有意义
// reason: String, // 未取消时是空字符串占位
// }
//
// 这套字段允许构造出 done == true && cancelled == true 的"不可能状态",
// 且每个时间戳在错误状态下成为必须解释的占位值。运行时要靠层层 if 才能挡住非法组合。

这不是语法错误,而是设计反例:布尔组合允许 done && cancelled 同时为真,时间戳在非法状态下成为占位负担。枚举让"完成必须有完成时间、取消必须有原因"成为类型结构本身,而不是运行时检查。

状态转移:非法路径返回 Err

状态机的价值在于哪些转移不合法。把转移规则收进方法,非法转移就成了可观测的 Err,而不是悄悄变成另一个状态。

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
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TaskState {
Todo,
InProgress { started_at: u64 },
Done { finished_at: u64 },
Cancelled { reason: String },
}

#[derive(Debug, PartialEq, Eq)]
pub enum TransitionError {
/// 已经在进行的任务不能再次开始。
AlreadyInProgress,
/// 未开始的任务不能直接完成。
NotStarted,
/// 已完成是终态。
AlreadyDone,
/// 已取消是终态。
AlreadyCancelled,
/// 完成时间不能早于开始时间。
FinishedBeforeStarted { started_at: u64, finished_at: u64 },
}

impl TaskState {
pub fn start(self, started_at: u64) -> Result<TaskState, TransitionError> {
match self {
TaskState::Todo => Ok(TaskState::InProgress { started_at }),
TaskState::InProgress { .. } => Err(TransitionError::AlreadyInProgress),
TaskState::Done { .. } => Err(TransitionError::AlreadyDone),
TaskState::Cancelled { .. } => Err(TransitionError::AlreadyCancelled),
}
}

pub fn finish(self, finished_at: u64) -> Result<TaskState, TransitionError> {
match self {
TaskState::InProgress { started_at } => {
if finished_at < started_at {
Err(TransitionError::FinishedBeforeStarted { started_at, finished_at })
} else {
Ok(TaskState::Done { finished_at })
}
}
// 关键:Todo -> Done 是非法转移,必须显式拒绝
TaskState::Todo => Err(TransitionError::NotStarted),
TaskState::Done { .. } => Err(TransitionError::AlreadyDone),
TaskState::Cancelled { .. } => Err(TransitionError::AlreadyCancelled),
}
}

pub fn cancel(self, reason: String) -> Result<TaskState, TransitionError> {
match self {
// Cancelled 是终态:任何状态都可以取消,但取消后不能再变
TaskState::Cancelled { .. } => Err(TransitionError::AlreadyCancelled),
other => Ok(match other {
TaskState::Todo => TaskState::Cancelled { reason },
TaskState::InProgress { .. } => TaskState::Cancelled { reason },
TaskState::Done { .. } => TaskState::Cancelled { reason },
TaskState::Cancelled { .. } => unreachable!(),
}),
}
}
}

fn main() {
// 合法路径:Todo -> InProgress -> Done
let task = TaskState::Todo;
let task = task.start(100).unwrap();
assert_eq!(task, TaskState::InProgress { started_at: 100 });
let task = task.finish(250).unwrap();
assert_eq!(task, TaskState::Done { finished_at: 250 });

// 非法转移一:Todo 不能直接 Done
let fresh = TaskState::Todo;
assert_eq!(
fresh.finish(250),
Err(TransitionError::NotStarted)
);

// 非法转移二:Done 是终态,不能再开始
assert_eq!(
task.start(300),
Err(TransitionError::AlreadyDone)
);

// 非法转移三:Cancelled 是终态
let cancelled = TaskState::Todo.cancel(String::from("duplicate")).unwrap();
assert_eq!(
cancelled.start(100),
Err(TransitionError::AlreadyCancelled)
);

// 非法转移四:完成时间早于开始时间
let running = TaskState::InProgress { started_at: 200 };
assert_eq!(
running.finish(100),
Err(TransitionError::FinishedBeforeStarted { started_at: 200, finished_at: 100 })
);
}

这些断言就是状态机的验收标准:

  • Todo -> InProgressInProgress -> DoneOk
  • Todo -> Done 返回 Err(NotStarted)——未开始的任务不能直接完成。
  • DoneCancelled 是终态,任何后续转移都 Err

cancel 里的写法也值得注意:非终态统一落到 other 分支,用内层 match 把每种状态映射到带原因的 Cancelled。如果将来新增变体(如 Paused),内层 match 不穷尽会直接编译失败——演进保护网已经就位(第 9 章展开)。

三种变体形态

变体的形态由"这个状态带不带数据、带几个、要不要名字"决定:

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
#[derive(Debug)]
pub enum Message {
/// 单元变体:不带数据,纯状态标记。
Ping,
/// 元组变体:带一个匿名值,像元组结构体。
Rename(String),
/// 结构体变体:带命名字段,多个数据各得其所。
Reschedule { from: u64, to: u64 },
}

fn describe(message: &Message) -> &'static str {
match message {
Message::Ping => "connectivity check",
Message::Rename(_) => "title change",
Message::Reschedule { .. } => "time change",
}
}

fn main() {
assert_eq!(describe(&Message::Ping), "connectivity check");
assert_eq!(describe(&Message::Rename(String::from("v2"))), "title change");
assert_eq!(
describe(&Message::Reschedule { from: 100, to: 200 }),
"time change"
);
}

TaskState 全部采用结构体变体(Todo 是单元变体),因为时间戳和原因都值得有名字。只有一个字段的轻量数据用元组变体即可。

Option 表正常缺失,Result 表可行动失败

标准库里最重要的两个枚举不是业务状态,而是两种"值可能不存在"的语义:

  • Option<T>:正常的缺失。 查找的键不在、可选项未填,这不是错误,是程序预期内的分支。
  • Result<T, E>:可行动的失败。 调用方必须处理:重试、上报、改走别的路径。
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)]
struct Task {
id: u64,
title: String,
done: bool,
}

/// 返回 Option:查不到是正常情况,不是失败。
fn find_title(tasks: &[Task], id: u64) -> Option<&str> {
tasks.iter().find(|task| task.id == id).map(|task| task.title.as_str())
}

/// 返回 Result:超出合理范围是可行动的失败,调用方必须处理。
#[derive(Debug, PartialEq, Eq)]
enum LimitError {
TooHigh { requested: u64, max: u64 },
}

fn parse_limit(raw: &str) -> Result<u64, LimitError> {
let value: u64 = raw.parse().map_err(|_| LimitError::TooHigh { requested: 0, max: 100 })?;
if value > 100 {
Err(LimitError::TooHigh { requested: value, max: 100 })
} else {
Ok(value)
}
}

fn main() {
let tasks = vec![
Task { id: 1, title: String::from("ship release notes"), done: false },
Task { id: 2, title: String::from("write postmortem"), done: true },
];

// Option:缺失是正常分支,用 unwrap_or 给默认值即可
assert_eq!(find_title(&tasks, 1), Some("ship release notes"));
assert_eq!(find_title(&tasks, 99), None);
assert_eq!(find_title(&tasks, 99).unwrap_or("(missing)"), "(missing)");

// Result:失败需要调用方行动,错误类型带上下文
assert_eq!(parse_limit("50"), Ok(50));
assert_eq!(
parse_limit("500"),
Err(LimitError::TooHigh { requested: 500, max: 100 })
);
}

选错的信号:把"键不存在"建模成 Err(调用方被迫处理一个其实正常的分支),或把"配置非法"建模成 None(丢失了失败原因,调用方无法行动)。自定义业务枚举(TaskState)、OptionResult 三者各管一段,不要互相顶替。

递归数据:Box 的唯一常规出场

如果一个变体的数据里要再放"同一种类型",枚举的大小在编译期就无法确定——Expr 里套 Expr,可以无限深。Box<T> 是指针大小的间接层,把"无限大"变成"指针指向的另一个值":

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)]
enum Expr {
Number(i64),
Add(Box<Expr>, Box<Expr>),
Mul(Box<Expr>, Box<Expr>),
}

impl Expr {
fn eval(&self) -> i64 {
match self {
Expr::Number(n) => *n,
Expr::Add(left, right) => left.eval() + right.eval(),
Expr::Mul(left, right) => left.eval() * right.eval(),
}
}
}

fn main() {
// (2 + 3) * 4 = 20
let expr = Expr::Mul(
Box::new(Expr::Add(Box::new(Expr::Number(2)), Box::new(Expr::Number(3)))),
Box::new(Expr::Number(4)),
);
assert_eq!(expr.eval(), 20);

// 1 + (2 * 3) = 7
let expr2 = Expr::Add(
Box::new(Expr::Number(1)),
Box::new(Expr::Mul(Box::new(Expr::Number(2)), Box::new(Expr::Number(3)))),
);
assert_eq!(expr2.eval(), 7);
}

不加 Box 的递归枚举无法通过编译:

1
2
3
4
5
6
7
8
// 无法编译:递归类型大小无法确定
enum Expr {
Number(i64),
Add(Expr, Expr), // 错误:recursive type has infinite size
Mul(Expr, Expr),
}
// 编译器要求每个值的大小在编译期确定;Expr 内含 Expr 会无限展开。
// 修正:用 Box<Expr> 引入指针大小的间接层(见上方可运行示例)。

只有递归数据结构才需要这样做。不要因为"变体可能很大"就默认装箱:枚举的大小等于最大变体的大小,普通业务枚举(如 TaskState)直接放数据即可;移动成本是否成为问题,先测量再说(第 19 章)。

边界与失败场景

  • 变体数据在错误状态下成为占位值是布尔组合的通病;枚举变体让数据随状态存在或不存在。
  • 终态要显式建模。 Done/Cancelled 之后的任何转移都应 Err,而不是被静默忽略或 panic。
  • OptionResult 不互换。 混用会迫使调用方处理不存在的分支,或丢失可行动的失败上下文。
  • Box 只解决递归大小问题。 它引入一次堆分配和一层指针间接;为"可能的性能"提前装箱是反向优化。

为什么可行

运行时枚举通常是一个判别符加当前变体的数据,编译器按最大变体分配空间(具体布局由编译器决定,布局优化不能作为 FFI 契约;与 C 互操作时须显式 #[repr(C)],第 17 章展开)。Option<&T> 这类特殊情况还能复用空指针等无效值而不额外占用判别符空间。

更重要的是编译期保证:match 要求覆盖全部变体(第 9 章的穷尽性),所以"新增变体"会变成编译错误而不是遗漏分支;转移规则收在方法里,非法路径只可能以 Err 的形式出现,状态机的违例无法静默发生。这与第 7 章"私有字段 + 校验构造器"是同一思想的两种形态:让非法状态难以表示。

常见误区

  1. 用布尔组合表达互斥状态。 done && cancelled 同时为真的组合无法从类型上排除,层层 if 只是补丁。
  2. None 当失败。 查无数据是正常分支;真正需要调用方行动的失败用 Result 带上下文返回。
  3. 转移方法返回新状态但不报错。 非法转移若被静默忽略或 panic,调用方失去处理机会;返回 Result<_, TransitionError> 让失败可观测。
  4. 给普通枚举到处加 Box 装箱只用于递归类型;普通变体直接内联,需要时再测量。
  5. 新增变体后靠 _ 通配兜底。 兜底分支会吞掉编译器的穷尽性检查,新状态可能落进错误的默认路径(下一章展开)。

自测

  1. TaskState::Todo.finish(250) 返回什么?为什么 Todo -> Done 必须非法?
    答案方向: 返回 Err(TransitionError::NotStarted);未开始的任务跳过执行期直接完成,会造出没有 started_at 的完成记录,破坏状态机语义。
  2. in_progress: bool + done: bool + cancelled: bool 代替 TaskState 有什么问题?
    答案方向: 允许 done && cancelled 等不可能组合,且时间戳、原因在错误状态下需要占位值;运行时检查无法穷尽。
  3. "按 ID 查任务标题,可能查不到"应返回 Option 还是 Result
    答案方向: Option<&str>;查不到是正常缺失。若是"读取任务文件失败"这类需要行动的失败才用 Result
  4. 递归枚举为什么必须用 Box
    答案方向: 变体直接内含自身类型会让大小无限展开;Box 是指针大小的间接层,递归部分放堆上。
  5. Done 状态下再调用 cancel 应该怎样?
    答案方向: 按业务规则决定;本文模型中允许(Done -> Cancelled 合理,如撤销误标完成),但 Cancelled 之后的一切转移都 Err——终态规则必须显式写进方法并测试。

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