如何自定义错误类型实现 Error trait?

解读

国内 Rust 面试中,“自定义错误 + Error trait” 几乎是中高级岗位的必考题。面试官想确认三件事:

  1. 你是否理解 Rust 错误处理哲学(可恢复用 Result,不可恢复用 panic);
  2. 能否在库代码中给出语义清晰、可组合、可打印的错误类型;
  3. 是否熟悉标准库 std::error::Error 的契约与常见配套 trait(DisplayDebugSendSync'static)。

回答时切忌只写“派生 thiserror 就行”,必须手写一次标准库实现,再补充生态方案,才能体现“知其然且知其所以然”。

知识点

  1. Error trait 定义
    位于 std::error::Error,核心接口:

    • fn source(&self) -> Option<&(dyn Error + 'static)> —— 支持错误链
    • 自动实现 Display + Debug 要求
    • 自身必须 Send + Sync + 'static,才能与 anyhow/eyre 无缝交互
  2. 实现步骤
    a. 定义具体错误枚举/结构体;
    b. 手动实现 Display(给用户看)与 Debug(给程序员看);
    c. 实现 std::error::Error,按需重写 source()
    d. 为各种底层错误(std::io::Errorserde_json::Error 等)提供 From 转换,用 ? 自动向上传播
    e. 单元测试:断言 error.to_string()error.chain().count() 符合预期。

  3. 常见坑

    • 忘记加 '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,库作者提供,使用者零成本
  • 单元测试覆盖错误链,防止逻辑回归

拓展思考

  1. 库与二进制职责分离

    • 库 crate:只定义结构化错误类型,绝不依赖 anyhow
    • 二进制 crate:在 main() 里用 anyhow::Result<()> 统一收拢,日志 + 错误上下文一次搞定。
  2. thiserror vs 手写
    手写一次后,可介绍 thiserror 的宏展开原理:

    • 自动生成 DisplayError
    • 保证 #[source] 字段与 #[from] 一一对应;
    • 编译期零成本,不引入运行时依赖
  3. 错误上报与可观测性
    国内线上环境常接 Sentry / Prometheus,需在错误链中附带 trace_iduser_id 等扩展字段。可封装 struct ContextualError<E> { source: E, ctx: Map } 并代理 Error,实现结构化日志与指标对齐

  4. no_std 场景
    嵌入式或内核模块无法使用 std::error::Error,可自定义 trait Error: Debug + Display 并手动实现,保证与 std 接口同构,未来迁移到 std 时零改动。