panic! 还是不要 panic!

To panic! or Not to panic!

那么该怎样确定,什么时候应该调用 panic!,以及什么时候应该返回 Result 呢?在代码中止时,就没有办法恢复了。当然可以在任何错误情形下调用 panic!,而不管存不存在可能的恢复方式,不过这个时候就是代码编写者本人,代替代码在做出这种情形为不可恢复的确定了。而在选择了返回某个 Result 值是,就赋予了调用代码(the calling code)各种选项。调用代码就可以根据其自身情况,而选择尝试恢复,或者他可以决定在此情形下的某个 Err 是不可恢复的,进而他就可以调用 panic! 而将可恢复错误,转变为不可恢复错误。这样看来,在对某个可能失败的函数进行定义时,返回一个 Result 就是良好的默认选择。

而在示例程序、原型代码及测试等中,那么比起返回 Result,编写程序中止代码就要更合适。接下来就要探讨一下为何这样讲,随后就要讨论一些编译器无法搞清楚,但作为代码编写者的人类却明白程序失败不可能发生的情形。本章将以一些有关在库代码中,如何确定要不要中止程序的守则结束(in situations such as examples, prototype code, and tests, it's more approciate to write code that panics instead of returning a 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 这样的可能会中止程序运行方法的调用,确信就表明那是一个是要这个应用程序对错误进行处理的占位符,而根据接下来代码所做的事情,这样的调用会有所不同。

与此类似,unwrapexpect 方法在构造原型程序,尚未准备好确定如何处理错误时,是十分方便的。这些方法在代码中留下了清楚的一些记号,这些记号在已做好准备让程序更为健壮时会用到。

而当方法调用在测试中失败时,即使那个方法并非正在测试的某些功能,也会希望整个测试失败。由于 panic! 正是将测试标记为失败的方式,那么调用 unwrapexpect,则正是要用到的了。

相比于编译器,代码编写者掌握了更多信息的情形

Cases in Which You Have More Information Than the Compiler

在有着确保了 Result 将有着 Ok 值的其他某些逻辑,但编译器对此逻辑却一无所知时,调用 unwrapexpect 也是恰当的。这时将仍然有个需要处理的 Result 值:对于不论所调用的什么操作,即使在当前特定情形下,逻辑上失败绝无可能,但所调用的操作原本总体上仍是有失败可能。在经由亲自检查代码,而能确保绝不会有 Err 变种时,调用 unwrap 就是完美可接受的,且将自己设想的绝不会有 Err 变种的原因,在 expect 文本中撰写出来,这样做甚至更佳。下面就是一个示例:

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 类型,同时由于编译器不是足够聪明到发现这个字符串总是个有效的 IP 地址,那么编译器仍将要求,以 Err 变种是一种可能性那样,对这个 Result 进行处理。在这个 IP 地址为来自用户输入,而非这里的硬编码到程序中,进而 确实 有着失败可能时,无疑就要打算对这个 Result 进行更为健壮的处理了。这种提及这个 IP 地址为硬编码的假设,将提醒到在将来,需要从其他来源获取这个 IP 地址时,就要把 expect 修改为更好的错误处理代码。

错误处理准则

Guidelines for Error Handling

在代码可能以糟糕状态结束运行时,那么让代码中止运行就是明智的。在这种情形下,所谓 糟糕状态(a bad state) 就是在某种假设、保证、合约,或恒值已被破坏,譬如在无效值、矛盾值,或缺失值被传递到所编写代码 -- 加上以下的一项或多项:

  • 糟糕状态是某些不期望的东西,他们与偶发的东西相反,比如用户输入的错误格式数据;

  • 在此处之后的代码,需要依赖于不处在这种糟糕状态,而不是在接下来的每一步都检查这个问题;

  • 没有以自己所使用的类型,来编码该信息的好办法。在第 17 章的 “将状态与行为编码为类型” 小节,就会贯穿一个这里所意指的示例。

在有人调用到咱们的代码,并传入了无意义的值时,在可以的情况下,最好返回一个错误,这样库用户就可以确定在那样的情况下,他们打算做什么。然而在继续执行下去会不安全或有危害的情形中,那么最佳选择就会时调用 panic!,并警醒使用到咱们库的人他们代码中的错误,这样在他们开发过程中就可以修好那个代码错误。与此类似,在调用不在掌控中的外部代码,且该外部代码返回了无法修复的无效状态时,那么 panic! 通常就是恰当选择。

不过在失败为预期的时,那么相比于构造一个 panic! 调用,返回一个 Result 则更为恰当。这类示例包括给到解析器错误格式数据,或某个返回了表示已达到访问数限制的 HTTP 请求等。在这些情况下,返回一个 Result 就表示失败是一种调用代码必须确定如何处理的预期可能。

在所编写代码被使用无效值调用,而执行了某种可能将用户置于危险境地的操作时,那么代码就应首先对这些值进行检查,并在这些值无效时中止运行。这主要是处于安全原因:尝试运行于无效数据,就会将代码暴露于漏洞。这就是在尝试超出边界的内存访问时,标准库会调用 panic! 的主要原因:尝试访问不属于当前数据结构的内存,是个常见的安全问题。函数通常有着 合约(contracts):只在输入满足特定要求时,他们的行为才有保证。那么由于合约破坏总是表明调用者侧的代码错误,且这种错误并非是要调用代码必须显式处理的那种错误,因此在合约被破坏时的中止运行就说得通了。实际上,调用代码是没有恢复的合理方法的;调用的 代码编写者 需要修复该代码。应在函数的 API 文档中,解释函数的合约,尤其是在合约破坏会导致中止运行时。

但是,在全部的函数中,进行大量错误检查,则会显得冗长而烦人。幸运的是,可使用 Rust 的类型系统(并因此由编译器完成类型检查),来完成许多的检查。在函数有着作为参数的特定类型时,就可以在知悉编译器已经确保有着有效值的情况下,着手处理代码的业务逻辑。比如,在有着一个不同于 Option 的类型时,程序就期望有 某个东西(something) 而非 什么也没有(nothing)。代码这时就不必处理 SomeNone 变种的两种情形:无疑将只有一种有着某个值的情形。尝试将无值传递给该函数的代码,甚至都不会编译,那么该函数就不必在运行时对那样的情况进行检查了。另一个示例则是使用某个诸如 u32 无符号整数,这就确保了参数绝不会是个负数。

创建用于验证的定制类型

Creating Custom Types for Validation

接下来将这个运用 Rust 的类型系统,来确保有着有效值的概念,进行进一步拓展,而看看创建一个用于验证的定制类型。回顾在第二章中的猜数游戏,其中的代码要求用户猜出一个 1100 之间的数字。在将用户猜的数字与那里的秘密数字比对之前,是绝无对用户猜数是否处于 1100 之间,进行过验证的;那里只验证过猜数为正数。在这个示例中,后果并不是非常可怕:这里的输出 “太大了” 或 “太小了” 仍将正确。但引导用户朝向有效的猜数,并在用户猜出不在该范围的数,与用户敲入了比如一些字母时,而有不同的表现,将是一项有用的功能增强。

完成此功能增强的一种方式,将是将猜数解析为一个 i32 而非仅仅为一个 u32,从而允许潜在的负数,并在随后键入一个该数位于范围中的检查,像下面这样:

#![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 与秘密数字之间的比较,获悉 guess 是在 1100 之间。

然而这并非一种理想的方案:若程序只运行在 1100 之间的值这一点至关重要,且程序有着许多有此要求的函数,而在每个函数中都进行这样的一个检查,就会显得冗长乏味(并可能影响性能)。

相反,这里可以构造一种新类型,并将那些验证放入某个函数,从而创建出该类型的一个示例,而非在各个地方重复这些验证。那样的话,这些函数就可以在他们的签名中,安全地使用这种新类型,并信心十足地使用他们接收到的那些值了。下面清单 9-13 给出了一种定义 Guess 类型的方式,在 new 函数接收到一个 1100 之间的值时,这种方式下将只创建一个 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 类型值必须在 1 与 100 之间,收到的是 {}", value);
        }

        Guess { value }
    }

    pub fn value(&self) -> i32 {
        self.value
    }
}
}

