该用 panic! 还是不用 panic!
那么,你该如何决定何时应该调用 panic!,何时应该返回 Result 呢?当代码 panic 时,就没有恢复的可能了。你可以对任何错误情况都调用 panic!,不管是否有可能恢复,但这样你就替调用者做出了“这个情况不可恢复“的决定。当你选择返回 Result 值时,你把选择权交给了调用代码。调用代码可以选择以适合其场景的方式尝试恢复,也可以认定这个 Err 值是不可恢复的,从而调用 panic! 将你的可恢复错误变成不可恢复错误。因此,当你定义一个可能失败的函数时,返回 Result 是一个好的默认选择。
在示例代码、原型代码和测试中,使用 panic 比返回 Result 更合适。接下来我们探讨一下原因,然后讨论编译器无法判断失败不可能发生、但你作为开发者可以判断的情况。本章最后会给出一些关于在库代码中是否应该 panic 的通用指导原则。
示例代码、原型代码和测试
当你编写示例来阐述某个概念时,同时包含健壮的错误处理代码会使示例变得不够清晰。在示例中,大家都理解调用像 unwrap 这样可能 panic 的方法只是一个占位符,代表你希望应用程序处理错误的方式,具体方式会因代码的其余部分而异。
类似地,在原型开发阶段,当你还没准备好决定如何处理错误时,unwrap 和 expect 方法非常方便。它们在代码中留下了清晰的标记,等你准备好让程序更加健壮时就可以处理它们。
如果在测试中某个方法调用失败了,你会希望整个测试都失败,即使该方法并不是被测试的功能。因为 panic! 正是将测试标记为失败的方式,所以调用 unwrap 或 expect 正是应该做的事情。
当你比编译器掌握更多信息时
当你有其他逻辑能确保 Result 一定是 Ok 值,但编译器无法理解这个逻辑时,调用 expect 也是合适的。你仍然需要处理一个 Result 值:你调用的操作在一般情况下仍然有可能失败,即使在你的特定场景中逻辑上不可能失败。如果你通过手动检查代码能确保永远不会出现 Err 变体,那么调用 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 改为更好的错误处理代码。
错误处理指导原则
当你的代码有可能进入无效状态(bad state)时,建议让代码 panic。在这个语境中,无效状态是指某些假设、保证、契约或不变量被打破了,例如无效的值、矛盾的值或缺失的值传递给了你的代码——并且满足以下一个或多个条件:
- 这个无效状态是非预期的,而不是偶尔可能发生的事情,比如用户输入了错误格式的数据。
- 此后的代码需要依赖于不处于这种无效状态,而不是在每一步都检查这个问题。
- 没有好的方式将这个信息编码到你使用的类型中。我们将在第 18 章的“将状态和行为编码为类型”部分通过一个例子来说明这一点。
如果别人调用你的代码并传入了无意义的值,最好是返回一个错误,让库的使用者决定在这种情况下该怎么做。然而,在继续执行可能不安全或有害的情况下,最好的选择可能是调用 panic!,提醒使用你的库的人注意他们代码中的 bug,以便他们在开发过程中修复它。类似地,如果你调用了不受你控制的外部代码,而它返回了你无法修复的无效状态,调用 panic! 通常也是合适的。
然而,当失败是可预期的,返回 Result 比调用 panic! 更合适。例如,解析器收到了格式错误的数据,或者 HTTP 请求返回了表示你触发了速率限制的状态码。在这些情况下,返回 Result 表明失败是一种可预期的可能性,调用代码必须决定如何处理。
当你的代码对使用无效值进行操作可能会让用户面临风险时,代码应该先验证值是否有效,如果值无效则 panic。这主要是出于安全考虑:尝试操作无效数据可能会暴露代码的漏洞。这也是标准库在你尝试越界访问内存时会调用 panic! 的主要原因:尝试访问不属于当前数据结构的内存是一个常见的安全问题。函数通常有契约(contracts):只有在输入满足特定要求时,其行为才是有保证的。当契约被违反时 panic 是合理的,因为契约违反总是意味着调用方的 bug,而且这不是你希望调用代码去显式处理的那种错误。实际上,调用代码没有合理的方式来恢复;需要修复代码的是调用方的程序员。函数的契约,特别是当违反契约会导致 panic 时,应该在函数的 API 文档中加以说明。
然而,在所有函数中都进行大量的错误检查会很冗长且烦人。幸运的是,你可以利用 Rust 的类型系统(以及编译器执行的类型检查)来为你完成许多检查。如果你的函数以某个特定类型作为参数,你就可以在编译器已经确保你拥有一个有效值的前提下继续编写代码逻辑。例如,如果你使用的是一个具体类型而不是 Option,那么你的程序期望的是有值而不是无值。这样你的代码就不需要处理 Some 和 None 两种情况:它只有一种情况,即确定有一个值。试图向你的函数传入空值的代码甚至无法通过编译,所以你的函数在运行时不需要检查这种情况。另一个例子是使用无符号整数类型如 u32,这确保了参数永远不会是负数。
创建自定义类型进行验证
让我们更进一步,利用 Rust 的类型系统来确保我们拥有一个有效值,看看如何创建自定义类型来进行验证。回忆一下第 2 章的猜数字游戏,我们的代码要求用户猜一个 1 到 100 之间的数字。在将用户的猜测与我们的秘密数字进行比较之前,我们从未验证过用户的猜测是否在这些数字之间;我们只验证了猜测是正数。在这种情况下,后果并不严重:我们输出的“太大了“或“太小了“仍然是正确的。但引导用户做出有效的猜测,并在用户猜测超出范围与用户输入非数字字符时采取不同的行为,会是一个有用的改进。
一种实现方式是将猜测解析为 i32 而不仅仅是 u32,以允许可能的负数,然后添加一个范围检查,如下所示:
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 在 1 到 100 之间的前提下,继续进行 guess 与秘密数字的比较。
然而,这并不是一个理想的解决方案:如果程序绝对必须只操作 1 到 100 之间的值,并且有很多函数都有这个要求,那么在每个函数中都进行这样的检查会很繁琐(而且可能影响性能)。
相反,我们可以在一个专门的模块中创建一个新类型,将验证逻辑放在创建该类型实例的函数中,而不是到处重复验证。这样,函数就可以安全地在其签名中使用这个新类型,并放心地使用接收到的值。示例 9-13 展示了一种定义 Guess 类型的方式,它只会在 new 函数接收到 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
}
}
}
Guess 类型注意这段代码位于 src/guessing_game.rs 中,它依赖于在 src/lib.rs 中添加模块声明 mod guessing_game;,这里我们没有展示。在这个新模块的文件中,我们定义了一个名为 Guess 的结构体,它有一个名为 value 的 i32 类型字段。数字将存储在这里。
然后,我们在 Guess 上实现了一个名为 new 的关联函数,用于创建 Guess 值的实例。new 函数定义了一个 i32 类型的 value 参数,并返回一个 Guess。new 函数体中的代码会测试 value 以确保它在 1 到 100 之间。如果 value 没有通过测试,我们会调用 panic!,这将提醒编写调用代码的程序员他们有一个需要修复的 bug,因为创建一个 value 超出此范围的 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 的 value 不可能未经 Guess::new 函数中的条件检查。
一个参数或返回值只能是 1 到 100 之间数字的函数,就可以在其签名中声明它接受或返回 Guess 而不是 i32,这样就不需要在函数体中做任何额外的检查了。
总结
Rust 的错误处理功能旨在帮助你编写更健壮的代码。panic! 宏表示你的程序处于一个它无法处理的状态,并让你告诉进程停止运行,而不是尝试使用无效或不正确的值继续执行。Result 枚举利用 Rust 的类型系统来表明操作可能会以一种你的代码可以恢复的方式失败。你可以使用 Result 来告诉调用你代码的代码,它需要处理潜在的成功或失败。在适当的场景中使用 panic! 和 Result 会使你的代码在面对不可避免的问题时更加可靠。
既然你已经看到了标准库如何通过 Option 和 Result 枚举来使用泛型,我们接下来将讨论泛型的工作原理以及如何在你的代码中使用它们。