Rust 学习笔记:错误处理
Rust 学习笔记:错误处理
- Rust 学习笔记:错误处理
- 不可恢复的错误
- 带有结果的可恢复错误
- 匹配不同的错误
- 出现错误时 panic 的快捷方式:unwrap 和 expect
- 传播错误
- 传播错误的快捷方式:? 操作符
- 哪里可以使用 ? 操作符
- panic or not panic,这是一个问题
- 示例、原型代码和测试
- 拥有比编译器更多信息的情况
- 为验证创建自定义类型
- 通过 from trait 返回自定义错误
- 错误处理指南
- 总结
Rust 学习笔记:错误处理
错误在软件中是不可避免的,所以 Rust 有很多特性可以处理出错的情况。
Rust 将错误分为两大类:可恢复的和不可恢复的错误。对于可恢复的错误,我们可能报告该错误并重试操作;对于不可恢复的错误,我们希望立即停止程序。
大多数语言不区分这两种错误,使用异常等机制进行处理。Rust 没有异常,类型为 Result<T, E> 表示可恢复的错误,而 panic! 宏在程序遇到不可恢复的错误时停止执行。
不可恢复的错误
在实践中,有两种方法可以引起 panic:
- 采取导致代码 panic 的操作(例如访问超过结束的数组)
- 显式调用 panic! 宏
默认情况下,panic 将打印一条失败消息,沿着堆栈返回并清理遇到的每个函数的数据,最后退出。该情况下,Rust 在 panic 发生时显示调用堆栈,以便更容易地跟踪 panic 的来源。
还有一种情况,在 Cargo.toml 中的 [profile] 部分中添加 panic = 'abort'
,设置在 panic 时立即中止,这将在不进行清理的情况下结束程序。
[profile.release]
panic = 'abort'
示例 1:
fn main() {panic!("crash and burn");
}
调用 panic 会导致最后两行中包含的错误消息,显示了我们的 panic 消息和源代码中发生 panic 的位置。
示例 2:
fn main() {let v = vec![1, 2, 3];v[99];
}
如果试图在一个不存在的索引上读取一个元素,Rust 将停止执行并 panic。
在 C 语言中,试图读取超出数据结构末端的数据是未定义的行为。你可能会得到内存中与数据结构中那个元素对应的位置,即使内存不属于那个结构。这被称为缓冲区过读,如果攻击者能够以这样一种方式操纵索引,从而读取存储在数据结构之后的不允许他们读取的数据,则可能导致安全漏洞。
Rust 还提醒我们:可以设置 RUST_BACKTRACE 环境变量(为任意不为 0 的值),以获得导致错误的确切回溯信息。
回溯是所有被调用的函数的列表。Rust 中的回溯和其他语言中的一样:从顶部开始读,直到看到自己写的文件,这就是问题产生的地方。该点上方的行是代码调用过的代码,下面几行代码调用了您的代码。
注意,这里使用 Windows 的 PowerShell 是无法运行的,可以切换 git bash 运行即可。
backtrace 的第 5 行指向项目中导致问题的行:src/main.rs 的第 4 行。如果我们不想让程序陷入恐慌,就应该从提到我们编写的文件的第一行所指向的位置开始调查。
根据操作系统和 Rust 版本的不同,backtrace 的确切输出可能会有所不同。
带有结果的可恢复错误
大多数错误并不严重到需要程序完全停止。有时,当一个函数失败时,它的原因很容易解释和响应。例如,如果您尝试打开一个文件,但由于该文件不存在而导致该操作失败,那么您可能希望创建该文件,而不是终止该进程。
Result 枚举被定义为有两个变体,Ok 和 Err,如下所示:
enum Result<T, E> {Ok(T),Err(E),
}
T 和 E 是泛型类型参数,T 表示成功情况下在 Ok 变量中返回的值的类型,而 E 表示失败情况下在 Err 变量中返回的错误的类型。我们可以在许多不同的情况下使用 Result 类型及其上定义的函数,其中我们希望返回的成功值和错误值可能不同。
让我们调用一个返回 Result 值的函数,因为该函数可能会失败:
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 函数需要有一种方法来告诉我们它是成功还是失败,同时给我们文件句柄或错误信息。这个信息正是 Result 枚举所传达的。
在 File::open 成功的情况下,变量 greeting_file_result 中的值将是一个包含文件句柄的 Ok 实例。在失败的情况下,greeting_file_result 中的值将是Err的一个实例,其中包含有关发生的错误类型的更多信息。
使用 match 能很好地处理 Result<T, E> 类型:
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:?}"),};
}
当结果为 Ok 时,这段代码将返回 Ok 变量的内部文件值,然后我们将该文件句柄值赋给变量 greeting_file。匹配之后,我们可以使用文件句柄进行读写。
当结果为 Err 时,我们选择调用 panic! 宏输出错误信息。
匹配不同的错误
我们希望针对不同的失败原因采取不同的行动。如果 File::open 因为文件不存在而失败,我们希望创建文件并返回新文件的句柄。如果 File::open 由于任何其他原因而失败,我们仍然希望代码 panic。
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,它表示我们试图打开的文件还不存在。这种情况下,我们尝试用 file::create 创建文件。但是,由于 File::create 也可能失败,我们还需要在内部匹配表达式中添加另一个 match:创建成功时,同样返回文件句柄;无法创建时,打印一条不同的错误消息。
出现错误时 panic 的快捷方式:unwrap 和 expect
使用 match 可能有点冗长,并且不能很好地传达意图。Result<T, E> 类型在其上定义了许多帮助器方法来执行各种更具体的任务。
unwrap 方法是一个快捷方法,如果 Result 值是 Ok 变体,则 unwrap 将返回 Ok 内部的值;如果 Result 是 Err 变体,unwrap 将调用 panic! 宏。
use std::fs::File;fn main() {let greeting_file = File::open("hello.txt").unwrap();
}
如果我们在没有 hello.txt 文件的情况下运行这段代码,我们将看到 panic 发出的错误消息:
使用 expect 可以自定义 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! 宏时打印我们传递的参数。
unwrap 更常用,因为可以在调试中使用更多的信息。
传播错误
当函数的实现调用可能失败的东西时,可以将错误返回给调用代码。这被称为传播错误,并为调用代码提供了更多的控制。
示例:
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 填充。
如果这个函数成功,调用这个函数的代码将收到一个 Ok 值,该值包含一个 String——这个函数从文件中读取的用户名。如果此函数遇到任何问题,调用代码将接收一个 Err 值,该值包含 io::Error 的实例,该实例包含有关问题所在的更多信息。
之所以选择 io::Error 作为函数的返回类型,是因为在函数体中调用的 File::open 函数和 read_to_string 方法这两个可能失败的操作返回的错误值的类型恰好是 io::Error。
这种传播错误的模式在 Rust 中非常常见,因此 Rust 为了方便起见提供了问号操作符 ? 。
传播错误的快捷方式:? 操作符
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 值之后,其工作方式与之前为处理 Result 值而定义的匹配表达式几乎相同。如果 Result 的值为Ok,则该表达式将返回 Ok 中的值,程序将继续执行。如果该值为 Err,则整个函数将返回 Err,就像我们使用了 return 关键字一样,因此错误值将传播到调用代码。
? 操作符消除了大量的样板文件,使这个函数的实现更简单。我们甚至可以通过在 ? 之后立即链接方法调用来进一步缩短代码:
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)
}
代码还能更短吗?当然可以!
将文件读入字符串是一种相当常见的操作,因此标准库提供了方便的 fs::read_to_string 函数,该函数打开文件,创建一个新的 String,读取文件的内容,将内容放入该 String并返回。
最终,函数被简化成了 one line code:
use std::fs;
use std::io;fn read_username_from_file() -> Result<String, io::Error> {fs::read_to_string("hello.txt")
}
哪里可以使用 ? 操作符
? 操作符只能在返回类型与使用 ? 的值兼容的函数中使用,即 Result 类型。
让我们看看一个错误示例:
use std::fs::File;fn main() {let greeting_file = File::open("hello.txt")?;
}
这段代码打开一个文件,可能会失败。? 操作符在 File::open 返回的 Result 值之后,但是这个主函数的返回类型是 (),而不是 Result。
这个错误指出我们只允许使用 ? 返回 Result、Option 或其他实现 FromResidual 的类型的函数中的操作符。
要修复这个错误,有两种选择。一种选择是更改函数的返回类型,使其与使用的值兼容。只要没有限制,就继续操作。另一种选择是使用 match 或 Result<T, E> 方法之一,以任何合适的方式处理 Result<T, E>。
? 操作符在Option<T>上调用时的行为与在 Result<T, E> 上调用时的行为相似:如果值为 None,则在该点将提前从函数返回 None。如果值是 Some,则 Some 内的值是表达式的结果值,函数继续执行。
下面给出一个示例:
fn last_char_of_first_line(text: &str) -> Option<char> {text.lines().next()?.chars().last()
}
这个函数返回 Option<char>,因为这里可能有字符,但也可能没有。
如果 text 不是空字符串,next 将返回一个 Some 值,其中包含 text 中第一行的字符串切片。? 提取字符串切片,然后调用该字符串切片上的 chars 来获取其字符的迭代器,调用 last 来返回迭代器中的最后一项。
注意,可以使用 ? 操作符对返回 Result 的函数中的 Result 进行操作,也可以使用 ? 操作符在返回 Option 的函数中对 Option 进行操作,但不能混合匹配。? 操作符不会自动将结果转换为选项,反之亦然。在这些情况下,可以使用 Result 上的 ok 方法或 Option 上的 ok_or 方法等方法显式地进行转换。
到目前为止,我们使用的所有 main函数都是 return ()。main 函数的特殊之处在于它是可执行程序的入口点和出口点,它的返回类型是有限制的,这样程序才能按照预期的方式运行。
幸运的是,main 函数也可以返回 Result<(), E>。我们将 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 对象,表示“任何类型的错误”。使用 ? 允许在错误类型为 Box<dyn error> 的主函数中返回 Result 值,因为它允许提前返回任何 Err 值,即使在 main 函数体中添加更多返回其他错误的代码,这个签名仍然是正确的。
当 main 函数返回 Result<(), E> 时,如果 main 函数返回 Ok(()),可执行程序将以 0 的值退出;如果 main 函数返回 Err 值,可执行程序将以非 0 的值退出。这与用 C 编写的可执行程序的约定兼容。
main 函数可以返回任何实现 std::process::Termination trait 的类型,它包含一个返回 ExitCode 的函数报告。
panic or not panic,这是一个问题
本节将总结一些关于如何决定是否在库代码中 panic 的一般指导原则。
示例、原型代码和测试
当编写示例来说明某些概念时,使用 panic 可以使得示例变得清晰。
类似地,在决定如何处理错误之前,unwrap 和 expect 方法在原型制作时非常方便,它们会在代码中留下清晰的标记。
如果一个方法调用在测试中失败,希望整个测试都失败,用 panic 比较好。
拥有比编译器更多信息的情况
当代码确保 Result 将具有 Ok 值时,调用 unwrap 或 expect 也是合适的,但仍然有一个需要处理的 Err 变体。
示例:
use std::net::IpAddr;let home: IpAddr = "127.0.0.1".parse().expect("Hardcoded IP address should be valid");
我们肯定希望以更健壮的方式处理结果。except 中处理了Result 为 Err 的情况。
为验证创建自定义类型
让我们进一步考虑使用 Rust 的类型系统来确保我们有一个有效的值,并看看如何创建一个用于验证的自定义类型。
完善之前的猜数游戏,引导用户进行有效的猜测,并在用户猜测超出范围的数字时与用户键入(例如字母)时具有不同的行为。
loop {// --snip--let guess: i32 = match guess.trim().parse() {Ok(num) => num,Err(_) => continue,};if guess < 1 || guess > 100 {println!("The secret number will be between 1 and 100.");continue;}match guess.cmp(&secret_number) {// --snip--}
if 表达式检查我们的值是否超出了范围,之后继续进行 guess 和秘密数之间的比较。然而,这不是一个理想的解决方案:如果程序只对 1 到 100 之间的值进行操作是绝对关键的,并且它有许多具有此需求的函数,那么在每个函数中进行这样的检查将是乏味的(并且可能影响性能)。
相反,我们可以在专用模块中创建新类型,并将验证放入函数中以创建该类型的实例,而不是到处重复验证。这样,函数就可以安全地在其签名中使用新类型,并放心地使用它们接收到的值。我们定义 Guess 类型的一种方法,该方法只在新函数接收到 1 到 100 之间的值时创建实例。
pub struct Guess {value: i32,
}impl Guess {pub fn new(value: i32) -> Guess {if value < 1 || value > 100 {panic!("Guess value must be between 1 and 100, got {value}.");}Guess { value }}pub fn value(&self) -> i32 {self.value}
}
首先,我们创建一个名为 guessing_game 的新模块。接下来,我们在该模块中定义了一个名为 Guess 的结构体,该结构体有一个 i32 类型、名为 value 的字段,这是存储数字的地方。
然后,我们在 Guess 上实现一个名为 new 的关联函数,该函数创建 Guess 值的实例。这个新函数被定义为有一个名为 value 的参数,类型为 i32,并返回 Guess。代码测试 value 以确保它在 1 到 100 之间。如果 value 没有通过这个测试,就会 panic。
接下来,我们实现一个名为 value 的方法,它借用 self,没有任何其他参数,并返回一个 i32。这种方法有时被称为 getter,因为它的目的是从字段中获取一些数据并返回这些数据。这个公共方法是必要的,因为 Guess 结构体的 value 字段是私有的。
如果一个函数有一个参数或者只返回 1 到 100 之间的数字,那么它就可以在它的签名中声明它接受或返回一个 Guess 而不是一个 i32,并且不需要在函数体中做任何额外的检查。
通过 from trait 返回自定义错误
这里将 io::Error 和 ParseIntError 通过 from trait 转换成枚举 MyError。
use std::{fs, io};
use std::fs::File;
use std::io::{read_to_string, Read};
use std::num::ParseIntError;#[derive(Debug)]
pub enum MyError {Io(io::Error),ParseInt(ParseIntError),Other(String),
}impl From<io::Error> for MyError {fn from(err: io::Error) -> Self {MyError::Io(err)}
}impl From<ParseIntError> for MyError {fn from(err: ParseIntError) -> Self {MyError::ParseInt(err)}
}fn read_username_from_file() -> Result<String, MyError> {let mut name = String::new();let file = File::open("username.txt")?.read_to_string(&mut name)?;let num: i32 = "55".parse()?;Ok(name)
}fn main() {}
错误处理指南
当您的代码可能以糟糕的状态结束时,建议让代码陷入 panic。坏状态包含:
- 无效值、矛盾值或丢失值被传递给代码
- 用户以错误的格式输入数据
然而,当失败在所难免时,返回 Result 比制造 panic 更合适。
总结
Rust 的错误处理特性旨在帮助编写更健壮的代码。
panic! 宏表示程序处于无法处理的状态,并将进程停止,而不是尝试使用无效或不正确的值继续。
枚举 Result 使用 Rust 的类型系统来指示操作可能失败的方式,代码可以从中恢复。
何时使用 panic:不可恢复的错误场景
- 程序进入不可预期的 bad state
- 安全问题
- 代码不能继续执行
- 违反函数契约或关键假设
何时使用 Result:可能恢复的错误场景
- 提供恢复选项
- 预期可能发生的错误
- 希望调用者决定如何处理错误
推荐使用 panic 的情况:
- 原型代码、示例、测试
- 安全性关键的输入验证
- 调用外部不可控代码时的异常状态
推荐使用 Result 的情况:
- 处理可预期的错误
- HTTP 请求失败
- 解析错误
- 用户输入验证
更多资料参见 Rust Book: Error Handling - To panic! or not to panic!