第七章 错误处理:失败是值,不是异常

你有没有想过,为什么在你写过的那些语言里,一个函数”出错”的时候,它往往会扔出一个东西、像烫手山芋一样砸向调用栈,然后靠一层层 try-catch 去接住——可要是没人接,整个程序就原地爆炸?

墨叔在第一章讲过,Rust 把很多”运行时才暴露的麻烦”提前挪到了编译期。错误处理,正是这套哲学最淋漓尽致的一次落地。这一章我们要把”出错”这件事,从”抛异常”的直觉里连根拔起,重新种成”返回一个普通的值”。

先说结论,省得你后面迷路:在 Rust 里,错误不是从天而降的异常,而是函数签名里明明白白写着的、和正常返回值平起平坐的一种结果。你要么拿到 Ok 里的宝贝,要么拿到 Err 里的麻烦,二者必居其一,编译器逼你把两条路都写上。

一、Result 和 Option,不过是第六章的枚举

第六章我们聊过,Rust 的 enum 是代数数据类型——它把”几种互斥的可能”打包成一个类型。错误处理的两大主角 Result<T, E> 和 Option<T>,本质上就是标准库给你预先烤好的两个枚举。

Option<T> 说的是”这事儿可能没结果”:

// 标准库的定义,你心里要长这样一棵树
enum Option<T> {
    Some(T),   // 有,里面包着一个值
    None,      // 无,空着
}

Result<T, E> 说的是”这事儿可能办砸”:

enum Result<T, E> {
    Ok(T),   // 办成了,里面是成果
    Err(E),  // 办砸了,里面是错误
}

你看,这就是第六章那套”带数据的枚举”。Some(5)、Err(io::Error) 和你自己写的 enum Message { Quit, Move { x: i32, y: i32 } } 没有任何本质区别。区别只在于:这两个枚举被标准库钦定为”全国通用货币”,于是配套设施(? 操作符、一堆组合方法)全都围着它们转。

为什么要用枚举而不是别的?因为枚举天然逼你”把两种可能都列出来”。一个返回 Option<User> 的函数,调用方拿到值后,编译器会强制你用 match 或 if let 处理 None 的情况——你不能假装”用户一定存在”然后直接解包,因为编译器根本不给你一条”只写成功路径”的捷径。这和你在别的语言里 user.getName() 不假思索地调、结果撞上空指针崩溃,是两种世界观。

打个比方。去快递柜取件,柜门要么弹开(拿到包裹),要么屏幕显示”取件码错误”(拿不到)。Option 就是把”柜门状态”做成了一枚举:你手里的不是”一个可能为空的对象”,而是”一个已经标明 Some(包裹) 或 None(空) 的明确结果”。快递柜不会给你可以乱摸的空洞,它直接告诉你”这次取件成功还是失败”。Rust 要的就是这种”结果先定性,再谈内容”的踏实感。

Result 比 Option 多带了一个 E——错误的类型。因为一个函数失败,失败的原因也是信息。Option 的 None 只告诉你”没”,Result 的 Err(e) 告诉你”没,而且是因为 e 才没”。打开一个不存在的文件,你需要的不是一句冷冰冰的 None,而是”文件不存在”或”权限不足”这种能指导你下一步怎么做的错误。所以:可恢复的、需要你知晓原因的失败,用 Result;纯粹”有没有”的缺失,用 Option。

二、? 操作符:错误传播的”一键原路返回”

现在麻烦来了。Result 满天飞,你每调一个可能出错的函数,都得 match 一把:成功就继续,失败就……把错误往上传。如果每个函数都手写 match,代码会被 Err(e) => return Err(e) 这种 boilerplate 淹没。

于是有了 ?。它干的事极其简单:在一个返回 Result 的表达式后面加 ?,意思是”如果这是 Ok,就把里面的值取出来继续;如果这是 Err,立刻 return 这个 Err 给上层”。

use std::fs::File;
use std::io::{self, Read};
 
fn read_username() -> Result<String, io::Error> {
    let mut f = File::open("user.txt")?;   // 打不开?错误直接返回给调用者
    let mut s = String::new();
    f.read_to_string(&mut s)?;             // 读失败?同样原路返回
    Ok(s)                                   // 都顺利,把成果包成 Ok 返回
}

你品这个 ?。它读起来像”顺流而下”——一路 ? 下去,任何一个环节翻车,整条链立刻短路,错误原封不动往上传。没有嵌套,没有缩进地狱,没有 try { ... } catch { ... } 把正常逻辑和错误处理搅成一锅粥。

但墨叔要提醒你一件容易被忽略的事:? 不是魔法,它背后还是那个 Result 枚举。? 只是”如果 Err 就 return Err”这句高频 boilerplate 的语法糖。它不改变语义,只消除噪音。你要是真想看糖化开前的样子,把 File::open("user.txt")? 换成:

let mut f = match File::open("user.txt") {
    Ok(file) => file,
    Err(e) => return Err(e),
};

一模一样。? 就是这么朴实。

