【译】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 书有一个关于错误处理的章节。它提供了一个非常简短的概述,但不够详细,特别标准库中最近添加的内容。

运行代码

如果你想运行下面的任何一个代码示例,那么可以试试下面的方式:

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

每个代码示例都带有标签。(很遗憾,没有名称的代码示例不能以这种方式运行。)

目录

这篇文章很长,主要是因为我从一开始就从 sum 类型和组合子开始,并逐步地说明 Rust 处理错误的方式。因此,在其他表达型系统方面有经验的程序员可能希望跳过这部分。以下是我的简短指南:

  • 如果你是 Rust、系统编程和表达型系统方面不熟悉,那么从头开始,一步一步来。(如果完全不懂,你可能应该先阅读一下 Rust book)
  • 如果你以前从未见过 Rust,但对函数式语言(“代数数据类型”和“组合子”让你感到温暖和模糊)有经验,那么你可以跳过这些基础知识,先阅读了解一下多种错误类型, 你已经完全理解了标准库 error trait。略读基本的语法可能可能会更好。你也可能需要查阅 Rust 书 看看 Rust 闭包和宏。
  • 如果您已经有了 Rust 方面的经验,并且只是想了解错误处理的细节,那么您可以直接跳过这部分直到文章最后。你可能会找到一些非常有用的研究案例

  • 基本知识
    • unwrap 的详细解释
    • Option 类型
      • Option<T> 值的作用
    • Result 类型
      • 解析整数
      • Result 类型别名惯用法
    • 一个简短的插曲: 使用 unwrap 并不是坏事
  • 处理多种错误类型
    • 组合 OptionResult
    • 组合子的局限
    • 提前返回
    • try! 宏/? 操作符
    • 自定义错误类型
  • 用于错误处理的标准库 trait
    • Error trait
    • From trait
    • 真实的 try! 宏/? 操作符
    • 组合自定义错误类型
    • 给库作者的建议
  • 案例研究: 读取人口数据的程序
    • Github 仓库
    • 初始化设置
    • 参数解析
    • 写逻辑
    • 使用 Box<Error> 进行错误处理
    • 从 stdin 中读取
    • 使用自定义类型进行错误处理
    • 添加功能
  • 总结

基本知识

我喜欢将错误处理看作是使用 case analysis 来确定计算是否成功。正如我们将看到的,符合人体工程学的错误处理的关键是减少程序员必须做的显式 case analysis 的数量,同时保持代码的可组合性。

保持代码的可组合性非常重要,因为如果没有这个需求,我们可能会在遇到非正常情况时发生 panic。(panic 让当前任务发生异常,大多数情况下,整个程序会中止。)下面是一个例子:

// panic-simple
// 猜 0 和 10 之间的数
// 如果匹配上之前设定好的数值,返回 true,否则返回 false
fn guess(n: i32) -> bool {
    if n < 1 || n > 10 {
        panic!("Invalid number: {}", n);
    }
    n == 5
}

fn main() {
    guess(11);
}

(如果您愿意,很容易运行这段代码。)

如果你尝试运行这段代码,程序会崩溃,并提示如下信息:

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

这里还有一个稍微好看点的例子。接受一个整数作为参数的程序,将其加倍并打印出来。

// unwrap-double
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

如果给这个程序零个参数(错误1) ,或者如果第一个参数不是整数(错误2) ,那么程序就会像上面的示例一样陷入崩溃。

我喜欢把这种错误处理方式想象成一头公牛在瓷器店里奔跑。公牛会去它想去的地方,但是在这个过程中它会践踏一切。

unwrap

在前面的示例(unwrap-double)中,我声称如果程序出现两个错误中的一个,它将简单地 panic,然而,该程序没有像第一个示例 (panic-simple) 那样显式地进行 panic。这是因为这种 panic 嵌入在 unwrap 的调用中。

“unwrap” 调用在 Rust 中的解释是,“给我计算结果,如果有错误,只要 panic 并停止程序即可。”,如果我只打印展开后的结果会更容易,因为它非常简单,但是要做到这一点,我们首先需要探索 OptionResult 类型。这两种类型都有 unwrap 方法。

Option 类型

标准库中定义了 Option 类型:

// option-def
enum Option<T> {
    None,
    Some(T),
}

Option 类型是用 Rust 类型系统来表示缺失的 可能性 的一种方式。将缺失的可能性编码到类型系统中是一个重要的概念,因为它将导致编译器强制程序员处理缺失的情况。让我们来看一个在字符串中查找子字符的例子:

// option-ex-string-find
// 在 `haystack` 中搜索 Unicode 字符 `needle`。如果找到了,将返回该字符的字节偏移量。否则,返回 "None"。
fn find(haystack: &str, needle: char) -> Option<usize> {
    for (offset, c) in haystack.char_indices() {
        if c == needle {
            return Some(offset);
        }
    }
    None
}

(提示: 生产中不要使用此代码,而是使用标准库中的 find 方法。)

