Andrew Gallant's Blog 关于 项目 GitHub 赞助我

在 Rust 中使用 unwrap() 没问题

2022年8月8日

在 Rust 1.0 发布的前一天,我发表了一篇博客文章,涵盖了错误处理的基础知识。文章中间的一个特别重要但很小的部分名为“unwrapping isn’t evil”。该部分简要描述了,总的来说,如果 unwrap() 用在测试/示例代码中,或者当 panic 表明存在 bug 时,使用它是可以的。

我通常仍然坚持这个观点。这个观点已经在 Rust 的标准库和许多核心生态系统中得到了实践。(并且这种实践早于我的博客文章。)然而,对于何时可以使用 unwrap(),何时不可以使用,似乎仍然存在广泛的困惑。这篇文章将更详细地讨论这个问题,并专门回应我看到的一些观点。

这篇博客文章有点像一个 FAQ,但它旨在按顺序阅读。每个问题都建立在前一个问题之上。

目标读者:主要是 Rust 程序员,但我希望我提供了足够的背景知识,使这里阐述的原则适用于任何程序员。尽管将一个明显的映射应用到具有不同错误处理机制的语言(例如异常)可能很棘手。

目录

我的立场是什么?

我认为有必要预先说明我对错误处理和 panic 的一些立场。这样,读者就可以清楚地知道我的观点是什么。

本文的其余部分将证明这些立场的合理性。

unwrap() 是什么?

由于这篇文章中表达的观点_并非_ Rust 特有的,我认为有必要介绍一下 unwrap() 到底是什么。unwrap() 是指在 Option<T>Result<T, E> 上定义的函数,它在 SomeOk 变体的情况下返回底层 T,否则会 panic。它们的定义非常简单。对于 Option<T>

impl<T>Option<T>{pubfn unwrap(self)-> T{matchself{Some(val)=>val,None=>panic!("called `Option::unwrap()` on a `None` value"),}}}

现在对于 Result<T, E>

impl<T,E: std::fmt::Debug>Result<T,E>{pubfn unwrap(self)-> T{matchself{Ok(t)=>t,Err(e)=>panic!("called `Result::unwrap()` on an `Err` value: {:?}",e),}}}

我试图在这篇文章中解决的关键问题是是否以及在多大程度上应该使用 unwrap()

panic” 是什么意思?

当发生 panic 时,通常会发生以下两种情况之一:

发生哪件事取决于程序的编译方式。可以通过 Cargo.toml 中的 panic 配置文件设置来控制它。

当展开发生时,可以捕获 panic并对其进行处理。例如,Web 服务器可能会捕获在请求处理程序内部发生的 panic,以避免使整个服务器崩溃。另一个示例是测试工具,它可以捕获在测试中发生的 panic,以便可以执行其他测试并漂亮地打印结果,而不是立即使整个工具崩溃。

虽然 panic 可以用于错误处理,但它通常被认为是一种糟糕的错误处理形式。值得注意的是,该语言不支持将 panic 用作错误处理,而且至关重要的是,不保证会发生展开。

