Rust 中的错误处理


原文地址:https://blog.burntsushi.net/rust-error-handling/

原文标题:Error Handling in Rust

原文作者:Andrew Gallant’s Blog

翻译日期: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。它提供了非常简短的概述,但是(还)没有介绍得足够详细,尤其是在使用标准库的一些最新内容时。

译者注:最新版的 Rust Book 已经更新了许多内容,建议阅读新版的错误处理章节

简体中文版地址, 错误处理章节

运行代码

读者如果想要运行本文中的代码示例,可以使用下面的方法:

1
2
3
$ git clone git://github.com/BurntSushi/blog
$ cd blog/code/rust-error-handling
$ cargo run --bin NAME-OF-CODE-SAMPLE [ args ... ]

每个代码示例有其名称。(没有命名的代码不能按照这种方式运行。)

说明

本文很长,主要是因为我从一开始就使用多种错误类型及其组合,并尝试使用 Rust 逐步进行错误处理。因此,在其他显式类型系统中有经验的程序员可能想快速跳转本文。这是一些简短指南:

  • 如果你不熟悉 Rust,系统编程和显式类型系统,那么请从头开始并逐步进行。(如果你是全新用户,则可能应该先通读 Rust Book。)
  • 如果你以前从未看过 Rust,但是有过使用函数式语言的经验(对“代数数据类型”和“组合器”感到熟悉),那么你可以跳过基础知识,而先略读多种错误类型,然后仔细阅读 标准库错误特征。(如果你以前从未真正看过Rust,略读基础知识可能是一个不错的主意。)你可能需要查询 Rust Book,以获取有关 Rust 闭包和宏的帮助。
  • 如果你已经对 Rust 有所了解,并且只想学习如何对错误进行处理,那么你可以直接跳到最后。略读模式匹配可能会比较有用。

基础知识

我喜欢将错误处理变为使用模式匹配来判断一个计算任务是否成功。正如我们将要看到的,工程上错误处理的关键是保持代码可组合性的同时,减少显式模式匹配的数目

保持代码的可组合性很重要,因为如果没有此要求,我们可以在遇到意外情况时直接进行 panic 操作。(panic 导致当前任务结束,并且在大多数情况下,整个程序都将中止。)这是一个示例:

panic-simple
1
2
3
4
5
6
7
8
9
10
11
12
// Guess a number between 1 and 10.
// If it matches the number I had in mind, return true. Else, return false.
fn guess(n: i32) -> bool {
if n < 1 || n > 10 {
panic!("Invalid number: {}", n);
}
n == 5
}

fn main() {
guess(11);
}

如果你想要运行此代码,查看 运行代码 一章。

如果你尝试运行此代码,程序将会中止,并报告错误信息:

1
thread '<main>' panicked at 'Invalid number: 11', src/bin/panic-simple.rs:5

这是另外一个例子。程序接收一个整数作为参数,将其乘 2 并打印出来。

unwrap-double
1
2
3
4
5
6
7
8
9
10
11
use std::env;

fn main() {
let mut argv = env::args();
let arg: String = argv.nth(1).unwrap(); // error 1
let n: i32 = arg.parse().unwrap(); // error 2
println!("{}", 2 * n);
}

// $ cargo run --bin unwrap-double 5
// 10

如果你没有给程序传递参数 (error 1) 或者第一个参数不是整数 (error 2),程序会像第一个例子一样中止。

我认为这种错误处理方式就像是在中国商店中奔跑的公牛。公牛会到达它想去的地方,但是会践踏过程中的一切。

Unwrapping 说明

在上面的例子里 (unwrap-double),我声称:如果程序满足两个错误条件之一,该程序将中止。但是,程序中并未像第一个示例(panic-simple)那样包含显式调用 panic 。这是因为 panic 嵌入在对 unwrap 的调用中。

要 “unwrap” Rust 中的某些内容,也就意味着:“给我计算的结果,并且如果有错误,请立即调用 panic 并中止程序。” 如果我直接展示用于 unwrapping 的代码可能会帮助你理解这一点,因为它十分简单。但是要做到这一点,我们首先需要探讨 OptionResult 类型。这两种类型在其上都有一个称为 unwrap 的方法。

Option 类型

Option 类型定义在 标准库 中:

option-def
1
2
3
4
enum Option<T> {
None,
Some(T),
}

Option 类型在 Rust 中主要适用于表示不存在的可能性。将不存在的可能性编码到类型系统中是一个很重要的概念,因为它可以通过编译器去强制程序员处理这样的不存在的情况。让我们看一个例子,它在字符串中尝试查找一个字符:

option-ex-string-find
1
2
3
4
5
6
7
8
9
10
// Searches `haystack` for the Unicode character `needle`. If one is found, the
// byte offset of the character is returned. Otherwise, `None` is returned.
fn find(haystack: &str, needle: char) -> Option<usize> {
for (offset, c) in haystack.char_indices() {
if c == needle {
return Some(offset);
}
}
None
}

(Pro-tip:请勿使用此代码。请使用标准库中的 find 方法。)

注意到当函数找到一个匹配的字符时,它并不直接返回 offset,而是返回 Some(offset)SomeOption 中的一个变体,或者说是一个值构造(value constructor)函数。你可以将其认为是一个函数:fn<T>(value: T) -> Option<T>。相应的, None 也是一个值构造(value constructor)函数,只不过它没有参数。你可以将 None 认为是一个函数: fn<T>() -> Option<T>

这看起来很简单,但这只是程序的一半,另一半是使用我们编写的函数 find。让我们尝试使用它在文件名中查找扩展名:

option-ex-string-find
1
2
3
4
5
6
7
fn main_find() {
let file_name = "foobar.rs";
match find(file_name, '.') {
None => println!("No file extension found."),
Some(i) => println!("File extension: {}", &file_name[i+1..]),
}
}

此代码对 find 返回的 Option<usize> 进行模式匹配,实际上,模式匹配是获取存储在Option<T>中的值的唯一方法。这意味着作为程序员的你,必须处理当 Option<T>None 的情况,而不仅仅是 Some(t)

但是等等,那么在 unwrap-double 中使用的 unwrap 是什么情况呢?那里没有模式匹配!这是因为它将模式匹配嵌入到 unwrap 方法中。你可以根据需要自己定义 unwrap 方法:

option-def-unwrap
1
2
3
4
5
6
7
8
9
10
11
12
13
14
enum Option<T> {
None,
Some(T),
}

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

unwrap 方法中抽象出模式匹配,正是工程中使用 unwrap 的方式。然而,unwrap 中的 panic! 仍然意味着这个 unwrap 是不可组合的:这是中国商店里的公牛

组合 Option<T>

option-ex-string-find 其中,我们看到如何使用find来查找文件名中的扩展名。但是,并非所有文件名都带有 .,因此文件名可能没有扩展名。我们将这种不存在的可能性编码为类型 Option<T>。换句话说,编译器将迫使我们处理扩展不存在的可能性。就我们而言,我们只是打印出一条错误消息。

获取文件扩展名是很常见的操作,因此可以将其放入函数中:

option-ex-string-find
1
2
3
4
5
6
7
8
9
// Returns the extension of the given file name, where the extension is defined
// as all characters succeeding the first `.`.
// If `file_name` has no `.`, then `None` is returned.
fn extension_explicit(file_name: &str) -> Option<&str> {
match find(file_name, '.') {
None => None,
Some(i) => Some(&file_name[i+1..]),
}
}

(Pro-tip:请勿使用此代码。请使用标准库中的 extension 方法。)

上面的代码仍然很简单,但需要注意的一点是,find 强制我们考虑不存在的可能性。这种情况的好处在于:编译器不会让我们意外忘记文件名没有扩展名的情况。另一方面,像 extension_explicit 函数所实现的那样,进行显式的模式匹配可能会有点繁琐。

