如何自定义错误类型实现 Error trait?
解读
国内 Rust 面试中,“自定义错误 + Error trait” 几乎是中高级岗位的必考题。面试官想确认三件事:
- 你是否理解 Rust 错误处理哲学(可恢复用 Result,不可恢复用 panic);
- 能否在库代码中给出语义清晰、可组合、可打印的错误类型;
- 是否熟悉标准库
std::error::Error的契约与常见配套 trait(Display、Debug、Send、Sync、'static)。
回答时切忌只写“派生 thiserror 就行”,必须手写一次标准库实现,再补充生态方案,才能体现“知其然且知其所以然”。
知识点
-
Error trait 定义
位于std::error::Error,核心接口:fn source(&self) -> Option<&(dyn Error + 'static)>—— 支持错误链- 自动实现
Display+Debug要求 - 自身必须
Send + Sync + 'static,才能与anyhow/eyre无缝交互
-
实现步骤
a. 定义具体错误枚举/结构体;
b. 手动实现Display(给用户看)与Debug(给程序员看);
c. 实现std::error::Error,按需重写source();
d. 为各种底层错误(std::io::Error、serde_json::Error等)提供From转换,用 ? 自动向上传播;
e. 单元测试:断言error.to_string()与error.chain().count()符合预期。 -
常见坑
- 忘记加
'static导致Box<dyn Error>无法存储; - 把
source()写成递归引用自身,造成无限循环; - 在
no_std环境未开启error_in_core而直接use std::error::Error; - 过度使用
String作为错误载体,丢失结构化信息且分配频繁。
- 忘记加
答案
use std::{fmt, error, io, num};
/// 1. 定义错误类型
#[derive(Debug)]
pub enum CliError {
Io(io::Error),
Parse(num::ParseIntError),
Config(String),
}
/// 2. Display:面向终端用户
impl fmt::Display for CliError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
CliError::Io(e) => write!(f, "IO 错误: {}", e),
CliError::Parse(e) => write!(f, "数字解析错误: {}", e),
CliError::Config(s) => write!(f, "配置错误: {}", s),
}
}
}
/// 3. Error trait:面向错误链
impl error::Error for CliError {
/// 返回底层错误源,实现错误链
fn source(&self) -> Option<&(dyn error::Error + 'static)> {
match self {
CliError::Io(e) => Some(e),
CliError::Parse(e) => Some(e),
CliError::Config(_) => None,
}
}
}
/// 4. From 转换,? 自动向上传播
impl From<io::Error> for CliError {
fn from(e: io::Error) -> Self { CliError::Io(e) }
}
impl From<num::ParseIntError> for CliError {
fn from(e: num::ParseIntError) -> Self { CliError::Parse(e) }
}
/// 5. 使用示例
fn read_port() -> Result<u16, CliError> {
let content = std::fs::read_to_string("port.txt")?; // io::Error -> CliError
let port: u16 = content.trim().parse()?; // ParseIntError -> CliError
if port == 0 {
return Err(CliError::Config("端口号不能为 0".into()));
}
Ok(port)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn error_chain() {
let e = read_port().unwrap_err();
// 验证 Display
assert!(e.to_string().contains("IO 错误") || e.to_string().contains("解析错误"));
// 验证 source
assert!(e.source().is_some());
}
}
关键点:
- 必须实现 Display 与 Error,缺一不可;
- source() 返回的是引用,不能返回局部变量;
- 自动转换靠 From,库作者提供,使用者零成本;
- 单元测试覆盖错误链,防止逻辑回归。
拓展思考
-
库与二进制职责分离
- 库 crate:只定义结构化错误类型,绝不依赖
anyhow; - 二进制 crate:在
main()里用anyhow::Result<()>统一收拢,日志 + 错误上下文一次搞定。
- 库 crate:只定义结构化错误类型,绝不依赖
-
thiserror vs 手写
手写一次后,可介绍 thiserror 的宏展开原理:- 自动生成
Display与Error; - 保证
#[source]字段与#[from]一一对应; - 编译期零成本,不引入运行时依赖。
- 自动生成
-
错误上报与可观测性
国内线上环境常接 Sentry / Prometheus,需在错误链中附带trace_id、user_id等扩展字段。可封装struct ContextualError<E> { source: E, ctx: Map }并代理Error,实现结构化日志与指标对齐。 -
no_std 场景
嵌入式或内核模块无法使用std::error::Error,可自定义trait Error: Debug + Display并手动实现,保证与 std 接口同构,未来迁移到 std 时零改动。