自定义错误类型
从字符串错误到 enum 错误,再到
Errortrait 与错误链。
自定义错误类型:三个层次
层次 a:简单 enum + Display + Error(含 source())
适用:错误种类少、字段少的小工具/库。
rust
use std::error::Error;
use std::fmt;
use std::num::ParseIntError;
#[derive(Debug)]
enum CliError {
Io(std::io::Error),
ParseInt(ParseIntError),
EmptyInput,
}
impl fmt::Display for CliError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
CliError::Io(e) => write!(f, "IO 错误:{e}"),
CliError::ParseInt(e) => write!(f, "不是整数:{e}"),
CliError::EmptyInput => f.write_str("输入为空"),
}
}
}
impl Error for CliError {
// 只给「有底层原因」的变体返回 source,其余返回 None
fn source(&self) -> Option<&(dyn Error + 'static)> {
match self {
CliError::Io(e) => Some(e),
CliError::ParseInt(e) => Some(e),
CliError::EmptyInput => None,
}
}
}
impl From<std::io::Error> for CliError {
fn from(e: std::io::Error) -> Self { CliError::Io(e) }
}
impl From<ParseIntError> for CliError {
fn from(e: ParseIntError) -> Self { CliError::ParseInt(e) }
}
fn main() -> Result<(), CliError> {
let n: i32 = "42".parse()?; // ParseIntError -> CliError
println!("{n}");
if n == 0 {
return Err(CliError::EmptyInput);
}
Ok(())
}要点:
Errortrait 只有source()(还有已废弃的description()、cause())有默认实现,所以impl Error for CliError {}的最小实现可以是空的花括号——但那样就丢了错误链。Display是Error的超 trait:impl Error之前必须先impl Display+Debug。忘了Display会报the trait bound CliError: Display is not satisfied。From是?的燃料,缺一个就会在用到的地方报 E0277。
层次 b:带上下文的 struct 错误
适用:同一类错误出现在很多地方,你想知道「是哪一次、哪个路径/哪一行」出的问题。这比多建 N 个 enum 变体更省事。
rust
use std::error::Error;
use std::fmt;
use std::fs::File;
use std::io::{BufRead, BufReader};
use std::num::ParseIntError;
use std::path::PathBuf;
#[derive(Debug)]
struct FileNumberError {
path: PathBuf,
line: usize, // 第几行(0 表示与具体行无关,例如打开文件失败)
kind: Kind,
}
#[derive(Debug)]
enum Kind {
Io(std::io::Error),
Parse(ParseIntError),
}
impl fmt::Display for FileNumberError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "处理 `{}` 第 {} 行失败", self.path.display(), self.line)
}
}
impl Error for FileNumberError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
match &self.kind {
Kind::Io(e) => Some(e),
Kind::Parse(e) => Some(e),
}
}
}
/// 逐行读取文件,把每行解析成 i64 并求和;空行和 `#` 开头的行跳过。
fn sum_file(path: &str) -> Result<i64, FileNumberError> {
let path = PathBuf::from(path);
let file = File::open(&path).map_err(|e| FileNumberError {
path: path.clone(),
line: 0,
kind: Kind::Io(e),
})?;
let mut total = 0i64;
for (idx, line) in BufReader::new(file).lines().enumerate() {
let line = line.map_err(|e| FileNumberError {
path: path.clone(),
line: idx + 1,
kind: Kind::Io(e),
})?;
let text = line.trim();
if text.is_empty() || text.starts_with('#') {
continue;
}
total += text.parse::<i64>().map_err(|e| FileNumberError {
path: path.clone(),
line: idx + 1,
kind: Kind::Parse(e),
})?;
}
Ok(total)
}
fn main() {
println!("{:?}", sum_file("definitely-missing.txt"));
}输出:
textErr(FileNumberError { path: "definitely-missing.txt", line: 0, kind: Io(Os { code: 2, kind: NotFound, message: "系统找不到指定的文件。" }) })
这里有个设计取舍:因为 FileNumberError 需要 path 才能构造,所以每个 ? 点都得写 map_err,不能只靠 From。这是故意的——用一点啰嗦换取「错误自带上下文」。thiserror 的 #[from] 和 anyhow 的 context 就是来消除这份啰嗦的。
层次 c:thiserror(库)+ anyhow(二进制)
thiserror 是过程宏(proc macro):你写 #[derive(Error)] + 属性,它替你生成 Display、Error::source、From。
安装:
powershell
cargo add thiserror anyhowtoml
# Cargo.toml(cargo add 之后的实际片段)
[dependencies]
thiserror = "2"
anyhow = "1"💡 对照:
thiserror之于 Rust,类似「手写Exception子类 +getCause()」的自动生成;anyhow类似 Go 的fmt.Errorf("...: %w", err)+errors.Unwrap的组合,但带类型擦除和漂亮的链式打印。
完整示例:
rust
use std::num::ParseIntError;
use std::path::PathBuf;
use thiserror::Error;
#[derive(Debug, Error)]
enum ConfigError {
#[error("配置文件不存在:{path}")]
Missing { path: PathBuf },
#[error("字段 `{key}` 为空")]
Empty { key: String },
#[error("字段 `{key}` 不是整数")]
BadNumber {
key: String,
#[source] // 标记为错误链的下一环(字段名叫 source 时会被自动识别)
source: ParseIntError,
},
#[error("读写配置文件失败")]
Io {
source: std::io::Error, // 注意:这里刻意不写 #[from],见下方说明
},
#[error(transparent)] // Display 与 source 都直接转发到底层错误,不再包一层文字
Other(#[from] Box<dyn std::error::Error + Send + Sync + 'static>),
}
fn parse_port(raw: &str) -> Result<u16, ConfigError> {
if raw.is_empty() {
return Err(ConfigError::Empty { key: "port".into() });
}
// thiserror 只为标了 #[from] 的字段生成 From;想带属主字段就自己 map_err
raw.parse()
.map_err(|source| ConfigError::BadNumber { key: "port".into(), source })
}
fn load(path: &str) -> Result<u16, ConfigError> {
// 用 map_err 而非 `?` 的直接转换:既保留 io 错误,又能给出更具体的 Missing 变体
let text = std::fs::read_to_string(path).map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
ConfigError::Missing { path: PathBuf::from(path) }
} else {
ConfigError::Io { source: e }
}
})?;
parse_port(text.trim())
}
fn main() {
match load("definitely-missing.toml") {
Ok(port) => println!("port = {port}"),
Err(e) => {
println!("{e}");
// 手工遍历错误链(anyhow 会帮你打印)
let mut cur = std::error::Error::source(&e);
while let Some(s) = cur {
println!(" caused by: {s}");
cur = s.source();
}
}
}
}输出:
text配置文件不存在:definitely-missing.toml
thiserror 属性速查:
| 属性 | 生成什么 |
|---|---|
#[error("...")] | impl Display({字段名} 直接插值,{0} 按下标) |
#[error(transparent)] | Display 与 source() 直接转发给唯一字段 |
#[source] | source() 返回该字段(字段名叫 source 时会被自动识别,此时写上只是为了显式表达意图) |
#[from] | 额外生成 impl From<该字段类型>(暗含 #[source]) |
#[backtrace] | 转发 Backtrace |
⚠️ 陷阱(
#[from]的冲突):#[from]会生成impl From<该字段类型> for 你的错误。而Box<dyn Error + Send + Sync + 'static>那个#[from]生成的是一条泛型 impl(标准库为所有E: Error + Send + Sync + 'static实现了From<E> for Box<dyn Error + Send + Sync>,所以From<Box<dyn Error + Send + Sync>>覆盖了「任意错误装箱后再转」)。如果同时再给Io { source: std::io::Error }加#[from],两条Fromimpl 在io::Error这个类型上重叠,编译器会报conflicting implementations of trait From。上面的代码因此故意不给
Io加#[from],改为在load里map_err显式包装(顺便区分NotFound)。这是真实项目里非常常见的取舍:#[from]只给那个「你想要自动转换」的类型;需要分支判断或附加字段的,一律map_err。补充:
Box<dyn Error + Send + Sync>不满足Sized,所以它不会拿到impl From<E: Error + 'static> for Box<dyn Error>那条 impl 的覆盖;真正冲突的是「为io::Error同时生成From<io::Error>与From<Box<dyn Error + Send + Sync>>后,?对io::Error出现两个可行候选」。总之:一个错误类型只留一个#[from]。
anyhow 侧:
rust
use anyhow::{anyhow, bail, ensure, Context, Result};
fn run_multi_step(raw: &str) -> Result<i32> {
// context:给 Result 加一层「这一步在干什么」
let n: i32 = raw
.trim()
.parse()
.with_context(|| format!("输入 `{raw}` 不是整数"))?;
// ensure!:条件不成立就 return Err(anyhow!(...))
ensure!(n != 0, "输入不能为 0");
// context 也可以用在 Option 上:None 会转成 anyhow 错误
let doubled = n.checked_mul(2).with_context(|| format!("{n} * 2 溢出 i32"))?;
// bail!:直接提前返回一个 anyhow 错误(等价于 return Err(anyhow!(...)))
if doubled > 1000 {
bail!("结果 {doubled} 超出业务上限 1000");
}
// anyhow!:构造一个「没有底层原因」的错误,可带格式化参数
if doubled == 42 {
return Err(anyhow!("结果恰好是 42,业务上不允许"));
}
Ok(doubled)
}
fn main() -> Result<()> {
for input in ["7", "0", "not-a-number", "99999"] {
match run_multi_step(input) {
Ok(v) => println!("{input:>14} -> {v}"),
Err(e) => println!("{input:>14} -> 失败: {e:#}"),
}
}
Ok(())
}anyhow API 速查:
| API | 用法 | 说明 |
|---|---|---|
anyhow!("msg {}", x) | 构造错误 | 得到 anyhow::Error |
bail!("msg") | 立即返回 | return Err(anyhow!(...)) 的语法糖 |
ensure!(cond, "msg") | 条件断言 | if !cond { bail!(...) } |
.context("msg") | Result / Option 通用 | 静态字符串,零分配倾向 |
.with_context(|| format!(...)) | 同上 | 需要格式化时用,惰性求值 |
Result<T> | 类型别名 | anyhow::Result<T> = Result<T, anyhow::Error> |
{e:#} | Display 的 alternate | 只打印「最外层上下文」 |
{e:?} | Debug | 打印完整因果链 |
工程结论(记住这一条就够了):
库(library)用
thiserror定义enum错误;二进制(binary)用anyhow做胶水和上下文。理由:库的调用方需要
match具体错误变体来决定行为,所以错误类型必须具名、可枚举、#[non_exhaustive];而二进制没有下游消费者,只需要「出错时给人看一条清晰的链」,所以用类型擦除的anyhow::Error最省事。在二进制里两者会同时出现:内部模块可以用
thiserror定义领域错误,main里用?把它们自动转成anyhow::Error(anyhow为所有E: Error + Send + Sync + 'static实现了From)。
Error trait 与错误链
std::error::Error 的定义
rust
pub trait Error: Debug + Display {
fn source(&self) -> Option<&(dyn Error + 'static)> { None }
// 已废弃:description()、cause()
}三件事:
- 超 trait 是
Debug + Display。所以任何错误类型都必须实现这两个。(thiserror帮你生成Display,Debug用#[derive(Debug)]。) source()默认返回None。要参与错误链就必须覆写它。'static约束:source()返回的引用必须活得和&self一样久,所以底层错误不能借用self之外的临时数据。这也是为什么错误类型里通常存String而不是&str。
⚠️ 陷阱:
Box<dyn Error + 'static>里的'static是「这个类型不含非'static的借用」的意思,不是「错误对象永不释放」。所以Box<dyn Error>完全可以是一个在函数里创建、函数结束就释放的错误值。把它读成「活到程序结束」是最常见的误解(详见附录 A ·'static的两种含义)。
最小实现与「空实现」对比:
rust
use std::error::Error;
use std::fmt;
#[derive(Debug)]
struct Bare;
// Display 必须写
impl fmt::Display for Bare {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("一个没有底层原因的错误")
}
}
// 最小实现:花括号里什么都不写,source() 用默认的 None
impl Error for Bare {}
fn main() {
let e = Bare;
println!("{e} / source = {:?}", e.source());
}输出:
text一个没有底层原因的错误 / source = None
遍历错误链
rust
use std::error::Error;
fn print_chain(e: &(dyn Error + 'static)) {
println!("{e}");
let mut cur = e.source();
while let Some(s) = cur {
println!(" caused by: {s}");
cur = s.source();
}
}💡 对照:这就是
anyhow打印Caused by:那一段的手写版,也对应 Java 的while (t.getCause() != null)和 Python 的while e.__cause__。
downcast_ref:从 dyn Error 还原具体类型
类型擦除之后想按类型分支,用 downcast_ref / downcast_mut / downcast(拥有所有权时):
rust
use std::error::Error;
use std::io;
fn classify(e: &(dyn Error + 'static)) -> &'static str {
if let Some(ioe) = e.downcast_ref::<io::Error>() {
match ioe.kind() {
io::ErrorKind::NotFound => "文件不存在",
io::ErrorKind::PermissionDenied => "权限不足",
_ => "其他 IO 问题",
}
} else if e.downcast_ref::<std::num::ParseIntError>().is_some() {
"整数解析失败"
} else {
"未知错误"
}
}
fn main() {
let io_err = io::Error::new(io::ErrorKind::NotFound, "缺文件");
let boxed: Box<dyn Error> = Box::new(io_err);
println!("{}", classify(boxed.as_ref()));
let parse_err: Box<dyn Error> = "x".parse::<i32>().unwrap_err().into();
println!("{}", classify(parse_err.as_ref()));
}输出:
text文件不存在 整数解析失败
⚠️ 陷阱:
downcast_ref只看当前这一层,不会自动往source()里找。要「在整条链上找某个类型」,得自己循环:rustfn find_io(e: &(dyn Error + 'static)) -> Option<&io::Error> { let mut cur: Option<&(dyn Error + 'static)> = Some(e); while let Some(err) = cur { if let Some(ioe) = err.downcast_ref::<io::Error>() { return Some(ioe); } cur = err.source(); } None }这正是 Go 的
errors.As(err, &target)帮你做的事(errors.As会遍历 Unwrap 链)。
Box<dyn Error + Send + Sync + 'static>
| 类型 | 能跨线程吗 | 能放 Arc 共享吗 | 常见用途 |
|---|---|---|---|
Box<dyn Error> | ❌ | ❌ | 单线程 main、简单工具 |
Box<dyn Error + Send> | ✅ | ❌ | 线程间转移所有权 |
Box<dyn Error + Send + Sync> | ✅ | ✅ | Arc<dyn Error>、tokio::spawn、异步任务 |
Box<dyn Error + Send + Sync + 'static> | ✅ | ✅ | 库 API 的「任意错误」出口('static 是默认,写出来更明确) |
rust
use std::error::Error;
// 想在线程里用,返回类型就得带上 Send + Sync
fn on_worker() -> Result<(), Box<dyn Error + Send + Sync + 'static>> {
let n: i32 = "12".parse()?; // 具体错误自动装箱
let _ = n;
Ok(())
}
fn main() {
let h = std::thread::spawn(on_worker);
println!("{:?}", h.join().unwrap());
}输出:
textOk(())
⚠️ 陷阱:
?不能在Box<dyn Error>与Box<dyn Error + Send + Sync>之间自动转换!标准库没有impl From<Box<dyn Error + Send + Sync>> for Box<dyn Error>。所以从一开始就统一选一种装箱类型,别在中途换来换去。
std::io::ErrorKind 匹配
io 错误是唯一「标准库给了结构化分类」的错误:不要去看 e.to_string() 里有没有 "NotFound",要匹配 kind()。
rust
use std::fs::File;
use std::io::ErrorKind;
fn open_or_default(path: &str) -> String {
match File::open(path) {
Ok(_) => format!("打开 {path} 成功"),
Err(e) if e.kind() == ErrorKind::NotFound => "文件不存在,用默认配置".to_string(),
Err(e) if e.kind() == ErrorKind::PermissionDenied => "权限不足".to_string(),
Err(e) => format!("其他错误: {e}"),
}
}
fn main() {
println!("{}", open_or_default("definitely-missing.txt"));
}输出:
text文件不存在,用默认配置
常用 ErrorKind:NotFound、PermissionDenied、AlreadyExists、InvalidInput、UnexpectedEof、TimedOut、ConnectionRefused、WouldBlock、Interrupted、Other,以及带 unstable 变体的 Unsupported。
🚀 进阶:
ErrorKind是#[non_exhaustive]的,所以match必须有兜底分支——这是标准库给自己留扩展空间的官方示范,「库 API 的错误类型应当是enum且#[non_exhaustive]」一节我们自己也要这么做。
From 与 ? 的配合,以及「不要用 String 当错误」
From 转换的完整链条:
text
底层:ParseIntError
│ impl From<ParseIntError> for ConfigError
▼
中层:ConfigError::BadNumber { key, source }
│ impl From<ConfigError> for anyhow::Error(anyhow 提供的 blanket impl)
▼
顶层:anyhow::Error? 只做一步转换(调用一次 From::from),但因为每层都有 From,整条链就能一路 ? 上去。
「不要用 String 当错误类型」原则:
rust
// ❌ 库 API 里这样写:调用方无法区分错误种类
fn load_bad(path: &str) -> Result<String, String> {
std::fs::read_to_string(path).map_err(|e| e.to_string())
}问题有三:
- 调用方无法分支。想知道「是不是文件不存在」,只能
if e.contains("NotFound")——脆弱且随语言/版本变化。 - 丢失
source()链,?的上层无法再做From转换,也没法downcast_ref。 - 错误类型没有文档价值。签名
Result<T, String>等于说「可能因为任何原因失败」。
合理例外:
| 例外 | 说明 |
|---|---|
main 的内部小步骤(不进公共 API) | 快速原型无所谓,但要记得别把这种函数 pub 出去 |
| 单元测试的辅助函数 | 错误消息直接 format! 更方便断言 |
已经用 Cow<'static, str> / 自定义 Message 类型做了分类 | 至少要能 kind() 分支 |
| 一次性 CLI 脚本 | 反正没人 match 它 |
正确做法:要么用 enum 具名变体,要么用 Box<dyn Error + Send + Sync>("我不在乎具体类型,只要能打印")。