← Rust:从入门到精通
全栈开发Rust:从入门到精通

Enum 即状态机:模式匹配与错误处理的第一笔回报

Rust 从入门到精通系列第 4 篇:会携带数据的 enum 如何让非法状态无法表示、match 的穷尽性检查如何把重构变成跟着编译器散步、Option 为什么是 null 的葬礼,以及 Result 和 ? 运算符怎样把错误处理从金字塔压成一条直线。

RustEnumsPattern MatchingError HandlingResult

每个写过几年代码的人都修过同一个 bug:某个对象处于“已连接,但同时也是错误”的状态。一个 Connection 类里躺着 connected: boolerror: 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: trueerror: 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}"),
}
  1. 穷尽性检查。 漏掉任何一个变体,编译直接失败(E0004: non-exhaustive patterns)。这条规则的回报在你给 enum 新增变体那天兑现:编译器会挨个点名每一个需要回答新情况的 match——重构从“全局搜索加祈祷”变成“跟着红色波浪线散步”。这是整个语言里最被低估的生产力特性。
  2. 绑定就是解构。 Connected { session } => ... 把数据从变体里拆出来递给你,类型保证正确。模式里还可以嵌套守卫:attempt if attempt >= 3 => ...——复杂分派写一处,读也是一处。
  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 复读机——语义完全相同,形状完全不同,而形状决定了你三个月后还读不读得懂自己的代码。

两条落地规则,够你用很久:

  1. 库用结构化错误,应用用聚合错误。 写库时定义自己的错误 enum,让调用者能区分你的失败(生态里的 thiserror 把样板压缩到几行);写应用时一个 AppErroranyhow 兜住一切。分界线只有一个问题:你的调用者需不需要对你的不同错误做不同反应? 需要就给 enum,不需要就聚合。生态选型的细节留给后面专门一篇。
  2. panic 是给程序员的 bug,Result 是给世界的失败。 磁盘满了、网络断了、配置写错了——这些是世界的正常状态,走 Result。索引越界、“绝不可能”的不变式被打破——这些是代码的 bug,让它 panic、早点死在测试里。边界画清楚了,程序才是诚实的。

顺带一个贴心设计:main 可以返回 Result——fn main() -> Result<(), Box<dyn Error>>——于是 ? 能一路用到程序顶层,原型代码从第一行起就是正确的形状。

练习,以及第 5 篇

  1. Connection 写成 enum,实现 fn send(&self, msg: &str) -> Result<(), String>:只有 Connected 才真正“发送”(打印即可),其余变体返回描述性错误。然后试着用 struct + bool 实现同样的约束——数一下你需要在心里维护多少条不变式,再数一下 enum 版本是多少条。
  2. Connection 加一个 Closed { by_peer: bool } 变体,然后编译。跟着 E0004 走遍每一个 match——这就是“编译器陪你重构”的真实手感。把其中一个 match 改成 _ 结尾再编译:感受那份安静,并记住它的价格。
  3. fn parse_port(s: &str) -> Result<u16, String>:拒绝空串、非数字和超出 0..=65535 的值。约束:不用 if/else 金字塔,只用 mapand_then? 的组合。然后写三个测试,各打中一个失败分支——错误路径值得和快乐路径同等的测试待遇。

第 5 篇把“数据是什么”补全为“数据能做什么”:trait——Rust 的共享行为机制、没有继承的世界如何组织代码,以及你每天都会实现的几个标准 trait(DisplayFromIterator 家族)。从那一篇起,你写的代码会开始“看起来像 Rust”。

guest@swangnice:~$