带 Result 的可恢复错误
大多数错误并不严重到需要程序完全停止。有时函数失败的原因是你可以轻松解释并做出响应的。例如,如果你尝试打开一个文件,但操作因文件不存在而失败,你可能希望创建该文件而不是终止进程。
回想 “使用 Result 处理可能的失败”在第 2 章中,Result 枚举定义为有两个变体:Ok 和 Err,如下所示
#![allow(unused)] fn main() { enum Result<T, E> { Ok(T), Err(E), } }
T 和 E 是泛型类型参数:我们将在第 10 章中更详细地讨论泛型。现在你需要知道的是,T 代表在 Ok 变体中成功情况下将返回的值的类型,而 E 代表在 Err 变体中失败情况下将返回的错误的类型。因为 Result 具有这些泛型类型参数,我们可以在许多不同的情况下使用 Result 类型及其定义的方法,在这些情况下我们想要返回的成功值和错误值可能不同。
我们来调用一个返回 Result 值函数,因为该函数可能会失败。在代码清单 9-3 中,我们尝试打开一个文件。
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 展示了一种使用我们第 6 章讨论过的基本工具 match 表达式来处理 Result 的方法。
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:?}"), }; }
match 表达式处理可能返回的 Result 变体注意,与 Option 枚举一样,Result 枚举及其变体已通过 prelude 被引入作用域,因此在 match 分支中,我们不需要在 Ok 和 Err 变体之前指定 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
通常,这段输出会准确地告诉我们哪里出了问题。
匹配不同的错误
代码清单 9-4 中的代码会 panic!,无论 File::open 失败的原因是什么。然而,我们希望针对不同的失败原因采取不同的行动。如果 File::open 失败是因为文件不存在,我们希望创建该文件并返回新文件的句柄。如果 File::open 因任何其他原因失败——例如,因为我们没有打开文件的权限——我们仍然希望代码像代码清单 9-4 中那样 panic!。为此,我们添加了一个内部的 match 表达式,如代码清单 9-5 所示。
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:?}"),
},
_ => {
panic!("Problem opening the file: {error:?}");
}
},
};
}
File::open 在 Err 变体中返回的值的类型是 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 章中,你将了解闭包(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 表达式,并且读起来更清晰。阅读完第 13 章后,请回顾这个例子,并在标准库文档中查找 unwrap_or_else 方法。处理错误时,还有许多类似的方法可以简化巨大的嵌套 match 表达式。
出错时 panic 的快捷方式:unwrap 和 expect
使用 match 效果不错,但可能有点啰嗦,并且不总是能很好地传达意图。Result<T, E> 类型定义了许多辅助方法,用于执行各种更具体的任务。unwrap 方法是一个快捷方法,其实现就像我们在代码清单 9-4 中编写的 match 表达式一样。如果 Result 值是 Ok 变体,unwrap 将返回 Ok 内部的值。如果 Result 是 Err 变体,unwrap 会为我们调用 panic! 宏。以下是 unwrap 的一个示例
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 的语法如下所示
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 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,并提供更多关于为什么操作期望总是成功的信息。这样,如果你的假设被证明是错误的,你就有更多信息用于调试。
传播错误
当函数实现中调用某个可能失败的操作时,你可以不在此函数内部处理错误,而是将错误返回给调用代码,以便由调用代码决定如何处理。这被称为错误传播(propagating the error),它赋予调用代码更多的控制权,因为调用代码可能拥有更多信息或逻辑来决定如何处理错误,而这些信息在当前函数的上下文可能是不可用的。
例如,代码清单 9-6 展示了一个从文件中读取用户名的函数。如果文件不存在或无法读取,此函数会将这些错误返回给调用它的代码。
#![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), } } }
match 将错误返回给调用代码的函数这个函数可以用更短的方式编写,但我们首先会手动实现很多部分,以便探索错误处理;最后我们会展示更短的方式。首先让我们看看函数的返回类型:Result<String, io::Error>。这意味着该函数正在返回一个 Result<T, E> 类型的值,其中泛型参数 T 已被具体类型 String 填充,泛型类型 E 已被具体类型 io::Error 填充。
如果此函数顺利成功,调用它的代码将收到一个包含 String 的 Ok 值——这是此函数从文件中读取的 username。如果此函数遇到任何问题,调用代码将收到一个包含 io::Error 实例的 Err 值,其中包含更多关于问题的信息。我们选择 io::Error 作为此函数的返回类型,因为这恰好是我们在函数体中调用的两个可能失败的操作返回的错误值的类型:File::open 函数和 read_to_string 方法。
函数体首先调用 File::open 函数。然后我们使用类似于代码清单 9-4 中的 match 来处理 Result 值。如果 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 成功,那么我们的函数就成功了,我们将文件中现在存储在 username 中的用户名封装在 Ok 中返回。如果 read_to_string 失败,我们以与处理 File::open 返回值的 match 中返回错误值相同的方式返回错误值。然而,我们不需要显式地写 return,因为这是函数中的最后一个表达式。
调用此函数的代码随后将处理获取一个包含用户名的 Ok 值,或者一个包含 io::Error 的 Err 值。如何处理这些值取决于调用代码。例如,如果调用代码获得了 Err 值,它可以调用 panic! 使程序崩溃,可以使用一个默认用户名,或者从文件以外的某个地方查找用户名。我们没有足够的信息来了解调用代码实际想要做什么,因此我们将所有的成功或错误信息向上层传播,以便它能适当地处理。
这种传播错误的模式在 Rust 中非常常见,因此 Rust 提供了问号运算符 ? 来简化此操作。
传播错误的快捷方式:? 运算符
代码清单 9-7 展示了 read_username_from_file 的一种实现,其功能与代码清单 9-6 相同,但此实现使用了 ? 运算符。
#![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 trait 定义的 from 函数,该函数用于将值从一种类型转换为另一种类型。当 ? 运算符调用 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 所示。
#![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) } }
? 运算符后链式调用方法我们将创建新 String 到 username 中的操作移到了函数的开头;这部分没有改变。我们没有创建变量 username_file,而是直接将 read_to_string 的调用链式连接到 File::open("hello.txt")? 的结果上。在 read_to_string 调用末尾仍然有一个 ?,并且当 File::open 和 read_to_string 都成功而不是返回错误时,我们仍然返回包含 username 的 Ok 值。功能与代码清单 9-6 和代码清单 9-7 再次相同;这只是一种不同、更符合人体工程学的写法。
代码清单 9-9 展示了使用 fs::read_to_string 使其更短的方式。
#![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") } }
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 函数中使用 ? 运算符会得到什么错误。
use std::fs::File;
fn main() {
let greeting_file = File::open("hello.txt")?;
}
() 的 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
这个错误指出,我们只能在返回 Result、Option 或其他实现了 FromResidual trait 的类型的函数中使用 ? 运算符。
要修复这个错误,你有两个选择。一个选择是更改函数的返回类型,使其与你使用 ? 运算符作用的值类型兼容,只要没有限制阻止你这样做。另一个选择是使用 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<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(())。这段代码现在可以编译了。
use std::error::Error;
use std::fs::File;
fn main() -> Result<(), Box<dyn Error>> {
let greeting_file = File::open("hello.txt")?;
Ok(())
}
main 的返回类型改为 Result<(), E> 允许在 Result 值上使用 ? 运算符。Box<dyn Error> 类型是一个trait 对象(trait object),我们将在 “使用允许不同类型值的 Trait 对象”的第 18 章中讨论。现在,你可以将 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 的细节,让我们回到何时使用哪种方式才合适的主题。