实际上,extension_explicit 中的模式匹配遵循一种非常常见的模式:将函数映射到 Option<T> 内部的值,如果该 OptionNone,只需返回 None 即可。

Rust 具有参数多态性,因此定义抽象该模式的组合器非常容易:

option-map
1
2
3
4
5
6
fn map<F, T, A>(option: Option<T>, f: F) -> Option<A> where F: FnOnce(T) -> A {
match option {
None => None,
Some(value) => Some(f(value)),
}
}

事实上,map 在标准库中被定义为 Option<T> 上的一种方法

有了新的组合器,我们可以重写 extension_explicit 方法以去除模式匹配:

option-ex-string-find
1
2
3
4
5
6
// Returns the extension of the given file name, where the extension is defined
// as all characters succeeding the first `.`.
// If `file_name` has no `.`, then `None` is returned.
fn extension(file_name: &str) -> Option<&str> {
find(file_name, '.').map(|i| &file_name[i+1..])
}

另一种很常见的模式是,当 Option 值为 None 时,为其分配一个默认值。例如,你的程序假定:即使文件扩展名不存在,文件的扩展名也是 rs。同样的,对此情况的模式匹配并不特定于文件扩展名。使用 Option<T> 也可以实现它:

option-unwrap-or
1
2
3
4
5
6
fn unwrap_or<T>(option: Option<T>, default: T) -> T {
match option {
None => default,
Some(value) => value,
}
}

这里的要求是,默认值必须与 Option<T> 内的值具有相同的类型。在我们的例子里,使用它非常简单:

option-ex-string-find
1
2
3
4
fn main() {
assert_eq!(extension("foobar.csv").unwrap_or("rs"), "csv");
assert_eq!(extension("foobar").unwrap_or("rs"), "rs");
}

(请注意,unwrap_or 在标准库中是定义在 Option<T> 上的一种方法,所以我们在这里用的并不是我们在上面自己定义的函数。记得查找更通用的 unwrap_or_else 方法。)

我认为还有一种组合器值得特别注意:and_then。它使组合不同的计算变得更容易,这些计算都会处理不存在的可能性。例如,本节中的许多代码都是关于查找给定文件名的扩展名。为此,你首先需要从文件路径中提取出文件名。尽管大多数文件路径都具有文件名,但并非所有都具有,例如.../

因此,我们面临的挑战是查找给定文件路径下所有文件的扩展名 。让我们从显式模式匹配开始:

option-ex-string-find
1
2
3
4
5
6
7
8
9
10
11
12
13
14
fn file_path_ext_explicit(file_path: &str) -> Option<&str> {
match file_name(file_path) {
None => None,
Some(name) => match extension(name) {
None => None,
Some(ext) => Some(ext),
}
}
}

fn file_name(file_path: &str) -> Option<&str> {
// implementation elided
unimplemented!()
}

你可能会认为我们可以只使用 map 组合器来减少模式匹配,但是它的类型不太合适。即,map 采用仅对内部值执行某些操作的函数。然后,总是用 Some 来包装该函数的结果。但是,我们需要类似于 map,但允许调用者返回其他的 Option。它的通用实现甚至比 map 更简单:

option-and-then
1
2
3
4
5
6
7
fn and_then<F, T, A>(option: Option<T>, f: F) -> Option<A>
where F: FnOnce(T) -> Option<A> {
match option {
None => None,
Some(value) => f(value),
}
}

现在我们可以重写 file_path_ext 函数,而无需进行显式的模式匹配:

option-ex-string-find
1
2
3
fn file_path_ext(file_path: &str) -> Option<&str> {
file_name(file_path).and_then(extension)
}

Option 类型在标准库中定义了许多其他组合器。建议浏览此文档并熟悉其可用的内容,它们通常可以为你减少模式匹配。熟悉这些组合器会很有帮助,并且它们中大多也为 Result 定义了相似的语义,我们将在下面讨论。

组合使用 Option 类型是个比较符合工程学的实现,因为它们减少了显式的模式匹配。它们仍然是可组合的,因为它们允许调用者以自己的方式处理不存在的可能性。类似于 unwrap 的方法移除了这种可能性,因为他们在 Option<T>None 会中止程序运行。

Result 类型

Result 类型也定义在标准库中:

result-def
1
2
3
4
enum Result<T, E> {
Ok(T),
Err(E),
}

Result 类型是更丰富版的 Option。也就是说,不同于像 Option 那样表示不存在的可能性Result表示的是出现错误的可能性。通常,错误用于解释为什么某些计算结果会失败。这是更严格的 Option 的通用形式。请考虑以下类型别名,该别名在各个方面的语义上均等同于实际的 Option<T>

option-as-result
1
type Option<T> = Result<T, ()>;

这将 Result 的第二个参数类型始终固定为 ()(发音为 “unit” 或 “empty tuple”)。并且也只定义在 () 类型中。(() 类型和值这两个级别的术语具有相同的符号!)

Result 类型是表示计算中两个可能结果之一的方式。按照惯例,一个结果是预期正确的结果即“Ok”,而另一个结果是不预期的错误即“Err”。

就像 Option 一样,Result 类型也具有在标准库中定义的 unwrap 方法。让我们自己定义一下:

result-def
1
2
3
4
5
6
7
8
9
impl<T, E: ::std::fmt::Debug> Result<T, E> {
fn unwrap(self) -> T {
match self {
Result::Ok(val) => val,
Result::Err(err) =>
panic!("called `Result::unwrap()` on an `Err` value: {:?}", err),
}
}
}

这实际上与我们对 Option::unwrap 的定义相类似,只不过它在 panic! 消息中返回错误值。这使调试程序更加容易,但还需要我们在 E 类型参数(代表我们的错误类型)上添加 Debug 约束。由于绝大多数类型都应满足 Debug 约束条件,因此这在实践中很容易解决。(类型上的 Debug 只是意味着有一种合理的方式来以人类可读的形式打印该类型的值。)

好的,让我们继续下一个例子。

解析整数

Rust 标准库使将字符串转换为整数十分容易。实际上也是如此,编写如下内容非常简单:

result-num-unwrap
1
2
3
4
5
6
7
8
fn double_number(number_str: &str) -> i32 {
2 * number_str.parse::<i32>().unwrap()
}

fn main() {
let n: i32 = double_number("10");
assert_eq!(n, 20);
}

在此时,你应该对调用 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
2
3
impl str {
fn parse<F: FromStr>(&self) -> Result<F, F::Err>;
}

嗯… 所以我们至少知道需要使用 Result。当然,返回 Option 也是可取的。毕竟,字符串要么解析为数字,要么不是数字。虽然这是一种合理的方法,但是使用 Result 可以从内部区分为什么字符串没有解析为整数。(无论它是一个空字符串,一个无效数字,数字太大还是太小。)因此,使用 Result 更有意义,因为我们希望提供的信息不仅仅是简单的“不存在”,我们想说明为什么解析会失败。当遇到 OptionResult 之间的选择时,你应该尝试效仿这样的推理。如果你可以提供详细的错误信息,那么你就应该这样做。(我们将在稍后看到更多信息。)

好的,但是我们如何编写返回类型?上面定义的 parse 方法在标准库中所有不同的数字类型上都是通用的。我们可以(并且应该)使函数也如此通用,不过现在让我们首先仅支持显式定义类型。我们只关心 i32,因此我们需要找到它的 FromStr 实现并查看其关联类型 Err。这么做是为了找到具体的错误类型,在此情况下为 std::num::ParseIntError。然后,我们可以重写函数:

result-num-no-unwrap
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
use std::num::ParseIntError;

fn double_number(number_str: &str) -> Result<i32, ParseIntError> {
match number_str.parse::<i32>() {
Ok(n) => Ok(2 * n),
Err(err) => Err(err),
}
}

