前置知识: Rust

Rust 错误处理

8 min入门

panic 与 Result、? 运算符、unwrap/expect、自定义错误与错误转换

前置知识

学习目标

读完本文你将能够:

  1. 分清 panic(不可恢复)与 Result(可恢复)各自的适用场景;
  2. 会用 ? 传播错误,理解它背后的 From 转换机制;
  3. 会用 thiserror 为库定义精确错误枚举,用 anyhow 为应用快速兜底;
  4. 会写返回 Result 的 main 函数,让 CLI 工具以正确的退出码收场;
  5. 按场景对照表选择正确的错误处理写法,摆脱无脑 unwrap。

预计 60 分钟。

1. 从”外卖送餐”说起:错误处理的两条路径

想象点外卖:餐厅没营业(不可恢复——这家店永远送不了)和 今天路上堵车(可恢复——换个时间或路线还能送到)是两种完全不同的情况,处理方式也该不同。

Rust 把错误分成两类,处理方式截然不同:

错误类型场景处理方式
不可恢复错误程序无法继续(如数组越界、断言失败)panic:展开并退出(可配置为直接终止)
可恢复错误文件不存在、网络超时、解析失败Result<T, E>:返回错误值,由调用方决定

设计哲学:用类型表达失败——函数签名里的 Result 就是文档,调用方被迫处理错误,不存在”悄悄失败”。

2. panic:不可恢复错误

rust
fn main() {
    let v = vec![1, 2, 3];
    println!("{}", v[99]); // 越界访问 -> panic
}

讲解:越界、整数溢出(debug 模式)、显式 panic!("msg") 都会触发。panic 会打印错误信息、源码位置与回溯栈,然后回滚当前线程栈并释放资源(unwind)。

rust
fn main() {
    panic!("出错了");          // 显式触发
    assert!(1 > 2, "断言失败"); // 断言失败也会 panic
}

讲解:assert! 系列宏在测试与内部不变量检查中常用。panic! 应在”程序无法继续”时使用,普通业务失败不要用 panic。

3. Result:可恢复错误

标准库的 Result 枚举(带两个泛型参数):

rust
enum Result<T, E> {
    Ok(T),   // 成功,携带结果值
    Err(E),  // 失败,携带错误值
}
rust
use std::fs::File;
use std::io::Read;

fn read_whole(path: &str) -> Result<String, std::io::Error> {
    let mut f = File::open(path)?;   // ? 失败则提前返回 Err
    let mut content = String::new();
    f.read_to_string(&mut content)?; // ? 自动转换错误类型(此处同为 io::Error)
    Ok(content)
}

讲解:? 是错误处理的语法糖:Ok(v) 则解出 v 继续,Err(e) 则立即把 e 返回给调用方。函数返回类型必须为 Result(或 Option)才能用 ?。

3.1 处理 Result 的常见方式

rust
use std::fs;

// 方式一:match 显式处理
match fs::read_to_string("a.txt") {
    Ok(text) => println!("内容:{text}"),
    Err(e) => eprintln!("读取失败:{e}"),
}

// 方式二:unwrap——成功取 Ok,失败 panic(仅原型/测试使用)
let text = fs::read_to_string("a.txt").unwrap();

// 方式三:expect——panic 时附带自定义信息
let text = fs::read_to_string("a.txt")
    .expect("a.txt 必须存在");

// 方式四:unwrap_or——失败用默认值兜底
let text = fs::read_to_string("a.txt").unwrap_or_default();

讲解:unwrap/expect 遇到 Err 会 panic,仅适合”这个错误不可能发生”或原型阶段;生产代码用 ? 或 match 显式处理。eprintln! 输出到标准错误流。

3.2 Result 与 Option 互转

rust
// ok_or:Option -> Result
let maybe: Option<i32> = Some(42);
let result = maybe.ok_or("值不存在")?;   // 得 Result<i32, &str>

// 常用组合:链式 + ok_or
let port: u16 = "8080"
    .parse::<u16>()
    .ok()
    .ok_or("端口格式错误")?;

讲解:ok_or 让 Option 也能用 ? 传播错误;ok() 把 Result 降级为 Option。二者配合可统一”可空 + 可失败”的处理链。

4. 自定义错误类型

业务系统应定义自己的错误类型,而不是到处用字符串。推荐用 thiserror 派生宏简化样板:

toml
[dependencies]
thiserror = "2"
rust
use thiserror::Error;

#[derive(Debug, Error)]
enum AppError {
    #[error("配置缺失:{key}")]
    MissingConfig { key: String },

    #[error("网络错误:{0}")]
    Network(#[from] std::io::Error),

    #[error("解析失败:{0}")]
    Parse(String),
}

fn load_config() -> Result<String, AppError> {
    let raw = std::fs::read_to_string("config.toml")?; // io::Error 自动转 Network
    if raw.is_empty() {
        return Err(AppError::MissingConfig { key: "config.toml".into() });
    }
    Ok(raw)
}

讲解:#[derive(Error)] 自动实现 Display(按 #[error(...)] 模板)与 std::error::Error;#[from] 生成 From 转换,使 ? 能自动把 io::Error 转成 AppError::Network。

纯标准库实现(不引依赖)的原理:

rust
#[derive(Debug)]
struct MyError(String);

impl std::fmt::Display for MyError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "自定义错误:{}", self.0)
    }
}
impl std::error::Error for MyError {}

讲解:std::error::Error trait 要求类型同时实现 Debug 与 Display;手写这段代码就是 thiserror 替你生成的内容。

5. 错误转换:让 ? 自动工作

? 依赖 From trait 把底层错误转换为函数声明的错误类型:

rust
use std::fs;
use std::net::IpAddr;

fn load_and_parse() -> Result<IpAddr, Box<dyn std::error::Error>> {
    let raw = fs::read_to_string("ip.txt")?;  // io::Error -> Box<dyn Error>
    let ip: IpAddr = raw.trim().parse()?;     // ParseIntError -> Box<dyn Error>
    Ok(ip)
}

讲解:Box<dyn Error> 是”万能错误容器”,任何实现了 Error 的类型都能 ? 进去——适合快速原型。大型项目建议用 thiserror 定义精确错误类型,便于调用方按类型分支处理。

6. anyhow:应用程序的错误兜底

thiserror 适合库:定义精确的错误枚举,让调用方按类型分支处理。而应用程序(CLI、服务二进制)通常不关心错误的具体分类,只想拿到人类可读的信息继续向上传播。社区惯例是:库用 thiserror,应用用 anyhow。

toml
[dependencies]
anyhow = "1"
rust
use anyhow::{Context, Result, bail};

fn load_config() -> Result<String> {   // Result = Result<T, anyhow::Error>
    let raw = std::fs::read_to_string("config.toml")
        .context("读取配置文件失败")?;  // context 叠加"当时在做什么"
    if raw.is_empty() {
        bail!("配置文件为空");           // bail! 直接返回 Err(anyhow!(...))
    }
    Ok(raw)
}

fn main() -> Result<()> {
    let cfg = load_config()?;
    println!("配置加载成功:{} 字节", cfg.len());
    Ok(())
}

讲解:anyhow::Error 是”万能错误容器”——任何实现了 std::error::Error 的类型都能被 ? 自动转换进去,一条调用链上混用 io::Error、解析错误也不用写转换代码;context() 在错误上叠加描述,最终日志会形成”读取配置文件失败: No such file or directory (os error 2)“这样层层递进的可读信息。相比第 5 节手写的 Box<dyn Error>,anyhow 额外提供了上下文叠加、错误链遍历与 backtrace 支持,是应用侧的工业级选择。

场景推荐方案
库(crates.io 发布、被他人依赖)thiserror:精确枚举,调用方可编程处理
应用主逻辑(CLI、服务)anyhow:快速传播 + context 上下文
同一项目的不同分层业务库用 thiserror,main/装配层用 anyhow 汇总

7. main 函数返回 Result

main 返回 Result 时,Err 会以非零退出码结束程序并打印 Debug 信息:

rust
use std::fs;

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let content = fs::read_to_string("a.txt")?;   // 失败直接退出
    println!("{content}");
    Ok(())
}

讲解:这是小型 CLI 工具的标准写法:错误沿 ? 一路向上,main 兜底退出。也常用 Result<(), AppError>。

8. 常见错误处理模式对比

场景推荐写法
原型/教学代码unwrap / expect
普通业务函数? + 自定义错误类型
命令行工具main 返回 Result
配置缺失等”不可能”情况expect 带清晰信息
错误后继续处理其他任务match 分分支处理

