Rust中的作用域错误
摘要
Kan-Ru Chen 介绍了 `scoped-error`,一个新的 Rust crate,旨在通过将上下文附件限定在模块级别来改善错误处理的人机工学,解决了像 anyhow 和 thiserror 等现有 crate 的问题。
<p><a href="https://lobste.rs/s/kmbmt1/scoped_error_rust">评论</a></p>
查看缓存全文
缓存时间: 2026/05/25 07:06
# Rust 中的作用域错误(Scoped Error)
来源:https://kanru.info/scoped-error/
西元2026年05月22日 \- Kan\-Ru Chen
- [灵感来源](#the-inspiration)
- [进入作用域错误](#enter-scoped-error)
我从未对 Rust 中的任何错误处理 crate 完全满意过。我尝试过很多,甚至自己开发了一些辅助工具。以下是我发现的每个 crate 的关键问题,而这些问题正是我的 `scoped-error` crate 试图解决的。
## 灵感来源
**`anyhow`**
适用于即插即用的 Error 类型,开箱即用,但需要在每个地方添加 `.with_context()`,显得冗长且重复。错误报告需要了解 `anyhow::Error` 如何处理格式化字符串。错误传播缺少位置信息;替代方案是 backtrace,但这会引入较重的 std 依赖。
**`thiserror`**
适用于定义自定义 Error 类型。`#[from]` 实现鼓励使用一个单一的 Error 类型来涵盖所有可能的来源。但人体工程学止步于此。如果你想获得带有良好上下文的每个模块错误,使用这些类型仍然很繁琐。与手动编写 Error 类型相比,它的改进似乎很小,却要付出 `syn` 和编译时开销的代价。
**`snafu`**
将手动上下文附加与 `anyhow` 和 `thiserror` 模式结合在一个 crate 中。然而,我感觉自己正在将所有错误分支编码成 `Snafu` 上下文。这些实现细节本不需要公开,但 `snafu` 将 Error 类型与它们紧密耦合。也许是我用错了。
**`exn`**
一种令人耳目一新的错误处理方法。我实际上是根据博客文章《停止转发错误,开始设计它们》的模式开始编写我的 crate 的。`exn` 0.3 的小问题是:(1) 你仍然需要为每个可失败操作记住 `.or_raise(err)`,而且很容易在模块内部方法调用中漏掉;(2) `Exn` 包装器本身不是 std `Error`,因此与其他错误类型的互操作需要像 `exn-anyhow` 或 `exn-stderr` 这样的适配器。
在切换这些错误 crate 的过程中,我不断注意到一个差距:使用 `anyhow` 类的 crate,你在每个调用点附加上下文,但方法本身却缺少上下文。示例:
```rust
use anyhow::Result;
fn read_config() -> Result<String> {
let raw = std::fs::read_to_string("config.toml")?;
Ok(raw)
}
fn complex_method() -> Result<()> {
let cfg = read_config().context("validate config file")?;
parse(cfg).context("parse config file")?;
Ok(())
}
```
当所有方法都返回 `anyhow::Result` 时,很容易用 `?` 把上下文弄丢。使用 `exn` 类的 crate,你为每个方法定义错误上下文,并将其附加到所有错误分支上。`snafu` 的工作方式类似。示例:
```rust
use exn::{Result, ResultExt};
use thiserror::Error;
#[derive(Debug, Error)]
#[error("MyError: {0}")]
struct MyError(&'static str);
fn read_config() -> Result<String, MyError> {
let err = || MyError("read config file");
let raw = std::fs::read_to_string("config.toml").or_raise(err)?;
Ok(raw)
}
fn complex_method() -> Result<(), MyError> {
let err = || MyError("complex method");
let cfg = read_config().or_raise(err)?;
parse(cfg).or_raise(err)?;
Ok(())
}
```
共同问题是:当所有方法共享同一个 `Result` 类型时,很容易用 `?` 把上下文弄丢。
## 进入作用域错误
我非常喜欢 `exn` 的方法:定义一个错误闭包来强制转换为模块作用域的 Error 类型。但重复的 `.or_raise(err)?` 很快就会让人厌烦。我开始创建包装器来调解从源错误到模块作用域 Error 的转换。很快我意识到这种模式解决了其他方法的一些人体工程学问题,并满足了我关心的所有条件。示例:
```rust
use scoped_error::{Error, expect_error};
fn read_config() -> Result<String, Error> {
expect_error("read config file", || {
let raw = std::fs::read_to_string("config.toml")?;
Ok(raw)
})
}
fn complex_method() -> Result<(), Error> {
expect_error("failed to do complex thing", || {
let cfg = read_config()?;
parse(cfg)?;
Ok(())
})
}
```
核心思想很简单:只附加一次上下文。不是每个调用点,不是每个失败点,而是在调用者和逻辑之间。我希望每个模块都有 Error 类型,而不必在每个步骤手动转换。
`expect_error()` 有三个职责:准备未来错误的上下文,将内部错误强制转换为盒装类型以消除内部错误的类型,以及用内部错误作为源包装外部错误。结果是:一个干净、可读的可失败操作声明。默认提供了一个 `Error` 类型,但任何实现了 `WithContext` 的 std `Error` 都可以。核心库非常小,小到可以直接 vendored 到你的项目中¹。
内部的盒装错误类型 `Frame` 取名自 `exn`。它将任何错误转换为 `Box<dyn StdError + Send + Sync + 'static>`,并通过 `#[track_caller]` 捕获文件位置,提供轻量级的堆栈跟踪。使用内置的 `Error` 类型或 `ErrorExt::report()` 辅助方法,错误树(是的,树是支持的)会显示如下:
```
Error: failed to do complex thing, at src/main.rs:12:19
|-- read config file, at src/main.rs:5:19
`-- No such file or directory (os error 2)
```
`scoped-error` ² crate 还附带了一些额外功能:一个用于创建实现了 `WithContext` 的常见错误的 `macro_rules!` 宏,以及一个用于多原因错误的 `Many` 类型。该 crate 发布在 crates.io 上,源代码在 Codeberg ³,文档 ⁴ 涵盖了详细信息。
如果你尝试了,我很想听听你的用例。可以提 issue 或给我留言。我现在在自己项目中使用它,终于感觉 Rust 的错误处理对了。
```rust
// Copyright (C) 2026 Kan-Ru Chen
// SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
use std::any::Any;
use std::borrow::Cow;
use std::error::Error as StdError;
use std::fmt::{Debug, Display};
use std::panic::Location;
/// 一个可以为错误携带上下文信息的 trait。
pub trait WithContext: StdError + Any {
/// 向此错误附加一个上下文层。
fn with_context(self, context: Frame) -> Self;
/// 获取创建此错误或附加上下文的位置。
fn location(&self) -> Option<&'static Location<'static>>;
}
/// 错误上下文的单个层。
pub struct Frame {
/// 导致此上下文的底层错误。
pub source: Box<dyn StdError + Send + Sync + 'static>,
/// 附加此上下文的位置。
pub location: &'static Location<'static>,
}
impl<T> From<T> for Frame
where
T: Into<Box<dyn StdError + Send + Sync + 'static>>,
{
/// 从任何错误类型创建 `Frame`,并捕获调用者的位置。
#[track_caller]
fn from(value: T) -> Self {
let source = value.into();
let location = Location::caller();
Frame { source, location }
}
}
/// 低级函数,用于使用自定义错误构造函数添加上下文。
#[inline(always)]
pub fn expect_error_fn<E, F, T>(
err: F,
body: impl FnOnce() -> Result<T, E>,
) -> Result<T, E>
where
F: FnOnce() -> E,
E: WithContext,
{
body().map_err(|context| err().with_context(context))
}
/// 为错误添加上下文,返回自定义错误类型。
#[inline(always)]
pub fn expect_error<M, T, E>(
msg: M,
body: impl FnOnce() -> Result<T, E>,
) -> Result<T, E>
where
M: Into<Cow<'static, str>>,
E: From<(Cow<'static, str>, Frame)>,
{
body().map_err(|context| (msg.into(), context).into())
}
```
---
¹ https://kanru.info/scoped-error/#1
² https://crates.io/crates/scoped-error
³ https://codeberg.org/kanru/scoped-error
⁴ https://docs.rs/scoped-error/
相似文章
自定义错误在我的Rust应用中是必不可少的
一篇提倡在Rust应用中使用自定义错误类型的博客文章,解释了如何使用map_err和From trait创建一个统一的AppError枚举,以简化不同子系统间的错误处理。
安全变得简单 第1部分:单一所有权(并非)可选
本文介绍了一种基于线性类型和抽象解释的内存安全新方法,旨在比Rust更符合人机工程学原理地消除诸如释放后使用和内存泄漏等常见错误。
擦除存在类型
深入探讨 Rust 类型系统中的存在量词,比较 `dyn Trait` 和 `impl Trait`,并探索超越 `Self` 的存在量化类型变量的高级模式。
安全 Rust 的边界
TokioConf 2026 的一篇演讲/博客文章探讨了如何通过为复杂指针结构实现追踪式垃圾回收,将安全 Rust 推向极限,并分享处理循环引用与原始指针 GC 设计的技巧。
最小可行的Zig错误上下文
一篇博文,详细介绍了使用errdefer日志在Zig中添加错误上下文的最小模式,并将其与完整的诊断接收器(diagnostics sinks)和catch块进行比较,讨论了权衡取舍。