panic 导致从未捕获的展开时,程序可能会在整个堆栈展开后中止,并打印 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(){letmutf=std::fs::File::open("foobar").unwrap();std::io::copy(&mutf,&mutstd::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 错误时试图做什么。它还包含大量对最终用户无用的噪音。

总之:

什么是错误处理?

错误处理是在代码中“出错”时所做的事情。无需深入研究,在 Rust 中处理错误有几种不同的方法:

  1. 可以使用非零退出代码中止。
  2. 可以使用错误 panic。它可能会中止进程。也可能不会。如前一节所述,这取决于程序的编译方式。
  3. 可以像处理普通值一样处理错误,通常使用 Result<T, E>。如果错误一直冒泡到 main 函数,则可以将错误打印到 stderr,然后中止。

所有这三种都是完全有效的错误处理策略。问题在于,前两种方法在 Rust 程序的上下文中会导致非常糟糕的用户体验。因此,(3) 通常被认为是最佳实践。标准库和所有核心生态系统库都使用 (3)。此外,据我所知,所有“流行的”Rust 应用程序也都使用 (3)。

(3) 中最重要的部分之一是能够将额外的上下文附加到错误值,因为它们会返回给调用者。anyhow 库使这毫不费力。这是我正在开发的 regex-cli 工具中的一个片段:

useanyhow::Context;ifletSome(x)=args.value_of_lossy("warmup-time"){lethdur: ShortHumanDuration=x.parse().context("--warmup-time")?;margs.bench_config.approx_max_warmup_time=Duration::from(hdur);}

这里重要的是 x.parse().context("--warmup-time")? 部分。对于那些不熟悉 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 库消除了一些与这样做相关的样板代码,但我建议跳过它以避免过程宏依赖项,如果还没有为其他内容使用过程宏依赖项。)

应该使用 unwrap() 进行错误处理吗?

在以下三种情况下,经常看到 unwrap() 用于错误处理:

  1. 快速的一次性程序、原型设计或可能编写供个人使用的程序。由于唯一的最终用户是应用程序的程序员,因此 panic 不一定是糟糕的用户体验。
  2. 在测试中。通常,如果 Rust 测试 panic 则失败,如果不 panic 则通过。因此,在这种情况下,unwrap() 非常好,因为很可能 panic 正是想要的结果。请注意,可以从单元测试中返回 Result,这允许在测试中使用 ?
  3. 在文档示例中。过去,在文档示例中将错误视为值而不是使用 panic 会花费更多的工作。但是现在,? 可以在 doctests 中使用

就我个人而言,我对是否_应该_在上述任何情况下使用 unwrap() 并没有非常强烈的意见。以下是我对每个问题的看法:

  1. 即使在快速程序或仅为自己构建的程序中,我也将错误视为值。anyhow 使这变得非常简单。只需 cargo add anyhow 然后使用 fn main() -> anyhow::Result<()>。仅此而已。在这种情况下,使用 panic 进行错误处理没有巨大的符合人体工程学的优势。anyhow 甚至会发出回溯。
  2. 我在测试中自由地使用 unwrap()。我很少在单元测试中使用 ?。这可能是因为我开始编写 Rust 时,单元测试无法返回 Result<T, E>。我从未看到改变我在这里所做的事情并写出更长的签名有什么令人信服的优势。
  3. 我通常倾向于将错误视为值,而不是在文档示例中 panic。特别是,所要做的只是在大多数示例的底部添加 # Ok::<(), Box<dyn std::error::Error>>(()),现在 ? 可以在示例中使用。这很容易做到,并显示了往往更符合习惯的代码。也就是说,_真正的_错误处理往往会向错误添加上下文。我认为这是符合习惯的,但我在文档示例中没有这样做。此外,文档示例往往针对的是演示 API 的某些特定方面,并且期望它们在其他每个方面都完全符合习惯,尤其是在分散了示例的重点的情况下,似乎是不现实的。所以总的来说,我认为文档中的 unwrap() 还可以,但我一直在远离它,因为它很容易做到。

因此,总而言之,我会说“不要在 Rust 中使用 unwrap() 进行错误处理”是一个很好的初步近似值。但是,由于其简洁性,理性的人可能会不同意是否在某些情况下使用 unwrap()(如上所述)。

也就是说,我认为可以毫不争议地说,unwrap() 不应用于 Rust 库或旨在供他人使用的应用程序中的错误处理。这是一个价值判断。人们可能会不同意这一点,但我认为很难争辩说使用 unwrap() 进行错误处理会导致良好的用户体验。因此,我认为大多数人都赞同这一点:unwrap(),更普遍地说,panic,在 Rust 中不是一种充分的错误处理方法。

2025-05-17 添加:虽然不是我的常见做法,但其他人报告说 unwrap() 的另一种常见用途是临时的。也就是说,一些程序员在试图弄清楚如何解决问题时编写 unwrap()。稍后,可能在提交补丁之前,他们会回来,删除 unwrap() 并添加“正确的”错误处理。这完全有效。可以用 TODO 注释或 .expect("TODO") 标记此类 unwrap() 实例,以确保记得稍后处理它们。

那么“可恢复”与“不可恢复”的错误呢?

Rust Book 中的“错误处理”章节 普及了将错误视为“可恢复”与“不可恢复”的想法。也就是说,如果一个错误是“可恢复的”,那么应该将其视为一个普通值并使用 Result<T, E>。另一方面,如果一个错误是不可恢复的,那么 panic 就可以了。

我个人从未发现这种特殊的概念化有帮助。正如我所看到的,问题在于确定特定错误是否“可恢复”的歧义。到底是什么意思?

我认为更具体地说是非常有帮助的。也就是说,如果发生 panic,那么程序中存在 bug。如果 panic 发生在函数内部,因为未遵守记录的前提条件,那么错误在于函数的调用者。否则,错误在于该函数的实现。

这就是确定是将错误视为值还是将它们视为 panic 所需要知道的全部内容。一些例子:

所以永远不应该 panic 吗?

一般来说,是的,正确的 Rust 程序不应该 panic

这是否意味着如果在快速 Rust“脚本”中使用 panic 进行错误处理,那么它就不正确? David Tolnay 建议 这接近于一种 罗素悖论,我倾向于同意他的观点。或者,可以将脚本或原型视为具有标记为 wontfix 的 bug。

所以永远不应该使用 unwrap()expect() 吗?

不!只有当它的值不是调用者期望的值时,unwrap()expect() 之类的函数才会 panic。如果该值_始终_是调用者期望的值,那么 unwrap()expect() 将永远不会导致 panic。如果发生 panic,那么这通常对应于_违反了程序员的期望_。换句话说,运行时不变性被打破并导致了 bug。

这与“不要使用 unwrap() 进行错误处理”截然不同。这里的关键区别在于我们_期望_以一定的频率发生错误,但我们_从不_期望发生 bug。当确实发生 bug 时,我们试图消除该 bug(或将其声明为无法解决的问题)。

我认为围绕 unwrap() 的许多困惑来自于好心人说“不要使用 unwrap()”之类的话,而他们_实际_的意思是“不要使用 panic 作为错误处理策略。” 这被另一群实际上字面上意思是“不要使用 unwrap()”,永远,在任何情况下,以至于它根本不应该存在 的人加倍困惑。这又被另一群人三重困惑,他们说“不要使用 unwrap()”,但实际上意思是“不要使用 unwrap()expect()、切片索引或任何其他 panic 例程,即使证明 panic 是不可能的。”

换句话说,我试图在这篇文章中解决两个问题。一个是确定何时应该使用 unwrap() 的问题。另一个是沟通问题。这恰好是一个不精确导致_表面上_奇怪的不一致建议的领域。

什么是运行时不变性?

它是_应该_始终为真的东西,但该保证是在运行时_维护的_,而不是在编译时证明的。

不变性的一个简单例子是一个永远不为零的整数。有几种方法可以设置它:

(注意:std::num::NonZeroUsize 除了在编译时强制执行此特定不变性之外,还具有其他好处。也就是说,它允许编译器进行内存布局优化,其中 Option<NonZeroUsize> 在内存中的大小与 usize 相同。)

在这种情况下,如果需要像“一个永远不为零的整数”这样的不变性,那么利用像 NonZeroUsize 这样的类型是一个非常有吸引力的选择,几乎没有缺点。在需要实际使用整数时,它确实在代码中引入了一些噪音,因为必须调用 get() 才能获得实际的 usize,并且可能需要实际的 usize 来执行算术运算或使用它来索引切片。

那么为什么不将所有不变性都变为编译时不变性呢?

在某些情况下,无法做到。我们将在下一节中介绍。

在其他情况下,_可以_做到,但由于某种原因,选择不这样做。其中一个原因是 API 复杂性。

考虑一下来自我的 aho-corasick 库(它提供了 Aho-Corasick 算法 的实现)的一个真实示例。如果运行时没有使用 “standard” 匹配类型 构建 AhoCorasick 自动机,则其 AhoCorasick::find_overlapping_iter 方法会 panic。换句话说,AhoCorasick::find_overlapping_iter 例程对调用者施加了一个有记录的前提条件,承诺仅在以某种方式构建 AhoCorasick 时才调用它。我这样做有几个原因:

我所说的 “API 简单性” 是什么意思? 嗯,可以通过将运行时不变性移动到编译时不变性来消除此 panic。 也就是说,API 可以提供例如 AhoCorasickOverlapping 类型,并且重叠搜索例程将仅在该类型上定义,而不在 AhoCorasick 上定义。 因此,该 crate 的用户永远无法在配置不正确的自动机上调用重叠搜索例程。 编译器根本不允许它。

但这会给 API 增加很多额外的表面积。 并且它以真正有害的方式做到了这一点。 例如,像 AhoCorasick 一样,AhoCorasickOverlapping 类型仍然希望具有正常的非重叠搜索例程。 现在有理由能够编写接受任何类型的 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>,}implDFA{// 如果 DFA 匹配整个“haystack”,则返回 true。
// 对于所有输入,此例程始终返回 true 或 false。
// 它永远不会 panic。
fn is_match(&self,haystack: &[u8])-> bool {letmutstate_id=self.start_id;for&byteinhaystack{// 乘以 256,因为那是我们 DFA 的字母表大小。
// 换句话说,每个状态有 256 个转换。 每个字节一个。
state_id=self.transitions[state_id*256+usize::from(byte)];ifself.is_match_id[state_id]{returntrue;}}false}}