这里有个关键的心智切换:如果你写过 Python 或 Java,? 看起来像”会冒泡的异常”,但本质完全不同。异常是”运行时抛、运行时接、不接就崩”的机制;? 是在编译期就规定好”这个函数返回 Result,错误只能顺着返回值往上走”。调用方拿到的永远是那个枚举,不存在”某个错误绕过签名偷偷飞上来”。错误,自始至终是个值。

三、panic 的边界:什么时候该炸,什么时候该还

既然有了 Result,那 panic! 这种”直接让程序崩”的东西,到底什么时候用?这是初学者最容易糊涂的一刀。

先看清两类错误的区别,这是分界的刀刃:

  • 可恢复的错误:文件没找到、网络超时、用户输入非法。这些在真实世界里天天发生,调用方拿到后大概率能换个法子重试、提示用户、或走 fallback。这类错误,必须用 Result 返回,交给调用方决定怎么办。
  • 不可恢复的错误(bug):数组越界、已经 unwrap 了一个确定是 None 的值、违反了程序内部不变量。这类不是”环境导致办不成”,而是”程序员写错了”。这时候继续跑下去只会数据错乱,不如立刻停下来。

打个比方。餐厅后厨,两种事故:一种是”顾客点的鱼卖完了”(可恢复,服务员去跟顾客说换道菜);一种是”厨师把盐当成了糖,整锅汤都得倒”(内部 bug,这桌菜已经没法救了,得停下手查流程)。前者该返回给前厅去应对,后者该让后厨立刻警觉、别再继续错下去。

所以墨叔给你一条好记的原则:

错误来自”外部世界的不确定性” → 用 Result,把它当值传回去。错误来自”你代码内部的逻辑漏洞” → 用 panic!,让它在开发期就狠狠暴露。

有两个具体信号,告诉你”这里该 panic”:

  1. 你刚用 if 或 match 确认过”这不可能是 None/Err”,只是为了拿到里面的值图省事——这时用 unwrap()/expect()。expect 比 unwrap 好,因为它崩的时候会带一句你写的人话,方便定位:
let config = load_config().expect("配置文件加载失败,请检查 config.toml 路径");
  1. 你写的函数有个前置条件,调用方如果传错就是 bug(而不是正常情况),那你在函数开头 assert! 一下,把契约写硬:
fn set_age(age: i32) {
    assert!(age >= 0 && age < 150, "年龄必须在合理范围内,调用方传了 {age}");
    // ...
}

反过来,永远别用 panic 去处理”用户输入错”或”外部服务挂了”这类正常会出现的情况。那是把本该交给用户的决定权,变成了一颗炸全组的雷。

四、自定义错误类型与 thiserror:点到即止

当你的程序稍微大一点,会有好几种不同的错误来源:读文件出错、解析 JSON 出错、网络请求出错……如果都返回 io::Error,调用方分不清”到底是哪坏”。这时你会想定义自己的错误枚举,把各路错误归一:

#[derive(Debug)]
enum AppError {
    Io(std::io::Error),
    Parse(std::num::ParseIntError),
    NotFound(String),
}

手写这个枚举、再为它实现 From 以便用 ? 自动转换,有点啰嗦。社区因此有了 thiserror 这个库,它用过程宏帮你把样板代码缩到几行:

use thiserror::Error;
 
