第四章 错误处理

除了最简单的程序外, 方法都有可能会调用失败. 在本章中, 我们将探讨表述、处理和传播这些失败的不同方法, 以及它们各自的优缺点. 我们将从不同的错误表述方式开始, 包括枚举和擦除, 然后研究一些需要特殊表述技术的错误情况. 接下来, 我们将探讨处理错误的各种方式, 以及错误处理机制在未来的发展趋势.

值得注意的是, Rust中错误处理的最佳实践仍然是一个活跃的话题, 在撰写本文的时候, Rust生态系统还没有确定一种统一的处理方法.因此, 本章将重点讨论底层原理和技术手段, 而不是推荐特定的cratepatterns.

表述错误

在编写可能失败的代码时, 最重要的问题是用户将如何与这些错误交互. 用户是否需要确切地知道发生了什么错误, 以及错误的细节, 还是只需要记录错误发生了, 然后继续执行? 要了解这一点, 我们需要研究错误的性质是否影响到调用者在收到错误后的行为. 这将决定我们该如何表述不同的错误类型.

表述错误有两种主要的方式: 枚举和擦除. 也就是说, 你可以让你的错误类型枚举可能的错误情况, 以便调用者能够区分它们, 或者你也可以只向调用者提供一个单一的、不透明的错误. 让我们依次讨论这两种方式.

枚举

在这个示例中, 我们将使用一个库函数, 将字节从某个输入流复制到某个输出流中, 很和std::io::copy的行为很相似. 用户会提供两个流, 一个用于读, 一个用于写, 然后你将字节从一个流复制到另一个流. 在这个过程中, 任何一个流都有可能错误, 这时复制必须停止, 并向用户返回错误. 在这种情况下, 用户很可能希户知道是输入流还是输出流出现了问题. 例如, 在一个web服务中, 如果在向客户端传输文件的过程中, 输入流发生了错误, 可能是因为磁盘被弹出, 而如果输出流发生错误, 那有可能是客户端断开了连接. 后者可能是服务器可以忽略的错误, 因为对新的连接仍然可以完成文件复制, 而前者可能需要关闭整个服务器.

这例子中我们需要枚举错误. 用户需要能够区分不同的错误情况, 以便做出恰当的响应, 所以我们使用一个名为CopyError的枚举类型, 每个变体代表一个错误的根本原因, 就像清单5-1中那样.

#![allow(unused)]
fn main() {
pub enum CopyError {
    In(std::io::Error),
    Out(std::io::Error)
}

// 清单 5-1: 枚举错误类型
}

每个变量还包括实际遇到的错误, 以便向调用者提供尽可能多的出错信息.

当你创建自定义错误类型时, 需要采取一些步骤, 使该错误类型能够很好地融入Rust的生态系统中. 首先, 你的错误类型应该实现std::error::Error trait, 它为调用者提供了内部错误类型的常用方法. 其中主要的方法是Error::source, 它提供了一种机制来查找错误的根本原因. 最常用于打印错误回溯信息, 显示错误从根源一路传播的过程. 对于我们的CopyError类型, source的实现非常简单: 我们在self上进行匹配并提取并返回内部的std::io::Error.

其中, 你的类型应该同时实现DisplayDebug, 以便调用者可以有意义地打印错误信息. 如果你实现了Error trait, 这是必须的. 一般来说, Display的实现应提供出错原因的简洁描述, 并能很容易地嵌入到更大的错误消息中. 显示的格式应使用小写字母, 并且末尾不加标点符号, 以便在更长的错误信息自然嵌入. Debug应该提供更详细的错误描述, 包括可能有助于追踪错误原因的附加信息, 如端口号、请求ID、文件路径等, 通常使用#[derive(Debug)]足以满足这些要求.

NOTE: 在旧版本的Rust代码中, 你可能会看到对Error::description方法, 但该方法已被弃用, 改用 Display.