9. 综合示例:小型文件统计工具

rust
use std::fs;
use std::io;

fn count_lines(path: &str) -> Result<usize, io::Error> {
    let content = fs::read_to_string(path)?;
    Ok(content.lines().count())
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    for path in ["a.txt", "b.txt"] {
        match count_lines(path) {
            Ok(n) => println!("{path}: {n} 行"),
            Err(e) => eprintln!("{path}: 读取失败 {e}"),
        }
    }
    Ok(())
}

讲解:核心业务函数返回 Result<_, io::Error> 保持精确;main 用 Box<dyn Error> 兜底;外层循环逐文件容错——错误”能精确时精确、能继续时继续”。

10. 动手实践:给错误处理装上肌肉记忆

错误处理的语法一天就能看完,分寸感要靠练。以下三个任务合计约 60 分钟,围绕第 9 节的文件统计工具迭代三次,每次换一种工程姿势。

任务一:亲手走一遍 unwrap 的事故现场(约 15 分钟)

先把第 9 节的 count_lines 改成 fs::read_to_string(path).unwrap(),删掉工作目录下的 a.txt 再运行,观察 panic 输出:读一遍「线程恐慌」信息里的位置与原因,然后在 PowerShell 里执行 $LASTEXITCODE 看退出码是不是 101。最后恢复为 Result 版本。

提示:这道题的产物不是代码而是记录——panic 的输出格式、退出码数字、以及「panic 发生在库内部还是你的调用行」这个信息在栈回溯里的位置。参考检查点:能说出为什么这个版本比第 9 节版本差——不只是「不优雅」,而是任何一个文件缺失都会杀死整批统计任务,容错能力归零。

任务二:用 thiserror 给工具装上精确错误类型(约 25 分钟)

给统计工具定义自己的错误枚举替代裸 io::Error:

rust
use std::fmt;

#[derive(Debug)]
enum ToolError {
    Read { path: String, source: std::io::Error },
    EmptyFile { path: String },
}

impl fmt::Display for ToolError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            ToolError::Read { path, source } => write!(f, "读取 {path} 失败:{source}"),
            ToolError::EmptyFile { path } => write!(f, "{path} 是空文件"),
        }
    }
}

impl std::error::Error for ToolError {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        match self {
            ToolError::Read { source, .. } => Some(source),
            _ => None,
        }
    }
}

要求:count_lines 签名改为返回 Result<usize, ToolError>,文件为空时用 ToolError::EmptyFile 返回错误(提示:构造结构体变体时用路径拼接),main 里 match 两种变体分别打印。先自己写完整段,再对照上面骨架检查。

参考检查点:能说出 source() 的作用——它把底层 io::Error 挂在错误链上,调用方既能按 ToolError 类型分支,又能顺着链条找到操作系统层面的原始原因;这正是第 4 节手写版与 thiserror 生成版共享的核心结构。

任务三:同一需求的 anyhow 版与取舍笔记(约 20 分钟)

再复制一份工具代码,错误类型全部换成 anyhow::Result,read_to_string 后接 .with_context(|| format!("读取 {path} 失败"))?,空文件用 bail!。跑通后对比两个版本,写一篇 200 字取舍笔记:哪个版本报错信息更可读、哪个版本的调用方能做编程化处理、如果你要把这个工具发布成库该选哪个。

参考检查点:笔记里至少出现这组结论——anyhow 的 context 让报错自带「当时在做什么」,读日志的人最舒服;thiserror 的枚举让调用方 match 错误变体做不同处理,写程序的人最舒服;对外的库必须用 thiserror(调用方拿不到枚举就没法精确处理),二进制应用的顶层用 anyhow 最省事。这与第 6 节的表互相印证,但这次是你自己跑出来的结论。

11. 小结

错误处理三板斧:Result 表达失败、? 简化传播、自定义错误类型提升可读性;panic 只留给不可恢复场景。牢记:panic 是异常,Result 是常态;写库用 thiserror 定义精确错误、写应用用 anyhow 快速兜底。下一步学习集合与迭代器,掌握 Rust 的日常数据处理武器库。

一句话记忆:Rust 错误处理三句话——“能恢复的用 Result(? 一路传播)、不能恢复的用 panic!、生产代码别用 unwrap”;用 thiserror 定义自己的错误类型,让 ? 自动做类型转换。