Andrew Gallant 2022-08-08
在 Rust 1.0 发布的前一天,我发表了一篇博客文章,涵盖了错误处理的基础知识。文章中间一个特别重要但篇幅较小的部分名为“unwrap 并非邪恶”。该部分简要说明:总体而言,如果 unwrap() 用在测试/示例代码中,或者当 panic 表明存在 bug 时,使用 unwrap() 是可以接受的。
我今天基本上仍然持有这一观点。这种信念也体现在 Rust 标准库和许多核心生态 crate 的实践中(而且这种实践早于我的博客文章)。然而,关于何时可以或不可以使用 unwrap(),似乎仍存在广泛的困惑。本文将更详细地讨论这一点,并具体回应我所见到的一些观点。
这篇博文以 FAQ(常见问题解答)的形式撰写,但建议按顺序阅读。每个问题都建立在前一个问题的基础上。
目标读者:主要是 Rust 程序员,但我希望提供了足够的上下文,使得这里阐述的原则适用于任何程序员。尽管对于具有不同错误处理机制(例如异常)的语言来说,直接映射可能比较棘手。
我的立场是什么?
我认为有必要一开始就明确我在错误处理和 panic 方面的若干立场。这样读者就能确切知道我的出发点。
- panic 不应用于应用程序或库中的错误处理。
- 在原型开发、测试、基准测试和文档示例中,使用 panic 进行错误处理可能是可以接受的。
- 如果一个 Rust 程序发生了 panic,那就表明程序中存在 bug。也就是说,正确的 Rust 程序不会 panic。
- 总能为 panic 分配“责任”——要么是 panic 的函数本身有错,要么是调用该函数的调用方有错。
- 除了需要使用形式化方法(或类似技术)来证明程序正确性的领域外,将所有不变量都移入类型系统是不现实或不可行的。
- 因此,当出现运行时不变量时,我们有几个选择:
- 可以让函数在某些输入子集上 panic(即违反前置条件),从而使函数成为“偏函数”。在这种情况下,如果函数 panic,则 bug 在调用方。
- 假设不变量永远不会被破坏,并在破坏时 panic(即内部不变量)。在这种情况下,如果函数 panic,则 bug 在被调用方。
- 对于前置条件违反的情况,也可以在违反时返回给调用方(例如通过返回错误)。但对于内部不变量违反的情况,绝不应这样做,因为这会泄露实现细节。
- 在上述 (1) 和 (2) 的情况下,使用 unwrap()、expect() 和切片索引语法等都是可以的。
- 优先使用 expect() 而不是 unwrap(),因为它在 panic 发生时提供更具描述性的消息。但在 expect() 会导致噪音时,使用 unwrap()。
本文其余部分将论证这些立场。
什么是 unwrap()?
由于本文表达的观点并不局限于 Rust,我认为有必要先说明 unwrap() 到底是什么。unwrap() 是定义在 Option<T> 和 Result<T, E> 上的一个方法,它在值为 Some 或 Ok 时返回底层的 T,否则 panic。它们的定义非常简单。对于 Option<T>:
impl<T> Option<T> {
pub fn unwrap(self) -> T {
match self {
Some(val) => val,
None => panic!("called `Option::unwrap()` on a `None` value"),
}
}
}
对于 Result<T, E>:
impl<T, E: std::fmt::Debug> Result<T, E> {
pub fn unwrap(self) -> T {
match self {
Ok(t) => t,
Err(e) => panic!("called `Result::unwrap()` on an `Err` value: {:?}", e),
}
}
}
本文试图解决的关键矛盾是:是否以及在多大程度上应该使用 unwrap()。
“panic” 是什么意思?
当发生 panic 时,通常会发生以下两种情况之一:
- 进程中止。
- 如果目标平台支持,栈会 unwind(展开)。如果 unwind 未被捕获,则最终会导致进程中止,并打印 panic 消息及 panic 源位置。
具体发生哪种情况取决于程序的编译方式,可通过 Cargo.toml 中的 panic 配置项控制。
当发生 unwind 时,可以捕获 panic 并进行处理。例如,Web 服务器可能会捕获请求处理器中的 panic,以避免整个服务器崩溃。另一个例子是测试框架,它会捕获测试中的 panic,以便继续执行其他测试并美化输出结果,而不是立即终止整个测试套件。
虽然 panic 可用于错误处理,但通常被认为是一种糟糕的错误处理方式。尤其值得注意的是,语言本身对将 panic 作为错误处理的支持很差,而且 unwind 并不能保证发生。
当 panic 导致 unwind 且未被捕获时,程序很可能会在栈完全 unwind 后中止,并打印 panic 携带的消息。(我说“很可能”,是因为可以设置 panic 处理器和 panic 钩子。)例如:
fn main() {
panic!("bye cruel world");
}
运行结果:
$ cargo build
$ ./target/debug/rust-panic
thread 'main' panicked at 'bye cruel world', main.rs:2:5
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
如提示所示,可以启用回溯:
$ RUST_BACKTRACE=1 ./target/debug/rust-panic
thread 'main' panicked at 'bye cruel world', main.rs:2:5
stack backtrace:
0: rust_begin_unwind
at /rustc/0f4bcadb46006bc484dad85616b484f93879ca4e/library/std/src/panicking.rs:584:5
1: core::panicking::panic_fmt
at /rustc/0f4bcadb46006bc484dad85616b484f93879ca4e/library/core/src/panicking.rs:142:14
2: rust_panic::main
at ./main.rs:2:5
3: core::ops::function::FnOnce::call_once
at /rustc/0f4bcadb46006bc484dad85616b484f93879ca4e/library/core/src/ops/function.rs:248:5
note: Some details are omitted, run with `RUST_BACKTRACE=full` for a verbose backtrace.
panic 对应用程序的最终用户来说并不是特别有用或友好的错误消息。然而,panic 通常为程序员提供非常有用的调试信息。根据经验,堆栈跟踪通常足以精确理解应用程序内部出了什么问题。但它对最终用户帮助不大。例如,如果打开文件失败就 panic 是不合适的:
fn main() {
let mut f = std::fs::File::open("foobar").unwrap();
std::io::copy(&mut f, &mut std::io::stdout()).unwrap();
}
运行上述程序的结果:
$ ./target/debug/rust-panic
thread 'main' panicked at 'called `Result::unwrap()` on an `Err` value: Os { code: 2, kind: NotFound, message: "No such file or directory" }', main.rs:2:47
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
在这种场景下,错误消息并非完全无用,但它不包含文件路径,也没有提供应用程序在遇到 I/O 错误时正在尝试做什么的上下文信息。此外,它包含了许多对最终用户无用的噪音。
总结:
- panic 对程序员非常有用。它们提供消息、堆栈跟踪和行号。仅凭这些信息通常就足以诊断 bug。
- panic 对最终用户不太友好。它们比静默中止要好,但 panic 消息通常缺乏对最终用户相关的上下文,且通常是为程序员编写的。
什么是错误处理?
错误处理是指代码中“出错”时所采取的措施。不深入讨论,Rust 中有几种不同的错误处理方式:
- 可以以非零退出码中止。
- 可以用错误 panic。它可能会中止进程,也可能不会。如前所述,这取决于程序的编译方式。
- 可以将错误作为普通值处理,通常使用
Result<T, E>。如果错误一直冒泡到 main 函数,可能会将错误打印到 stderr 然后中止。
这三种都是完全有效的错误处理策略。问题在于,在 Rust 程序的上下文中,前两种会导致非常糟糕的用户体验。因此,(3) 通常被视为最佳实践。标准库和所有核心生态库都使用 (3)。据我所知,所有“流行”的 Rust 应用程序也都使用 (3)。
(3) 最重要的部分之一是能够在错误值返回给调用方时附加额外的上下文。anyhow crate 使这变得轻而易举。以下是我正在开发的 regex-cli 工具中的一个片段:
use anyhow::Context;
if let Some(x) = args.value_of_lossy("warmup-time") {
let hdur: ShortHumanDuration = x.parse().context("--warmup-time")?;
margs.bench_config.approx_max_warmup_time = Duration::from(hdur);
}
关键部分是 x.parse().context("--warmup-time")?。对于不熟悉 Rust 的人,我来分解一下:
x是Cow<'a, str>,即“要么是拥有的 String,要么是借用的 &str”。“Cow” 代表 “copy-on-write”。parse()是FromStr::from_str的简写,它将字符串解析为其他数据类型。在这里是ShortHumanDuration。由于解析可能失败,parse()返回Result<T, E>。context()来自anyhow::Contexttrait。它是一个“扩展 trait”,为Result<T, E>添加方法。在这里,context("--warmup-time")为错误的因果链添加了一条简短消息。?后缀操作符表示:“如果Result<T, E>是Ok(T),则返回T,否则将E作为错误从当前函数返回。”(注意:这不是对?的精确描述。详见 Rust 参考手册的“问号操作符”部分。)
最终结果是,如果用户向 --warmup-time 标志传递了无效值,错误消息将包含 --warmup-time:
$ regex-cli bench measure --warmup-time '52 minutes'
Error: --warmup-time
Caused by:
duration '52 minutes' not in '<decimal>(s|ms|us|ns)' format
这清楚地表明了用户输入中哪一部分有问题。
(注:anyhow 非常适合面向应用程序的代码,但如果构建的是供他人使用的库,我建议编写具体的错误类型并提供适当的 std::fmt::Display 实现。thiserror crate 可以减少一些样板代码,但如果尚未因其他原因使用过程宏依赖,我会跳过它以避免引入过程宏依赖。)
应该用 unwrap() 进行错误处理吗?
在以下三种场景中,经常可以看到 unwrap() 被用于错误处理:
- 快速一次性程序、原型开发或为自己编写的程序。由于唯一的最终用户就是程序员本人,panic 不一定会带来糟糕的用户体验。
- 在测试中。一般来说,Rust 测试在 panic 时失败,不 panic 时通过。因此在这种上下文中,unwrap() 完全没问题,因为 panic 很可能正是你想要的。请注意,单元测试也可以返回
Result,从而允许在测试中使用?。 - 在文档示例中。过去,在文档示例中将错误作为值处理比使用 panic 要麻烦得多。不过现在,doctest 中也可以使用
?。
就我个人而言,我对是否在上述任何场景中使用 unwrap() 并没有特别强烈的看法。以下是我对每种情况的看法:
- 即使在快速程序或仅为我自己构建的程序中,我也将错误作为值处理。
anyhow使这变得极其简单。只需cargo add anyhow,然后使用fn main() -> anyhow::Result<()>。就是这样。在这种上下文中,使用 panic 进行错误处理并没有巨大的便利性优势。anyhow甚至会发出回溯。 - 我在测试中大量使用 unwrap()。我很少(如果有的话)在单元测试中使用
?。这可能是因为我开始写 Rust 时单元测试还不能返回Result<T, E>。我从未看到有 compelling 的理由改变我的做法并编写更长的签名。 - 我通常倾向于在文档示例中将错误作为值处理而不是 panic。特别是,只需在大多数示例底部添加
# Ok::<(), Box<dyn std::error::Error>>(()),现在就可以在示例中使用?。这很容易做到,并且展示了更符合惯用法的代码。话虽如此,真正的错误处理往往会为错误添加上下文。我认为这是符合惯用法的,但我不会在文档示例中这样做。此外,文档示例通常旨在展示 API 的某个特定方面,期望它们在其他每个方面都完美符合惯用法——尤其是如果这会分散对示例重点的关注——似乎是不现实的。因此,总的来说,我认为在文档中使用 unwrap() 是可以的,但我一直在逐渐远离它,因为这很容易做到。
总之,我认为“不要在 Rust 中使用 unwrap() 进行错误处理”是一个不错的初步近似。但合理的人可能会就是否在某些场景(如上所述)中使用 unwrap() 产生分歧,因为它的简洁性。
话虽如此,我认为无可争议的是:unwrap() 不应用于供他人使用的 Rust 库或应用程序中的错误处理。这是一个价值判断。有人可能不同意,但我认为很难论证使用 unwrap() 进行错误处理能带来良好的用户体验。因此,我认为大多数人在此达成一致:unwrap() 以及更广泛地说,panic,不是 Rust 中 adequate 的错误处理方法。
2025年5月17日补充:虽然这不是我的常见做法,但其他人报告称 unwrap() 的另一种常见用途是临时的。也就是说,一些程序员在试图解决问题时会先写 unwrap()。稍后,可能在提交补丁之前,他们会回来移除 unwrap() 并添加“proper”的错误处理。这是完全有效的。标记这些 unwrap() 实例时,使用 TODO 注释或 .expect("TODO") 可能很有用,以确保记得稍后处理它们。
那么“可恢复”与“不可恢复”错误呢?
Rust Book 中的“错误处理”章节推广了将错误视为“可恢复”与“不可恢复”的概念。也就是说,如果错误是“可恢复的”,那么应该将其作为普通值处理并使用 Result<T, E>。另一方面,如果错误是不可恢复的,那么 panic 是可以的。
我个人从未觉得这种概念化特别有帮助。在我看来,问题在于确定特定错误是否“可恢复”存在模糊性。这到底是什么意思?
我认为更 helpful 的是具体化。也就是说,如果发生 panic,那么程序中某处存在 bug。如果 panic 发生在函数内部,因为未满足记录的前置条件,那么责任在函数的调用方。否则,责任在该函数的实现方。
这就是确定是将错误作为值处理还是作为 panic 处理所需知道的一切。一些例子:
- 如果程序无法打开最终用户指定路径的文件,这是 bug 吗?不是。所以应将其作为错误值处理。
- 如果程序无法从静态字符串字面量构建正则表达式,这是 bug 吗?是的。程序员输入了那个正则表达式。它应该是正确的。所以 panic 是合适的。
所以永远不应该 panic 吗?
一般来说,是的,正确的 Rust 程序不应该 panic。
这是否意味着如果在快速 Rust “脚本”中使用 panic 进行错误处理,那么它就不是正确的?David Tolnay 曾建议这近乎 Russell 悖论,我倾向于同意他的观点。或者,可以将脚本或原型视为带有标记为 wontfix 的 bug。
所以永远不应该使用 unwrap() 或 expect() 吗?
不!像 unwrap() 或 expect() 这样的例程只有在其值不是调用方预期的情况下才会 panic。如果值始终是调用方预期的,那么 unwrap() 和 expect() 就永远不会导致 panic。如果确实发生了 panic,那么这通常对应于程序员期望的违反。换句话说,运行时不变量被破坏并导致了 bug。
这与“不要用 unwrap() 进行错误处理”截然不同。这里的 key difference 是,我们 expect 错误会以某种频率发生,但我们 never expect bug 会发生。当 bug 确实发生时,我们会寻求移除 bug(或声明这是一个不会修复的问题)。
我认为,围绕 unwrap() 的很多 confusion 来自善意的人说“不要使用 unwrap()”,而他们实际意思是“不要将 panic 作为错误处理策略”。这又被另一群人进一步混淆,他们 actually literally 意思是“永远不要在任何情况下使用 unwrap()”,甚至认为它根本不应该存在。还有第三群人说“不要使用 unwrap()”,但实际上意思是“不要使用 unwrap()、expect()、切片索引或任何其他 panic 例程,即使你证明了 panic 是不可能的”。
换句话说,本文试图解决两个问题。一个是确定何时应该使用 unwrap() 的问题。另一个是沟通问题。这恰好是一个 imprecision 会导致看似奇怪不一致建议的领域。
什么是运行时不变量(runtime invariant)?
它是应该始终为真的东西,但保证是在运行时维护的,而不是在编译时证明的。
一个简单的不变量例子是一个永不为零的整数。有几种设置方式:
- 使用
std::num::NonZeroUsize。这在编译时维护不变量,因为该类型的构造保证它不能为零。 - 使用
Option<usize>并依赖提供此值的调用方在内部 usize 为 0 时使用 None。这在运行时维护不变量,因为Option<usize>的构造未被封装。 - 使用
usize并依赖提供此值的调用方永不将其设为 0。这也维护运行时不变量。
(注:std::num::NonZeroUsize 除了在编译时强制执行此特定不变量外还有其他好处。即,它允许编译器进行内存布局优化,使得 Option<NonZeroUsize> 在内存中的大小与 usize 相同。)
在这种情况下,如果需要“永不为零的整数”这样的不变量,那么使用 NonZeroUsize 这样的类型是一个非常 compelling 的选择,几乎没有缺点。它确实在代码中引入了一些噪音,因为在实际使用整数时需要调用 get() 来获取实际的 usize,而实际的 usize 可能是进行算术运算或用于切片索引所必需的。
为什么不让所有不变量都成为编译时不变量?
在某些情况下,这是做不到的。我们将在下一节讨论。
在其他情况下,虽然可以做到,但出于某些原因选择不这样做。其中一个原因是 API 复杂性。
考虑我 aho-corasick crate(提供 Aho-Corasick 算法实现)中的一个真实世界例子。其 AhoCorasick::find_overlapping_iter 方法会在 AhoCorasick 自动机未在运行时以“match kind”为“standard”的方式构建时 panic。换句话说,AhoCorasick::find_overlapping_iter 例程对调用方施加了一个记录的前置条件,要求只在 AhoCorasick 以特定方式构建时才调用它。我这样做的原因有几个:
- 重叠搜索只有在“match kind”设置为“standard”时才有意义。
- 设置“match kind”几乎总是由程序员完成的,而不是由程序输入控制的。
- API 简洁性。
我所说的“API 简洁性”是什么意思?嗯,可以通过将这个运行时不变量移到编译时不变量来消除这个 panic。也就是说,API 可以提供例如 AhoCorasickOverlapping 类型,而重叠搜索例程只定义在该类型上,而不是 AhoCorasick 上。因此,crate 用户永远无法在配置不当的自动机上调用重叠搜索例程。编译器根本不会允许。
但这会大大增加 API 的表面积。而且是以非常隐蔽的方式。例如,AhoCorasickOverlapping 类型仍然希望拥有与 AhoCorasick 相同的普通非重叠搜索例程。现在合理地希望编写接受任何类型 Aho-Corasick 自动机并运行非重叠搜索的例程。在这种情况下,要么 aho-corasick crate 要么使用 crate 的程序员需要定义某种泛型抽象来启用这一点。或者,更可能的是,可能需要复制一些代码。
因此我做出判断:拥有一个可以做所有事情的类型——但在某些配置下某些方法可能会大声失败——是最好的。aho-corasick 的 API 设计不会导致产生静默错误结果的微妙逻辑错误。如果犯了错误,调用方仍然会得到带有清晰消息的 panic。到那时,修复将很容易。
作为交换,我们得到了一个整体上更简单的 API。只有一个类型可用于搜索。不必回答诸如“等等,我想要哪个类型?现在我得去理解两者并尝试拼凑”之类的问题。如果想编写接受任何自动机并执行非重叠搜索的单个泛型例程,也不需要泛型。因为只有一个类型。
如果不变量无法移到编译时怎么办?
考虑如何使用确定性有限自动机(DFA)实现搜索。基本实现只有几行,很容易在这里包含:
type StateID = usize;
struct DFA {
// 起始状态的 ID。每次搜索都从这里开始。
start_id: StateID,
// 行主序转换表。对于状态 's' 和字节 'b',
// 下一个状态是 's * 256 + b'。
transitions: Vec<StateID>,
// 特定状态 ID 是否对应匹配状态。
// 保证长度等于状态数。
is_match_id: Vec<bool>,
}
impl DFA {
// 如果 DFA 匹配整个 'haystack',返回 true。
// 此例程对所有输入总是返回 true 或 false。
// 它从不 panic。
fn is_match(&self, haystack: &[u8]) -> bool {
let mut state_id = self.start_id;
for &byte in haystack {
// 乘以 256 因为这是我们 DFA 的字母表大小。
// 换句话说,每个状态有 256 个转换。每个字节一个。
state_id = self.transitions[state_id * 256 + usize::from(byte)];
if self.is_match_id[state_id] {
return true;
}
}
false
}
}
这里有几个可能发生 panic 的地方:
state_id * 256 + byte可能不是self.transitions的有效索引。state_id可能不是self.is_match_id的有效索引。state_id * 256乘法在 debug 模式下可能 panic。在 release 模式下,目前会执行 wrapping 乘法,但在未来的 Rust 版本中可能会改为在溢出时 panic。- 同样,
+ usize::from(byte)加法可能因相同原因 panic。
如何在编译时保证给定算术和切片访问的情况下永远不会发生 panic?请记住,transitions 和 is_match_id 向量可能是从用户输入构建的。因此无论怎么做,都不能依赖编译器知道 DFA 的输入。构建 DFA 的输入可能是任意的正则表达式模式。
没有可行的方法将 DFA 正确构建和搜索的不变量推到编译时。它必须是运行时不变量。谁负责维护这个不变量?构建 DFA 的实现和使用 DFA 执行搜索的实现。这两者都需要相互一致。换句话说,它们共享一个秘密:DFA 在内存中的布局方式。(注意:我以前曾错误地认为不可能将不变量推入类型系统。我承认这里存在可能性,我的想象力并不 great。然而,我相当确定这样做会涉及相当多的仪式感和/或适用性受限。尽管如此,即使它不完全符合要求,这也将是一个有趣的练习。)
如果发生了 panic,这意味着什么?它必须意味着代码某处存在 bug。由于此例程的文档保证它从不 panic,问题必须出在实现上。要么是 DFA 构建方式有 bug,要么是 DFA 搜索方式有 bug。
为什么不返回错误而要 panic?
当存在 bug 时,可以返回错误而不是 panic。可以重写上一节中的 is_match 函数以返回错误而不是 panic:
// 如果 DFA 匹配整个 'haystack',返回 true。
// 此例程对所有输入总是返回 Ok(true) 或 Ok(false)。
// 除非实现中有 bug,否则它从不返回错误。
fn is_match(&self, haystack: &[u8]) -> Result<bool, &'static str> {
let mut state_id = self.start_id;
for &byte in haystack {
let row = match state_id.checked_mul(256) {
None => return Err("state id too big"),
Some(row) => row,
};
let row_offset = match row.checked_add(usize::from(byte)) {
None => return Err("row index too big"),
Some(row_offset) => row_offset,
};
state_id = match self.transitions.get(row_offset) {
None => return Err("invalid transition"),
Some(&state_id) => state_id,
};
match self.is_match_id.get(state_id) {
None => return Err("invalid state id"),
Some(&true) => return Ok(true),
Some(&false) => {}
}
}
Ok(false)
}
注意这个函数变得多么复杂。也注意文档是多么笨拙。谁会写“如果实现有 bug,这些文档完全是错的”这样的东西?你在任何非实验性库中见过吗?这没有太大意义。而且为什么要返回一个文档保证永远不会返回的错误?明确地说,出于 API 演化的原因(即“也许某天它会返回错误”),可能会这样做,但此例程在任何可能的未来场景下都不会返回错误。
这种例程的好处是什么?如果我们 steelman 支持这种编码风格的倡导者,那么我认为论证最好 limited 到某些高可靠性领域。我个人在这些领域没有太多经验,但我可以想象一些情况,人们不希望在最终编译的二进制文件中有任何 panic 分支。这给人很多 assurance,知道自己代码在任何给定点处于什么状态。这也意味着可能无法使用 Rust 标准库或大多数核心生态 crate,因为它们都将在某处包含 panic 分支。换句话说,这是一种非常 expensive 的编码风格。
这种编码风格的真正有趣之处——将运行时不变量推入错误值——实际上是 impossible 正确记录错误条件。well documented 的错误条件以某种方式将函数的输入与某些 failure case 关联起来。但这里 literally 无法做到,因为如果能做到,就是在记录一个 bug!
即使不是必需的,什么时候也应该使用 unwrap()?
考虑一个例子,其中 unwrap() 的使用实际上可以避免,且成本只是轻微的代码复杂性。这个改编的片段来自 regex-syntax crate:
enum Ast {
Empty(std::ops::Range<usize>),
Alternation(Alternation),
Concat(Concat),
// ... and many others
}
// 正则表达式如 'a|b|...|z' 的 AST 表示。
struct Alternation {
// 此交替在具体语法中出现位置的字节偏移。
span: std::ops::Range<usize>,
// 每个交替的 AST。
asts: Vec<Ast>,
}
impl Alternation {
/// 将此交替返回为最简单的可能 'Ast'。
fn into_ast(mut self) -> Ast {
match self.asts.len() {
0 => Ast::Empty(self.span),
1 => self.asts.pop().unwrap(),
_ => Ast::Alternation(self),
}
}
}
self.asts.pop().unwrap() 片段在 self.asts 为空时会 panic。但由于我们检查了其长度非零,它不可能为空,因此 unwrap() 永远不会 panic。
但为什么在这里使用 unwrap()?我们实际上可以完全不用 unwrap() 编写它:
fn into_ast(mut self) -> Ast {
match self.asts.pop() {
None => Ast::Empty(self.span),
Some(ast) => {
if self.asts.is_empty() {
ast
} else {
self.asts.push(ast);
Ast::Alternation(self)
}
}
}
}
这里的问题是,如果 pop() 后 self.asts 非空,那么我们确实想要创建 Ast::Alternation,因为有两个或更多子表达式。如果有零个或一个子表达式,那么有更简单的表示可用。所以在多个子表达式的情况下,pop 一个后,实际上需要将其 push 回 self.asts 再构建交替。
重写的代码没有 unwrap(),这是一个优势,但它迂回且奇怪。原始代码要简单得多,且 trivially 可以观察到 unwrap() 永远不会导致 panic。
为什么不使用 expect() 而是 unwrap()?
expect() 类似于 unwrap(),但它接受一个消息参数,并在 panic 输出中包含该消息。换句话说,如果发生 panic,它会为 panic 消息添加一点额外的上下文。
我认为 generally 推荐使用 expect() 而不是 unwrap() 是个好主意。但是,我认为完全禁止 unwrap() 并不是一个好主意。通过 expect() 添加上下文有助于告知读者,作者考虑了相关不变量并编写了消息说明 exactly 期望什么。
不过,expect() 消息往往很短,通常不包含使用 expect() 正确的完整理由。以下是 regex-syntax crate 中的另一个例子:
/// 解析最多 3 位长的 Unicode 码点的八进制表示。
/// 这期望解析器位于第一个八进制数字处,
/// 并将解析器推进到八进制数字后的第一个字符。
/// 这也假设启用了八进制转义解析。
///
/// 假设满足前置条件,此例程永远不会失败。
fn parse_octal(&self) -> ast::Literal {
// 检查记录的前置条件。
assert!(self.parser().octal);
assert!('0' <= self.char() && self.char() <= '7');
let start = self.pos();
// 解析最多两个更多数字。
while self.bump()
&& '0' <= self.char()
&& self.char() <= '7'
&& self.pos().offset - start.offset <= 2
{}
let end = self.pos();
let octal = &self.pattern()[start.offset..end.offset];
// 解析八进制永远不会失败,因为上面保证了有效数字。
let codepoint =
std::u32::from_str_radix(octal, 8).expect("valid octal number");
// 3 位八进制的最大值是 0777 = 511,[0, 511] 中没有
// 无效的 Unicode 标量值。
let c = std::char::from_u32(codepoint).expect("Unicode scalar value");
ast::Literal {
span: Span::new(start, end),
kind: ast::LiteralKind::Octal,
c,
}
}
这里有两次使用 expect()。在每种情况下,expect() 消息都有些用处,但 expect() 在这两种情况下都可以的真正要点来自注释。注释解释了为什么 from_str_radix 和 from_u32 操作永远不会失败。expect() 消息只是给出了一个额外的提示,使 panic 消息稍微更有用。
是否使用 unwrap() 或 expect() 取决于判断。在上面的 into_ast() 例子中,我认为 expect() 添加了无意义的噪音,因为周围的代码如此 trivially 显示了为什么 unwrap() 是可以的。在这种情况下,甚至没有必要写注释说明这一点。
expect() 以其他方式添加噪音的例子:
Regex::new("...").expect("a valid regex");
mutex.lock().expect("an unpoisoned mutex");
slice.get(i).expect("a valid index");
我的观点是,这些实际上都没有为代码添加任何信号,反而使代码更加冗长和嘈杂。如果 Regex::new 调用在静态字符串字面量上失败,已经会打印很好的错误消息。例如,考虑这个程序:
fn main() {
regex::Regex::new(r"foo\p{glyph}bar").unwrap();
}
运行它:
$ cargo run
Finished dev [unoptimized + debuginfo] target(s) in 0.00s
Running `target/debug/rust-panic`
thread 'main' panicked at 'called `Result::unwrap()` on an `Err` value: Syntax(
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
regex parse error:
foo\p{glyph}bar
^^^^^^^^^
error: Unicode property not found
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
)', main.rs:4:36
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrac
基本上,在某个时候,为相同的常见操作一遍又一遍地编写相同的 expect() 消息 becomes a tedious exercise。相反,应该运用 good judgment 来确定在任何给定情况下是使用 unwrap() 还是 expect()。
(注:关于 Regex 例子,有些人说字符串字面量中的无效正则表达式应该导致程序编译失败。Clippy 实际上有针对此的 lint,但一般来说,Regex::new 无法通过 Rust 的 const 功能做到这一点。如果要实现,那么大部分 Rust 语言都需要在 const 上下文中可用。可以编写过程宏代替,但 Regex::new 仍然需要存在。)
是否应该对 unwrap() 的使用进行 lint 检查?
反对“依靠良好判断力”这一观点的一个常见理由是:将人类的主观判断从决策中移除有时反而更好。如果对 unwrap() 进行 lint 检查(即禁止使用),那么每位程序员都必须写出 unwrap() 以外的代码。这种做法背后的逻辑是:强制这一步骤可能会促使程序员更深入地思考自己的代码是否会引发 panic(恐慌),而他们原本可能不会这么做。我同意,要求程序员写 expect() 并提供一条错误信息确实会动用更多的脑细胞,很可能真的会让人们更认真地考虑 panic 是否可能发生。
尽管我认为在某些特定场景下,这样的 lint 规则并非完全不合理,但我仍然想提出几点反对意见。
首先,正如我之前提到的,在许多情况下,expect() 会向代码中引入不必要的噪音,使代码变得冗长、杂乱。很多时候,unwrap() 不会失败的原因要么显而易见,要么如果确实需要更详细的解释,也更可能出现在注释中,而不是 expect() 的消息里。
其次,unwrap() 是符合惯用法的(idiomatic)。请注意,我这里是在做描述性陈述,而非规范性主张。我不是说它“应该”成为惯用法,而是说它“已经是”惯用法——这一点可以从标准库和核心生态 crate 中的广泛使用得到印证。它不仅在我的代码中很常见,在整个 Rust 社区中也是如此。这表明在实践中 unwrap() 并非真正有问题(尽管我也承认这一论断存在一些混杂因素)。
第三,有许多常见的操作同样会导致 panic,却并不需要显式写出 unwrap():
- 切片索引语法:例如,
slice[i]在i越界时会 panic。虽然其 panic 信息比slice.get(i).unwrap()稍好一些,但最终仍会 panic。如果我们因为unwrap()容易被不加思考地使用而禁止它,那是否也应该禁止切片索引语法? - 算术运算溢出:目前在 release 模式下会回绕(wrap),但在 debug 模式下会 panic。未来甚至可能在 release 模式下也 panic。如果我们因为
unwrap()容易被草率使用而禁止它,那是否也应该禁止像+和*这样的基本运算符?(即使今天在 release 模式下不 panic,也不代表 release 模式下不会出现 bug!实际上,算术运算静默回绕很可能导致 bug。那么为什么不禁止它,而强制大家 everywhere 使用wrapping_add或checked_add呢?记住,我们的目标不是避免 panic,而是避免 bug。) - 使用
RefCell进行内部可变性操作时,其borrow()和borrow_mut()方法在运行时发生借用冲突时会 panic。同样的论点也适用于此。 - 内存分配本身也可能失败,而目前分配失败会导致进程 abort(中止),这比 panic 更严重。(尽管据我所知,理想情况下分配失败应触发 panic 而非 abort。)这是否意味着我们也应对内存分配更加谨慎?
当然,我的论点中存在一个明显的漏洞:“不要让完美成为善行的敌人”(don’t let perfect be the enemy of the good)。即使我们无法或不愿对所有可能导致 panic 的操作进行 lint 检查,也不意味着我们就不能通过禁止 unwrap() 来改善现状。但我认为,像切片索引语法和算术运算符这类操作实在太常见了,单靠禁止 unwrap() 并不会带来显著的改进。
第四点,也是最后一点:禁止 unwrap() 有一定概率导致程序员开始写 expect(""),或者如果连空字符串也被禁止,就写 expect("no panic")。我相信大多数人都熟悉这类因 lint 规则而催生的敷衍行为。你见过多少次函数 frob_quux 上面的注释写着 “This frob’s quux”?这种注释很可能只是因为某个 lint 工具强制要求添加而已。
不过正如我所说,我理解对此问题合理的人可以持不同意见。我并没有一个无懈可击的论据来反对对 unwrap() 进行 lint 检查。我只是认为,为此付出的努力(the juice)不值得所获得的收益(the squeeze)。
为什么 panic 如此优秀?
panic 之所以重要,是因为很多 bug 根本不需要在调试器中运行 Rust 程序就能被发现。为什么?因为大量 bug 会直接导致 panic,而 panic 会提供堆栈跟踪(stack trace)和行号——这正是调试器能提供的最重要功能之一(尽管不是唯一功能)。panic 的价值还不止于此:当 Rust 程序在终端用户手中 panic 时,用户可以轻松分享 panic 信息,甚至只需设置 RUST_BACKTRACE=1 就能获得完整的堆栈跟踪。这是一个非常简单的操作,尤其在难以复现问题的场景下极为有用。
2025年5月17日补充:请注意,此处并非要将 panic 与错误处理(error handling)进行对比。重点在于:当 bug 发生时,如果它表现为一次 panic,你应该感到庆幸。因为相比微妙的逻辑错误,panic 通常更容易调试。如果你希望在使用错误值(error values)时也能获得堆栈跟踪和行号信息,
thiserror和anyhow等 crate 可以提供这一能力。
正因为 panic 如此有用,我们应该尽可能地利用它们:
- 使用
assert!(及相关宏)来积极检查前置条件(preconditions)和运行时不变量(runtime invariants)。在检查前置条件时,确保 panic 信息与文档中声明的前置条件相关,必要时添加自定义消息。例如:assert!(!xs.is_empty(), "expected parameter 'xs' to be non-empty"); - 当
expect()能为 panic 信息提供有意义的上下文时,请使用它。如果expect()与某个前置条件相关,那么清晰的 panic 信息就显得尤为重要。 - 当
expect()只会增加噪音时,请放心使用unwrap()。 - 对于像切片索引这样的操作,当无效索引意味着程序存在 bug 时(这种情况通常成立),请继续使用索引语法。
当然,如果可能,将运行时不变量尽可能提升为编译期不变量通常是更优的选择。这样你就无需担心 unwrap()、assert! 或其他任何运行时检查了——不变量的正确性由程序能够成功编译这一事实来保证。Rust 在这方面尤为出色,其整个内存安全机制就极度依赖于将运行时约束转化为编译期约束的能力。
然而,如上所述,有时将不变量编码到类型系统中要么不可能,要么不值得。在这种情况下,请欣然接受 panic。