Option 与 Result:不抛异常的错误处理

多数语言用 null 表示「没有」,用异常表示「失败」,Rust 把这两件事都搬进了类型系统:Option 表示值可能不存在,Result 表示操作可能失败。这篇解决的问题是:两个类型的 API 表面、如何在它们之间转换、match 与组合子方法各自适用的场合,以及为什么这套机制比 try/catch 更适合写可靠的服务端代码。读完你能脱离 unwrap 写出处理完整的业务代码。

一、Option:把 null 变成类型的一部分

Option<T> 是一个两变体枚举:Some(T) 表示有值,None 表示没有。凡是可能返回空值的函数,签名都用它代替裸 T。好处在调用端:拿到 Option 的人必须先拆开才能用值,「可能为空」这件事从注释变成了编译器强制的事实。

fn find_user(id: u32) -> Option<String> {
    if id == 1 { Some(String::from("张三")) } else { None }
}

fn main() {
    match find_user(2) {
        Some(name) => println!("找到 {}", name),
        None => println!("用户不存在"),
    }
}

标准库为 Option 准备了一整套组合子方法,日常最常用的列在下表。

方法 作用 空值时的行为
map(f) 转换内部值 保持 None
and_then(f) 串联可能失败的步骤 短路为 None
unwrap_or(d) 取值或默认值 返回默认值
is_some() / is_none() 判断存在 布尔结果
take() 取出值,原处变 None 返回 None

组合子的价值在于写出「无 if 的处理链」:每一步都声明性表达,None 自动向下游传播,代码读起来像流水线而不是条件判断树。养成用 map / and_then 替代 match 的习惯,是从「会写 Rust」到「写地道 Rust」的明显分水岭。

一个帮助体会的画面:Option 像是流水线上的托盘,工序不关心托盘是不是空的。map 是加工、and_then 是可能产出空托盘的子工序、unwrap_or 是末端取货时给空托盘放默认件。从这种视角读代码,你会发现大量嵌套 match 其实都可以压缩成两三行链式调用,可读性反而更高。

二、Result:把错误变成值

Result<T, E> 同样是枚举:Ok(T) 是成功值,Err(E) 是错误值。E 由调用者决定,标准库只规定它需要实现 std::error::Error 供格式化与转换。与异常的根本不同在于:Result 出现在签名里,编译器强制调用方看到错误的存在,不会出现「某个深层函数突然抛异常打断控制流」的意外。

fn parse_port(s: &str) -> Result<u16, String> {
    let n: u16 = s.parse().map_err(|e| format!("解析失败: {}", e))?;
    if n > 1024 { Ok(n) } else { Err("端口需大于 1024".into()) }
}

注意 ? 运算符在这里提前出场,它的完整语义留给下一篇。现在只需理解:它让错误沿调用栈向上传播,效果类似其他语言的隐式 rethrow,但所有传播路径都在类型签名里可见。

三、两者之间的转换

Option 与 Result 经常需要互相变形:数据库查询没有记录(Option)要转成「未找到」错误(Result),反之亦然。四种方向各有惯用方法。

转换方向 惯用方法 使用场景
Option → Result ok_or(err) 缺省值不合理时
Result → Option .ok() 只关心成功结果
嵌套 Option 拍平 flatten() 多层 Some 套娃
Result 链式串联 and_then 分步校验
fn load_config(path: &str) -> Result<String, String> {
    let text = std::fs::read_to_string(path)
        .map_err(|e| format!("读取失败: {}", e))?;
    let version = text.lines().next().ok_or("配置为空")?;
    Ok(version.to_string())
}

ok_or 把 None 升级为 Err,是把「不存在」变成「错误」的桥梁;.ok() 反向时丢弃错误信息,只保留「成功与否」,适合记录日志后降级处理。

四、unwrap 家族与何时能用

unwrap 在原型阶段是无敌方便的,但它把可恢复错误升级为 panic。生产代码里的规矩是:只有在「逻辑上不可能为空」的位置允许 unwrap,且必须配合注释说明理由。标准库提供了更细的替代品:

  • expect(消息):同 unwrap,但 panic 信息可定制,定位更快。
  • unwrap_or / unwrap_or_default / unwrap_or_else:给出 fallback,不 panic。
  • unwrap_or_default:类型有 Default 实现时直接用默认值。

下表按场景给出推荐选择。

场景 推荐做法
单元测试 unwrap / expect
原型与脚本 unwrap 可接受
库的公共 API 永远返回 Result
配置缺失有合理默认 unwrap_or_default
逻辑上必为 Some expect 加注释

把「测试里随意 unwrap、库里绝不 unwrap」内化成肌肉记忆后,你的代码会自动分层:意外情况走 Result 让调用方决策,绝不可能的情况才 panic,这就是可恢复与不可恢复错误的分界线。

五、错误类型的设计

E 的选择是 Rust 错误处理最深的话题。小工具用 String 错误就够,库则应当定义自己的错误枚举,让调用方可以 match 具体原因。社区的事实标准是 thiserror(派生式错误枚举)与 anyhow(应用层错误聚合),前者面向库作者,后者面向应用作者,二者也可以在同一项目混用,让库暴露精确错误、应用统一聚合。具体 API 以官方文档为准,版本之间可能微调,写库时先查目标 edition 的行为。

设计错误枚举时的经验法则:按「调用方需要区分的原因」划分变体,而不是按「错误发生的位置」。一个 DbError::NotFound 与 DbError::Conflict 的区分对调用方有价值;而把错误按函数名划分变体几乎等于没分层。另一条法则是让错误携带足够定位问题的上下文:文件路径、输入片段、期望与实际值,这些字段写进枚举变体后,日志与排查会顺利得多。

六、异步错误的特别说明

异步上下文里 Result 的处理方式不变,但要注意:Future 里的 ? 在 poll 返回 Pending 时不会传播错误——错误只在 poll 返回 Ready(Err) 时流动。多数时候这不会影响你的代码形态,但在手写 Future 或 select 分支时需要清楚。另一个细节是错误需要 Send 才能跨线程传播,多线程运行时里错误类型若含 Rc 会直接编译失败。处理网络请求时这套机制非常省心,错误类型决定前端如何展示用户可见提示,Tauri 桌面开发指南 里的命令错误就是 Result 直通前端展示的完整案例。写命令行工具时,错误最终汇聚到 exit code 与 stderr,见后续 CLI 章节。

延伸阅读

  • 下一篇「? 运算符」详细拆解错误传播机制,是本篇的直接续集,两章合起来就是 Rust 错误处理的地基。
  • 错误处理与日志输出的配合在服务端至关重要,可参考 正则表达式精进 的日志解析示例理解错误格式设计,结构化错误信息会显著降低排查成本。
  • 想看 Result 在完整项目里从底层流到用户界面的链路,浏览器扩展开发指南 的消息层做了完整示范,错误如何跨边界传递一目了然。