是否恐慌

RustRustBeginner
立即练习

This tutorial is from open-source community. Access the source code

💡 本教程由 AI 辅助翻译自英文原版。如需查看原文,您可以 切换至英文原版

简介

欢迎来到「惊慌与否」实验。本实验是 《Rust 程序设计语言》 的一部分。你可以在 LabEx 中练习 Rust 技能。

在本实验中,决定是调用 panic! 还是返回 Result 取决于错误情况的可恢复性以及调用代码可用的选项。


Skills Graph

%%%%{init: {'theme':'neutral'}}%%%% flowchart RL rust(("Rust")) -.-> rust/BasicConceptsGroup(["Basic Concepts"]) rust(("Rust")) -.-> rust/DataTypesGroup(["Data Types"]) rust(("Rust")) -.-> rust/FunctionsandClosuresGroup(["Functions and Closures"]) rust(("Rust")) -.-> rust/DataStructuresandEnumsGroup(["Data Structures and Enums"]) rust(("Rust")) -.-> rust/ErrorHandlingandDebuggingGroup(["Error Handling and Debugging"]) rust(("Rust")) -.-> rust/AdvancedTopicsGroup(["Advanced Topics"]) rust/BasicConceptsGroup -.-> rust/variable_declarations("Variable Declarations") rust/DataTypesGroup -.-> rust/integer_types("Integer Types") rust/DataTypesGroup -.-> rust/string_type("String Type") rust/FunctionsandClosuresGroup -.-> rust/function_syntax("Function Syntax") rust/FunctionsandClosuresGroup -.-> rust/expressions_statements("Expressions and Statements") rust/DataStructuresandEnumsGroup -.-> rust/method_syntax("Method Syntax") rust/ErrorHandlingandDebuggingGroup -.-> rust/panic_usage("panic! Usage") rust/AdvancedTopicsGroup -.-> rust/traits("Traits") subgraph Lab Skills rust/variable_declarations -.-> lab-100411{{"是否恐慌"}} rust/integer_types -.-> lab-100411{{"是否恐慌"}} rust/string_type -.-> lab-100411{{"是否恐慌"}} rust/function_syntax -.-> lab-100411{{"是否恐慌"}} rust/expressions_statements -.-> lab-100411{{"是否恐慌"}} rust/method_syntax -.-> lab-100411{{"是否恐慌"}} rust/panic_usage -.-> lab-100411{{"是否恐慌"}} rust/traits -.-> lab-100411{{"是否恐慌"}} end

惊慌与否

那么,你如何决定何时应该调用 panic!,何时应该返回 Result 呢?当代码发生恐慌时,就无法恢复了。对于任何错误情况,无论是否有可能恢复,你都可以调用 panic!,但这样你就是在替调用代码决定某个情况是无法恢复的。当你选择返回一个 Result 值时,你给了调用代码一些选择。调用代码可以选择以适合其情况的方式尝试恢复,或者它可以决定在这种情况下 Err 值是无法恢复的,所以它可以调用 panic!,把你这个可恢复的错误变成一个不可恢复的错误。因此,当你定义一个可能失败的函数时,返回 Result 是一个很好的默认选择。

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

示例、原型代码和测试

当你编写一个示例来说明某个概念时,包含健壮的错误处理代码可能会使示例变得不那么清晰。在示例中,可以理解的是,对可能会导致恐慌的 unwrap 之类的方法的调用,是你希望应用程序处理错误的方式的占位符,具体处理方式可能会因代码的其他部分的功能而异。

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

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

你比编译器掌握更多信息的情况

当你有其他逻辑确保 Result 将具有 Ok 值,但编译器无法理解该逻辑时,调用 unwrapexpect 也是合适的。你仍然需要处理一个 Result 值:无论你调用的是什么操作,通常仍然有可能失败,即使在你的特定情况下从逻辑上讲是不可能的。如果你可以通过手动检查代码确保永远不会有 Err 变体,那么调用 unwrap 是完全可以接受的,甚至更好的做法是在 expect 的文本中记录你认为永远不会有 Err 变体的原因。下面是一个例子:

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 改为更好的错误处理代码。

错误处理指南

