阶段六 · 错误处理与工程组织

错误处理

一句话总结

Rust 把错误分两类:可恢复错误Result,不可恢复错误panic!.
? 运算符让错误传播像 Go 的 if err != nil 一样直接却更简洁,还能借 From 自动转换错误类型.
生态里 thiserror / anyhow 进一步减负.

两类错误,两种机制

Go 只有一套错误观(error 接口 + panic).Rust 在类型层面分得更清楚:

错误性质 机制 典型场景 Go 类比
可恢复(预期内) Result<T, E> 文件不存在,解析失败,网络超时 返回 error
不可恢复(bug) panic! 数组越界,断言失败,不变量被破坏 panic

把可恢复错误想成"顾客点的菜没了"--正常业务,换一道即可.
不可恢复错误是"后厨着火了"--程序无法继续,只能紧急停业(panic).
Rust 用不同的类型强制区分对待,而 Go 全凭约定.

Result:可恢复错误的载体

ResultOption 一样是枚举(第 06 篇),只是把"无值"换成了"出错原因":

enum Result<T, E> {
Ok(T), // 成功,携带结果 T
Err(E), // 失败,携带错误 E
}

凡是可能失败的操作,返回类型就是 Result.调用方用 match 处理:

use std::fs::File;

match File::open("config.toml") {
Ok(file) => println!("打开成功:{:?}", file),
Err(e) => println!("打开失败:{e}"),
}

Go 的 file, err := os.Open(...) 把结果和错误拆成两个返回值;Rust 把它们合并进一个 Result.
区别在于:Go 可以无视 err 直接用 file(埋下隐患),Rust 必须先从 Result 里"取出" Ok 才能拿到 file,绕不过去.

? 运算符:优雅的错误传播

真实代码里,错误常需逐层上抛.Go 的写法是反复的 if err != nil { return err }:

func readUsername() (string, error) {
f, err := os.Open("user.txt")
if err != nil {
return "", err
}
data, err := io.ReadAll(f)
if err != nil {
return "", err
}
return string(data), nil
}

Rust 的 ? 把这套样板压成一个符号:Ok(v) 就取出 v 继续;是 Err(e) 就立即 return 这个错误:

use std::fs::File;
use std::io::Read;

fn read_username() -> Result<String, std::io::Error> {
let mut f = File::open("user.txt")?; // 失败则直接 return Err
let mut s = String::new();
f.read_to_string(&mut s)?; // 同上
Ok(s)
}

两个 ? 取代四行 if err != nil,逻辑主线清晰浮现,错误处理退到背景.

? 的使用前提

? 只能用在返回 ResultOption 的函数里(因为它要 return 错误).

main 中使用时,把 main 返回类型写成 Result<(), Box<dyn std::error::Error>>.这点和 Go "任何函数都能 return err" 略有不同--Rust 要求函数签名先"声明自己会失败".

? 与 From:自动转换错误类型

? 有个强大的隐藏能力,直接用上了第 13 篇的 From trait:当函数返回的错误类型与 ? 处产生的错误类型不同时,它会自动调用 From::from 进行转换.这让能把多种底层错误统一成一个上层错误类型:

enum AppError {
Io(std::io::Error),
Parse(std::num::ParseIntError),
}

impl From<std::io::Error> for AppError {
fn from(e: std::io::Error) -> Self { AppError::Io(e) }
}
impl From<std::num::ParseIntError> for AppError {
fn from(e: std::num::ParseIntError) -> Self { AppError::Parse(e) }
}

// 现在一个函数里,两种不同的底层错误都能用 ? 自动转成 AppError
fn read_number() -> Result<i32, AppError> {
let s = std::fs::read_to_string("num.txt")?; // io::Error → AppError
let n = s.trim().parse::<i32>()?; // ParseIntError → AppError
Ok(n)
}

这比 Go 的 fmt.Errorf("...: %w", err) 更自动--定义好 From,? 就替完成转换.

自定义错误类型与生态工具

上面手写 From 还是略繁琐.社区有两个事实标准大幅减负:

工具 定位 适用
thiserror 用派生宏自动生成自定义错误类型(含 Display,From,Error 实现) :需要明确的错误类型供调用方匹配
anyhow 提供一个万能错误类型 anyhow::Error,任意错误都能塞进去 应用:只想方便地传播错误,不在乎精确类型
// thiserror:上面手写的 From + Display,一个派生宏搞定
use thiserror::Error;

#[derive(Error, Debug)]
enum AppError {
#[error("IO 错误")]
Io(#[from] std::io::Error), // #[from] 自动生成 From 实现
#[error("解析失败")]
Parse(#[from] std::num::ParseIntError),
}

最佳实践:库用 thiserror,应用用 anyhow

经验法则:写时用 thiserror 定义清晰的错误枚举,让调用者能精确匹配处理;写应用(二进制程序)时用 anyhow 图省事,直接 ? 传播各种错误到 main.

这对应 Go 里标准 errorspkg/errors 的取舍.入门阶段先理解标准库的 Result/?,需要时再引入这两个 crate.

unwrap 与 expect:便利但危险

两个方法能"强行"从 Result/Option 取值:成功返回内部值,失败就 panic:

let n: i32 = "42".parse().unwrap();          // 成功返回 42
let m: i32 = "abc".parse().unwrap(); // panic!解析失败
let p: i32 = "abc".parse()
.expect("端口号必须是数字"); // panic 并附带自定义信息

生产代码慎用 unwrap

unwrap 把可恢复错误升级成了崩溃.它适合原型,示例,测试,或能逻辑确证不会失败的场景.

生产业务路径上,应当用 ? 传播或 match 妥善处理.看到满屏 unwrap 的代码,基本可判定未认真处理错误.需要表达"这里不可能失败"时,优先用 expect("原因") 写明理由,方便日后排查.

panic:何时该用

panic! 用于程序无法合理继续的情形--通常意味着代码有 bug 或核心不变量被破坏:

fn divide(a: i32, b: i32) -> i32 {
if b == 0 {
panic!("除数不能为 0,这是调用方的 bug");
}
a / b
}

判断准则:错误是"预期会发生,调用方应当处理"的(如用户输入非法)→ 用 Result;错误是"不该发生,发生即 bug"的 → 用 panic!.绝大多数业务错误属于前者.

快速回顾

  • 两类错误:可恢复用 Result<T, E>,不可恢复(bug)用 panic!.
  • Result:Ok/Err 枚举,把结果与错误合并进一个值,必须显式取出才能用.
  • ? 运算符:成功取值,失败即 return,取代成片 if err != nil;靠 From 自动转换错误类型.
  • 自定义错误:用枚举收拢失败原因;thiserror(库)/ anyhow(应用)大幅减负.
  • unwrap / expect:失败即 panic,仅用于原型或确证不失败处;表达"不可能失败"优先 expect 写明理由.
  • 选择准则:预期内,应被处理 → Result;不该发生,属 bug → panic.

动手练习

  1. 自定义 Result:写 fn parse_age(s: &str) -> Result<u32, String>,解析失败时返回描述性错误.
  2. ? 串联:写一个连续做两步可能失败操作的函数,用 ? 串联,让 main 返回 Result<(), Box<dyn std::error::Error>>.
  3. From 转换:定义 enum AppError 并手写两个 From 实现,让两种底层错误都能被 ? 自动转换.
  4. thiserror 简化:引入 thiserror,用 #[derive(Error)] + #[from] 重写上一题,对比代码量.
  5. unwrap vs expect:对一个会失败的 parse 分别调用 unwrapexpect("..."),对比 panic 信息的可读性.