这里有几个可能会发生 panic 的地方:

如何保证在编译时永远不会发生 panic,考虑到算术和切片访问? 请记住,transitionsis_match_id 向量可能来自用户输入构建。 因此,无论如何完成,都不能依赖编译器知道 DFA 的输入。 从中构建 DFA 的输入可能是任意正则表达式模式。

没有可行的方法可以将 DFA 的构造和搜索正确的不变性推送到编译时。 它必须是运行时不变性。 谁负责维护这种不变性? 构建 DFA 的实现和使用 DFA 执行搜索的实现。 这两件事需要彼此一致。 换句话说,它们共享一个秘密:DFA 在内存中的布局方式。 (警告:我之前对将不变性推送到类型系统中的不可能性是错误的。我承认这里有可能,我的想象力不是很好。但是,我相当确定这样做会带来相当多的仪式和/或其适用性受到限制。尽管如此,即使它没有完全符合要求,它仍然是一个有趣的练习。)

如果任何事情都 panic,那意味着什么? 它_必须_意味着代码中的某个地方存在 bug。 由于此例程的文档保证它永远不会 panic,因此问题必须在于实现。 要么是在构建 DFA 的方式中,要么是在搜索 DFA 的方式中。

为什么不返回错误而不是 panic 呢?

与其在出现 bug 时 panic,不如返回一个错误。 可以重写上一节中的 is_match 函数以返回一个错误而不是 panic

