键盘快捷键

按 ← 或 → 在章节间导航

按 S 或 / 搜索本书

按 ? 显示此帮助

按 Esc 隐藏此帮助

应该 panic! 还是不应该 panic!

那么,该如何决定何时调用 panic!,何时返回 Result 呢?代码发生 panic 时,无法从中恢复。无论错误是否可能恢复,你都可以在任何错误情形下调用 panic!;但这样一来,你就代替调用方代码做出了该情形不可恢复的决定。选择返回 Result 值,则是把选择权交给调用方。调用方可以根据自身情形尝试恢复,也可以认定此处的 Err 值不可恢复,于是调用 panic!,将可恢复错误转变为不可恢复错误。因此,在定义可能失败的函数时,默认返回 Result 是不错的选择。

在示例、原型代码和测试等场景中,编写会 panic 的代码通常比返回 Result 更合适。接下来我们会探讨原因,然后讨论一些编译器无法判断失败是否不可能、但人可以判断的情形。本章最后会给出一些通用准则,帮助你决定库代码是否应该 panic。

示例、原型代码和测试

编写示例来说明某个概念时,如果同时加入完备的错误处理代码,示例可能会变得不够清晰。在示例中,像 unwrap 这样可能引发 panic 的方法调用,通常被视为占位写法;实际应用应如何处理错误,取决于代码其余部分的具体行为。

同样,在编写原型、尚未准备好决定如何处理错误时,unwrap 和 expect 方法会很方便。它们会在代码中留下清晰的标记,提醒你之后可以让程序变得更加健壮。

如果测试中的某个方法调用失败,即使该方法并非测试的功能本身,你也希望整个测试失败。测试通过 panic! 标记失败,因此调用 unwrap 或 expect 正合适。

当你掌握的信息比编译器更多时

如果你有其他逻辑能够确保 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 是可以接受的。不过,字符串是硬编码且有效的,并不会改变 parse 方法的返回类型:我们仍会得到一个 Result 值,编译器仍会要求我们处理它,仿佛 Err 变体确实可能出现,因为编译器还不够聪明,无法看出这个字符串始终是有效的 IP 地址。如果 IP 地址字符串来自用户,而不是硬编码在程序中,那么它确实有可能解析失败;此时我们肯定应该以更健壮的方式处理 Result。说明该 IP 地址是硬编码的这一假设,有助于我们在将来需要从其他来源获取 IP 地址时,把 expect 改为更完善的错误处理代码。

错误处理准则

如果代码可能进入糟糕状态,那么让它 panic 通常是可取的。这里的糟糕状态指某项假设、保证、契约或不变量遭到破坏,例如向代码传入无效值、互相矛盾的值或缺失的值,并且还符合以下一项或多项条件:

  • 这种糟糕状态是意料之外的,而不是像用户偶尔输入格式错误的数据那样可能发生的情况。
  • 从此处开始,代码需要依赖程序不处于这种糟糕状态,而不是在每一步都检查问题是否存在。
  • 无法通过所用类型以恰当的方式表达这项信息。第 18 章的“将状态和行为编码为类型”一节会通过示例解释这一点。

如果有人调用你的代码并传入不合理的值,只要可行,最好返回错误,让库的使用者决定如何处理。但如果继续执行可能不安全或造成危害,那么更好的选择可能是调用 panic!,提醒库的使用者其代码存在 bug,以便他们在开发期间修复。同样,如果你调用的外部代码不受你控制,并且它返回了你无从修复的无效状态,调用 panic! 往往也很合适。

不过,如果失败是预期之中的情况,那么返回 Result 比调用 panic! 更合适。例如,解析器收到格式错误的数据,或者 HTTP 请求返回表示已达到速率限制的状态码。在这些情况下,返回 Result 表明失败是预期可能发生的,调用方代码必须决定如何处理。

