Result与可恢复的错误

ch09-01-unrecoverable-errors-with-panic.md
commit 0c1d55ef48e5f6cf6a3b221f5b6dd4c922130bb1

大部分错误并没有严重到需要程序完全停止执行。有时,一个函数会因为一个容易理解并回应的原因失败。例如,如果尝试打开一个文件不过由于文件并不存在而操作就失败,这是我们可能想要创建这个文件而不是终止进程。

回忆一下第二章“使用Result类型来处理潜在的错误”部分中的那个Result枚举,它定义有如下连个成员,OkErr

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

TE是泛型类型参数;第十章会详细介绍泛型。现在你需要知道的就是T代表成功时返回的Ok成员中的数据的类型,而E代表失败时返回的Err成员中的错误的类型。因为Result有这些泛型类型参数,我们可以将Result类型和标准库中为其定义的函数用于很多不同的场景,这些情况中需要返回的成功值和失败值可能会各不相同。

让我们调用一个返回Result的函数,因为它可能会失败:如列表 9-2 所示打开一个文件:

Filename: src/main.rs
use std::fs::File;

fn main() {
    let f = File::open("hello.txt");
}

Listing 9-2: Opening a file

如何知道File::open返回一个Result呢?我们可以查看标准库 API 文档,或者可以直接问编译器!如果给f某个我们知道不是函数返回值类型的类型注解,接着尝试编译代码,编译器会告诉我们类型不匹配。然后错误信息会告诉我们f的类型应该是什么,为此我们将let f语句改为:

let f: u32 = File::open("hello.txt");

现在尝试编译会给出如下错误:

error[E0308]: mismatched types
 --> src/main.rs:4:18
  |
4 |     let f: u32 = File::open("hello.txt");
  |                  ^^^^^^^^^^^^^^^^^^^^^^^ expected u32, found enum
`std::result::Result`
  |
  = note: expected type `u32`
  = note:    found type `std::result::Result<std::fs::File, std::io::Error>`

这就告诉我们了File::open函数的返回值类型是Result<T, E>。这里泛型参数T放入了成功值的类型std::fs::File,它是一个文件句柄。E被用在失败值上其类型是std::io::Error

这个返回值类型说明File::open调用可能会成功并返回一个可以进行读写的文件句柄。这个函数也可能会失败:例如,文件可能并不存在,或者可能没有访问文件的权限。File::open需要一个方式告诉我们是成功还是失败,并同时提供给我们文件句柄或错误信息。而这些信息正是Result枚举可以提供的。

File::open成功的情况下,变量f的值将会是一个包含文件句柄的Ok实例。在失败的情况下,f会是一个包含更多关于出现了何种错误信息的Err实例。

我们需要在列表 9-2 的代码中增加根据File::open返回值进行不同处理的逻辑。列表 9-3 展示了一个处理Result的基本工具:第六章学习过的match表达式。

Filename: src/main.rs
use std::fs::File;

fn main() {
    let f = File::open("hello.txt");

    let f = match f {
        Ok(file) => file,
        Err(error) => {
            panic!("There was a problem opening the file: {:?}", error)
        },
    };
}

Listing 9-3: Using a match expression to handle the Result variants we might have

注意与Option枚举一样,Result枚举和其成员也被导入到了 prelude 中,所以就不需要在match分支中的OkErr之前指定Result::

这里我们告诉 Rust 当结果是Ok,返回Ok成员中的file值,然后将这个文件句柄赋值给变量fmatch之后,我们可以利用这个文件句柄来进行读写。

match的另一个分支处理从File::open得到Err值的情况。在这种情况下,我们选择调用panic!宏。如果当前目录没有一个叫做 hello.txt 的文件,当运行这段代码时会看到如下来自panic!宏的输出:

thread 'main' panicked at 'There was a problem opening the file: Error { repr:
Os { code: 2, message: "No such file or directory" } }', src/main.rs:8

匹配不同的错误

列表 9-3 中的代码不管File::open是因为什么原因失败都会panic!。我们真正希望的是对不同的错误原因采取不同的行为:如果File::open因为文件不存在而失败,我们希望创建这个文件并返回新文件的句柄。如果File::open因为任何其他原因失败,例如没有打开文件的权限,我们仍然希望像列表 9-3 那样panic!。让我们看看列表 9-4,其中match增加了另一个分支:

Filename: src/main.rs
use std::fs::File;
use std::io::ErrorKind;

fn main() {
    let f = File::open("hello.txt");

    let f = match f {
        Ok(file) => file,
        Err(ref error) if error.kind() == ErrorKind::NotFound => {
            match File::create("hello.txt") {
                Ok(fc) => fc,
                Err(e) => {
                    panic!(
                        "Tried to create file but there was a problem: {:?}",
                        e
                    )
                },
            }
        },
        Err(error) => {
            panic!(
                "There was a problem opening the file: {:?}",
                error
            )
        },
    };
}

Listing 9-4: Handling different kinds of errors in different ways

File::open返回的Err成员中的值类型io::Error,它是一个标准库中提供的结构体。这个结构体有一个返回io::ErrorKind值的kind方法可供调用。io::ErrorKind是一个标准库提供的枚举,它的成员对应io操作可能导致的不同错误类型。我们感兴趣的成员是ErrorKind::NotFound,它代表尝试打开的文件并不存在。

if error.kind() == ErrorKind::NotFound条件被称作 match guard:它是一个进一步完善match分支模式的额外的条件。这个条件必须为真才能使分支的代码被执行;否则,模式匹配会继续并考虑match中的下一个分支。模式中的ref是必须的,这样error就不会被移动到 guard 条件中而只是仅仅引用它。第十八章会详细解释为什么在模式中使用ref而不是&来获取一个引用。简而言之,在模式的上下文中,&匹配一个引用并返回它的值,而ref匹配一个值并返回一个引用。

在 match guard 中我们想要检查的条件是error.kind()是否是ErrorKind枚举的NotFound成员。如果是,尝试用File::create创建文件。然而File::create也可能会失败,我们还需要增加一个内部match语句。当文件不能被打开,会打印出一个不同的错误信息。外部match的最后一个分支保持不变这样对任何除了文件不存在的错误会使程序 panic。

失败时 panic 的捷径:unwrapexpect

match能够胜任它的工作,不过它可能有点冗长并且并不总是能很好的表明意图。Result<T, E>类型定义了很多辅助方法来处理各种情况。其中之一叫做unwrap,它的实现就类似于列表 9-3 中的match语句。如果Result值是成员Okunwrap会返回Ok中的值。如果Result是成员Errunwrap会为我们调用panic!

use std::fs::File;

fn main() {
    let f = File::open("hello.txt").unwrap();
}

如果调用这段代码时不存在 hello.txt 文件,我们将会看到一个unwrap调用panic!时提供的错误信息:

thread 'main' panicked at 'called `Result::unwrap()` on an `Err` value: Error {
repr: Os { code: 2, message: "No such file or directory" } }',
/stable-dist-rustc/build/src/libcore/result.rs:868

还有另一个类似于unwrap的方法它还允许我们选择panic!的错误信息:expect。使用expect而不是unwrap并提供一个好的错误信息可以表明你的意图并有助于追踪 panic 的根源。expect的语法看起来像这样:

use std::fs::File;

fn main() {
    let f = File::open("hello.txt").expect("Failed to open hello.txt");
}

expectunwrap的使用方式一样:返回文件句柄或调用panic!宏。expect用来调用panic!的错误信息将会作为传递给expect的参数,而不像unwrap那样使用默认的panic!信息。它看起来像这样:

thread 'main' panicked at 'Failed to open hello.txt: Error { repr: Os { code:
2, message: "No such file or directory" } }',
/stable-dist-rustc/build/src/libcore/result.rs:868

传播错误

当编写一个其实现会调用一些可能会失败的操作的函数时,除了在这个函数中处理错误外,还可以选择让调用者知道这个错误并决定该如何处理。这被称为传播propagating)错误,这样能更好的控制代码调用,因为比起你代码所拥有的上下文,调用者可能拥有更多信息或逻辑来决定应该如何处理错误。

例如,列表 9-5 展示了一个从文件中读取用户名的函数。如果文件不存在或不能读取,这个函数会将这些错误返回给调用它的代码:

use std::io;
use std::io::Read;
use std::fs::File;

fn read_username_from_file() -> Result<String, io::Error> {
    let f = File::open("hello.txt");

    let mut f = match f {
        Ok(file) => file,
        Err(e) => return Err(e),
    };

    let mut s = String::new();

    match f.read_to_string(&mut s) {
        Ok(_) => Ok(s),
        Err(e) => Err(e),
    }
}

