panic 与 Result
本章定位:把「出错」这件事从语言的隐藏机制变成类型系统里的普通值——从
panic!、Result<T, E>一路讲到自定义错误类型。前置知识: 结构体、枚举与 trait(enum与match是Result的基础)、 集合与迭代器(collect/try_for_each是 「与其他语言的对照」一节的关键工具)。 术语译法以附录 A · 术语中英对照为准。
本章目标
- 能区分不可恢复错误(
panic!)与可恢复错误(Result<T, E>),并说出 Rust 为什么不做异常。 - 能熟练使用
Result的 18 个常用方法,并解释?运算符等价于哪一段match。 - 能自己定义符合工程规范的错误类型:
enum+Display+Error+From,并用#[non_exhaustive]留出扩展空间。 - 能判断什么时候该用
thiserror(库),什么时候该用anyhow(二进制),并写出带上下文的错误链。 - 能看懂并修掉本章列出的 6 类经典编译错误,知道
unwrap()的合法使用边界。
两类错误:不可恢复与可恢复
先看结论
Rust 把「出错」分成两类,并且用不同的语言机制处理:
| 类别 | 代表 | 是否可恢复 | 表现 |
|---|---|---|---|
| 不可恢复错误(unrecoverable) | panic!、数组越界、unwrap() 失败 | 否 | 线程终止(默认展开栈) |
| 可恢复错误(recoverable) | 文件不存在、解析失败、网络超时 | 是 | 返回值 Result<T, E> |
关键点:Rust 没有异常(exception),没有 try / catch / throw / finally。可恢复错误就是一个普通返回值,和 i32、String 没有本质区别。
为什么 Rust 不做异常
💡 对照:Python 里
open("x")失败会抛FileNotFoundError;Java 里new FileInputStream("x")抛FileNotFoundException。调用方从签名看不出来(Python 完全看不出,Java 只有 checked exception 才在签名里)。Go 则把 error 当返回值:f, err := os.Open("x")。
Rust 选择 Go 那一侧的思路,但用类型系统做得更彻底。四个理由:
控制流显式化(control flow is explicit) 读代码时,只要看到
?、match、unwrap就知道这里可能提前返回。异常的问题是:任何一行函数调用都可能「凭空」跳到几百行外的catch,本地推理失效。没有隐藏跳转,借用检查器才能工作 如果函数可以在任意点抛出异常跳走,那么「引用在什么时候还有效」就无法静态判定。
?提前返回是一个普通的return,借用检查器(borrow checker)可以正常分析资源释放顺序。#[must_use]让「忘记处理」变成编译期警告Result和Option都标注了#[must_use]:rustfn main() { let mut v: Vec<i32> = Vec::new(); v.first(); // 忘记处理返回值 }输出(
cargo build的警告):textwarning: unused return value of `core::slice::<impl [T]>::first` that must be used --> src/main.rs:3:5 | 3 | v.first(); | ^^^^^^^^^ | = note: `#[warn(unused_must_use)]` (part of `#[warn(unused)]`) on by default help: use `let _ = ...` to ignore the resulting value也就是说:忽略了错误,编译器会当场抱怨。Java 的
catch (Exception e) {}空实现、Python 的except: pass在 Rust 里对应不上——除非你写let _ = ...;显式表态。错误类型参与类型推导
Result<T, E>的E是具体类型,编译器知道你可能产生哪些错误;?还能自动做From转换。这比 Python 的「任何地方都可能抛任何东西」精确得多,也比 Go 的「必须手写if err != nil」简洁。
三方对照表
| 维度 | Rust | Java | Go | Python |
|---|---|---|---|---|
| 错误表示 | Result<T, E> 返回值 | Throwable 子类对象 | error 接口值 | BaseException 子类对象 |
| 抛出方式 | return Err(e) / ? | throw new XxxException() | return nil, err | raise XxxError() |
| 捕获方式 | match / if let / ? 向上传播 | try { } catch (X) { } | if err != nil { } | try: / except X: |
| 是否在签名中可见 | 是(返回类型 Result<T, E>) | 只有 checked exception 需要 throws | 是(多返回值) | 否 |
| 忘记处理会怎样 | 编译警告 unused_must_use | checked 是编译错误;unchecked 静默 | 静默(编译器不管) | 静默(运行期才炸) |
| 错误链 | Error::source() | Throwable.getCause() | errors.Is/As + %w 包装 | raise ... from e / __cause__ |
| 提前返回语法 | ? | 无(必须 throw) | if err != nil { return err } | 无(必须 raise) |
| 不可恢复错误 | panic! | Error(如 OutOfMemoryError) | panic() | SystemExit / 进程崩溃 |
| 资源清理 | RAII,Drop 自动执行 | finally / try-with-resources | defer | finally / with |
| 跨 FFI 边界 | panic 穿出 extern "C" 是 UB | 异常不能穿 JNI 边界(同样危险) | 无此概念 | C 扩展里同样危险 |
⚠️ 陷阱:
Result是普通值,所以它也会被?、被match、被collect、被放进Vec。不要把它想成「异常对象」,它是可组合的数据。
panic! 与不可恢复错误
panic! 的两种收尾方式:展开 vs 中止
panic! 触发后,当前线程停止正常执行。怎么收尾由 Cargo.toml 决定:
toml
# Cargo.toml
[profile.release]
panic = "abort" # 直接 abort,不展开栈;二进制更小、更快,但无法 catch_unwind| 策略 | panic = "unwind"(默认) | panic = "abort" |
|---|---|---|
| 行为 | 逐帧展开栈,运行每帧的 Drop | 立即调用 abort,进程终止 |
| 资源清理 | Drop 会执行,锁会毒化(poisoning) | 不执行 |
能否 catch_unwind | 能 | 不能(catch 不到,直接死) |
| 体积/性能 | 略大 | 更小更快 |
| 适用 | 一般程序、测试、库 | 嵌入式、WASM、cdylib、无法承受展开的场景 |
🧠 原理:
abort下Drop不执行,所以如果持有的是「必须手动释放」的外部资源(如 ffmpeg 的裸指针包装),就会泄漏。反过来,unwind也不是零成本:编译器要为每个栈帧生成「着陆垫(landing pad)」代码。
⚠️ 注意:
panic = "abort"写在最终产物那一侧才生效。库 crate 里配panic = "abort"会被忽略(Cargo 会警告),因为策略由链接出的二进制决定。
panic! 宏家族
rust
fn main() {
let v = vec![1, 2, 3];
// assert!:条件为假时 panic,可带格式化消息
assert!(!v.is_empty(), "v 必须非空,实际长度 {}", v.len());
// assert_eq! / assert_ne!:同时打印左右两边的值(要求实现 Debug + PartialEq)
assert_eq!(v.iter().sum::<i32>(), 6);
assert_ne!(v.len(), 0);
// debug_assert!:只在 debug 构建下检查,release 下整段消失
debug_assert!(v.len() < 100);
// unreachable!:逻辑上不可能到达;到达了说明前面的不变量被破坏
for i in 0..v.len() {
match i {
0..=2 => {}
_ => unreachable!("i 只可能是 0..=2,实际 {i}"),
}
}
// todo! / unimplemented!:尚未实现;返回类型是 `!`,可以当任何类型用
fn parse_mode(s: &str) -> u8 {
match s {
"a" => 0,
"b" => 1,
other => unimplemented!("暂不支持的模式:{other}"),
}
}
println!("mode a = {}", parse_mode("a"));
}输出:
textall asserts passed mode a = 0
各宏的定位:
| 宏 | 用途 | release 下是否检查 |
|---|---|---|
panic!("…") | 主动报告不可恢复错误 | 是 |
assert!(cond, "…") | 检查前置/后置条件、不变量 | 是 |
assert_eq!(a, b) / assert_ne!(a, b) | 检查相等性,失败时打印两边的值 | 是 |
debug_assert! / debug_assert_eq! | 昂贵的内部一致性检查 | 否(整段消失) |
unreachable!() | 声称某分支不可能到达 | 是 |
todo!() | 「这里还没写」,会留下编译通过但运行 panic 的坑 | 是 |
unimplemented!() | 「这个分支故意不支持」 | 是 |
💡 对照:
assert在 Python 里可以用-O关掉;Rust 的assert!永远生效,只有debug_assert!才会被 release 优化掉。想「release 里也关掉」要自己用debug_assert!。
🚀 进阶:
todo!()/unimplemented!()的类型是!(never type),因此可以放在任何需要返回值的分支里而不用编造值。这也是「先让类型检查通过,再填实现」的标准手法——但要记得cargo clippy有todo相关 lint,别让它留在提交里。
panic 的载荷与 backtrace
panic! 的参数会变成「载荷(payload)」,类型是 Box<dyn Any + Send>:
panic!("文本")→ 载荷是&'static strpanic!("带 {}", 值)→ 载荷是String(因为做了格式化)panic_any(x)→ 载荷是任意Send + 'static值
想看调用栈,运行时设置环境变量:
powershell
# PowerShell(Windows)
$env:RUST_BACKTRACE = "1" # 简短回溯
$env:RUST_BACKTRACE = "full" # 完整回溯(含标准库帧)
cargo runbash
# bash(Linux/macOS)
RUST_BACKTRACE=1 cargo runcatch_unwind:能抓,但别用来做业务错误处理
std::panic::catch_unwind 可以把 panic 变成 Result:
rust
use std::panic;
fn risky(n: i32) -> i32 {
if n < 0 {
panic!("n 不能为负数:{n}");
}
100 / n
}
fn main() {
let r = panic::catch_unwind(|| risky(-1));
println!("catch_unwind -> is_err = {}", r.is_err());
if let Err(payload) = r {
// 载荷可能是 &str 或 String,要按类型 downcast
let msg = payload
.downcast_ref::<&str>()
.map(|s| (*s).to_string())
.or_else(|| payload.downcast_ref::<String>().cloned());
println!("payload = {msg:?}");
}
}输出(stderr 里仍有 panic 报告,这是正常的):
textcatch_unwind -> is_err = true payload = Some("n 不能为负数:-1")
限制(很重要):
闭包必须满足
UnwindSafe。闭包捕获了&mut之类「可能被观察到中间状态」的东西时要显式包一层AssertUnwindSafe——这是你在向编译器承诺「我看过代码,状态被破坏也没关系」:rustuse std::panic::{self, AssertUnwindSafe}; let mut counter = 0; let res = panic::catch_unwind(AssertUnwindSafe(|| { counter += 1; // 如果在这里 panic,counter 可能处于半更新状态 counter }));panic = "abort"下完全失效。它抓不到真正致命的情况(如
SIGSEGV、栈溢出之后的二次 panic、std::process::abort)。不要用它代替
Result。业务逻辑里的「文件不存在」「校验失败」应该是Err,用catch_unwind只会让控制流更难读、还丢失类型信息(载荷是Box<dyn Any>,得手动 downcast)。
合法的用途只有这几类:测试框架断言 panic(#[should_panic] 的底层)、FFI 回调边界兜底、线程池把某个任务 panic 转成结果、插件/脚本宿主隔离。
跨 FFI 边界 panic:不要做
一句话:让 panic 穿过 extern "C" 边界是未定义行为(UB)——C 的调用约定不知道「展开栈并跳回调用方」这件事。C++ 那边是 std::terminate,Rust 这边则是 UB,比直接崩还糟。
rust
#[unsafe(no_mangle)]
pub extern "C" fn process(n: i32) -> i32 {
// 任何可能 panic 的东西都必须被拦住
let result = std::panic::catch_unwind(|| {
if n < 0 {
panic!("负数");
}
n * 2
});
match result {
Ok(v) => v,
Err(_) => -1, // 转成 C 能理解的错误码
}
}⚠️ 注意:Rust 2024 起
extern "C"函数默认会隐式启用「panic 跨边界即 abort」的保护(extern "C"的panic策略从unwind变为了abort语义,编译期会插入 abort 调用),所以「UB」在实践中表现为进程直接终止。但这仍然不是错误处理:正确的做法是在 FFI 边界内catch_unwind并把错误转成错误码。相关提示:
panic in a function that cannot unwind/extern "C" fn相关 lint 会明确告诉你这一点。
Result<T, E> 详解
定义
rust
// 标准库里的定义(简化)
pub enum Result<T, E> {
Ok(T),
Err(E),
}它就是一个两变体的枚举,只额外带了一句 #[must_use]。T 是成功值的类型,E 是错误值的类型。没有别的魔法。
rust
fn div(a: i32, b: i32) -> Result<i32, String> {
if b == 0 {
return Err("除数不能为 0".to_string());
}
Ok(a / b)
}
fn main() {
match div(10, 2) {
Ok(v) => println!("10 / 2 = {v}"),
Err(e) => eprintln!("失败:{e}"),
}
// if let 只关心失败时更简洁
if let Err(e) = div(1, 0) {
println!("预期中的失败:{e}");
}
}输出:
text10 / 2 = 5 预期中的失败:除数不能为 0
#[must_use] 的实际表现:
rust
fn main() {
let mut v: Vec<i32> = Vec::new();
v.pop(); // Vec::pop 返回 Option,但这里没标 must_use;换成返回 Result 的 API 就会警告
v.first(); // 返回 Option<&i32>,标了 #[must_use] -> 编译警告
}⚠️ 陷阱:警告是
unused_must_use,不是错误。CI 里请加-D warnings(或RUSTFLAGS="-D warnings")把它变成硬失败,否则「忘记处理错误」会一路溜进生产。
常用方法速查(18 个,覆盖 「Result<T, E> 详解」一节全部要求)
所有方法都以 self 或 &self 接收 Result。假设:
rust
let ok: Result<i32, String> = Ok(3);
let err: Result<i32, String> = Err("boom".to_string());
let txt: Result<i32, std::num::ParseIntError> = "x".parse();| 方法 | 签名要点 | 作用 | 示例结果 |
|---|---|---|---|
unwrap() | self -> T | 成功取值,失败则 panic(Err 需 Debug) | ok.unwrap() → 3 |
expect(msg) | self -> T | 同上,但 panic 消息换成 msg | 见下方消息对比 |
unwrap_or(d) | self, T -> T | 失败取默认值,急切求值 | err.unwrap_or(0) → 0 |
unwrap_or_else(f) | self, F: FnOnce(E) -> T | 失败时用错误值算默认值,惰性 | err.unwrap_or_else(|e| e.len() as i32) → 4 |
unwrap_or_default() | self -> T(T: Default) | 失败取 T::default() | err.unwrap_or_default() → 0 |
map(f) | F: FnOnce(T) -> U | 只变换 Ok 里的值 | ok.map(|n| n * 2) → Ok(6) |
map_err(f) | F: FnOnce(E) -> G(错误类型可换) | 只变换 Err 里的值 | err.map_err(|e| e.len()) → Err(4) |
and_then(f) | F: FnOnce(T) -> Result<U, E> | 链式「成功后再来一步」,避免嵌套 | ok.and_then(|n| Ok(n + 1)) → Ok(4) |
or_else(f) | F: FnOnce(E) -> Result<T, G> | 失败时换一条路,可换错误类型 | err.or_else(|_| Ok(0)) → Ok(0) |
ok() | self -> Option<T> | 丢掉错误,只留成功值 | err.ok() → None |
err() | self -> Option<E> | 丢掉成功值,只留错误 | ok.err() → None |
is_ok() | &self -> bool | 只判断,不取值 | ok.is_ok() → true |
is_err() | &self -> bool | 只判断 | err.is_err() → true |
as_ref() | &self -> Result<&T, &E> | 借用版,不消耗自身 | ok.as_ref() → Ok(&3) |
as_mut() | &mut self -> Result<&mut T, &mut E> | 可变借用版 | ok.as_mut() → Ok(&mut 3) |
inspect(f) | F: FnOnce(&T),稳定于 1.76 | 对 Ok 值做副作用(打日志),不改值 | ok.inspect(|n| println!("{n}")) → Ok(3) |
inspect_err(f) | F: FnOnce(&E),稳定于 1.76 | 对 Err 值做副作用(埋点/日志),不改值 | err.inspect_err(|e| eprintln!("{e}")) → Err(..) |
⚠️ 陷阱(本题最常被想当然的一点):
Result没有take()方法!Option::take()存在,Result::take()在标准库里不存在(写r.take()会得到 E0599,编译器还会误以为你在说Iterator::take,提示你.into_iter().take())。需要Result的等价能力时,用Option或mem::replace自己搭,见「借用版访问:as_ref/as_mut/mem::replace」。
🧠 原理:
map/and_then/or_else/inspect全是组合子(combinator)——把「成功路径」和「失败路径」当成流水线来拼。Option上的同名方法(map/and_then/or_else/take/inspect)语义完全一致,只是「失败」就是None。但方法集合并不完全重合:Option独有take/replace/get_or_insert,Result独有unwrap_err/expect_err/map_or的兄弟map_or_else用法差异。用之前先查文档。
借用版访问:as_ref / as_mut / mem::replace
当你只持有 &mut Result<T, E> 时,unwrap() 拿不走值(会移动)。两种正确解法,加上一个常见误解:
rust
fn main() {
let mut slot: Result<i32, String> = Ok(42);
// 1. as_ref / as_mut:只在借用期内看或改,不搬走内容
if let Ok(v) = slot.as_ref() {
println!("借用看到 {v}"); // v 是 &i32
}
*slot.as_mut().unwrap() += 1; // 可变借用,原地修改
println!("改完 = {slot:?}");
// 2. mem::replace:真正意义上的「取走,留下替代品」
let replacement: Result<i32, String> = Err("已被取走".to_string());
let taken = std::mem::replace(&mut slot, replacement);
println!("取走 {taken:?},原位剩下 {slot:?}");
// 3. 对比:Option::take() 是存在的,语义 = mem::replace(self, None)
let mut maybe: Option<String> = Some("x".to_string());
let got = maybe.take();
println!("Option 取走 {got:?},原位剩下 {maybe:?}");
}输出:
text借用看到 42 改完 = Ok(43) 取走 Ok(43),原位剩下 Err("已被取走") Option 取走 Some("x"),原位剩下 None
⚠️ 陷阱:
Result没有take()(也没有replace())。写slot.take()会得到 E0599no method named 'take' found for enum Result,而且编译器的建议会误导你——它以为你在找Iterator::take,提示你写.into_iter().take(),那完全是另一回事。更彻底的一点:
Result甚至没有实现Default(Option::default()是None,而「默认的成功值 / 默认的错误值」没有合理定义),所以std::mem::take(&mut slot)也用不了:
rust
fn main() {
let mut r: Result<i32, String> = Err("bad".to_string());
let taken: Result<i32, String> = std::mem::take(&mut r);
println!("taken = {taken:?}, left = {r:?}");
}输出(编译错误):
texterror[E0277]: the trait bound `Result<i32, String>: Default` is not satisfied --> src/main.rs:3:53 | 3 | let taken: Result<i32, String> = std::mem::take(&mut r); | -------------- ^^^^^^ the trait `Default` is not implemented | for `Result<i32, String>` | = note: required by a bound in `std::mem::take`想给
Result做「取走并替换」,唯一通用手段是std::mem::replace(&mut slot, 替代值)。如果确实需要Default语义,就把字段改成Option<Result<T, E>>(None表示「还没有值」),或者为新类型自己impl Default。
💡 对照:Java 里
Optional.get()是终局取值,没有「借用版」概念;Rust 因为所有权,必须显式区分self/&self/&mut self三套方法。C++ 里对应的是std::optional::value()与operator*——同样没有借用/移动的区分,因为 C++ 默认就是拷贝。
inspect / inspect_err:为日志而生(1.76+)
在链中间打日志,又不想破坏链的类型:
rust
fn main() {
let r: Result<i32, std::num::ParseIntError> = "7".parse();
let doubled = r
.inspect(|n| println!("解析成功:{n}"))
.map(|n| n * 2)
.inspect_err(|e| eprintln!("解析失败:{e}"));
println!("{doubled:?}");
}输出:
text解析成功:7 Ok(14)
在 1.76 之前得写 .map(|n| { println!("{n}"); n }) 这种别扭的写法——inspect 就是把「看一眼」和「改一改」分开。
? 运算符:可恢复错误的传播
? 到底等价于什么
expr? 在 expr: Result<T, E> 时语义是:
rust
// expr? 的等价展开(在返回 Result<_, F> 且 E: Into<F> 的函数里)
match expr {
Ok(v) => v,
Err(e) => return Err(From::from(e)), // 注意这里自动做了类型转换
}在 expr: Option<T> 时:
rust
match expr {
Some(v) => v,
None => return None,
}两点必须记住:
- 它是
return,不是throw。 控制流只在当前函数内提前返回,跳到调用方那一行继续执行——没有栈展开,没有catch,Drop也照常按作用域顺序执行。 Err分支会调用From::from。 这是?最容易被忽视、也最有用的部分。
? 只能用在返回「可以用 ? 的类型」的函数里
编译器要求当前函数的返回类型实现 FromResidual。实践上就是三种:
| 函数(或闭包)返回类型 | 其中 ? 可以作用于 |
|---|---|
Result<T, E> | Result<_, E2>,要求 E2: Into<E>(实际是 E2: Into<E> 的 From 版本) |
Option<T> | Option<_>;不能作用于 Result(要先 .ok()) |
T(如 i32、()) | 不能用 ?(E0277) |
记法:? 的「残差(residual)」必须和返回类型的残差同族——Option 与 Result 是两族,不能互穿。
rust
// ✅ 返回 Result,可以用 ?
fn parse_pair(s: &str) -> Result<(i32, i32), String> {
let (a, b) = s.split_once(',').ok_or("缺少逗号")?; // Option -> ? 用 ok_or 转成 Result
let a: i32 = a.trim().parse().map_err(|e| format!("左边不是整数: {e}"))?;
let b: i32 = b.trim().parse().map_err(|e| format!("右边不是整数: {e}"))?;
Ok((a, b))
}
// ✅ 返回 Option,? 直接穿透 None
fn first_char_len(s: &str) -> Option<usize> {
let c = s.chars().next()?; // None 直接提前返回 None
Some(c.len_utf8())
}
fn main() {
println!("{:?}", parse_pair("1, 2"));
println!("{:?}", parse_pair("1"));
println!("{:?}", first_char_len("中"));
println!("{:?}", first_char_len(""));
}输出:
textOk((1, 2)) Err("缺少逗号") Some(3) None
⚠️ 陷阱:
Option函数里的?不能作用于Result。要在Option函数里用Result,先.ok()?;反之在Result函数里用Option,先.ok_or(...)?或.ok_or_else(...)?。
main 的返回类型
rust
// 1) 常规:不返回任何东西,错误自己处理
fn main() {}
// 2) 返回 Result:runtime 会在 Err 时打印 Debug 并设置退出码 1
fn main() -> Result<(), Box<dyn std::error::Error>> {
let n: i32 = "42".parse()?; // 具体错误自动装箱成 Box<dyn Error>
println!("{n}");
Ok(())
}
// 3) Terminaton trait:还可以用 ExitCode 精确控制退出码
use std::process::ExitCode;
fn main() -> ExitCode {
match run() {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("错误:{e}");
ExitCode::from(2)
}
}
}
fn run() -> Result<(), String> { Ok(()) }fn main() -> Result<(), E> 要求 E: Debug(2024 起还要求 E: Termination 的等价约束,标准做法就是 Box<dyn Error> 或 anyhow::Error)。失败时的输出形如:
text
Error: ParseIntError { kind: InvalidDigit }💡 对照:这相当于 Python 的「让异常冒到顶层,解释器打印 traceback 并以非 0 退出」,但 Rust 打印的是
Debug格式,没有栈回溯(除非你挂了 panic hook)。
Box<dyn Error> vs anyhow:怎么选
| 方案 | 写法 | 优点 | 缺点 |
|---|---|---|---|
Box<dyn Error> | fn main() -> Result<(), Box<dyn Error>> | 零依赖,标准库即可 | 不能带自定义上下文;Box<dyn Error> 不是 Send,跨线程麻烦;错误链只能靠 source() 手工遍历 |
Box<dyn Error + Send + Sync> | 同上加两个 auto trait | 可跨线程、可放 Arc | 写起来长;仍然没有上下文 API |
anyhow::Error | fn main() -> anyhow::Result<()> | .context() / .with_context() / bail! / ensure!;打印时自动展示整条链 | 引入依赖;anyhow::Error 不能用作库的公开错误类型(调用方无法 match) |
工程结论:
- 一次性脚本 / 二进制 crate 的
main→anyhow。 - 库(lib)的公开 API → 自定义
enum(通常配thiserror)。 - 不想加依赖的小工具 →
Box<dyn Error>,够用。
? 的自动 From::from 转换:完整示例
这是 ? 最省事的地方:只要给目标错误类型实现了 From<源错误>,? 就会自动转换。手写一个完整例子:
rust
use std::error::Error;
use std::fmt;
use std::num::ParseIntError;
use std::path::PathBuf;
#[derive(Debug)]
enum AppError {
Io { path: PathBuf, source: std::io::Error },
Parse { text: String, source: ParseIntError },
}
impl fmt::Display for AppError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
AppError::Io { path, .. } => write!(f, "读写 `{}` 失败", path.display()),
AppError::Parse { text, .. } => write!(f, "`{text}` 不是整数"),
}
}
}
impl Error for AppError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
match self {
AppError::Io { source, .. } => Some(source),
AppError::Parse { source, .. } => Some(source),
}
}
}
// 关键:为每种底层错误实现 From,? 才能自动转换
impl From<std::io::Error> for AppError {
fn from(source: std::io::Error) -> Self {
AppError::Io { path: PathBuf::from("<unknown>"), source }
}
}
impl From<ParseIntError> for AppError {
fn from(source: ParseIntError) -> Self {
AppError::Parse { text: String::new(), source }
}
}
fn read_number(path: &str) -> Result<i32, AppError> {
let text = std::fs::read_to_string(path)?; // io::Error -> AppError 自动转换
let n: i32 = text.trim().parse()?; // ParseIntError -> AppError 自动转换
Ok(n)
}🧠 原理:
?展开成return Err(From::from(e))。所以「?报错说From未实现」不是 bug,而是编译器在提醒你:这个错误类型还没被纳入你的错误体系。想丢掉原始信息也可以写impl From<E> for AppError { fn from(_: E) -> Self { AppError::Other } },但那就丢失了source()链。⚠️ 注意:
From是唯一能被?自动调用的转换。TryFrom不会。所以像u16 -> u8这种可能失败/截断的转换,?帮不上忙,得手写u8::try_from(x)?。
? 与 unwrap 的选择标准
| 场景 | 用什么 | 理由 |
|---|---|---|
函数签名允许返回错误(Result/Option) | ? | 免费传播,保留类型与链 |
| 有上下文要补充 | .map_err(...)? 或 .context(...)? | 让最终用户看得懂 |
| 测试代码 | unwrap() / expect() | 测试里 panic 就是失败,最简洁 |
| 原型 / 一次性脚本 | unwrap() | 先跑通再重构 |
已证明不可能失败(Mutex::lock、常量字面量解析) | expect("不变量的理由") | 用消息记录「为什么安全」 |
main(返回值固定) | 改成 -> Result<(), E> + ? | 除非确实要 panic,否则别在 main 里 unwrap |
| 库代码 | 禁止 unwrap()(#![deny(clippy::unwrap_used)]) | 库无权决定进程是否崩溃 |
组合子链 vs match:同一逻辑的三种写法
任务:读文件 → 去掉首尾空白 → 解析成整数 → 乘以 2。三种写法并排比较。
写法 A:match 嵌套(最啰嗦,但每一步都显式)
rust
use std::fs;
fn double_number(path: &str) -> Result<i32, String> {
match fs::read_to_string(path) {
Ok(text) => match text.trim().parse::<i32>() {
Ok(n) => Ok(n * 2),
Err(e) => Err(format!("内容不是整数: {e}")),
},
Err(e) => Err(format!("读取 {path} 失败: {e}")),
}
}
fn main() {
println!("{:?}", double_number("definitely-missing.txt"));
}输出:
textErr("读取 definitely-missing.txt 失败: 系统找不到指定的文件。 (os error 2)")
写法 B:? + 组合子(错误类型统一时最紧凑)
rust
use std::fs;
use std::num::ParseIntError;
#[derive(Debug)]
enum AppError {
Io(std::io::Error),
Parse(ParseIntError),
}
impl From<std::io::Error> for AppError {
fn from(e: std::io::Error) -> Self { AppError::Io(e) }
}
impl From<ParseIntError> for AppError {
fn from(e: ParseIntError) -> Self { AppError::Parse(e) }
}
fn double_number(path: &str) -> Result<i32, AppError> {
let text = fs::read_to_string(path)?; // 提前返回,错误类型由 ? 转换
let n = text.trim().parse::<i32>().map_err(AppError::Parse)?; // 也可以手动 map_err
Ok(n * 2)
}
fn main() {
println!("{:?}", double_number("definitely-missing.txt"));
}输出:
textErr(Io(Os { code: 2, kind: NotFound, message: "系统找不到指定的文件。" }))
写法 C:anyhow::Context(上下文最清晰)
toml
# Cargo.toml
[dependencies]
anyhow = "1"rust
use anyhow::{Context, Result};
fn double_number(path: &str) -> Result<i32> {
let text = std::fs::read_to_string(path)
.with_context(|| format!("读取文件 `{path}`"))?;
let n: i32 = text
.trim()
.parse()
.with_context(|| format!("`{path}` 的内容不是整数"))?;
Ok(n * 2)
}
fn main() -> Result<()> {
// 用 {:#} 打印「一层」上下文,用 {:?} 打印完整链
if let Err(e) = double_number("definitely-missing.txt") {
println!("简洁形式: {e:#}");
println!("完整链:\n{e:?}");
}
Ok(())
}输出(形如):
text简洁形式: 读取文件 `definitely-missing.txt` 完整链: 读取文件 `definitely-missing.txt` Caused by: 系统找不到指定的文件。 (os error 2)
怎么选
| 写法 | 可读性 | 适用 |
|---|---|---|
A. match 嵌套 | 差(三层就难以阅读) | 需要针对不同错误做不同处理(不是传播,而是分支逻辑) |
B. ? + 组合子 | 好 | 错误类型统一、逻辑线性、面向库 API |
C. anyhow::Context | 最好(对人) | 面向最终用户/开发者的诊断信息;二进制 crate |
什么时候「提前返回」比组合子更清晰?
需要多次使用中间值时:
rust// 组合子写法会变得别扭:n 要在两个闭包里用 let r = "5".parse::<i32>() .map(|n| n * 2) .and_then(|n| if n > 100 { Err("太大") } else { Ok(n) }); // 提前返回写法一目了然 fn calc(s: &str) -> Result<i32, String> { let n: i32 = s.parse().map_err(|e| format!("{e}"))?; let doubled = n * 2; if doubled > 100 { return Err(format!("{doubled} 太大")); } Ok(doubled) }需要在中间步骤打日志、加锁、改状态时。
链超过 3~4 个组合子时。超过就该拆成命名的中间变量 +
?。失败路径是不同的业务分支(要
match具体变体)时——组合子只能「传播」,不能「分支」。
延伸阅读
- 同一概念的第二种讲法(官方书中文版、Rust 圣经的逐章映射),见 附录 E · 对照阅读与组合学习法。
- 官方文档、中文资料、书单与工具的完整索引,见 附录 D · 学习资源与文档索引。