panic 与 Result Err
前面基础篇的错误处理章节已经学过 panic! 和 Result 的基本用法, 这一篇专题主要深入讨论: 什么时候该用 panic!, 什么时候该用 Result, 以及实际项目中常用的错误处理库(anyhow、thiserror)
回顾: Rust 没有异常(exception)的概念, 错误只分为两大类
- 可恢复的错误(recoverable): 比如文件不存在, 网络超时, 用
Result处理 - 不可恢复的错误(unrecoverable): 比如数组越界, 除零, 用
panic!处理
panic 与 Result 的对比
| 对比项 | panic! | Result |
|---|---|---|
| 错误类型 | 不可恢复错误 | 可恢复错误 |
| 程序行为 | 程序立即终止(打印错误信息后退出) | 把错误返回给调用者, 由调用者决定怎么处理 |
| 适用场景 | 示例代码/原型/测试/编译器保证不可能出错的情况 | 函数返回值, 一切可能失败的 I/O 操作 |
| 能否被捕获 | 默认不能(会展开调用栈, 见下方 panic 的行为配置) | 可以, 调用者用 match 分支处理 |
| 返回值 | 无(类型是 !, 永不返回) | Ok(T) 或 Err(E) |
什么时候用 panic
panic! 的使用场景是: 程序已经没有继续执行下去的意义了, 直接终止
fn main() {
// 快速验证逻辑, 暂时不想处理错误
// 比如学习阶段, 或者写一次性脚本
let num: u8 = "123".parse().unwrap();
println!("num = {num}");
}#[test]
fn test_parse() {
// 测试失败就应该 panic, 否则测试无法发现 bug
let num: u8 = "123".parse().unwrap();
assert_eq!(num, 123);
}fn main() {
let arr = [1, 2, 3];
// 明确知道数组不为空, 所以这里不可能返回 None
// 此时用 unwrap 是安全的
let first = arr.first().unwrap();
println!("{first}");
}注意点
unwrap 和 expect 只是 match 的简写, 遇到 Err 时都会触发 panic!, 在正式代码中不要随便使用, 一旦出错程序就崩溃了
unwrap(): panic 时输出默认错误信息expect("自定义消息"): panic 时输出自定义消息, 方便排查问题, 优先使用expect
什么时候用 Result
只要错误是可预料的(文件不存在, 网络断开, 用户输入不合法...), 都应该返回 Result, 把错误交给调用者处理, 而不是直接 panic
use std::fs::File;
fn main() {
// 文件可能不存在, 这是一个可恢复的错误
// 如果直接 unwrap, 程序会崩溃, 这是不合理的
// 应该把错误交给调用者处理
let file = match File::open("config.txt") {
Ok(f) => f,
Err(e) => {
eprintln!("打开配置文件失败: {e}");
// 可以在这里降级处理, 比如使用默认配置
return;
}
};
}Result 的常用方法
Result 提供了大量组合子方法, 避免手写 match, 常用如下:
| 方法 | 作用 |
|---|---|
| unwrap | Ok 取出值, Err 时 panic |
| expect | 同 unwrap, 但是可以自定义 panic 消息 |
| unwrap_or | Err 时返回默认值 |
| unwrap_or_else | Err 时执行闭包, 用闭包的返回值 |
| unwrap_or_default | Err 时返回类型的默认值 |
| map | 只对 Ok 的值做转换 |
| map_err | 只对 Err 的值做转换 |
| and_then | Ok 时继续执行一个返回 Result 的闭包(链式调用) |
| or_else | Err 时执行一个返回 Result 的闭包 |
| is_ok / is_err | 判断当前是 Ok 还是 Err, 返回 bool |
fn main() {
let ok: Result<i32, &str> = Ok(10);
let err: Result<i32, &str> = Err("出错了");
// unwrap: Ok 取出值, Err 直接 panic
println!("{}", ok.unwrap()); // 10
// unwrap_or: Err 时使用默认值
println!("{}", err.unwrap_or(0)); // 0
// unwrap_or_else: Err 时执行闭包
println!("{}", err.unwrap_or_else(|e| {
eprintln!("错误信息: {e}");
-1
})); // -1
// unwrap_or_default: Err 时返回类型的默认值
println!("{}", err.unwrap_or_default()); // 0
// map: 只对 Ok 的值做转换, Err 原样返回
let double = ok.map(|x| x * 2);
println!("{double:?}"); // Ok(20)
// map_err: 只对 Err 的值做转换, Ok 原样返回
let mapped = err.map_err(|e| format!("自定义错误: {e}"));
println!("{mapped:?}"); // Err("自定义错误: 出错了")
// and_then: Ok 时继续执行闭包(闭包也要返回 Result)
let result = ok.and_then(|x| Ok(x + 5));
println!("{result:?}"); // Ok(15)
// or_else: Err 时执行闭包, 可以"挽救"错误
// 注意: 闭包需要返回完整类型的 Result, 所以要标注类型
let result: Result<i32, &str> = err.or_else(|_e| Ok(100));
println!("{result:?}"); // Ok(100)
// is_ok / is_err
println!("{} {}", ok.is_ok(), ok.is_err()); // true false
}panic 的行为配置
panic! 默认会展开调用栈(unwind), 逐层执行清理逻辑(释放资源)后再退出, 也可以通过配置改为直接中止(abort), 不执行任何清理, 减小程序体积
# Cargo.toml
[profile.release]
panic = "abort" # 发布版本直接终止, 不展开调用栈(程序体积更小)调试 panic 时可以设置环境变量 RUST_BACKTRACE 打印调用栈:
RUST_BACKTRACE=1 cargo run
# 输出:
# thread 'main' panicked at src/main.rs:4:5:
# 出错了
# stack backtrace:
# 0: rust_begin_unwind
# ...Result 与错误传播
? 运算符的原理
? 运算符是错误传播的语法糖, 它的完整写法其实就是一段 match:
// 定义一个可能失败的函数
fn div(a: i32, b: i32) -> Result<i32, String> {
if b == 0 {
return Err("除数不能为 0".to_string());
}
Ok(a / b)
}
fn calc() -> Result<i32, String> {
// Ok 时取出值, Err 时直接 return 给调用者
let x = div(10, 2)?;
let y = div(x, 5)?;
Ok(x + y)
}
fn main() {
println!("{:?}", calc()); // Ok(6)
}fn calc() -> Result<i32, String> {
// div(10, 2)? 等价于下面这段 match
let x = match div(10, 2) {
Ok(v) => v,
Err(e) => return Err(e), // 错误直接返回, 后面的代码不执行
};
let y = match div(x, 5) {
Ok(v) => v,
Err(e) => return Err(e),
};
Ok(x + y)
}错误类型的自动转换
? 在返回错误时, 会自动调用 From::from 做类型转换, 所以只要实现了 From<原始错误类型>, 就能把不同来源的错误统一转换成自己的错误类型
use std::fs::File;
use std::io::Read;
// 自定义错误类型
#[derive(Debug)]
enum MyError {
Io(std::io::Error), // IO 错误
Utf8(std::string::FromUtf8Error), // 编码错误
}
// 实现 From, 让 ? 能把 io::Error 自动转成 MyError::Io
impl From<std::io::Error> for MyError {
fn from(e: std::io::Error) -> Self {
MyError::Io(e)
}
}
// 实现 From, 让 ? 能把 FromUtf8Error 自动转成 MyError::Utf8
impl From<std::string::FromUtf8Error> for MyError {
fn from(e: std::string::FromUtf8Error) -> Self {
MyError::Utf8(e)
}
}
fn read_file(path: &str) -> Result<String, MyError> {
let mut file = File::open(path)?; // io::Error -> MyError::Io
let mut buf = Vec::new();
file.read_to_end(&mut buf)?; // io::Error -> MyError::Io
let content = String::from_utf8(buf)?; // FromUtf8Error -> MyError::Utf8
Ok(content)
}
fn main() {
match read_file("a.txt") {
Ok(content) => println!("{content}"),
Err(e) => eprintln!("读取失败: {e:?}"),
}
}这个手动实现
From的过程, 后面学的 thiserror 可以用一行#[from]自动完成
main 函数返回 Result
main 函数也可以返回 Result, 此时如果返回 Err, 程序会自动打印错误信息并以非 0 状态码退出, 适合命令行程序的顶层入口
use std::error::Error;
// Box<dyn Error>: 不知道(也不关心)错误的具体类型, 直接向上抛
fn main() -> Result<(), Box<dyn Error>> {
// 整个 main 里可以放心大胆的用 ? 了
let content = std::fs::read_to_string("config.txt")?;
println!("{content}");
Ok(())
}注意点
Box<dyn Error> 只能当作"万能错误"向上抛, 调用者拿不到具体的错误类型, 所以只适合在 main 这种顶层使用; 库代码必须返回具体的错误类型, 方便调用者分类处理
Option 与 Result 互转
两种类型经常需要互相转换: 忘记处理错误把 Result 丢成 Option, 或者反过来给 Option 补上错误信息
| 方法 | 作用 |
|---|---|
| Result::ok | Result<T, E> -> Option<T> (丢弃错误) |
| Result::err | Result<T, E> -> Option<E> (丢弃值) |
| Option::ok_or | Option<T> -> Result<T, E> (None 时使用传入的错误) |
| Option::ok_or_else | 同 ok_or, 但是错误由闭包延迟生成 |
fn main() -> Result<(), &'static str> {
// Result -> Option: 只关心有没有值, 不关心错误
let num = "42".parse::<i32>().ok();
println!("{num:?}"); // Some(42)
// Option -> Result: 给 None 补上错误信息
// ok_or 的参数是立即求值的, ok_or_else 是延迟求值的(性能更好)
let num = "42".parse::<i32>().ok().ok_or("解析失败")?;
println!("num = {num}"); // 42
// 解析失败时, ok_or 会把错误返回出去
let num = "abc".parse::<i32>().ok().ok_or_else(|| "解析失败")?;
Ok(())
}使用 anyhow
anyhow 是应用层(二进制程序)最常用的错误处理库, 核心思路是: 不关心错误的具体类型, 只负责传递和展示错误
cargo add anyhowanyhow::Result<T>: 等价于Result<T, anyhow::Error>的类型别名anyhow::Error: 一个"万能"错误类型, 任何实现了std::error::Error的错误都能自动转换进去
use anyhow::{Result, Context};
// 返回类型直接写 anyhow::Result<T>, 不需要指定具体的错误类型
fn read_user_config() -> Result<String> {
let content = std::fs::read_to_string("config.json")
// 给错误附加上下文信息, 方便排查问题
.with_context(|| "读取配置文件失败")?;
Ok(content)
}
fn main() -> Result<()> {
let config = read_user_config()?;
println!("{config}");
Ok(())
}运行时输出错误, 会同时打印上下文和底层错误:
Error: 读取配置文件失败
Caused by:
No such file or directory (os error 2)添加上下文 Context
Context trait 给错误"套上一层信息", 常用方法:
| 方法 | 作用 |
|---|---|
| context | 给错误添加上下文(参数立即求值) |
| with_context | 给错误添加上下文(参数是闭包, 延迟求值, 推荐用于拼接字符串) |
use anyhow::{Result, Context};
fn main() -> Result<()> {
// context: 直接传字符串
let content = std::fs::read_to_string("a.txt")
.context("读取文件失败")?;
// with_context: 传闭包, 只有出错时才执行(字符串拼接更高效)
let content = std::fs::read_to_string("a.txt")
.with_context(|| format!("读取文件 {} 失败", "a.txt"))?;
Ok(())
}常用宏
| 宏 | 作用 |
|---|---|
| anyhow! | 创建一个 anyhow::Error 值 |
| bail! | 直接返回错误(等价于 return Err(anyhow!(...))) |
| ensure! | 条件不成立时返回错误(等价于 if + bail!) |
use anyhow::{anyhow, bail, ensure, Result};
fn check_age(age: i32) -> Result<()> {
// ensure!: 条件不满足时直接返回错误
ensure!(age >= 0, "年龄不能是负数: {age}");
// bail!: 直接返回错误
if age < 18 {
bail!("未成年: {age}");
}
// anyhow!: 创建一个 Error 值, 再手动 return
if age > 150 {
return Err(anyhow!("年龄过大: {age}"));
}
Ok(())
}
fn main() -> Result<()> {
check_age(-1)?;
// Error: 年龄不能是负数: -1
Ok(())
}注意点
anyhow 只适合应用程序(二进制程序), 不适合库
- 库需要暴露明确的错误类型, 让调用者能
match到具体分支去处理 - anyhow::Error 会丢失错误类型信息, 调用者无法分类处理
- 库的错误定义, 应该用下面的 thiserror :::
thiserror 使用
thiserror 是库最常用的错误处理库, 核心思路是: 用 #[derive(Error)] 自动给自定义错误类型实现 Display 和 std::error::Error
cargo add thiserror定义错误类型
只需要给枚举(或结构体)加 #[derive(Error)], 再给每个变体加 #[error("...")] 描述消息即可
use thiserror::Error;
// 只需要 derive Error 和 Debug
// thiserror 会自动实现 Display 和 std::error::Error
#[derive(Debug, Error)]
enum UserError {
// 单元变体: 直接写消息
#[error("用户名不能为空")]
EmptyName,
// 结构体变体: 可以用 {字段名} 引用字段
#[error("用户 {name} 不存在")]
NotFound { name: String },
// 元组变体: {0} 表示第 0 个字段
#[error("年龄不合法: {0}")]
InvalidAge(i32),
// #[from] 会自动生成 From<std::io::Error> 实现
// 这样 ? 就能把 io::Error 自动转成 UserError::Io
#[error("IO 错误: {0}")]
Io(#[from] std::io::Error),
// 多个 #[from] 也是可以的, 会自动生成多个 From 实现
#[error("数字解析失败: {0}")]
Parse(#[from] std::num::ParseIntError),
}
fn read_age_from_file(path: &str) -> Result<u8, UserError> {
// 文件不存在 -> io::Error -> 自动转换为 UserError::Io
let content = std::fs::read_to_string(path)?;
// 内容不是数字 -> ParseIntError -> 自动转换为 UserError::Parse
let age = content.trim().parse::<u8>()?;
Ok(age)
}常用属性
| 属性 | 作用 |
|---|---|
#[error("...")] | 定义 Display 输出格式, 支持 {0}(第 0 个字段) 和 {字段名} 引用字段 |
#[from] | 自动实现 From<字段类型>, 配合 ? 使用, 最常见的用法是包一层底层错误 |
#[source] | 标记错误来源字段(自动实现 source() 方法), 底层错误会作为"Caused by"打印 |
#[error(transparent)] | 直接透传内部错误的 Display 和 source, 不新增消息 |
use thiserror::Error;
#[derive(Debug, Error)]
enum MyError {
#[error("业务错误: {0}")]
Business(String),
// #[source]: 手动指定错误来源
// 打印时底层错误会作为 Caused by 展示
#[error("底层错误")]
Underlying {
#[source]
source: std::io::Error,
},
// #[error(transparent)]: 完全透传内部错误
// Display 和 source 都用 io::Error 的
#[error(transparent)]
Transparent(#[from] std::io::Error),
}使用场景
- thiserror: 库代码, 定义明确的错误类型, 调用者可以
match具体分支处理 - anyhow: 应用程序, 只关心错误能不能被传递和打印
两个可以搭配使用, 见下面的 anyhow 与 thiserror 一起使用
anyhow 与 thiserror 一起使用
实际项目的分层原则: 库用 thiserror 定义错误, 应用用 anyhow 消费错误
- 库(被他人调用): 用 thiserror 定义具体的错误类型, 调用者才能分类处理
- 应用(二进制程序): 用 anyhow 传递错误, 打印时自动带上所有"Caused by"
为什么能混用? 因为
anyhow::Error实现了From<E>(任何实现了std::error::Error的类型), 所以库返回的 thiserror 错误, 在应用里直接用?就能自动转换成anyhow::Error
project/
├── Cargo.toml
├── lib-demo/ # 库 crate: 使用 thiserror
│ ├── Cargo.toml
│ └── src/lib.rs
└── src/main.rs # 二进制 crate: 使用 anyhow库层: thiserror 定义错误
# lib-demo/Cargo.toml
[dependencies]
thiserror = "2"// lib-demo/src/lib.rs
use thiserror::Error;
// 库对外暴露的错误类型, 必须是明确的
#[derive(Debug, Error)]
pub enum DemoError {
#[error("年龄不合法: {0}")]
InvalidAge(u8),
#[error("配置读取失败: {0}")]
Config(#[from] std::io::Error),
}
// 库的公开函数: 返回自己定义的错误类型
pub fn get_age(config_path: &str) -> Result<u8, DemoError> {
// io::Error 通过 #[from] 自动转成 DemoError::Config
let content = std::fs::read_to_string(config_path)?;
// 解析失败时, 返回 DemoError::InvalidAge
let age = content
.trim()
.parse::<u8>()
.map_err(|_| DemoError::InvalidAge(0))?;
Ok(age)
}应用层: anyhow 消费错误
# Cargo.toml (二进制 crate)
[dependencies]
anyhow = "1"
lib-demo = { path = "./lib-demo" }// src/main.rs
use anyhow::{Context, Result};
fn main() -> Result<()> {
// 库返回的 DemoError, 通过 ? 自动转换为 anyhow::Error
// 因为 anyhow::Error 实现了 From<DemoError>
let age = lib_demo::get_age("age.txt")?;
println!("age = {age}");
// 也可以再添加上下文, 打印时会显示:
// Error: 获取年龄失败
//
// Caused by:
// 配置读取失败: No such file or directory (os error 2)
let age = lib_demo::get_age("age.txt").context("获取年龄失败")?;
println!("age = {age}");
Ok(())
}其他常用错误处理库
| 库 | 定位 | 说明 |
|---|---|---|
| snafu | 错误定义 | thiserror 的替代品, 支持自动生成错误上下文(context), 用法更灵活 |
| eyre | 错误报告 | anyhow 的替代品, 可以自定义错误报告的处理方式(Report Handler) |
| color-eyre | 错误报告 | eyre 的增强版, 输出带颜色/间距的详细错误报告, 适合开发调试 |
| miette | 错误报告 | 输出带源码片段/高亮的诊断报告, 类似编译器的错误提示, 适合 CLI 工具 |
| derive_more | 派生宏 | 除了 Error, 还提供了 Display/From/Add 等大量派生宏, 一个宏全家桶 |
| fehler | 异常模拟 | 用宏模拟其他语言的 try/catch 写法, 与 Rust 社区风格不符, 已停止维护 |
如何选择
- 库: thiserror 或 snafu
- 应用: anyhow 够用, 想要更漂亮的错误报告用 color-eyre / miette :::