The Missing Piece in Rust Error Handling

本文为原文前 6,000 字符的节选翻译,完整内容请查看原文。

Rust already has most of what I want from error handling: explicit control flow, errors as values, and concise propagation with ?. The friction comes when deciding what to put in the error half of Result. We often end up choosing between precise types that require boilerplate and convenient types that hide which errors can occur. But precision and convenience do not have to be competing goals. Error types should compose as easily as the functions that return them.

Rust 在错误处理方面已经具备了我想要的大部分功能:显式的控制流、作为值的错误,以及使用 ? 进行简洁的传播。矛盾在于决定在 Result 的错误部分放入什么。我们往往需要在需要大量样板代码的精确类型和隐藏了可能发生错误的便捷类型之间做出选择。但精确性和便捷性不必是相互冲突的目标。错误类型应该像返回它们的函数一样易于组合。

The Problem With Rust Error Handling: Consider reading a server port from a file. Reading can fail with an io::Error, and parsing can fail with a ParseIntError. A conventional implementation might look like this: use std::{io, num::ParseIntError}; #[derive(Debug, thiserror::Error)] pub enum PortError { #[error(transparent)] Io(#[from] io::Error), #[error(transparent)] Parse(#[from] ParseIntError), } fn load_port(path: &str) -> Result<u16, PortError> { let contents = std::fs::read_to_string(path)?; Ok(contents.trim().parse()?) }

Rust 错误处理的问题:考虑从文件中读取服务器端口。读取可能会因 io::Error 而失败,解析可能会因 ParseIntError 而失败。传统的实现可能如下所示:use std::{io, num::ParseIntError}; #[derive(Debug, thiserror::Error)] pub enum PortError { #[error(transparent)] Io(#[from] io::Error), #[error(transparent)] Parse(#[from] ParseIntError), } fn load_port(path: &str) -> Result<u16, PortError> { let contents = std::fs::read_to_string(path)?; Ok(contents.trim().parse()?) }

thiserror removes the manual Display, Error, and From implementations. But we still have to decide how this enum relates to every other error enum in our program. Now load a host address, bind a socket, and initialize a database. Each operation has its own errors. We can wrap those enums in another enum, flatten their variants into a new enum, or give everything one large crate-wide error type. The first approach creates nesting, the second creates conversions, and the third means functions advertise errors they cannot actually return.

thiserror 移除了手动的 Display、Error 和 From 实现。但我们仍然必须决定这个枚举如何与程序中的其他错误枚举相关联。现在加载主机地址、绑定套接字并初始化数据库。每个操作都有其自己的错误。我们可以将这些枚举包装在另一个枚举中,将它们的变体扁平化为一个新枚举,或者为所有内容提供一个大型的、全 crate 范围的错误类型。第一种方法会产生嵌套,第二种方法会产生转换,而第三种方法意味着函数会声明它们实际上无法返回的错误。

An I/O error may also end up in several different nested variants, making handling it at a higher level unnecessarily awkward. Alternatively, an anyhow like approach makes propagation and attaching context straightforward. We can downcast when we need to inspect a concrete error. However, the function signature no longer tells us which error types are possible, and the compiler cannot track whether we have handled all of them. The usual advice is to use typed errors in libraries and opaque errors in applications. But applications need typed recovery too, and libraries often contain internal operations whose callers only need to propagate a failure. The useful distinction is whether a caller needs to do something different based on the error type.

I/O 错误也可能最终出现在几个不同的嵌套变体中,使得在更高级别处理它变得不必要地笨拙。或者,像 anyhow 这样的方法使传播和附加上下文变得简单直接。当我们需要检查具体错误时,可以进行向下转型。然而,函数签名不再告诉我们哪些错误类型是可能的,编译器也无法跟踪我们是否处理了所有这些错误。通常的建议是在库中使用类型化错误,在应用程序中使用不透明错误。但应用程序也需要类型化恢复,而库通常包含内部操作,其调用者只需要传播失败。有用的区别在于调用者是否需要根据错误类型采取不同的操作。

Error Types Should Compose: What we actually want to say is simple: this function can fail with an io::Error or a ParseIntError. Declaring an enum is one way to express that, but the combination itself should not need a new type declaration. I use eros to express this as an error set. The port example becomes: use eros::IntoUnion; use std::{io, num::ParseIntError}; fn load_port(path: &str) -> eros::Result<u16, (io::Error, ParseIntError)> { let contents = std::fs::read_to_string(path).union()?; contents.trim().parse().union() }

错误类型应该组合:我们真正想表达的内容很简单:此函数可能会因 io::Error 或 ParseIntError 而失败。声明一个枚举是表达这一点的一种方式,但组合本身不应该需要新的类型声明。我使用 eros 将其表达为一个错误集。端口示例变为:use eros::IntoUnion; use std::{io, num::ParseIntError}; fn load_port(path: &str) -> eros::Result<u16, (io::Error, ParseIntError)> { let contents = std::fs::read_to_string(path).union()?; contents.trim().parse().union() }

No new enum or conversions need to be declared. For reuse, the set can be named with a normal type alias: type PortErrors = (std::io::Error, std::num::ParseIntError); eros::Result<T, E> is an alias for the ordinary Result<T, ErrorUnion>. Here, ErrorUnion<(io::Error, ParseIntError)> holds one of the listed errors. The tuple describes the possible types; it does not store both errors. This is an open sum type: we describe the combination we need without declaring a new named enum for that combination.

