Rust 中的错误处理

就像大多数编程语言一样,Rust 让程序员使用一种特定方式来处理错误。一般来说,错误处理分为两类途径:异常和返回值。Rust 使用的是返回值。

在本文,我打算为 Rust 中如何处理错误做一个全面论述。不仅如此,我会尽量每次只引入一种错误处理方式,那么在如何将每一种组合到一起的时候,你就可以有一个坚实的实践知识基础。

如果使用最幼稚的方法,Rust 中的错误处理可以是冗长且恼人的。本文会探索那些绊脚石,并且示范如何使用标准库来让错误处理更加简洁且符合习惯。

目标受众:那些不熟悉Rust错误处理惯用法的Rust新手。对Rust有一定了解是很有帮助的。(本文大量使用了一些标准特性和一些非常少量的闭包和宏。)

更新(2018/04/14):示例已转换为?,并添加了一些文本以提供有关改动的历史背景。

简要说明

本文中的所有代码示例都使用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 是一个系统级编程语言并且有一个具有丰富表达能力的类型系统(它的目标是C/C++的替代品)。如果你刚刚接触 Rust 语言,建议你从头一步一步读完这篇文章。如果你还完全没有听说过 Rust,建议百度或读官方文档 Rust book

如果你之前没有用过 Rust 但是有一些函数式编程语言经验(比如对"代数数据类型"、"连接器"等概念比较熟悉),那么你可以跳过基础部分,直接从“复合错误类型”开始,简单熟悉一下然后通读标准库的"Error Traits"(错误特性,对错误的一种约束,类似接口)。你也可能需要查询 Rust Book 以了解 Rust 的闭包和宏的概念(Rust Book 是官方学习 Rust 的经典资源)。

如果你对 Rust 非常熟悉了解,只想学习一下如何进行错误处理,你可以直接跳到最后面看那些练习题

基础

我将错误处理看做是使用“值分配”来决定一个计算的成功与否。就像我们即将看到的,人工错误处理的关键就是减少大量的显式实例分解,这些是在保留代码可组合性的同时程序不得不做的。

保留代码可组合性是很重要的,因为如果没有这个条件,不管什么时候我们遇到意外都会遇上 panic。(panic 会导致当前任务的放弃,而且在大多数情况下,整个程序都会终止。)例子如下:

panic-simple

// 从1到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

另一个更实际的例子。程序接受一个整型数作为参数,将其乘以 2 并打印输出。

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

如果你给程序一个 0 作为参数(error 1)或者如果第一个参数不是一个整形数(error 2),这个程序就像第一个例子一样处于 panic 的状态。

我更倾向于将这种错误处理方式看做公牛穿过瓷器店。公牛将会到它想去的地方,但是它将践踏途径的一切。

复合类型展开(Unwrapping)的解释

在之前的例子中(unwrap-double),我声明了如果达成了两个错误中的一个,程序将会出现简单的panic,程序不像第一个例子(panic-simple)那样直接调用 panic。因为 panic 嵌入在 unwrap 的调用中了。

在 Rust 中“unwrap”是指,“给我计算的结果,如果有错,就 painc 并且停止程序。”如果我只是展示代码的解包会更好,因为它非常简单,但是那样做的话,我们首先需要了解 Option 和 Result 类型。这些类型都有一个叫做 unwrap 的方法。

Option类型

Option类型是在标准库中定义的:

option-def

enum Option<T> {
    None,
    Some(T),
}

Option类型是一种使用Rust的类型系统来表达“无”的可能性的方法。将“无”的可能性编入类型系统是一个重要的概念,因为它会导致编译器强制程序员处理“无”的情况。我们来看看一个试图在字符串中查找字符的例子:

option-ex-string-find

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

(专业提示:不要使用此代码,应该使用标准库中的 find 方法)

注意:当这个函数找到一个匹配的字符时,它返回的不是一个 offset,相反,它会返回 Some(offset)。Some 是一个 Option 类型的变体或者数值构造函数。你可以认为它是一个函数,类型是 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 方法将案例分析抽象出来。这正是使人们易于使用 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 proceding 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 中做的直白的案例统计,每次都会让人感觉厌烦。

事实上,在 extension_explicit 中的案例统计遵循一个非常通用的模式:给 Option<T> 内的一个值 map 一个方法,除非 option 是 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 方法以去除"值匹配"部分:

option-ex-string-find

// 返回给定文件名的扩展名, 就是'.'后的所有字符.
// 如果'file_name'没有'.', 就返回'None'.
fn extension(file_name: &str) -> Option<&str> {
    find(file_name, '.').map(|i| &file_name[i+1..])
}

另一种比较常用的模式是:当 Option 值为 None 时就赋一个默认值。例如,当值为 None 时,你的程序假设扩展名为'rs'。如你所想,这种“值匹配”不仅仅适用于文件扩展名 —— 它适用于任何 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。它可以很容易地组合不同的计算,并容许空值。例如,这段中的大多数代码是查找给定文件名的扩展名。为了达到目的,你首先需要从文件路径中提取有效文件名,可是并不是所有的文件路径都包含一个有效的文件名,像 ... 或 /等路径名就没有文件名。

接下来,让我们一起面对挑战:如何从给定文件路径中找到文件扩展名。 我们从显式的值匹配开始:

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 连接器(或组合器)以减少"值匹配"导致的代码冗余,可是,它们的类型不匹配。换句话说,传入 map 的函数只能对 Option 内部值做一些运算,而这个函数的计算结果会被 Some 重新包装。相反,我们需要 map 类似的功能,但允许调用者返回另一个 Option 类型(就是可以将 Option<T> 转换为 Option<A> 的一个函数)。它的泛型实现比 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 连接器(或组合器)。你最好浏览一下这个列表并熟悉它们,以方便以后你处理“值匹配”的问题,减少代码冗余。熟悉它们还会获得额外的好处,因为 Result 也实现了它们中的大部分(语法类似),这些后面我们会讲到。

连接器(或组合器)使我们使用像 Option 类似的类型时变得方便,因为它们减少了显式的“值匹配”问题。它们也是可以相互组合的,因为它们允许调用者用自己的方法来处理空值问题。像 unwrap 等方法就移除了这种选择性,如果 Option<T> 为 None,就直接终止程序了。

Result 类型

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

result-def

enum Result<T, E> {
    Ok(T),
    Err(E),
}

Result 类型是 Option 类型的升级版。它除了可以像 Option 类型一样表示空值外,还能表示错误类型。通常,错误被用来解释为什么一些运算失败了。严格来说, 这是 Option 类型的一种更通用形式的表达。看看下面这个类型别名,它语义上就等于 Option<T>:

option-as-result

type Option<T> = Result<T, ()>;

这里将 Result 类型的第二个参数修复为 '()' (读作 "单元" 或 "空元组",其实就是空类型)。准确的说就是一个 '()' 值填充了这个 '()' 类型。(是的,这里类型和值共享了同样的符号表示!)

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 约束才行(E 类型代表我们的错误类型)。因为大多数主要的类型都满足 Debug 约束,所以实际中不会有太大问题。(一个类型如果有Debug 约束, 意味着在调试时,这个类型可以被打印为可读性更好的形式。)

好的,接下来我们举一个例子。

解析整型数

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 类型是比较合适的,因为我们想提供比简单的“空值”更多的信息,告诉调用者为什么会解析失败。当你面临 Option 或 Result 两难选择时最好也进行类似的思考,如果能提供更详细的信息,那就提供吧(后面你将看到更多这种情况)。

好的,但我们如何编写我们的返回类型呢?上面定义的parse方法对标准库中定义的所有不同的数字类型是通用的。我们可以(也可能应该)也使得我们的函数变得通用化,但是让我们暂时选择明确性。我们只关心 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),
    }
}

比较好一点了,但现在我们又写了大量的代码!“值匹配”问题再一次困扰了我们。

连接器(或组合器)是救世主!像 Option类型一样,Result 类型也定义了大量的连接器(或组合器)方法,而且它们之间还有大量的共同连接器,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),
    }
}

另外还有 unwrap_or 和 and_then 方法。此外,因为 Result 类型存在第二个参数(错误类型),一些连接器会仅仅影响错误类型, 比如 map_err(相对于 map)和 or_else(相对于 and_then)。

Result 类型别名惯用语法

在标准库中,你可能经常看到类似 Result<i32> 等单参数的 Result 类型,可是,Result 不是应该有两个类型参数吗?我们怎么能只提供一个参数就可以使用 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 中。通常,某人写一个 io::Result<T>,它清楚地表明你正在使用 io 模块的类型别名,而不是来自 std::result 的简单定义。(这个惯用法还用于 fmt::Result。)