// 如果 DFA 匹配整个“haystack”,则返回 true。
// 对于所有输入,此例程始终返回 Ok(true) 或 Ok(false)。
// 除非其实施中存在 bug,否则它永远不会返回错误。
fn is_match(&self,haystack: &[u8])-> Result<bool,&'staticstr>{letmutstate_id=self.start_id;for&byteinhaystack{letrow=matchstate_id.checked_mul(256){None=>returnErr("state id too big"),Some(row)=>row,};letrow_offset=matchrow.checked_add(usize::from(byte)){None=>returnErr("row index too big"),Some(row_offset)=>row_offset,};state_id=matchself.transitions.get(row_offset){None=>returnErr("invalid transition"),Some(&state_id)=>state_id,};matchself.is_match_id.get(state_id){None=>returnErr("invalid state id"),Some(&true)=>returnOk(true),Some(&false)=>{}}}Ok(false)}

请注意此函数变得多么复杂。 并请注意文档是多么笨拙。 谁会写诸如“如果实施有 bug,这些文档完全错误”之类的内容? 您在任何非实验性库中都见过吗? 这没有多大意义。 如果文档保证永远不会返回错误,为什么要返回错误? 为了清楚起见,_可能_出于 API 演变的原因而想要这样做(即,“也许有一天它会返回一个错误”),但在任何可能的情况下,此例程在任何可能的未来情况下都不会返回错误。

这种例程有什么好处? 如果我们要精炼 支持这种编码风格的倡导者,那么我认为该论点最好仅限于某些高可靠性领域。 我个人在该领域没有太多经验,但我可以想象在任何地方都不希望最终编译的二进制文件中存在任何 panic 分支的情况。 这使人们对代码在任何给定时间所处的状态类型有了很大的保证。 这也意味着人们可能无法使用 Rust 的标准库或大多数核心生态系统 crate,因为它们都将在其中的某个地方存在 panic 分支。 换句话说,这是一种非常昂贵的编码风格。

这种编码风格真正有趣的地方 - 将运行时不变性推送到错误值中 - 实际上不可能正确记录错误条件。 记录良好的错误条件_将函数的输入_与某种方式的某些失败案例联系起来。 但是人们实际上无法为此函数执行此操作,因为如果人们可以,那么人们将记录一个 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{// 指向此 alternation 在具体语法中出现的位置的字节偏移量。
span: std::ops::Range<usize>,// 每个 alternation 的 AST。
asts: Vec<Ast>,}implAlternation{/// 将此 alternation 作为最简单的“Ast”返回。
fn into_ast(mutself)-> Ast{matchself.asts.len(){0=>Ast::Empty(self.span),1=>self.asts.pop().unwrap(),_=>Ast::Alternation(self),}}}

如果 self.asts 为空,self.asts.pop().unwrap() 片段将 panic。 但是由于我们检查了其长度为非零,因此它不能为空,