第四章 错误处理
除了最简单的程序外,你都会有可能失败的方法。 在本章中,我们将研究表示、处理和传播这些失败的不同方法,以及每种方法的优点和缺点。我们将首先探讨表示错误的不同方法,包括枚举和擦除,然后研究一些需要不同表示技术的特殊错误情况。接下来,我们将研究处理错误的各种方法和错误处理的未来。
值得注意的是,Rust 中错误处理的最佳实践仍然是一个活跃的话题,在写这篇文章的时候,这个生态系统还没有确定一个统一的方法。因此,本章将重点讨论基本原则和技术,而不是推荐具体的包或模式。
代表错误(Representing Errors)
当你编写可能失败的代码时,最重要的问题是你的用户将如何与任何返回的错误互动。用户是否需要确切地知道哪个错误发生了,以及关于什么地方出错的细节,还是他们只需要记录一个错误的发生,然后尽可能地继续前进?为了理解这一点,我们必须看一下错误的性质是否可能影响到调用者在收到错误后的行为。这反过来又决定了我们如何表示不同的错误。
你有两个主要的选择来表示错误:枚举和清除。也就是说,你可以让你的错误类型枚举可能的错误条件,以便调用者能够区分它们,或者你可以只向调用者提供一个单一的、不透明的错误。让我们依次讨论这两种选择。
枚举
在我们的例子中,我们将使用一个库函数,将字节从某个输入流复制到某个输出流中,很像 std::io::copy。 用户为你提供了两个流,一个要读,一个要写,你将字节从一个复制到另一个。在这个过程中,任何一个流都有可能失败,这时拷贝必须停止,并向用户返回一个错误。在这里,用户可能想知道是输入流还是输出流失败。例如,在一个网络服务器中,如果输入流在向客户端传输文件时发生错误,可能是因为磁盘被弹出,而如果输出流发生错误,可能是客户端刚刚断开连接。后者可能是一个服务器应该忽略的错误,因为复制到新的连接仍然可以完成,而前者可能需要关闭整个服务器。
这是一个我们想要列举错误的情况。用户需要能够区分不同的错误情况,以便他们能够做出适当的反应,所以我们使用一个名为 CopyError 的枚举,每个变量代表一个单独的错误的根本原因,就像清单 4-1 中那样。
#![allow(unused)] fn main() { pub enum CopyError { In(std::io::Error), Out(std::io::Error) } // 清单 4-1: 枚举错误类型 }
每个变量还包括所遇到的错误,以便为调用者提供尽可能多的出错信息。
当制作你自己的错误类型时,你需要采取一些步骤,使错误类型与 Rust 生态系统的其他部分很好地配合。首先,你的错误类型应该实现 std::error::Error 特质,它为调用者提供了内省错误类型的常用方法。主要的方法是 Error::source,它提供了一种机制来寻找错误的根本原因。这最常用于打印一个回溯,显示一路追溯到错误的根本原因。对于我们的 CopyError 类型,source 的实现是直接的:我们在 self 上进行匹配,提取并返回内部的 std::io::Error。
第二,你的类型应该同时实现 Display 和 Debug ,以便调用者可以有意义地打印你的错误。如果你实现了 Error 特性,这就是必须的。一般来说,你对 Display 的实现应该给出一个关于出错原因的单行描述,可以很容易地被折叠到其他错误信息中。显示的格式应该是小写的,没有尾部的标点符号,这样可以很好地融入其他更大的错误报告。Debug 应该提供一个更具描述性的错误,包括在追踪错误原因时可能有用的辅助信息,如端口号、请求标识符、文件路径等,#[derive(Debug)] 通常足以满足这些要求。
注意:在旧的 Rust 代码中,你可能会看到对
Error::description方法的引用,但这已经被弃用了,转而使用Display。
第三,如果可能的话,你的类型应该同时实现 Send 和 Sync,这样用户就能够跨线程共享错误。如果你的错误类型不是线程安全的,你会发现几乎不可能在多线程环境下使用你的包。实现 Send 和 Sync 的错误类型也更容易使用,非常常见的 std::io::Error 类型能够包裹实现 Error、Send 和 Sync 的错误。当然,并不是所有的错误类型都能合理地实现 Send 和 Sync,比如它们与特定的线程本地资源相联系,这也没关系。你可能也不会跨越线程边界发送这些错误。然而,在你把 Rc<String>和 RefCell<bool>类型放在你的错误中之前,这是要注意的事情。
最后,如果可能的话,你的错误类型应该是 'static。这样做最直接的好处是,它允许调用者更容易地在调用堆栈中传播你的错误,而不会遇到生存期问题。它还可以使你的错误类型更容易被用于类型消除的错误类型,我们很快就会看到。
不透明的错误
现在让我们考虑一个不同的例子:一个图像解码库。你给这个库一串字节去解码,它让你访问各种图像操作方法。如果解码失败,用户需要能够弄清楚如何解决这个问题,因此必须了解其原因。但是,原因是图像头中的大小字段无效,还是压缩算法未能解压一个块,这很重要吗?也许不重要,即使知道确切的原因,应用程序也无法从这两种情况下有意义地恢复。在这样的情况下,作为库的作者,你可能想提供一个单一的、不透明的错误类型。这也会使你的库更容易使用,因为只有一个错误类型在任何地方使用。这个错误类型应该实现 Send 、Debug、Display和 Error(包括适当的 source 方法),但除此之外,调用者不需要知道更多的东西。你可以在内部表示更精细的错误状态,但没有必要将这些暴露给库的用户。这样做只会不必要地增加你的 API 的大小和复杂性。
你的不透明的错误类型到底应该是什么,主要取决于你。它可以是一个具有所有私有字段的类型,只公开有限的方法来显示和反省错误,或者它可以是一个严重的类型消除的错误类型,如 Box<dyn Error + Send + Sync + 'static>,它只透露了它是一个错误的事实,一般不会让用户反省。决定让你的错误类型有多不透明,主要是看错误除了描述之外是否有什么有趣的地方。使用 Box<dyn Error>,你让你的用户没有什么选择,只能把你的错误冒出来。如果它确实没有任何有价值的信息呈现给用户,例如,如果它只是一个动态的错误信息,或者是来自你程序深处的大量不相关的错误之一,这可能是好的。但是如果这个错误有一些有趣的方面,例如行号或状态代码,你可能想通过一个具体但不透明的类型来暴露它。
注意:一般来说,社区的共识是,错误应该是罕见的,因此不应该给 "快乐路径 "增加很多成本。出于这个原因,错误通常被放置在一个指针类型的后面,比如
Box或Arc。这样一来,它们就不可能给它们所包含的整个结果类型的大小增加很多。
使用类型消除的错误的一个好处是,它允许你轻松地结合来自不同来源的错误,而不必引入额外的错误类型。也就是说,基于类型的错误通常可以很好地组合,并允许你表达一个开放式的错误集。如果你写了一个返回类型为 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 时才起作用。如果我们返回一个不透明的、非 'static 的 Error,我们就剥夺了用户进行这种错误反省的能力。
在生态系统中,对于一个库的类型消除的错误(或者更广泛地说,它的类型消除的类型)是否是其公共和稳定 API 的一部分,存在一些分歧。也就是说,如果你的库中的方法 foo 将 lib::MyError 作为 Box<dyn Error> 返回,将 foo 改为返回不同的错误类型是否是一种破坏性的改变?类型签名并没有改变,但是用户可能写了一些代码,认为他们可以使用降频来把这个错误转回 lib::MyError。我对此事的看法是,你选择返回 Box<dyn Error> (而不是 lib::MyError) 是有原因的,除非有明确的文档说明,否则这并不能保证有什么特别的下转换。
注意:虽然
Box<dyn Error + ...>是一个有吸引力的类型擦除的错误类型,但它本身并没有实现 Error,这与直觉相反。 因此,考虑在实现 Error 的库中添加你自己的 BoxError 类型以实现类型擦除。
你可能想知道 Error::downcast_ref 如何做到安全。也就是说,它如何知道提供的 dyn Error 参数是否确实属于给定的类型 T?标准库中甚至有一个名为 Any 的特性,它是为任何类型实现的,它为 dyn Any 实现了 downcast_ref,这怎么能行?答案在于编译器支持的类型 std::any::TypeId,它允许你为任何类型获得一个唯一的标识符。Error trait 有一个隐藏提供的方法,叫做 type_id,它的默认实现是返回 TypeId::of::<Self>()。类似地,Any 对于 T 有一个 impl Any 的覆盖实现,在该实现中,其 type_id 返回相同的内容。在这些 impl 块的上下文中,Self 的具体类型是已知的,所以这个 type_id 是真实类型的类型标识符。downcast_ref 调用 self.type_id,它通过动态大小类型的 vtable(见第 2 章)转发到底层类型的实现,并将其与提供的 downcast 类型的类型标识符进行比较。如果它们匹配,那么 dyn Error 或 dyn Any 背后的类型就真的是 T,并且从一个类型的引用到另一个类型的引用是安全的。
特殊错误案例
有些函数是易错的,但如果失败了也不能返回任何有意义的错误。从概念上讲,这些函数的返回类型是Result<T, ()>。在一些代码库中,你可能会看到它被表示为 Option<T>。虽然这两个函数的返回类型都是合法的选择,但它们表达了不同的语义,你通常应该避免将 Result<T, ()> "简化"为 Option<T>。 Err(()) 表示一个操作失败了,应该重试、报告或以其他方式例外处理。另一方面,None 只表达了函数没有任何东西可以返回;它通常不被认为是一个特殊情况或应该被处理的东西。你可以在结果类型的 #[must_use] 注解中看到这一点--当你得到一个结果时,语言期望处理这两种情况是很重要的,而对于一个选项,两种情况实际上都不需要处理。
注意:你还应该记住,
()并没有实现Error特性。这意味着它不能被类型化为Box<dyn Error>,并且在使用时可能会有点麻烦。出于这个原因,在这些情况下,定义你自己的单元结构类型,为其实现Error,并将其作为错误,而不是()。
有些函数,比如那些启动持续运行的服务器循环的函数,只返回错误;除非发生错误,否则它们永远运行。其他函数永远不会出错,但需要返回一个结果,例如,为了匹配一个特征签名。对于这样的函数,Rust 提供了 never 类型,用 ! 的语法编写。never 类型表示一个永远不能生成的值。你不能自己构造这种类型的实例,唯一的方法是进入一个无限循环或恐慌,或通过其他一些编译器知道永远不会返回的特殊操作。对于 Result,当你有一个你知道永远不会被使用的 Ok 或 Err 时,你可以把它设置为 ! 类型。如果你写了一个返回 Result<T, !> 的函数,你将永远无法返回 Err,因为唯一的办法就是输入永远不会返回的代码。因为编译器知道任何带有 ! 的变体都不会被产生,所以它也可以考虑到这一点来优化你的代码,比如不生成 Result<T, !> 上 unwrap 的恐慌代码。而当你进行模式匹配时,编译器知道任何包含 ! 的变量甚至不需要被列出。相当不错!
最后一个奇怪的错误情况是错误类型 std::thread::Result。 这里是它的定义。
#![allow(unused)] fn main() { type Result<T> = Result<T, Box<dyn Any + Send + 'static>>; }
错误类型是类型清除的,但是它并没有像我们到目前为止所看到的那样被清除成一个 dyn Error。相反,它是一个动态 Any,它只保证错误是某种类型,仅此而已。..... 这根本算不上是一种保证。之所以出现这种奇怪的错误类型,是因为 std::thread::Result 的错误变量只在响应恐慌时产生;具体来说,如果你试图加入一个已经恐慌的线程。 在这种情况下,除了忽略错误或使用 unwrap 使自己恐慌外,加入的线程能做的事情并不多。从本质上讲,错误类型是 "恐慌",其值是传递给 panic! 的任何参数,它确实可以是任何类型(尽管它通常是一个格式化的字符串)。
传播错误
Rust 的 ? 操作符是解包或提前返回的速记工具,用于轻松处理错误。但它也有一些值得了解的其他技巧。首先,? 通过 From 特性执行类型转换。在一个返回 Result<T, E> 的函数中,你可以在任何 Result<T, X> 上使用 ? ,其中 E: From<X> 。这就是通过 Box<dyn Error>消除错误的特点;你可以在任何地方使用 ? 而不用担心特定的错误类型,而且通常会 "正常工作"。
FROM 和 INTO
标准库有许多转换特性,但其中两个核心特性是 From 和 Into。你可能会觉得有两个很奇怪:如果我们有 From,为什么还需要 Into,反之亦然?有几个原因,但让我们从历史原因开始:在 Rust 的早期,由于第二章中讨论的一致性规则,不可能只有一个。或者,更确切地说,一致性规则曾经是什么。
假设你想在你的板块中定义的某个本地类型和标准库中的某个类型之间实现双向转换。你可以为写
impl<T> From<Vec<T>> for MyType<T>和impl<T> Into<Vec<T>> for MyType<T>,但是如果你只有From或Into,你必须写impl<T> From<MyType<T>> for Vec<T>或impl<T> Into<MyType<T>> for Vec<T>。然而,编译器曾经拒绝了这些实现!只有从 Rust 1.41.0 开始,当覆盖类型的例外被添加到一致性规则中时,它们才合法。在那之前,有必要同时拥有这两个特性。由于很多 Rust 代码是在 Rust 1.41.0 之前写的,所以现在这两个特征都不能被删除。然而,除了这一历史事实之外,即使我们今天可以从头开始,也有很好的人体工程学理由来拥有这两个特征。在不同的情况下,使用一个或另一个往往会明显地更容易。例如,如果你正在写一个方法,该方法接收一个可以变成
Foo的类型,你是想写fn(implot Into<Foo>)还是fn<T>(T) where Foo: From<T>?反过来说,要把一个字符串变成一个语法标识符,你是愿意写Ident::from("foo")还是<_ as Into<Ident>>::into("foo")?这两个特性都有其用途,我们最好同时拥有它们。鉴于我们确实有这两样东西,你可能想知道你今天应该在你的代码中使用哪一个。 答案是非常简单的:实现
From,并在约束使用Into。原因是Into对任何实现了From的T都有一个覆盖实现,所以不管一个类型是明确地实现了From还是Into,它都实现了Into!当然,正如简单的事情经常发生一样,故事并没有完全结束。因为当 Into 被用作绑定时,编译器经常要 "通过" 覆盖实现,所以一个类型是否实现了
Into的推理比它是否实现了From更复杂。而且在某些情况下,编译器还没有聪明到可以解决这个难题。由于这个原因,在编写本文时,?操作符使用From,而不是Into。大多数时候,这并没有什么区别,因为大多数类型都实现了 From,但这确实意味着来自旧库的错误类型实现了Into,但可能无法与?随着编译器越来越聪明,?可能会被 "升级" 为使用Into,到那时这个问题就会消失,但这是我们现在的情况。
需要注意的第二个方面是,? 这个操作符实际上只是一个暂称为 Try 的特性的语法糖。在写这篇文章的时候,Try 特性还没有稳定下来,但是当你读到这篇文章的时候,它或者类似的东西很可能已经被确定下来。由于细节还没有全部弄清楚,我将只给你一个 Try 工作原理的大纲,而不是完整的方法特征。在其核心部分,Try 定义了一个封装类型,其状态要么是进一步计算是有用的(快乐路径),要么是没有用的。你们中的一些人会正确地想到单体,尽管我们不会在这里探讨这种联系。例如,在 Result<T, E> 的情况下,如果你有一个 Ok(t),你可以通过解开 t 来继续进行快乐的路径。另一方面,如果你有一个 Err(e),你想停止执行并立即产生错误值,因为你没有 t,所以不可能进一步计算。
Try 的有趣之处在于它适用于更多的类型,而不仅仅是结果。例如,一个 Option<T> 遵循同样的模式--如果你有一个 Some(t),你可以在快乐路径上继续下去,而如果你有一个 None,你想产生 None 而不是继续。这种模式延伸到了更复杂的类型,比如 Poll<Result<T, E>>,它的快乐路径类型是 Poll<T>,这使得 ? 适用的情况远比你想象的多。当 Try 稳定下来后,我们可能会看到 ? 开始与各种类型一起工作,使我们的快乐路径代码更漂亮。
? 操作符已经可以在易错函数、doctests 和 fn main 中使用了。不过,为了充分发挥它的潜力,我们还需要一种方法来对这种错误进行范围处理。例如,考虑清单 4-2 中的函数。
#![allow(unused)] fn main() { fn do_the_thing() -> Result<(), Error> { let thing = Thing::setup()?; // .. code that uses thing and ? .. thing.cleanup(); Ok(()) } // 清单 4-2:使用"? "运算符的多步骤易错函数。 }
这并不完全像预期的那样工作。在 setup 和 cleanup 之间的任何问题都会导致整个函数的提前返回,从而跳过 cleanup 的代码!这就是 try 块要解决的问题。一个尝试块的行为就像一个单次迭代的循环,其中 ? 使用 break 而不是 return,并且该块的最后表达式有一个隐含的 break。我们现在可以修改清单 4-2 中的代码,使其总是进行清理,如清单 4-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 } // 清单 4-3:一个多步骤的易错函数,总是自己清理。 }
在写这篇文章时,"尝试块 "也不稳定,但对其有用性有足够的共识,它们很可能以类似于这里描述的形式出现。
总结
本章介绍了在 Rust 中构造错误类型的两种主要方式:枚举和擦除。我们研究了什么时候你可能想要使用这两种方式,以及这两种方式的优点和缺点。我们还看了一些 ? 操作符的幕后内容,并考虑了 ? 如何在未来变得更加有用。在下一章中,我们将从代码中抽身出来,看看你是如何构建 Rust 项目的。我们将研究特征标志、依赖管理和版本管理,以及如何使用工作区和子板块管理更复杂的板块。下一页见!