清单 9-13:只会在值处于 1100 之间,才继续执行的一个 Guess 类型

首先,这里定义了一个名为 Guess,带有一个叫做 value、保存了一个 i32 值字段的结构体。这就是要存储数字的地方。

随后这里在 Guess 上实现了一个名为 new 的关联函数,其创建出一个 Guess 类型的实例。这个 new 函数被定义为有着一个名为 value、类型为 i32 的参数,以及要返回一个 Guess 类型值。new 函数体中的代码,对 value 进行了测试,从而确保 value 是在 1100 之间。若 value 未通过此测试,那么就做出一个 panic! 调用,由于创建一个超出此范围的 Guess 会破坏 Guess::new 所依赖的合约,因此这就会警醒到编写调用代码的程序员,他们有个需要修复的代码错误。Guess::new 可能中止运行的条件,应在其公开的 API 文档中,进行说明。在第 14 章就会涉及到在所创建的文档中,表示 panic! 可能性的一些约定。在 value 通过该测试时,这里就会创建一个将 value 字段设置为那个 value 参数的新 Guess 类型值,并返回这个 Guess 类型值。

接下来,这里实现了一个名为 value、借用了 self,不带任何其他参数,并返回一个 i32 的方法。由于这类方法的目的,是要从一些字段获取数据并加以返回,因此有时就被叫做 取值方法(getter)。因为 Guess 结构体的这个 value 字段是私有的,那么这个公开方法就是必要的。这个 value 字段作为私有至关重要,这样使用这个 Guess 结构体的代码就不被允许直接设置 value:该模组外部的代码,必须 使用 Guess::new 函数,来创建 Guess 的实例,这样就确保了 Guess 不会有未经 Guess::new 函数中条件检查的 value

现在某个有着一个 1100 之间参数,或只返回 1100 之间数字的函数,就可以在其函数签名中,声明他所取参数或其返回值为 Guess 类型而非 i32 类型,而不再需要在其函数体中完成任何额外检查了。

本章小结

Rust 的那些错误处理特性,被设计用于帮助编写更为健壮的代码。panic! 这个宏,发出了程序处于其无法处理状态的信号,并让咱们告知进程停下来,而不是尝试以无效或不正确的一些值继续运行。而 Result 这个枚举则使用了 Rust 的类型系统,来表示以代码可以从中恢复过来的某种方式的一些操作失败(the Result enum uses Rust's type system to indicate that operations might fail in a way that your code could recover from)。还可使用 Result 来告诉调用了咱们代码的代码,需要处理潜在的成功与失败情形。在一些适当情形下,运用 panic!Result 就会令到咱们的代码在各种不可避免的问题面前,更加可靠。

既然这里已经见识到标准库在 OptionResult 枚举上,运用到泛型的一些有用方式,那么接下来就要谈及泛型的原理,以及怎样在咱们的代码中运用泛型。

Last change: 2023-11-30, commit: 6aef347