Skip to content

panic 与 Result

本章定位:把「出错」这件事从语言的隐藏机制变成类型系统里的普通值——从 panic!Result<T, E> 一路讲到自定义错误类型。前置知识: 结构体、枚举与 traitenummatchResult 的基础)、 集合与迭代器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。可恢复错误就是一个普通返回值,和 i32String 没有本质区别。

为什么 Rust 不做异常

💡 对照:Python 里 open("x") 失败会抛 FileNotFoundError;Java 里 new FileInputStream("x")FileNotFoundException。调用方从签名看不出来(Python 完全看不出,Java 只有 checked exception 才在签名里)。Go 则把 error 当返回值:f, err := os.Open("x")

Rust 选择 Go 那一侧的思路,但用类型系统做得更彻底。四个理由:

  1. 控制流显式化(control flow is explicit) 读代码时,只要看到 ?matchunwrap 就知道这里可能提前返回。异常的问题是:任何一行函数调用都可能「凭空」跳到几百行外的 catch,本地推理失效。

  2. 没有隐藏跳转,借用检查器才能工作 如果函数可以在任意点抛出异常跳走,那么「引用在什么时候还有效」就无法静态判定。? 提前返回是一个普通的 return,借用检查器(borrow checker)可以正常分析资源释放顺序。

  3. #[must_use] 让「忘记处理」变成编译期警告ResultOption 都标注了 #[must_use]

    rust
    fn main() {
        let mut v: Vec<i32> = Vec::new();
        v.first(); // 忘记处理返回值
    }

    输出(cargo build 的警告):

    text
    warning: 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 _ = ...; 显式表态。

  4. 错误类型参与类型推导Result<T, E>E 是具体类型,编译器知道你可能产生哪些错误;? 还能自动做 From 转换。这比 Python 的「任何地方都可能抛任何东西」精确得多,也比 Go 的「必须手写 if err != nil」简洁。

三方对照表

维度RustJavaGoPython
错误表示Result<T, E> 返回值Throwable 子类对象error 接口值BaseException 子类对象
抛出方式return Err(e) / ?throw new XxxException()return nil, errraise XxxError()
捕获方式match / if let / ? 向上传播try { } catch (X) { }if err != nil { }try: / except X:
是否在签名中可见(返回类型 Result<T, E>只有 checked exception 需要 throws是(多返回值)
忘记处理会怎样编译警告 unused_must_usechecked 是编译错误;unchecked 静默静默(编译器不管)静默(运行期才炸)
错误链Error::source()Throwable.getCause()errors.Is/As + %w 包装raise ... from e / __cause__
提前返回语法?无(必须 throwif err != nil { return err }无(必须 raise
不可恢复错误panic!Error(如 OutOfMemoryErrorpanic()SystemExit / 进程崩溃
资源清理RAII,Drop 自动执行finally / try-with-resourcesdeferfinally / 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、无法承受展开的场景

🧠 原理abortDrop 不执行,所以如果持有的是「必须手动释放」的外部资源(如 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"));
}

输出:

text
all 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 clippytodo 相关 lint,别让它留在提交里。

panic 的载荷与 backtrace

panic! 的参数会变成「载荷(payload)」,类型是 Box<dyn Any + Send>

  • panic!("文本") → 载荷是 &'static str
  • panic!("带 {}", 值) → 载荷是 String(因为做了格式化)
  • panic_any(x) → 载荷是任意 Send + 'static

想看调用栈,运行时设置环境变量:

powershell
# PowerShell(Windows)
$env:RUST_BACKTRACE = "1"        # 简短回溯
$env:RUST_BACKTRACE = "full"     # 完整回溯(含标准库帧)
cargo run
bash
# bash(Linux/macOS)
RUST_BACKTRACE=1 cargo run

catch_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 报告,这是正常的):

text
catch_unwind -> is_err = true
payload = Some("n 不能为负数:-1")

限制(很重要)

  1. 闭包必须满足 UnwindSafe。闭包捕获了 &mut 之类「可能被观察到中间状态」的东西时要显式包一层 AssertUnwindSafe——这是你在向编译器承诺「我看过代码,状态被破坏也没关系」

    rust
    use std::panic::{self, AssertUnwindSafe};
    
    let mut counter = 0;
    let res = panic::catch_unwind(AssertUnwindSafe(|| {
        counter += 1; // 如果在这里 panic,counter 可能处于半更新状态
        counter
    }));
  2. panic = "abort"完全失效

  3. 它抓不到真正致命的情况(如 SIGSEGV、栈溢出之后的二次 panic、std::process::abort)。

  4. 不要用它代替 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}");
    }
}

输出:

text
10 / 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(ErrDebugok.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 -> TT: 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.76Ok 值做副作用(打日志),不改值ok.inspect(|n| println!("{n}"))Ok(3)
inspect_err(f)F: FnOnce(&E)稳定于 1.76Err 值做副作用(埋点/日志),不改值err.inspect_err(|e| eprintln!("{e}"))Err(..)

⚠️ 陷阱(本题最常被想当然的一点)Result 没有 take() 方法!Option::take() 存在,Result::take() 在标准库里不存在(写 r.take() 会得到 E0599,编译器还会误以为你在说 Iterator::take,提示你 .into_iter().take())。需要 Result 的等价能力时,用 Optionmem::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_insertResult 独有 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() 会得到 E0599 no method named 'take' found for enum Result,而且编译器的建议会误导你——它以为你在找 Iterator::take,提示你写 .into_iter().take(),那完全是另一回事。

更彻底的一点:Result 甚至没有实现 DefaultOption::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:?}");
}

输出(编译错误):

text
error[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,
}

两点必须记住:

  1. 它是 return,不是 throw 控制流只在当前函数内提前返回,跳到调用方那一行继续执行——没有栈展开,没有 catchDrop 也照常按作用域顺序执行。
  2. 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)」必须和返回类型的残差同族——OptionResult 是两族,不能互穿。

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(""));
}

输出:

text
Ok((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::Errorfn main() -> anyhow::Result<()>.context() / .with_context() / bail! / ensure!;打印时自动展示整条链引入依赖;anyhow::Error 不能用作库的公开错误类型(调用方无法 match

工程结论:

  • 一次性脚本 / 二进制 crate 的 mainanyhow
  • 库(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"));
}

输出:

text
Err("读取 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"));
}

输出:

text
Err(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 具体变体)时——组合子只能「传播」,不能「分支」。



延伸阅读


内容以 rustc 1.98.1 · Rust 2024 edition 为基准