fn main() {
match double_number("10") {
Ok(n) => assert_eq!(n, 20),
Err(err) => println!("Error: {:?}", err),
}
}

这看起来好了一些,但是现在代码行数更多了!模式匹配再次使我们感到繁琐。

因此可以使用组合器来帮助我们!就像 Option 一样,Result 有很多定义为方法的组合器。ResultOption 在公共的组合器上有很大的交集。特别的,map 便是该交集的一部分:

result-num-no-unwrap-map
1
2
3
4
5
6
7
8
9
10
11
12
use std::num::ParseIntError;

fn double_number(number_str: &str) -> Result<i32, ParseIntError> {
number_str.parse::<i32>().map(|n| 2 * n)
}

fn main() {
match double_number("10") {
Ok(n) => assert_eq!(n, 20),
Err(err) => println!("Error: {:?}", err),
}
}

通常使用的组合器在 Result 中都有,包括 unwrap_orand_then。此外,由于 Result 具有第二种类型的参数,因此有一些组合器仅使用错误类型,例如 map_err(类似于 map)和 or_else (类似于 and_then)。

Result 类型别名

在标准库中,你可能经常看到类似 Result<i32> 的类型。但是,在 Result 中定义了两个类型参数,我们如何只指定一个就可以使用呢?答案是定义一个 Result 类型别名,在其中固定类型参数中的一个特定类型。通常,固定类型是错误类型。例如,我们前面的解析整数的示例可以这样重写:

result-num-no-unwrap-map-alias
1
2
3
4
5
6
7
8
use std::num::ParseIntError;
use std::result;

type Result<T> = result::Result<T, ParseIntError>;

fn double_number(number_str: &str) -> Result<i32> {
unimplemented!();
}

为什么要这样做?因为如果我们有很多需要返回 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>。但是,当你同时使用 OptionResult 时会发生什么?又或者如果有一个 Result<T, Error1> 和一个 Result<T, Error2>?处理不同错误类型的组合是摆在我们面前的下一个挑战,它将成为本文其余部分的主题。

组合 OptionResult

到目前为止,我已经讨论了为 Option 定义的组合器和为 Result 定义的组合器。我们可以使用这些组合器来组合不同计算的结果,而无需进行明确的模式匹配。

但是在实际代码中,事情并不总是那么简单。有时你需要混合使用 OptionResult 类型。我们是否必须诉诸明确的模式匹配,还是可以继续使用组合器?

现在,让我们重新回顾本文中的第一个示例:

1
2
3
4
5
6
7
8
9
10
11
use std::env;

fn main() {
let mut argv = env::args();
let arg: String = argv.nth(1).unwrap(); // error 1
let n: i32 = arg.parse().unwrap(); // error 2
println!("{}", 2 * n);
}

// $ cargo run --bin unwrap-double 5
// 10

鉴于我们刚学会的 OptionResult 以及它们的各种组合器,我们应该尝试改写这个程序,以让错误得到妥善处理:当没有错误时,程序不应该崩溃。

这里的棘手问题是 argv.nth(1) 返回一个 Option,而 arg.parse() 返回一个 Result,这些不是可以直接组合的。当同时面对 OptionResult 时,解决方案通常是将 Option 转换为 Result。在我们的示例中,命令行参数如果为空(来自 env::args())表示用户未正确调用程序。我们可以使用 String 来描述这个错误。我们试试吧:

error-double-string
1
2
3
4
5
6
7
8
9
10
11
12
13
14
use std::env;

fn double_arg(mut argv: env::Args) -> Result<i32, String> {
argv.nth(1)
.ok_or("Please give at least one argument".to_owned())
.and_then(|arg| arg.parse::<i32>().map_err(|err| err.to_string()))
}

fn main() {
match double_arg(env::args()) {
Ok(n) => println!("{}", n),
Err(err) => println!("Error: {}", err),
}
}

在此示例中,有一些新东西。首先是 Option::ok_or 组合器的使用。这是将 Option 转换为 Result 的一种方法。转换需要你指定如果 OptionNone,会使用什么错误。就像我们看到的其他组合器一样,其定义非常简单:

option-ok-or-def
1
2
3
4
5
6
fn ok_or<T, E>(option: Option<T>, err: E) -> Result<T, E> {
match option {
Some(val) => Ok(val),
None => Err(err),
}
}

这里使用的另一个新的组合器是 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 是很有用的。它使你可以专注于问题而不是错误处理,并且可以揭示需要进行正确错误处理的地方。让我们开始编写第一版程序,然后对其进行重构,以使用更好的错误处理。

io-basic-unwrap
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
use std::fs::File;
use std::io::Read;
use std::path::Path;

fn file_double<P: AsRef<Path>>(file_path: P) -> i32 {
let mut file = File::open(file_path).unwrap(); // error 1
let mut contents = String::new();
file.read_to_string(&mut contents).unwrap(); // error 2
let n: i32 = contents.trim().parse().unwrap(); // error 3
2 * n
}

fn main() {
let doubled = file_double("foobar");
println!("{}", doubled);
}

(N.B. 使用 AsRef<Path> 因为它们是使用在 std::fs::File::open 上的相同参数类型。这使得使用任何类型的字符串作为文件路径都符合工程学。)

这里可能会发生三种不同的错误:

  1. 打开文件时出现问题。
  2. 从文件读取数据时出现问题。
  3. 将数据解析为数字时出现问题。

前两个问题属于 std::io::Error 类型 ,这一点可以从 std::fs::File::openstd::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。让我们看看这如何影响我们的代码:

io-basic-error-string
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
use std::fs::File;
use std::io::Read;
use std::path::Path;

fn file_double<P: AsRef<Path>>(file_path: P) -> Result<i32, String> {
File::open(file_path)
.map_err(|err| err.to_string())
.and_then(|mut file| {
let mut contents = String::new();
file.read_to_string(&mut contents)
.map_err(|err| err.to_string())
.map(|_| contents)
})
.and_then(|contents| {
contents.trim().parse::<i32>()
.map_err(|err| err.to_string())
})
.map(|n| 2 * n)
}

fn main() {
match file_double("foobar") {
Ok(n) => println!("{}", n),
Err(err) => println!("Error: {}", err),
}
}

这段代码看起来有些复杂。像这样的代码可能需要大量的练习才能变得容易编写,我写的方式是遵循返回类型。一旦将 file_double 返回类型更改为 Result<i32, String>,我就必须开始寻找合适的组合器。在这种情况下,我们只用了三种不同的组合程序:and_thenmapmap_err

and_then 用于链接多个计算,其中每个计算都可能返回错误。打开文件后,还有另外两个可能失败的计算:从文件读取并将内容解析为数字,相应地,有两个 and_then 调用。

map 用于将函数应用于 ResultOk(...) 值。例如,最后一次调用 mapOk(...) 值(i32)乘以 2。如果在此之前发生了错误,则由于 map 定义的方式,该操作将被跳过。

map_err 是使所有这些工作都有效的技巧。map_err 就像 map 一样,只是它对 ResultErr(...) 值应用了一个函数。在这种情况下,我们希望将所有错误都转换为一种类型:String。由于 io::Errornum::ParseIntError 实现 ToString,我们可以调用 to_string() 方法将其转换。

综上所述,代码仍然很繁琐。掌握组合器的用法很重要,但是它们有其局限性。让我们尝试另一种方法:提前返回

提取返回

我想使用提前返回重写上一节的代码。提前返回可以让你尽早退出该功能。我们在 file_double 中无法从一个闭包内部提前返回,因此我们需要恢复为显式的模式匹配。

io-basic-error-string-early-return
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
use std::fs::File;
use std::io::Read;
use std::path::Path;