注意,当这个函数找到匹配的字符时,它不仅仅返回 offset。相反,它返回 Some(offset)SomeOption 类型的变体或值构造器。你可以把它看作类型为 fn<T>(value: T) -> Option<T> 的函数。相应的,None 也是一个值构造器,只是它没有参数。您可以将 None 看作是 fn<T>() -> Option<T> 类型的函数。

这可能看起来像无事生非,但这只是故事的一半。另一半是使用我们编写的 find 函数。让我们尝试用它来查找文件名中的扩展名。

//option-ex-string-find
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> 进行 case analysis。实际上,case analysis 是获取 Option<T> 中存储的值的唯一方法。这意味着,作为程序员,当 Option<T>None 而不是 Some(t) 时,必须处理这种情况。

但是等等,用 unwrap-double 例子中的 unwrap 怎么样?那里没有 case analysis!相反,case analysis 被放在 unwrap 方法中。如果你愿意,你可以自己定义它:

//option-def-unwrap
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 方法抽象出了案例分析(case analysis)。这正使 unwrap 的使用更加符合人体工程学。不幸的是,panic! 意味着 unwrap 是不可组合的: 它是瓷器店里的公牛(前面提到的比喻)。

Option<T> 值的组合

option-ex-string-find 示例中,我们看到了如何使用 find 来查找文件名中的扩展名。当然,并非所有文件都有扩展名。所以文件名可能没有扩展名。这种 缺失的可能性 被编码到使用 Option<T> 的类型中。换句话说,编译器将强迫我们额外处理不存在的可能性。在我们的例子中,我们只是打印出一条消息说明这一点。

获取文件扩展名是一个非常常见的操作,所以把它放到一个函数中是很有必要的:

//option-ex-string-find
// 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..]),
    }
}

(专业提示: 生产环境中不要使用此代码,而是使用标准库中的 extension 方法。)

代码保持简单,但需要注意的重要事情是,find 函数迫使我们考虑缺失的可能性。这是一件好事,因为这意味着编译器不会让我们意外地忘记文件没有扩展名的情况。另一方面,像我们每次在 extension_ explicit 中所做的那样进行明确的 case analysis 可能会让人有点厌烦。

实际上,extension_ explicit 中的 case analysis 遵循一个非常常见的模式: 将一个函数映射到 Option<T> 中的值,除非值是 None,而这时只返回 None

Rust 有参数多态,所以很容易定义一个组合子来抽象这种模式:

// option-map
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 的方法来摆脱 case analysis:

//option-ex-string-find
// 返回定义了扩展名文件的扩展名。遇到第一个 `.` 字符,返回其后的所有字符串。如果文件名没有 `.`,则直接返回 `None`
fn extension(file_name: &str) -> Option<&str> {
    find(file_name, '.').map(|i| &file_name[i+1..])
}

我发现的另一个非常常见的模式是,当 Option 值为 None 时,给变量分配一个默认值。例如,可能你的程序中假定文件的扩展名是 rs,即使没有。正如你可能想象的那样,这种 case analysis 并不是专门用于文件扩展名的,它可以用于任意的 Option<T> 类型:

// option-unwrap-or
fn unwrap_or<T>(option: Option<T>, default: T) -> T {
    match option {
        None => default,
        Some(value) => value,
    }
}

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

//option-ex-string-find
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。它可用于组合独特的计算,处理“缺失”可能性变得容易。例如,本节中的大部分代码都是关于查找指定文件的扩展名的。为此,首先需要从文件路径提取的文件名。虽然大多数文件路径都有文件名,但并非所有路径都有文件名。例如:.../

因此,我们的任务就是找到指定文件的扩展名。我们从显式的 case analysis 开始:

//option-ex-string-find
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 组合子来减少 case analysis,但是它的类型并不完全适合。也就是说,map 接受一个只对内部值执行某些操作的闭包作为参数。闭包的结果总是用 Some 重新包装。相反,我们需要类似 map 的东西,但是它允许调用者返回另一个 Option。它的通用实现甚至比 map 更简单:

// option-and-then
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 函数,而不需要进行明确的 case analysis:

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

Option 类型在标准库中定义了许多其他的组合子。浏览一下这个列表,熟悉它们对你有帮助 —— 它们通常可以帮你减少 case analysis。熟悉这些组合子将带来好处,因为它们中的许多也是为 Result 定义的(具有类似的语义) ,我们将在下面讨论。

组合子是类似 Option 的人体工程学的类型,因为他们减少了 case analysis。它们也是可组合的,因为它们允许调用者以自己的方式处理缺失的可能性。使用 unwrap 这样的方法排除可能性时,如果 Option<T>None,会发生 panic。

Result 类型(The Result type)

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

// result-def
enum Result<T, E> {
    Ok(T),
    Err(E),
}

Result 类型是 Option 的丰富版本。Result 表达了错误的可能性,而不是像 Option 那样表达缺失的可能性。通常,错误被用来解释为什么一些计算的结果失败。这是一个严格的更通用的 Option 形式。考虑下面的类型别名,它在语义上与实际的 Option<T> 在各个方面都等价:

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

