第 7 课:错误处理 ------ panic、Result 与 ?
预计时间:75-90 分钟 | 难度:⭐⭐⭐ | 前置:第 6 课(struct/enum/Option/Result)、第 10 课会用到本课的自定义错误
7.1 这节课解决什么问题
Rust 没有异常,没有 try/catch/throw。取而代之的是一套更显式的哲学:
可以预见、可以恢复的错误(文件不存在、网络超时、JSON 解析失败)
→ 用返回值表达:Result<T, E>,调用者必须处理
不可预见、不可恢复的错误(越界、断言失败、除以零)
→ panic!:程序直接崩,或至少把那条线程崩掉
这种设计带来的好处:错误是"值" ,是函数签名的一部分(fn f() -> Result<T, E> 一读就知道会失败),编译器逼你在每个会失败的地方做决策。代价是要学会几种"错误值"的传递手法------本课的目标就是把这套手法练到肌肉记忆,它直接决定第 13-15、17-21 课写网络/存储/FFI 代码时是否顺手。
7.2 两类错误的清晰分界
panic!(不可恢复) |
Result<T,E>(可恢复) |
|
|---|---|---|
| 触发 | 越界、unwrap(None/Err)、断言失败、除零 | 任何"能预见可能失败"的操作 |
| 程序表现 | 默认 unwind 回溯并终止当前线程(--release 可改 abort) |
函数正常返回 Err(e),流程可控 |
| 谁决定 | 通常由代码自身缺陷导致 | 由外部条件/输入导致 |
| 应对 | 修复 bug;测试用 #[should_panic] |
调用方用 match/? 决定如何降级 |
判断口诀 :能提前知道"可能会失败"且业务上能降级 → Result;属于"程序 bug、不该发生" → panic 由标准库触发即可,别到处手动 panic。
7.2.1 panic 现场长什么样
rust
fn main() {
let v = vec![1, 2, 3];
let x = v[99]; // 越界 → panic
}
thread 'main' panicked at src/main.rs:4:14:
index out of bounds: the len is 3 but the index is 99
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
- 报错给出行列与原因;
- 设置
RUST_BACKTRACE=1重跑可拿到调用栈(做 FFI/实战调试时必备); - 依赖里某个 crate panic 会沿线程 unwind,跨 FFI 边界的 panic 是未定义行为 ------第 16 课讲
catch_unwind时必须回来用它。
7.3 Result 的三种消费姿势
先建一个"会失败"的样例函数(读文件,标准库 std::fs 今天只当黑盒用):
rust
use std::fs;
// 打开并读取一个文件 → Result<String, io::Error>
fn read_file(path: &str) -> Result<String, std::io::Error> {
fs::read_to_string(path)
}
fn main() {
// 姿势一:match 全处理(最啰嗦但最清晰)
match read_file("hello.txt") {
Ok(content) => println!("内容:{content}"),
Err(e) => println!("读取失败:{e}"),
}
// 姿势二:unwrap / expect ------ 练习/原型期"坚信成功"
let content = read_file("hello.txt").expect("hello.txt 必须存在");
println!("{content}");
}
unwrap():Err 时 panic;expect("自定义信息"):同样 panic,但先打你的说明------代码审查时 expect 的文案等于在解释"为什么这里不可能失败";- 生产代码里,
unwrap/expect只出现在"已用逻辑保证不可能失败"的地方(如先if exists再读且没有并发删文件的情况)。否则一律走姿势三。
7.3.1 姿势三:? 运算符 ------ 把错误"交给上层"
在返回 Result 的函数里,? 是"成功了取 Ok 里的值继续;失败了直接把 Err 原样返回":
rust
use std::fs;
fn read_config(path: &str) -> Result<String, std::io::Error> {
// fs::read_to_string(path)? 读到内容就用,Err 就 return Err 给调用者
let raw = fs::read_to_string(path)?;
Ok(format!("配置已加载({} 字节)", raw.len()))
}
fn main() -> Result<(), std::io::Error> {
// main 也可以返回 Result!Err 会被运行时打印并置退出码
let msg = read_config("config.toml")?;
println!("{msg}");
Ok(())
}
? 是语法糖,等价于:
rust
let raw = match fs::read_to_string(path) {
Ok(v) => v,
Err(e) => return Err(e.into()), // into():自动做错误类型转换(见 7.4)
};
💡
?只能在返回Result/Option/可转成 Result 的类型 的函数里用。main 里用?需要把 main 声明成-> Result<(), E>(见上)。这条在实战的每个 IO 函数里都会出现,务必练熟。
7.4 错误类型转换:为什么一层层 ? 能串起来
? 内部有 .into(),意味着只要上层错误类型能从下层错误类型 From 转换,就能一路向上抛。标准库为此做了大量现成实现:
rust
use std::fs;
// 读取数字并解析。可能错在:读文件(io::Error)或解析(ParseIntError)。
// 函数统一返回 io::Error 就能直接 ?(io::Error 实现了 From<ParseIntError>?并没有)
// ------所以这个例子用 std::io::Error 写不干净,见下方正确示范
正确示范------定义自己的错误枚举,让两类底层错误都能转进来:
rust
use std::fs;
#[derive(Debug)]
enum ConfigError {
Io(std::io::Error), // 包一层
BadNumber(std::num::ParseIntError),
}
impl From<std::io::Error> for ConfigError {
fn from(e: std::io::Error) -> Self { ConfigError::Io(e) }
}
impl From<std::num::ParseIntError> for ConfigError {
fn from(e: std::num::ParseIntError) -> Self { ConfigError::BadNumber(e) }
}
fn read_port() -> Result<u16, ConfigError> {
let raw = fs::read_to_string("port.conf")?; // io::Error → ConfigError::Io ✓
let n: u16 = raw.trim().parse()?; // ParseIntError → ConfigError::BadNumber ✓
Ok(n)
}
fn main() {
match read_port() {
Ok(p) => println!("端口:{p}"),
Err(e) => println!("读取配置失败:{e:?}"),
}
}
两条结论:
- 手写
From太啰嗦 → 7.6 的thiserror用一行 derive 替你写完; - 因为
?内置转换,错误类型设计决定调用链是否干净。实战规范:库(业务核心)用精确的领域错误(thiserror),应用入口(main/UI 桥)用宽泛的 anyhow 统一兜底。
7.5 Option 与 Result 互转、常用组合子
7.5.1 Option ↔ Result
rust
fn main() {
// Option → Result:给 None 一个理由
let o: Option<i32> = None;
let r: Result<i32, &str> = o.ok_or("值为空"); // Err("值为空")
println!("{r:?}");
// Result → Option:丢掉错误信息,只要成功值
let ok: Result<i32, &str> = Ok(7);
let o2 = ok.ok(); // Some(7)
let o3 = ok.err(); // None(Ok 时没有错误)
// transpose:把"Result 里的 Option"翻过来
let outer: Result<Option<i32>, &str> = Ok(Some(3));
let flipped: Option<Result<i32, &str>> = outer.transpose(); // Some(Ok(3))
println!("{flipped:?}");
}
7.5.2 组合子:在 Result 上链式变换而不 unwrap
rust
fn main() {
let r: Result<i32, &str> = Ok(10);
// map:成功就变换值,错误原样保留
let r2 = r.map(|n| n * 2); // Ok(20)
// and_then:连续执行"可能失败"的下一步
let r3 = r.and_then(|n| if n >= 5 { Ok(n - 5) } else { Err("太小") });
println!("{r3:?}"); // Ok(5)
// unwrap_or / unwrap_or_else:失败给默认值
let v = r.unwrap_or(0); // 10
let e: Result<i32, &str> = Err("oops");
let v2 = e.unwrap_or_else(|msg| { println!("fallback:{msg}"); -1 }); // -1
println!("{v} {v2}");
}
| 组合子 | 作用 | 什么时候用 |
|---|---|---|
.map(f) |
成功值变换 | 纯计算 |
.and_then(f) |
成功后执行下一个可能失败步骤 | 链式 IO/校验 |
.ok_or(e) / .ok() |
Option↔Result 互转 | 切换语义 |
.unwrap_or(d) / .unwrap_or_else(f) |
提供兜底值 | 有合理默认值 |
.transpose() |
翻转嵌套 | 少见但考试爱考 |
💡 多数场景,用
?比组合子更直白;组合子适合"中途不想 return、想把整条链路的结果当值处理"。别为了炫技硬套 map 链。
7.6 生产级错误类型:thiserror 与 anyhow
手写 From 又累又容易漏,实战用两个 crate(本课起引入依赖,第 10 课会系统讲 Cargo 依赖):
toml
[dependencies]
thiserror = "2"
anyhow = "1"
7.6.1 thiserror:给"库/核心"定义精确错误
rust
use thiserror::Error;
// 领域错误:一个枚举列出这个模块所有会失败的场景
#[derive(Debug, Error)]
pub enum AuthError {
#[error("用户名不存在:{0}")]
UserNotFound(String),
#[error("密码错误")]
WrongPassword,
#[error("token 已过期")]
TokenExpired,
#[error("底层网络错误:{0}")]
Network(#[from] std::io::Error), // #[from] = 自动 impl From
}
#[error("...")]生成 Display 文案,{0}指第 0 个字段;#[from]自动补Fromimpl → 下层错误?直接转进来;- 调用处因此能写干净的错误链(这是 7.4 手写版的一行版)。
rust
use std::fs;
fn load_credentials() -> Result<String, AuthError> {
// io::Error 自动转 AuthError::Network
let raw = fs::read_to_string("creds.txt")?;
if raw.trim().is_empty() {
return Err(AuthError::UserNotFound("creds.txt 无内容".into()));
}
Ok(raw)
}
7.6.2 anyhow:给"应用/入口"快速兜底
rust
use anyhow::{Context, Result};
fn run() -> Result<()> {
let raw = std::fs::read_to_string("config.toml")
.context("读取配置文件失败")?; // context 给错误加"上下文文案"
let port: u16 = raw.trim().parse().context("配置里 port 不是数字")?;
println!("端口 {port}");
Ok(())
}
fn main() {
if let Err(e) = run() {
// 打印时能串出完整因果链
eprintln!("启动失败: {e:#}");
std::process::exit(1);
}
}
7.6.3 两者的分工(写进实战的规矩)
库 / 业务核心(要被别处调用) → thiserror:错误类型精确、可 match、可序列化给 UI
二进制 / 入口(main、CLI、demo) → anyhow:不关心精确类型,只求带上下文地报错
向上(核心→UI 桥)转成 UI 可读信息 → match 精确错误 → 映射成字符串/枚举发给界面
💡 一句话:"库"用 thiserror 把错误说明白,"app"用 anyhow 把错误传出去。 实战篇 17-19 课会严格执行这条,UI 拿到的将是一份带错误码的领域错误。
7.7 错误处理最佳实践清单
- 会失败就返回
Result,不要吞错误 :宁可让调用者显式处理,也别let _ =静默丢弃(编译器会对Result加 must_use 警告)。 unwrap/expect只留给"已被证明不可能失败"的地方 ,其余用?或组合子。- 错误要带上下文 :只给
Err(io::Error)太裸,用.context("读取会话列表")或领域错误里带操作名。 - 别用错误做控制流 :可预见的"文件不存在"是业务分支(用
Result判断存在与否是合理的),不是 panic。 - panic 留在边界 :库代码尽量不主动 panic(用
assert!/expect前先想调用者能否接受);跨 FFI 的函数绝不能让 panic 溜出去(16 课catch_unwind)。 - UI 层收到错误要可展示:核心把错误码/文案规范化,App 再本地化。
7.8 📝 动手练习
参考实现放 code/07-error-handling/(写作时同步给出),本课需要一个带 Cargo.toml 依赖的工程。
- 读懂三种报错 :对不存在文件分别用
match、.expect()、?(在main -> Result<(), io::Error>里)读取,观察三种路径的输出与退出行为。 - ? 链练习 :写
fn read_port() -> Result<u16, Box<dyn std::error::Error>>(万能错误,快速验证期用):读port.conf→ trim → parse → 返回。体会"一层层 ?"(练习期可以偷懒用Box<dyn Error>,生产别用)。 - Option/Result 互转 :写
fn parse_port(s: &str) -> Result<u16, String>,内部用.parse().map_err(|e| format!("非法端口:{e}"));再用.ok_or写一个"取 Vec 首元素"为 Result 的版本。 - 组合子训练 :对
Result<u32, &str>串.map→.and_then→.unwrap_or完成"读用户分 → 加 10 → 若超过 100 视为满分"。 - thiserror 领域错误 :用 thiserror 定义
OrderError(字段:EmptyCart、InvalidAmount(u32)、Network(std::io::Error)+#[from]),写fn checkout(items: &[String], amount: u32) -> Result<String, OrderError>触发每种错误。 - anyhow 入口 :把 2 改成用 anyhow
.context(),main 里用{e:#}打印错误链。 - panic 观察 :运行越界 panic,加
RUST_BACKTRACE=1再看 backtrace 形状,截取前 10 行贴进笔记(16 课做 FFI 时还会用到 backtrace)。
验收门禁 :不查资料能说清------何时用 panic 何时用 Result;? 的展开语义(含 .into());main 如何返回 Result;thiserror 与 anyhow 各自定位;能默写 #[error("..{0}")] 与 .context() 的最小用法。
✅ 本节小结
- 两分法 :可恢复错误 →
Result;不可恢复 →panic!(默认 unwind,RUST_BACKTRACE=1看栈); - 消费 Result :match 全处理 / unwrap/expect(仅"不可能失败"处)/
?(向上抛,自动.into()); - 互转与组合 :
ok_or/ok/transpose、map/and_then/unwrap_or(_else); - 自定义错误 :库用 thiserror(
#[error]+#[from]),入口用 anyhow(.context());#[from]让?链一路畅通; - 最佳实践:不吞错、错误带上下文、panic 留在边界、UI 收到规范化错误。
下一课预告 :第 8 课《泛型、trait 与 trait 对象》------把"行为"抽成接口:trait 怎么定义怎么实现、泛型约束怎么写、dyn Trait 动态分发与 vtable 是什么、以及 #[derive(...)] 派生宏速查表。第 6-7 课所有"自动实现 From/Error/Debug"背后都是 trait 在起作用。