一个简单的插曲:unwrapping 并不是恶魔

如果你一直在读到现在,你可能已经注意到,我已经采取了一种非常强硬的方式来避免调用可能导致程序 panic 和中断的 unwrap 方法。通常这是个很好的建议。

然而,在合适的场合 unwrap 也可以被得体地使用。但是判断什么时候可以使用 unwrap 却是模棱两可的,一些人并不同意这些看法。我将总结一下我对这件事的一些观点:

这肯定不是一个完整列表。此外,当使用 Option 类型时,最好用它的 expect 方法解出数据。expect 与 unwrap 一样,但它会额外的打印一段可以自定义的消息。这使程序终止时更人性化,它会输出你给定的消息而不是生硬地说"当 unwrap 时遇到 None 值"。

我的建议可以总结为一句话:做好判断。这就是为什么我的文章中从来不会出现“永远不要做 X”或“Y 被认为是有害的”的一个原因。你会遇到大量的事情需要做折衷和平衡,至于如何取舍由你决定,我只是帮助你尽可能精确地理解其中的利弊。

至此,我们已经讲了 Rust 中错误处理的基本知识,也讲到了错误展开(unwrapping)的一些知识,下面让我们开始一起探索标准库中更多的东西吧!

组合使用多种错误类型

到目前为止,我们已经学习了 Option<T> 和 Result<T, SomeError> 如何单独使用。但如果你同时需要 Option 和 Result 该怎么办?或者你同时有一个 Result<T, Error1> 和 Result<T, Error2> 类型呢(仅仅错误类型不同)?处理复合错误类型是我们将要面对的另一个挑战,它也是这篇文章剩余部分的重要议题。

组合 Option 和 Result

迄今为止,我们已经讲了Option 和 Result 中的连接器(或组合器),我们可以用这些连接器来组合不同运算的结果而无须做显式的”值匹配"(Case Analysis)。

当然,在实际中,事情不会总是那么简单,有时你需要混合使用 Option 和 Result 类型。那么我们是又要回到做显式“值匹配”的老路还是可以继续使用连接器呢?

现在,让我们重新回顾一下我们的第一个例子:

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

结合我们之前讲的 Option、Result 和它们的各种连接器,我们尝试重写这段代码使得错误可以被正确处理,并且如果发生错误程序也不会终止。

这里的难点是:argv.nth(1) 返回一个 Option 类型,而 arg.parse() 返回一个 Result 类型,它们不能直接组合。当同时遇到 Option 和 Result 时,常用的解决方案是将 Option 转换为 Result 类型。在我们的例子中,命令行参数的缺失意味着用户没有正确地执行程序,我们可以只用 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()))
}

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

在这个例子中出现了一些新东西。第一个就是 Option::ok_or 连接器,这是将 Option 转换为 Result 的一个方法,这个转换需要你说明当 Option 为 None 时,应该使用什么错误类型。像其它我们之前看到的连接器一样,它的定义非常简单:

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>,我们必须同时将来自 arg.parse() 的 ParseIntError 转换为 String。

连接器的限制

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 使用的相同的边界,这使得它可以使用任何类型的字符串作为文件路径。)

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

  1. 打开文件时出现问题。

  2. 从文件读取数据时出现问题。

  3. 将数据解析为数字时出现问题。

前两个问题可以用 std::io::Error 类型来表示,这是因为 std::fs::File::open 和 std::io::Read::read_to_string 的返回类型都是关于 IO 的(注意:它们都用我们之前讲的 Result 类型别名,点击 link 就可以看到别名定义,然后是底层的 io::Error 类型)。第三个问题可以用 std::num::ParseIntError 类型表示。特别地,io::Error 类型存在于标准库的各个地方,我们会一直看到它。

让我们开始重构 file_double 函数吧。为了使我们的函数可以与其它部分相接合,那么如果以上所说的错误产生了就不能粗暴地终止程序,这也就意味着如果操作失败,函数应该返回一个错误类型。我们的问题是现在 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>,我们必须重新寻找合适的连接器(或组合器)。在这个例子里,我们只用到了 3 个不同的连接器 and_then, map 和 map_err。