这样固定了 Result 的第二个类型参数为 () (发音为“unit”或“empty tuple”)。只有一个值存在于 () 类型中: ()。(是的,类型和值级别的术语有相同的符号!)

Result 类型是表示计算中两种可能结果之一的一种方式。按照惯例,一种意味着结果是预期值或“Ok”,而另一个结果意味着意外或“Err”。

Option 一样,Result 类型也有一个在标准库中定义的 unwrap 方法,我们试试定义它:

// result-def
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 约束意味着有更合理的方法且以更加可读的方式打印该类型的值。)

好,让我们继续来看一个例子。

解析整数(Parsing integers)

Rust 标准库使字符串转换为整数变得非常简单。事实上,它是如此简单,以至于很容易就写成这样:

// result-num-unwrap
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:

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 方法的签名:

impl str {
    fn parse<F: FromStr>(&self) -> Result<F, F::Err>;
}

嗯。所以我们至少知道我们需要使用 Result。当然,这也有可能返回一个 Option。毕竟,一个字符串作为数字解析要么成功,要么解析失败,对吗?这当然是一种合理的方式,但是内部实现要区分为什么字符串不被解析为整型。(无论是空字符串、无效数字、太大或太小。)因此,使用 Result 是有意义的,因为我们希望提供更多的信息,而不仅仅是“缺失”,我们要表明解析失败的原因。当面临 OptionResult 之间的选择时,您应该试着模仿这种推理方式。如果您可以提供详细的错误信息,那么您可能应该这样做。(我们稍后会看到更多内容。)

但是我们如何确定返回的类型呢?上面定义的解析方法是基于泛型,它涵盖了标准库中定义的所有不同数值类型。我们也可以(也许应该)使我们的函数具有通用性,但是让我们先明确一下。我们只关心 i32,因此需要找到它的 FromStr 实现(在浏览器中为执行 CTRL-F 搜索 “FromStr”) ,并查看它的相关类型 Err。我们这样做是为了找到具体的错误类型。在本例中,它是 std::num::ParseIntError。最后,我们可以重写函数:

// result-num-no-unwrap
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),
    }
}

这样更好一点,但现在我们已经编写了更多的代码!case analysis 再次出现了。

用组合子来拯救!和 Option 一样,Result 也有很多的组合子方法。ResultOption 之间有很多的公共组合子方法。特别的,map 就是其中之一:

// result-num-no-unwrap-map
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 类型别名惯用法(The Result type alias idiom)

在标准库中,您可能经常看到 Result<i32> 这样的类型。但是等等,我们定义了 Result 有两个类型参数。我们怎样才能仅仅指定一个呢?关键是定义一个 Result 类型别名,该别名将类型参数之一固定为特定类型。通常被固定的类型是错误类型。例如,我们之前的解析整数的例子可以这样重写:

// result-num-no-unwrap-map-alias
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<T>。通常情况下,写作 io::Result<T>,这清楚地表明你使用的是 io 模块的类型别名,而不是 std::result 中的简单定义。(这种惯用方法也适用于 fmt::Result。)

一个简短的插曲:unwrap 并不是坏事(A brief interlude: unwrapping isn’t evil)

如果你跟上我了,你可能已经注意到,我对像 unwrap 这样的调用采取了相当严格的措施,因为它可能会导致错误并中止您的程序。一般来说,这样做更严谨。

然而,unwrap 仍然可以明智地使用。使用 unwrap 的确切理由不是那么容易说清楚,理性的人可能不同意。我将总结一下我对这个问题的一些看法。

  • 在示例和比较随意的代码中。有时候你需要编写示例或者一个简单的程序,错误处理并不重要,在这种情况下,打破 unwrap 的便利性是很困难的,所以很有吸引力
  • panic 时表明程序中有错误。代码的变量应该能够防止某种情况发生(比如从空堆栈中弹出)时,就可以允许恐慌。这是因为它暴露了程序中的 bug。这很明确,比如 assert! 时发生失败, 也可能是因为数组的索引超出了边界。

这可能是一个不全的列举。此外,在使用 Option 时,最好使用其 expect 方法。expectunwrap 的功能完全一样,只是它会打印一条你想要的消息。这使在发生 panic 时更容易处理,因为它将显示你的信息而不是 “called unwrap on a None value.”。

我的建议可以归结为: 做正确的判断。这就是为什么“永远不要选择 x” 或者“选择 y 是有害的”这样的观点没有出现在我的文章中的原因。所有的事情都需要权衡,作为程序员的你应该决定什么是根据你的场景决定的。我的目标只是帮助你尽可能准确地评估和权衡。

现在我们已经介绍了 Rust 中错误处理的基本知识,并且已经介绍了关于 unwrap 的部分,让我们开始探索更多标准库的相关内容。

处理多种错误类型(Working with multiple error types)

