Result 与 ?:错误是值,不是异常
Result 是 Option 的孪生兄弟:Option 说「可能没有」,Result 说「可能失败,失败时还带上原因」。Rust 没有异常 —— 错误就是一个普通的返回值,你必须处理或明确往上抛。这看起来啰嗦,直到你遇见 ?:一个字符,把「失败就提前返回」的四行样板压回一个问号。
又一个普通枚举
和 Option 一样,Result 也只是标准库里一个普通枚举 —— 无非多带了「失败的原因」:
enum Result<T, E> {
Ok(T), // 成功,带着结果 T
Err(E), // 失败,带着错误 E
}
凡是可能失败的操作,返回值都是 Result:读文件(可能不存在)、解析数字(可能不是数字)、发网络请求(可能超时)。函数签名里的 -> Result<Config, ParseError> 就是一句诚实的话:「我可能给你一个 Config,也可能给你一个 ParseError,你两种都得准备好」。
和 Option 一样,你也绕不过失败那一半 —— 想拿到 Ok 里的值,就得处理 Err:
match "42".parse::<i32>() {
Ok(n) => println!("解析出 {}", n),
Err(e) => println!("解析失败:{}", e),
}
没有异常,意味着什么
这是 Result 和你熟悉的 try/catch 之间,最本质的差别。想清楚它,你就懂了 Rust 错误处理的全部哲学:
异常是一条隐藏的控制流:任何一行代码都可能突然抛出,跳过后面所有语句,一路窜到某个不知道在哪的 catch。你看一个函数签名,看不出它会不会抛、抛什么 —— 除非去读实现、读文档、或者等它半夜炸给你看。
错误即值:失败就是一个普通返回值,明明白白写在签名的 -> Result<T, E> 里。控制流没有任何隐藏跳转,函数会不会失败、失败是什么类型,一眼看得见,且编译器强制你面对。
代价看起来是啰嗦 —— 每个可能失败的调用后面,好像都要跟一段处理。如果真是这样,没人受得了。所以 Rust 给了 Result 一个杀手锏。
? :把样板压成一个字符
绝大多数时候,你对错误的处理其实是「这一步要是失败了,我这个函数也没法继续,就把这个错原样往上抛给我的调用者」。这个模式太常见了,Rust 给它一个专门的运算符 ?:
fn read_config() -> Result<Config, Error> {
let text = std::fs::read_to_string("config.toml")?; // 读失败?直接 return Err
let config = parse(&text)?; // 解析失败?直接 return Err
Ok(config) // 全过了,返回 Ok
}
每个 ? 的含义是:如果是 Ok(v),就取出 v 继续往下;如果是 Err(e),就立刻 return Err(e),函数到此为止。它其实就是下面这坨 match 的语法糖:
let text = match std::fs::read_to_string("config.toml") {
Ok(v) => v,
Err(e) => return Err(e), // ← ? 帮你写的就是这一行
};
于是你得到了两全:错误处理像异常一样不啰嗦(一个问号),又像返回值一样显式可见(问号就摆在那,你知道这里会短路)。这可能是 Rust 语言设计里最讨人喜欢的一笔。
下面这个函数解析 "端口,主机",中间有四个可能失败的 ?。换不同的输入,看它在哪一步失败、然后后面的步骤根本不执行、直接返回那一步的 Err。解析是真跑的。
玩一玩你就体会到 ? 的精髓:"abc,localhost" 在第 ② 步(解析数字)就短路了,③④ 根本没跑;"80,localhost" 一路过到第 ④ 步(范围检查)才失败。错误在哪产生、在哪终止、带什么信息,全程可见 —— 没有一处是「不知从哪飞来的异常」。
不同的错误类型怎么统一:? 会帮你转换
上面 read_config 里,read_to_string 失败给的是 io::Error,parse 失败给的是 ParseError,两种不同的错误,怎么能都被同一个 ? 抛成函数声明的那个 Error?因为 ? 在抛出前,会自动尝试类型转换(通过一个叫 From 的 trait,下一章讲)。你只要让你的错误类型「能从各种子错误转换过来」,? 就能把五花八门的底层错误,统一收敛成你函数对外承诺的那一个错误类型。这套机制怎么优雅地搭起来,是第 22 章「工程化」的活(thiserror / anyhow 两个库让它几乎零样板)。
那 panic 是什么:不可恢复的错误
Rust 其实有两套错误机制,别搞混:
- Result —— 可恢复的错误。「文件没找到」「用户输入不合法」这类预期之内、调用者有办法应对的失败。用返回值表达,让调用者决定怎么办。这是你 99% 的时候该用的。
- panic! —— 不可恢复的错误。「数组越界」「断言失败」「程序进入了逻辑上不可能的状态」这类bug 级的失败。它会展开栈、终止当前线程。
unwrap/expect(上一章那颗自装炸弹)触发的就是 panic。
判断标准:「调用者拿到这个失败,有合理的办法处理吗?」有 → Result;没有、这纯粹是个 bug → panic。把「文件没找到」写成 panic 是坏味道(那是正常情况,该让调用者处理);把「我代码里这个 index 逻辑上不可能越界,越了就是我写错了」交给 panic 则很合理。
回流
Java / Kotlin / Python:它们用异常。好处是主流程干净(错误处理被挪到 catch);坏处正是那份「隐藏」—— 一个方法会抛什么,签名基本不说(Java 的 checked exception 想解决这个,但因为太烦被大家用 throws Exception 或直接吞掉给绕过了)。你有没有见过 catch (Exception e) {} 这种把错误默默吃掉的代码?Rust 的 Result 让「吞掉错误」变成一件你必须显式做的事(比如 let _ = ...),从而扎眼、可审查。
Go:Go 也是「错误即值」—— if err != nil { return err } 满地都是。思想和 Rust 完全一致,都是显式、无隐藏跳转。但 Go 缺一个 ?,所以那三行样板得手写无数遍,是 Go 程序员公认的痛点。Rust = Go 的「错误即值」哲学 + 一个 ? 的人体工学。你要是写过 Go,会对 ? 一见钟情。
这一章的一句话
错误是普通返回值 Result<T, E>,不是从天而降的异常;你必须处理或用 ? 显式上抛。? 把「失败就提前返回」压成一个字符,让错误处理既显式又不啰嗦 —— 失败在哪产生、往哪走,全程看得见。
卷 III 已经给了你两样超能力:枚举让非法状态无法表达,Option/Result 让「空」和「错」无处遁形。还剩两章,讲 Rust 怎么做抽象 —— 而且是不花运行时成本的抽象。下一章:trait。