9.3 要不要 panic!

何时该 panic!,何时该返回 Result

译文 · 基于 The Rust Programming Language(rustc 1.97.1)

要不要 panic!

原文链接: https://doc.rust-lang.org/stable/book/ch09-03-to-panic-or-not-to-panic.html

要不要 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。例如:

1
2
3
4
5
    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! 的主要原因:访问不属于当前数据结构的内存是常见安全问题。函数常常有契约(contract):只有输入满足特定要求时,其行为才有保证。契约被违反时 panic 是合理的,因为契约违反总是表明调用方有 bug,而且也不是你希望调用方显式处理的那类错误。事实上,调用方几乎没有合理的恢复方式;需要修复代码的是调用方开发者。函数的契约——尤其是违反会导致 panic 时——应在该函数的 API 文档中说明。

  不过,若每个函数都塞满错误检查,会又冗长又烦人。幸运的是,可以用 Rust 的类型系统(以及编译器的类型检查)替你完成许多检查。若函数参数是某个特定类型,你就可以放心继续写逻辑,因为编译器已经确保你拿到的是有效值。例如,若用的是某个类型而不是 Option,程序期望的是有值而不是没有。代码就不必再处理 Some 与 None 两种情况,而只有“一定有值”这一种。试图向函数传入“没有”的代码甚至无法通过编译,因而函数不必在运行时检查这种情况。另一个例子是使用无符号整数类型如 u32,这能确保参数永远不为负。

用于校验的自定义类型

  我们把“用类型系统确保有效值”再推进一步:创建用于校验的自定义类型。回想第 2 章的猜数字游戏:代码要求用户猜 1 到 100 之间的数字。我们在与秘密数字比较之前从未校验猜测是否落在该范围内,只校验了猜测为正。那时后果并不严重:“太大了”或“太小了”的输出仍然正确。但若能引导用户给出有效猜测,并在“超出范围”与“输入了字母”等情况下有不同行为,会是有用的增强。

  一种做法是把猜测解析为 i32 而不仅是 u32(以允许潜在的负数),然后再检查数字是否在范围内,例如:

文件名:src/main.rs

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
    loop {
        // --snip--


        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--

    }

  if 表达式检查值是否越界,告知用户问题,并调用 continue 开始下一轮循环、再要一次猜测。if 之后,我们就可以在知道 guess 介于 1 和 100 之间的前提下,继续把它与秘密数字比较。

  不过这并非理想方案:若程序绝对只能处理 1 到 100 之间的值,且许多函数都有此要求,在每个函数里都做这样的检查会很乏味(还可能影响性能)。

  相反,我们可以在专用模块中新建一个类型,并把校验放在创建该类型实例的函数里,而不是到处重复校验。这样,函数就可以在签名中安全地使用新类型,并自信地使用收到的值。示例 9-13 展示了一种定义 Guess 类型的方式:只有当 new 收到的值介于 1 和 100 之间时,才会创建 Guess 实例。

文件名:src/guessing_game.rs

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
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 接受一个 i32 类型的参数 value,并返回一个 Guess。函数体测试 value 是否介于 1 和 100 之间。若未通过测试,就调用 panic!,提醒编写调用代码的程序员存在需要修复的 bug——因为用超出该范围的 value 创建 Guess 会违反 Guess::new 所依赖的契约。Guess::new 可能 panic 的条件应在其面向公众的 API 文档中说明;第 14 章会介绍在你编写的 API 文档中标明可能 panic! 的约定。若 value 通过测试,就创建 value 字段设为该参数的新 Guess 并返回。

  接下来,我们实现名为 value 的方法:它借用 self,没有其他参数,返回 i32。这类方法有时叫做getter,因为它的目的是从字段取出数据并返回。这个公开方法是必要的,因为 Guess 的 value 字段是私有的。让 value 保持私有很重要,这样使用 Guess 的代码就不能直接设置 value:guessing_game 模块外的代码必须通过 Guess::new 创建实例,从而保证不存在未经 Guess::new 中条件检查的 value。

  于是,参数或返回值只能是 1 到 100 之间数字的函数,就可以在签名中声明接受或返回 Guess 而不是 i32,并且不必在函数体中再做额外检查。

小结

  Rust 的错误处理特性旨在帮助你写出更稳健的代码。panic! 宏表示程序处于无法处理的状态,并让你告诉进程停止,而不是带着无效或不正确的值继续。Result 枚举利用类型系统表明:操作可能以你的代码能够恢复的方式失败。你也可以用 Result 告诉调用你的代码:它同样需要处理潜在的成功或失败。在恰当的场景使用 panic! 与 Result,能让你的代码在面对不可避免的问题时更加可靠。

  既然已经看到标准库如何用泛型与 Option、Result 枚举结合,接下来我们将讨论泛型如何工作,以及如何在自己的代码中使用它们。

最后修改 August 11, 2026: 更新 (70a5af133)