到目前为止,我们已经研究了错误处理,其中包含 Option<T>Result<T, SomeError> 。但是当你同时拥有 Option 和一个 Result 时会发生什么呢?或者如果你有一个 Result<T, Error1> 和一个 Result<T, Error2> 怎么办?处理不同错误类型如何将它们组合是摆在我们面前的下一个挑战,也是本文余下部分的主要内容。

组合选项和结果(Composing Option and Result)

到目前为止,我已经讨论了为 Option 定义的组合子和为 Result 定义的组合子。我们可以使用这些组合子来组合不同计算的结果,而不需要进行显式的 case analysis。

当然,在真正的代码中,事情并不总是那么简单。有时你有一个 Option 和 Result 类型的混合。我们进行明确的 case analysis,还是继续使用组合子?

现在,让我们回顾一下这篇文章中的第一个例子:

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 和它们的各种组合子的知识,我们应该尝试重写它,以便正确地处理错误,并且如果出现错误,程序不会 panic。

这里比较棘手的一点是,argv.nth (1) 生成一个 Option,而 arg.parse() 返回 Result。这些不是直接组合的。当同时面对 OptionResult 时,解决方案通常是将 Option 转换为 Result。在我们的示例中,缺少命令行参数(来自 env::args())意味着用户没有正确地调用程序。我们可以用一个 String 来描述这个错误。我们试试:

// error-double-string
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()))
        .map(|i| i * 2)
}

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
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>,所以我们还必须将 ParseIntErrorarg.parse() 转换为 String

组合子的局限性(The limits of combinators)

执行 IO 和解析输入是一项非常常见的任务,这也是我个人在 Rust 中经常做的事情。因此,我们将使用(并继续使用)IO 和各种解析例程来举例说明错误处理。

我们从简单的开始。首先是打开一个文件,读取其所有的内容,并将其内容转换为一个数字。然后乘以 2,输出结果。

尽管我已经尝试说服你不要使用 unwrap,但是首先使用 unwrap 编写代码还是很有用的(方便)。它可以让你能够专注于你的问题,而不是错误处理,并且它公开了错误处理需要在哪儿进行。从那里开始,我们可以知道要处理的代码,然后可以更好地重构错误处理。

// io-basic-unwrap
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);
}

(注意: 使用 AsRef<Path> 是因为它与 std::fs::File::open 的 bound(约束)相同。这使得使用任何类型的字符串作为文件路径成为可能。)

这里可能会出现三种不同的错误:

  • 打开文件有问题
  • 从文件中读取数据有问题
  • 将字符串解析为数字有问题

前两个问题通过 std::io::Error 类型描述。我们知道这一点是因为理解了 std::fs::File::openstd::io::Read::read_to_string 的返回类型。(请注意,它们都使用前面描述的 Result 类型别名惯用法。如果跟踪查看 Result 类型,您将看到类型别名定义,从而看到底层 io::Error type 类型。)第三个问题由 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
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 用于连接多个计算,其中每次计算都可能返回一个 error。在打开文件之后,还有两个计算可能失败: 从文件中读取和将内容解析为一个数字。相应地,有两个 and_then 调用。

map 的作用是将闭包函数应用于 ResultOk (...) 值。例如,map 的最后一个调用将 Ok (...) 值(即 i32)乘以 2。如果在这之前发生错误,则会跳过此操作,因为 map 是这样定义的。

map_err 是使所有这一切正常执行的诀窍。map_errmap 类似,只是它将闭包函数应用于 ResultErr (...) 值。在这种情况下,我们希望将所有错误转换为一种类型: String。由于 io::Errornum::ParseIntError 都实现了 ToString trait,所以我们可以调用 to_string() 方法来进行转换。

尽管如此,代码仍然是很粗糙。掌握使用组合子很重要,但它们也有局限性。让我们尝试一种不同的方法: 提前返回(early returns)。

提前返回(Early returns)

我打算使用上一节中的代码,并使用提前返回的方式重写它。提前返回可以让你提前退出函数。我们不能在 file_double 中的某个闭包提前返回值,所以我们需要回到显式的 case analysis。

//io-basic-error-string-early-return
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 的显式 case analysis。如果出现错误,它只是停止执行该函数并返回错误(再将其转换为字符串)。

这难道不是一种退化吗?之前,我说过符合人体工程学的错误处理的关键是减少明确的 case analysis,然而我们在这里增加了 case analysis。事实证明,有多种方法可以减少显式的case analysis。选择组合子并不是唯一的方法。

try! 宏 / ? 操作符

在 Rust 的旧版本(Rust 1.12 或更老版本)中,Rust 中错误处理的基石是 try! 宏。try! 宏抽象了 case analysis 就像组合子一样,但与组合子不同,它也抽象控制流。也就是说,它可以抽象出上面看到的提前返回模式。

这里有一个 try! 宏的简化定义:

// try-def-simple
macro_rules! try {
    ($e:expr) => (match $e {
        Ok(val) => val,
        Err(err) => return Err(err),
    });
}

(正式的定义会稍微复杂一些,我们稍后再讨论。)