fn file_double<P: AsRef<Path>>(file_path: P) -> Result<i32, String> {
let mut file = match File::open(file_path) {
Ok(file) => file,
Err(err) => return Err(err.to_string()),
};
let mut contents = String::new();
if let Err(err) = file.read_to_string(&mut contents) {
return Err(err.to_string());
}
let n: i32 = match contents.trim().parse() {
Ok(n) => n,
Err(err) => return Err(err.to_string()),
};
Ok(2 * n)
}

fn main() {
match file_double("foobar") {
Ok(n) => println!("{}", n),
Err(err) => println!("Error: {}", err),
}
}

部分人可能不认为此代码是比使用组合器的代码更好,但是,如果你不熟悉组合器方法,那么这段代码看起来将会更简单。它使用带有 matchif let 的显式模式匹配。如果发生错误,它只是停止执行该函数并返回错误(通过将其转换为字符串)。

这不是倒退一步吗?之前,我曾说过,工程学错误处理的关键是减少显式模式匹配,但是我们在这里已恢复为显式模式匹配。事实证明,有多种方法可以减少显式模式匹配,组合器不是唯一的方法。

try! 宏/? 操作符

在较旧的 Rust 版本(Rust 1.12 或更早版本)中,Rust 中错误处理的基石是 try! 宏。try! 宏将模式匹配抽象成组合器,但不同于组合器,它也抽象控制流。即,它可以抽象出上面看到的提前返回模式。

这是try!宏的简化定义:

try-def-simple
1
2
3
4
5
6
macro_rules! try {
($e:expr) => (match $e {
Ok(val) => val,
Err(err) => return Err(err),
});
}

真实的定义 要复杂得多。我们将在以后再看。)

使用 try! 宏可以很容易地简化我们的示例。由于它可以进行模式匹配并为我们实现提前返回,因此我们获得了更易于阅读的紧凑代码:

io-basic-error-try
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
use std::fs::File;
use std::io::Read;
use std::path::Path;

fn file_double<P: AsRef<Path>>(file_path: P) -> Result<i32, String> {
let mut file = try!(File::open(file_path).map_err(|e| e.to_string()));
let mut contents = String::new();
try!(file.read_to_string(&mut contents).map_err(|e| e.to_string()));
let n = try!(contents.trim().parse::<i32>().map_err(|e| e.to_string()));
Ok(2 * n)
}

fn main() {
match file_double("foobar") {
Ok(n) => println!("{}", n),
Err(err) => println!("Error: {}", err),
}
}

map_err 调用仍然需要传递给我们的 try! 定义。这是因为错误类型仍需要转换为 String。好消息是,我们将很快学习如何删除这些 map_err 调用!坏消息是,在删除 map_err 调用之前,我们将需要更多地了解标准库中的几个重要特征。

在较新版本的 Rust(Rust 1.13 或更高版本)中,该 try! 宏已替换为 ? 操作符。虽然它打算增加我们在这里不会介绍的新功能,但是使用 ? 代替try! 是很简单的:

io-basic-error-question
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
use std::fs::File;
use std::io::Read;
use std::path::Path;

fn file_double<P: AsRef<Path>>(file_path: P) -> Result<i32, String> {
let mut file = File::open(file_path).map_err(|e| e.to_string())?;
let mut contents = String::new();
file.read_to_string(&mut contents).map_err(|e| e.to_string())?;
let n = contents.trim().parse::<i32>().map_err(|e| e.to_string())?;
Ok(2 * n)
}

fn main() {
match file_double("foobar") {
Ok(n) => println!("{}", n),
Err(err) => println!("Error: {}", err),
}
}

定义自己的错误类型

在深入探讨一些标准库错误 traits 之前,我想通过在前面的示例中删除对我们的错误类型 String 的使用来结束本节。

String 像在前面的示例中一样,使用起来很方便,因为很容易将错误转换为字符串,甚至可以当场将自己的错误作为字符串来实现。但是,使用 String 错误会带来一些不利影响。

第一个缺点是错误消息往往会使你的代码变得混乱。可以在其他地方定义错误消息,但是除非你受过特别的训练,否则很难将错误消息嵌入代码中。确实,我们在前面的示例中完全做到了这一点。

第二个更重要的缺点是 String有损的。也就是说,如果所有错误都转换为字符串,那么我们传递给调用方的错误将变得完全不透明。调用者可以对 String 错误进行的唯一合理的处理就是将其显示给用户。当然,检查字符串以确定错误的类型并不可靠。(诚然,与应用程序相比,此缺点在库内部要重要得多。)

例如,io::Error 类型嵌入 io::ErrorKind,这是表示 IO 操作期间出了什么问题的结构化数据。这很重要,因为你可能希望根据错误做出不同的反应。(例如,BrokenPipe 错误可能意味着优雅地退出程序,而 NotFound 错误可能意味着退出并显示错误代码以向用户显示错误。)使用 io::ErrorKind,调用者可以使用模式匹配检查错误的类型,这绝对优于试图弄清楚一个 String 错误的细节。

与其在前面的从文件中读取整数的示例中将 String 用作错误类型,不如定义自己的错误类型,该错误类型表示结构化数据中的错误。如果调用者想检查详细信息,我们将努力不从潜在错误中删除信息。

表示多种可能性之一的理想方法是定义自己的枚举类型 enum。在我们的例子中,错误是 io::Errornum::ParseIntError,因此很自然地定义为:

io-basic-error-custom
1
2
3
4
5
6
7
8
9
10
use std::io;
use std::num;

// We derive `Debug` because all types should probably derive `Debug`.
// This gives us a reasonable human readable description of `CliError` values.
#[derive(Debug)]
enum CliError {
Io(io::Error),
Parse(num::ParseIntError),
}

调整我们的代码非常容易。无需将错误转换为字符串,我们只需使用相应的值构造函数将它们转换为我们的类型 CliError 即可:

io-basic-error-custom
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
use std::fs::File;
use std::io::Read;
use std::path::Path;

fn file_double<P: AsRef<Path>>(file_path: P) -> Result<i32, CliError> {
let mut file = File::open(file_path).map_err(CliError::Io)?;
let mut contents = String::new();
file.read_to_string(&mut contents).map_err(CliError::Io)?;
let n: i32 = contents.trim().parse().map_err(CliError::Parse)?;
Ok(2 * n)
}

fn main() {
match file_double("foobar") {
Ok(n) => println!("{}", n),
Err(err) => println!("Error: {:?}", err),
}
}

唯一的更改是将 map_err(|e| e.to_string())(将错误转换为字符串)切换到 map_err(CliError::Io)map_err(CliError::Parse),调用者可以通过问题的错误等级决定是否向用户报告。实际上,将 String 用作错误类型会从调用方中删除这种选择,而使用自定义 enum 错误类型,例如 CliError,除了描述错误的结构化数据外,还可以像以前一样为调用方提供很多便利。

经验法则是尽量定义自己的错误类型,但是 String 错误类型会在一定程度上发生,特别是在编写应用程序时。如果要编写库,则强烈建议定义自己的错误类型,以免不必要地从调用方中删除选择。

用于错误处理的标准库 traits

标准库为错误处理定义了两个不可或缺的 trait: std::error::Errorstd::convert::From。尽管 Error 专为一般性地描述错误而设计,但是 From trait 在两个不同类型之间转换值时起更一般的作用。

Error trait

Error trait 在标准库中的定义如下:

error-def
1
2
3
4
5
6
7
8
9
use std::fmt::{Debug, Display};

trait Error: Debug + Display {
/// A short description of the error.
fn description(&self) -> &str;

/// The lower level cause of this error, if any.
fn cause(&self) -> Option<&Error> { None }
}

此 trait 是非常通用的,因为它打算表示错误的所有类型。我们将在后面看到这对编写可组合代码很有帮助。简单地说,该 trait 允许你执行以下操作:

  • 获取 Debug 错误的表示形式。
  • 获取 Display 错误的面向用户的表示形式。
  • 获得错误的简短描述(通过 description 方法)。
  • 检查错误的因果链(如果存在)(通过 cause 方法)。