and_then 被用来连接多个运算,每个运算都可能返回一个错误。比如打开一个文件后,至少有 2 种可能的错误发生:读文件和将内容转换为数字。相应地,这个例子中有两处对 and_then 的调用。

map 被用来给 Result 的 Ok(...) 值施加一个函数应用(对 Ok(...) 中的值调用一个函数)。例如,例子中最后的 map 就是将 Ok(...) 值乘以 2(Ok(...) 的值是一个 i32 类型),如果在这之前出错了,这步操作就会根据 map 的定义被跳过。

map_err是让这些成功执行的诀窍。map_err就像是map,除了它对Result的Err(...) 值调用函数。在这个例子中,我们想将我们所有的错误都转换成一个类型:String。由于包括io::Error 和 num::ParseIntError都实现了ToString,因此我们可以调用to_string()函数来转换他们。
尽管如此,代码仍然很粗糙。掌握combinators很重要,但是他们也有他们的限制。我们试试另一种不同的方法:提前返回。

提前返回(Early returns)

我使用提前返回方法对前一部分中的代码重新实现。提前返回让你提前从函数中退出。我们不能在file_double函数中从另一个闭包里面提前返回。因此我们需要再次进行显式实例分解。

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

理智的人可能会不赞成这个代码比使用 combinator 的代码更好,但如果你不熟悉 combinator 方法,这段代码对我来说看起来更简单。它使用对 match 和 if let 的明确的案例分析。如果出现错误,它会停止执行该函数并返回错误(通过将其转换为字符串)。

这不是倒退吗?以前,我说人体工程学的错误处理的关键是减少显式的案例分析,但我们在这里已回退到显式的案例分析。事实证明,有多种方法可以减少显示的案例分析。 combinators 不是唯一的方法。

try! 宏/? 运算符

在较旧版本的 Rust(Rust 1.12 或更旧的版本)中,Rust 中错误处理的基石是 try! 宏。try! 宏像 combinators 一样抽象案例分析,但与 combinators 不同的是,它还抽象了控制流。也就是说,它可以抽象上面所看到的提前返回(early return)模式。

这里是一个简单的 try! 宏的定义:

try-def-simple

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

(真正的定义有些复杂。我们稍后再来处理)

使用 try! 宏定义使得简化我们最后一个例子非常简单。由于它为我们提供了案例分析和提前返回,所以我们得到更紧凑的代码以便阅读起来更加容易:

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

这里 map_err 调用仍旧需要用我们自定义的 try! 处理。这是因为错误类型仍旧需要被转换为 String。好消息是我们马上就会学习如何消除这些 map_err 调用。坏消息是在这之前我们必须学习一些标准库中重要的 traits (类似 Java 中的接口)。

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

定义你自己的错误类型

在我们深入了解一些标准库错误的特性之前,我想通过在前面的示例中删除使用字符串作为我们的错误类型来结束本节。

像我们在前面的例子中那样使用字符串是很方便的,因为这很容易将错误转换为字符串,甚至可以即时以字符串形式自己组装错误。但为你的错误使用 String 是有一些劣势的。

第一个劣势是错误消息会使你的代码变混乱。在其他地方定义错误消息是可能的,但除非你非常自律,否则将错误消息嵌入到你的代码中是非常吸引人的。事实上,我们在之前的示例中就是这样做的。

第二个也是更重要的缺点是字符串有损的。也就是说,如果所有错误都转换为字符串,那么我们传递给调用者的错误将变得完全不透明。调用者可以用字符串错误做的唯一合理的事情是向用户展示它。当然,这需要检查字符串的错误类型以确定是否健壮。(不可否认,这种缺点在库内部比在应用程序中更为重要。)

例如,io::Error 类型嵌入了一个 io::ErrorKind,它是表示 IO 操作期间出错的结构化数据。这很重要,因为您可能想根据错误采取不同的反应。(例如,BrokenPipe 错误可能意味着正常退出程序,而 NotFound 错误可能意味着退出并显示错误代码并向用户显示错误。)使用 io::ErrorKind,调用方可以通过“值匹配”检查错误的类型 ,这优于试图梳理字符串内部错误的细节。

在先前从文件中读取一个整型的例子中,我们不使用 String 作为一个错误类型,而是使用结构化数据定义我们自己的错误类型来代表错误。我们尽量避免上层调用查看错误的详细信息,我们在底层尽量不丢失任何错误信息。