使用 try! 宏使我们很容易简化我们的最后一个例子。因为它做了 case analysis 和提前返回,我们得到了更紧密的代码,更容易阅读:

// io-basic-error-try
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),
    }
}

鉴于我们对 try! 的定义,map_err 调用仍然是必要的。这是因为仍然需要将错误类型转换为 String。好消息是,我们很快就会知道如何去掉这些 map_err 调用!坏消息是,在删除 map_err 调用之前,我们需要更多地了解标准库中的一些重要 trait。

在 Rust 的新版本(Rust 1.13 或更新版本),try! 宏被替换为 ? 操作符。虽然它的目的是增加新的能力,我们不会在这里讨论,使用 ? 而不是 try! 很简单:

// io-basic-error-question
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),
    }
}

自定义错误类型(Defining your own error type)

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

像我们在前面的例子中那样使用 String 是很方便的,因为很容易将错误转换为字符串,甚至可以直接将你自己的错误作为字符串。但是,使用 String 来处理错误有一些缺点。

第一个缺点是错误消息容易使代码混乱。很可能也在其他地方定义错误消息,但是除非你非常严格,否则将错误消息嵌入到代码中是非常容易做到的。实际上,我们在前一个例子中就是这样做的。

第二个缺点是 String 是有损耗的。也就是说,如果所有错误都转换为 String,那么我们传递给调用方的错误就完全不透明了。对于 String 错误,调用者能够做的唯一合理的事情就是向用户显示那个字符串。当然,检查字符串来确定错误类型是不健壮的。(不可否认,这个缺点在库中比在应用程序中更为重要。)

例如,io::Error 类型中嵌入一个 io::ErrorKind,这是一个结构化数据,表示 IO 操作期间出错的地方。这很重要,因为你可能希望根据错误做出不同的响应。(例如,BrokenPipe 错误可能意味着优雅地退出程序,而 NotFound 错误可能意味着退出时带有错误代码并向用户显示错误。) 使用 io::ErrorKind,调用者可以通过 case analysis 检查错误的类型,这比试图梳理出 String 内部错误的细节要优雅得多。

我们可以定义自己的错误类型,用结构化数据表示错误,而不是像上面所说的从文件读取整数的示例中使用 String 作为错误类型。我们尽量不要在潜在的错误中丢弃信息,以防调用者想要检查错误细节。

表示许多可能性之一的理想方法是使用枚举定义我们自己的聚合类型。在我们的例子中,要么是 io::Error 错误,要么是 num::ParseIntError 错误,所以下面的定义就更自然了:

// io-basic-error-custom I-basic-error-custom
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 I-basic-error-custom
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 作为错误类型会导致调用者丢失一些选择性,而使用 CliError 这样的自定义 enum 错误类型会像以前一样为调用者提供所有便利,包括描述错误的结构化数据。

A rule of thumb is to define your own error type, but a String error type will do in a pinch, particularly if you’re writing an application. If you’re writing a library, defining your own error type should be strongly preferred so that you don’t remove choices from the caller unnecessarily.

一个经验法则是定义自己的错误类型,但必要时使用 String 错误类型也可以,特别是在编写应用程序时。如果你正在编写一个库,那么最好定义你自己的错误类型,这样你就不必要地从调用方去除选项。

用于错误处理的标准库特性(Standard library traits used for error handling)

标准库定义了两个用于错误处理的 trait: std::error::Errorstd::convert::From。虽然 Error 是专门为一般性描述错误而设计的,但 From trait 在两个不同类型之间转换值时起着很大的作用。

Error trait(The Error trait)

标准库中定义了 Error trait:

// error-def
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(impl Error),这意味着错误可以作为一个 trait 对象而存在。可以表示为 Box<Error> 或者 &Error。事实上,cause 方法返回 &Error,它本身就是 trait 对象。稍后我们将重新讨论作为 trait 对象的 Error trait 实用例子。

现在,展示一个实现 Error trait 的示例就足够了。我们可以使用我们在前一节中定义的错误类型:

// error-impl
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 错误或将字符串转换为数字的错误。通过向枚举定义中添加新的变体,该错误可以表示任意多的错误类型。

实现 Error 是相当直接的,它主要是很明确 case analysis。

// error-impl
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 实现: 匹配不同的错误类型,并满足为“描述”和“原因”定义的约束。

From trait(The From trait)

标准库中定义了 std::convert::From:

// from-def
trait From<T> {
    fn from(T) -> Self;
}

很简单,是吗?From 非常有用,因为它提供了一种通用的方式来讨论从特定类型 T 到其他类型的转换(在本例中,“其他类型”是 impl 或 Self 的主体)。并且 From 的关键是由标准库提供的一组实现

这里有一些简单的例子来演示 From 是如何工作的:

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

好了,From 对于字符串之间的转换很有用。但是用其怎样进行错误处理呢?事实证明,有一个关键的 impl:

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

这个 impl 说明,对于任何实现 Error 的类型,我们可以将它转换为 trait 对象 Box<Error>。这可能看起来并不十分有用,但是在泛型上下文中是有用的。