Listing 9-5: A function that returns errors to the calling code using match

首先让我们看看函数的返回值:Result<String, io::Error>。这意味着函数返回一个Result<T, E>类型的值,其中泛型参数T的具体类型是String,而E的具体类型是io::Error。如果这个函数没有出任何错误成功返回,函数的调用者会收到一个包含StringOk值————函数从文件中读取到的用户名。如果函数遇到任何错误,函数的调用者会收到一个Err值,它储存了一个包含更多这个问题相关信息的io::Error实例。我们选择io::Error作为函数的返回值是因为它正好是函数体中那两个可能会失败的操作的错误返回值:File::open函数和read_to_string方法。

函数体以File::open函数开头。接着使用match处理返回值Result,类似于列表 9-3 中的match,唯一的区别是不再当Err时调用panic!,而是提早返回并将File::open返回的错误值作为函数的错误返回值传递给调用者。如果File::open成功了,我们将文件句柄储存在变量f中并继续。

接着我们在变量s中创建了一个新String并调用文件句柄fread_to_string方法来将文件的内容读取到s中。read_to_string方法也返回一个Result因为它也可能会失败:哪怕是File::open已经成功了。所以我们需要另一个match来处理这个Result:如果read_to_string成功了,那么这个函数就成功了,并返回文件中的用户名,它现在位于被封装进Oks中。如果read_to_string失败了,则像之前处理File::open的返回值的match那样返回错误值。并不需要显式的调用return,因为这是函数的最后一个表达式。

调用这个函数的代码最终会得到一个包含用户名的Ok值,亦或一个包含io::ErrorErr值。我们无从得知调用者会如何处理这些值。例如,如果他们得到了一个Err值,他们可能会选择panic!并使程序崩溃、使用一个默认的用户名或者从文件之外的地方寻找用户名。我们没有足够的信息知晓调用者具体会如何尝试,所以将所有的成功或失败信息向上传播,让他们选择合适处理方法。

这种传播错误的模式在 Rust 是如此的常见,以至于有一个更简便的专用语法:?

传播错误的捷径:?

列表 9-6 展示了一个read_username_from_file的实现,它实现了与列表 9-5 中的代码相同的功能,不过这个实现是使用了问号运算符:

use std::io;
use std::fs::File;

fn read_username_from_file() -> Result<String, io::Error> {
    let mut f = File::open("hello.txt")?;
    let mut s = String::new();
    f.read_to_string(&mut s)?;
    Ok(s)
}

Listing 9-6: A function that returns errors to the calling code using ?

Result值之后的?被定义为与列表 9-5 中定义的处理Result值的match表达式有着完全相同的工作方式。如果Result的值是Ok,这个表达式将会返回Ok中的值而程序将继续执行。如果值是ErrErr中的值将作为整个函数的返回值,就好像使用了return关键字一样,这样错误值就被传播给了调用者。

在列表 9-6 的上下文中,File::open调用结尾的?将会把Ok中的值返回给变量f。如果出现了错误,?会提早返回整个函数并将任何Err值传播给调用者。同理也适用于read_to_string调用结尾的?

?消除了大量样板代码并使得函数的实现更简单。我们甚至可以在?之后直接使用链式方法调用来进一步缩短代码:

use std::io;
use std::io::Read;
use std::fs::File;

fn read_username_from_file() -> Result<String, io::Error> {
    let mut s = String::new();

    File::open("hello.txt")?.read_to_string(&mut s)?;

    Ok(s)
}

s中创建新的String被放到了函数开头;这没有什么变化。我们对File::open("hello.txt")?的结果直接链式调用了read_to_string,而不再创建变量f。仍然需要read_to_string调用结尾的?,而且当File::openread_to_string都成功没有失败时返回包含用户名sOk值。其功能再一次与列表 9-5 和列表 9-5 保持一致,不过这是一个与众不同且更符合工程学的写法。

?只能被用于返回Result的函数

?只能被用于返回值类型为Result的函数,因为他被定义为与列表 9-5 中的match表达式有着完全相同的工作方式。matchreturn Err(e)部分要求返回值类型是Result,所以函数的返回值必须是Result才能与这个return相兼容。

让我们看看在main函数中使用?会发生什么,如果你还记得的话它的返回值类型是()

use std::fs::File;

fn main() {
    let f = File::open("hello.txt")?;
}