用 Result
处理可恢复的错误
ch09-02-recoverable-errors-with-result.md
commit 699adc6f5cb76f6e9d567ff0a57d8a844ac07a88
大部分错误并没有严重到需要程序完全停止执行。有时候,一个函数失败,仅仅就是因为一个容易理解和响应的原因。例如,如果因为打开一个并不存在的文件而失败,此时我们可能想要创建这个文件,而不是终止进程。
回忆一下第二章 “使用 Result
类型来处理潜在的错误” 部分中的那个 Result
枚举,它定义有如下两个成员,Ok
和 Err
:
#![allow(unused)] fn main() { enum Result<T, E> { Ok(T), Err(E), } }
T
和 E
是泛型类型参数;第十章会详细介绍泛型。现在你需要知道的就是 T
代表成功时返回的 Ok
成员中的数据的类型,而 E
代表失败时返回的 Err
成员中的错误的类型。因为 Result
有这些泛型类型参数,我们可以将 Result
类型和标准库中为其定义的函数用于很多不同的场景,这些情况中需要返回的成功值和失败值可能会各不相同。
让我们调用一个返回 Result
的函数,因为它可能会失败:如示例 9-3 所示打开一个文件:
文件名:src/main.rs
use std::fs::File; fn main() { let greeting_file_result = File::open("hello.txt"); }
File::open
的返回值是 Result<T, E>
。泛型参数 T
会被 File::open
的实现放入成功返回值的类型 std::fs::File
,这是一个文件句柄。错误返回值使用的 E
的类型是 std::io::Error
。这些返回类型意味着 File::open
调用可能成功并返回一个可以读写的文件句柄。这个函数调用也可能会失败:例如,也许文件不存在,或者可能没有权限访问这个文件。File::open
函数需要一个方法在告诉我们成功与否的同时返回文件句柄或者错误信息。这些信息正好是 Result
枚举所代表的。
当 File::open
成功时,greeting_file_result
变量将会是一个包含文件句柄的 Ok
实例。当失败时,greeting_file_result
变量将会是一个包含了更多关于发生了何种错误的信息的 Err
实例。
我们需要在示例 9-3 的代码中增加根据 File::open
返回值进行不同处理的逻辑。示例 9-4 展示了一个使用基本工具处理 Result
的例子:第六章学习过的 match
表达式。
文件名:src/main.rs
use std::fs::File; fn main() { let greeting_file_result = File::open("hello.txt"); let greeting_file = match greeting_file_result { Ok(file) => file, Err(error) => panic!("Problem opening the file: {error:?}"), }; }
注意与 Option
枚举一样,Result
枚举和其成员也被导入到了 prelude 中,所以就不需要在 match
分支中的 Ok
和 Err
之前指定 Result::
。
这里我们告诉 Rust 当结果是 Ok
时,返回 Ok
成员中的 file
值,然后将这个文件句柄赋值给变量 greeting_file
。match
之后,我们可以利用这个文件句柄来进行读写。
match
的另一个分支处理从 File::open
得到 Err
值的情况。在这种情况下,我们选择调用 panic!
宏。如果当前目录没有一个叫做 hello.txt 的文件,当运行这段代码时会看到如下来自 panic!
宏的输出:
$ cargo run
Compiling error-handling v0.1.0 (file:///projects/error-handling)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.73s
Running `target/debug/error-handling`
thread 'main' panicked at src/main.rs:8:23:
Problem opening the file: Os { code: 2, kind: NotFound, message: "No such file or directory" }
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
一如既往,此输出准确地告诉了我们到底出了什么错。
匹配不同的错误
示例 9-4 中的代码不管 File::open
是因为什么原因失败都会 panic!
。我们真正希望的是对不同的错误原因采取不同的行为:如果 File::open
因为文件不存在而失败,我们希望创建这个文件并返回新文件的句柄。如果 File::open
因为任何其他原因失败,例如没有打开文件的权限,我们仍然希望像示例 9-4 那样 panic!
。让我们看看示例 9-5,其中 match
增加了另一个分支:
文件名:src/main.rs
use std::fs::File;
use std::io::ErrorKind;
fn main() {
let greeting_file_result = File::open("hello.txt");
let greeting_file = match greeting_file_result {
Ok(file) => file,
Err(error) => match error.kind() {
ErrorKind::NotFound => match File::create("hello.txt") {
Ok(fc) => fc,
Err(e) => panic!("Problem creating the file: {e:?}"),
},
other_error => {
panic!("Problem opening the file: {other_error:?}");
}
},
};
}
File::open
返回的 Err
成员中的值类型 io::Error
,它是一个标准库中提供的结构体。这个结构体有一个返回 io::ErrorKind
值的 kind
方法可供调用。io::ErrorKind
是一个标准库提供的枚举,它的成员对应 io
操作可能导致的不同错误类型。我们感兴趣的成员是 ErrorKind::NotFound
,它代表尝试打开的文件并不存在。这样,match
就匹配完 greeting_file_result
了,不过对于 error.kind()
还有一个内层 match
。
我们希望在内层 match
中检查的条件是 error.kind()
的返回值是否为 ErrorKind
的 NotFound
成员。如果是,则尝试通过 File::create
创建文件。然而因为 File::create
也可能会失败,还需要增加一个内层 match
语句。当文件不能被创建,会打印出一个不同的错误信息。外层 match
的最后一个分支保持不变,这样对任何除了文件不存在的错误会使程序 panic。
不同于使用
match
和Result<T, E>
这里有好多
match
!match
确实很强大,不过也非常的原始。第十三章我们会介绍闭包(closure),它会和定义在Result<T, E>
中的很多方法一起使用。在处理代码中的Result<T, E>
值时,相比于使用match
,使用这些方法会更加简洁。例如,这是另一个编写与示例 9-5 逻辑相同但是使用闭包和
unwrap_or_else
方法的例子:use std::fs::File; use std::io::ErrorKind; fn main() { let greeting_file = File::open("hello.txt").unwrap_or_else(|error| { if error.kind() == ErrorKind::NotFound { File::create("hello.txt").unwrap_or_else(|error| { panic!("Problem creating the file: {:?}", error); }) } else { panic!("Problem opening the file: {:?}", error); } }); }
虽然这段代码有着如示例 9-5 一样的行为,但并没有包含任何
match
表达式且更容易阅读。在阅读完第十三章后再回到这个例子,并查看标准库文档unwrap_or_else
方法都做了什么操作。在处理错误时,还有很多这类方法可以消除大量嵌套的match
表达式。
失败时 panic 的简写:unwrap
和 expect
match
能够胜任它的工作,不过它可能有点冗长并且不总是能很好的表明其意图。Result<T, E>
类型定义了很多辅助方法来处理各种情况。其中之一叫做 unwrap
,它的实现就类似于示例 9-4 中的 match
语句。如果 Result
值是成员 Ok
,unwrap
会返回 Ok
中的值。如果 Result
是成员 Err
,unwrap
会为我们调用 panic!
。这里是一个实践 unwrap
的例子:
文件名:src/main.rs
use std::fs::File; fn main() { let greeting_file = File::open("hello.txt").unwrap(); }
如果调用这段代码时不存在 hello.txt 文件,我们将会看到一个 unwrap
调用 panic!
时提供的错误信息:
thread 'main' panicked at 'called `Result::unwrap()` on an `Err` value: Os {
code: 2, kind: NotFound, message: "No such file or directory" }',
src/main.rs:4:49
还有另一个类似于 unwrap
的方法它还允许我们选择 panic!
的错误信息:expect
。使用 expect
而不是 unwrap
并提供一个好的错误信息可以表明你的意图并更易于追踪 panic 的根源。expect
的语法看起来像这样:
文件名:src/main.rs
use std::fs::File; fn main() { let greeting_file = File::open("hello.txt") .expect("hello.txt should be included in this project"); }
expect
与 unwrap
的使用方式一样:返回文件句柄或调用 panic!
宏。expect
在调用 panic!
时使用的错误信息将是我们传递给 expect
的参数,而不像 unwrap
那样使用默认的 panic!
信息。它看起来像这样:
thread 'main' panicked at 'hello.txt should be included in this project: Error
{ repr: Os { code: 2, message: "No such file or directory" } }',
src/libcore/result.rs:906:4
在生产级别的代码中,大部分 Rustaceans 选择 expect
而不是 unwrap
并提供更多关于为何操作期望是一直成功的上下文。如此如果该假设真的被证明是错的,你也有更多的信息来用于调试。
传播错误
当编写一个其实先会调用一些可能会失败的操作的函数时,除了在这个函数中处理错误外,还可以选择让调用者知道这个错误并决定该如何处理。这被称为 传播(propagating)错误,这样能更好的控制代码调用,因为比起你代码所拥有的上下文,调用者可能拥有更多信息或逻辑来决定应该如何处理错误。
例如,示例 9-6 展示了一个从文件中读取用户名的函数。如果文件不存在或不能读取,这个函数会将这些错误返回给调用它的代码:
文件名:src/main.rs
#![allow(unused)] fn main() { use std::fs::File; use std::io::{self, Read}; fn read_username_from_file() -> Result<String, io::Error> { let username_file_result = File::open("hello.txt"); let mut username_file = match username_file_result { Ok(file) => file, Err(e) => return Err(e), }; let mut username = String::new(); match username_file.read_to_string(&mut username) { Ok(_) => Ok(username), Err(e) => Err(e), } } }
这个函数可以编写成更加简短的形式,不过我们以大量手动处理开始以便探索错误处理;在最后我们会展示更短的形式。让我们看看函数的返回值:Result<String, io::Error>
。这意味着函数返回一个 Result<T, E>
类型的值,其中泛型参数 T
的具体类型是 String
,而 E
的具体类型是 io::Error
。
如果这个函数没有出任何错误成功返回,函数的调用者会收到一个包含 String
的 Ok
值 —— 函数从文件中读取到的用户名。如果函数遇到任何错误,函数的调用者会收到一个 Err
值,它储存了一个包含更多这个问题相关信息的 io::Error
实例。这里选择 io::Error
作为函数的返回值是因为它正好是函数体中那两个可能会失败的操作的错误返回值:File::open
函数和 read_to_string
方法。
函数体以调用 File::open
函数开始。接着使用 match
处理返回值 Result
,类似示例 9-4,如果 File::open
成功了,模式变量 file
中的文件句柄就变成了可变变量 username_file
中的值,接着函数继续执行。在 Err
的情况下,我们没有调用 panic!
,而是使用 return
关键字提前结束整个函数,并将来自 File::open
的错误值(现在在模式变量 e
中)作为函数的错误值传回给调用者。
所以,如果在 username_file
中有一个文件句柄,该函数随后会在变量 username
中创建一个新的 String
并调用文件句柄 username_file
上的 read_to_string
方法,以将文件的内容读入 username
。read_to_string
方法也返回一个 Result
,因为它可能会失败,哪怕是 File::open
已经成功了。因此,我们需要另一个 match
来处理这个 Result
:如果 read_to_string
执行成功,那么这个函数也就成功了,我们将从文件中读取的用户名返回,此时用户名位于被封装进 Ok
的 username
中。如果 read_to_string
执行失败,则像之前处理 File::open
的返回值的 match
那样返回错误值。然而,我们无需显式调用 return
语句,因为这是函数的最后一个表达式。
调用这个函数的代码最终会得到一个包含用户名的 Ok
值,或者一个包含 io::Error
的 Err
值。我们无从得知调用者会如何处理这些值。例如,如果他们得到了一个 Err
值,他们可能会选择 panic!
并使程序崩溃、使用一个默认的用户名或者从文件之外的地方寻找用户名。我们没有足够的信息知晓调用者具体会如何尝试,所以将所有的成功或失败信息向上传播,让他们选择合适的处理方法。
这种传播错误的模式在 Rust 是如此的常见,以至于 Rust 提供了 ?
问号运算符来使其更易于处理。
传播错误的简写:?
运算符
示例 9-7 展示了一个 read_username_from_file
的实现,它实现了与示例 9-6 中的代码相同的功能,不过这个实现使用了 ?
运算符:
文件名:src/main.rs
#![allow(unused)] fn main() { use std::fs::File; use std::io::{self, Read}; fn read_username_from_file() -> Result<String, io::Error> { let mut username_file = File::open("hello.txt")?; let mut username = String::new(); username_file.read_to_string(&mut username)?; Ok(username) } }
Result
值之后的 ?
被定义为与示例 9-6 中定义的处理 Result
值的 match
表达式有着完全相同的工作方式。如果 Result
的值是 Ok
,这个表达式将会返回 Ok
中的值而程序将继续执行。如果值是 Err
,Err
将作为整个函数的返回值,就好像使用了 return
关键字一样,这样错误值就被传播给了调用者。
示例 9-6 中的 match
表达式与 ?
运算符所做的有一点不同:?
运算符所使用的错误值被传递给了 from
函数,它定义于标准库的 From
trait 中,其用来将错误从一种类型转换为另一种类型。当 ?
运算符调用 from
函数时,收到的错误类型被转换为由当前函数返回类型所指定的错误类型。这在当函数返回单个错误类型来代表所有可能失败的方式时很有用,即使其可能会因很多种原因失败。
例如,我们可以将示例 9-7 中的 read_username_from_file
函数修改为返回一个自定义的 OurError
错误类型。如果我们也定义了 impl From<io::Error> for OurError
来从 io::Error
构造一个 OurError
实例,那么 read_username_from_file
函数体中的 ?
运算符调用会调用 from
并转换错误而无需在函数中增加任何额外的代码。
在示例 9-7 的上下文中,File::open
调用结尾的 ?
会将 Ok
中的值返回给变量 username_file
。如果发生了错误,?
运算符会使整个函数提前返回并将任何 Err
值返回给调用代码。同理也适用于 read_to_string
调用结尾的 ?
。
?
运算符消除了大量样板代码并使得函数的实现更简单。我们甚至可以在 ?
之后直接使用链式方法调用来进一步缩短代码,如示例 9-8 所示:
文件名:src/main.rs
#![allow(unused)] fn main() { use std::fs::File; use std::io::{self, Read}; fn read_username_from_file() -> Result<String, io::Error> { let mut username = String::new(); File::open("hello.txt")?.read_to_string(&mut username)?; Ok(username) } }
在 username
中创建新的 String
被放到了函数开头;这一部分没有变化。我们对 File::open("hello.txt")?
的结果直接链式调用了 read_to_string
,而不再创建变量 username_file
。仍然需要 read_to_string
调用结尾的 ?
,而且当 File::open
和 read_to_string
都成功没有失败时返回包含用户名 username
的 Ok
值。其功能再一次与示例 9-6 和示例 9-7 保持一致,不过这是一个与众不同且更符合工程学(ergonomic)的写法。
示例 9-9 展示了一个使用 fs::read_to_string
的更为简短的写法:
文件名:src/main.rs
#![allow(unused)] fn main() { use std::fs; use std::io; fn read_username_from_file() -> Result<String, io::Error> { fs::read_to_string("hello.txt") } }
将文件读取到一个字符串是相当常见的操作,所以 Rust 提供了名为 fs::read_to_string
的函数,它会打开文件、新建一个 String
、读取文件的内容,并将内容放入 String
,接着返回它。当然,这样做就没有展示所有这些错误处理的机会了,所以我们最初就选择了艰苦的道路。
哪里可以使用 ?
运算符
?
运算符只能被用于返回值与 ?
作用的值相兼容的函数。因为 ?
运算符被定义为从函数中提早返回一个值,这与示例 9-6 中的 match
表达式有着完全相同的工作方式。示例 9-6 中 match
作用于一个 Result
值,提早返回的分支返回了一个 Err(e)
值。函数的返回值必须是 Result
才能与这个 return
相兼容。
在示例 9-10 中,让我们看看在返回值不兼容的 main
函数中使用 ?
运算符会得到什么错误:
文件名:src/main.rs
use std::fs::File;
fn main() {
let greeting_file = File::open("hello.txt")?;
}
这段代码打开一个文件,这可能会失败。?
运算符作用于 File::open
返回的 Result
值,不过 main
函数的返回类型是 ()
而不是 Result
。当编译这些代码,会得到如下错误信息:
$ cargo run
Compiling error-handling v0.1.0 (file:///projects/error-handling)
error[E0277]: the `?` operator can only be used in a function that returns `Result` or `Option` (or another type that implements `FromResidual`)
--> src/main.rs:4:48
|
3 | fn main() {
| --------- this function should return `Result` or `Option` to accept `?`
4 | let greeting_file = File::open("hello.txt")?;
| ^ cannot use the `?` operator in a function that returns `()`
|
= help: the trait `FromResidual<Result<Infallible, std::io::Error>>` is not implemented for `()`
help: consider adding return type
|
3 ~ fn main() -> Result<(), Box<dyn std::error::Error>> {
4 | let greeting_file = File::open("hello.txt")?;
5 +
6 + Ok(())
7 + }
|
For more information about this error, try `rustc --explain E0277`.
error: could not compile `error-handling` (bin "error-handling") due to 1 previous error
这个错误指出只能在返回 Result
或者其它实现了 FromResidual
的类型的函数中使用 ?
运算符。
为了修复这个错误,有两个选择。一个是,如果没有限制的话将函数的返回值改为 Result<T, E>
。另一个是使用 match
或 Result<T, E>
的方法中合适的一个来处理 Result<T, E>
。
错误信息也提到 ?
也可用于 Option<T>
值。如同对 Result
使用 ?
一样,只能在返回 Option
的函数中对 Option
使用 ?
。在 Option<T>
上调用 ?
运算符的行为与 Result<T, E>
类似:如果值是 None
,此时 None
会从函数中提前返回。如果值是 Some
,Some
中的值作为表达式的返回值同时函数继续。示例 9-11 中有一个从给定文本中返回第一行最后一个字符的函数的例子:
fn last_char_of_first_line(text: &str) -> Option<char> { text.lines().next()?.chars().last() } fn main() { assert_eq!( last_char_of_first_line("Hello, world\nHow are you today?"), Some('d') ); assert_eq!(last_char_of_first_line(""), None); assert_eq!(last_char_of_first_line("\nhi"), None); }
这个函数返回 Option<char>
因为它可能会在这个位置找到一个字符,也可能没有字符。这段代码获取 text
字符串 slice 作为参数并调用其 lines
方法,这会返回一个字符串中每一行的迭代器。因为函数希望检查第一行,所以调用了迭代器 next
来获取迭代器中第一个值。如果 text
是空字符串,next
调用会返回 None
,此时我们可以使用 ?
来停止并从 last_char_of_first_line
返回 None
。如果 text
不是空字符串,next
会返回一个包含 text
中第一行的字符串 slice 的 Some
值。
?
会提取这个字符串 slice,然后可以在字符串 slice 上调用 chars
来获取字符的迭代器。我们感兴趣的是第一行的最后一个字符,所以可以调用 last
来返回迭代器的最后一项。这是一个 Option
,因为有可能第一行是一个空字符串,例如 text
以一个空行开头而后面的行有文本,像是 "\nhi"
。不过,如果第一行有最后一个字符,它会返回在一个 Some
成员中。?
运算符作用于其中给了我们一个简洁的表达这种逻辑的方式。如果我们不能在 Option
上使用 ?
运算符,则不得不使用更多的方法调用或者 match
表达式来实现这些逻辑。
注意你可以在返回 Result
的函数中对 Result
使用 ?
运算符,可以在返回 Option
的函数中对 Option
使用 ?
运算符,但是不可以混合搭配。?
运算符不会自动将 Result
转化为 Option
,反之亦然;在这些情况下,可以使用类似 Result
的 ok
方法或者 Option
的 ok_or
方法来显式转换。
目前为止,我们所使用的所有 main
函数都返回 ()
。main
函数是特殊的因为它是可执行程序的入口点和退出点,为了使程序能正常工作,其可以返回的类型是有限制的。
幸运的是 main
函数也可以返回 Result<(), E>
,示例 9-12 中的代码来自示例 9-10 不过修改了 main
的返回值为 Result<(), Box<dyn Error>>
并在结尾增加了一个 Ok(())
作为返回值。这段代码可以编译:
use std::error::Error;
use std::fs::File;
fn main() -> Result<(), Box<dyn Error>> {
let greeting_file = File::open("hello.txt")?;
Ok(())
}
Box<dyn Error>
类型是一个 trait 对象(trait object)第十八章 顾及不同类型值的 trait 对象” 部分会做介绍。目前可以将 Box<dyn Error>
理解为 “任何类型的错误”。在返回 Box<dyn Error>
错误类型 main
函数中对 Result
使用 ?
是允许的,因为它允许任何 Err
值提前返回。即便 main
函数体从来只会返回 std::io::Error
错误类型,通过指定 Box<dyn Error>
,这个签名也仍是正确的,甚至当 main
函数体中增加更多返回其他错误类型的代码时也是如此。
当 main
函数返回 Result<(), E>
,如果 main
返回 Ok(())
可执行程序会以 0
值退出,而如果 main
返回 Err
值则会以非零值退出;成功退出的程序会返回整数 0
,运行错误的程序会返回非 0
的整数。Rust 也会从二进制程序中返回与这个惯例相兼容的整数。
main
函数也可以返回任何实现了 std::process::Termination
trait 的类型,它包含了一个返回 ExitCode
的 report
函数。请查阅标准库文档了解更多为自定义类型实现 Termination
trait 的细节。
现在我们讨论过了调用 panic!
或返回 Result
的细节,是时候回到它们各自适合哪些场景的话题了。