还记得我们之前处理的两个错误吗?也就是 io::Errornum::ParseIntError。因为两者都实现了 Error,所以它们可以利用 From trait:

// from-examples-errors
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);

这里有一个非常重要的模式需要了解。err1 和 err2 具有相同的类型。这是因为他们是存在的量化类型(quantified types),或 trait 对象。特别是,它们的底层类型从编译器的信息中消除了,因此对编译器来说,真正看到的 err1 和 err2 是完全相同的。此外,我们使用完全相同的函数调用: From::from 构造 err1 和 err2。这是因为 From::From 在其参数和返回类型上都重载了。

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

是时候重访一位老朋友了;try! 宏 / ? 操作符。

真实的 try! 宏/? 操作符(The real try! macro/? operator)

之前,我给出了 try! 的定义:

macro_rules! try {
    ($e:expr) => (match $e {
        Ok(val) => val,
        Err(err) => return Err(err),
    });
}

这不是它的真实定义,它在标准库中的真实定义如下:

// try-def
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
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! ,我们将在本文的其余部分转为使用 ?

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

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 有一个方法,让我们将任何错误类型转换为 Box<Error>:

// io-basic-error-try-from I-basic-error-try-from
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)
}

我们已经非常接近理想的错误处理。对于错误处理,我们代码的开销很小,因为 ? 操作符同时包含三种东西:

  • 案例分析(Case analysis)
  • 控制流(Control flow. )
  • 错误类型转换(Error type conversion)

当这三者结合在一起时,我们写出的代码不会受组合子、unwrap 调用或 case analysis 种的某一个所局限。

还有一个小问题:Box<Error> 类型是不透明的。如果我们向调用者返回一个 Box<Error> ,调用者就不能(容易地)检查底层错误类型。但这种情况肯定比 String 好,因为调用者可以调用像 descriptioncause 这样的方法,但是限制仍然存在:Box<Error> 是不透明的。(注意: 这并不完全正确,因为 Rust 确实有运行时反射,这在某些超出本文描述的其他场景中也许非常有用。)

是时候重新检查自定义 CliError 类型并将所有内容绑定在一起了。

组合自定义错误类型(Composing custom error types)

在最后一部分,我们看到的是真实的 ? 运算符,以及它如何通过从错误值上调用 From::from 来为我们进行自动类型转换。特别地,我们将错误转换为 Box<Error> ,它可以工作,但是对于调用方来说,类型是不透明的。

为了解决这个问题,我们使用了我们已经熟悉的补救方法: 自定义错误类型。同样,下面是读取文件内容并将其转换为整数的代码:

// io-basic-error-custom-from I-basic-error-custom-from
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 trait。问题是,没有 From impl 允许我们从 io::Errornum::ParseIntError 错误类型转换为我们的自定义 CliError 类型。当然,解决这个问题很容易!因为我们定义了 CliError,所以我们可以使用它来实现:

// io-basic-error-custom-from I-basic-error-custom-from
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)
    }
}

所有这些 impl 都是告诉我们如何通过 From 从其他错误类型中创建一个 CliError。在我们的例子中,构造器就像调用相应的值构造函数一样简单。事实上,它通常就是这么简单。

我们终于可以重写 file_double

// io-basic-error-custom-from I-basic-error-custom-from
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 impl。

如果我们修改了 file_double 函数来执行其他操作,比如说,将一个字符串转换成一个浮点数,那么我们就需要给我们的错误类型添加一个新的变体:

enum CliError {
    Io(io::Error),
    ParseInt(num::ParseIntError),
    ParseFloat(num::ParseFloatError),
}

为了反映这个变化,我们需要为 CliError 更新前面的 impl From<num::ParseIntError> ,并添加新的 impl From<num::ParseFloatError>

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)
    }
}

就是这样!

给库开发者的建议(Advice for library writers)

Rust 库的习惯用法仍在发展中,但是如果你的库需要报告自定义错误,那么您可能应该定义自己的错误类型。是公开它的表示形式(如 ErrorKind)还是保持隐藏(如 ParseIntError) ,这取决于你自己。无论如何操作,通常最好至少提供一些关于错误的信息,而不仅仅是它的 String 表示形式。但是毫无疑问,这取决于你的使用场景。

至少,您可能应该实现 Error trait。这将为库的用户提供编写错误的最小灵活性。实现 Error trait 还意味着保证用户能够获得错误的字符串表示形式(因为它同时要求实现 fmt::Debugfmt::Display trait)。

除此之外,在错误类型上提供 From 的实现也很有用。这允许你(库作者)和你的用户编写更详细的错误。例如,csv::Errorio::Errorbyteorder::Error 提供了 From 实现。

最后,根据您的喜好,您可能还需要定义 Result 类型别名,特别是如果您的库定义了单个错误类型。这种方式被使用在标准库中的 io::Resultfmt::Result

案例研究: 读取人口数据的程序(Case study: A program to read population data)

