课程概览 · 第 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 为非零大小的表达式分配堆空间。普通业务枚举不必预先装箱;是否优化较大变体的移动成本应依据测量。
深入阅读:枚举布局的保证范围
图:只保存当前有效 payload;示意位置与字段顺序不是默认 ABI 承诺。概念上,枚举存储当前变体的判别信息和 payload;实现可利用 niche 等方式合并判别信息,并不保证一定有单独的标签字段。Option、Result 本身不要求堆分配;Option<String> 中字符缓冲区的分配来自内部 String。
对符合文档条件的引用、NonZero* 等类型,Option 的表示保证允许 Option<T> 与 T 具有相同大小和对齐等属性。不要推广到所有 T,也不要凭默认枚举的布局定义 FFI 或二进制协议。类型结构排除不了所有无效时间和空字符串,这些输入约束仍需校验。
自测
Done { finished_at: 0 } 能构造吗?“类型绑定数据”保证了什么、没有保证什么?running.finish(50) 失败后还能读取 running 吗?成功后原状态是否自动改变?- 查询没有记录与文件读取失败分别用什么类型表达?
Result<Option<u16>, E> 的三种结果分别意味着什么?- 为什么递归表达式需要间接层?所有
Option<T> 都与 T 一样大吗?
参考答案
- 能;类型保证完成状态携带时间,但时间数值是否有效需要业务校验。
- 能,方法只借用旧值;成功返回新值,也不会自动替换旧值。
- 正常未找到用
Option,可恢复失败用带原因的 Result。 - 成功缺失、成功存在、操作失败;
None 不等于 Err。 - 间接层让递归类型大小有限;相同布局只适用于文档明确保证的类型。