# 要 panic! 还是不要 panic!
那么,你如何决定何时应该调用 panic! 以及何时应该返回 Result 呢?当代码发生 panic 时,没有恢复的方法。你可以为任何错误情况调用 panic!,无论是否有可能恢复,但这样你就代表调用代码做出了一个情况不可恢复的决定。当你选择返回 Result 值时,你给了调用代码选择权。调用代码可以选择以适合其情况的方式尝试恢复,或者它可以决定在这种情况下 Err 值是不可恢复的,所以它可以调用 panic! 并将你的可恢复错误转变为不可恢复错误。因此,返回 Result 是定义可能失败的函数时的一个很好的默认选择。
在示例、原型代码和测试等情况下,编写发生 panic 的代码比返回 Result 更为合适。让我们探讨一下原因,然后讨论编译器无法判断失败是不可能的情况,但作为人类,你可以判断。本章将以一些关于如何决定是否在库代码中使用 panic 的一般指导原则作为结束。
# 示例、原型代码和测试
当你编写一个示例来说明某个概念时,同时包含健壮的错误处理代码可能会使示例不那么清晰。在示例中,大家都理解像 unwrap 这样可能会 panic 的方法调用只是你希望应用程序处理错误的方式的占位符,这可能会根据你代码的其余部分在做什么而有所不同。
同样,在原型设计中,unwrap 和 expect 方法非常方便,在你准备好决定如何处理错误之前。它们在你的代码中留下清晰的标记,表明你准备好让程序更加健壮的时候。
如果测试中的方法调用失败,你会希望整个测试失败,即使该方法不是被测试的功能。因为 panic! 是测试被标记为失败的方式,所以调用 unwrap 或 expect 正是应该发生的事情。
# 你比编译器拥有更多信息的情况
当你有一些其他逻辑确保 Result 将具有 Ok 值,但这种逻辑不是编译器能够理解的,调用 unwrap 或 expect 也是合适的。你仍然会有一个需要处理的 Result 值:你调用的任何操作仍然有可能在一般情况下失败,即使在你特定的情况下逻辑上是不可能的。如果你可以通过手动检查代码来确保你永远不会有 Err 变体,那么调用 unwrap 是完全可以接受的,甚至更好的是在 expect 文本中记录你认为永远不会有 Err 变体的原因。这里有一个例子:
fn main() {
use std::net::IpAddr;
let home: IpAddr = "127.0.0.1"
.parse()
.expect("Hardcoded IP address should be valid");
}
我们通过解析一个硬编码的字符串来创建一个 IpAddr 实例。我们可以看到 127.0.0.1 是一个有效的 IP 地址,所以在这里使用 expect 是可以接受的。然而,拥有一个硬编码的有效字符串并不会改变 parse 方法的返回类型:我们仍然会得到一个 Result 值,编译器仍然会让我们处理 Result,就好像 Err 变体是一种可能性一样,因为编译器不够聪明,无法看出这个字符串总是一个有效的 IP 地址。如果 IP 地址字符串来自用户而不是硬编码到程序中,因此确实有失败的可能性,我们肯定会希望以更健壮的方式处理 Result。提到这个 IP 地址是硬编码的假设将提示我们,如果将来我们需要从其他来源获取 IP 地址,就会改变 expect 为更好的错误处理代码。
# 错误处理的指导原则
当你的代码可能会陷入糟糕状态时,建议让你的代码 panic。在这种情况下,糟糕状态是指某些假设、保证、契约或不变量被破坏,例如当无效值、矛盾值或缺失值被传递给你的代码时,再加上以下一项或多项:
- 糟糕状态是意外的,而不是可能经常发生的事情,比如用户以错误格式输入数据。
- 在这一点之后,你的代码需要依赖于不处于这种糟糕状态,而不是在每一步都检查问题。
- 没有好的方法在你使用的类型中编码这些信息。我们将在第 18 章的"将状态和行为编码为类型"部分中详细讨论这个含义。
如果有人调用你的代码并传入没有意义的值,如果可能的话,最好返回一个错误,这样库的用户可以决定在这种情况下他们想做什么。然而,在继续可能不安全或有害的情况下,最好的选择可能是调用 panic! 并提醒使用你的库的人注意他们代码中的错误,以便他们在开发过程中修复它。同样,如果你调用的外部代码超出了你的控制范围,并且它返回了一个你无法修复的无效状态,panic! 通常是合适的。
然而,当预期会失败时,返回 Result 比调用 panic! 更合适。例子包括解析器被给予格式错误的数据,或者 HTTP 请求返回表明你已达到速率限制的状态。在这些情况下,返回 Result 表明失败是一种预期的可能性,调用代码必须决定如何处理。
当你的代码执行一个操作,如果使用无效值调用可能会使用户处于风险中,你的代码应该首先验证值是否有效,如果值无效则 panic。这主要是出于安全原因:尝试对无效数据进行操作可能会使你的代码暴露于漏洞。这是标准库在你尝试进行越界内存访问时调用 panic! 的主要原因:尝试访问不属于当前数据结构的内存是一个常见的安全问题。函数通常有契约:只有在输入满足特定要求时,它们的行为才能得到保证。当契约被违反时,panic 是有意义的,因为契约违反总是表明调用方的错误,而且这不是你希望调用代码必须明确处理的错误类型。事实上,对于调用代码来说,没有合理的恢复方式;调用程序员需要修复代码。函数的契约,特别是当违反会导致 panic 时,应该在函数的 API 文档中解释。
然而,在所有函数中都有大量的错误检查会很冗长且令人烦恼。幸运的是,你可以使用 Rust 的类型系统(以及编译器完成的类型检查)来为你完成许多检查。如果你的函数有一个特定类型的参数,你可以继续你的代码逻辑,知道编译器已经确保你有一个有效值。例如,如果你有一个类型而不是 Option,你的程序期望有东西而不是什么都没有。然后你的代码不必处理 Some 和 None 变体的两种情况:它只会有一种情况,即肯定有一个值。尝试向你的函数传递空值的代码甚至不会编译,所以你的函数不必在运行时检查这种情况。另一个例子是使用无符号整数类型,如 u32,这确保参数永远不会是负数。
# 创建用于验证的自定义类型
让我们更进一步,利用 Rust 的类型系统来确保我们有一个有效值,并看看创建一个用于验证的自定义类型。回想一下第 2 章中的猜谜游戏,我们的代码要求用户猜一个 1 到 100 之间的数字。我们从未在检查用户的猜测与我们的秘密数字之前验证用户的猜测是否在这些数字之间;我们只验证猜测是否为正数。在这种情况下,后果并不是很严重:我们的输出"太高"或"太低"仍然是正确的。但是,引导用户进行有效猜测并在用户猜测的数字超出范围时与用户输入字母时有不同的行为,这将是一个有用的增强功能。
实现这一点的一种方法是将猜测解析为 i32 而不仅仅是 u32,以允许可能的负数,然后添加一个检查,确保数字在范围内,如下所示:
文件名:src/main.rs:
use rand::Rng;
use std::cmp::Ordering;
use std::io;
fn main() {
println!("Guess the number!");
let secret_number = rand::thread_rng().gen_range(1..=100);
loop {
// --snip--
println!("Please input your guess.");
let mut guess = String::new();
io::stdin()
.read_line(&mut guess)
.expect("Failed to read line");
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--
Ordering::Less => println!("Too small!"),
Ordering::Greater => println!("Too big!"),
Ordering::Equal => {
println!("You win!");
break;
}
}
}
}
if 表达式检查我们的值是否超出范围,告诉用户问题所在,并调用 continue 开始循环的下一次迭代并要求另一个猜测。在 if 表达式之后,我们可以继续比较 guess 和秘密数字,知道 guess 在 1 到 100 之间。
然而,这不是一个理想的解决方案:如果程序只对 1 到 100 之间的值进行操作是绝对关键的,并且它有许多具有此要求的函数,那么在每个函数中都进行这样的检查将会很繁琐(并可能影响性能)。
相反,我们可以创建一个新类型,并将验证放在一个函数中,以创建该类型的实例,而不是在各处重复验证。这样,函数可以安全地在其签名中使用新类型,并自信地使用它们接收的值。清单 9-13 展示了一种定义 Guess 类型的方法,该类型只有在新函数接收到 1 到 100 之间的值时才会创建 Guess 的实例。
#![allow(unused)]
fn main() {
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
}
}
}
清单 9-13:一个只有在值在 1 到 100 之间时才会继续的 Guess 类型
首先,我们定义了一个名为 Guess 的结构体,它有一个名为 value 的字段,该字段持有一个 i32。这是存储数字的地方。
然后,我们在 Guess 上实现了一个名为 new 的关联函数,用于创建 Guess 值的实例。new 函数被定义为有一个名为 value 的 i32 类型参数,并返回一个 Guess。new 函数体中的代码测试 value 以确保它在 1 到 100 之间。如果 value 没有通过这个测试,我们会调用 panic!,这将提醒编写调用代码的程序员,他们有一个需要修复的错误,因为创建一个值超出此范围的 Guess 将违反 Guess::new 所依赖的契约。Guess::new 可能会 panic 的条件应该在其面向公众的 API 文档中讨论;我们将在第 14 章中介绍你创建的 API 文档中指示 panic! 可能性的文档约定。如果 value 通过测试,我们创建一个新的 Guess,其 value 字段设置为 value 参数,并返回 Guess。
接下来,我们实现了一个名为 value 的方法,它借用 self,没有其他参数,并返回一个 i32。这种方法有时被称为 getter,因为它的目的是从其字段获取一些数据并返回它。这个公共方法是必要的,因为 Guess 结构体的 value 字段是私有的。重要的是 value 字段是私有的,这样使用 Guess 结构体的代码就不允许直接设置 value:模块外的代码必须使用 Guess::new 函数创建 Guess 的实例,从而确保没有办法让 Guess 拥有一个没有经过 Guess::new 函数中的条件检查的值。
一个只有参数或只返回 1 到 100 之间的数字的函数,可以在其签名中声明它接受或返回一个 Guess 而不是 i32,并且不需要在其函数体中进行任何额外的检查。
# 总结
Rust 的错误处理功能旨在帮助你编写更健壮的代码。panic! 宏表明你的程序处于无法处理的状态,让你告诉进程停止,而不是尝试继续使用无效或不正确的值。Result 枚举使用 Rust 的类型系统来表明操作可能会以你的代码可以恢复的方式失败。你可以使用 Result 告诉调用你代码的代码,它需要处理潜在的成功或失败。在适当的情况下使用 panic! 和 Result 将使你的代码在面对不可避免的问题时更加可靠。
现在你已经看到了标准库如何使用泛型与 Option 和 Result 枚举的有用方法,我们将讨论泛型如何工作以及如何在你的代码中使用它们。