这篇文章很长,根据你的情况,它可能相当多。虽然这篇文章中有大量的示例代码,但大部分都设计得有针对性。虽然我没有足够的智慧来制作专业的教学范例,但我可以写一个案例研究。

为此,我想构建一个命令行程序,可以用于查询世界人口数据。目标很简单: 你给它一个地点,它会告诉你人口。尽管很简单,但还是有很多地方可能出错!

我们将使用的数据来自数据科学工具包(Data Science Toolkit)。我已经为这个练习准备了一些数据。您可以获取世界人口数据(41 MB gzip 压缩,145 MB 未压缩),或者只获取美国人口数据(2.2 MB gzip 压缩,7.2 MB 未压缩)。

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

在 Github 上(It’s on Github)

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

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

我们将逐步构建这个项目。请继续阅读并跟我一起写代码!

初始设置(Initial setup)

我不打算花很多时间在 Cargo 上建立一个项目,因为它已经在 Rust BookCargo 的文档中说明了。

从零开始,运行 cargo new --bin city-pop,并确保你的 Cargo.toml 看起来像这样:

[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.*"

你应该已经能够运行:

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

参数解析(Argument parsing)

让我们来解析一下参数。关于 Docopt 我不会谈论太多细节,但是有一个很好的网页描述了它和关于 Rust crate 的文档。简而言之,Docopt 根据使用字符串生成一个参数解析器。解析完成后,我们可以将程序参数解码为 Rust 结构体。下面是我们的程序,其中包含适当的 extern crate 语句、字符串用法、Args struct 和一个空的 main

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::new 创建一个新的解析器,然后用 Docopt::decode 将当前的程序参数解码为一个结构体。问题是这两个函数都可以返回一个 docopt::Error。我们可以从显式的 case analysis 开始:

// 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
macro_rules! fatal {
    ($($tt:tt)*) => {{
        use std::io::Write;
        writeln!(&mut ::std::io::stderr(), $($tt)*).unwrap();
        ::std::process::exit(1)
    }}
}

如果使用 unwrap 在这里可能没有问题,因为如果它失败了,这意味着您的程序无法写入 stderr。一个很好的经验法则是中止,当然,如果需要的话,你可以做其他的事情。

代码看起来好点了,但是显式的 case analysis 仍然是累赘:

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 方法,它实际上完成了我们刚才所做的工作。结合我们对组合子的了解,我们就有了简洁易读的代码:

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

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

编写逻辑(Writing the logic)

我们编写代码的方式各不相同,但当我不知道如何编写时,错误处理通常是我最不愿考虑的事情。这对于优秀的设计来说并不是一个很好的实践,但是对于快速的原型设计来说却是很有用的。在我们的例子中,由于 Rust 迫使我们明确错误处理,它也将使我们的程序中一些可能导致错误的地方更加凸显。为什么?因为 Rust 会让我们使用 unwrap!这可以让我们对如何处理错误处理有一个很好的参考视图。

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

// 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 的三个地方:

还有其他的吗?如果我们找不到一个匹配的城市呢?像 grep 这样的工具会返回错误代码,所以我们也可以这样做。因此,我们要针对我们的问题进行逻辑错误判断,IO 错误和 CSV 解析错误。我们将探索两种不同的方法来处理这些错误。

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

Box<Error> 处理错误(Error handling with Box

Box<Error> 很好,因为它正好有效。你不需要定义自己的错误类型,也不需要任何 From 实现。缺点是,因为 Box<Error> 是 trait 对象,所以它会擦除类型,这意味着编译器不能再推断它的底层类型。

之前,我们重构代码,将函数的类型从 T 改为 Result<T, OurErrorType>。在这种情况下,OurErrorType 只是 Box<Error>。但 T 是什么?我们可以给 main 添加一个返回类型吗?

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

让我们将重构函数逻辑,但是保留 unwrap 的调用。请注意,我们选择通过忽略该行来处理缺失人口计数的情况。

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 的另一个更好的变体),但是我们仍然应该处理没有任何搜索结果的情况。

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

  • 更改 search 的返回类型为 Result<Vec<PopulationCount>, Box<Error>>.
  • 使用 ? 操作符,以便将错误返回给调用方,而不是进行 panic
  • main 中处理错误

我们试试:

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)
    }
}

Instead of x.unwrap(), we now have x?. Since our function returns a Result<T, E>, the ? operator will return early from the function if an error occurs.

我们现在不用 x.unwrap(),而是用 x?。由于我们的函数返回 Result<T, E>,那么 ? 如果出现错误,操作符将从函数中提前返回。

这段代码中有一个很大的问题: 我们使用了 Box<Error + Send + Sync> 而非 Box<Error>。我们这样做是为了将普通字符串转换为错误类型。我们需要这些额外的 bound,这样我们就可以使用相应的 From 实现

// 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 中读取(Reading from stdin)

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

