错误是一种值:Result、? 与 panic!
用 Result<T, E> 枚举表示可恢复错误、用 ? 传播它、把 panic! 留给无法恢复的情况,并说清 Rust 为什么不用异常
在 Java 里写一个可能失败的方法,你 throw;调用方 try-catch,或者干脆在签名上加 throws 把锅甩给上一层。Python 更随意,任何一行都可能抛异常,而函数签名上完全看不出来。Rust 两者都不用。
The Rust Book 第九章开头说得很直接:大多数语言不区分两类错误,都用异常这一套机制一并处理;Rust 不这样------它用一个类型 Result<T, E> 表示可恢复的错误,用一个宏 panic! 表示不可恢复的错误。所以这一篇讲的不只是两个新工具,而是 Rust 做的一个前提性选择:失败不是一条控制流通道,而是一个值。理解了这一点,后面所有细节都顺理成章。
Result 就是一个普通 enum
上一篇讲枚举时我们说过,enum 表达"一个值只能是几种之一"。Result 是标准库里最常用的那个 enum,没有任何特殊之处:
rust
enum Result<T, E> {
Ok(T), // 成功,带着结果值
Err(E), // 失败,带着错误值
}
T 是成功时带的值,E 是失败时带的值,都是泛型参数(下一篇讲泛型,现在只需知道它们可以换成任何具体类型)。读文件是最常见的例子:
rust
use std::fs::File;
let greeting_file_result = File::open("hello.txt");
// 类型是 Result<File, std::io::Error>
File::open 有可能成功,返回一个文件句柄(T 就是 std::fs::File);也有可能失败------文件不存在、或者没有权限(E 就是 std::io::Error)。Book 的说法是:这个函数需要同时告诉我们"成没成"和"成的话给你句柄、不成的话给你错误信息",而这正是 Result 传达的东西。
它和 Option 是什么关系
上一篇讲的 Option<T> 只回答"有还是没有",None 不告诉你为什么没有。Result<T, E> 是它的加强版:失败时把原因一并带出来。所以两者分工很清楚------一个操作失败不需要理由时用 Option(比如在一堆元素里找个东西,没找到就是没找到);失败需要向调用方交代时用 Result。
两者在代码里可以显式互转:Result 有 ok() 方法把它变成 Option(丢掉错误信息),Option 有 ok_or() 把它变成 Result(补上一条错误信息)。需要互转的场景通常和 ? 有关,下面会讲到。
因为它是值,你能对它做值能做的事
这是 Rust 和异常机制真正的分野。在 Java 里,异常是一个控制流构造:throw 之后控制权跳到最近的 catch,中间那些帧就没了。你没办法把一个"可能发生的异常"存进变量、放进 Vec、当作参数传给另一个函数。异常本身不是值,它的类型只是被附加在方法签名上的一个通道。
Result 是一等公民,上面那些事都能做:你可以 match 它,可以调方法(unwrap_or、map_err、and_then 有一大串),可以作为返回值参与泛型,可以先存起来稍后统一处理。Rust 的设计讨论里把这点说得很清楚:有异常的语言有失败的类型 (异常类),但没有"可能失败"这个类型;而 Rust 把"可能失败"本身变成了值。
标准库还给它加了一个小机关:Result 被标了 #[must_use],效果是------你调了一个返回 Result 的函数却把返回值扔掉,编译器会发出 unused_must_use 警告,提示原文是"this Result may be an Err variant, which should be handled"。标准库文档自己解释了这个设计的动机:用返回值表示错误的老问题是返回值太容易被忽略,于是错误悄无声息地过去了。Java 里被吞掉的异常要等到排查线上问题时才发现;Rust 让编译器替你盯住每个没处理的返回值。
三种处理方式:match、unwrap、?
有了 Result,剩下的问题是你怎么处理它。
老老实实 match
rust
let greeting_file = match greeting_file_result {
Ok(file) => file,
Err(error) => panic!("Problem opening the file: {error:?}"),
};
上一节的模式匹配在这里原样适用:Ok(file) 把里面的文件句柄绑出来,Err(error) 处理失败。缺点是啰嗦------尤其是当你只是想把错误往上交的时候,每个调用点都写一遍 match 会让真正的逻辑淹没在样板里。
unwrap 与 expect:不打算处理的两种写法
unwrap 就是上面那个 match 的缩写:Ok 就取出里面的值,Err 就替你调 panic!。expect 一模一样,只是让你自己指定 panic 消息:
rust
let greeting_file = File::open("hello.txt").expect("hello.txt should be included in this project");
Book 的建议是用 expect 而不是 unwrap,并认真写消息------因为一条好的消息能说明你的意图,出错时更容易定位。注意 expect 消息的推荐写法是描述你为什么认为它应该是 Ok,而不是复述"打不开文件",前者才是排查时需要的信息。
那什么时候可以 unwrap?Book 给了几个明确场合:写示例代码时(示例里塞满错误处理反而看不清主题)、原型阶段(还没想好怎么处理错误,用 unwrap 留下清晰的待办标记)、测试里(测试失败本来就该 panic)。还有一个容易被忽略的正当理由:逻辑上你确知不会失败,但编译器看不出来 。比如解析一个硬编码的 "127.0.0.1",parse 的返回类型仍然是 Result,编译器不会因为你写了个合法字符串就放过你。这时用 expect 并在参数里写明理由("这个 IP 是硬编码的")是完全合理的------而且如果以后这个值改成从用户输入来,那行 expect 会提醒你该换成真正的错误处理了。
反过来说,unwrap 绝不能当默认选项:它把一个可恢复的错误直接降级成程序崩溃。
?:把错误交给上一层
真正日常的写法是 ?:
rust
use std::fs::File;
use std::io::{self, Read};
fn read_username_from_file() -> Result<String, io::Error> {
let mut username_file = File::open("hello.txt")?;
let mut username = String::new();
username_file.read_to_string(&mut username)?;
Ok(username)
}
? 放在一个 Result 后面,行为等价于一个 match:如果是 Ok,把里面的值取出来继续往下走;如果是 Err,就从整个函数提前返回 ,把这个错误值交给调用方,效果等同于写了 return Err(...)。所以整个函数最终会把 Ok(username) 或中途的第一个错误返回出去。? 让错误传播从一段二十行的 match 变成两个字符。
它和手写 match 有一个实质差别,值得单独说:? 会经过 From::from 做一次转换 。错误值的类型会被转换成当前函数返回类型里的错误类型。这一条撑起了一个很实用的做法------自定义一个错误 enum,给每种底层错误实现 From:
rust
enum CliError {
IoError(io::Error),
ParseError(num::ParseIntError),
}
impl From<io::Error> for CliError {
fn from(error: io::Error) -> Self { CliError::IoError(error) }
}
之后,io::Error 和 ParseIntError 都能用 ? 直接从这个函数里出来,函数签名上只写一个 CliError 就够了。From 这个 trait 的文档也把这条列为它的主要用途之一:让一个函数返回单一错误类型,同时不丢失底层原因。
? 有两条限制。第一,它只能用在返回类型兼容的函数里------写 main 里或者返回 String 的函数里会报 E0277,提示 ? 只能用于返回 Result、Option 或实现了 FromResidual 的类型。补救办法是把函数返回类型改成 Result;main 例外,它可以写 -> Result<(), Box<dyn Error>>(现在读作"任何类型的错误"),这样 main 里也能用 ?。第二,Result 上的 ? 和 Option 上的 ? 不能混用,编译器不会自动互转,需要显式调用 ok() 或 ok_or()。
panic!:不打算恢复的那条路
panic! 触发方式有两种:显式调用,或者做了一件会 panic 的事(比如访问越界的数组下标)。默认行为是打印失败信息、回溯栈并清理每一层函数的数据,然后退出。
这里有个可以配置的开关。回溯清理是有成本的,Rust 允许你在 Cargo.toml 里改成直接 abort------不清理,进程立刻结束,剩下的内存由操作系统回收:
toml
[profile.release]
panic = 'abort'
代价是更小的二进制和更快的退出,代价则是析构函数不再运行。想定位 panic 来源时设环境变量 RUST_BACKTRACE=1,会打印调用链;读法是从上往下读,读到第一个出现你自己文件名的行,那里就是问题源头,它上面的都是库或标准库代码。
有一点需要说清楚,因为从 Java 过来的人常会问:Rust 有 std::panic::catch_unwind,能捕获 panic,但它不是 try-catch 的替代品。标准库文档明确不建议用它做通用 try/catch------常规会失败的操作应该用 Result;而且它只能捕获"回溯型"的 panic,abort 型的根本捕获不到。
怎么选:调用方能不能合理地做点什么
标准不是"错误严重不严重",而是调用方有没有合理的应对办法 。Book 把这条讲得很透:代码 panic 之后就无法恢复了;而你返回 Result 时,是把选择权交给调用方------它可以按自己的情况尝试恢复,也可以认为这种情况不可恢复、自己调 panic! 把它变成崩溃。所以定义一个可能失败的函数时,返回 Result 是好的默认选择。
反过来,panic! 的正当场合是那些"调用方没有合理恢复手段"的情形:函数的契约被违反(这永远意味着调用方有 bug,需要改代码而不是 catch),或者继续执行下去不安全。标准库在越界访问时 panic 就是最后一条理由------访问不属于当前数据结构的内存是常见的安全问题来源。
还有一个更优雅的方向值得知道:用类型系统把检查本身消掉 。如果函数参数是 i32 而不是 Option<i32>,你就不用处理"可能是空"的情况;如果是 u32,你就不用检查负数------编译器已经在类型层面保证了。这也是上一篇 Option 那条规则的延伸:把可能出问题的情况写进类型,让编译器替你在编译期把门。
为什么 Rust 不用异常
最容易被误解的一点是:Rust 不用异常,不是因为异常"不好"。恰恰相反,Rust 的 RFC 243 开头就承认,用 enum Result 做错误处理"简单、行为良好、容易理解",问题在于常常又丑又不方便用 ------于是这个 RFC 要做的是改善可用性,而不是换掉这套机制。它加了两样东西:? 运算符和 catch 表达式(catch 最终没有进入语言,? 留下了)。
? 的设计意图,RFC 里有一段很值得读的话:要求显式写 ? 来传播错误,是在"完全自动传播"(大多数语言的做法)和"完全手动传播"之间取了一个平衡。它的收益是,函数调用仍然只是返回结果的普通函数调用,背后没有魔法;同时你可以在"用 ? 传播"和"直接处理这个 Result"之间自己选。
这段话说出了和异常的真正区别:异常是非局部的控制流 。读一段 Java 代码,你没法只看这段代码就知道哪一行会跳走------它可能调了个三层深的方法,那里抛了个 unchecked 异常直接穿透回来。Rust 里每个可能提前返回的点都是明写的 :一个 ? 就是一个提前返回,你扫一眼函数体就能数出来有几个出口。代价是不可否认的------Java 里你可以一路 throws 上去,一个字的处理代码都不写;Rust 里每传播一层就要一个 ?(当然,这也意味着你能看见它)。
另一层差异在错误类型能不能参与泛型。Java 的 checked exception 也进签名,但它是控制流通道而不是值------单一返回值之外多挂一条通道,于是在泛型接口、lambda、Stream 这些地方就很别扭,常见的绕法是把 checked 异常包成 RuntimeException 再抛出去,等于把检查绕过去了。Result<T, E> 只是普通的泛型类型,E 可以是任何类型、可以和 T 一起参与泛型推导,不需要特殊的语言机制来支持。
到这里,Rust 错误处理的主干就说完了:失败是一个值(Result 这个枚举),它必须被面对(#[must_use]),传播它有一个专门的运算符(?,附带 From 转换),而真正不该继续的情况留给 panic!。判断标准始终是同一个问题------调用方拿到这个失败,有没有什么事是它能合理去做的。
顺带一提,Option 和 Result 是所有这一切的原料。下一篇讲泛型,你会看到 Option<T>、Result<T, E>、Vec<T> 背后是同一套机制,也是你自己写数据类型时会反复用到的东西。
参考来源
- The Rust Book 第 9 章开头:可恢复 / 不可恢复错误的划分,以及 Rust 不使用异常:https://doc.rust-lang.org/book/ch09-00-error-handling.html
- The Rust Book 9.2「Recoverable Errors with Result」--- Result 定义、File::open 的类型、unwrap/expect、? 的展开与 From 转换、? 的使用限制与 main 返回 Result:https://doc.rust-lang.org/book/ch09-02-recoverable-errors-with-result.html
- The Rust Book 9.3「To panic! or Not to panic!」--- 何时 panic 何时返回 Result、expect 的合理场合、契约违反与越界访问、用类型系统消除检查:https://doc.rust-lang.org/book/ch09-03-to-panic-or-not-to-panic.html
- The Rust Book 9.1「Unrecoverable Errors with panic!」--- panic! 的触发方式、栈展开与 panic = 'abort'、RUST_BACKTRACE 的读法:https://doc.rust-lang.org/book/ch09-01-unrecoverable-errors-with-panic.html
- std::convert::From 官方文档 --- From 在错误处理中的用途与自定义错误类型示例:https://doc.rust-lang.org/core/convert/trait.From.html
- must_use 属性文档 --- Result 的 #must_use 标注与 unused_must_use 警告:https://doc.rust-lang.org/core/attribute.must_use.html
- Rust Internals 讨论 --- 异常语言有失败的类型却没有"可能失败"的类型,Result 把可失败性具体化:https://internals.rust-lang.org/t/pre-rfc-catching-functions/6505
- std::panic::catch_unwind 文档 --- 只捕获回溯型 panic,不推荐当作通用 try/catch:https://doc.rust-lang.org/std/panic/fn.catch_unwind.html
- RFC 243「Trait-based exception handling」--- 对 Result 方案的评估、? 在自动传播与手动传播之间的取舍:https://rust-lang.github.io/rfcs/0243-trait-based-exception-handling.html