#[derive(Error, Debug)]
enum AppError {
    #[error("IO 错误: {0}")]
    Io(#[from] std::io::Error),        // #[from] 自动实现 From,? 能直接转
 
    #[error("解析失败: {0}")]
    Parse(#[from] std::num::ParseIntError),
 
    #[error("找不到: {0}")]
    NotFound(String),
}

这一块你点一下知道有这条路、知道 #[from] 是”让 ? 自动把底层错误包进我的枚举”就够了,具体宏的细节第十六章讲过程宏时再拆。墨叔不在这里铺开,免得冲淡主线:自定义错误类型的核心目的,是让你的函数签名诚实地告诉调用方”我可能以这几种方式失败”,而不是用 Box<dyn Error> 这种万能口袋把所有差别抹平。

五、“失败是值而非异常”:一种世界观的重装

如果你写过靠异常(exception)工作的语言,这里的心智要重装——不是语法层面的重装,是世界观层面的。

异常的世界观是:出错是小概率的、不寻常的、最好别发生的意外,所以它被设计成”绕开正常返回值、沿着调用栈向上飞”的特殊通道。 正常路径写在 try 里干干净净,错误处理甩到 catch 里。问题在于:异常会”飞过”那些没声明要接它的函数,你调一个看起来人畜无害的函数,背后可能炸出你根本没准备的异常。错误处理,在你的代码里成了隐形的、可选的合同。

Rust 的世界观正好反过来:失败是计算的、平常的一种结果,和成功一样普通。 一个解析函数,返回 Result<User, ParseError>——“解析可能失败”这件事,就白纸黑字写在签名上。你没法”忘记处理”,因为类型系统不让。错误不再是一条偷偷飞走的暗道,它是一条人人都得走的明路。

这带来一个深刻的工程后果:可读性即可靠性。 当你读一个 Rust 函数,你看签名就知道它会在哪失败、失败成什么样;你不用去翻它调的每一层深处,猜”这里会不会突然抛个什么”。错误被”值”化了之后,它就能像普通值一样被传递、被变换、被组合——map_err 改改错误类型、and_then 串起一串可能失败的步骤、? 一键原路返回。错误处理从”灾难应对”变成了”数据流水线”上的一段普通加工。

墨叔干了二十年,见过太多”线上炸了,一查是某个底层函数抛了个没人 catch 的异常”的事故。Rust 这一套,把”可能失败”焊进了每个函数的脸面。你第一次写会觉得”好啰嗦,到处 ?、到处 Result”;等你维护过别人写的、签名里清清楚楚写着失败方式的大模块,你会懂得这份啰嗦是金不换的。

【思考题】

  1. 假设你有一个函数 fn first_char(s: &str) -> ???,它返回字符串的第一个字符。请你想清楚:它该返回 Option<char> 还是 Result<char, 某错误>?依据是什么?再进一步,如果字符串是空串,这算”可恢复的错误”还是”纯缺失”?把这个判断讲给你的标准定下来。

  2. ? 操作符只能在”返回 Result 的函数”里用。现在问题来了:假如你在一个返回 Option<T> 的函数里,调用了一个返回 Result<T, E> 的函数,你能在中间直接写 ? 吗?如果能,错误 E 会怎么变成 None?如果不能,你有什么别的写法把 Result 顺势转成 Option 继续 ??请写出代码推导。

  3. 墨叔说”panic 留给内部 bug,Result 留给外部不确定性”。请你设想一个真实场景:一个解析 HTTP 请求体的函数,收到了一个长度超乎预期的 body。这算该 panic 还是该返回 Result?如果换个角度,是”框架内部缓冲区算错了容量”导致越界,这又该归到哪一类?把你的分界线讲清楚。

【参考答案】

  1. 该返回 Option<char>,不是 Result。理由是:空字符串取首字符,不是”办砸了”,而是”这件事客观上没有结果”——它不需要一个”错误原因”来指导调用方怎么办,调用方只关心”有还是没有”。这正是 Option 的语义领地:Some(c) 表示有首字符,None 表示空串没有。反过来说,如果函数是”把字符串解析成整数”,那就该用 Result<i32, ParseIntError>,因为”abc”解析失败是需要告诉调用方”为什么失败”的,而且调用方可能据此提示用户改输入。所以你的标准可以定为:失败是否需要携带”原因”来驱动后续应对?要,用 Result;只是”有没有”的缺失,用 Option。 空串取首字符属于后者,纯缺失,归 Option。

  2. 关键在类型要对得上。? 的要求是:当前函数的返回类型,能接住被 ? 转换后的错误。具体说,Rust 允许 ? 在两种情形下工作:一是函数返回 Result<T, E>,且底层 Result 的 E 能转成函数的 E(通过 From);二是函数返回 Option<T>,此时 ? 会把任何 Err(e) 直接变成 None 返回——因为 Option 没有错误信息位,Err 一被 ? 碰到就”坍缩”成 None。所以若你的函数是 fn f() -> Option<T>,里面调用了返回 Result<T, E> 的东西,你可以直接写 let x = do_result_thing()?;,一旦它是 Err,函数立刻返回 None,E 被丢弃。如果你不想丢 E、还想保留信息,那就别让外层是 Option,或者显式写 do_result_thing().ok()?——.ok() 把 Result 转成 Option(Ok→Some,Err→None),然后 ? 在 Option 上下文里遇到 None 返回。代码推导如下:

fn f() -> Option<i32> {
    let x: i32 = "12".parse::<i32>().ok()?; // .ok() 把 Result 转 Option,? 遇 None 即返
    Some(x + 1)
}
// 如果 parse 失败,? 让 f 整体返回 None,错误原因被 .ok() 吞掉
  1. 第一问:解析 HTTP 请求体时收到超长 body,这是外部不确定性——客户端可能恶意、可能出错、可能协议版本不对,你的程序必须继续服务别的请求,不能自己崩。所以应当返回 Result,把这个”请求非法”作为可恢复错误交给上层去拒绝该连接。归 Result。第二问:如果是你框架内部的缓冲区容量算错了、导致代码去访问一个越界的索引,那是内部 bug——它不该发生在正确实现的程序里,发生了说明逻辑有漏洞,继续跑只会处理错数据甚至扩大损害,此时该 panic!(或越界访问本身就会 panic)。所以分界线一句话:错误根子在”外部世界不按剧本走” → Result 交还控制权;错误根子在”我自己的代码违背了不变量” → panic 立刻暴露。 同样的”越界”动作,诱因在外部就是 Result,诱因在自己就是 panic,区分点在责任的归属,不在现象的长相。

小结

Rust 把错误从”飞天的异常”重装成”落地的值”:Result/Option 不过是第六章那套枚举的标配款,? 是错误原路返回的语法糖,panic 留给内部 bug 而非外部不确定性。记住——失败是计算的平常结果,把它写进签名,可靠性才看得见。