Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

panic! 还是不 panic!

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

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

示例、原型代码和测试

当你编写示例来阐述某个概念时,包含健壮的错误处理代码可能会使示例不够清晰。在示例中,人们理解对 unwrap 等可能 panic 的方法的调用是一个占位符,代表你希望应用程序处理错误的方式,这取决于其余代码正在做什么。

类似地,unwrapexpect 方法在原型设计阶段非常方便,当你尚未准备好决定如何处理错误时。它们会在你的代码中留下清晰的标记,以便你准备好使程序更健壮时进行处理。

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

当你比编译器拥有更多信息时

当你拥有一些其他逻辑确保 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 是可以接受的。然而,拥有一个 hardcoded(硬编码的)、有效的字符串并不会改变 parse 方法的返回类型:我们仍然得到一个 Result 值,编译器仍然会让我们处理这个 Result,就好像 Err 变体有可能发生一样,因为编译器不够聪明,无法看出这个字符串始终是一个有效的 IP 地址。如果 IP 地址字符串来自用户而不是 hardcoded 在程序中,因此确实有失败的可能性,我们肯定会希望以更健壮的方式处理 Result。提及该 IP 地址是 hardcoded 这一假设会提示我们,如果将来需要从其他来源获取 IP 地址,我们应当将 expect 改为更好的错误处理代码。

错误处理指南

当你的代码有可能最终处于不良状态(bad state)时,建议让代码 panic。在此上下文中,不良状态是指某些假设、保证、契约(contract)或不变式(invariant)被破坏的情况,例如当无效值、矛盾值或缺失值被传递给您的代码时——再加上以下一个或多个条件:

  • 不良状态是出乎意料的,而不是偶尔可能发生的事情(例如用户以错误格式输入数据)。
  • 此点之后的代码需要依赖不处于此不良状态,而不是在每一步都检查问题。
  • 没有很好的方法将此信息编码到你所使用的类型中。我们将在第 18 章的“将状态和行为编码为类型”中举例说明我们的意思。

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

然而,当失败是可预期的时候,返回 Result 比调用 panic! 更合适。例如,解析器接收到格式错误的数据,或 HTTP 请求返回表示已达到速率限制的状态。在这些情况下,返回 Result 表明失败是一种预期可能性,调用代码必须决定如何处理。

当你的代码执行的操作在使用无效值调用时可能使用户面临风险时,你的代码应首先验证值的有效性,如果值无效则 panic。这主要是出于安全原因:尝试对无效数据进行操作可能会使你的代码暴露于漏洞中。这是标准库在你尝试越界内存访问时会调用 panic! 的主要原因:尝试访问不属于当前数据结构的内存是一个常见的安全问题。函数通常有契约(contracts):只有在输入满足特定要求时,它们的行为才有保证。在违反契约时 panic 是有意义的,因为契约违规总是表明调用方的 bug,并且这不是你希望调用代码必须显式处理的那种错误。实际上,调用代码没有合理的方式可以恢复;调用方的程序员需要修复代码。函数的契约,特别是当违规会导致 panic 时,应在函数的 API 文档中加以说明。

然而,在所有函数中进行大量的错误检查会显得冗长且烦人。幸运的是,你可以利用 Rust 的类型系统(以及编译器进行的类型检查)为你完成许多检查。如果你的函数有一个特定类型的参数,你可以继续执行代码的逻辑,知道编译器已经确保你拥有一个有效值。例如,如果你使用的是某个类型而不是 Option,你的程序期望拥有某个值而不是什么也没有。然后你的代码就不必处理 SomeNone 变体的两种情况:它只有一个明确有值的情况。试图将 nothing 传递给函数的代码甚至无法编译,因此你的函数不必在运行时检查这种情况。另一个例子是使用无符号整数类型(如 u32),它确保参数永远不会是负数。

用于验证的自定义类型

让我们将使用 Rust 的类型系统确保我们拥有有效值的想法再推进一步,看看如何创建一个用于验证的自定义类型。回想一下第 2 章中的猜数字游戏,其中我们的代码要求用户猜测一个介于 1 和 100 之间的数字。在与秘密数字进行比较之前,我们从未验证用户的猜测是否在这两个数字之间;我们只验证了猜测是正数。在这种情况下,后果并不严重:我们输出“太大了“或“太小了“仍然会是正确的。但是,引导用户进行有效猜测,并在用户猜测超出范围的数字与例如用户输入字母等情况下表现出不同的行为,将是一个有用的增强。

一种方法是将猜测解析为 i32 而不是仅 u32,以允许潜在负数,然后添加检查数字是否在范围内,如下所示:

Filename: 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 实例。

Filename: 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!("Guess value must be between 1 and 100, got {value}.");
        }

        Guess { value }
    }

    pub fn value(&self) -> i32 {
        self.value
    }
}
}
Listing 9-13: 一个仅在值介于 1 和 100 之间时才继续执行的 Guess 类型

请注意,src/guessing_game.rs 中的这段代码依赖于在 src/lib.rs 中添加一个模块声明 mod guessing_game;,我们这里没有展示。在这个新模块的文件中,我们定义了一个名为 Guess 的结构体,它具有一个名为 value 的字段,该字段持有一个 i32。这就是数字将被存储的地方。

然后,我们在 Guess 上实现了一个名为 new 的关联函数(associated function),用于创建 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 结构体的代码就不允许直接设置 valueguessing_game 模块外部的代码必须使用 Guess::new 函数来创建 Guess 的实例,从而确保 Guessvalue 不可能没有被 Guess::new 函数中的条件检查过。

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

总结

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

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