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 的实际影响很有帮助。