在众多产生错误可能性之中代表一个错误的理想方式,就是使用枚举来定义我们的全部类型。在我们的例子中,一个错误不是 io::Error 就是 num::ParseIntError,因此一个定义自然就产生了:

io-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

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)。调用者可以决定要向用户报告的详细程度。实际上,使用字符串作为错误类型屏蔽了调用者类似的选择:在使用像 CliError 这样的自定义枚举错误类型时,除了为调用方提供之前提及所有的便利之外,还可以提供描述错误的结构化数据的便利。

一条经验法则就是定义你自己的错误类型,但是在紧急关头可以使用字符串错误类型,特别是当你正在编写一个应用程序的时候。如果你在编写一个库,强烈推荐定义你自己的错误类型,因此上层调用时就没有必要选择性删除错误细节。

用于错误处理的标准库 trait

标准库为错误处理定义了两类必要的 trait:std::error::Error 和 std::convert::From。其中 Error trait 是专为一般描述错误而设计的,From trait 则是普遍用于在两个不同类型之间转换值的时候。

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

Error trait 属于 super generic,因为它意味着要被所有代表错误的类型实现。当写组合代码的时候这个会很有用,就像我们即将看到的。除此之外,Error trait 至少还可以让你做下面的事情:

前两个是 Error 需要对 Debug 和 Display 的实现的结果。后两个是来自于两个在 Error 中定义的函数。所有的错误类型都要实现 Error,也就意味着错误可以作为一个 trait 对象被延伸量化,这证明了 Error 的能力。这明显就像 Box<Error> 或者 &Error。的确,cause 函数返回了一个 &Error,它自己就是一个 trait 对象。我们之后会将其作为一个 trait 对象来重新审视 Error trait 的用法。

现在,需要展示一个例子实现 Error 特点。让我们使用我们在前一节中定义的错误类型。

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 的实现非常简单。这主要是一个明确的案例分析。

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 的实现:匹配不同的错误类型并且满足了为 description 和 cause 方法定义的要求。

From 特性

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 特性(trait)在字符串之间的转换是非常方便的, 但如果将它用作错误类型之间的转换呢? 这里有一个非常关键的声明:

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

这个声明是说:对于任何实现了特性(trait) Error的类型, 我们都可以将它转换为一个 特性对象(trait object) Box<Error>. 这里可能乍看起来没有什么用, 但它在处理泛化关系时非常有用.

还记得我们之前处理的两个错误类型吗? 就是 io::Error 和 num::ParseIntError. 因为这两个都实现了Error特性, 所以他们也可以使用From进行转换:

from-examples-errors

use std::error::Error;
use std::fs;
use std::io;
use std::num;

// 我们故意制造一些错误
let io_err: io::Error = io::Error::last_os_error();
let parse_err: num::ParseIntError = "not a number".parse::<i32>().unwrap_err();

// 将不同的错误类型都统一到 Box<Error> 类型
let err1: Box<Error> = From::from(io_err);
let err2: Box<Error> = From::from(parse_err);

这里有一个非常重要的待识别的模式。err1 和 err2 都有相同的类型。这是因为它们是存在量化的类型或 trait 对象。特别是,它们的基本类型已从编译器的认知中删除,所以它确实将 err1 和 err2 视为完全相同的。另外,我们使用完全相同的函数调用 From::from 构造了 err1 和 err2。这是因为 From::from 的参数和返回类型都被重载了。

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

是时间重温一位老朋友了; try!宏/?运算符。

真正的try!宏/?运算符

以前,我展现的是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的类型。就像我们再前一部分看到的,From有一个实现,可以让我们将任何错误类型都转换为Box<Error>:

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

我们已经非常接近理想的错误处理。我们的代码由于错误处理所引入的开销已经很非常小了,是因为?运算符同时封装了三类事情:

  1. 值匹配
  2. 控制流

  3. 错误类型转换

当这三样东西都组合起来,我们就可以得到不受combinators影响的代码,来调用unwrap或者实例分解。
还有一个剩下的小问题:Box<Error>类型是不透明的。如果我们上层调用返回Box<Error>,上层调用不能(简单地)解析底层的错误类型。这种情况肯定比String要好,因为上层调用可以调用像descriptioncause之类的函数,但是限制仍然存在:Box<Error>是不透明的。(注:这并不是完全正确的,由于Rust有运行时反射机制,这在一些不在本文讲述范围的情况下很有用。)
现在开始重温我们的自定义CliError类型并将所有东西联系在一起。