第三, 如果可能的话, 你的类型应该同时实现SendSync, 这样用户才能在跨线程时共享错误. 如果你的错误类型不是线程安全的, 那么在多线程环境中无法使用你的crate. 实现SendSync的错误类型也更容易与常见的std::io::Error错误类型配合使用, 后者实现ErrorSendSync. 当然, 并不是所有的错误类型都合理地实现SendSync, 比如它们可能依赖于特定的线程本地资源, 这种情况下不支持也是可以接受的. 毕竟你很可能不会跨越线程发送这些错误. 不过, 在错误中使用 Rc<String>RefCell<bool>类型之前, 还是要注意这一点.

最后, 如果可能的话, 你的错误类型应该是'static生命周期的. 这样做最直接的好处是, 调用者更容易地在调用堆栈中传播你的错误, 而不会遇到生命周期的问题. 此外, 这还使得你的错误类型更容易与类型擦除的错误类型一起使用, 我们很快就会看到这一点

不透明的错误

现在让我们考虑另一个例子: 一个图像解码库. 你将一堆字节传给这个库进行解码, 它就会为你提供各种图像处理方法. 如果解码失败, 用户需要知道如何解决问题, 因此必须了解其原因. 重要是引起的原因, 是图像标头中的大小字段无效, 还是压缩算法中解压块问题, 这很重要吗? 也许不重要, 即使应用程序知道确切的原因, 也无法从这两种情况下进行有意义地恢复. 在这样的情况下, 作为库的作者, 你可能更希望提供一种单一的、不透明的错误类型. 这也会使你的库更容易使用, 因为整个库中只使用一个错误类型. 这个错误类型应该实现SendDebugDisplayError(在合适的情况下也包括source方法), 除此之外, 调用者无需了解更多的细节. 你可以在库内部表述更细致的错误状态, 但没有必要将这些暴露给使用者. 这样做减少你的API的体积和复杂性.

不透明的错误类型到底应该是什么, 主要取决于你. 它可以是一个具有所有私有字段的类型, 只对外公开有限的方法来显示和检查错误的方法, 也可能是一个类型擦除的错误类型, 如Box<dyn Error + Send + Sync + 'static>, 它只表明这是一个错误之外, 不会向使用者透露任何信息, 也通常不允许用户进行深入查看. 决定错误类型透明的程度, 主要取决于这个错误除了描述之外是否还有值得用户了解的内容. 如果使用Box<dyn Error>, 用户别无选择, 只能把你的错误向上传播. 如果该错误确实没有任何有价值的信息提供给用户(例如, 如果它只是一个动态的错误信息, 或者是来自你程序深处的某些完全无关错误之一), 这样做也许没什么问题. 但如果这个错误有一些有意义的细节, 例如行号或状态代码, 你可能会想通过一个具体但不透明的类型来暴露它.

NOTE: 一般来说, 社区的共识是错误应该是罕见的, 因此不应该增加太多"正常路径"的成本. 因此, 错误通常被放置在一个指针类型的后面, 比如BoxArc. 这样一来, 错误就不太可能增加所包含的整个Result类型的大小.

使用类型消除的错误的一个好处是, 它可以让你轻松地将不同来源的错误合并, 而无需引入额外的错误类型. 也就是说, 基于类型的错误通常可以很好地组合, 并允许你表达一个开放式的错误集. 如果你编写了一个返回类型为Box<dyn Error + ...>的函数, 那么你可以在该函数内部使用?来处理不同的错误类型, 它们都会被转化为那个共同的错误类型.

Box<dyn Error + Send + Sync + 'static>上的'static约束值得多花一点时间来研究. 我在上一节中提到, 它可以让调用者传播错误, 而不用担心失败的方法的生存期约束, 但它有一个更大的目的: 提供访问向下转型. 向下转型是指将一种类型的项转换为一种更具体的类型的过程. 这是少数几个Rust在运行时能让你访问类型信息的情况之一; 它是动态语言经常提供的更通用的类型反射的一个有限案例. 在错误的上下文中, 当dyn Error原本是一个具体的底层错误类型时, 向下转换允许用户将该错误转为该类型. 例如, 如果用户收到的错误是std::io::Error的类型std::io::ErrorKind::WouldBlock, 用户可能想采取一个特定的操作, 但在其他情况下他们不会采取相同的操作. 如果用户得到一个dyn Error, 他们可以使用Error::downcast_ref来尝试将这个错误向下转换到std::io::Error. downcast_ref方法返回一个Option, 它告诉用户向下转换是否成功. 这里有一个关键的观察点: downcast_ref只有在参数是'static 时才起作用. 如果我们返回一个不透明的、非'staticError, 用户就无法按自己的意愿对错误进行检查.