当你的代码可能最终处于不良状态时,最好让代码发生恐慌。在这种情况下,“不良状态”是指某些假设、保证、约定或不变量被打破,例如当无效值、矛盾值或缺失值被传递给你的代码时,再加上以下一种或多种情况:

  • 不良状态是意外情况,而不是偶尔可能发生的情况,比如用户输入错误格式的数据。
  • 在此之后你的代码需要依赖于不处于这种不良状态,而不是在每一步都检查问题。
  • 没有好的方法在你使用的类型中编码此信息。我们将在“将状态和行为编码为类型”中详细说明我们的意思。

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

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

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

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

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

让我们进一步运用 Rust 的类型系统来确保我们拥有一个有效值的理念,看看如何创建一个用于验证的自定义类型。回忆一下第 2 章中的猜数字游戏,在那个游戏中,我们的代码要求用户猜一个 1 到 100 之间的数字。在将用户的猜测与我们的秘密数字进行比较之前,我们从未验证过用户的猜测是否在这个范围内;我们只验证了猜测是否为正数。在这种情况下,后果并不是很严重:我们输出的“太高”或“太低”仍然是正确的。但是,如果能引导用户进行有效的猜测,并在用户猜测超出范围与用户输入字母等情况时表现出不同的行为,那将是一个很有用的改进。

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

文件名:src/main.rs

loop {
    --snip--

    let guess: i32 = match guess.trim().parse() {
        Ok(num) => num,
        Err(_) => continue,
    };

    if guess < 1 || guess > 100 {
        println!("秘密数字将在 1 到 100 之间。");
        continue;
    }

    match guess.cmp(&secret_number) {
        --snip--
}

if 表达式检查我们的值是否超出范围,告诉用户问题所在,并调用 continue 开始循环的下一次迭代并要求再次猜测。在 if 表达式之后,我们可以继续进行 guess 和秘密数字之间的比较,因为我们知道 guess 在 1 到 100 之间。

然而,这并不是一个理想的解决方案:如果程序绝对必须只对 1 到 100 之间的值进行操作,并且有许多函数都有这个要求,那么在每个函数中都进行这样的检查会很繁琐(并且可能会影响性能)。

相反,我们可以创建一个新类型,并将验证放在一个函数中,以创建该类型的实例,而不是在各处重复验证。这样,函数在其签名中使用新类型并自信地使用它们接收到的值就是安全的。清单 9-13 展示了一种定义 Guess 类型的方法,只有当 new 函数接收到一个 1 到 100 之间的值时,才会创建 Guess 的实例。

文件名:src/lib.rs

1 pub struct Guess {
    value: i32,
}

impl Guess {
  2 pub fn new(value: i32) -> Guess {
      3 if value < 1 || value > 100 {
          4 panic!(
                "猜测值必须在 1 到 100 之间,得到的是 {}.",
                value
            );
        }

      5 Guess { value }
    }

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

清单 9-13:一个只接受 1 到 100 之间值的 Guess 类型

首先,我们定义一个名为 Guess 的结构体,它有一个名为 value 的字段,用于存储一个 i32 [1]。这就是数字将被存储的地方。

然后,我们在 Guess 上实现一个关联函数 new,用于创建 Guess 值的实例 [2]。new 函数被定义为有一个名为 valuei32 类型参数,并返回一个 Guessnew 函数体中的代码会测试 value,以确保它在 1 到 100 之间 [3]。如果 value 没有通过这个测试,我们就进行一次 panic! 调用 [4],这将提醒编写调用代码的程序员他们有一个需要修复的错误,因为创建一个 value 超出此范围的 Guess 将违反 Guess::new 所依赖的契约。Guess::new 可能会发生恐慌的情况应该在其面向公众的 API 文档中进行讨论;我们将在第 14 章中介绍在你创建的 API 文档中表明可能发生 panic! 的文档约定。如果 value 通过了测试,我们就创建一个新的 Guess,将其 value 字段设置为 value 参数,并返回这个 Guess [5]。

接下来,我们实现一个名为 value 的方法,它借用 self,没有任何其他参数,并返回一个 i32 [6]。这种方法有时被称为“获取器”,因为它的目的是从其字段中获取一些数据并返回它。这个公共方法是必要的,因为 Guess 结构体的 value 字段是私有的。value 字段为私有很重要,这样使用 Guess 结构体的代码就不允许直接设置 value:模块外部的代码必须使用 Guess::new 函数来创建 Guess 的实例,从而确保不可能有一个 Guessvalue 没有经过 Guess::new 函数中的条件检查。

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

总结

恭喜你!你已经完成了“是否恐慌”实验。你可以在 LabEx 中练习更多实验来提升你的技能。