本文是对 Improving error handling - panics vs. proper errors 的整理与翻译。
原文属于 fasterthanli.me 的 "Making our own ping" 系列第 10 篇,主题是:在继续解析原始网络包之前,先把项目里的错误处理方式从零散的 unwrap()、expect()、panic,逐步整理成更适合库代码的 Result、自定义错误类型和可读 backtrace。(fasterthanli.me)
内容结构概览
这篇文章主要围绕一个问题展开:Rust 项目里什么时候可以 panic,什么时候应该返回一个真正的错误?
整体脉络如下:
- 先讨论
panic和Result的边界:应用代码里 panic 有时可以接受,但库代码更应该把可恢复错误交给调用者处理。 - 解释为什么
catch_unwind不是常规错误处理方案。 - 检查项目中已有的
expect()、unwrap(),尤其是动态加载 Windows DLL 的loadlibrary模块。 - 引入
thiserror,用更少代码定义清晰的错误类型。 - 引入
failure,让错误携带 backtrace,看到错误真正产生的位置。 - 引入
color-backtrace,让 backtrace 更容易阅读。 - 解释为什么错误类型不能随便持有短生命周期的
&str,以及为什么很多错误信息应该拥有String。 - 讨论
failure::Error下的 downcast 和模式匹配问题。 - 最后把错误类型拆得更精确:
LibraryError和ProcError分开。 - 顺手用
thiserror简化项目顶层Error,并把netinfo里的expect()替换成明确的错误返回。
一、为什么这篇突然不继续写 ping,而是先讲错误处理?
在前几篇里,我们已经写出了一个叫 ersatz 的实验性项目,用 Rust 去做类似 ping 的事情。项目已经能和底层网络、Windows API、raw socket 这些东西打交道,但代码里还混着几种不同的错误处理风格:有些函数返回 Result<T, E>,有些地方直接 unwrap(),有些地方用 expect(),还有一个手写的顶层 Error 枚举,里面包着 raw socket 错误、I/O 错误和 Win32 错误。(fasterthanli.me)
这种状态在探索阶段很常见。刚开始写一个底层项目时,注意力往往在"先让它跑起来",不是在"把每一种失败都设计得很优雅"。但当代码逐渐接近一个可复用的库时,错误处理就不能再随便了。
因为应用程序崩了,最多是当前命令失败;库代码崩了,可能会把使用它的整个程序一起带走。
原文这一篇的核心转折点就在这里:ersatz 现在看起来像命令行程序,但之后可能会被另一个程序 sup 当成库来使用,用来发送和接收 ICMP 包。也就是说,它不再只是"自己跑起来就行",还要变成别人能安全调用的组件。(fasterthanli.me)
二、panic 到底什么时候可以接受?
Rust 里的 panic! 不一定是坏东西。它表达的是一种"程序已经进入不该发生的状态"的情况。比如:
rust
let value = maybe_value.expect("value should exist");
这段代码的意思不是"我不知道这里会不会失败",而是"按照我的程序逻辑,这里必须有值;如果没有,说明我的假设错了"。
原文给出的判断标准大致是:
应用代码里 panic 可以接受。库代码里 panic 也不是绝对禁止,但它应该只用于两类情况:第一,库本身有 bug;第二,调用者以某种类型系统无法提前阻止的方式误用了库。除此之外,如果错误是调用者有可能处理的,就应该返回 Result。(fasterthanli.me)
举个例子,项目里有一段代码会查找默认网络接口:
rust
let route = routes
.iter()
.find(|r| r.dest == Ipv4Addr::new(0, 0, 0, 0))
.expect("default route should exist");
这在命令行程序里也许还说得过去。机器如果连默认路由都没有,程序确实很难继续工作。但如果 ersatz 被某个长期运行的服务器使用呢?服务器的某个请求处理逻辑调用了这个库,结果库内部 panic,把整个服务进程干掉,这就不太合理了。
更稳妥的做法是:返回一个明确的错误,比如 DefaultRouteMissing,让调用方决定是重试、降级、返回 HTTP 500,还是记录日志后继续服务其他请求。
三、能不能用 catch_unwind 把 panic 接住?
Rust 提供了 std::panic::catch_unwind(),看起来好像可以把 panic 当异常一样捕获:
rust
let result = std::panic::catch_unwind(|| {
panic!("something went wrong");
});
但原文马上指出,这不是一个理想的常规错误处理方式。
第一,即使你捕获了 panic,panic 的信息仍然可能被打印出来。也就是说,程序没有终止,但用户还是看到了"线程 panic 了"的输出,这会让日志非常混乱。
第二,catch_unwind() 返回的错误不是一个清晰的业务错误类型,而是类似 Box<dyn Any + Send> 这样的东西。要拿到里面真正的内容,你需要 downcast,而且 downcast 还可能失败。
第三,catch_unwind() 并不能捕获所有 panic。Rust 的 panic 策略可以是 unwind,也可以是 abort;如果配置成 abort,进程会直接终止,根本没有 unwind 过程可捕获。原文也引用了标准库文档对这一点的说明。(fasterthanli.me)
所以,catch_unwind() 更像是跨越 FFI 边界、线程边界、插件边界时的保护措施,而不是日常业务逻辑里的错误处理机制。
真正应该做的是:把可预期、可表达、可恢复的问题建模成 Result<T, E>。
四、先从 loadlibrary 里的 expect 开始
项目里有一个 loadlibrary 模块,用来动态加载 Windows DLL,并获取某个函数指针。早期代码里,它大概是这种风格:
rust
pub fn open(name: &str) -> Option<Library> {
let c_name = CString::new(name).expect("invalid library name");
// 调用 LoadLibraryA
}
这里有一个隐藏问题:CString::new(name) 是可能失败的。因为 C 字符串不能在中间包含 \0,如果传入的字符串里有 NUL 字节,构造 CString 就会失败。
如果这个模块只在程序内部用,并且传入的永远是硬编码字符串,比如 "KERNEL32.dll",那 expect() 基本不会出问题。即使出问题,也很可能在测试阶段暴露出来。
但原文提出了一个更现实的场景:假设未来有一个 REPL,允许用户输入 DLL 名称,然后动态加载。如果用户输入了非法字符串,我们显然不希望整个 REPL 直接 panic 退出。(fasterthanli.me)
这说明 Option<T> 已经不够用了。因为 Option 只能告诉你"有"或"没有",却不能告诉你为什么失败。这里至少有几种失败原因:
- 传入的库名不是合法的 C 字符串;
- DLL 找不到;
- DLL 找到了,但指定函数不存在;
- 函数名本身也可能不是合法的 C 字符串。
这些都应该进入错误类型。
五、用 thiserror 少写很多模板代码
如果手写 Rust 错误类型,通常要写 Debug、Display、std::error::Error,还可能要写一堆 From 实现。对于探索性项目来说,这很容易让人嫌麻烦,于是继续 unwrap()。
thiserror 的价值就在这里:它让我们用派生宏快速定义清晰的错误类型。原文在这里引入了 thiserror,用它替代大量手写错误实现。(fasterthanli.me)
示意代码可以写成这样:
rust
use thiserror::Error;
#[derive(Debug, Error)]
pub enum LoadLibraryError {
#[error("invalid library name: {0}")]
InvalidLibraryName(String),
#[error("library not found: {0}")]
LibraryNotFound(String),
#[error("symbol not found: {symbol} in {library}")]
SymbolNotFound {
library: String,
symbol: String,
},
}
这样做以后,Library::new() 就可以从:
rust
fn new(name: &str) -> Option<Library>
变成:
rust
fn new(name: &str) -> Result<Library, LoadLibraryError>
这是一种很重要的设计变化。
以前调用者只能知道"加载失败了"。现在调用者可以知道"传入名称非法""库不存在"还是"函数不存在"。错误不再只是程序崩溃前的一句提示,而变成了程序接口的一部分。
六、Result 不是为了让你最后继续 unwrap
不过,单纯把函数签名改成 Result 并不意味着错误处理就变好了。如果调用方还是这么写:
rust
let lib = Library::new(name).unwrap();
那失败时仍然会 panic。
区别在于:这次 panic 的原因更清楚了。以前可能只是:
text
called `Option::unwrap()` on a `None` value
现在至少能看到具体错误,比如"非法库名"或者"找不到某个 proc"。
但原文指出,这还不够。因为此时 backtrace 主要告诉你 .unwrap() 发生在哪里,而不是错误最初是在哪里构造出来的。对于复杂代码来说,这两者不总是同一个位置。(fasterthanli.me)
这就引出了下一步:不仅要有错误类型,还要有错误发生位置的上下文。
七、failure:让错误携带 backtrace
原文接着引入了 failure。在当时的 Rust 生态里,failure 是一个常见的错误处理库,它提供了 failure::Error 和 failure::Fallible<T>。
failure::Fallible<T> 可以理解成:
rust
type Fallible<T> = Result<T, failure::Error>;
它的一个重要能力是:错误可以携带 backtrace。这样,当错误一路通过 ? 往上传递时,我们不只是看到最后在哪里处理失败,也能看到错误更早是在什么调用栈里产生的。原文通过给 failure 开启 backtrace 功能,展示了错误发生位置的栈信息。(fasterthanli.me)
在库函数里,代码大概会变成这种风格:
rust
fn open_library(name: &str) -> failure::Fallible<Library> {
let c_name = CString::new(name)
.map_err(|_| LoadLibraryError::InvalidLibraryName(name.to_owned()))?;
let handle = unsafe { load_library(c_name.as_ptr()) };
handle.ok_or_else(|| LoadLibraryError::LibraryNotFound(name.to_owned()))?;
Ok(Library { /* ... */ })
}
这里的关键不是具体 API,而是 ? 的传播方式:底层错误可以被转换成统一的错误容器,然后由上层统一打印、记录或处理。
不过,原文也指出了一个新问题:backtrace 变多了,而且输出很啰嗦。你可能同时看到 .unwrap() 的 panic backtrace,以及 failure 捕获的错误 backtrace。信息是多了,但不一定更易读。(fasterthanli.me)
八、better-panic 只能美化 panic,不能美化普通错误
在前面,作者试过 better-panic。它可以把 panic 的 backtrace 打印得更好看,带颜色,也更容易定位源码位置。
但它有一个天然限制:它只处理 panic。
当我们开始认真返回 Result 时,很多失败已经不再是 panic 了。错误通过 Result 返回,经过 ? 一路上传,最后由顶层处理。这种情况下,better-panic 就帮不上忙。原文也因此把它移除了。(fasterthanli.me)
这其实是一个很好的提醒:
panic 的调试体验和 Result 的错误报告体验,是两套东西。不能因为 panic 的 backtrace 漂亮,就把本该返回错误的地方写成 panic。
九、color-backtrace:让错误栈更适合人看
为了解决 backtrace 太难读的问题,原文又试了 color-backtrace。它可以把栈信息打印得更清晰,并隐藏一些初始化阶段、panic 之后的无关帧。原文提到,它当时也提供了对 failure 的实验性支持。(fasterthanli.me)
不过它不是"装上就自动解决一切"。对于 failure 的错误,作者需要在顶层手动打印 backtrace。
于是程序结构被改成两层:
rust
fn main() {
if let Err(err) = real_main() {
eprintln!("Fatal error: {}", err);
// 这里统一打印 backtrace
}
}
fn real_main() -> failure::Fallible<()> {
// 真正的程序逻辑
Ok(())
}
这个结构很常见,也很实用。
main() 负责最后兜底:如果程序失败,就以统一格式报告错误。real_main() 负责业务逻辑:里面可以自由使用 ?,让错误自然向上传播。
这样做之后,命令行程序的错误输出就更像一个产品,而不是一堆随缘的 panic 日志。
十、错误信息为什么不能随便借用 &str?
接下来,原文讨论了一个非常 Rust 的问题:生命周期。
一开始,为了让错误类型能保存传入的库名,作者把函数签名从:
rust
fn new(name: &str) -> Result<Library, Error>
改成了类似:
rust
fn new(name: &'static str) -> Result<Library, Error>
这看起来解决了问题,因为错误里可以安全保存 name。但它也带来了限制:调用者只能传入 'static 字符串,比如字符串字面量。对于 REPL 这种场景,用户输入是运行时生成的 String,它不是 'static。
为什么不能直接把普通 &str 存进错误里?
因为错误往往会在函数返回之后才被格式化、打印或记录。函数一返回,局部变量可能已经被释放。如果错误里只是借用了一个局部字符串,那它可能指向已经无效的内存。Rust 编译器会阻止这种情况。原文用几个小例子解释了这一点:局部 String 的引用不能逃出函数,但字符串字面量可以,因为它拥有 'static 生命周期;另一种方案是让错误类型拥有自己的 String。(fasterthanli.me)
因此,更合理的错误类型应该拥有数据:
rust
#[derive(Debug, Error)]
pub enum LibraryError {
#[error("invalid library name")]
InvalidName,
#[error("library not found: {0}")]
NotFound(String),
}
如果错误需要在未来某个时间点被打印,它就应该拥有足够的信息,而不是借用早就可能消失的局部变量。
十一、让 Library 自己记住名字
为了让错误消息更清楚,原文还让 Library 结构体保存了自己的名称。
为什么这有用?
假设我们成功加载了某个 DLL,之后再从这个 DLL 里找函数。如果函数不存在,错误消息最好能告诉我们:
- 找的是哪个函数;
- 在哪个库里找;
- 是函数名非法,还是函数不存在。
所以 Library 不应该只保存底层 handle,也应该保存库名。
示意结构如下:
rust
pub struct Library {
name: String,
handle: RawHandle,
}
然后函数查找失败时,就可以返回更具体的错误:
rust
#[derive(Debug, Error)]
pub enum ProcError {
#[error("invalid proc name")]
InvalidName,
#[error("proc {proc} not found in {library}")]
NotFound {
library: String,
proc: String,
},
}
这样错误信息不需要调用者猜,也不需要强迫调用者去读 backtrace。仅凭错误文本,就能知道大概发生了什么。
十二、一个微妙的问题:? 可能转换成你不想要的错误类型
原文中有一个很细的点,值得单独拿出来讲。
当 CString::new(name)? 失败时,它产生的是 std::ffi::NulError。而在使用 failure::Fallible<T> 时,? 可能直接把 NulError 转进 failure::Error,而不是先转成我们自定义的 Error::InvalidName。
结果就是:我们精心写好的"invalid library name"这类错误消息可能被绕过去了。
解决办法是显式指定转换:
rust
let c_name = CString::new(name)
.map_err(MyError::from)?;
这句话的意思是:不要让 ? 自己猜该怎么转,先把 NulError 转成我的错误类型,再继续向上传播。
这个细节说明,Rust 的 ? 很方便,但它不是魔法。尤其当你引入统一错误容器时,要注意错误转换路径是否符合预期。原文也正是在这里发现了错误消息被"吞掉"的问题,然后用 map_err(Error::from) 修正。(fasterthanli.me)
十三、failure::Error 可以 downcast,但库里不一定应该返回它
如果所有函数都返回 failure::Error,调用者当然可以统一处理错误。但问题是:调用者如果想针对某一种具体错误做分支,就要 downcast。
比如调用者想知道失败是不是"库不存在",就不能直接 match,而要类似这样:
rust
if let Some(err) = err.downcast_ref::<LibraryError>() {
match err {
LibraryError::NotFound(name) => {
// 换一个库试试
}
_ => {}
}
}
这能用,但不够自然。
原文由此提出一个更好的边界划分:库代码应该返回自己的具体错误类型;应用代码可以在顶层使用 failure::Error 这类统一错误容器。(fasterthanli.me)
也就是说:
rust
fn new(name: &str) -> Result<Library, LibraryError>
比下面这样更适合库 API:
rust
fn new(name: &str) -> failure::Fallible<Library>
前者对调用者更友好,因为调用者可以直接 match LibraryError。后者对应用顶层更方便,因为所有错误都能统一打印 backtrace。
这就是错误处理设计里非常重要的一点:库和应用的需求不一样。
十四、把 LibraryError 和 ProcError 分开
原文接着进一步细化错误类型。
一开始,loadlibrary 模块可能只有一个总的 Error:
rust
enum Error {
InvalidName,
LibraryNotFound,
ProcNotFound,
}
但这其实不够精确。因为 Library::new() 不可能返回 ProcNotFound,而 get_proc() 不可能返回 LibraryNotFound。如果两个函数都返回同一个大而全的错误类型,调用者在匹配时就会遇到一些理论上不可能出现的分支。
更好的方式是拆开:
rust
pub enum LibraryError {
InvalidName,
NotFound(String),
}
pub enum ProcError {
InvalidName,
NotFound {
library: String,
proc: String,
},
}
于是函数签名变成:
rust
fn new(name: &str) -> Result<Library, LibraryError>;
fn get_proc<T>(&self, name: &str) -> Result<T, ProcError>;
这个设计有几个好处:
第一,错误类型更贴近函数本身。
第二,调用者匹配错误时,不需要处理不可能出现的情况。
第三,错误变体名称可以更短。比如 ProcError::NotFound 就足够清楚,不需要叫 ProcNotFound,因为上下文已经在类型名里了。
第四,库代码里的 ? 也更自然,不需要到处做额外转换。
这就是 Rust 错误类型设计里一个很实用的原则:不要急着做一个"万能错误类型"。在库的边界上,具体比统一更重要。
十五、应用顶层仍然可以统一处理错误
虽然库函数返回具体错误类型,但应用的顶层仍然可以使用统一错误容器。
例如主流程里可能是:
rust
fn real_main() -> failure::Fallible<()> {
let kernel32 = Library::new("KERNEL32.dll")?;
let func = kernel32.get_proc::<SomeFn>("SomeFunction")?;
// 继续执行程序逻辑
Ok(())
}
这里 LibraryError 和 ProcError 会通过 ? 转成顶层统一错误。调用方不需要在主流程里手动写一堆 match,除非它确实想针对某个错误恢复。
原文总结这个状态是"几个世界里最好的组合":应用层可以得到干净的 backtrace;库层保留完整类型信息;? 在库代码里也能自然工作。(fasterthanli.me)
这也是整篇文章最重要的实践结论之一:
库 API 返回具体错误;应用入口统一兜底报告。
十六、bind! 宏里的 unwrap 还要不要改?
项目里还有一个 bind! 宏,用来声明某个 DLL 里的函数,然后通过懒加载拿到函数指针。里面也有 unwrap()。
这时候作者没有机械地把所有 unwrap() 都消灭掉,而是判断:这里的 unwrap() 可以保留。
原因是:
第一,bind! 绑定的是静态库名和静态函数名,不是用户输入。
第二,这些函数是懒加载的,真正出错时可能已经在调用某个外部函数的过程中,很难把错误优雅地传出去。
第三,这些函数的 ABI 可能根本不允许返回 Rust 风格的错误。
所以这里 panic 是可以接受的。但前面做的错误类型改造仍然有价值:如果某个函数名写错了,现在 panic 信息里会明确告诉你哪个库里缺了哪个函数,而不是只说 Option::unwrap() 遇到了 None。原文也展示了这种改造后的错误输出更容易定位。(fasterthanli.me)
这点很重要:好的错误处理不是"绝不 panic",而是知道哪里应该 panic,哪里不应该 panic。
十七、用 thiserror 简化项目顶层 Error
除了 loadlibrary 模块,项目本来还有一个顶层错误类型,大概负责包装:
- raw socket 错误;
- I/O 错误;
- Win32 错误码。
早期写法需要手动实现 From、Display、Debug 和 std::error::Error。这类代码很机械,也很容易写得又长又无聊。
用 thiserror 后,可以直接写成:
rust
#[derive(Debug, Error)]
pub enum Error {
#[error("raw socket error: {0}")]
RawSocket(#[from] rawsock::Error),
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("Win32 error code {0} (0x{0:x})")]
Win32(u32),
}
#[from] 的意思是:当底层出现 rawsock::Error 或 std::io::Error 时,可以自动转换成这个顶层 Error。这样之前依赖 ? 的地方不用改,调用点仍然干净。原文也明确说,这次替换后,代码库里使用 Error 的地方基本不需要变化。(fasterthanli.me)
这正是 thiserror 最舒服的地方:它不强迫你改变错误模型,只是帮你减少模板代码。
十八、把 netinfo 里的 expect 也换掉
最后,原文回到最开始那个网络接口问题。
netinfo::default_nic_guid() 里有几个 expect():
- 找不到默认路由时 panic;
- 找不到默认网络接口时 panic;
- 解析接口名称里的 GUID 失败时 panic。
这些其实都可以变成明确错误。
顶层错误类型里新增几个变体:
rust
#[derive(Debug, Error)]
pub enum Error {
// 其他错误略
#[error("default IP route not found")]
DefaultRouteMissing,
#[error("default network interface not found")]
DefaultInterfaceMissing,
#[error("default network interface could not be identified")]
DefaultInterfaceUnidentified,
}
然后把原来的:
rust
let route = routes.iter()
.find(is_default_route)
.expect("default route should exist");
改成:
rust
let route = routes.iter()
.find(is_default_route)
.ok_or(Error::DefaultRouteMissing)?;
这就是从"我觉得这里应该有,没有就崩"变成"这里可能没有,没有就返回一个清晰错误"。
同样,接口不存在、GUID 找不到,也都可以用 ok_or(...) ? 表达。
这种改动看似很小,但它改变了库的行为:以前调用方没有选择,只能接受进程 panic;现在调用方可以拿到错误,决定下一步怎么做。原文也在这一段完成了当前代码库中已知 panic 点的清理。(fasterthanli.me)
十九、这篇真正想教的不是某个 crate,而是错误处理边界
这篇文章表面上讲了几个 crate:
thiserror:帮你定义自己的错误类型,少写模板代码;failure:让错误可以携带 backtrace;better-panic:让 panic 的 backtrace 更好看;color-backtrace:让 backtrace 输出更适合人类阅读。
原文最后也总结了这些 crate 的作用。(fasterthanli.me)
但真正重要的不是"该不该用这些 crate",而是下面这些判断:
第一,panic 不是错误处理的默认方式。它更适合表达程序 bug、违反不变量、类型系统无法表达的误用。
第二,库代码应该尽量返回具体错误。因为调用者可能有恢复策略。
第三,应用顶层可以统一处理错误。比如统一打印"Fatal error"以及 backtrace。
第四,错误信息要包含足够上下文。只说"None unwrap 失败"没有意义;说"在 KERNEL32.dll 里找不到某个函数"才有调试价值。
第五,错误类型应该拥有需要长期保存的数据。不要为了省一次 String 分配,就把短生命周期引用塞进错误里。
第六,不要为了"消灭 unwrap"而机械重构。像 bind! 这种绑定静态系统 API 的地方,panic 可能是合理的。关键是 panic 信息要足够明确。
二十、从这个 ping 项目里能学到什么?
这篇虽然暂时没有继续解析 ICMP 或 IPv4 包,但它补上了一个底层项目迟早要面对的问题:错误处理是 API 设计的一部分。
尤其是写 Rust 库时,错误处理不只是"出了错打印一下"。它关系到:
- 调用者能不能区分不同失败原因;
- 调用者能不能恢复;
- 日志里能不能看到真正出错位置;
- 项目从实验代码走向库代码时,边界是否清晰;
- 未来调试底层网络问题时,能不能少猜一点。
对于这个自制 ping 项目来说,后面要处理原始数据包、IPv4 头、ICMP 报文、系统 API、socket 权限等各种低层细节。任何一步都可能失败。如果错误处理还停留在 unwrap() 和 expect(),后面会非常痛苦。
所以这篇先停下来整理错误处理,其实很合理。
先把失败路径设计清楚,后面继续解析网络包时,代码才不会被一堆不明所以的 panic 打断。
结语
这一篇的主题可以概括成一句话:
不要把所有失败都当成程序崩溃;能表达、能传播、能被调用者处理的失败,就应该成为类型系统里的一部分。
在 Rust 里,这通常意味着:用 Result 表达失败,用自定义错误类型表达原因,用 ? 保持调用链干净,在应用顶层统一报告错误,并在必要时附带 backtrace。
等这些基础设施准备好以后,项目才更适合继续往下走:真正开始解析 IPv4 包,继续完成"自己写一个 ping"的目标。