前两个来自于 Error 要求实现 DebugDisplay。后两者来自 Error 上定义的两种方法。Error 的力量来自所有错误类型均隐含 Error 的事实,这意味着可以将存在的错误量化为 trait 对象,这表现为 Box<Error>&Error。实际上,cause 方法返回一个 &Error,它本身就是一个 trait 对象。稍后,我们将重新使用 Error trait 作为 trait 对象。

就目前而言,已经能够通过实现 Errortrait 编写示例。让我们使用上一节中定义的错误类型 :

error-impl
1
2
3
4
5
6
7
8
9
10
use std::io;
use std::num;

// We derive `Debug` because all types should probably derive `Debug`.
// This gives us a reasonable human readable description of `CliError` values.
#[derive(Debug)]
enum CliError {
Io(io::Error),
Parse(num::ParseIntError),
}

这种特殊的错误类型表示可能发生两种类型的错误:处理 I/O 的错误或将字符串转换为数字的错误。通过向 enum 定义添加新的变体,该错误可以表示所需的错误类型。

为其实现 Error 非常简单,主要是要进行很多显式的模式匹配。

error-impl
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
use std::error;
use std::fmt;

impl fmt::Display for CliError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match *self {
// Both underlying errors already impl `Display`, so we defer to
// their implementations.
CliError::Io(ref err) => write!(f, "IO error: {}", err),
CliError::Parse(ref err) => write!(f, "Parse error: {}", err),
}
}
}

impl error::Error for CliError {
fn description(&self) -> &str {
// Both underlying errors already impl `Error`, so we defer to their
// implementations.
match *self {
CliError::Io(ref err) => err.description(),
// Normally we can just write `err.description()`, but the error
// type has a concrete method called `description`, which conflicts
// with the trait method. For now, we must explicitly call
// `description` through the `Error` trait.
CliError::Parse(ref err) => error::Error::description(err),
}
}

fn cause(&self) -> Option<&error::Error> {
match *self {
// N.B. Both of these implicitly cast `err` from their concrete
// types (either `&io::Error` or `&num::ParseIntError`)
// to a trait object `&Error`. This works because both error types
// implement `Error`.
CliError::Io(ref err) => Some(err),
CliError::Parse(ref err) => Some(err),
}
}
}

注意这是一个非常典型的 Error 实现:匹配不同的错误类型,并实现 descriptioncause 的定义。

From trait

std::convert::From trait 也是定义在标准库中:

from-def
1
2
3
trait From<T> {
fn from(T) -> Self;
}

是不是非常简单?From 之所以非常有用,是因为它为我们提供了一种通用的方式来实现从特定类型 T 到其他类型的转换(在这种情况下,“其他类型”是 impl 的主体,也即是 Self)。From标准库提供的一组实现

以下是一些简单的示例,说明其 From 工作方式:

from-examples
1
2
3
let string: String = From::from("foo");
let bytes: Vec<u8> = From::from("foo");
let cow: ::std::borrow::Cow<str> = From::from("foo");

OK,因此 From 对于在字符串之间进行转换很有用。那么错误呢?同样有一个关键的实现:

1
impl<'a, E: Error + 'a> From<E> for Box<Error + 'a>

这个实现表示对于任何实现了 Error 的类型,我们可以将它转换为 trait 对象 Box<Error>,这在一般情况下很有用。

还记得我们以前处理的两个错误吗?也就是,io::Errornum::ParseIntError。既然都实现了 Error,它们同样可以使用 From

from-examples-errors
1
2
3
4
5
6
7
8
9
10
11
12
use std::error::Error;
use std::fs;
use std::io;
use std::num;

// We have to jump through some hoops to actually get error values.
let io_err: io::Error = io::Error::last_os_error();
let parse_err: num::ParseIntError = "not a number".parse::<i32>().unwrap_err();

// OK, here are the conversions.
let err1: Box<Error> = From::from(io_err);
let err2: Box<Error> = From::from(parse_err);

注意这里有一个非常重要的模式。两个 err1err2 具有相同的类型。这是因为它们是表示上完全相同的类型或 trait 对象。并且,编译器删除了它们的底层类型,因此在编译器看来,err1err2 完全相同。此外,我们构造 err1err2 使用了完全相同的函数调用:From::from。这是因为 From::from 在其参数和返回类型上都重载了。

此模式很重要,因为它解决了我们先前遇到的一个问题:提供了一种使用相同函数,将错误转换为相同类型的方法。

是时候重温一个老朋友了:try! 宏/ ? 操作符。

实际的 try! 宏/ ? 操作符

之前,我介绍了 try! 的定义:

1
2
3
4
5
6
macro_rules! try {
($e:expr) => (match $e {
Ok(val) => val,
Err(err) => return Err(err),
});
}

这不是真实的定义。它在标准库中的真正定义:

try-def
1
2
3
4
5
6
macro_rules! try {
($e:expr) => (match $e {
Ok(val) => val,
Err(err) => return Err(::std::convert::From::from(err)),
});
}

有一个微小而强大的更改:错误值 From::from 通过传递。这使 try! 宏的功能更加强大,因为它为你提供了自动类型转换。这也与 ? 操作符的工作方式非常相似,但后者的定义略有不同,即 x? 类似以下内容:

questionmark-def
1
2
3
4
match ::std::ops::Try::into_result(x) {
Ok(v) => v,
Err(e) => return ::std::ops::Try::from_error(From::from(e)),
}

Try trait 暂时还在修改,不在本文的讨论范围之内,但是其本质是它提供了一种对许多不同类型的成功/失败模式进行抽象的方法,而无需与 Result<T, E> 紧密联系。如你所见,x? 语法仍然调用 From::from,这是我们实现自动错误转换的方式。

由于目前编写的大多数代码都使用 ? 代替 try!,因此我们将在本文的其余部分中继续使用 ?

让我们看一下我们之前编写的用于读取文件并将其内容转换为整数的代码:

1
2
3
4
5
6
7
8
9
10
11
use std::fs::File;
use std::io::Read;
use std::path::Path;

fn file_double<P: AsRef<Path>>(file_path: P) -> Result<i32, String> {
let mut file = File::open(file_path).map_err(|e| e.to_string())?;
let mut contents = String::new();
file.read_to_string(&mut contents).map_err(|e| e.to_string())?;
let n = contents.trim().parse::<i32>().map_err(|e| e.to_string())?;
Ok(2 * n)
}

之前,我说过我们可以不调用 map_err。确实,我们要做的就是选择一种适用 From 的类型。正如我们在上一节中所看到的,From 的实现可以让我们将任何错误类型转换为 Box<Error>

io-basic-error-try-from
1
2
3
4
5
6
7
8
9
10
11
12
use std::error::Error;
use std::fs::File;
use std::io::Read;
use std::path::Path;

fn file_double<P: AsRef<Path>>(file_path: P) -> Result<i32, Box<Error>> {
let mut file = File::open(file_path)?;
let mut contents = String::new();
file.read_to_string(&mut contents)?;
let n = contents.trim().parse::<i32>()?;
Ok(2 * n)
}

我们已经非常接近理想的错误处理。我们的代码在错误处理上的开销很小,因为 ? 操作符同时封装了三件事:

  1. 模式匹配。
  2. 控制流。
  3. 错误类型转换。

当三者结合在一起时,我们得到的代码不受组合器,调用 unwrap 或模式匹配的束缚。

剩下的只有一点点:Box<Error> 类型是不透明的。如果我们返回 Box<Error> 给调用方,则调用方将无法检查潜在的错误类型。虽然这种情况肯定比返回 String 要好,因为调用者可以调用诸如 descriptioncause的方法,但局限性仍然是:Box<Error> 不透明。(注意,这并非完全正确,因为 Rust 确实具有运行时反射,这在超出本文范围的某些情况下很有用。)