如果代码执行的操作在使用无效值时可能危及用户,就应该先验证这些值,并在它们无效时 panic。这主要是出于安全考虑:尝试处理无效数据可能使代码暴露于漏洞之中。这也是标准库会在你尝试越界访问内存时调用 panic! 的主要原因:访问不属于当前数据结构的内存是常见的安全问题。函数通常有自己的契约:只有输入满足特定要求时,函数行为才有保证。违反契约意味着调用方存在 bug,因此在契约遭到违反时 panic 是合理的;你不希望调用方代码必须显式处理这类错误。事实上,调用方也没有合理的恢复方式;需要修复代码的是调用方的程序员。函数的契约,尤其是违反契约会导致 panic 时,应在该函数的 API 文档中说明。

然而,在所有函数中加入大量错误检查会冗长又烦琐。幸运的是,你可以利用 Rust 的类型系统(以及编译器执行的类型检查)替你完成许多检查。如果函数参数具有特定类型,你就可以在编写逻辑时确信编译器已经确保传入的是有效值。例如,使用某个类型而非 Option,就表示程序要求必须有某个值,而不是没有值。这样,代码就不必分别处理 Some 和 None 两种情况,只需处理确定有值的情况。试图向函数传入空值的代码根本无法编译,因此函数无需在运行时检查这种情况。另一个例子是使用 u32 这样的无符号整数类型,它可以确保参数不会为负数。

使用自定义类型进行验证

让我们更进一步,看看如何利用 Rust 的类型系统确保值有效,并创建用于验证的自定义类型。回想第 2 章的猜数字游戏:代码要求用户猜一个 1 到 100 之间的数字。在将猜测值与秘密数字比较之前,我们并没有验证它是否处于这个范围内,只检查了它是否为正数。在这个例子中,后果并不严重:“太大”或“太小”的提示仍然正确。不过,更好的做法是引导用户输入有效猜测,并在用户猜出范围外的数字时,与输入字母等情况采取不同的处理方式。

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

Filename: src/main.rs
use std::cmp::Ordering;
use std::io;

use rand::prelude::*;

fn main() {
    println!("Guess the number!");

    let secret_number = rand::rng().random_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
    }
}
}
清单 9-13:只接受 1 到 100 之间的值的 Guess 类型

请注意,这段位于 src/guessing_game.rs 的代码依赖于在 src/lib.rs 中添加模块声明 mod guessing_game;,这里没有展示该声明。在这个新模块的文件中,我们定义了一个名为 Guess 的结构体,其中有一个名为 value、用于保存 i32 的字段。

接着,我们为 Guess 实现一个名为 new 的关联函数,用来创建 Guess 实例。new 函数接受一个名为 value、类型为 i32 的参数,并返回一个 Guess。函数体会检查 value 是否处于 1 到 100 之间。如果没有通过检查,我们就调用 panic!,提醒编写调用代码的程序员存在需要修复的 bug:创建超出此范围的 Guess 会违反 Guess::new 所依赖的契约。应在面向使用者的 API 文档中说明 Guess::new 可能 panic 的情形;第 14 章会介绍如何在 API 文档中说明可能发生 panic! 的文档规范。如果 value 通过检查,我们就创建一个 Guess,将其 value 字段设为参数 value,然后返回该 Guess。

接下来,我们实现一个名为 value 的方法。它借用 self,没有其他参数,并返回一个 i32。这类方法有时称为 getter,因为它的作用是从字段中获取数据并返回。由于 Guess 结构体的 value 字段是私有的,因此需要这个公有方法。value 字段保持私有很重要,这样使用 Guess 结构体的代码就不能直接设置 value:guessing_game 模块之外的代码必须使用 Guess::new 函数创建 Guess 实例,从而确保每个 Guess 的 value 都经过了 Guess::new 中条件的检查。

之后,参数或返回值只能是 1 到 100 之间数字的函数,可以在签名中声明它接收或返回 Guess 而不是 i32,这样函数体就不需要额外检查。

总结

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

现在你已经了解标准库如何将泛型与 Option 和 Result 枚举结合使用,接下来我们会讨论泛型的工作原理,以及如何在代码中使用泛型。