每个写过几年代码的人都修过同一个 bug:某个对象处于“已连接,但同时也是错误”的状态。一个 Connection 类里躺着 connected: bool 和 error: String | null 两个字段,四种组合里两种毫无意义——而毫无意义的那两种,恰恰是凌晨三点告警的来源。你加了注释,写了文档,在 review 里抓过三次,然后第四种语言的第四个新人又把它写错了。
前三篇讲的是东西活多久;本篇转向东西是什么。Rust 对“非法状态”的回答不是约定、不是 lint、不是更好的文档——是让这种状态在类型层面写不出来。承载这个回答的三个语言特性,恰好也是你每天写得最多的三样:会携带数据的 enum、穷尽检查的 match,以及 Option/Result/? 组成的错误处理流水线。这是类型系统开始付利息的地方。
会携带数据的 enum
如果你来自 Java、C# 或 TypeScript,你以为 enum 是“给一组常量起名字”。Rust 的 enum 完全是另一种生物——和类型(sum type):每个变体可以携带形状各不相同的数据:
enum Connection {
Disconnected,
Connecting { attempt: u32 },
Connected { session: Session },
Failed { reason: String },
}
数一下这两种建模的差别。bool 加可空字段的 struct 有四种位组合,其中 connected: true 且 error: Some(..) 毫无意义;这个 enum 恰好只有四种状态,每种都精确携带属于它的数据。非法状态不是被禁止——是无法表示,编译器替你把它删掉了。
更深一层的性质是数据跟着状态走。session 只存在于 Connected 变体里,所以“在没有 session 的情况下调用 session.send()“不是运行时才会暴露的错误路径——它在类型里根本不存在。你不能访问一个不存在的字段,正如你不能从一个已 move 的变量里再借出引用。第 2 篇的规则管内存安全,这条规则管领域安全,机制是同一个:把不变式从注释搬进类型,让编译器成为那个永远不困、永不忘记的检查者。
真实代码里这意味着什么?状态机——连接、订单、任务、协议解析——从“文档里的一张图加一屏防御性检查”变成“一个 enum 加一次 match”。这就是标题的意思:在 Rust 里,enum 就是状态机的书写方式。
match:穷尽,或者被拒
match 看起来像 switch,用起来完全是另一种东西,差别有三层:
match conn {
Connection::Disconnected => retry(),
Connection::Connecting { attempt } => wait(backoff(attempt)),
Connection::Connected { session } => session.send(msg),
Connection::Failed { reason } => log::warn!("gave up: {reason}"),
}
- 穷尽性检查。 漏掉任何一个变体,编译直接失败(E0004: non-exhaustive patterns)。这条规则的回报在你给 enum 新增变体那天兑现:编译器会挨个点名每一个需要回答新情况的 match——重构从“全局搜索加祈祷”变成“跟着红色波浪线散步”。这是整个语言里最被低估的生产力特性。
- 绑定就是解构。
Connected { session } => ...把数据从变体里拆出来递给你,类型保证正确。模式里还可以嵌套守卫:attempt if attempt >= 3 => ...——复杂分派写一处,读也是一处。 _是一把刀。 通配分支让你今天少写几行,代价是明天新增变体时编译器保持沉默——那些本该被点名的 match 会悄悄走_。约定很简单:自己的 enum,穷尽写满;只有对未来必然生长的外部 enum(标准库标记了#[non_exhaustive]的那些),才用_收尾。
只关心一个变体时不用摆出完整 match。if let 处理“命中才做”,let-else 处理“不命中就提前返回”——后者把卫语句写进了语法:
let Connection::Connected { session } = &conn else {
return Err("not connected".into());
};
session.send(msg) // 这里往下,session 一定存在
注意这个模式:检查和取出是同一步操作。没有“先检查状态再祈祷字段非空”的两步舞——编译器知道 else 之后变量一定是什么形状。
Option:null 的葬礼
Tony Hoare 在 2009 年为他在 1965 年发明 null 引用道歉,称之为自己“十亿美元的错误”——保守估计。Rust 没有 null。有的是标准库里一个平平无奇的 enum:
enum Option<T> {
Some(T),
None,
}
关键不在于换个名字,在于类型不同。Option<String> 不是 String——想使用里面的值,你必须先回答“None 怎么办“,而编译器盯着你回答。null 的问题从来不是它会崩溃,而是它传染:一个可为 null 的返回值,污染调用链上每一层,直到某一层忘记检查。Option 把“可能没有”这个事实写进签名,传染链在类型层面就断了。
日常使用是一套组合子,而不是 if 金字塔:
.unwrap_or(default)—— 没有就用默认值;.unwrap_or_else(|| compute())惰性版本,默认值昂贵时用。.map(f)—— 有就转换,没有就还是None,链式继续。.and_then(f)——f本身也返回Option时的版本,把可能失败的步骤串成一条链。.ok_or(err)—— 跨进Result世界的桥。
至于 .unwrap():它在原型里是速度,在生产里是定时炸弹。每个 unwrap 都是一句“这里永远不会是 None”的断言,而历史对这类断言并不友善。留下它的正当理由只有一个——你刚在上一行用类型或逻辑证明了它是 Some,那就在注释里写下这个证明。
Result 与 ?:错误处理的第一笔回报
Result<T, E> 是 Option 的表哥:None 换成了携带原因的 Err(E)。而 ? 运算符是这一切合流的地方——它让错误处理从“每一步都打断主线的仪式”变成“一行一个检查点的流水线”:
fn load_config(path: &Path) -> Result<Config, AppError> {
let text = std::fs::read_to_string(path)?; // io 错误 → 自动转换,出站
let config: Config = toml::from_str(&text)?; // 解析错误 → 同样出站
validate(&config)?; // 领域错误 → 还是出站
Ok(config) // 快乐路径是最后一行
}
? 做且只做两件事:值是 Ok(t),取出 t 继续;值是 Err(e),立刻从当前函数返回,并在出站前调用 From::from 把错误转换成签名承诺的类型。快乐路径从上到下一条直线;错误处理沉进了签名和那一个字符里。对照其他语言的写法——三层嵌套的 try/catch,或者 if err != nil 复读机——语义完全相同,形状完全不同,而形状决定了你三个月后还读不读得懂自己的代码。
两条落地规则,够你用很久:
- 库用结构化错误,应用用聚合错误。 写库时定义自己的错误 enum,让调用者能区分你的失败(生态里的
thiserror把样板压缩到几行);写应用时一个AppError或anyhow兜住一切。分界线只有一个问题:你的调用者需不需要对你的不同错误做不同反应? 需要就给 enum,不需要就聚合。生态选型的细节留给后面专门一篇。 - panic 是给程序员的 bug,
Result是给世界的失败。 磁盘满了、网络断了、配置写错了——这些是世界的正常状态,走Result。索引越界、“绝不可能”的不变式被打破——这些是代码的 bug,让它 panic、早点死在测试里。边界画清楚了,程序才是诚实的。
顺带一个贴心设计:main 可以返回 Result——fn main() -> Result<(), Box<dyn Error>>——于是 ? 能一路用到程序顶层,原型代码从第一行起就是正确的形状。
练习,以及第 5 篇
- 把
Connection写成 enum,实现fn send(&self, msg: &str) -> Result<(), String>:只有Connected才真正“发送”(打印即可),其余变体返回描述性错误。然后试着用 struct + bool 实现同样的约束——数一下你需要在心里维护多少条不变式,再数一下 enum 版本是多少条。 - 给
Connection加一个Closed { by_peer: bool }变体,然后编译。跟着 E0004 走遍每一个 match——这就是“编译器陪你重构”的真实手感。把其中一个 match 改成_结尾再编译:感受那份安静,并记住它的价格。 - 写
fn parse_port(s: &str) -> Result<u16, String>:拒绝空串、非数字和超出0..=65535的值。约束:不用 if/else 金字塔,只用map、and_then、?的组合。然后写三个测试,各打中一个失败分支——错误路径值得和快乐路径同等的测试待遇。
第 5 篇把“数据是什么”补全为“数据能做什么”:trait——Rust 的共享行为机制、没有继承的世界如何组织代码,以及你每天都会实现的几个标准 trait(Display、From、Iterator 家族)。从那一篇起,你写的代码会开始“看起来像 Rust”。