使用 Result 的可恢复错误

大多数错误并不严重到需要程序完全停止的地步。有时,当函数失败时,其原因是你可以轻松地解释和响应的。例如,如果你尝试打开一个文件,但该操作失败,因为该文件不存在,你可能希望创建该文件而不是终止进程。

回想一下第 2 章 “使用 Result 处理潜在的失败”中,Result 枚举被定义为具有两个变体,OkErr,如下所示

#![allow(unused)] fn main() { enum Result<T, E> { Ok(T), Err(E), } }

TE 是泛型类型参数:我们将在第 10 章中更详细地讨论泛型。你现在需要知道的是,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"); }
列表 9-3:打开文件

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 展示了一种使用基本工具 match 表达式处理 Result 的方法,我们在第 6 章中讨论过 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:?}"), }; }
列表 9-4:使用 match 表达式处理可能返回的 Result 变体

请注意,与 Option 枚举一样,Result 枚举及其变体已被 prelude 引入作用域,因此我们不需要在 match 分支中的 OkErr 变体之前指定 Result::

当结果为 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

像往常一样,此输出准确地告诉我们哪里出错了。

匹配不同的错误

无论 File::open 失败的原因是什么,列表 9-4 中的代码都会 panic!。但是,我们希望针对不同的失败原因采取不同的操作。如果 File::open 因文件不存在而失败,我们希望创建该文件并返回新文件的句柄。如果 File::open 因任何其他原因(例如,因为我们没有打开该文件的权限)而失败,我们仍然希望代码以与列表 9-4 中相同的方式 panic!。为此,我们添加了一个内部 match 表达式,如列表 9-5 所示。

文件名: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:?}"); } }, }; }
列表 9-5:以不同的方式处理不同类型的错误

File::openErr 变体中返回的值的类型是 io::Error,这是一个由标准库提供的结构体。此结构体有一个方法 kind,我们可以调用它来获取 io::ErrorKind 值。枚举 io::ErrorKind 由标准库提供,并具有表示可能由 io 操作导致的各种错误的变体。我们想要使用的变体是 ErrorKind::NotFound,它表示我们尝试打开的文件尚不存在。因此,我们对 greeting_file_result 进行匹配,但我们也在 error.kind() 上有一个内部匹配。

我们想要在内部匹配中检查的条件是 error.kind() 返回的值是否是 ErrorKind 枚举的 NotFound 变体。如果是,我们尝试使用 File::create 创建文件。但是,由于 File::create 也可能失败,因此我们需要内部 match 表达式中的第二个分支。当文件无法创建时,会打印不同的错误消息。外部 match 的第二个分支保持不变,因此程序会在除缺少文件错误之外的任何错误上 panic。

使用 match 处理 Result<T, E> 的替代方案

太多的 match 了!match 表达式非常有用,但也非常原始。在第 13 章中,你将学习闭包,闭包与 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 表达式,并且更易于阅读。在阅读完第 13 章后,再回到此示例,并在标准库文档中查找 unwrap_or_else 方法。当处理错误时,更多这样的方法可以清理庞大的嵌套 match 表达式。

错误时 panic 的快捷方式:unwrapexpect

使用 match 效果很好,但它可能有点冗长,并且并不总是能很好地传达意图。Result<T, E> 类型在其上定义了许多辅助方法,用于执行各种更具体的任务。unwrap 方法是一个快捷方法,其实现方式与我们在列表 9-4 中编写的 match 表达式完全相同。如果 Result 值是 Ok 变体,unwrap 将返回 Ok 内部的值。如果 ResultErr 变体,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 src/main.rs:4:49: called `Result::unwrap()` on an `Err` value: Os { code: 2, kind: NotFound, message: "No such file or directory" }

类似地,expect 方法也允许我们选择 panic! 错误消息。使用 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"); }

我们以与 unwrap 相同的方式使用 expect:返回文件句柄或调用 panic! 宏。expect 在其 panic! 调用中使用的错误消息将是我们传递给 expect 的参数,而不是 unwrap 使用的默认 panic! 消息。这是它的样子

thread 'main' panicked at src/main.rs:5:10: hello.txt should be included in this project: Os { code: 2, kind: NotFound, message: "No such file or directory" }

在生产质量的代码中,大多数 Rustaceans 选择 expect 而不是 unwrap,并提供更多关于为什么预期操作总是成功的上下文。这样,如果你的假设被证明是错误的,你将有更多信息用于调试。

传播错误

当函数的实现调用某些可能失败的内容时,你可以将错误返回给调用代码,而不是在函数本身内处理错误,以便调用代码可以决定该怎么做。这被称为传播错误,并为调用代码提供了更多控制权,在调用代码中,可能存在更多信息或逻辑来指示应如何处理错误,而不是你在代码上下文中可用的信息或逻辑。

例如,列表 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), } } }
列表 9-6:使用 match 将错误返回给调用代码的函数

此函数可以用更短的方式编写,但我们将首先手动完成很多操作,以便探索错误处理;最后,我们将展示更短的方式。让我们首先查看函数的返回类型:Result<String, io::Error>。这意味着该函数正在返回 Result<T, E> 类型的值,其中泛型参数 T 已被具体类型 String 填充,而泛型类型 E 已被具体类型 io::Error 填充。