在生态系统中, 对于库的类型擦的错误(或者更广泛地说, 它的类型擦除类型)是否属于公共且稳定API的一部分, 这里存在一些争议. 也就是说, 如果你的库中的方法foolib::MyError作为Box<dyn Error>返回, 将foo改为返回不同的错误类型是否是一种破坏性的变更? 类型签名并没有改变, 但是用户可能写了一些代码, 认为他们可以使用向下转型来把这个错误转回lib::MyError. 我对此事的看法是, 你选择返回Box<dyn Error>(而不是 lib::MyError)是有原因的, 除非有明确的文档说明, 否则这并不能保证向下转换有特别之处.

注意: 虽然Box<dyn Error + ...>是一个有吸引力的类型擦除的错误类型, 但它本身并没有实现Error, 这与直觉相反. 因此, 请考虑添加自己的BoxError类型, 以实现Error的库中来进行类型擦除。

你可能想知道Error::downcast_ref是如何做到安全. 也就是说, 它如何知道提供的dyn Error参数是否确实属于给定的类型T? 标准库中甚至有一个名为Anytrait, 它是为任何类型实现的, 也可以为dyn Any实现了downcast_ref, 这怎么能行? 答案在于编译器支持的类型std::any::TypeId, 它允许你为任何类型获得一个唯一的标识符.Error trait 有一个隐藏提供的方法, 叫做type_id, 它的默认实现是返回TypeId::of::<Self>(). 类似地, Any有一个对Timpl Any的通用实现, 在该实现中, 其type_id返回相同的内容. 在这些impl块的上下文中, Self的具体类型是已知的, 所以这个type_id 是真实类型的类型标识符. downcast_ref调用self.type_id, 它通过动态大小类型的vtable(见第3章)向下转到底层类型的实现, 并将其与提供的downcast类型的类型标识符进行比较. 如果它们匹配, 那么dyn Errordyn Any背后的类型就真的是T, 并且从一个类型的引用到另一个类型的引用是安全的.

特殊错误案例

有些函数是易错的, 但一旦失败也不能返回任何有意义的错误. 从概念上讲, 这些函数的返回类型是Result<T, ()>. 在一些代码库中, 你可能会看到它被表述为 Option<T>. 虽然这两个函数的返回类型都是合法的选择, 但它们表达了不同的语义, 你通常应该避免将Result<T, ()>"简化"为Option<T>. Err(())表述一个操作失败了, 应该重试、报告或以其他特殊处理. 另一方面, None只表达了函数没有任何内容; 它通常不被认为是一个特殊情况或应该被处理的东西. 你可以在Result类型的#[must_use]注解中看到这一点--当你得到一个Result时, 语言认为处理这两种情况是很重要的, 而对于一个Option, 两种情况实际上都不需要处理.

NOTE: 你还应该记住, ()并没有实现 Error 特性. 这意味着它不能被类型化为Box<dyn Error>, 并且在使用时?可能会有点麻烦. 出于这个原因, 在这些情况下, 定义你自己的单元结构类型, 为其实现 Error, 并将其作为错误, 而不是().