无需声明新的枚举或转换。为了重用,可以使用普通的类型别名来命名该集合:type PortErrors = (std::io::Error, std::num::ParseIntError);。eros::Result<T, E> 是普通 Result<T, ErrorUnion<E>> 的别名。在这里,ErrorUnion<(io::Error, ParseIntError)> 保存了所列错误之一。元组描述了可能的类型;它不会同时存储两个错误。这是一种开放和类型(open sum type):我们描述了我们需要的组合,而无需为该组合声明一个新的命名枚举。

.union() wraps an ordinary result’s error in an ErrorUnion, inferring the destination set from the surrounding code. If we remove io::Error from this signature, the file read no longer compiles. We cannot accidentally propagate an error that the signature does not include. This becomes more useful when functions are combined. Suppose we also load the server’s host address. Building on load_port: use eros::ReshapeUnion; use std::net::{AddrParseError, IpAddr, TcpListener}; fn load_host(path: &str) -> eros::Result<IpAddr, (io::Error, AddrParseError)> { let contents = std::fs::read_to_string(path).union()?; contents.trim().parse().union() }

.union() 将普通结果的错误包装在 ErrorUnion 中,并从周围的代码中推断目标集合。如果我们从这个签名中删除 io::Error,文件读取将无法编译。我们无法意外传播签名中未包含的错误。当函数组合在一起时,这变得更有用。假设我们还要加载服务器的主机地址。在 load_port 的基础上:use eros::ReshapeUnion; use std::net::{AddrParseError, IpAddr, TcpListener}; fn load_host(path: &str) -> eros::Result<IpAddr, (io::Error, AddrParseError)> { let contents = std::fs::read_to_string(path).union()?; contents.trim().parse().union() }

fn bind_server() -> eros::Result<TcpListener, (io::Error, AddrParseError, ParseIntError)> { let host = load_host(“config/host.txt”).widen()?; let port = load_port(“config/port.txt”).widen()?; TcpListener::bind((host, port)).union() } .widen() converts an existing union into a union whose set contains all its possible errors. Both configuration operations can return an io::Error, so we list it once. Context can describe which operation failed. Widening into a set that omits a possible error is rejected at compile time. The caller describes the combined possibilities without wrapping each function’s errors in another layer of enums. Adding another operation means adding its possible errors to the set, and the compiler checks that we have accounted for them.

fn bind_server() -> eros::Result<TcpListener, (io::Error, AddrParseError, ParseIntError)> { let host = load_host("config/host.txt").widen()?; let port = load_port("config/port.txt").widen()?; TcpListener::bind((host, port)).union() }。.widen() 将现有的联合转换为一个集合包含其所有可能错误的联合。两个配置操作都可以返回 io::Error,所以我们只列出一次。上下文可以描述哪个操作失败了。如果扩展到一个省略了可能错误的集合,会在编译时被拒绝。调用者描述了组合的可能性,而无需将每个函数的错误包装在另一层枚举中。添加另一个操作意味着将其可能的错误添加到集合中,编译器会检查我们是否已经考虑了它们。

Handling Errors Changes The Type: Declaring precise errors becomes much more useful when handling an error removes it from the set. For example, suppose our policy is to use port 8080 whenever reading the port file fails, while still rejecting malformed contents: fn port_or_default(path: &str) -> eros::Result<u16, (ParseIntError,)> { load_port(path).recover::<io::Error, _>(|error| { eprintln!(“{error:?}; using port 8080”); 8080 }) }

处理错误会改变类型:当处理错误将其从集合中移除时,声明精确的错误会变得更有用。例如,假设我们的策略是每当读取端口文件失败时使用端口 8080,同时仍然拒绝格式错误的内容:fn port_or_default(path: &str) -> eros::Result<u16, (ParseIntError,)> { load_port(path).recover::<io::Error, _>(|error| { eprintln!("{error:?}; using port 8080"); 8080 }) }

The return type now contains only ParseIntError. recover handles the selected error type and turns the handler’s value into a success. Other errors pass through unchanged. This is the part I find most useful. The signature describes what can still go wrong after our recovery policy has run. A caller does not need to know that an I/O error was possible somewhere below it, because that error has already been handled. We can also recover a group of error types. If both unreadable files and invalid numbers should use a default, all possible errors can be removed: fn forgiving_port(path: &str) -> u16 { load_port(path) .recover::<(io::Error, ParseIntError), >(|| 8080) .into_value() } After recovery, the result has the empty error set (). .into_value() ex

返回类型现在仅包含 ParseIntError。recover 处理选定的错误类型,并将处理程序的值转换为成功。其他错误保持不变。这是我发现最有用的部分。签名描述了在我们的恢复策略运行后,还有什么可能会出错。调用者不需要知道其下方的某个地方可能发生 I/O 错误,因为该错误已经被处理了。我们也可以恢复一组错误类型。如果不可读的文件和无效的数字都应该使用默认值,则可以移除所有可能的错误:fn forgiving_port(path: &str) -> u16 { load_port(path) .recover::<(io::Error, ParseIntError), _>(|_| 8080) .into_value() }。恢复后,结果具有空的错误集 ()。.into_value() ex