如果此函数成功且没有任何问题,则调用此函数的代码将收到一个 Ok 值,其中包含一个 String—此函数从文件中读取的 username。如果此函数遇到任何问题,则调用代码将收到一个 Err 值,其中包含 io::Error 的一个实例,其中包含有关问题所在的更多信息。我们选择 io::Error 作为此函数的返回类型,因为这恰好是此函数体中我们调用的两个可能失败的操作返回的错误值的类型:File::open 函数和 read_to_string 方法。

函数体首先调用 File::open 函数。然后我们使用类似于列表 9-4 中 matchmatch 处理 Result 值。如果 File::open 成功,则模式变量 file 中的文件句柄将成为可变变量 username_file 中的值,并且函数继续执行。在 Err 的情况下,我们没有调用 panic!,而是使用 return 关键字完全从函数中提前返回,并将来自 File::open 的错误值(现在在模式变量 e 中)作为此函数的错误值返回给调用代码。

因此,如果我们在 username_file 中有一个文件句柄,则该函数然后在变量 username 中创建一个新的 String,并在 username_file 中的文件句柄上调用 read_to_string 方法,以将文件内容读取到 username 中。即使 File::open 成功,read_to_string 方法也会返回一个 Result,因为它也可能失败。因此,我们需要另一个 match 来处理该 Result:如果 read_to_string 成功,那么我们的函数就成功了,并且我们返回文件中的用户名,该用户名现在包装在 Ok 中的 username 中。如果 read_to_string 失败,我们以与处理 File::open 的返回值时返回错误值相同的方式返回错误值。但是,我们不需要显式地说 return,因为这是函数中的最后一个表达式。

调用此代码的代码将处理获取包含用户名的 Ok 值或包含 io::ErrorErr 值。由调用代码决定如何处理这些值。如果调用代码获得 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) } }
列表 9-7:使用 ? 运算符将错误返回给调用代码的函数

放置在 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) } }
列表 9-8:在 ? 运算符之后链接方法调用

我们已将 username 中新 String 的创建移至函数开头;那部分没有改变。我们没有创建变量 username_file,而是将对 read_to_string 的调用直接链接到 File::open("hello.txt")? 的结果。我们在 read_to_string 调用的末尾仍然有一个 ?,并且当 File::openread_to_string 都成功时,我们仍然返回包含 usernameOk 值,而不是返回错误。该功能再次与列表 9-6 和列表 9-7 中的功能相同;这只是一种不同的、更符合人体工程学的编写方式。

列表 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") } }
列表 9-9:使用 fs::read_to_string 而不是打开然后读取文件

将文件读取到字符串中是一个相当常见的操作,因此标准库提供了方便的 fs::read_to_string 函数,该函数打开文件,创建一个新的 String,读取文件内容,将内容放入该 String 中,然后返回它。当然,使用 fs::read_to_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")?; }
列表 9-10:尝试在返回 ()main 函数中使用 ? 将无法编译。

此代码打开一个文件,这可能会失败。? 运算符跟随 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 + Ok(()) | For more information about this error, try `rustc --explain E0277`. error: could not compile `error-handling` (bin "error-handling") due to 1 previous error

此错误指出,我们只允许在返回 ResultOption 或其他实现 FromResidual 类型的函数中使用 ? 运算符。

要修复此错误,你有两种选择。一种选择是将函数的返回类型更改为与你使用 ? 运算符的值兼容的类型,只要你没有阻止这种情况的限制即可。另一种选择是使用 matchResult<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); }
列表 9-11:在 Option<T> 值上使用 ? 运算符

此函数返回 Option<char>,因为那里可能存在字符,但也可能不存在字符。此代码接受 text 字符串切片参数,并在其上调用 lines 方法,该方法返回一个遍历字符串中行的迭代器。由于此函数想要检查第一行,因此它在迭代器上调用 next 以从迭代器获取第一个值。如果 text 是空字符串,则对 next 的此调用将返回 None,在这种情况下,我们使用 ? 停止并从 last_char_of_first_line 返回 None。如果 text 不是空字符串,则 next 将返回一个 Some 值,其中包含 text 中第一行的字符串切片。

? 提取字符串切片,我们可以对该字符串切片调用 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(())。此代码现在将编译。

文件名:src/main.rs
use std::error::Error; use std::fs::File; fn main() -> Result<(), Box<dyn Error>> { let greeting_file = File::open("hello.txt")?; Ok(()) }
列表 9-12:将 main 更改为返回 Result<(), E> 允许在 Result 值上使用 ? 运算符。

Box<dyn Error> 类型是一个trait 对象,我们将在 “使用允许不同类型值的 trait 对象”一章的第 18.2 节中讨论它。现在,你可以将 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 值,则将以非零值退出。用 C 编写的可执行文件在退出时返回整数:成功退出的程序返回整数 0,而错误退出的程序返回除 0 之外的某个整数。Rust 也从可执行文件返回整数以与此约定兼容。

main 函数可以返回任何实现 std::process::Termination trait的类型,该 trait 包含一个返回 ExitCode 的函数 report。有关为你自己的类型实现 Termination trait 的更多信息,请参阅标准库文档。

现在我们已经讨论了调用 panic! 或返回 Result 的细节,让我们回到关于如何决定在哪些情况下使用哪种方法的讨论。