课程概览 · 第 8 章
上一篇:结构体:组织数据、封装状态与编写方法
下一篇:模式匹配:用穷尽分支处理业务状态
当一个值只可能处于若干互斥状态时,用 enum 表达它。它比"状态码 + 若干可空字段"更可靠:每个状态拥有自己的数据,处理者必须覆盖全部可能。第 7 章的 Task 里已经出现了一个 TaskState 字段,本章把它变成主角:带数据的状态、可观测的非法转移,以及 Option、Result、Box 各自的适用边界。
图:状态与数据绑定在同一变体上;是否缺失、如何失败、是否递归都由相应类型明示。学习目标与默认选择
学完本章你应当能够:
- 用带 payload 的枚举变体表达互斥状态,避免"不可能的布尔组合"。
- 把合法状态转移收进返回
Result<_, TransitionError> 的方法,让非法转移可观测。 - 区分三种建模工具:
Option 表正常缺失、Result 表可行动失败、自定义枚举表业务状态。 - 知道递归枚举为什么需要
Box,且只在递归数据上使用它。
默认选择:互斥状态用自定义枚举;"查无此项"用 Option;"这次调用失败了、调用方要处理"用 Result。不要用 Box、Rc 或外部 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") }, ]; 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 }) } } 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 { 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() { 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 });
let fresh = TaskState::Todo; assert_eq!( fresh.finish(250), Err(TransitionError::NotStarted) );
assert_eq!( task.start(300), Err(TransitionError::AlreadyDone) );
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 -> InProgress 与 InProgress -> Done 是 Ok。Todo -> Done 返回 Err(NotStarted)——未开始的任务不能直接完成。Done、Cancelled 是终态,任何后续转移都 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, }
fn find_title(tasks: &[Task], id: u64) -> Option<&str> { tasks.iter().find(|task| task.id == id).map(|task| task.title.as_str()) }
#[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 }, ];
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)");
assert_eq!(parse_limit("50"), Ok(50)); assert_eq!( parse_limit("500"), Err(LimitError::TooHigh { requested: 500, max: 100 }) ); }
|
选错的信号:把"键不存在"建模成 Err(调用方被迫处理一个其实正常的分支),或把"配置非法"建模成 None(丢失了失败原因,调用方无法行动)。自定义业务枚举(TaskState)、Option、Result 三者各管一段,不要互相顶替。
递归数据: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() { 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);
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。 Option 与 Result 不互换。 混用会迫使调用方处理不存在的分支,或丢失可行动的失败上下文。Box 只解决递归大小问题。 它引入一次堆分配和一层指针间接;为"可能的性能"提前装箱是反向优化。
为什么可行
运行时枚举通常是一个判别符加当前变体的数据,编译器按最大变体分配空间(具体布局由编译器决定,布局优化不能作为 FFI 契约;与 C 互操作时须显式 #[repr(C)],第 17 章展开)。Option<&T> 这类特殊情况还能复用空指针等无效值而不额外占用判别符空间。
更重要的是编译期保证:match 要求覆盖全部变体(第 9 章的穷尽性),所以"新增变体"会变成编译错误而不是遗漏分支;转移规则收在方法里,非法路径只可能以 Err 的形式出现,状态机的违例无法静默发生。这与第 7 章"私有字段 + 校验构造器"是同一思想的两种形态:让非法状态难以表示。
常见误区
- 用布尔组合表达互斥状态。
done && cancelled 同时为真的组合无法从类型上排除,层层 if 只是补丁。 - 把
None 当失败。 查无数据是正常分支;真正需要调用方行动的失败用 Result 带上下文返回。 - 转移方法返回新状态但不报错。 非法转移若被静默忽略或 panic,调用方失去处理机会;返回
Result<_, TransitionError> 让失败可观测。 - 给普通枚举到处加
Box。 装箱只用于递归类型;普通变体直接内联,需要时再测量。 - 新增变体后靠
_ 通配兜底。 兜底分支会吞掉编译器的穷尽性检查,新状态可能落进错误的默认路径(下一章展开)。
自测
TaskState::Todo.finish(250) 返回什么?为什么 Todo -> Done 必须非法?
答案方向: 返回 Err(TransitionError::NotStarted);未开始的任务跳过执行期直接完成,会造出没有 started_at 的完成记录,破坏状态机语义。- 用
in_progress: bool + done: bool + cancelled: bool 代替 TaskState 有什么问题?
答案方向: 允许 done && cancelled 等不可能组合,且时间戳、原因在错误状态下需要占位值;运行时检查无法穷尽。 - "按 ID 查任务标题,可能查不到"应返回
Option 还是 Result?
答案方向: Option<&str>;查不到是正常缺失。若是"读取任务文件失败"这类需要行动的失败才用 Result。 - 递归枚举为什么必须用
Box?
答案方向: 变体直接内含自身类型会让大小无限展开;Box 是指针大小的间接层,递归部分放堆上。 Done 状态下再调用 cancel 应该怎样?
答案方向: 按业务规则决定;本文模型中允许(Done -> Cancelled 合理,如撤销误标完成),但 Cancelled 之后的一切转移都 Err——终态规则必须显式写进方法并测试。
下一篇把状态机拆给模式匹配处理:模式匹配:用穷尽分支处理业务状态。