整理自定义错误类型

在最后一部分,我们看一下真正的?运算符,以及它是如何对错误值调用From::from来进行自动类型转换的。特别地,我们将错误转换成Box<Error>,这是有用的,但是这个类型对上层调用来说是不可见的。
为了解决这个问题,我们使用已经熟悉的相同的改进方法:一个自定义错误类型。再来一次,这是读取文件内容并将其转换为整型的代码:

io-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 的定义。问题是不存在 From 的实现可支持我们将错误类型(如 io::Error 和 num::ParseIntError)转换为我们自定义的 CliError。当然,这很容易解决!既然我们定义了 CliError,我们可以用它配合 From 来实现它:

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

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

最后我们来重写file_double:
io-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,不再需要调用map_err。这是有效的,因为我们对所有可以表现出来的错误类型都提供了From实现。
如果我们修改file_double函数来实现其他的操作,或者说,将string转换为float,然后我们会需要在错误类型中添加一个新的变量:

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

还需要添加一个新的From实现:

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

这样就没问题了!

给库编写者的建议

Rust 库的惯用法仍在形成中,但如果你的库需要上报自定义错误,那么你应该定义自己的错误类型。是否公开(如 ErrorKind)或隐藏(如 ParseIntError)其存在性取决于你自己。不管你怎么做,通常至少要提供一些关于错误的信息,而不仅仅是它的字符串表示,这样做的话是一个好的实践。但当然,这取决于实际用例。

至少,你应该实现 Error 特性。这将为你的库的用户提供一些组建错误的最小灵活性。实现 Error trait 还意味着用户可以确保获得错误所对应的字符串的表示形式(因为它需要 fmt::Debug 和 fmt::Display 的实现)。

除此之外,在你的错误类型中提供对From的实现也是很有用的。这会让你(库的作者)和你的用户组合出可以获取更多细节的错误。例如,csv::Error提供了包括io::Error 和 byteorder::Error的From实现。
最终,根据你的爱好,你可能想定义一个Result类型的别名,特别是当你的库定义了一个单一错误类型。标准库中的io::Result fmt::Result也使用了这种方法。

案例分析:读取人口数据的程序

本文很长,而且与你的背景相关,它可能会比较晦涩。然而在你的练习过程中有大量的代码可以供你使用,大部分都是特别为了教学而设计的。然而我对精心设计非初级范例的教学范例并不是特别擅长,但是我还是可以写出一些案例分析的。

对于这个案例来说,我会创建一个命令行程序让你查询世界人口数据。目标很简单:你提供一个位置,它就会告诉你人口。尽管这很简单,还是有很多会出错的地方!
我们使用的数据来自于数据科学工具集。我已经为这次练习准备了一些数据。你也可以从这里获取世界人口数据(gzip压缩后41MB,未压缩154MB)或者只使用美国人口数据(gzip压缩后2.2MB,未压缩7.2M)。
目前为止,我已经将代码限制到Rust的标准库范围内。对于像这样一个真实的任务,我们无论如何都会想使用一些工具来解析CSV数据,解析程序参数并且自动将他们解码为Rust类型。对于这部分,我们会使用csvdocoptrustc-serialize工具包。

初始设置

我不会花费大量的时间来使用 Cargo 建立一个项目,因为它已经被 Rust 文档和 Cargo 文档所覆盖了。

要从头开始,运行 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!

参数解析

我们使用其他方法进行参数解析。关于Docopt我不会讲太多细节,但是这有一个不错的网页来介绍它,并且为Rust工具包提供了文档。简洁来说,Docopt从usage字符串产生一个参数解析器。一旦解析完成,我们可以将程序参数解析成一个Rust结构。下面是我们的程序,它包含适当的extern crate语句,usage字符串,我们的Args结构体和一个空的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.
";

#[derive(Debug, RustcDecodable)]
struct Args {
    arg_data_path: String,
    arg_city: String,
}

fn main() {

}

好的,开始编码了。Docopt的文档告诉我们可以使用Docopt::new创建一个新的解析器,然后使用Docopt::decode将当前程序参数解析成一个结构。关键就是这些程序都会返回docopt::Error。我们可以从显式实例分解开始:

// 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,然后exit:
fatal-def

macro_rules! fatal {
    ($($tt:tt)*) => {{
        use std::io::Write;
        writeln!(&mut ::std::io::stderr(), $($tt)*).unwrap();
        ::std::process::exit(1)
    }}
}

这里的unwrap很可能没问题,因为如果它失败了,也就意味着你的程序没有打印到stderr。一个好的经验法则就是:程序在这里终止是没有问题的,但是当然,如果你需要做什么事情的话你可以去做。
代码看起来更好了,但是显式实例分解仍然是个累赘:

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,这可以成功的完成我们刚才做的事情。将其与我们的combinators知识连接起来,我们就有简洁易读的代码:

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;。)

// 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.
#[derive(Debug, RustcDecodable)]
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 一次解析一调记录, 而且解析一条记录 (在循环的实现上查看关联类型的Item)会产生 csv::Error.


  3. 如果 row.population 是空的, 就会调用expect 导致panic.

还有其他的吗?如果我们不能找到匹配的城市怎么办?像grep一样的工具都会返回一个错误码,因此我们的程序也应该这么做。因此我们的错误有针对这个问题的逻辑错误,IO错误和CSV解析错误。我们准备使用两种不同的方法来处理这些错误。
我将以Box<Error>开始。之后,我们会看如何定义我们自己的错误类型才能是有用的。

用 Box<Error>进行错误处理

Box<Error> 非常好用。你不需要自定义错误类型,也不需要进行任何 From 特性的实现。缺点就是因为 Box<Error>是特性对象(trait object),它擦除了类型信息,这也就意味着编译器再也不能推断出它后面的真实数据类型了(trait object相当于C++/Java中的动态分派,同样使用vtable,Box<T>是一个胖指针,类型擦除后,隐藏在指针后的实际数据类型编译器就不能保证了,也就是Rust不能保证 Runtime 时的类型安全了,有可能会出错。编译时类型安全是 Rust 的一大卖点呀)。

之前重构我们的代码时,我们函数的返回类型从 T 改为 Result<T, OurErrorType>。这里,OurErrorType就是Box<Error>。可是,类型 T是什么呢?我们可以给 main 函数增加一个返回类型吗?

对于第二个问题的答案是:NO,不能。这也就意味着我们必须重新写一个函数。对于第一个问题的答案,我们能做的是返回一个可以匹配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的一个更好的变种),但我们仍旧需要处理任何搜索结果的空值问题。

为了使用正确的错误处理方式,我们需要做到以下几点:

1. 将 search 函数的返回类型改为 Result<Vec<PopulationCount>, Box<Error>>。

2. 使用 ? 操作符,使得错误会返回给调用者而不是终止程序。

3. 在 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)
    }
}

没有使用 x.unwrap(),我们现在有了 x?。由于我们的函数返回一个 Result<T, E>,所以如果发生错误,? 运算符将从函数中提前返回。

在这段代码中有一个很大的问题:我们使用 Box<Error + Send + Sync> 而不是 Box<Error>。我们这样做是为了将一个纯字符串转换为错误类型。我们需要这些额外的边界,以便我们可以使用相应的 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 进行读取

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

添加对 stdin 的支持实际上非常简单。我们只有两件事要做:

  1. 调整程序参数,使一个参数 —— the city 可以被接受,而人口数据则从 stdin 中读取。

  2. 修改搜索函数以获取可选的文件路径。如果没有,它应该知道从 stdin 中读取。

以下是新的”使用说明“和 Args 结构体:

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

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

#[derive(Debug, RustcDecodable)]
struct Args {
    arg_data_path: Option<String>,
    arg_city: String,
}

我们所做的就是将Docopt的”使用说明“字符串中的 data-path 类型改为可选类型(Option<String>),同时将对应的结构体中的 arg_data_path成员也改为可选类型,其余的交给 Docopt包来处理。

修改 search 函数需要一些技巧。csv包可以为实现了 io::Read 特性的任何类型提供一个解析器,但是我们如何使用同一套代码来服务于两个不同类型呢?有两种方法可以实现:一个是写一个支持泛化类型的 search,使得泛化参数 R 满足 io::Read;另一个就是使用特性对象(trait objects):

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 和 From 来实现这一点。

由于我们有三个不同的错误(IO,CSV解析和找不到),我们定义一个拥有三种变体的枚举:

#[derive(Debug)]
enum CliError {
    Io(io::Error),
    Csv(csv::Error),
    NotFound,
}

现在是 Display 和 Error 的实现:

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::Error 和 csv::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 后,我们只需要对 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)
    }
}

再也没有其它的需要改变了。

再增加一些功能

如果你像我一样,对泛型编程是如此的热爱,那么我告诉你,有时候它并不值得你如此付出!让我们看看我们之前所做的吧:

1. 定义了一个新的错误类型。

2. 增加了对 Error, Display 和 两个 From 特性的实现。

这里最大的缺点就是我们的程序没有因此提升太多。我个人喜欢它是因为我喜欢用 enum 类型表示错误,但是存在一定的开销,尤其是在比较短的程序里,就像我们写的这个一样。

不过,用自定义错误类型的一个好处是:现在在 main 函数中可以有更多的方式来处理错误了。之前用 Box<Error>时,我们没有任何选择,只能打印出错误信息。现在我们仍旧这样做,不过,如果我们愿意,也可以增加一个 --quiet 标志,它可以抑制大量冗余信息的输出。

现在,如果程序没有发现匹配值,它将输出错误信息。这样处理不是太灵活,尤其如果你打算将它用在 shell 脚本中的时候。

因此,让我们来增加一个标志吧。像之前一样,我们需要处理"使用说明"字符串并给 Args 结构体增加一个标志成员,其余的由 docopt包处理:

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.
";

#[derive(Debug, RustcDecodable)]
struct Args {
    arg_data_path: Option<String>,
    arg_city: String,
    flag_quiet: bool,
}

现在,我们只需要实现我们的 quiet 方法,这个需要我们修改main函数中的值匹配(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 错误或是数据解析错误,我们必然是无法淡定的。所以我们使用案例分析检查错误类型是否是 NotFound 以及是否启用了 --quiet 参数。如果搜索失败,我们将依照 grep 的惯例,以返回码的方式退出。

如碰到了 Box<Error>,那实现 --quiet 功能将是相当有技巧的方式。

学习的案例越来越多,对此,你应该好好享受这个世界,然后以合适的错误处理方式来写自己的程序及库。

简短故事

因为这篇文章太长了, 这里我将 "Rust中的错误处理" 做一个简短的总结, 希望对大家有所帮助. 注意这些只是我的经验总结, 并不是必须遵守的清规戒律. 所以你也可能有更好的方式来打破这些规则.

1. 如果你正在写的程序非常小, 那进行错误处理就有些累赘了, 这时用 unwrap 就够了(像Result::unwrap, Option::unwrap 或 Option::expect等). 代码的使用者会懂得如何正确选择错误处理方式(如果他们不知道, 把这篇文章给他看~~).

2. 如果仅仅是一个用于快速验证的小程序, 那么使用 unwrap 也没有什么丢人的. 不过要注意, 如果程序突然在某些分支崩溃了, 对于打印出的那些奇怪的让人摸不着头脑的消息也不要惊讶.

3. 对于用于快速验证的小程序, 如果看着那些奇怪的错误消息不舒服, 你也可以使用failure包里的failure::Error来作为你的错误类型. failure::Error就像之前我们讲的 Box<Error>类型一样, 但它还包含丰富的堆栈回溯信息并支持更好的向下类型转换.

4. 除了以上情况之外, 在程序中最好使用自定义错误类型并实现 From 和 Error 特性, 这样就可以使用 ? 操作符的各种魔法了.

5. 如果你是库作者, 并且你的函数可能会返回某些错误, 那么就使用自定义错误类型并实现std::error::Error特性. 最好也要实现 From 特性, 这样你的库代码和调用者都容易使用它(由于Rust的一致性规则, 库的使用者不能给你的错误类型实现 From 特性, 所以必须由库作者自己来做这件事). 

6. 学过了 Option 和 Result 的连接器(或组合器), 我们知道, 单独地使用它们经常会使人有点厌烦. 我个人反倒是发现了它们之间的一些有意思的组合使用方法: 就是将 ? 操作符与连接器一起混合使用. 像 and_then, map 和 unwrap_or 就是我的最爱.

最后: 展望未来, 随着 failure 包不断地受到重视, 以上的这些建议可能也渐渐地不合时宜了. 如果想了解 failure 包的更多知识, 请点击链接.