要 panic!
还是不要 panic!
To panic!
or Not to panic!
那么,当代码死机无法恢复时,咱们要如何决定何时应该调用 panic!
,何时应返回 Result
呢?对于任何错误情形,无论是否有可能恢复,咱们都可以调用 panic!
,但这样一来,咱们就代表调用代码,做出了无法恢复的决定。在咱们选择返回一个 Result
值时,咱们就给了调用代码一些选择。调用代码可以选择以适合其情况的方式尝试恢复,或者他可以决定在这种情况下的某个 Err
值是不可恢复的,因此他可以调用 panic!
并将咱们的可恢复错误,变成一个不可恢复的错误。因此,在咱们定义某个可能失败的函数时,返回 Result
是一个不错的默认选择。
在示例程序、原型代码及测试等情况下,编写会死机而非返回一个 Result
的代码更为合适。我们来探讨一下原因,然后讨论在哪些情况下,编译器无法区分出失败是不可行的,但作为人类咱们却可以。本章最后将介绍一些,关于如何决定是否要在库代码中使用执行死机的一般指导原则。Let's explore why, then discuss situations in which the compiler can't tell that failure is impossible, but you as a human can. The chapter will conclude with some general guidelines on how to decide whether to panic in library code.
示例程序、原型代码与测试
Examples, Prototype Code, and Tests
在咱们编写说明某些概念的示例程序时,同时包含一些健壮的错误处理代码,会使示例程序变得不那么明了。在示例程序中,到像 unwrap
这样可能会死机的某个方法的调用,往往是作为咱们打算咱们应用程序处理错误方式的占位符,而根据咱们代码其余部分的操作,咱们程序处理错误方式可能不一样。
同样,在咱们原型设计时,准备好决定如何处理错误前,unwrap
和 expect
两个方法非常方便。他们会在咱们的代码中,留下一些清晰的标记,以便咱们准备好让程序更健壮时使用。
如果一次测试中的某个方法调用失败,咱们会希望整个测试都失败,即使该方法并不属于被测试的功能。因为 panic!
是标记测试失败的方式,调用 unwrap
或 expect
正是不二之选。
相比于编译器,咱们掌握了更多信息的情况
Cases in Which You Have More Information Than the Compiler
当咱们有其他确保 Result
值将有着一个 OK
值的逻辑时,调用 unwrap
或 expect
也是合适的,但编译器并不理解这些逻辑。咱们仍将有个咱们需要处理的 Result
值:无论咱们调用的是何种操作,在一般情况下都有可能失败,即使在咱们特定情况下该操作逻辑上是不可行的。若咱们能通过手动检查代码,确保咱们绝不会有个 Err
变种,那么调用 unwrap
就是完全可接受的,甚至最好在 expect
文本中,记录下咱们认为不会出现 Err
变种的原因。下面是个例子:
fn main () { use std::net::IpAddr; let home: IpAddr = "127.0.0.1" .parse() .expect("硬编码的 IP 地址应是有效的"); }
通过解析一个硬编码字符串,我们创建出一个 IpAddr
实例。我们可以看到,127.0.0.1
是个有效的 IP 地址,因此在这里使用 expect
是可接受的。然而,有着一个硬编码的有效字符串,并不会改变 parse
这个方法的返回类型:我们仍会得到一个 Result
值,同时编译器仍将让我们按照 Err
变种为一种可能性一样,处理这个 Result
,因为编译器还不够聪明,无法发现这个字符串始终是个有效的 IP 地址。在这个 IP 地址字符串是来自用户,而不是硬编码到程序中的时,就因此 确实 存在失败的可能性,那么我们肯定就会希望以一种更健壮的方式,处理这个 Result
。在将来我们需要从其他来源获取该 IP 地址时,那么提及这个 IP 地址是硬编码的假设,将提醒我们将 expect
修改成更好的错误处理代码。
错误处理指南
Guidelines for Error Handling
当咱们的代码有可能陷入某种糟糕的状态时,那么最好让咱们的代码死机。在此语境下,所谓 糟糕状态,bad state 是指某个假定、保证、合约,或恒定值被破坏,例如在无效值、矛盾值或缺失值被传递给咱们的代码时 -- 再加上以下一种或多种情况:
-
糟糕状态是指意想不到的某种情况,与偶尔会发生的情况相反,比如错误格式的用户输入数据等;
-
咱们此处之后的代码,需要依赖于不再处于这种不良状态,而不是在每一步都检查问题;
-
并无以咱们所使用类型,编码这些信息的某种良好方式。我们将在第 18 章 “将状态和行为编码为类型” 中,举例说明这个意思。
如果有人调用了咱们的代码并传入不合理的值,那么最好是咱们尽可能返回某个错误,这样该库的用户就可以决定,他们在这种情况下要做什么。但是,如果继续下去可能不安全或有害,那么最好的选择就可能是调用 panic!
,并提醒使用咱们库的人他们代码中的错误,以便他们在开发过程中修复错误。类似地,若咱们调用的外部代码不在咱们的掌控中,而他返回了咱们无法修复的某种无效状态,这时通常也适合调用 panic!
。
不过,当失败属于预期中的时,返回一个 Result
就比调用一次 panic!
更合适。例如,某个解析器得到畸形数据,或者某次 HTTP 请求返回了一个表明咱们已达速率限制的状态时。在这些情况下,返回一个 Result
就表明失败是预期的、调用代码必须决定如何处理的一种可能。
当咱们的代码执行某项若其调用的是无效值时,可能会给用户带来风险的操作时,咱们的代码就应首先检查这些值是有效的,并在值无效时死机。这主要是出于安全考虑:尝试在无效数据上操作,会使咱们的代码暴露于漏洞之中。这也是标准库会在咱们尝试越界内存访问时,调用 panic!
的主要原因:尝试访问不属于当前数据结构的内存,是个常见的安全问题。函数通常都有 合约,contracts:只有在输入满足特定要求时,他们的行为才有保证。在这种合约破坏时死机是有道理的,因为合约破坏总是表明某个调用方的错误,而这并不是咱们希望调用代码必须要显式处理的错误类型。事实上,并没有调用代码恢复的合理方式;调用的 程序员 需要修复代码。函数的合约,尤其是在破坏会导致死机时,应在该函数的 API 文档中加以说明。
然而,在所有咱们的函数中,进行大量的错误检查会显得冗长而烦人。幸运的是,咱们可使用 Rust 的类型系统(及因此由编译器完成的类型检查),为咱们完成许多的检查。若咱们的函数有某个特定类型的参数,咱们就可以在知悉编译器已确保了咱们有个有效值下,处理咱们代码的逻辑。例如,在咱们有个类型而不是 Option
时,那么咱们的程序就会期望得到 某个东西,something,而不是 全无,nothing。这样,咱们的代码就不必处理 Some
与 None
变种的两种情况:他只有肯定有某个值的一种情况。试图向咱们的函数传递全无的代码,甚至不会编译,因此咱们函数在运行时就不必检查这种情况。另一个例子是使用如 u32
的无符号整数类型,这确保参数绝不会是负数。
创建用于验证的定制类型
Creating Custom Types for Validation
我们来把使用 Rust 的类型系统,确保我们有个有效值的想法向前推进一步,看看如何创建出一个用于验证的定制类型。请回顾第 2 章中的猜数游戏,其中我们的代码要求用户猜一个 1
到 100
之间的数字。在将用户的猜数与我们的秘密数字核对前,我们从未验证过其是否介于这两个数字之间;我们只验证了用户猜数是个正数。在这种情况下,后果并不严重:我们“太大”或“太小”的输出,将仍然是正确的。但是,往有效猜数引导用户,并在用户猜的数字超出范围时,及用户输入字母等时有不同的行为,这将是个非常有用的增强功能。
实现这一增强的一种方法,是将猜测值解析为 i32
,而不是 u32
,允许出现潜在的负数,然后添加一个数字在范围内的检查,就像这样:
文件名:src/main.rs
#![allow(unused)] fn main() { loop { // --跳过-- let guess: i32 = match guess.trim().parse() { Ok(num) => num, Err(_) => { println! ("请输入一个数字!"); continue }, }; if guess < 1 || guess > 100 { println!("秘密数字将在 1 和 100 之间。"); continue; } match guess.cmp(&secret_number) { // --跳过-- } }
其中 if
表达式会检查我们的值是否超出范围,告诉用户问题所在,并调用 continue
开始循环的下一次迭代并请求另一个猜数。在这个 if
表达式后,我们可以在清楚 guess
介于 1
和 100
之间下,继续 guess
与秘密数字间的比较。
然而,这不是一种理想解决方案:在程序只对 1
到 100
间的值进行操作绝对重要,并且程序有着许多有此要求的函数,那么在每个函数中都进行这样的检查,将是非常乏味的(而且可能会影响性能)。
相反,我们可在一个专门模组中构造一个新类型,并把这些验证放入一个函数中,从而创建出该类型的实例,而不是在各处重复这些验证。这样,函数就可以安全地在其签名中使用这个新类型,并放心地使用接收到的值。下面清单 9-13 展示了定义 Guess
类型的一种方法,只有在 new
函数接收到介于 1
和 100
之间的值时,才会创建出一个 Guess
的实例。
文件名:src/guessing_game.rs
#![allow(unused)] fn main() { pub struct Guess { value: i32, } impl Guess { pub fn new(value: i32) -> Guess { if value < 1 || value > 100 { panic!("猜数值必须在 1 与 100 之间,得到了 {value}。"); } Guess { value } } pub fn value(&self) -> i32 { self.value } } }
清单 9-13:只有在处于 1
与 100
之间的值下才会继续的 Guess
类型
首先,我们创建了个名为 guessing_game
的新模组。接着,我们在该模块中定义了个名为 Guess
的结构体,该结构体有个名为 value
的字段,其中存放着一个 i32
。数字将存储于该处。
然后,我们在 Guess
上实现了一个名为 new
的关联函数,创建 Guess
值的实例。new
函数被定义为有个名为 value
,类型为 i32
的参数,并返回一个 Guess
。new
函数主体中的代码,会测试 value
确保他介于 1
和 100
之间。在 value
没有通过此测试时,我们进行一次 panic!
调用,这将提醒编写调用代码的程序员,他们有一个需要修复的错误,因为以在此范围之外的一个 value
创建一个 Guess
,将违反 Guess::new
所依赖的合约。Guess::new
可能会死机的那些条件,应该在面向公众的 API 文档中被提及;我们将在第 14 章中,介绍在咱们创建的 API 文档中,说明 panic!
可能性的文档约定。在 value
确实通过了测试时,我们就会创建出一个新的 Guess
,并将其 value
字段设置为参数 value
,然后返回该 Guess
。
接下来,我们实现了一个名为 value
的方法,该方法借用了 self
,不带任何其他参数,并返回一个 i32
。这种类别的方法,有时被称为 getter,因为他的目的是获取其字段中的某个数据并返回。这个公共方法是必要的,因为 Guess
结构体的 value
字段是私有的。value
这个字段是私有的很重要,这样使用 Guess
这个结构体的代码,就不能直接设置 value
:guessing_game
模组之外的代码,必须 使用 Guess::new
函数创建 Guess
的实例,从而确保某个 Guess
不可能有着一个,未经 Guess::new
函数中条件检查过的 value
。
某个有着参数或返回值仅为 1
到 100
之间数字的函数,随后就可以在其签名中,声明他接收或返回的是 Guess
而不是 i32
,并且不需要在其主体中进行任何额外的检查。
本章小结
Rust 的这些错误处理特性,旨在帮助咱们编写更健壮的代码。
panic!
这个宏表示咱们的程序处于其无法处理的某种状态,让咱们可以告诉进程停止,而不是尝试继续处理无效或不正确的值;Result
枚举使用 Rust 的类型系统,表示可能会失败,但咱们代码可从中恢复的那些操作。咱们可以使用Result
告诉调用咱们代码的代码,他也需要处理潜在的成功或失败。
在恰当情形下合理使用 panic!
与 Result
,可以让咱们的代码在面对不可避免的问题时更加可靠。
现在,咱们已经看到标准库以 Option
和 Result
两个枚举,使用泛型的一些有用方法,下面我们将讨论泛型的工作原理,以及如何在咱们的代码中使用泛型。
(End)