错误处理项目组的目标

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 作为单个错误消息的接口。如果我们能回到过去,我们目前建议改为将 fn message(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result 添加到 Error trait,但事已至此。

库今天解决这个问题的方式是滥用 Debug trait。像 eyreanyhow,甚至有时是 自定义错误枚举 等类型使用它们的 Debug 输出,以人类可读的报告形式打印完整的错误链。

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

未来的错误处理

最终,我们希望达到这样的目标:当在 Rust 中处理错误时,您所使用的默认工具都能做正确的事,并充分利用 Error trait 的设计。解包实现 Error trait 的类型会将原始错误保留为 dyn Error,该错误在 panic hook 中可用。打印完整的错误报告将很容易做到,并且显而易见。有了这些改变,希望在报告错误时意外丢弃信息会变得相当困难。

我们解决这些问题的计划是双重的

1. 错误 Trait + Panic 运行时集成

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

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

一旦 panic 处理程序能够处理 Error 类型,下一步将是更新 std 提供的默认 panic hook,以便在错误以这种方式暴露时,实际上通过 Error trait 报告 panic。它应该迭代来源,并打印错误本身捕获的回溯(如果可用),或者在其他情况下可能捕获一个回溯。

最后,我们需要专门化 expectunwrap,以便在解包实现 Error trait 的类型时,使用这些新的 Error 感知 panic 接口。为此,我们首先需要解决一个基于生命周期有条件的 trait 实现的特化健全性问题,但值得庆幸的是,我们已经对如何解决这个问题有很好的想法。

2. 错误报告器

我们还希望在 std 中提供一个基本的错误报告器,以及一些使其易于使用或易于替换为您自己喜欢的错误报告器的工具。打印错误及其来源是 Rust 中的一项基本操作,因此我们希望该语言为报告提供一个成功的坑,在那里最容易做的事情是正确的事情。我们无法完全实现这一点,因为我们使用 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,以便各个错误类型可以根据需要提供自己的报告格式。我们预计派生宏可能会利用这一点来定制错误报告格式的默认值。这将与组合很好地配合,因为来自最外层类型的报告器将用于格式化完整的错误链。

目前,我们无法按描述实现此方法,因为在 trait 方法的返回类型中不允许使用 impl Trait,但我们正在努力寻找一种方法,以向后兼容的方式将其添加到错误 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 上关于该主题的问题:rust-lang/project-error-handling#27

结论

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

这就是目前的计划,这不是我们想要做的所有更改的完整计划,但我们认为这是最好的第一步。但是,这绝不是一成不变的,我们有兴趣获得社区其他成员的反馈,以便我们可以改进我们的设计。因此,如果您有想法,请告诉我们,我们的项目组存储库是 https://github.com/rust-lang/project-error-handling。请随时打开一个 issue 或加入我们的zulip 流并创建一个新主题,让我们知道您对这个计划的看法。