Rust 中的错误处理
原文地址:https://blog.burntsushi.net/rust-error-handling/
原文标题:Error Handling in Rust
翻译日期:2020/08/03
Rust 中的错误处理
与大多数编程语言一样,Rust 鼓励程序员以特定方式处理错误。一般而言,错误处理分为两大类:异常和返回值。Rust选择使用返回值进行错误处理。
在本文中,我尝试对 Rust 中如何进行错误处理提供全面的说明。并且,我将尝试一次引入一个错误处理的方法,并帮助你获得扎实的实践知识,即如何整合所有内容。
如果没有良好的实现方式,Rust 中的错误处理可能会很冗长并繁琐。本文将探讨这些问题,并展示如何使用标准库使错误处理变得简明,并符合工程习惯。
目标受众:尚不明确 Rust 错误处理用法的新手。熟悉 Rust 语法会更好。(本文使用了许多标准库 trait,但很少使用闭包和宏。)
更新(2018/04/14):示例已转换为使用 ?
进行处理,并添加了一些文本以提供更改的背景。
更新(2020/01/03):删除了 failure
的使用建议, 并替换为建议使用 Box<Error + Send + Sync>
或 anyhow
。
简要说明
文中的代码示例均通过 Rust 1.0.0-beta.5
进行编译。随着 Rust 1.0 稳定版的发布,他们应该同样可以运行。
所有的代码都可以在作者的博客仓库中找到并编译。
Rust Book 中有一节 section on error handling。它提供了非常简短的概述,但是(还)没有介绍得足够详细,尤其是在使用标准库的一些最新内容时。
运行代码
读者如果想要运行本文中的代码示例,可以使用下面的方法:
1 | $ git clone git://github.com/BurntSushi/blog |
每个代码示例有其名称。(没有命名的代码不能按照这种方式运行。)
说明
本文很长,主要是因为我从一开始就使用多种错误类型及其组合,并尝试使用 Rust 逐步进行错误处理。因此,在其他显式类型系统中有经验的程序员可能想快速跳转本文。这是一些简短指南:
- 如果你不熟悉 Rust,系统编程和显式类型系统,那么请从头开始并逐步进行。(如果你是全新用户,则可能应该先通读 Rust Book。)
- 如果你以前从未看过 Rust,但是有过使用函数式语言的经验(对“代数数据类型”和“组合器”感到熟悉),那么你可以跳过基础知识,而先略读多种错误类型,然后仔细阅读 标准库错误特征。(如果你以前从未真正看过Rust,略读基础知识可能是一个不错的主意。)你可能需要查询 Rust Book,以获取有关 Rust 闭包和宏的帮助。
- 如果你已经对 Rust 有所了解,并且只想学习如何对错误进行处理,那么你可以直接跳到最后。略读模式匹配可能会比较有用。
基础知识
我喜欢将错误处理变为使用模式匹配来判断一个计算任务是否成功。正如我们将要看到的,工程上错误处理的关键是保持代码可组合性的同时,减少显式模式匹配的数目。
保持代码的可组合性很重要,因为如果没有此要求,我们可以在遇到意外情况时直接进行 panic
操作。(panic
导致当前任务结束,并且在大多数情况下,整个程序都将中止。)这是一个示例:
1 | // Guess a number between 1 and 10. |
如果你想要运行此代码,查看 运行代码 一章。
如果你尝试运行此代码,程序将会中止,并报告错误信息:
1 | thread '<main>' panicked at 'Invalid number: 11', src/bin/panic-simple.rs:5 |
这是另外一个例子。程序接收一个整数作为参数,将其乘 2 并打印出来。
1 | use std::env; |
如果你没有给程序传递参数 (error 1) 或者第一个参数不是整数 (error 2),程序会像第一个例子一样中止。
我认为这种错误处理方式就像是在中国商店中奔跑的公牛。公牛会到达它想去的地方,但是会践踏过程中的一切。
Unwrapping 说明
在上面的例子里 (unwrap-double
),我声称:如果程序满足两个错误条件之一,该程序将中止。但是,程序中并未像第一个示例(panic-simple
)那样包含显式调用 panic
。这是因为 panic
嵌入在对 unwrap
的调用中。
要 “unwrap
” Rust 中的某些内容,也就意味着:“给我计算的结果,并且如果有错误,请立即调用 panic
并中止程序。” 如果我直接展示用于 unwrapping 的代码可能会帮助你理解这一点,因为它十分简单。但是要做到这一点,我们首先需要探讨 Option
和 Result
类型。这两种类型在其上都有一个称为 unwrap
的方法。
Option
类型
Option
类型定义在 标准库 中:
1 | enum Option<T> { |
Option
类型在 Rust 中主要适用于表示不存在的可能性。将不存在的可能性编码到类型系统中是一个很重要的概念,因为它可以通过编译器去强制程序员处理这样的不存在的情况。让我们看一个例子,它在字符串中尝试查找一个字符:
1 | // Searches `haystack` for the Unicode character `needle`. If one is found, the |
(Pro-tip:请勿使用此代码。请使用标准库中的 find
方法。)
注意到当函数找到一个匹配的字符时,它并不直接返回 offset
,而是返回 Some(offset)
。Some
是 Option
中的一个变体,或者说是一个值构造(value constructor)函数。你可以将其认为是一个函数:fn<T>(value: T) -> Option<T>
。相应的, None
也是一个值构造(value constructor)函数,只不过它没有参数。你可以将 None
认为是一个函数: fn<T>() -> Option<T>
。
这看起来很简单,但这只是程序的一半,另一半是使用我们编写的函数 find
。让我们尝试使用它在文件名中查找扩展名:
1 | fn main_find() { |
此代码对 find
返回的 Option<usize>
进行模式匹配,实际上,模式匹配是获取存储在Option<T>
中的值的唯一方法。这意味着作为程序员的你,必须处理当 Option<T>
是 None
的情况,而不仅仅是 Some(t)
。
但是等等,那么在 unwrap-double
中使用的 unwrap
是什么情况呢?那里没有模式匹配!这是因为它将模式匹配嵌入到 unwrap
方法中。你可以根据需要自己定义 unwrap
方法:
1 | enum Option<T> { |
在 unwrap
方法中抽象出模式匹配,正是工程中使用 unwrap
的方式。然而,unwrap
中的 panic!
仍然意味着这个 unwrap
是不可组合的:这是中国商店里的公牛。
组合 Option<T>
在 option-ex-string-find
其中,我们看到如何使用find
来查找文件名中的扩展名。但是,并非所有文件名都带有 .
,因此文件名可能没有扩展名。我们将这种不存在的可能性编码为类型 Option<T>
。换句话说,编译器将迫使我们处理扩展不存在的可能性。就我们而言,我们只是打印出一条错误消息。
获取文件扩展名是很常见的操作,因此可以将其放入函数中:
1 | // Returns the extension of the given file name, where the extension is defined |
(Pro-tip:请勿使用此代码。请使用标准库中的 extension
方法。)
上面的代码仍然很简单,但需要注意的一点是,find
强制我们考虑不存在的可能性。这种情况的好处在于:编译器不会让我们意外忘记文件名没有扩展名的情况。另一方面,像 extension_explicit
函数所实现的那样,进行显式的模式匹配可能会有点繁琐。
实际上,extension_explicit
中的模式匹配遵循一种非常常见的模式:将函数映射到 Option<T>
内部的值,如果该 Option
为 None
,只需返回 None
即可。
Rust 具有参数多态性,因此定义抽象该模式的组合器非常容易:
1 | fn map<F, T, A>(option: Option<T>, f: F) -> Option<A> where F: FnOnce(T) -> A { |
事实上,map
在标准库中被定义为 Option<T>
上的一种方法。
有了新的组合器,我们可以重写 extension_explicit
方法以去除模式匹配:
1 | // Returns the extension of the given file name, where the extension is defined |
另一种很常见的模式是,当 Option
值为 None
时,为其分配一个默认值。例如,你的程序假定:即使文件扩展名不存在,文件的扩展名也是 rs
。同样的,对此情况的模式匹配并不特定于文件扩展名。使用 Option<T>
也可以实现它:
1 | fn unwrap_or<T>(option: Option<T>, default: T) -> T { |
这里的要求是,默认值必须与 Option<T>
内的值具有相同的类型。在我们的例子里,使用它非常简单:
1 | fn main() { |
(请注意,unwrap_or
在标准库中是定义在 Option<T>
上的一种方法,所以我们在这里用的并不是我们在上面自己定义的函数。记得查找更通用的 unwrap_or_else
方法。)
我认为还有一种组合器值得特别注意:and_then
。它使组合不同的计算变得更容易,这些计算都会处理不存在的可能性。例如,本节中的许多代码都是关于查找给定文件名的扩展名。为此,你首先需要从文件路径中提取出文件名。尽管大多数文件路径都具有文件名,但并非所有都具有,例如.
,..
或 /
。
因此,我们面临的挑战是查找给定文件路径下所有文件的扩展名 。让我们从显式模式匹配开始:
1 | fn file_path_ext_explicit(file_path: &str) -> Option<&str> { |
你可能会认为我们可以只使用 map
组合器来减少模式匹配,但是它的类型不太合适。即,map
采用仅对内部值执行某些操作的函数。然后,总是用 Some
来包装该函数的结果。但是,我们需要类似于 map
,但允许调用者返回其他的 Option
。它的通用实现甚至比 map
更简单:
1 | fn and_then<F, T, A>(option: Option<T>, f: F) -> Option<A> |
现在我们可以重写 file_path_ext
函数,而无需进行显式的模式匹配:
1 | fn file_path_ext(file_path: &str) -> Option<&str> { |
Option
类型在标准库中定义了许多其他组合器。建议浏览此文档并熟悉其可用的内容,它们通常可以为你减少模式匹配。熟悉这些组合器会很有帮助,并且它们中大多也为 Result
定义了相似的语义,我们将在下面讨论。
组合使用 Option
类型是个比较符合工程学的实现,因为它们减少了显式的模式匹配。它们仍然是可组合的,因为它们允许调用者以自己的方式处理不存在的可能性。类似于 unwrap
的方法移除了这种可能性,因为他们在 Option<T>
是 None
会中止程序运行。
Result
类型
Result
类型也定义在标准库中:
1 | enum Result<T, E> { |
Result
类型是更丰富版的 Option
。也就是说,不同于像 Option
那样表示不存在的可能性,Result
表示的是出现错误的可能性。通常,错误用于解释为什么某些计算结果会失败。这是更严格的 Option
的通用形式。请考虑以下类型别名,该别名在各个方面的语义上均等同于实际的 Option<T>
:
1 | type Option<T> = Result<T, ()>; |
这将 Result
的第二个参数类型始终固定为 ()
(发音为 “unit” 或 “empty tuple”)。并且也只定义在 ()
类型中。(()
类型和值这两个级别的术语具有相同的符号!)
Result
类型是表示计算中两个可能结果之一的方式。按照惯例,一个结果是预期正确的结果即“Ok
”,而另一个结果是不预期的错误即“Err
”。
就像 Option
一样,Result
类型也具有在标准库中定义的 unwrap
方法。让我们自己定义一下:
1 | impl<T, E: ::std::fmt::Debug> Result<T, E> { |
这实际上与我们对 Option::unwrap
的定义相类似,只不过它在 panic!
消息中返回错误值。这使调试程序更加容易,但还需要我们在 E
类型参数(代表我们的错误类型)上添加 Debug
约束。由于绝大多数类型都应满足 Debug
约束条件,因此这在实践中很容易解决。(类型上的 Debug
只是意味着有一种合理的方式来以人类可读的形式打印该类型的值。)
好的,让我们继续下一个例子。
解析整数
Rust 标准库使将字符串转换为整数十分容易。实际上也是如此,编写如下内容非常简单:
1 | fn double_number(number_str: &str) -> i32 { |
在此时,你应该对调用 unwrap
表示警惕。例如,如果字符串未解析为数字,则会出现 panic
:
1 | thread '<main>' panicked at 'called `Result::unwrap()` on an `Err` value: ParseIntError { kind: InvalidDigit }', /home/rustbuild/src/rust-buildbot/slave/beta-dist-rustc-linux/build/src/libcore/result.rs:729 |
这是相当不直观的,如果这种情况发生在你正在使用的库函数中,你可能会感到很烦恼。因此,我们应该尝试处理函数中的错误,并让调用者决定如何处理。这意味着更改 double_number
的返回类型。但是要怎么做呢?让我们查看标准库中 parse
方法的定义 :
1 | impl str { |
嗯… 所以我们至少知道需要使用 Result
。当然,返回 Option
也是可取的。毕竟,字符串要么解析为数字,要么不是数字。虽然这是一种合理的方法,但是使用 Result
可以从内部区分为什么字符串没有解析为整数。(无论它是一个空字符串,一个无效数字,数字太大还是太小。)因此,使用 Result
更有意义,因为我们希望提供的信息不仅仅是简单的“不存在”,我们想说明为什么解析会失败。当遇到 Option
和 Result
之间的选择时,你应该尝试效仿这样的推理。如果你可以提供详细的错误信息,那么你就应该这样做。(我们将在稍后看到更多信息。)
好的,但是我们如何编写返回类型?上面定义的 parse
方法在标准库中所有不同的数字类型上都是通用的。我们可以(并且应该)使函数也如此通用,不过现在让我们首先仅支持显式定义类型。我们只关心 i32
,因此我们需要找到它的 FromStr
实现并查看其关联类型 Err
。这么做是为了找到具体的错误类型,在此情况下为 std::num::ParseIntError
。然后,我们可以重写函数:
1 | use std::num::ParseIntError; |
这看起来好了一些,但是现在代码行数更多了!模式匹配再次使我们感到繁琐。
因此可以使用组合器来帮助我们!就像 Option
一样,Result
有很多定义为方法的组合器。Result
和 Option
在公共的组合器上有很大的交集。特别的,map
便是该交集的一部分:
1 | use std::num::ParseIntError; |
通常使用的组合器在 Result
中都有,包括 unwrap_or
和 and_then
。此外,由于 Result
具有第二种类型的参数,因此有一些组合器仅使用错误类型,例如 map_err
(类似于 map
)和 or_else
(类似于 and_then
)。
Result
类型别名
在标准库中,你可能经常看到类似 Result<i32>
的类型。但是,在 Result
中定义了两个类型参数,我们如何只指定一个就可以使用呢?答案是定义一个 Result
类型别名,在其中固定类型参数中的一个特定类型。通常,固定类型是错误类型。例如,我们前面的解析整数的示例可以这样重写:
1 | use std::num::ParseIntError; |
为什么要这样做?因为如果我们有很多需要返回 ParseIntError
的函数,那么定义一个始终使用 ParseIntError
的别名要方便得多,这样我们就不必一直重复它。
这个习惯用法在标准库中最常见的地方是 io::Result
。通常情况下,这样编写io::Result<T>
就可以清楚地表明你使用的是 io
模块的类型别名,而不是使用的普通定义 std::result
。(此习惯用法也用于 fmt::Result
。)
小插曲:unwrapping 并非不能使用的
如果你一直在阅读本文,你可能已经注意到,我采取了相当严格的措施来禁止调用 unwrap
导致程序中止 panic
的方法。一般来说,这是一个很好的建议。
但是,unwrap
仍然是可以使用。确切地说,使用 unwrap
是一个灰色地带,部分人并不建议使用。我总结了我对此事的一些看法。
- **在代码示例和简短的程序中。**有时你正在编写示例或简短的程序,而错误处理并不重要。在这种情况下使用
unwrap
非常方便。 - **在程序中止时表示程序中有错误。**当程序应防止发生某种情况时(例如,从空堆栈中弹出),则可以允许中止。因为它暴露了程序中的错误,这既可能是明确的,例如
assert!
失败,也可能是因为你对数组的索引超出范围。
这可能并不是详尽的说明。此外,使用 Option
时,通常最好使用其 expect
方法。除了打印一条给你的消息外,它的用途与 unwrap
完全相同。但它使输出的程序中止问题描述变得友好一些,因为它将显示你的消息,而不是“调用 None
值的 unwrap
”。
我的建议可以归结为:运用你良好的判断力。我的写作中从不出现 “从不做X” 或 “Y被视为有害” 这两个词。在所有方面都有权衡取舍,由程序员在你的用例使用可接受的部分。我的目标只是帮助你尽可能准确地评估权衡。
既然我们已经介绍了 Rust 中错误处理的基础知识,并且我已经讲过关于 unwrap
的内容,那么让我们开始探索标准库的更多内容。
使用多种错误类型
到目前为止,我们已经尝试的错误处理都是基于 Option<T>
或 Result<T, SomeError>
。但是,当你同时使用 Option
和 Result
时会发生什么?又或者如果有一个 Result<T, Error1>
和一个 Result<T, Error2>
?处理不同错误类型的组合是摆在我们面前的下一个挑战,它将成为本文其余部分的主题。
组合 Option
与 Result
到目前为止,我已经讨论了为 Option
定义的组合器和为 Result
定义的组合器。我们可以使用这些组合器来组合不同计算的结果,而无需进行明确的模式匹配。
但是在实际代码中,事情并不总是那么简单。有时你需要混合使用 Option
和 Result
类型。我们是否必须诉诸明确的模式匹配,还是可以继续使用组合器?
现在,让我们重新回顾本文中的第一个示例:
1 | use std::env; |
鉴于我们刚学会的 Option
,Result
以及它们的各种组合器,我们应该尝试改写这个程序,以让错误得到妥善处理:当没有错误时,程序不应该崩溃。
这里的棘手问题是 argv.nth(1)
返回一个 Option
,而 arg.parse()
返回一个 Result
,这些不是可以直接组合的。当同时面对 Option
和 Result
时,解决方案通常是将 Option
转换为 Result
。在我们的示例中,命令行参数如果为空(来自 env::args()
)表示用户未正确调用程序。我们可以使用 String
来描述这个错误。我们试试吧:
1 | use std::env; |
在此示例中,有一些新东西。首先是 Option::ok_or
组合器的使用。这是将 Option
转换为 Result
的一种方法。转换需要你指定如果 Option
是 None
,会使用什么错误。就像我们看到的其他组合器一样,其定义非常简单:
1 | fn ok_or<T, E>(option: Option<T>, err: E) -> Result<T, E> { |
这里使用的另一个新的组合器是 Result::map_err
。类似于Result::map
,但它将函数映射到Result
的错误部分上。如果Result
是一个Ok(...)
值,则将其返回原样。
我们之所以在这里使用 map_err
,是因为错误类型必须保持相同(因为我们使用 and_then
)。由于我们选择将 Option<String>
(来自于 argv.nth(1)
)转换为 Result<String, String>
,因此我们还必须将 ParseIntError
(来自于 arg.parse()
)转换为 String
。
组合器的限制
进行 IO 和解析输入是一项非常常见的任务,这也是我个人在 Rust 中做的比较多的工作。因此,我们将使用 IO 和各种解析例程来举例说明错误处理。
让我们从一个简单的例子开始。我们的任务是打开文件,读取文件的所有内容并将其内容转换为数字。然后,将其乘以 2
并打印输出。
尽管我尝试说服你不要使用 unwrap
,但是开始编写代码时 unwrap
是很有用的。它使你可以专注于问题而不是错误处理,并且可以揭示需要进行正确错误处理的地方。让我们开始编写第一版程序,然后对其进行重构,以使用更好的错误处理。
1 | use std::fs::File; |
(N.B. 使用 AsRef<Path>
因为它们是使用在 std::fs::File::open
上的相同参数类型。这使得使用任何类型的字符串作为文件路径都符合工程学。)
这里可能会发生三种不同的错误:
- 打开文件时出现问题。
- 从文件读取数据时出现问题。
- 将数据解析为数字时出现问题。
前两个问题属于 std::io::Error
类型 ,这一点可以从 std::fs::File::open
和 std::io::Read::read_to_string
的返回类型中得知 。(请注意,它们都使用前面描述的 Result
类型别名。如果单击 Result
类型,你将看到类型别名,并因此看到基础的 io::Error
类型。)第三个问题属于 std::num::ParseIntError
类型。io::Error
这种类型在整个标准库中的使用非常广泛,你将经常看到它。
让我们开始重构 file_double
函数。为了使此功能可与程序的其他组件组合,如果满足上述任何错误条件,则不要 panic
。实际上,这意味着该函数在任何操作失败时都应返回错误。我们的问题是 file_double
的返回类型为 i32
,这无法为我们提供任何有用的报告错误的方式。因此,我们必须首先将返回类型从 i32
更改为其他类型。
我们需要决定的第一件事:我们应该使用 Option
还是 Result
?使用 Option
非常简单,如果发生三个错误中的任何一个,我们可以简单地返回 None
。这有一定效果,并且比 panic
更好,但是我们可以做得更好,我们应该传递一些有关发生的错误的详细信息。由于我们要表达错误的可能性,因此应使用 Result<i32, E>
。但是 E
应该是什么呢?由于可能发生两种不同类型的错误,因此我们需要将它们转换为常见类型。一种这样的类型是 String
。让我们看看这如何影响我们的代码:
1 | use std::fs::File; |
这段代码看起来有些复杂。像这样的代码可能需要大量的练习才能变得容易编写,我写的方式是遵循返回类型。一旦将 file_double
返回类型更改为 Result<i32, String>
,我就必须开始寻找合适的组合器。在这种情况下,我们只用了三种不同的组合程序:and_then
,map
和 map_err
。
and_then
用于链接多个计算,其中每个计算都可能返回错误。打开文件后,还有另外两个可能失败的计算:从文件读取并将内容解析为数字,相应地,有两个 and_then
调用。
map
用于将函数应用于 Result
的 Ok(...)
值。例如,最后一次调用 map
将 Ok(...)
值(i32
)乘以 2
。如果在此之前发生了错误,则由于 map
定义的方式,该操作将被跳过。
map_err
是使所有这些工作都有效的技巧。map_err
就像 map
一样,只是它对 Result
的 Err(...)
值应用了一个函数。在这种情况下,我们希望将所有错误都转换为一种类型:String
。由于 io::Error
和 num::ParseIntError
实现 ToString
,我们可以调用 to_string()
方法将其转换。
综上所述,代码仍然很繁琐。掌握组合器的用法很重要,但是它们有其局限性。让我们尝试另一种方法:提前返回。
提取返回
我想使用提前返回重写上一节的代码。提前返回可以让你尽早退出该功能。我们在 file_double
中无法从一个闭包内部提前返回,因此我们需要恢复为显式的模式匹配。
1 | use std::fs::File; |
部分人可能不认为此代码是比使用组合器的代码更好,但是,如果你不熟悉组合器方法,那么这段代码看起来将会更简单。它使用带有 match
和 if let
的显式模式匹配。如果发生错误,它只是停止执行该函数并返回错误(通过将其转换为字符串)。
这不是倒退一步吗?之前,我曾说过,工程学错误处理的关键是减少显式模式匹配,但是我们在这里已恢复为显式模式匹配。事实证明,有多种方法可以减少显式模式匹配,组合器不是唯一的方法。
try!
宏/?
操作符
在较旧的 Rust 版本(Rust 1.12 或更早版本)中,Rust 中错误处理的基石是 try!
宏。try!
宏将模式匹配抽象成组合器,但不同于组合器,它也抽象控制流。即,它可以抽象出上面看到的提前返回模式。
这是try!
宏的简化定义:
1 | macro_rules! try { |
( 真实的定义 要复杂得多。我们将在以后再看。)
使用 try!
宏可以很容易地简化我们的示例。由于它可以进行模式匹配并为我们实现提前返回,因此我们获得了更易于阅读的紧凑代码:
1 | use std::fs::File; |
map_err
调用仍然需要传递给我们的 try!
定义。这是因为错误类型仍需要转换为 String
。好消息是,我们将很快学习如何删除这些 map_err
调用!坏消息是,在删除 map_err
调用之前,我们将需要更多地了解标准库中的几个重要特征。
在较新版本的 Rust(Rust 1.13 或更高版本)中,该 try!
宏已替换为 ?
操作符。虽然它打算增加我们在这里不会介绍的新功能,但是使用 ?
代替try!
是很简单的:
1 | use std::fs::File; |
定义自己的错误类型
在深入探讨一些标准库错误 traits 之前,我想通过在前面的示例中删除对我们的错误类型 String
的使用来结束本节。
String
像在前面的示例中一样,使用起来很方便,因为很容易将错误转换为字符串,甚至可以当场将自己的错误作为字符串来实现。但是,使用 String
错误会带来一些不利影响。
第一个缺点是错误消息往往会使你的代码变得混乱。可以在其他地方定义错误消息,但是除非你受过特别的训练,否则很难将错误消息嵌入代码中。确实,我们在前面的示例中完全做到了这一点。
第二个更重要的缺点是 String
是有损的。也就是说,如果所有错误都转换为字符串,那么我们传递给调用方的错误将变得完全不透明。调用者可以对 String
错误进行的唯一合理的处理就是将其显示给用户。当然,检查字符串以确定错误的类型并不可靠。(诚然,与应用程序相比,此缺点在库内部要重要得多。)
例如,io::Error
类型嵌入 io::ErrorKind
,这是表示 IO 操作期间出了什么问题的结构化数据。这很重要,因为你可能希望根据错误做出不同的反应。(例如,BrokenPipe
错误可能意味着优雅地退出程序,而 NotFound
错误可能意味着退出并显示错误代码以向用户显示错误。)使用 io::ErrorKind
,调用者可以使用模式匹配检查错误的类型,这绝对优于试图弄清楚一个 String
错误的细节。
与其在前面的从文件中读取整数的示例中将 String
用作错误类型,不如定义自己的错误类型,该错误类型表示结构化数据中的错误。如果调用者想检查详细信息,我们将努力不从潜在错误中删除信息。
表示多种可能性之一的理想方法是定义自己的枚举类型 enum
。在我们的例子中,错误是 io::Error
或 num::ParseIntError
,因此很自然地定义为:
1 | use std::io; |
调整我们的代码非常容易。无需将错误转换为字符串,我们只需使用相应的值构造函数将它们转换为我们的类型 CliError
即可:
1 | use std::fs::File; |
唯一的更改是将 map_err(|e| e.to_string())
(将错误转换为字符串)切换到 map_err(CliError::Io)
或 map_err(CliError::Parse)
,调用者可以通过问题的错误等级决定是否向用户报告。实际上,将 String
用作错误类型会从调用方中删除这种选择,而使用自定义 enum
错误类型,例如 CliError
,除了描述错误的结构化数据外,还可以像以前一样为调用方提供很多便利。
经验法则是尽量定义自己的错误类型,但是 String
错误类型会在一定程度上发生,特别是在编写应用程序时。如果要编写库,则强烈建议定义自己的错误类型,以免不必要地从调用方中删除选择。
用于错误处理的标准库 traits
标准库为错误处理定义了两个不可或缺的 trait: std::error::Error
和 std::convert::From
。尽管 Error
专为一般性地描述错误而设计,但是 From
trait 在两个不同类型之间转换值时起更一般的作用。
Error
trait
Error
trait 在标准库中的定义如下:
1 | use std::fmt::{Debug, Display}; |
此 trait 是非常通用的,因为它打算表示错误的所有类型。我们将在后面看到这对编写可组合代码很有帮助。简单地说,该 trait 允许你执行以下操作:
- 获取
Debug
错误的表示形式。 - 获取
Display
错误的面向用户的表示形式。 - 获得错误的简短描述(通过
description
方法)。 - 检查错误的因果链(如果存在)(通过
cause
方法)。
前两个来自于 Error
要求实现 Debug
和 Display
。后两者来自 Error
上定义的两种方法。Error
的力量来自所有错误类型均隐含 Error
的事实,这意味着可以将存在的错误量化为 trait 对象,这表现为 Box<Error>
或 &Error
。实际上,cause
方法返回一个 &Error
,它本身就是一个 trait 对象。稍后,我们将重新使用 Error
trait 作为 trait 对象。
就目前而言,已经能够通过实现 Error
trait 编写示例。让我们使用上一节中定义的错误类型 :
1 | use std::io; |
这种特殊的错误类型表示可能发生两种类型的错误:处理 I/O 的错误或将字符串转换为数字的错误。通过向 enum
定义添加新的变体,该错误可以表示所需的错误类型。
为其实现 Error
非常简单,主要是要进行很多显式的模式匹配。
1 | use std::error; |
注意这是一个非常典型的 Error
实现:匹配不同的错误类型,并实现 description
和 cause
的定义。
From
trait
std::convert::From
trait 也是定义在标准库中:
1 | trait From<T> { |
是不是非常简单?From
之所以非常有用,是因为它为我们提供了一种通用的方式来实现从特定类型 T
到其他类型的转换(在这种情况下,“其他类型”是 impl
的主体,也即是 Self
)。From
有标准库提供的一组实现。
以下是一些简单的示例,说明其 From
工作方式:
1 | let string: String = From::from("foo"); |
OK,因此 From
对于在字符串之间进行转换很有用。那么错误呢?同样有一个关键的实现:
1 | impl<'a, E: Error + 'a> From<E> for Box<Error + 'a> |
这个实现表示对于任何实现了 Error
的类型,我们可以将它转换为 trait 对象 Box<Error>
,这在一般情况下很有用。
还记得我们以前处理的两个错误吗?也就是,io::Error
和 num::ParseIntError
。既然都实现了 Error
,它们同样可以使用 From
:
1 | use std::error::Error; |
注意这里有一个非常重要的模式。两个 err1
和 err2
具有相同的类型。这是因为它们是表示上完全相同的类型或 trait 对象。并且,编译器删除了它们的底层类型,因此在编译器看来,err1
和 err2
完全相同。此外,我们构造 err1
和 err2
使用了完全相同的函数调用:From::from
。这是因为 From::from
在其参数和返回类型上都重载了。
此模式很重要,因为它解决了我们先前遇到的一个问题:提供了一种使用相同函数,将错误转换为相同类型的方法。
是时候重温一个老朋友了:try!
宏/ ?
操作符。
实际的 try!
宏/ ?
操作符
之前,我介绍了 try!
的定义:
1 | macro_rules! try { |
这不是真实的定义。它在标准库中的真正定义:
1 | macro_rules! try { |
有一个微小而强大的更改:错误值 From::from
通过传递。这使 try!
宏的功能更加强大,因为它为你提供了自动类型转换。这也与 ?
操作符的工作方式非常相似,但后者的定义略有不同,即 x?
类似以下内容:
1 | match ::std::ops::Try::into_result(x) { |
Try
trait 暂时还在修改,不在本文的讨论范围之内,但是其本质是它提供了一种对许多不同类型的成功/失败模式进行抽象的方法,而无需与 Result<T, E>
紧密联系。如你所见,x?
语法仍然调用 From::from
,这是我们实现自动错误转换的方式。
由于目前编写的大多数代码都使用 ?
代替 try!
,因此我们将在本文的其余部分中继续使用 ?
。
让我们看一下我们之前编写的用于读取文件并将其内容转换为整数的代码:
1 | use std::fs::File; |
之前,我说过我们可以不调用 map_err
。确实,我们要做的就是选择一种适用 From
的类型。正如我们在上一节中所看到的,From
的实现可以让我们将任何错误类型转换为 Box<Error>
:
1 | use std::error::Error; |
我们已经非常接近理想的错误处理。我们的代码在错误处理上的开销很小,因为 ?
操作符同时封装了三件事:
- 模式匹配。
- 控制流。
- 错误类型转换。
当三者结合在一起时,我们得到的代码不受组合器,调用 unwrap
或模式匹配的束缚。
剩下的只有一点点:Box<Error>
类型是不透明的。如果我们返回 Box<Error>
给调用方,则调用方将无法检查潜在的错误类型。虽然这种情况肯定比返回 String
要好,因为调用者可以调用诸如 description
和 cause
的方法,但局限性仍然是:Box<Error>
不透明。(注意,这并非完全正确,因为 Rust 确实具有运行时反射,这在超出本文范围的某些情况下很有用。)
现在该重新审视我们的自定义 CliError
类型并将所有内容整合在一起。
组合自定义错误类型
在上一节中,我们研究了 ?
运算符以及它如何通过调用 From::from
错误值为我们完成自动类型转换。特别是,我们可以将错误转换为 Box<Error>
,但是类型对于调用者是不透明的。
要解决此问题,我们使用我们已经熟悉的相同补救措施:自定义错误类型。下面仍然是读取文件内容并将其转换为整数的代码:
1 | use std::fs::File; |
请注意,我们仍然有 map_err
的调用。为什么?回想一下 ?
运算符和 From
的定义,问题在于,没有 From
实现使我们能够从错误类型(例如 io::Error
和 num::ParseIntError
)转换为我们自己的自定义类型 CliError
。当然,解决这个问题很容易!既然定义了 CliError
,我们就可以为它实现 From
:
1 | impl From<io::Error> for CliError { |
所有这些实现正在做的事情是教导 From
如何从其他错误类型创建 CliError
。在我们的例子中,实现就像调用相应的值构造函数一样简单。确实,这通常很容易。
我们终于可以重写file_double
:
1 | fn file_double<P: AsRef<Path>>(file_path: P) -> Result<i32, CliError> { |
我们在这里所做的唯一一件事就是删除对 map_err
的调用。不再需要它们,因为 ?
运算符会在错误值上调用 From::from
。之所以有效,是因为我们为所有可能出现的错误类型提供了 From
的实现。
如果我们修改 file_double
函数以执行其他操作,例如,将字符串转换为浮点数,则需要为错误类型添加新的变体:
1 | enum CliError { |
为了反映此更改,我们需要更新之前的 impl From<num::ParseIntError> for CliError
并添加新的 impl From<num::ParseFloatError> for CliError
:
1 | impl From<num::ParseIntError> for CliError { |
就是这样!
对库作者的建议
Rust 库的范式仍在形成,但是如果你的库需要报告自定义错误,那么你可能需要定义自己的错误类型。是否公开其表示形式(如 ErrorKind
)或使其隐藏(如 ParseIntError
)取决于你。无论如何执行,通常最好的做法是至少提供有关错误的信息,而不仅仅是其 String
表示形式。但是可以肯定的是,这将取决于用例。
至少,你应该实现 Error
trait。这将使你的库用户在组合错误类型时有一定的灵活性。实施此 Error
特征还意味着要确保用户具有获取错误的字符串表示形式的能力(因为它要求实现 fmt::Debug
和 fmt::Display
)。
除此之外,提供 From
错误类型的实现也可能很有用。这使你(库作者)和你的用户可以编写更详细的错误。例如, csv::Error
同时为 io::Error
和 byteorder::Error
提供了 From
实现 。
最后,根据你的喜好,你可能还想定义一个 Result
类型别名,尤其是在你的库定义了单个错误类型的情况下。这是在标准库使用的 io::Result
和 fmt::Result
。
案例学习: 读取人口数据
这个案例很长,根据你的知识背景,可能会比较复杂。虽然有很多示例代码与说明一起使用,但大多数代码都是专门为教学目的而设计的。虽然我不够聪明,无法制作既不是玩具示例又能够实现教学的示例,但我可以撰写实际案例。
为此,我想构建一个命令行程序,让你查询世界人口数据。目标很简单:你给它一个位置,它将告诉你人口数据。尽管简单,但仍有很多地方可能出错!
我们将使用的数据来自 Data Science Toolkit。我已经为此练习准备了一些数据。你可以获取 世界人口数据 (41MB gzip 压缩,145MB 未压缩),也可以仅获取 美国人口数据 (2.2MB gzip 压缩,7.2MB 未压缩)。
到目前为止,我一直将代码限制为 Rust 的标准库。但是对于像这样的真实任务,我们至少要使用某种东西来解析CSV数据,解析程序参数并将这些东西自动解码为 Rust 类型。为此,我们将使用 csv
, docopt
和rustc-serialize
crate。
在 Github 上获取
该案例研究的最终代码在Github上。如果你安装了Rust和Cargo,那么你要做的就是:
1 | git clone git://github.com/BurntSushi/rust-error-handling-case-study |
我们将逐步构建该项目。继续!
初始化
我不会花很多时间在 Cargo 上建立项目,因为 Rust Book 和 Cargo的文档 已经很好地介绍了该项目 。
要从头开始,请运行 cargo new --bin city-pop
并确保你的 Cargo.toml
如下所示:
1 | [package] |
你应该能够直接运行:
1 | cargo build --release |
参数解析
让我们首先进行参数解析。我不会在 Docopt 上介绍太多细节,但是有一个 不错的网页 描述了它以及 Rust crate 的文档。简单地说,Docopt 从 Usage 字符串生成一个参数解析器。解析完成后,我们可以将程序参数解码为Rust 结构体。我们的程序如下,其中带有适当的 extern crate
语句,Usage 字符串,我们的 Args
struct 和一个空的 main
:
1 | extern crate docopt; |
好的,是时候开始编写了。Docopt 的文档说,我们可以创建一个解析器 Docopt::new
,然后使用 Docopt::decode
将其解码为一个结构体。这两个函数都会返回 docopt::Error
。我们可以从显式模式匹配开始:
1 | // These use statements were added below the `extern` statements. |
这并不是很好。为了使代码更清晰,我们可以做的一件事是编写一个宏以将消息打印到 stderr
然后退出:
1 | macro_rules! fatal { |
unwrap
在这里是没问题的,因为如果失败的话,就意味着你的程序无法写入 stderr
。一个好的经验法则是可以中止,但是可以肯定的是,如果需要,你可以做其他事情。
这个代码看起来更好,但是显式的模式匹配仍然很麻烦:
1 | let args: Args = match Docopt::new(USAGE) { |
值得庆幸的是,docopt::Error
类型定义了一种便捷的方法 exit
,该方法可以有效地完成我们刚刚做的事情。将其与我们的组合器知识相结合,我们获得了简洁明了的代码:
1 | let args: Args = Docopt::new(USAGE) |
如果此代码成功完成,则将根据用户提供的值填充 args
。
编写程序逻辑
编写代码的方式各不相同,但是当我不确定如何编码问题时,错误处理通常是我要考虑的最后一件事。对于好的设计来说,这不是一个很好的做法,但是对于快速原型制作可能是有用的。在我们的案例中,由于 Rust 迫使我们对错误进行处理,这也将使程序的哪些部分可能导致错误变得显而易见。为什么?因为 Rust 将使我们调用 unwrap
,这可以使我们很好地了解如何进行错误处理。
在本案例中,程序逻辑非常简单。我们需要做的就是解析提供给我们的 CSV 数据,并在匹配的行中打印出一个字段。我们开始吧。(确保添加 extern crate csv;
到文件的顶部。)
1 | // This struct represents the data in each row of the CSV file. |
让我们分析可能出现的错误。我们可以从显而易见的地方开始:这三个 unwrap
地方为:
fs::File::open
可能返回io::Error
。csv::Reader::decode
一次解码一个记录,但是解码一条记录 (查看Iterator
上的关联类型Item
)可能产生一个csv::Error
。- 如果
row.population
为None
,则调用expect
会导致panic
。
还有其他吗?如果我们找不到匹配的城市怎么办?类似的工具 grep
将返回错误代码,因此我们也应该这样做。因此,我们得到了特定于我们问题的逻辑错误,IO 错误和 CSV 分析错误。我们将探索两种不同的方法来处理这些错误。
我想从使用 Box<Error>
开始。稍后,我们将看到定义自己的错误类型也是很有用的。
使用 Box<Error>
进行错误处理
Box<Error>
的特性很合适,因为你不需要定义自己的错误类型,也不需要任何 From
实现。缺点是,由于 Box<Error>
是 trait 对象,因此会删除隐含的类型,这意味着编译器无法再对其基础类型进行推理。
让我们开始改变函数的返回类型 T
到 Result<T, OurErrorType>
重构我们的代码。在这种情况下,OurErrorType
is Box<Error>
。那么 T
是什么?我们可以将返回类型添加到main
吗?
第二个问题的答案是否定的,我们不能。这意味着我们需要编写一个新函数。但是 T
是什么呢?最简单的方法是将匹配 Row
值的列表作为 Vec<Row>
返回。(更好的代码将返回一个迭代器,但这留给读者练习。)
让我们将代码重构为自己的函数,但保留对 unwrap
的调用。请注意,我们选择通过简单地忽略该行来处理人口总数缺失的可能性。
1 | struct Row { |
尽管我们摆脱了对 expect
的调用(是 unwrap
的更好的变体),但我们仍然应该处理没有任何搜索结果的情况。
要将其转换为正确的错误处理,我们需要执行以下操作:
- 将
search
的返回类型更改为Result<Vec<PopulationCount>, Box<Error>>
。 - 使用
?
运算符,以便将错误返回给调用者,而不用panic
该程序。 - 处理
main
中的错误。
让我们尝试一下:
1 | fn search<P: AsRef<Path>> |
不同于 x.unwrap()
,我们现在使用 x?
。由于我们的函数返回 Result<T, E>
,因此如果发生错误,?
操作符会从函数中提前返回。
这段代码有一个大陷阱:我们应该使用 Box<Error + Send + Sync>
代替 Box<Error>
。我们这样做是为了将纯字符串转换为错误类型。我们需要这些额外的限制,以便我们可以使用相应的 From
impls:
1 | // We are making use of this impl in the code above, since we call `From::from` |
现在,我们已经了解了如何使用 Box<Error>
进行正确的错误处理,让我们尝试使用自定义错误类型的另一种方法。但是首先,让我们从错误处理中休息一下,并增加对从 stdin
中读取数据的支持。
从 stdin 读取
在我们的程序中,我们接受单个文件作为输入,并对数据进行一次传递。这意味着我们可能应该能够在 stdin 上接受输入。但是我们也喜欢当前的格式,所以让我们两者兼而有之!
添加对 stdin 的支持实际上非常容易。我们只需要做两件事:
- 调整程序参数,以便在从 stdin 读取人口数据时可以接受一个参数:城市。
- 修改
search
功能以采用可选的文件路径。当为None
时,它知道应该从 stdin 读取。
首先,这是新的 Usage 和 Args
结构:
1 | static USAGE: &'static str = " |
我们所做的就是在 Docopt 用法字符串中将 data-path
参数设置为可选,并将相应的 struct 成员 arg_data_path
设置为可选。docopt
crate将处理其余部分。
修改 search
有些棘手。csv
crate 可以解析任何实现了io::Read
的类型。但是,如何在两种类型上使用相同的代码?实际上,我们可以采取几种方法。一种方法是编写 search
代码,使其对某些实现了 io::Read
的类型参数 R
是通用的。另一种方法是只使用 trait 对象:
1 | fn search<P: AsRef<Path>> |
使用自定义类型进行错误处理
之前,我们学习了如何使用自定义错误类型来编写错误类型。为此,我们将错误类型定义为 enum
,然后实现 Error
和 From
。
由于存在三个不同的错误(IO,CSV 解析和未找到),因此我们定义一个具有三个变体的 enum
:
1 | enum CliError { |
并且实现 Display
和 Error
:
1 | impl fmt::Display for CliError { |
在我们可以在 search
函数中使用 CliError
类型之前,我们需要提供一些 From
实现。我们如何知道要提供哪些实现?好吧,我们需要同时从io::Error
和 csv::Error
中转换为 CliError
。这些是唯一的外部错误,所以我们 From
现在只需要两个实现:
1 | impl From<io::Error> for CliError { |
由于定义了 ?
运算符,因此 From
非常重要 。特别是,如果发生错误,则对错误进行调用 From::from
,在这种情况下,会将其转换为我们自己的错误类型 CliError
。
随着 From
实现的完成,我们只需要对我们的 search
函数进行两个小调整:返回类型和“未找到”错误。这是完整的程序:
1 | fn search<P: AsRef<Path>> |
无需其他更改。
额外功能
如果你像我一样,那么编写通用代码会感觉不错,因为通用化的东西很酷!但是有时候,这样做是不值得的。看一下我们在上一步中所做的事情:
- 定义了新的错误类型。
- 新增实现了
Error
,Display
和两个From
。
这里最大的缺点是我们的程序并没有改善很多。我个人喜欢它,因为我喜欢使用 enum
表示错误,但是这样做有很多开销,尤其是在像这样的短程序中。
像我们在这里一样使用自定义错误类型的一个有用方面是,main
函数现在可以选择以不同方式处理错误。以前,使用 Box<Error>
时,它没有太多选择:仅打印消息。我们在这里仍然这样做,但是如果我们想添加一个 --quiet
标志怎么办?该 --quiet
标志应使任何详细的输出静音。
现在,如果程序找不到匹配项,它将输出一条消息,说明是这样。这可能有点笨拙,特别是如果你打算将该程序用于 shell 脚本中时。
因此,让我们从添加标志开始。像以前一样,我们需要调整用法字符串并在 Args
结构中添加一个标志。docopt
crate 完成剩下的事情:
1 | static USAGE: &'static str = " |
现在,我们只需要实现 “quiet” 功能即可。这需要我们在 main
中进行的调整:
1 | match search(&args.arg_data_path, &args.arg_city) { |
当然,如果发生 IO 错误或数据解析失败,我们不想静默输出。因此,我们采用模式匹配,以检查错误类型是否为 NotFound
和是否已启用 --quiet
。如果搜索失败,我们仍然会退出代码(遵循 grep
的约定)。
如果我们坚持使用 Box<Error>
,那么实现 --quiet
功能将非常棘手。
这几乎总结了我们的案例研究。从这里开始,你应该能够编写带有适当错误处理的自己的程序和库。
概括
由于本文很长,因此快速总结一下Rust中的错误处理很有用。这些是我的“经验法则”。他们并不是教条。每一个规则都可能会有充足的理由去反驳!
- 如果你正在编写示例代码,并不想实现过于繁琐的错误处理,
unwrap
应该是很好用的(不管是Result::unwrap
,Option::unwrap
或更好的Option::expect
)。你的代码的使用者应该知道如何使用正确的错误处理。(如果没有,让他们来看这篇文章!) - 如果你正在编写 quick ‘n’ dirty 程序,请不要羞于使用
unwrap
。警告:如果交接到别人的手中,当他们被错误的消息所困扰时,不要感到惊讶! - 如果你正在编写一个 quick ‘n’ dirty 程序,并且无论如何都不想造成
panic
,那么你应该使用如上例所示的Box<Error>
(或Box<Error + Send + Sync>
)。另一个比较好的替代方法是使用anyhow
crate 及其anyhow::Error
类型。使用anyhow
时,在 nightly Rust 中,你的错误将自动附加 backtraces 。 - 否则,在程序中使用适当的
From
和Error
实现定义自己的错误类型,以使?
操作符更加符合工程学。 - 如果你正在编写库,并且代码可能会产生错误,请定义自己的错误类型并实现
std::error::Error
trait。在适当的地方,实现From
使你的库代码和调用者的代码更易于编写。(由于 Rust 的一致性规则,调用者将无法为你的错误类型实现From
,因此你的库应该这样做。) - 使用
Option
和Result
上定义的组合器 。有时单独使用它们可能会有些麻烦,但是我发现,?
操作符和组合器的组合非常有吸引力。and_then
,map
和unwrap_or
是我的最爱。
Rust 中的错误处理