有些函数, 比如那些启动持续运行的服务器循环的函数, 只返回错误; 除非发生错误, 否则它们永远运行. 其他函数永远不会出错, 但仍然需要返回Result, 例如, 为了匹配trait签名. 对于这样的函数, Rust提供了never类型, 用!的语法编写. never类型表述一个永远无法生成的值. 你不能自己构造一个这个类型的实例, 唯一的方法是进入一个无限循环或panic, 或者通过其他编译器知道永远不会返回的特殊操作. 对于Result, 当你有一个你知道OkErr永远不会被使用时, 你可以把它设置为!类型. 如果你写了一个返回Result<T, !>的函数, 你将永远无法返回Err, 因为唯一的返回方式是进入永远不会返回的代码. 因为编译器知道任何带有!的变体都不会被产生, 它还可以在此基础上优化你的代码, 比如不生成Result<T, !>unwrappanic代码. 而当你进行模式匹配时, 编译器知道任何包含!的变量甚至不需要被列出. 挺酷的!

最后一个奇怪的错误情况是错误类型std::thread::Result. 这里是它的定义.

#![allow(unused)]
fn main() {
type Result<T> = Result<T, Box<dyn Any + Send + 'static>>;
}

错误类型是类型擦除的, 但它并没有像我们之前看到的那样被擦除为dyn Error. 取而代之的是dyn Any, 它只保证错误是某种类型, 仅此而已...这几乎没有什么保证. 出现这种奇怪的错误类型的原因是std::thread::Result的错误变量只有在panic时才会产生; 具体来说, 如果你试图加入一个已经panic的线程. 在这种情况下, 加入的线程除了忽略错误或使用unwrap使自己panic外, 还能做什么呢? 从本质上讲, 错误类型是"一个panic", 其值是传递给 panic! 的任何参数, 它确实可以是任何类型(尽管它通常是一个格式化的字符串).

传播错误

Rust? 操作符是unwrap或提前返回的简写, 用于轻松处理错误. 但它还有一些其他的技巧值得了解. 首先, ?通过From trait执行类型转换. 在一个返回Result<T, E>的函数中, 你可以在任何Result<T, X>上使用? , 其中E: From<X>. 这就是通过Box<dyn Error>擦除错误的特点; 你可以在任何地方使用?而不用担心特定的错误类型, 而且通常会"正常工作".

FROMINTO

标准库有许多转换特性, 但其中两个核心特性是FromInto. 你可能会觉得有两个很奇怪: 如果我们有From, 为什么还需要Into, 反之亦然? 有几个原因, 但让我们从历史原因开始: 在 Rust的早期, 由于第三章中讨论的一致性规则, 不可能只有一个. 或者, 更确切地说, 曾经的一致性规则是什么.

假设你想在你的crate中定义的某个本地类型和标准库中的某个类型之间实现双向转换. 你可以很容易的写impl<T> From<Vec<T>> for MyType<T>impl<T> Into<Vec<T>> for MyType<T>, 但是如果你只有FromInto, 你必须写impl<T> From<MyType<T>> for Vec<T>impl<T> Into<MyType<T>> for Vec<T>.然而, 编译器曾经拒绝了这些实现! 只有从Rust1.41.0开始, 当覆盖类型的例外被添加到一致性规则中时, 它们才合法.在那之前, 有必要同时拥有这两个特性. 由于很多 Rust代码是在Rust1.41.0 之前写的, 所以现在这两个特征都不能被删除.

然而, 除了这一历史事实之外, 即使我们今天可以从头开始, 也有很好的人体工程学理由来拥有这两个特征. 在不同的情况下, 使用其中一个或另一个通常会容易得多. 例如, 如果你正在写一个方法, 该方法接收一个可以变成 Foo 的类型, 你是想写fn(impl Into<Foo>) 还是 fn<T>(T) where Foo: From<T>? 反过来说, 要把一个字符串变成一个语法标识符, 你是愿意写 Ident::from("foo") 还是 <_ as Into<Ident>>::into("foo")? 这两个特性都有其用途, 我们最好同时拥有它们.

鉴于我们确实有这两种东西, 你可能想知道今天应该在代码中使用哪一个. 答案是非常简单的: 实现From, 并在约束使用Into. 原因是Into对任何实现了 FromT都有一个通用实现, 所以不管一个类型是明确地实现了From还是 Into, 它都实现了Into!

