错误处理项目组的努力方向

2021 年 7 月 1 日 · Jane Lusby 代表 库团队

这篇博文是我们之前文章的后续,详细介绍了我们目前正在进行的工作。我们已经就当前错误处理中看到的一些挑战进行了一段时间的迭代,现在达到了我们想要描述正在努力实现的一些新变化的地步。但首先我们需要描述我们已经确定的主要挑战。

免责声明:本文既是计划也是愿景。这里有一些技术挑战需要解决,所以最终结果可能与我们最初的设想大不相同,因此请不要认为这些是最终方案。

今天的错误处理

我们想要解决的第一个挑战是,在报告错误时很容易意外丢失上下文。这可能发生在几个地方:打印错误时忘记打印来源,从 main 返回错误时,或者将可恢复错误转换为不可恢复错误时。

考虑这个例子

use std::fmt;

// We have a program that loads a config and expects that
// loading the config will always succeed.
fn main() {
    let _config = load_config()
        .expect("config is always valid and exists");
}

// We have a dummy implementation of load_config which
// always errors, since we're just focusing on diagnostics
// here.
fn load_config() -> Result<(), Error> {
    Err(Error(SourceError))
}

// And we have an error type that just prints "invalid
// config" and has a source error which just prints "config
// file does not exist"
#[derive(Debug)]
struct Error(SourceError);

impl fmt::Display for Error {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str("invalid config")
    }
}

impl std::error::Error for Error {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        Some(&self.0)
    }
}

#[derive(Debug)]
struct SourceError;

impl fmt::Display for SourceError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str("config file does not exist")
    }
}

impl std::error::Error for SourceError {}

当我们运行它时,我们希望看到类似这样的输出

$ cargo run
thread 'main' panicked at 'config is always valid and exists', src/main.rs:4:33

Error:
    0: invalid config
    1: config file does not exist

note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

在这个错误消息中,我们可以看到我们因 panic 而退出。我们可以看到我们违反了哪个本应阻止 panic 发生的约束条件。我们可以看到 panic 发生的位置。并且我们可以看到通过 source 可访问的错误链中每个错误的错误消息。

这至少是错误处理项目组希望看到的 Rust 版本中的理想结果,但我们实际得到的却是这样...

$ cargo run
thread 'main' panicked at 'config is always valid and exists: Error(SourceError)', main.rs:4:33
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

现在,我绝对不认为在将可恢复错误提升为不可恢复错误时,这是我们想要的默认行为!unwrapexpect 通过使用错误的 Debug 实现将其转换为字符串来工作,但这对于实现了 Error trait 的类型来说通常是错误的操作。通过将 Error 转换为 String,我们丢失了通过 Error trait 精心分割的上下文片段,而且我们的错误类型的 derive(Debug) 输出很可能甚至不包含我们 Display 实现中的错误消息。

Rust 的 panic 基础设施没有提供将 Error 类型转换为 panic 的方法,它只支持将 Debug 类型转换为 panic,我们认为这是一个主要问题。同样,语言也没有提供方便的工具来打印错误及其所有来源的错误消息。

fn main() {
    let result = load_config();
    let _config = match result {
        Ok(config) => config,
        Err(error) => {
            println!("Error: {}", error);
            return;
        }
    };
}

当我们运行这个程序时,我们希望看到看起来像这样的输出

$ cargo run
Error: invalid config: config file does not exist

这里我们可以看到我们提供的头部,表明我们正在打印一个错误,后面跟着由冒号分隔的来源链中的每个错误消息。

但相反,我们得到的只是这个

$ cargo run
Error: invalid config

