panic! 与可恢复错误的分界

? 解决了错误传播的书写问题,但留下一个更难的决定:哪些错误该用 Result 往上传,哪些错误该直接 panic 终止程序。这篇把分界线讲清楚:panic 的两种底层策略、触发 panic 的全部常见路径、以及在库与应用程序中不同的取舍标准。分界一旦内化,你写的函数签名自己就会说话。

一、panic 究竟做了什么

panic 不是异常。触发时 Rust 会沿调用栈 unwind(展开),逐个调用析构函数释放资源,然后终止当前线程;主线程 panic 即进程退出。配 panic = "abort" 时跳过展开直接退出,二进制更小但失去栈回溯。

fn main() {
    let v = vec![1, 2, 3];
    println!("{}", v[10]); // 运行时 panic:index out of bounds
}

终端会看到 thread 'main' panicked at 'index out of bounds: the len is 3 but the index is 10',附文件名行号。设环境变量 RUST_BACKTRACE=1 可打印完整调用栈定位触发点。

展开与中止两种策略各有所长。展开让析构函数正常运行,RAII 资源(文件句柄、锁)被正确释放,代价是二进制里保留一大段展开表;中止省掉这些表,进程直接退出由操作系统回收资源,但任何析构副作用都不会发生。对常规命令行工具默认展开即可,嵌入式或体积敏感的场景才考虑切到 abort。两者切换只需 Cargo.toml 里一行 profile 配置,语义上要把中止当成「连遗言都不说的退出」。

还有一个容易混淆的点:panic 停的是线程不是进程。主线程 panic 等价于进程退出,但子线程 panic 只让那一个线程落幕,其余线程照常运行——如果它们不依赖被 panic 线程的结果的话。这把「进程崩了」与「线程挂了」拆成两件事,写多线程服务时要时刻记得。

二、Result 与 panic 的分工

核心原则:错误是调用者可以合理预期并处理的,就返回 Result;违反内部不变量、继续执行会污染数据的,直接 panic。形象的说法是 Result 是「提问」,panic 是「断言」。

场景 选择 理由
文件不存在、解析失败 Result 调用者可能有兜底方案
索引越界、除以零 panic 程序逻辑已错,无恢复意义
配置缺失(应用启动时) panic 早期失败优于带病运行
网络超时 Result 重试与降级是常态
枚举 match 漏分支 panic 不可达路径,编译器也认为错

另一个视角的判据是「错误的归因」:如果错误归因于环境(用户输入、磁盘状态、网络),它就是可预期外部条件,属 Result;如果归因于代码自身的 bug,它就是不变量崩塌,属 panic。同一件事在不同抽象层归因可能不同:网络字节序错误在底层是环境问题,到了已经校验过的协议层就是 bug。判据随抽象层浮动是正常的,签名照着当前层的归因写即可。

三、触发 panic 的常见路径

主动 panic 的写法有四种,各有适用场合:直接 panic! 宏、assert!/assert_eq! 断言、unreachable! 标记不可达、以及 unwrap/expect 的失败分支。测试代码里 unwrap 密集出现是合理的,生产代码则要看错误是否真的不可恢复。

fn divide(a: i32, b: i32) -> i32 {
    if b == 0 {
        panic!("除数不能为零,收到 {}", b); // 主动断言不变量
    }
    a / b
}

fn pick(xs: &[i32]) -> i32 {
    assert!(!xs.is_empty(), "切片不能为空");
    xs[0]
}

expect 比 unwrap 好在信息可追踪:失败信息里带上下文文本,排查时不用先猜是哪一行哪个变量。团队规范里常见的一条是「生产代码禁止裸 unwrap,要 unwrap 就写 expect 并说明原因」。

顺带理清几条边界:unwrap 和 expect 失败时 panic,成功时返回内部值,因此它们是 panic 与 Result 之间的桥;unwrap_or 系列则绝不 panic,失败时给出默认值。背下这张对照关系,读开源代码时各种链式写法就不再费解。

let port: u16 = std::env::var("PORT")
    .ok()
    .and_then(|s| s.parse().ok())
    .unwrap_or(8080); // 解析失败也只回落默认值,不 panic

四、库与应用的不同标准

写库时要默认 Result:你不知道调用者愿不愿意容忍这个错误,panic 会强行杀死对方的线程,属于越权行为。写应用程序时标准反过来:最外层拿到 Result 后用 expect 或直接 panic 收尾都合理,因为再往上传也没有人接了。

角色 默认策略 例外
库的公开 API Result 确定不可能恢复时 panic
库的内部辅助函数 panic 或 unwrap 仅当调用方都在同一控制域
应用 main expect 收尾 用户可见错误应友好提示
测试代码 unwrap 任意用 失败即测试失败正合适

五、catch_unwind 与边界情况

标准库提供 std::panic::catch_unwind 捕获 panic,但它只用于 FFI 边界等少数场景:C 代码调进 Rust 时不能让异常穿过去。它不能当异常机制用——展开过程中锁可能还持有着,状态是否一致没有保证。另外 panic 未必 unwind:设置了 abort 策略时根本没有捕获机会,代码不能假设对方一定用默认配置。

一个真实的坑:在闭包或迭代器里 panic 会终止当前线程,若线程是线程池借来的,整个池可能就此报废。写服务端代码时把可能 panic 的隔离边界想清楚,比事后查日志省事得多。

关于「该不该 catch_unwind」再补一条判断:它返回 Result 但不保证程序状态的一致性,所以只能用于「记录日志后尽快退出」这类目的,绝不能用来恢复执行。把它当 try-catch 用的代码最后都会演变成状态谜题——官方文档也明确把它定位为 FFI 边界工具而非通用机制。新手最常犯的错恰恰在此:从异常语言过来,看见 catch_unwind 如获至宝,结果写出一堆 unwind 之后继续跑的半残逻辑。

六、速查与心法

判断流程压缩成三问:调用者能否合理处理?继续运行是否有意义?错误是否揭示了 bug 而非环境?三问的答案决定 Result 还是 panic,规则简单到可以贴在显示器边上。另一条经验:不确定就选 Result,因为 Result 可以被调用者转成 panic(unwrap 一下即可),反过来不行。这条不对称性意味着 Result 是更宽容的选择,把决定权留给上层,而 panic 是一锤定音。

再补一张常见 panic 触发源的速查表,读代码遇到时能立刻对号入座:

触发源 触发条件 附近信息示例
数组/切片索引 下标越界 index out of bounds
整数运算 溢出(debug 模式) attempt to add with overflow
unwrap/expect Option 为 None called Option::unwrap on None
Vec::remove 移除不存在的索引 remove index out of bounds
panic! 宏 主动调用 自定义消息原文

注意 release 构建里整数溢出是环绕而不是 panic,这是调试与发布行为不同的少数地方之一,做跨环境排查时尤其要分辨。

想系统性地复查自己代码里的错误处理决策,Claude Code 使用指南 提供了按函数扫描 unwrap 调用并评估其必要性的工作流。错误处理的完整图景在 Option 与 Result 一章,本篇假定你已读过那两节。

延伸阅读

  • 至此错误处理三件套(Option 与 Result、? 运算符、panic 分界)收齐,下一篇进入 struct,开始把数据组织成结构化形态。
  • 写 CLI 工具时 panic 与用户提示的配合方式,可以看 CLI 实战一章的退出码设计,那里的经验与本篇直接衔接。
  • Tauri 桌面开发指南 的进程模型一章讨论了主进程 panic 时前端如何感知,对理解 panic 的实际影响很有帮助。