panic! 还是不要 panic!

那么,您如何决定何时应该调用 panic!,何时应该返回 Result 呢?当代码发生 panic 时,没有办法恢复。您可以为任何错误情况调用 panic!,无论是否有可能恢复,但这样您就代表调用代码决定了情况是不可恢复的。当您选择返回 Result 值时,您为调用代码提供了选择。调用代码可以选择尝试以适合其情况的方式进行恢复,或者它可以决定在这种情况下 Err 值是不可恢复的,因此它可以调用 panic! 并将您的可恢复错误变成不可恢复的错误。因此,当您定义可能失败的函数时,返回 Result 是一个很好的默认选择。

在示例、原型代码和测试等情况下,编写会 panic 而不是返回 Result 的代码更合适。让我们探讨一下原因,然后讨论编译器无法判断故障是否不可能,但作为人类您可以判断的情况。本章最后将总结一些关于如何在库代码中决定是否 panic 的通用指南。

示例、原型代码和测试

当您编写示例来说明某些概念时,也包含健壮的错误处理代码可能会使示例不太清晰。在示例中,人们理解,对可能 panic 的 unwrap 等方法的调用是指您希望应用程序如何处理错误的占位符,这可能会因代码的其余部分正在做什么而有所不同。

同样,unwrapexpect 方法在原型设计时非常方便,在您准备好决定如何处理错误之前。它们在您的代码中留下了清晰的标记,指示您何时准备好使您的程序更健壮。

如果方法调用在测试中失败,您会希望整个测试都失败,即使该方法不是正在测试的功能。因为 panic! 是如何将测试标记为失败的方式,所以调用 unwrapexpect 正是应该发生的事情。

您掌握的信息比编译器更多的情况

当您有一些其他逻辑可以确保 Result 将具有 Ok 值时,调用 unwrapexpect 也是合适的,但该逻辑不是编译器可以理解的。您仍然会有一个 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 是有道理的,因为约定违反总是表明调用方代码存在 bug,并且这不是您希望调用代码必须显式处理的错误类型。事实上,调用代码没有合理的恢复方法;调用程序员需要修复代码。函数的约定,尤其是当违反约定会导致 panic 时,应在函数的 API 文档中解释。

但是,在您的所有函数中进行大量错误检查将是冗长且令人讨厌的。幸运的是,您可以使用 Rust 的类型系统(以及编译器完成的类型检查)来为您完成许多检查。如果您的函数具有特定类型作为参数,您可以继续执行代码的逻辑,因为编译器已经确保您具有有效值。例如,如果您有一个类型而不是 Option,您的程序希望有东西而不是什么都没有。然后,您的代码不必处理 SomeNone 变体的两种情况:它将只处理一种肯定有值的情况。试图将空值传递给您的函数的代码甚至无法编译,因此您的函数不必在运行时检查这种情况。另一个示例是使用无符号整数类型(如 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 类型的方法,该类型仅在 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 } } }
列表 9-13:Guess 类型,它仅在值介于 1 和 100 之间时才继续

首先,我们定义一个名为 Guess 的结构体,它有一个名为 value 的字段,该字段保存一个 i32。这是数字将存储的位置。

然后,我们在 Guess 上实现一个名为 new 的关联函数,该函数创建 Guess 值的实例。new 函数被定义为具有一个名为 value 的类型为 i32 的参数,并返回一个 Guessnew 函数体中的代码测试 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 不可能具有未经 Guess::new 函数中的条件检查的 value

一个具有参数或仅返回介于 1 和 100 之间的数字的函数可以在其签名中声明它接受或返回一个 Guess 而不是 i32,并且不需要在其主体中进行任何额外的检查。

总结

Rust 的错误处理功能旨在帮助您编写更健壮的代码。panic! 宏表示您的程序处于无法处理的状态,并让您告诉进程停止,而不是尝试使用无效或不正确的值继续运行。Result 枚举使用 Rust 的类型系统来指示操作可能会以您的代码可以从中恢复的方式失败。您可以使用 Result 来告诉调用您的代码的代码,它也需要处理潜在的成功或失败。在适当的情况下使用 panic!Result 将使您的代码在面对不可避免的问题时更加可靠。

现在您已经看到了标准库将泛型与 OptionResult 枚举一起使用的有用方法,我们将讨论泛型的工作原理以及如何在您的代码中使用它们。