默认情况下,所有来源的错误消息都丢失了。这源于我们将 Display 用作单个错误消息的接口。如果我们可以回头,我们目前会建议改为向 Error trait 添加 fn message(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result,但已经无法回头了。

目前库解决这个问题的方法是滥用 Debug trait。像 eyreanyhow,甚至有时是 custom error enums 这样的类型,使用它们的 Debug 输出以人类可读的报告形式打印完整的错误链。

这样做的好处是使得打印完整的错误报告变得容易,并使得 unwrapexpect 以及从 main 返回时都能打印完整的错误报告。但这样做也阻止了我们访问错误的派生 Debug 格式,可能会隐藏调试所需的内部细节,而这些细节并非用于用户阅读的错误消息的一部分。

明天的错误处理

最终,我们希望达到这样一个境地:在 Rust 中处理错误时默认使用的工具都能做正确的事情,并充分利用 Error trait 的设计。解开实现 Error trait 的类型时,会将原始错误保留为一个 dyn Error,然后在 panic 钩子中可用。打印完整的错误报告将很容易做到且显而易见。有了这些改动,有望在报告错误时很难意外丢失信息。

我们解决这些问题的计划分为两部分

1. Error Trait + Panic Runtime 集成

首先我们需要集成 Error trait 和 panic runtime,这样做的第一步是将 Error trait 移入 core。这是必要的,因为 panic runtime 是 core 和语言本身的一部分,而 Error trait 目前位于 std 中。我们对这一变化感到非常兴奋,希望它能带来其他积极的下游影响,尤其是在嵌入式生态系统中。

一旦我们达到 Error trait 可以在 core API 中使用的程度,下一步将是添加一个从 Error 类型创建 panic 的接口。我们目前计划添加一个 panic_error 函数,类似于 std 中已有的 panic_any 函数。这个函数将通过 dyn Error 使 panic 处理器能够访问错误。

一旦 panic 处理器能够处理 Error 类型,下一步将是更新 std 提供的默认 panic 钩子,以便如果以 Error trait 的形式暴露,则实际通过 Error trait 报告 panics。它应该遍历来源,如果错误本身捕获了回溯信息,则打印回溯信息,否则可能自行捕获一个。

最后,我们需要专门化 expectunwrap,以便在解开实现 Error trait 的类型时使用这些新的了解 Error 的 panic 接口。为此,我们首先需要解决 trait 实现专门化中与生命周期条件相关的健全性问题,不过谢天谢地,我们已经对如何解决这个问题有了很好的想法。

2. 错误报告器

我们还希望在 std 中提供一个基本的错误报告器,并提供一些设施使其易于使用,或易于替换为您自己偏好的错误报告器。打印错误及其来源是 Rust 中的一个基本操作,因此我们希望语言为报告提供一个成功陷阱(pit of success),让最容易做的事情就是正确的事情。我们无法完全实现这一点,因为我们使用 Display 作为单个错误消息的接口,并且无法以向后兼容的方式更改它,但我们希望添加一种方便的方法来打印完整的错误链以及一些巧妙的 lint 能够缓解大部分压力。

我们计划通过首先在标准库中添加一个 Report 类型来解决这个问题,该类型包装 &dyn Error 并实现 Display,使其按需打印每个来源。我们希望 Report 的 display 方法的输出能够支持 Rust 生态系统中最常见的错误连接风格。

或者每条错误消息用冒号连接在一行

println!("Error: {}", Report::from(error));

// Outputs:
// Error: outermost error: second error: root error

或者每条错误消息各占一行的多行格式

println!("Error: {:#}", Report::from(error))

// Outputs:
// Error: outermost error
//
// Caused by:
//    0: second error
//    1: root error

第一种单行格式对于日志输出或内联错误消息很有用,而另一种多行格式对于面向用户的输出(如 CLI 接口或 GUI 弹窗)很有用。

我们还希望向 error trait 添加一个方法,以便方便地将任何错误包装在 Report 类型中,从而使报告错误就像 println!(\"Error: {}\", error.report()); 一样简单。

我们预计 report 方法会是这样的

fn report(&self) -> impl Display + '_
where
    Self: Sized,
{
    Report::from(self)
}

我们希望这里的返回类型是泛型的,而不是硬编码为 Report,这样如果需要,单个错误类型可以提供自己的报告格式。我们预计 derive 宏可以利用这一点来自定义错误报告格式的默认值。这将与组合(composition)配合得很好,因为最外层类型的报告器将用于格式化完整的错误链。

目前我们无法按描述实现此方法,因为 trait 方法的返回类型不允许使用 impl Trait,但我们正在努力寻找一种向后兼容地将此添加到 error trait 的方法。

信息重复问题

有了这些修复,就可以轻松地链接错误并完全一致地报告它们。然而,在这个系统中,Error 实现者需要注意一个危险:信息重复。

想象一个像之前例子中的错误,但不同的是,每个错误除了打印自己的消息并通过 source 返回下一个错误外,它们还在自己的错误消息后面包含其来源的错误消息。这样,当我们打印外部错误的 Display 输出时,我们会看到所有的错误消息,而不仅仅是链中的第一个。

println!("Error: {}", error);

// Outputs:
// Error: outermost error: second error: root error

现在,当我们使用 Report 打印同一错误类型,并期望需要遍历来源并打印它们时,会发生什么呢?

println!("Error: {:#}", error.report());

// Outputs
// Error: outermost error: second error: root error
//
// Caused by:
//    0: second error: root error
//    1: root error

来源错误消息重复了!使用 anyhoweyre 的多行输出,我们会在错误报告中得到一个漂亮的小三角形形状,如果您之前使用过这些库,可能已经遇到过。我们无法再区分错误链中各个错误的错误消息,因为这种错误类型手动连接来源并通过 source 函数返回它们。这也限制了我们如何格式化错误报告。如果我们想要一致的报告格式,并且有一个依赖库以单行连接错误,那么我们也被迫在整个应用程序中这样做。另一方面,如果我们有两个依赖库以不同的方式连接错误,那我们就束手无策了。

那么我们如何避免这种情况呢?我们对 Displaysource 的实现采取一致的分离原则。

实现 Display::fmtError::source 的准则

为了解决这个问题,项目错误处理最近创建了一个关于如何实现 Display::fmtError::source 的准则。其中我们提出了以下建议

带有来源错误的错误类型,应该通过 source 返回该错误,或者在其自己的 Display 输出中包含该来源的错误消息,但绝不能两者都做。

我们认为默认方式将是通过 source 返回错误。这样,在适当的时候可以通过 downcast 对来源错误作出反应。这对于正在更改现有公共错误类型的库尤为重要。对于这些库,从 source 中移除错误是一个在编译时检测不到的破坏性更改,可能导致仅进行主版本升级不足以解决问题。更改 Display 输出也是一个破坏性更改,尽管危险性较低。为了帮助解决这个问题,我们起草了一份建议的迁移计划:rust-lang/project-error-handling#44

在提出这项建议时,我们必须弄清楚 Error trait 在 Rust 中的主要作用是什么。与库团队讨论后,我们得出结论,在设计错误类型时,报告应被视为主要作用,而通过 downcast 作出反应应排在次要位置。通常这些需求并不冲突,但也可能出现问题。例如,在使用透明错误类型时,这些类型将所有方法转发给内部错误类型。当这些类型遵循此准则时,内部错误类型会被跳过,并且永远不能用于 downcast

此建议仅适用于作为库 API 的一部分公开的错误类型。库或应用程序中的内部错误可以随意处理,但一旦它们需要由第三方用户集成到其他 crate 中,错误遵循一致的风格就非常重要。如果您对我们的理由感兴趣或有任何意见,请查看我们在 GitHub 上关于此主题的 issue:rust-lang/project-error-handling#27

结论

我们希望这些更改将显著改善 Rust 提供的错误处理体验。错误报告将更加一致和灵活,并允许最终的应用程序开发者根据其特定用例定义错误报告的格式。在报告错误时,意外丢失信息的难度会大大增加。错误报告工具将更紧密地集成到标准库和语言本身中,我们希望这将通过更普遍地标准化 Error trait 为嵌入式生态系统带来额外的好处。

这就是目前的计划,它并非包含我们所有想进行的更改的完整计划,但我们认为这是最好的第一步。然而,这绝非板上钉钉,我们有兴趣听取社区其他成员的反馈,以便改进我们的设计。因此,如果您有想法,请告诉我们,我们的项目组仓库是 https://github.com/rust-lang/project-error-handling。请随时开启一个 issue 或进入我们的 zulip 讨论流 并创建一个新话题,告诉我们您对此计划的看法。