不要 panic

“它看起来非常复杂,这就是为什么它紧凑的塑料盖子上用大大的友好字母写着 DON'T PANIC 的原因之一。”——Douglas Adams

本条的标题应当更准确的描述为更应该返回 Result 而不是使用 panic!(但是不要 panic 更吸引人)。

Rust 的 panic 机制主要是针对程序中不可恢复的错误而设计的,默认情况下会终止发出 panic! 的线程。然而,除了默认情况还有其他选择。

特别是,来自具有异常系统的语言(例如 Java 或者 C++)的 Rust 新手通常会使用 std::panic::catch_unwind 作为模拟异常的方法,因为这似乎提供了一种在调用栈上捕获 panic 的机制。

考虑一个因无效输入而 panic 的函数:

#![allow(unused)]
fn main() {
fn divide(a: i64, b: i64) -> i64 {
    if b == 0 {
        panic!("Cowardly refusing to divide by zero!");
    }
    a / b
}
}

尝试用无效输入调用它,会按预期一样报错:

#![allow(unused)]
fn main() {
// 尝试去计算 0/0 是什么...
let result = divide(0, 0);
}
thread 'main' panicked at 'Cowardly refusing to divide by zero!', main.rs:11:9
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

以下包装函数使用 catch_unwind 来捕获 panic:

#![allow(unused)]
fn main() {
fn divide_recover(a: i64, b: i64, default: i64) -> i64 {
    let result = std::panic::catch_unwind(|| divide(a, b));
    match result {
        Ok(x) => x,
        Err(_) => default,
    }
}
}

似乎可以正常运行并模拟 catch

#![allow(unused)]
fn main() {
let result = divide_recover(0, 0, 42);
println!("result = {result}");
}
result = 42

然而,外在具有欺骗性。这种方法的第一个问题是,panic 并不总是被回退(unwind);有一个编译器选项(可通过 Cargo.toml 配置文件配置)可以改变 panic 后的行为,以便立即终止进程:

thread 'main' panicked at 'Cowardly refusing to divide by zero!', main.rs:11:9
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
/bin/sh: line 1: 29100 Abort trap: 6  cargo run --release

这使得任何尝试模拟异常的方式完全受到整个项目配置文件的支配。还有一种情况是,无论编译器或项目配置如何,某些目标平台(例如,WebAssembly)总会因 panic 而终止。

一个更微妙的问题是,panic 处理引发了异常安全:如果 panic 发生在对数据结构进行操作的过程中,则会消除对数据结构已处于自一致状态的任何保证。自 20 世纪 90 年代以来,在存在异常的情况下保持内部不变量已被证明极其困难;1这也是为什么 Google (众所周知)禁止在其 C++ 代码中使用异常的主要原因之一。

最后,panic 传播与 FFI(外部函数接口)绑定(第 34 条)的交互也很差应使用 catch_unwind 来防止 Rust 代码中的 panic 跨 FFI 绑定传播到非 Rust 调用代码

那么除了 panic! 之外还有什么方法可以处理错误呢?对于库代码,最好的替代方案就是返回具有合适错误类型的 Result第 4 条),以此将错误视为其他人的问题。这允许使用该库的人自行决定下一步该做什么——这可能涉及通过 ? 运算符将问题传播给队列中的下一个调用者。

错误传播必须在某处停止,如果你可以控制 main 的行为,那么根据经验适当调用 panic! 是可以的(或者 unwrap()expect() 等等);此时,就没有其他的调用者可以将错误传播给它了。

即使在库代码中,panic! 另一个合理的用处是在极少数遇到错误的情况下,并且你不希望用户必须通过 .unwrap() 调用来搞乱他们的代码。

如果错误情况应当发生只是因为(比如说)内部数据损坏,而不是由于无效输入,那么触发 panic! 是合理的。

允许无效输入引发的 panic 有时甚至是有用的,但这种无效输入应该是不常见的。这样的方法在相关的入点成对出现时效果最好:

  • “万无一失”函数,其签名意味着它总是会成功运行(如果不能成功就会 panic)
  • “可能出错”函数,返回一个 Result

对于前者,Rust 的 API 指南 建议 panic! 应该记录在内联文档的特定部分中(第 27 条)。

标准库中的 String::from_utf8_uncheckedString::from_utf8 的入点是后者的示例(尽管在这种情况下,panic 实际上被推迟到使用无效输入来构造 String 的位置)。

假设你正在尝试遵循本条给出的建议,则需要牢记以下几点。首先,panic 可能以不同形式出现;避免 panic! 的同时也要避免以下情况:

更难发现的情况如下:

  • slice[index] 索引超出范围
  • x / yy 是零时

关于避免 panic 的第二种观察是,一个依赖于人类持续保持警觉的计划永远不是一个好主意。

然而,让机器去持续保持警觉是另一回儿事:向你的系统持续集成(详见第 32 条)检查系统,以发现新的,潜在的 panic 代码要可靠的多。一个简单的版本可以是针对最常见的 panic 入点进行简单地 grep(如前所述);更彻底的检查可以涉及使用 Rust 生态系统中的其他工具(第 31 条),例如设置一个构建变体,并引入 no_panic crate。

注释

1

Tom Cargill 在 1994 年的文章《C++ Report》中探讨了保持 C++ 模板代码的异常安全性是多么困难,Herb Sutter 的 Guru of the Week #8 也有类似的讨论。

原文点这里查看