4. 错误处理工程化
Rust 把错误处理从“异常控制流”变成了“类型系统的一部分”。这意味着错误不是运行时突然冒出来的东西,而是函数签名里明确写出的返回值。工程化的第一步,就是理解 Option<T> 与 Result<T, E> 各自表达什么语义,以及它们如何影响调用方的决策。
Option<T> 表示“可能有值,也可能没有值”,它不携带失败原因。典型场景是查找:HashMap::get 返回 Option<&V>,因为“键不存在”是正常业务状态,不是错误。而 Result<T, E> 表示“要么成功得到 T,要么失败并携带 E”,它必须说明失败原因。选择哪一个,取决于“缺失”是否需要被解释、记录或向上反馈。如果缺失需要告诉用户“配置项缺失”还是“文件不存在”,就应该用 Result 而不是 Option。
在函数内部,? 运算符是最常用的传播工具。它会在值为 Err(e) 时提前返回,并把错误转换为当前函数的错误类型。这里的关键是 From trait:当 ? 遇到类型不匹配时,会尝试调用 From::from 做转换。因此,自定义错误类型只要实现了 From<io::Error>、From<ParseIntError> 等,就能让 ? 自动完成错误类型统一。
手写自定义错误类型通常需要实现 Display、Error 以及若干 From,样板代码较多。工程中更常见的是用 thiserror 为库或模块定义结构化错误。例如:
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ConfigError {
#[error("配置文件不存在: {path}")]
NotFound { path: String },
#[error("配置项 {key} 缺失")]
MissingKey { key: String },
#[error("解析配置失败")]
Parse(#[from] serde_json::Error),
#[error("读取配置失败")]
Io(#[from] std::io::Error),
}
这段代码里,#[error("...")] 自动生成 Display,#[from] 自动生成 From 实现。调用方可以精确匹配 ConfigError::MissingKey 并给出针对性提示,而不是拿到一个字符串。这正是“库应定义具体错误类型”的原因:调用方需要根据错误种类做不同处理。
与之相对,anyhow 适合应用层或二进制入口。它提供 anyhow::Result<T>,内部是 Result<T, anyhow::Error>,可以容纳任意实现了 std::error::Error 的错误,并支持 .context("...") 添加上下文。例如:
use anyhow::{Context, Result};
fn load_config(path: &str) -> Result<String> {
let content = std::fs::read_to_string(path)
.with_context(|| format!("读取配置文件失败: {path}"))?;
Ok(content)
}
这里 with_context 保留了底层 io::Error,同时附加了“读取哪个文件失败”的信息。打印错误链时,可以看到从高层上下文到底层原因的完整路径。这种“库用 thiserror、应用用 anyhow”的分工,是 Rust 社区较为成熟的实践:库暴露结构化错误,应用负责聚合与展示。
错误传播策略需要按边界划分。底层函数应尽量返回具体错误类型,让调用方有选择权;中间层可以用 ? 向上传递,必要时用 map_err 转换或补充上下文;最外层(main、HTTP handler、CLI 命令)负责把错误转成用户可读信息、日志或退出码。不要在所有层都无脑 unwrap,也不要在库中返回 anyhow::Error 而丢失可匹配性。
再看一个实际判断:当函数返回 Option 时,调用方只能知道“没有”,无法区分“文件不存在”和“权限不足”。如果业务需要区分,就必须把返回类型改成 Result。反过来,如果只是缓存未命中,用 Result 反而会让调用方被迫处理一个并不存在的“错误”。因此,类型选择本身就是接口设计的一部分。
在工程中,建议遵循以下原则:库的公开 API 返回具体错误枚举,并实现 std::error::Error;应用内部可以使用 anyhow 简化传播,但在需要分支处理的地方仍然匹配具体错误;unwrap 只允许出现在测试、原型或确实不可能失败的位置,并尽量用 expect 写明原因;跨线程或异步边界时,注意错误类型需要 Send + Sync,anyhow::Error 满足这一要求,而某些自定义错误若包含 Rc 则不行。
最后,错误信息应当面向“谁读”来设计。给用户看的消息要简洁、可操作,例如“配置文件 config.toml 不存在,请检查路径”;给开发者看的日志要保留错误链和上下文,例如“加载配置失败: 读取 config.toml 失败: No such file or directory”。anyhow 的 {:#} 格式化可以打印完整错误链,适合日志;{} 只打印最外层消息,适合直接展示。把这两类输出分开,是错误处理工程化落地的最后一步。