现在该重新审视我们的自定义 CliError 类型并将所有内容整合在一起。

组合自定义错误类型

在上一节中,我们研究了 ? 运算符以及它如何通过调用 From::from 错误值为我们完成自动类型转换。特别是,我们可以将错误转换为 Box<Error>,但是类型对于调用者是不透明的。

要解决此问题,我们使用我们已经熟悉的相同补救措施:自定义错误类型。下面仍然是读取文件内容并将其转换为整数的代码:

io-basic-error-custom-from
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
use std::fs::File;
use std::io::{self, Read};
use std::num;
use std::path::Path;

// We derive `Debug` because all types should probably derive `Debug`.
// This gives us a reasonable human readable description of `CliError` values.
#[derive(Debug)]
enum CliError {
Io(io::Error),
Parse(num::ParseIntError),
}

fn file_double_verbose<P: AsRef<Path>>(file_path: P) -> Result<i32, CliError> {
let mut file = File::open(file_path).map_err(CliError::Io)?;
let mut contents = String::new();
file.read_to_string(&mut contents).map_err(CliError::Io)?;
let n: i32 = contents.trim().parse().map_err(CliError::Parse)?;
Ok(2 * n)
}

请注意,我们仍然有 map_err 的调用。为什么?回想一下 ? 运算符和 From 的定义,问题在于,没有 From 实现使我们能够从错误类型(例如 io::Errornum::ParseIntError)转换为我们自己的自定义类型 CliError。当然,解决这个问题很容易!既然定义了 CliError,我们就可以为它实现 From

io-basic-error-custom-from
1
2
3
4
5
6
7
8
9
10
11
impl From<io::Error> for CliError {
fn from(err: io::Error) -> CliError {
CliError::Io(err)
}
}

impl From<num::ParseIntError> for CliError {
fn from(err: num::ParseIntError) -> CliError {
CliError::Parse(err)
}
}

所有这些实现正在做的事情是教导 From 如何从其他错误类型创建 CliError。在我们的例子中,实现就像调用相应的值构造函数一样简单。确实,这通常很容易。

我们终于可以重写file_double

io-basic-error-custom-from
1
2
3
4
5
6
7
fn file_double<P: AsRef<Path>>(file_path: P) -> Result<i32, CliError> {
let mut file = File::open(file_path)?;
let mut contents = String::new();
file.read_to_string(&mut contents)?;
let n: i32 = contents.trim().parse()?;
Ok(2 * n)
}

我们在这里所做的唯一一件事就是删除对 map_err 的调用。不再需要它们,因为 ? 运算符会在错误值上调用 From::from。之所以有效,是因为我们为所有可能出现的错误类型提供了 From 的实现。

如果我们修改 file_double 函数以执行其他操作,例如,将字符串转换为浮点数,则需要为错误类型添加新的变体:

1
2
3
4
5
enum CliError {
Io(io::Error),
ParseInt(num::ParseIntError),
ParseFloat(num::ParseFloatError),
}

为了反映此更改,我们需要更新之前的 impl From<num::ParseIntError> for CliError 并添加新的 impl From<num::ParseFloatError> for CliError

1
2
3
4
5
6
7
8
9
10
11
impl From<num::ParseIntError> for CliError {
fn from(err: num::ParseIntError) -> CliError {
CliError::ParseInt(err)
}
}

impl From<num::ParseFloatError> for CliError {
fn from(err: num::ParseFloatError) -> CliError {
CliError::ParseFloat(err)
}
}

就是这样!

对库作者的建议

Rust 库的范式仍在形成,但是如果你的库需要报告自定义错误,那么你可能需要定义自己的错误类型。是否公开其表示形式(如 ErrorKind)或使其隐藏(如 ParseIntError)取决于你。无论如何执行,通常最好的做法是至少提供有关错误的信息,而不仅仅是其 String 表示形式。但是可以肯定的是,这将取决于用例。

至少,你应该实现 Error trait。这将使你的库用户在组合错误类型时有一定的灵活性。实施此 Error 特征还意味着要确保用户具有获取错误的字符串表示形式的能力(因为它要求实现 fmt::Debugfmt::Display)。

除此之外,提供 From 错误类型的实现也可能很有用。这使你(库作者)和你的用户可以编写更详细的错误。例如, csv::Error同时为 io::Errorbyteorder::Error 提供了 From 实现 。

最后,根据你的喜好,你可能还想定义一个 Result 类型别名,尤其是在你的库定义了单个错误类型的情况下。这是在标准库使用的 io::Resultfmt::Result

案例学习: 读取人口数据

这个案例很长,根据你的知识背景,可能会比较复杂。虽然有很多示例代码与说明一起使用,但大多数代码都是专门为教学目的而设计的。虽然我不够聪明,无法制作既不是玩具示例又能够实现教学的示例,但我可以撰写实际案例。

为此,我想构建一个命令行程序,让你查询世界人口数据。目标很简单:你给它一个位置,它将告诉你人口数据。尽管简单,但仍有很多地方可能出错!

我们将使用的数据来自 Data Science Toolkit。我已经为此练习准备了一些数据。你可以获取 世界人口数据 (41MB gzip 压缩,145MB 未压缩),也可以仅获取 美国人口数据 (2.2MB gzip 压缩,7.2MB 未压缩)。

到目前为止,我一直将代码限制为 Rust 的标准库。但是对于像这样的真实任务,我们至少要使用某种东西来解析CSV数据,解析程序参数并将这些东西自动解码为 Rust 类型。为此,我们将使用 csvdocoptrustc-serialize crate。

在 Github 上获取

该案例研究的最终代码在Github上。如果你安装了Rust和Cargo,那么你要做的就是:

1
2
3
4
git clone git://github.com/BurntSushi/rust-error-handling-case-study
cd rust-error-handling-case-study
cargo build --release
./target/release/city-pop --help

我们将逐步构建该项目。继续!

初始化

我不会花很多时间在 Cargo 上建立项目,因为 Rust BookCargo的文档 已经很好地介绍了该项目 。

要从头开始,请运行 cargo new --bin city-pop 并确保你的 Cargo.toml 如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
[package]
name = "city-pop"
version = "0.1.0"
authors = ["Andrew Gallant <jamslam@gmail.com>"]

[[bin]]
name = "city-pop"

[dependencies]
csv = "0.*"
docopt = "0.*"
rustc-serialize = "0.*"

你应该能够直接运行:

1
2
3
cargo build --release
./target/release/city-pop
#Outputs: Hello, world!

参数解析

让我们首先进行参数解析。我不会在 Docopt 上介绍太多细节,但是有一个 不错的网页 描述了它以及 Rust crate 的文档。简单地说,Docopt 从 Usage 字符串生成一个参数解析器。解析完成后,我们可以将程序参数解码为Rust 结构体。我们的程序如下,其中带有适当的 extern crate 语句,Usage 字符串,我们的 Args struct 和一个空的 main

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
extern crate docopt;
extern crate rustc_serialize;

static USAGE: &'static str = "
Usage: city-pop [options] <data-path> <city>
city-pop --help

Options:
-h, --help Show this usage message.
";

struct Args {
arg_data_path: String,
arg_city: String,
}

fn main() {

}

好的,是时候开始编写了。Docopt 的文档说,我们可以创建一个解析器 Docopt::new,然后使用 Docopt::decode 将其解码为一个结构体。这两个函数都会返回 docopt::Error。我们可以从显式模式匹配开始:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// These use statements were added below the `extern` statements.
// I'll elide them in the future. Don't worry! It's all on Github:
// https://github.com/BurntSushi/rust-error-handling-case-study
//use std::io::{self, Write};
//use std::process;
//use docopt::Docopt;

