先给结论:Rust 里可预期的失败一律用 Result 返回,让调用方决定怎么办;问号运算符负责把错误往上层传;只有在确实无法继续运行时才让它 panic。把这条规则反过来用,也就是到处写 unwrap,程序会在最不方便的时候直接退出。

为什么必须专门讲:Rust 没有异常,也没有 null。所有"可能失败"的调用都会把失败编码进返回值类型里,编译器逼着你表态。这套设计一旦用顺了,线上最难查的一类问题会少一大半,但前提是理解三个零件:Result、问号运算符、以及把多种错误收拢到一个类型里的方式。
本文基于标准库与两个主流第三方库(thiserror、anyhow)的通用写法,示例需要在有 Cargo 的环境里编译。所有输出均为预期结果(未在本机执行),请按文中命令自己跑一遍。适用边界也写清楚:写库与写应用对错误类型的选择并不相同。
先看结论
- 可恢复的失败用 Result:打开文件失败、解析数字失败都属于这一类,应该返回给调用方处理。
- 问号运算符负责传播:在函数返回 Result 的前提下,
?会把错误直接返回,正常值继续往下走。 - 自定义错误用来收口:一个函数可能同时碰上多种错误,用枚举加 From 实现把它们合并成一个类型。
- unwrap 只在测试与示例里用:它在遇到错误时直接 panic,线上代码等于把异常处理权交给了运行时。
所有权与借用是这套机制的基础,需要系统过一遍可以看 Rust 教程,重点看枚举与模式匹配两章。
一、先把错误的两种形态分清楚
Rust 把失败分成两层:
- 不可恢复的错误:程序状态已经无法继续,比如访问越界、断言失败。这类情况触发 panic,线程直接展开栈并退出。
- 可恢复的错误:外部条件导致的操作失败,比如文件不存在、网络断开、输入不符合格式。这类情况由 Result 表达,交回给调用方决定是重试、降级还是提示用户。
Result 是一个枚举,只有两种取值:
enum Result<T, E> {
Ok(T),
Err(E),
}
这个定义的含义是:要么拿到类型为 T 的成功值,要么拿到类型为 E 的错误值,没有第三可能。由于它是返回值而不是控制流,编译器会强制你处理——不处理就拿不到里面的值,代码根本编不过。
一个完整的读文件函数:
use std::fs::File;
use std::io::{self, Read};
fn read_first_line(path: &str) -> Result<String, io::Error> {
let mut file = File::open(path)?;
let mut content = String::new();
file.read_to_string(&mut content)?;
let first = content.lines().next().unwrap_or("").to_string();
Ok(first)
}
这里两处 ? 的作用完全一致:成功就取出值继续,失败就把错误原样返回。函数签名里的 Result<String, io::Error> 决定了 ? 能不能用——函数本身必须返回 Result,否则编译器会直接报错。
二、unwrap 的代价与它的合理位置
unwrap 的行为很直白:拿到 Ok 就返回值,拿到 Err 就 panic。
let file = File::open("config.toml").unwrap();
这行代码在文件缺失时会让程序打印一段错误提示然后退出。它在两种场景下是完全合理的:
- 测试代码:测试里失败就该立刻暴露,写一堆匹配反而掩盖了意图;
- 示例代码与原型:为了把主体逻辑讲清楚,省略错误处理是可接受的。
它在另外两种场景下是明确的隐患:
- 库代码:库不该替调用方决定"这件事值得崩掉",返回 Result 让上层判断才是本分;
- 长时间运行的服务:一次解析失败就让整个服务退出,代价通常远超预期。
比 unwrap 好一点的是 expect,它可以带上一段说明文字,让 panic 时的提示有上下文。但两者的退出行为是一样的,仍然不适合生产路径。
真正需要"给个默认值"的时候,unwrap_or 是更合适的选择:
let port: u16 = text.parse().unwrap_or(8080);
这行的含义是解析失败就用 8080,程序继续。它不 panic,也不需要调用方处理错误,适合确实有合理兜底值的场景。