当然, 正如简单的事情一样, 故事并没有就此结束. 因为当Into被用作绑定时, 编译器经常要"通过"通用实现, 所以推断一个类型是否实现了Into的推断比它是否实现了From更复杂. 而且在某些情况下, 编译器还没有聪明到可以解决这个难题. 由于这个原因, 在编写本文时, ? 操作符使用From, 而不是Into. 大多数时候, 这并没有什么区别, 因为大多数类型都实现了From, 但这也意味着旧库中实现Into的错误类型可能无法与?运算, 随着编译器越来越聪明, ?可能会被"升级"为了能使用Into, 到那时这个问题就会消失, 但这也是我们目前所面临的问题.

第二个方面需要注意是, ?这个操作符实际上只是一个称为Try的特性的语法糖. 在写这篇文章的时候, Try特性还没有稳定下来, 但是当你读到这篇文章的时候, 它或者类似的东西很可能已经被确定下来. 由于细节还没有全部弄清楚, 我将只给你一个Try工作原理的大纲, 而不是完整的方法特征.在其核心部分, Try定义了一个封装类型, 其状态要么是进一步计算是有用的(快乐路径), 要么是无用的. 你们中的一些人会正确地想到单体(monads), 尽管我们不会在这里探讨这种联系. 例如, 在Result<T, E>的情况下, 如果你有一个Ok(t), 你可以通过解开t来继续在快乐的路径上运行. 另一方面, 如果你有一个Err(e), 你想立即停止执行并产生错误值, 因为你没有t, 所以不可能进一步计算.

Try 的有趣之处, 它不仅适用于Result类型, 还适用于更多的类型. 例如, Option<T>遵循同样的模式--如果你有一个Some(t), 你可以在快乐路径上继续下去, 而如果你有一个None, 就会产生None而不是继续. 这种模式延伸到了更复杂的类型, 比如Poll<Result<T, E>>, 它的快乐路径类型是 Poll<T>, 这使得?适用的情况远比你想象的多. 当Try稳定下来后, 我们可能会看到?开始与各种类型一起工作, 使我们的快乐路径代码更漂亮.

?操作符已经可以在易错函数、doctestsfn main中使用了. 不过, 为了充分发挥它的潜力, 我们还需要一种方法来对这种错误进行范围处理. 例如, 考虑清单5-2中的函数.

#![allow(unused)]
fn main() {
fn do_the_thing() -> Result<(), Error> {
    let thing = Thing::setup()?;
    // .. code that uses thing and ? ..
    thing.cleanup();
    Ok(())
}

// 清单 5-2: 使用"? "运算符的多步骤易错函数.
}

这并不完全符合预期. 在setupcleanup之间的任何问题都会导致整个函数的提前返回, 从而跳过cleanup的代码! 这就是try块要解决的问题. 一个尝试块的行为就像一个单次迭代的循环, 其中?使用break而不是return, 并且该块的最后表达式有一个隐含的break. 我们现在可以修改清单5-2中的代码, 使其总是执行清理, 如清单5-3所示

#![allow(unused)]
fn main() {
fn do_the_thing() -> Result<(), Error> {
    let thing = Thing::setup()?;
    let r = try {
    // .. code that uses thing and ? ..
    };
    thing.cleanup();
    r
}

// 清单 5-3: 一个多步骤的易错函数, 总是自己清理.
}

在写这篇文章时, try代码块也不稳定, 但对其有用性有足够的共识, 它们很可能以类似于这里描述的形式出现.

总结

本章介绍了在Rust中构造错误类型的两种主要方法: 枚举和擦除. 我们研究了在何时使用哪一种方式, 以及它们各自的优缺点. 我们还了解了一些?操作符的背后的机制, 并考虑了?如何在未来变得更加实用. 在下一章中, 我们将从代码中抽身出来, 看看你是如何组织一个Rust项目的. 我们将研究特性标志、依赖管理和版本管理, 以及如何使用工作区和子包管理更复杂的crate. 下一页见!