fn main() {
let args: Args = match Docopt::new(USAGE) {
Err(err) => {
writeln!(&mut io::stderr(), "{}", err).unwrap();
process::exit(1);
}
Ok(dopt) => match dopt.decode() {
Err(err) => {
writeln!(&mut io::stderr(), "{}", err).unwrap();
process::exit(1);
}
Ok(args) => args,
}
};
}

这并不是很好。为了使代码更清晰,我们可以做的一件事是编写一个宏以将消息打印到 stderr 然后退出:

fatal-def
1
2
3
4
5
6
7
macro_rules! fatal {
($($tt:tt)*) => {{
use std::io::Write;
writeln!(&mut ::std::io::stderr(), $($tt)*).unwrap();
::std::process::exit(1)
}}
}

unwrap 在这里是没问题的,因为如果失败的话,就意味着你的程序无法写入 stderr。一个好的经验法则是可以中止,但是可以肯定的是,如果需要,你可以做其他事情。

这个代码看起来更好,但是显式的模式匹配仍然很麻烦:

1
2
3
4
5
6
7
let args: Args = match Docopt::new(USAGE) {
Err(err) => fatal!("{}", err),
Ok(dopt) => match dopt.decode() {
Err(err) => fatal!("{}", err),
Ok(args) => args,
}
};

值得庆幸的是,docopt::Error 类型定义了一种便捷的方法 exit,该方法可以有效地完成我们刚刚做的事情。将其与我们的组合器知识相结合,我们获得了简洁明了的代码:

1
2
3
let args: Args = Docopt::new(USAGE)
.and_then(|d| d.decode())
.unwrap_or_else(|err| err.exit());

如果此代码成功完成,则将根据用户提供的值填充 args

编写程序逻辑

编写代码的方式各不相同,但是当我不确定如何编码问题时,错误处理通常是我要考虑的最后一件事。对于好的设计来说,这不是一个很好的做法,但是对于快速原型制作可能是有用的。在我们的案例中,由于 Rust 迫使我们对错误进行处理,这也将使程序的哪些部分可能导致错误变得显而易见。为什么?因为 Rust 将使我们调用 unwrap,这可以使我们很好地了解如何进行错误处理。

在本案例中,程序逻辑非常简单。我们需要做的就是解析提供给我们的 CSV 数据,并在匹配的行中打印出一个字段。我们开始吧。(确保添加 extern crate csv; 到文件的顶部。)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// This struct represents the data in each row of the CSV file.
// Type based decoding absolves us of a lot of the nitty gritty error
// handling, like parsing strings as integers or floats.
struct Row {
country: String,
city: String,
accent_city: String,
region: String,

// Not every row has data for the population, latitude or longitude!
// So we express them as `Option` types, which admits the possibility of
// absence. The CSV parser will fill in the correct value for us.
population: Option<u64>,
latitude: Option<f64>,
longitude: Option<f64>,
}

fn main() {
let args: Args = Docopt::new(USAGE)
.and_then(|d| d.decode())
.unwrap_or_else(|err| err.exit());

let file = fs::File::open(args.arg_data_path).unwrap();
let mut rdr = csv::Reader::from_reader(file);
for row in rdr.decode::<Row>() {
let row = row.unwrap();
if row.city == args.arg_city {
println!("{}, {}: {:?}",
row.city, row.country,
row.population.expect("population count"));
}
}
}

让我们分析可能出现的错误。我们可以从显而易见的地方开始:这三个 unwrap 地方为:

  1. fs::File::open 可能返回 io::Error
  2. csv::Reader::decode 一次解码一个记录,但是解码一条记录 (查看 Iterator 上的关联类型 Item)可能产生一个 csv::Error
  3. 如果 row.populationNone,则调用 expect 会导致 panic

还有其他吗?如果我们找不到匹配的城市怎么办?类似的工具 grep 将返回错误代码,因此我们也应该这样做。因此,我们得到了特定于我们问题的逻辑错误,IO 错误和 CSV 分析错误。我们将探索两种不同的方法来处理这些错误。

我想从使用 Box<Error> 开始。稍后,我们将看到定义自己的错误类型也是很有用的。

使用 Box<Error> 进行错误处理

Box<Error> 的特性很合适,因为你不需要定义自己的错误类型,也不需要任何 From 实现。缺点是,由于 Box<Error> 是 trait 对象,因此会删除隐含的类型,这意味着编译器无法再对其基础类型进行推理。

让我们开始改变函数的返回类型 TResult<T, OurErrorType> 重构我们的代码。在这种情况下,OurErrorType is Box<Error>。那么 T 是什么?我们可以将返回类型添加到main吗?

第二个问题的答案是否定的,我们不能。这意味着我们需要编写一个新函数。但是 T 是什么呢?最简单的方法是将匹配 Row 值的列表作为 Vec<Row> 返回。(更好的代码将返回一个迭代器,但这留给读者练习。)

让我们将代码重构为自己的函数,但保留对 unwrap 的调用。请注意,我们选择通过简单地忽略该行来处理人口总数缺失的可能性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
struct Row {
// unchanged
}

struct PopulationCount {
city: String,
country: String,
// This is no longer an `Option` because values of this type are only
// constructed if they have a population count.
count: u64,
}

fn search<P: AsRef<Path>>(file_path: P, city: &str) -> Vec<PopulationCount> {
let mut found = vec![];
let file = fs::File::open(file_path).unwrap();
let mut rdr = csv::Reader::from_reader(file);
for row in rdr.decode::<Row>() {
let row = row.unwrap();
match row.population {
None => { } // skip it
Some(count) => if row.city == city {
found.push(PopulationCount {
city: row.city,
country: row.country,
count: count,
});
},
}
}
found
}

fn main() {
let args: Args = Docopt::new(USAGE)
.and_then(|d| d.decode())
.unwrap_or_else(|err| err.exit());

for pop in search(&args.arg_data_path, &args.arg_city) {
println!("{}, {}: {:?}", pop.city, pop.country, pop.count);
}
}

尽管我们摆脱了对 expect 的调用(是 unwrap 的更好的变体),但我们仍然应该处理没有任何搜索结果的情况。

要将其转换为正确的错误处理,我们需要执行以下操作:

  1. search 的返回类型更改为 Result<Vec<PopulationCount>, Box<Error>>
  2. 使用 ? 运算符,以便将错误返回给调用者,而不用 panic 该程序。
  3. 处理 main 中的错误。

让我们尝试一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
fn search<P: AsRef<Path>>
(file_path: P, city: &str)
-> Result<Vec<PopulationCount>, Box<Error+Send+Sync>> {
let mut found = vec![];
let file = fs::File::open(file_path)?;
let mut rdr = csv::Reader::from_reader(file);
for row in rdr.decode::<Row>() {
let row = row?;
match row.population {
None => { } // skip it
Some(count) => if row.city == city {
found.push(PopulationCount {
city: row.city,
country: row.country,
count: count,
});
},
}
}
if found.is_empty() {
Err(From::from("No matching cities with a population were found."))
} else {
Ok(found)
}
}

不同于 x.unwrap(),我们现在使用 x?。由于我们的函数返回 Result<T, E>,因此如果发生错误,? 操作符会从函数中提前返回。

这段代码有一个大陷阱:我们应该使用 Box<Error + Send + Sync> 代替 Box<Error>。我们这样做是为了将纯字符串转换为错误类型。我们需要这些额外的限制,以便我们可以使用相应的 From impls

1
2
3
4
5
6
7
// We are making use of this impl in the code above, since we call `From::from`
// on a `&'static str`.
impl<'a, 'b> From<&'b str> for Box<Error + Send + Sync + 'a>

// But this is also useful when you need to allocate a new string for an
// error message, usually with `format!`.
impl From<String> for Box<Error + Send + Sync>

现在,我们已经了解了如何使用 Box<Error> 进行正确的错误处理,让我们尝试使用自定义错误类型的另一种方法。但是首先,让我们从错误处理中休息一下,并增加对从 stdin 中读取数据的支持。