三、把多种错误收拢成一个类型
当一个函数同时可能遇到 IO 错误和解析错误时,返回类型该写什么?答案是定义一个自己的错误类型,并让标准库的错误能自动转换过来。
use std::fmt;
use std::io;
use std::num::ParseIntError;
#[derive(Debug)]
enum AppError {
Io(io::Error),
Parse(ParseIntError),
}
impl fmt::Display for AppError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
AppError::Io(e) => write!(f, "读写失败:{}", e),
AppError::Parse(e) => write!(f, "解析失败:{}", e),
}
}
}
impl std::error::Error for AppError {}
impl From<io::Error> for AppError {
fn from(e: io::Error) -> Self {
AppError::Io(e)
}
}
impl From<ParseIntError> for AppError {
fn from(e: ParseIntError) -> Self {
AppError::Parse(e)
}
}
这套样板代码的几个零件各有作用:Display 决定错误打印出来的样子,std::error::Error 让它能被当作标准错误使用,而两个 From 实现是关键——有了它们,? 就能在遇到 io::Error 或 ParseIntError 时自动转换成 AppError,函数签名只需要写一种错误类型:
# Cargo.toml
[dependencies]
thiserror = "1"
anyhow = "1"
手写这些实现很啰嗦,实际项目通常用两个库分工:
- thiserror:用派生宏帮你生成上面那段代码,适合写库——错误类型是公开 API 的一部分,调用方需要能匹配具体的错误变体;
- anyhow:把所有错误装进一个通用的错误盒子里,适合写应用——多数情况下只关心"失败了,原因是这个",不需要区分变体。
选择依据是错误要不要被下游匹配:要匹配就用具体类型,只要展示就用通用盒子。
Cargo 的常用子命令记不清时,翻 Linux 速查手册 里的命令速查可以核对一遍。

四、排查顺序与验证方式
把定位固化成一条流程(预期结果,未在本机执行):
- 配置:在 Cargo.toml 里按定位选一个库,写库用 thiserror,写应用用 anyhow,不要两个都引;
- 检查:全局搜索 unwrap 与 expect,逐个确认是否处于生产路径;
- 运行:
cargo run跑一次正常路径,再手动把输入改成非法值跑一次失败路径; - 预期:失败路径打印出可读的错误说明,程序没有直接退出;
- 失败:编译期报错提示
?用不了,检查函数返回值是不是 Result; - 失败:提示错误类型不匹配,补一个 From 实现或者换用通用错误盒子;
- 修复:把生产路径上的 unwrap 换成
?传播,或换成带默认值的兜底; - 验证:
cargo test跑一遍,为失败路径补一个断言让它被覆盖到。
性能上几乎不需要担心:Result 是普通的枚举,没有异常机制的栈展开开销,正常路径与错误路径都是静态分发。真正影响体积的是泛型实例化,与错误处理本身无关。
总结
Rust 的错误处理不是语法负担,而是把"这件事可能失败"写进类型里,让编译器替你做检查。
四条带走:
- 可恢复的失败用 Result,问号运算符负责往上传;
- unwrap 会 panic,只留在测试与示例里;
- 多来源错误用自定义类型收口,靠 From 让
?自动转换; - 写库用具体错误类型,写应用用通用错误盒子。
下一步建议给现有项目加一条约定:新增代码里生产路径不允许出现 unwrap,用检查脚本在提交前扫一遍。
常见问题
Q:为什么我的函数里用不了问号运算符?
A:因为函数返回值不是 Result。? 需要把错误返回给上层,只有返回 Result(或 Option)的函数才允许使用,要么改签名,要么改成显式匹配。
Q:unwrap 和 expect 有什么区别?
A:行为一样,都会在出错时 panic。expect 可以额外带一段说明文字,出错时提示更清楚,但不改变退出行为。
Q:什么时候该用 panic?
A:当继续运行会导致更严重后果时,比如内部不变量被破坏、配置在启动时就非法。输入数据不合规则属于可恢复错误,应该返回 Result。
Q:thiserror 和 anyhow 怎么选?
A:看调用方要不要匹配具体错误。要匹配(写库)用 thiserror 定义具体变体;只展示(写应用)用 anyhow 收进通用错误盒子。
Q:怎么给错误加上上下文信息?
A:在错误往上传播的位置包一层说明,或者在自定义错误类型里带上出错的位置信息。通用错误盒子通常提供直接附加上下文的方法。
延伸学习
想把错误处理这块做扎实,可以按这个顺序来:
- 想把后端这条线补宽,后端技能树 可以按需挑着看;
- 读一篇相关笔记 四种错误处理方法对比,看不同语言的处理思路;
- 想对着另一份资料核对写法,翻 Rust Lang 最快。

TRAE-AI编程