添加对 stdin 的支持实际上非常简单。有两点需要考虑:

  • 调整程序参数,以便在从 stdin 读取参数时可以接受单个参数 city
  • 修改 search 函数,入参改为 optional 可选的文件路径。如果是 None,它应该知道读从 stdin 读取参数。

首先,下面是新的用法和 Args 结构:

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 参数可选,并使相应的结构成员 arg_data_path 可选。剩下的就交给 docopt crate 了。

修改 search 要稍微棘手一些。csv crate 可以用任何实现了 io::Read 的类型构建一个解析器。但是我们如何在这两种类型上使用相同的代码呢?实际上我们有几种方法可以做到这一点。一种方法是编写 search,使其在某个类型参数 R 上是泛型的,满足 io::Read 约束。另一种方法是直接使用 trait 对象:

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!
}

使用自定义类型进行错误处理(Error handling with a custom type)

以前,我们学习了如何使用自定义错误类型处理错误。为此,我们将错误类型定义为枚举,并为其实现 ErrorFrom trait。

由于我们有三个不同的错误(IO、 CSV 解析和 not found),我们定义一个带有三个变体的枚举:

enum CliError {
    Io(io::Error),
    Csv(csv::Error),
    NotFound,
}

现在来看看 DisplayError 的实现:

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 trait 实现。我们如何知道应该提供哪些信息?好吧,我们需要将 io::Errorcsv::Error 转换为 CliError。这些是唯一的外部错误,所以我们现在只需要两个 From 实现:

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 impl 之后,我们只需要对 search 函数进行两个小的调整:返回类型和“not found”错误处理。以下是调整代码:

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)
    }
}

无需其他更改。

添加功能(Adding functionality)

如果你像我一样,编写通用代码感觉很好,因为通用化的东西很酷!但是有时候,这种果汁并不值得去“榨取”。看看我们在前一步做了什么:

  • 定义新的错误类型
  • 增加 Error,Display 和两个 From trait 实现

更为致命的是我们的程序并没有提高很多。我个人很喜欢它,因为我喜欢使用枚举表示错误,但是这样做会有相当大的开销,特别是在像这样的小程序中。

使用自定义错误类型(就像我们在这里所做的)的一个有用的方面是,main 函数现在可以选择以不同的方式处理错误。在此之前,由于 Box<Error> 没有太多选择: 只能打印消息。我们现在仍然在这样做,但是如果我们想要,比如说,添加一个 --quiet 标志呢?--quiet 标志应该使任何冗长的输出保持静默。

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

让我们从添加参数标志开始。与前面一样,我们需要调整使用字符串并向 Args 结构添加一个标志。剩下的就交给 docopt crate 了:

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”功能,这需要我们对 case analysis 进行调整:

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 错误或数据无法解析,我们不希望保持安静。因此,我们使用 case analysis 来检查错误类型是否为 NotFound,以及是否启用了 --quiet。如果搜索失败,我们仍然使用退出代码退出(遵循 grep 的约定)。

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

这几乎总结了我们的案例研究。从这里开始,你应该准备好进入这个新世界了,在自己的程序和库中使用适当的错误处理。

总结(The short story)

因为这篇文章很长,所以对 Rust 中的错误处理进行一个快速的总结是很有用的。这些是我的“经验法则”,却不是绝对有效的。也可能有更好的方式打破这些总结!

  • 如果您正在编写简短的示例代码,错误处理可能会加重这些代码的负担,那么使用 unwrap 能没有问题(不管是不是 Result::unwrapOption::unwrap 或是更好的 Option::expect)。代码的使用者应该知道使用适当的错误处理。(如果他们不知道,请将他们推荐到这里!)
  • 如果你正快速地在写一个 n 脏的程序(‘n’ dirty program),不要害怕,如果你使用 unwrap。请注意: 如果它最终落入了别人的手中,如果他们被可怜的错误消息激怒了,不要感到惊讶
  • 如果你正在编写一个快速的 n 脏的程序,并且对 panic 感到羞愧,那么你可能应该使用上面示例中的 Box<Error>(或 Box<Error + Send + Sync>)。另一个不错的选择是 anyhow crate 及其 anyhow::Error 类型。当使用 anyhow 时, 如果是 nightly Rust, 你的错误将自动回溯。
  • 此外,在程序中,使用适当的 From 及 Error impl 使 ? 操作符宏更加符合人体工程学。
  • 如果您正在编写一个库,并且您的代码可能会产生错误,那么定义您自己的错误类型并实现 std::error::Error trait。在适当的情况下,实现 From 使你的库代码和调用者的代码更容易编写。(由于 Rust 的一致性规则,调用者将无法在你的错误类型上实现 From trait,所以你的库应该这样做。)
  • 学习在 Option 及 Result上定义的组合子。只是单纯的使用它们有时可能有点乏味,但我个人发现了一个健康的组合结合 ? 运算符和组合子是非常有吸引力的。and_then map andunwrap_or 等都是我喜欢用的。

Tips

posted @ 2022-02-05 17:21  suhanyujie  阅读(649)  评论(0编辑  收藏  举报