从 stdin 读取

在我们的程序中,我们接受单个文件作为输入,并对数据进行一次传递。这意味着我们可能应该能够在 stdin 上接受输入。但是我们也喜欢当前的格式,所以让我们两者兼而有之!

添加对 stdin 的支持实际上非常容易。我们只需要做两件事:

  1. 调整程序参数,以便在从 stdin 读取人口数据时可以接受一个参数:城市。
  2. 修改 search 功能以采用可选的文件路径。当为 None 时,它知道应该从 stdin 读取。

首先,这是新的 Usage 和 Args 结构:

1
2
3
4
5
6
7
8
9
10
11
12
static USAGE: &'static str = "
Usage: city-pop [options] [<data-path>] <city>
city-pop --help

Options:
-h, --help Show this usage message.
";

struct Args {
arg_data_path: Option<String>,
arg_city: String,
}

我们所做的就是在 Docopt 用法字符串中将 data-path 参数设置为可选,并将相应的 struct 成员 arg_data_path 设置为可选。docopt crate将处理其余部分。

修改 search 有些棘手。csv crate 可以解析任何实现了io::Read的类型。但是,如何在两种类型上使用相同的代码?实际上,我们可以采取几种方法。一种方法是编写 search 代码,使其对某些实现了 io::Read 的类型参数 R 是通用的。另一种方法是只使用 trait 对象:

1
2
3
4
5
6
7
8
9
10
11
fn search<P: AsRef<Path>>
(file_path: &Option<P>, city: &str)
-> Result<Vec<PopulationCount>, Box<Error+Send+Sync>> {
let mut found = vec![];
let input: Box<io::Read> = match *file_path {
None => Box::new(io::stdin()),
Some(ref file_path) => Box::new(fs::File::open(file_path)?),
};
let mut rdr = csv::Reader::from_reader(input);
// The rest remains unchanged!
}

使用自定义类型进行错误处理

之前,我们学习了如何使用自定义错误类型来编写错误类型。为此,我们将错误类型定义为 enum,然后实现 ErrorFrom

由于存在三个不同的错误(IO,CSV 解析和未找到),因此我们定义一个具有三个变体的 enum

1
2
3
4
5
enum CliError {
Io(io::Error),
Csv(csv::Error),
NotFound,
}

并且实现 DisplayError

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
impl fmt::Display for CliError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match *self {
CliError::Io(ref err) => err.fmt(f),
CliError::Csv(ref err) => err.fmt(f),
CliError::NotFound => write!(f, "No matching cities with a \
population were found."),
}
}
}

impl Error for CliError {
fn description(&self) -> &str {
match *self {
CliError::Io(ref err) => err.description(),
CliError::Csv(ref err) => err.description(),
CliError::NotFound => "not found",
}
}
}

在我们可以在 search 函数中使用 CliError 类型之前,我们需要提供一些 From 实现。我们如何知道要提供哪些实现?好吧,我们需要同时从io::Errorcsv::Error 中转换为 CliError。这些是唯一的外部错误,所以我们 From 现在只需要两个实现:

1
2
3
4
5
6
7
8
9
10
11
impl From<io::Error> for CliError {
fn from(err: io::Error) -> CliError {
CliError::Io(err)
}
}

impl From<csv::Error> for CliError {
fn from(err: csv::Error) -> CliError {
CliError::Csv(err)
}
}

由于定义了 ? 运算符,因此 From 非常重要 。特别是,如果发生错误,则对错误进行调用 From::from,在这种情况下,会将其转换为我们自己的错误类型 CliError

随着 From 实现的完成,我们只需要对我们的 search 函数进行两个小调整:返回类型和“未找到”错误。这是完整的程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
fn search<P: AsRef<Path>>
(file_path: &Option<P>, city: &str)
-> Result<Vec<PopulationCount>, CliError> {
let mut found = vec![];
let input: Box<io::Read> = match *file_path {
None => Box::new(io::stdin()),
Some(ref file_path) => Box::new(fs::File::open(file_path)?),
};
let mut rdr = csv::Reader::from_reader(input);
for row in rdr.decode::<Row>() {
let row = row?;
match row.population {
None => { } // skip it
Some(count) => if row.city == city {
found.push(PopulationCount {
city: row.city,
country: row.country,
count: count,
});
},
}
}
if found.is_empty() {
Err(CliError::NotFound)
} else {
Ok(found)
}
}

无需其他更改。

额外功能

如果你像我一样,那么编写通用代码会感觉不错,因为通用化的东西很酷!但是有时候,这样做是不值得的。看一下我们在上一步中所做的事情:

  1. 定义了新的错误类型。
  2. 新增实现了 ErrorDisplay 和两个 From

这里最大的缺点是我们的程序并没有改善很多。我个人喜欢它,因为我喜欢使用 enum 表示错误,但是这样做有很多开销,尤其是在像这样的短程序中。

像我们在这里一样使用自定义错误类型的一个有用方面是,main 函数现在可以选择以不同方式处理错误。以前,使用 Box<Error> 时,它没有太多选择:仅打印消息。我们在这里仍然这样做,但是如果我们想添加一个 --quiet 标志怎么办?该 --quiet 标志应使任何详细的输出静音。

现在,如果程序找不到匹配项,它将输出一条消息,说明是这样。这可能有点笨拙,特别是如果你打算将该程序用于 shell 脚本中时。

因此,让我们从添加标志开始。像以前一样,我们需要调整用法字符串并在 Args 结构中添加一个标志。docopt crate 完成剩下的事情:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
static USAGE: &'static str = "
Usage: city-pop [options] [<data-path>] <city>
city-pop --help

Options:
-h, --help Show this usage message.
-q, --quiet Don't show noisy messages.
";

struct Args {
arg_data_path: Option<String>,
arg_city: String,
flag_quiet: bool,
}

现在,我们只需要实现 “quiet” 功能即可。这需要我们在 main 中进行的调整:

1
2
3
4
5
6
7
match search(&args.arg_data_path, &args.arg_city) {
Err(CliError::NotFound) if args.flag_quiet => process::exit(1),
Err(err) => fatal!("{}", err),
Ok(pops) => for pop in pops {
println!("{}, {}: {:?}", pop.city, pop.country, pop.count);
}
}

当然,如果发生 IO 错误或数据解析失败,我们不想静默输出。因此,我们采用模式匹配,以检查错误类型是否为 NotFound 和是否已启用 --quiet。如果搜索失败,我们仍然会退出代码(遵循 grep 的约定)。

如果我们坚持使用 Box<Error>,那么实现 --quiet 功能将非常棘手。

这几乎总结了我们的案例研究。从这里开始,你应该能够编写带有适当错误处理的自己的程序和库。

概括

由于本文很长,因此快速总结一下Rust中的错误处理很有用。这些是我的“经验法则”。他们并不是教条。每一个规则都可能会有充足的理由去反驳!

  • 如果你正在编写示例代码,并不想实现过于繁琐的错误处理,unwrap 应该是很好用的(不管是 Result::unwrapOption::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 。
  • 否则,在程序中使用适当的 FromError 实现定义自己的错误类型,以使 ? 操作符更加符合工程学。
  • 如果你正在编写库,并且代码可能会产生错误,请定义自己的错误类型并实现 std::error::Error trait。在适当的地方,实现 From 使你的库代码和调用者的代码更易于编写。(由于 Rust 的一致性规则,调用者将无法为你的错误类型实现 From,因此你的库应该这样做。)
  • 使用 OptionResult 上定义的组合器 。有时单独使用它们可能会有些麻烦,但是我发现,? 操作符和组合器的组合非常有吸引力。and_thenmapunwrap_or是我的最爱。

Rust 中的错误处理

http://blog.czccc.cc/p/d02431de/

作者

Cheng

发布于

2020-08-03

更新于

2022-08-06

许可协议

评论