恐慌!或不恐慌!


那么,你如何决定什么时候应该打电话给 panic!以及什么时候应该返回 结果?当代码出现 panic 时,无法恢复。你可以称之为 panic! 对于任何错误情况,无论是否有可能的恢复方法,但是 那么你正在做出一个决定,即情况是无法恢复的 调用代码。当您选择返回 Result 值时,您将为调用代码提供选项。调用代码可以选择尝试以适合其情况的方式进行恢复,也可以决定 Err value 是 unrecoverable,因此它可以调用 panic!并将你的 recoverable 错误变成一个 unrecoverable 错误。因此,在定义可能失败的函数时,返回 Result 是一个不错的默认选择。


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


示例、原型代码和测试


当您编写示例来说明某些概念时,还包括健壮的错误处理代码可能会使示例不那么清晰。在示例中,可以理解为对 unwrap 等可能 panic 的方法的调用是作为您希望应用程序处理错误的方式的占位符,这可能会根据代码的其余部分而有所不同。


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


如果测试中的方法调用失败,则希望整个测试失败,即使该方法不是所测试的功能。因为 panic! 是测试被标记为失败的方式,所以调用 unwrapexpect 正是应该发生的。


您拥有的信息比编译器多的情况


当你有一些其他逻辑来确保 Result 将具有 Ok 值,但编译器无法理解该逻辑时,调用 unwrapexpect 也是合适的。您仍然需要处理一个 Result 值:无论您调用什么作,通常仍然有可能失败,即使在您的特定情况下这在逻辑上是不可能的。如果可以通过手动检查代码来确保永远不会有 Err 变体,则完全可以接受调用 unwrap,最好记录您认为永远不会有 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 地址是硬编码的假设将提示我们将期望更改为更好的错误处理代码。


错误处理准则


建议在代码可能最终处于错误状态时让代码 panic。在此上下文中,不良状态是指某些假设、保证、协定或不变量被破坏,例如,将无效值、矛盾值或缺失值传递给代码,以及以下一项或多项:


  • 错误状态是意外的,而不是可能偶尔发生的事情,例如用户以错误的格式输入数据。

  • 在此之后,您的代码需要依赖于不处于这种错误状态,而不是在每个步骤中都检查问题。

  • 没有一种将此信息编码为你使用的类型的好方法。我们将通过一个示例来说明我们在 “编码状态和行为” as Types“部分。


如果有人调用您的代码并传入没有意义的值,最好返回一个错误(如果可以),以便库的用户可以决定在这种情况下要做什么。但是,在继续可能不安全或有害的情况下,最好的选择可能是调用 panic!并提醒使用您的库的人注意他们代码中的 bug,以便他们可以在开发过程中修复它。同样,如果你调用的外部代码不受你控制,并且它返回一个你无法修复的无效状态,那么 panic! 通常是合适的。


但是,当预期会失败时,返回 Result 更合适 而不是打个 Panic!电话。示例包括为解析器提供格式错误的数据,或者 HTTP 请求返回指示您已达到速率限制的状态。在这些情况下,返回 Result 表示失败是调用代码必须决定如何处理的预期可能性。


当您的代码执行的作在使用无效值调用时可能会使用户面临风险,您的代码应首先验证值是否有效,如果值无效,则发出 panic。这主要是出于安全原因:尝试对无效数据进行作可能会使您的代码暴露于漏洞中。这就是标准库调用 panic 的主要原因!如果你尝试 越界内存访问:尝试访问不属于 当前数据结构是一个常见的安全问题。函数通常具有 contracts:只有当 inputs 满足特定要求时,它们的行为才能得到保证。在违反协定时出现 panic 是有道理的,因为协定冲突始终表示调用方端错误,而且这不是你希望调用代码必须显式处理的一种错误。事实上,没有合理的方法来调用代码进行恢复;调用的程序员需要修复代码。函数的协定,尤其是当违规会导致 panic 时,应在函数的 API 文档中解释。


但是,在所有函数中都有大量错误检查会很冗长且令人讨厌。幸运的是,你可以使用 Rust 的类型系统(以及编译器完成的类型检查)来为你做很多检查。如果您的函数将特定类型作为参数,则可以继续执行代码的逻辑,因为编译器已经确保您具有有效值。例如,如果你有一个类型而不是 Option,则你的程序期望有东西而不是没有。然后,你的代码就不必处理 SomeNone 变体的两种情况:它只有一种情况明确具有值。尝试不向函数传递任何内容的代码甚至不会编译,因此函数不必在运行时检查这种情况。另一个示例是使用无符号整数类型(如 u32),这可确保参数永远不会为负数。


创建用于验证的自定义类型


让我们进一步使用 Rust 的类型系统来确保我们有一个有效的值,并考虑创建一个自定义类型进行验证。回想一下第 2 章中的猜谜游戏,我们的代码要求用户猜一个介于 1 和 100 之间的数字。在与我们的秘密号码进行核对之前,我们从未验证过用户的猜测是否在这些数字之间;我们只验证了猜测是肯定的。在这种情况下,后果并不是很可怕:我们输出的 “Too high” 或 “Too Low” 仍然是正确的。但是,这将是一个有用的增强功能,可以引导用户进行有效的猜测,并且在用户猜测的数字超出范围时与用户键入字母时具有不同的行为。


一种方法是将猜测解析为 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 的实例。


文件名: src/lib.rs

#![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 值的实例。函数被定义为具有一个名为 value 的 i32 类型参数,并返回一个 Guess函数正文中的代码会测试 value 以确保它介于 1 和 100 之间。如果 value 没有通过这个测试,我们就会发出一个 panic!调用,这将提醒编写调用代码的程序员他们有一个需要修复的 bug,因为创建一个值超出这个范围的 Guess 会违反 Guess::new 所依赖的契约。其中 Guess::new 可能会恐慌应该在其面向公众的 API 文档中讨论;我们将在您在第 14 章中创建的 API 文档中介绍指示 panic! 可能性的文档约定。如果 value 通过测试后,我们创建一个新的 Guess,其 value 字段设置为 value 参数并返回 Guess


接下来,我们实现一个名为 value 的方法,它借用了 self,没有任何其他参数,并返回一个 i32。这种方法有时被称为 getter,因为它的目的是从其字段中获取一些数据并返回它。这个公共方法是必需的,因为 Guessvalue 字段 struct 是私有的。value 字段必须是私有的,因此不允许使用 Guess 结构的代码直接设置 value:模块外部的代码必须使用 Guess::new 函数来创建 Guess 的调用,从而确保 Guess 没有办法具有未被 Guess::new 函数中的条件检查的值


具有参数或仅返回 1 到 100 之间的数字的函数可以在其签名中声明它接受或返回 Guess 而不是 i32 中,并且不需要在其正文中执行任何额外的检查。


总结


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


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