使用 C++ 处理错误
本章将聚焦于 C++ 中的错误处理。作为一名程序员,你不可避免地会遇到需要确定如何传播程序错误的情况。无论是使用错误代码还是异常,我们都将深入了解它们,以更好地理解如何有效使用它们。
在这一章中,我们将探讨如何使用 C++ 处理 POSIX API 报告的错误。我们将从介绍 errno
线程局部变量和 strerror
函数开始。之后,我们将引入 std::error_code
和 std::error_condition
,并演示它们如何帮助封装来自 POSIX API 的 POSIX 错误。我们还将研究自定义错误类别,这允许我们比较不同来源产生的错误,并开发平台独立的错误处理代码。
随着学习的深入,我们将了解 C++ 中的异常以及如何将 std::error_code
转换为 std::system_error
异常。我们还将探索处理异常的一些最佳实践,例如通过值抛出异常并通过引用捕获它们。此外,我们将了解对象切片,这是在我们通过值而不是引用捕获异常时可能发生的副作用。最后,我们将深入了解 C++ 中的 RAII 技术,它消除了该语言中 finally
构造的需求。
通过本章的学习,你将对 C++ 中处理错误的各种方式有一个全面的了解,并将熟悉几种创建抗错误代码的技术。
总结起来,我们将涵盖以下主题:
- 使用 C++ 处理 POSIX API 的错误
- 从错误代码到异常
好的,我们开始吧!
技术要求
本章中的所有示例都在以下配置的环境中进行了测试:
- Linux Mint 21 Cinnamon 版本
- GCC 12.2,编译器标志:
-std=c++20
- 稳定的互联网连接
- 请确保您的环境至少是这么新。对于所有示例,您还可以选择使用 https://godbolt.org/。
使用 C++ 处理 POSIX API 的错误
在遵循 POSIX 标准的系统中,如 Unix 和 Linux,错误处理基于使用错误代码和错误消息在函数和应用程序之间传递错误。
通常情况下,当函数遇到错误时,它会返回一个非零错误代码,并将 errno
全局变量设置为特定的错误值,以指示错误的性质。然后,应用程序可以使用 errno
变量来确定错误的原因,并采取适当的行动。
除了错误代码,遵循 POSIX 标准的函数通常还提供描述错误性质的详细错误消息。这些错误消息通常是通过 strerror
函数访问的,该函数接受一个错误代码作为输入,并返回一个以空字符结尾的字符序列指针,其中包含相应的错误消息。
POSIX 错误处理风格要求开发人员在每次可能失败的系统调用或函数调用后检查错误,并以一致且有意义的方式处理错误。这可以包括记录错误消息、重试失败的操作,或在发生严重错误时终止程序。
让我们看一个示例,我们在其中演示如何使用 errno
变量和 strerror()
函数来处理 C++ 中的 POSIX 函数错误。
该示例使用了 open()
和 close()
POSIX 函数,它们尝试在我们的 Linux 测试环境的文件系统中打开和关闭文件:
#include <iostream>
#include <fcntl.h>
#include <unistd.h>
#include <cstring>
int main() {
const int fd{open("no-such-file.txt", O_RDONLY)}; //{1}
if (fd == -1) {
std::cerr << "Error opening file: " <<
strerror(errno) << '\n';
std::cerr << "Error code: " << errno << '\n';
return EXIT_FAILURE;
}
// Do something with the file...
if (close(fd) == -1) {
std::cerr << "Error closing file: " <<
strerror(errno) << '\n';
std::cerr << "Error code: " << errno << '\n';
return EXIT_FAILURE;
}
return 0;
}
在这个示例中,我们尝试使用 open()
函数打开一个名为 no-such-file.txt
的文件进行读取;见标记 {1}
。如果成功,open()
返回一个非负整数,对应于成功打开文件的文件描述符 ID。如果 open()
返回 -1
,我们知道发生了错误,所以我们使用 strerror(errno)
打印错误消息,并返回 errno
的值,其中写有相应的错误代码。
如果 open()
成功,我们对文件进行一些操作,然后使用 close()
函数关闭它。如果 close()
返回 -1
,我们再次使用 strerror(errno)
打印错误消息,并返回 errno
的值。
这是处理 POSIX 函数错误的常见技术。在出现错误的情况下,它们返回 -1
并将相应的错误代码设置到 errno
变量中。errno
变量是 int
类型的 线程局部 可修改变量。这意味着你可以在多线程环境中安全使用它。每个线程将有自己的副本,此线程调用的 POSIX 方法将使用此实例来报告错误。
为了在出现错误时打印有意义的消息,我们使用了 strerror()
函数,它接受一个整数并尝试将其值与系统特定错误代码的众所周知的描述列表相匹配。open()
函数可以报告几种错误,并根据发生的错误类型设置 errno
的不同值。让我们看看示例的输出:
Error opening file: No such file or directory
Error code: 2
正如我们所见,open()
方法未能打开文件,因为它不存在。在这种情况下,它将 errno
设置为 2
的值,对应于函数文档中指定的 ENOENT
值。在进行系统调用之前显式将 errno
设置为 0
是一个好习惯,以确保在调用后,你可以读取其真实响应。
使用 std::error_code 和 std::error_condition
在C++标准库中,提供了多个类用于处理来自低级API(例如POSIX接口)的错误。这些类包括std::error_code
,用于处理特定于系统的错误,以及std::error_condition
,用于处理可移植的错误代码。下面我们将更详细地探讨这两种方式。
std::error_code
让我们重新设计之前的示例,提供一个用于创建具有特定目录路径的目录的函数:
#include <iostream>
#include <sys/stat.h>
std::error_code CreateDirectory(const std::string& dirPath) {
std::error_code ecode{};
if (mkdir(dirPath.c_str(), 0777) != 0) {
ecode = std::error_code{errno,
std::generic_category()}; // {1}
}
return ecode;
}
int main() {
auto ecode{CreateDirectory("/tmp/test")};
if (ecode){ // {2}
std::cerr << "Error 1: " << ecode.message() <<
'\n';
}
ecode = CreateDirectory("/tmp/test"); // {3}
if (ecode){
std::cerr << "Error 2: " << ecode.message() <<
'\n';
}
if (ecode.value() == EEXIST) {
std::cout << "This is platform specific and not
portable.\n";
}
return 0;
}
我们的新函数CreateDirectory
的客户端不再直接使用errno
变量来确定操作是否成功,而是使用标准库提供的一个实用类——std::error_code
。std::error_code
用于存储和传递库或系统调用生成的错误代码。它是一个包装类,有预定义的错误类别可供使用。POSIX函数返回的错误大多是标准的,因此在标准库中已经预定义。因此,从errno
值创建std::error_code
实例并指定此值对应于std::generic_category()
是很直接的,就像前面示例中的标记{1}
所做的那样。errno
值实际上被强制转换为std::errc
枚举器的常量。
创建的std::error_code
对象有两个方法可以告诉你有关底层错误的细节。std::error_code::message()
方法返回一个有意义的字符串,可用于日志记录目的。我们示例中的std::error_code::value()
方法返回最初存储在errno
变量中的值。但std::error_code
对象最值得注意的操作可能是类的预定义operator bool()
。如果对象中存储了错误,它返回true
;否则,它返回false
。
从前面的示例中可以看到,CreateCategory()
方法的调用者检查是否发生了错误,如果是,它会获取存储的错误消息;见标记{2}
。这里,你可以找到在我们测试环境中运行程序的输出:
Error 2: File exists
This is platform specific and not portable.
从程序的输出中可以看出,第一次调用CreateDirectory()
成功,但第二次失败;见标记{3}
。这是因为CreateDirectory()
的实现首先检查这样的目录是否已经存在,如果没有,它会为我们创建它。但如果目录已存在,mkdir()
系统调用返回–1
并将errno
设置为EEXIST
。
关于std::error_code
类的重要一点是它是特定于平台的。这意味着其中存储的错误值强烈依赖于底层操作系统。在类似POSIX的系统中,例如Linux,我们拥有的错误值是EEXIST
。但这对其他操作系统来说并不一定是真的。
因此,如果我们设计代码尽可能地与平台无关,我们需要避免以下比较:
if (ecode.value() == EEXIST)
但我们还需要一种方法来确保已经存在的目录不会破坏我们程序逻辑。是的,从POSIX的角度来看,这是一个错误,但在我们特定的业务逻辑中,这不是程序继续执行的问题。
std::error_condition
解决这个问题的正确方法是使用另一个标准库类——std::error_condition
。顾名思义,它的主要目的是提供条件程序逻辑。让我们稍微修改之前示例中的CreateDirectory()
方法:
std::error_code CreateDirectory(const std::string& dirPath) {
std::error_code ecode{};
if (mkdir(dirPath.c_str(), 0777) != 0) {
std::errc cond{errno}; // {1}
ecode = std::make_error_code(cond); // {2}
}
return ecode;
}
如你所见,与前一个示例的区别在于我们如何构造error_code
对象。在修改后的代码中,我们首先创建一个std::errc
类型的对象,并用POSIX的errno
值初始化它;见标记{1}
。std::errc
类是一个作用域枚举类。它定义了可移植的错误条件,对应于特定的POSIX错误代码。这意味着,我们不再依赖于与特定POSIX错误代码对应的特定于平台的宏,如EEXIST
,而是转向一个无论来自哪个平台都具有相同错误条件的错误。
重要说明
你可以在这里找到std::errc
作用域枚举器的预定义可移植错误条件,它们对应于它们等效的POSIX错误代码:https://en.cppreference.com/w/cpp/error/errc。
一旦我们创建了std::errc
的实例,我们就将它传递给创建错误代码的工厂方法——std::make_error_code()
(见标记{2}
)——它为我们生成了一个通用类别的std::error_code
。
现在,让我们看看main()
方法如何被改变以便平台无关:
int main() {
auto ecode{CreateDirectory("/tmp/test")};
if (ecode){
std::cerr << "Error 1: " << ecode.message() <<
'\n';
}
ecode = CreateDirectory("/tmp/test");
if (ecode){
std::cerr << "Error 2: " << ecode.message() <<
'\n';
}
if (ecode == std::errc::file_exists) { // {3}
std::cout << "This is platform agnostic and is
portable.\n";
}
return 0;
}
我们仍然有两次调用CreateDirectory()
方法,第二次仍然返回一个error_code
。但主要区别来自我们比较ecode
对象的方式;见标记{3}
。我们不是将它与POSIX错误代码的整数值进行比较,而是将它与持有可移植错误条件的对象进行比较——std::errc::file_exists
。它具有相同的语义,即文件已存在,但它是平台无关的。在下一节中,我们将看到这有多么有用。
使用自定义错误类别
每位软件开发者都应该尽可能地编写可移植的代码。编写可移植代码提供了可重用性,这可以显著降低开发成本。当然,这并不总是可能的。有些情况下,你编写的代码专用于特定系统。但对于所有其他情况,将代码从底层系统抽象出来,可以让你轻松地将其迁移到其他系统,而无需进行大规模重构来使其工作。这更安全,成本更低。
让我们回到之前的示例,我们试图抽象从POSIX系统调用接收到的错误代码。它应该可以与可移植错误条件(如std::errc::file_exists
)进行比较。我们将用以下用例来扩展这一点。假设我们有一个也与文件打交道的自定义库,我们称之为MyFileLibrary
。但这个库不支持POSIX错误代码。它提供了一个不同的类别的自定义错误代码,这些代码在语义上对应于一些POSIX代码,但错误值不同。
该库支持以下错误及其相应的错误代码:
enum class MyFileLibraryError {
FileNotFound = 1000,
FileAlreadyExists = 2000,
FileBusy = 3000,
FileTooBig = 4000
};
正如你所见,我们的库可以返回FileAlreadyExists
枚举常量,就像mkdir()
系统调用一样,但错误值是1000
。因此,同时使用MyFileLibrary
和mkdir()
的主要逻辑应该能够以相同的方式处理这些错误,因为它们在语义上是相等的。让我们看看如何做到这一点。
在之前的示例中,我们创建了POSIX API返回的错误代码:
ecode = std::error_code{errno, std::generic_category()};
我们使用了std::generic_category
,它是基类std::error_category
的派生类。它在标准库中预定义为知道POSIX错误代码。这实际上是API返回的实际错误代码和std::error_condition
之间的转换发生的地方。因此,为了让MyFileLibrary
也具有相同的能力,我们需要定义一个新的std::error_category
派生类。我们将其命名为MyFileLibraryCategory
:
class MyFileLibraryCategory : public std::error_category {
public:
const char* name() const noexcept override { // {1}
return "MyFileLibrary";
}
std::string message(int ev) const override { // {2}
switch (static_cast<MyFileLibraryError>(ev)) {
case MyFileLibraryError::FileAlreadyExists:
return "The file already exists";
default:
return "Unsupported error";
}
}
bool equivalent(int code,
const std::error_condition& condition)
const noexcept override { // {3}
switch (static_cast<MyFileLibraryError>(code)) {
case MyFileLibraryError::FileAlreadyExists:
return condition == std::errc::file_exists; //{4}
default:
return false;
}
}
};
std::error_category
基类有几个虚拟方法,如果在派生类中重写,可以允许自定义行为。在我们的示例中,我们重写了以下内容:
name()
方法,用于报告此错误属于哪个类别;见标记{1}
message()
方法,用于报告与特定错误值相对应的消息字符串;见标记{2}
equivalent()
方法,用于比较我们库生成的自定义错误代码和预定义的std::error_condition
值
equivalent()
方法获取自定义错误代码,将其强制转换为MyFileLibraryError
的值,对于每个特定情况,决定它与哪个condition
匹配;见标记{3}
。
现在,既然我们有了我们新的、闪亮的自定义错误类别——MyFileLibraryCategory
,让我们看看如何使用它:
const MyFileLibraryCategory my_file_lib_category{}; // {1}
int main() {
std::error_code file_exists{static_cast<int>
(MyFileLibraryError::FileAlreadyExists),
my_file_lib_category}; // {2}
if (file_exists == std::errc::file_exists) { // {3}
std::cout << "Msg: " << file_exists.message() <<
'\n'; // {4}
std::cout << "Category: " << file_exists
.default_error_condition().category().name() <<
'\n'; // {5}
}
return 0;
}
我们需要采取的第一步是实例化我们自定义类别的对象;见标记{1}
。然后,我们创建一个error_code
实例,我们用FileAlreadyExists
错误值初始化它,并指定它来自MyFileLibraryCategory
类别;见标记{2}
。由于我们有一个有效的错误代码实例——file_exists
——我们准备好将其与平台无关的std::errc::file_exists
错误条件进行比较。
程序的输出如下:
Msg: The file already exists
Category: MyFileLibrary
正如你所见,现在可以使用我们定义的自定义错误类别——MyFileLibraryCategory
——比较来自MyFileLibrary
的错误和通用的std::errc::file_exists
。相应的错误消息被显示(见标记{3}
),类别也是如此(见标记{4}
)。
重要说明
在这里,你可以找到std::error_category
基类公开的所有虚拟方法的完整描述:https://en.cppreference.com/w/cpp/error/error_category。
现在我们熟悉了错误代码和错误条件的使用,让我们看看如何使用C++异常的强大机制来传播错误。
从错误代码到异常
异常处理是编程中的一个重要方面,特别是在处理可能打断程序正常流程的错误时。尽管有多种方法可以在代码库中处理错误,但异常提供了一种强大的机制,用于以将错误流与正常程序流分离的方式处理错误。
在处理错误代码时,确保所有错误情况得到妥善处理并保持代码可维护性可能是个挑战。通过将错误代码包装在异常中,我们可以创建更加实用的错误处理方法,使得理解代码和以更集中的方式捕获错误变得更容易。
很难说在代码库中处理错误时哪种方法更好,使用异常的决定应基于实用考虑。虽然异常在代码组织和可维护性方面可以提供显著好处,但它们可能会带来性能损失,这在某些系统中可能是不可接受的。
异常的核心是将正常程序流程与错误流程分离。与可以被忽略的错误代码不同,异常不容易被忽视,使它们成为确保错误以一致和集中的方式处理的更可靠方法。
虽然异常可能不适用于每个代码库,但它们提供了一种强大的处理错误的方式,可以使代码更易于维护和理解。通过正确使用异常,程序员可以做出有关如何在代码中处理错误的明智决定。让我们更深入地了解这一点。
std::system_error
在上一节中,我们创建了一个程序,正确处理了POSIX系统调用mkdir()
报告的错误。现在,让我们看看如何使用异常而不是错误代码来改进这个程序中的错误处理。这是重新审视的CreateDirectory()
方法:
void CreateDirectory(const std::string& dirPath) { // {1}
using namespace std;
if (mkdir(dirPath.c_str(), 0777) != 0) {
const auto ecode{make_error_code(errc{errno})}; //{2}
cout << "CreateDirectory reports error: " <<
ecode.message() << '\n';
system_error exception{ecode}; // {3}
throw exception; // {4}
}
}
在CreateDirectory()
方法中,我们使用mkdir()
API进行系统调用,如果失败,它会返回非零结果,并在errno
变量中存储POSIX错误代码。到目前为止没有什么新东西。就像我们之前的示例一样,我们从errno
的值创建一个std::error_code
(见标记{2}
),以报告给我们的CreateDirectory()
方法的调用者。但我们不喜欢直接将错误作为函数的结果返回,而是更喜欢使用异常来处理,并使我们的函数变为void;见标记{1}
。
由于我们已经创建了一个错误代码对象,我们将使用它来创建一个异常。为此,我们将使用标准库中预定义的一个异常类,专门定义为包装std::error_code
对象——std::system_error
。
std::system_error
是C++标准库中std::exception
接口类的派生类型。它被各种库函数使用,这些函数通常与操作系统设施接口,并可以通过生成std::error_code
或std::error_condition
来报告错误。
图5.1 - std::system_error异常的继承关系图
在我们的示例中,为了创建一个std::system_error
对象,我们必须将我们已经创建的std::error_code ecode
实例传递给其构造函数;见标记{3}
。
与标准库中的基础异常类std::exception
派生的任何其他异常一样,std::system_error
具有what()
方法。它旨在报告有关异常背后错误的详细说明的有意义的字符串。更具体地说,它在底层调用它所包装的std::error_code
对象的message()
方法,并返回其结果。
既然我们已经创建了一个新的、闪亮的异常对象,我们现在需要将其抛出回我们的API调用者。这是通过throw
关键字完成的;见标记{4}
。一个重要的注意事项是,我们通过值抛出异常对象;我们不抛出对它的引用或指针。
重要说明
作为经验法则,尽可能通过值抛出异常。
异常相对于错误代码的一个关键优势是,调用者不能省略它们。当函数返回错误代码时,由函数的调用者决定是否检查返回值。有些情况下,返回值被错误地忽略了,这导致程序中的错误。使用异常作为错误处理机制时,不存在这样的可能性。一旦抛出异常,它会沿调用栈向上传播,直到被适当的程序异常处理逻辑捕获,或者到达函数栈的顶部。如果异常在其传播路径中的任何地方都未被捕获,也称为栈展开,那么它将通过调用std::terminate
函数终止程序。
重要说明
查看以下std::system_error
参考页面:https://en.cppreference.com/w/cpp/error/system_error。
现在,让我们回到我们的示例,看看main()
方法应该如何重写,以处理从CreateDirectory()
方法抛出的异常:
int main() {
try {
CreateDirectory("/tmp/test"); // First try succeeds
CreateDirectory("/tmp/test"); // Second try throws
} catch (const std::system_error& se) { // {5}
const auto econd{se.code()
.default_error_condition()}; // {6}
if (econd != std::errc::file_exists) { // {7}
std::cerr << "Unexpected system error: " <<
se.what() << '\n';
throw; // {8}
}
std::cout << "Nothing unexpected, safe to
continue.\n";
}
return 0;
}
与错误代码不同,一旦函数返回错误代码,就需要进行赋值和检查,而异常则需要被捕获并采取适当的行动。在C++中,使用try-catch构造来捕获异常。在前面的示例中,你可以看到我们调用CreateDirectory()
方法两次,因为第二次调用会生成错误,该错误会以异常的形式沿栈向上传播。这个异常将被标记{5}
中的catch
子句捕获。如你所见,catch
子句期望一个参数,指定应该捕获什么;见标记{5}
。它的语法类似于函数的参数列表,您可以通过值或引用传递对象。
在我们的示例中,我们通过常量引用捕获CreateDirectory()
方法抛出的异常。我们不通过值捕获的原因是为了避免不必要的对象复制和更重要的是为了避免对象切割。我们很快就会深入了解C++中的异常捕获技术的细节,但现在,让我们专注于我们当前的示例。一旦我们捕获到异常,我们就可以从中提取error_condition
对象;见标记{6}
。这是可能的,因为system_error
类支持错误代码和错误条件,并使我们能够获取它们。当我们有了error_condition
时,我们可以成功地检查这个异常是否对我们的程序是一个真正的问题,或者它可以被忽略;见标记{7}
。
重要说明
尽可能通过引用(最好是常量)捕获异常,而不是通过值,以避免潜在的对象切割和由于对象复制带来的额外开销。
我们的业务程序逻辑预期报告文件已存在的错误是正常的,不应该中断程序执行。最终,它表明我们试图创建一个已经存在的目录,这是可以的,我们可以继续。但如果错误是我们不知道如何处理的其他错误,那么我们必须报告该错误,并将其重新抛给调用栈中的上层方法,它们可能更好地知道如何处理这类错误。这是通过语言中的throw
子句完成的;见标记{8}
。这里的一个重要细节是,为了重新抛出现有的异常而不是抛出一个新的,你必须只使用throw;
,不带任何参数。
重要说明
使用无参数的throw;
子句来重新抛出现有的异常。
当然,如果错误是我们预期的,比如std::errc::file_exists
,那么我们可以安全地继续程序执行,无需重新抛出此异常。你可以看到程序的输出如下:
CreateDirectory reports error: File exists
Nothing unexpected, safe to continue.
我们可以看到异常由CreateDirectory()
方法抛出,并被main()
方法中的catch
子句捕获。在这个示例中,我们看到使用异常而不是错误代码清晰地区分了正常程序执行路径和错误路径,使我们更容易重新抛出无法适当处理的错误。
通过值抛出,通过引用捕获
在C++中,我们实际上可以抛出任何对象。你可以像下面这样成功地做到这一点:
throw 42;
上述语句抛出了一个值为42
的整数对象。但是,仅仅因为你可以做某事,并不意味着这样做是个好主意。异常的目的是为发生的错误带来上下文。仅仅抛出42
的值并没有提供太多上下文,对吧?对于接收你异常的人来说,42
意味着什么?并不多!
这个声明得到了由C++标准委员会的一些关键成员开发的C++核心指南项目的充分证实。C++核心指南是每个C++开发者的真正有用指南,无论你拥有什么样的专业水平。它收集了关于C++中不同特性的推荐和最佳实践。
重要说明
确保熟悉C++核心指南,你可以在https://isocpp.github.io/CppCoreGuidelines/CppCoreGuidelines#c-core-guidelines找到它。
C++核心指南指出,我们必须确保抛出有意义的异常。如果你没有一个适用于你情况的标准定义异常,你可以抛出一个从某些标准异常派生的用户定义类型:
https://isocpp.github.io/CppCoreGuidelines/CppCoreGuidelines#e14-use-purpose-designed-user-defined-types-as-exceptions-not-built-in-types
C++核心指南还建议通过值抛出异常,并通过引用捕获它们。当然,如果我们通过常量引用捕获就更好了。通过值抛出确保抛出对象的生命周期将由你的系统运行时管理。否则,如果你抛出一个你在堆上分配的对象的指针,谁将负责在不再需要时删除这个对象,很可能你会最终遇到内存泄漏:
https://isocpp.github.io/CppCoreGuidelines/CppCoreGuidelines#e15-throw-by-value-catch-exceptions-from-a-hierarchy-by-reference
让我们通过一个示例来理解。我们将定义一个方法——Throw()
——它通过值抛出一个带有错误代码bad_file_descriptor
的std::system_error
异常:
void Throw() {
using namespace std;
throw system_error{make_error_code
(errc::bad_file_descriptor)};
}
这个方法将被main()
方法调用,在其中我们将捕获抛出的异常:
int main() {
using namespace std;
try {
try {
Throw(); // {1}
} catch (runtime_error e) { // {2}
throw e; // {3}
}
} catch (const exception& e) { // {4}
const system_error& se{dynamic_cast<const
system_error&>(e)}; // {5}
const auto econd{se.code()
.default_error_condition()};
std::cerr << econd.message() << '\n';
}
return 0;
}
正如你在上面的示例中看到的,我们定义了两个try-catch
块——一个内部的和一个外部的。背后的原因是,在同一个try-catch块中,catch
分支中抛出的异常不能被另一个catch
分支捕获。它们被传播出去,因此,为了捕获它们,我们需要一个外部的try-catch块。
在标记{1}
中,我们调用Throw()
方法,该方法抛出一个异常。但在标记{2}
中,我们捕获了抛出的异常。实际上,我们没有直接捕获std::system_error
,而是捕获了它的父类——std::runtime_error
。此外,你可以看到我们通过值runtime_error e
捕获这个异常。
一旦我们捕获了runtime_error
异常,我们采取的唯一行动是将它从内部try-catch块中抛出:
throw e;
当你重新抛出一个现有的异常时,总是要小心。上述语句并没有重新抛出在catch
子句中捕获的异常,而是抛出了一个新实例的runtime_error
异常,这是捕获异常的副本。
一旦抛出新的异常,它被外部catch
子句中的标记{4}
捕获。正如你所看到的,遵循C++核心指南的建议,我们通过常量引用而不是通过值捕获标准库的基本异常类——std::exception
——这也是std::runtime_error
的基类。
在catch
子句中,我们尝试将其向下转换回其原始类型——std::system_error
——并打印出其std::error_condition
的消息。让我们看看程序的输出:
terminate called after throwing an instance of 'std::bad_cast'
what(): std::bad_cast
但令人惊讶的是,我们没有得到预期的结果。向下转换失败了,当它失败时,它会生成一个标准异常——std::bad_cast
——从外部catch
子句中抛出。但这个异常没有被另一个try-catch块保护,因此,它传播出main()
方法,这实际上是程序的函数栈顶部。正如我们之前解释的,如果异常在函数栈中的上行传播过程中未被捕获,那么将调用std::terminate
函数。
但为什么当我们尝试向下转换为std::system_error
时转换失败了呢?原因是Throw()
方法抛出了std::system_error
,一切应该正常工作。好吧,它应该,但实际上并没有。让我们更深入地了解这个问题。
Throw()
方法确实通过值抛出了一个std::system_error
实例。但内部catch
子句通过值捕获了一个基类异常,并抛出了它的副本:
catch (runtime_error e) {
throw e;
}
这导致了一个问题,因为我们重新抛出的对象不再是std::system_error
的实例。它已经被切割为其基类——std::runtime_error
。所有作为原始std::system_error
对象的一部分的信息不再是新创建的副本std::runtime_error
- e
类型的一部分。
因此,向下转换为std::system_error
不成功,我们的程序终止。
总之,我们可以说,遵循通过值抛出异常,通过引用捕获它们,并尽可能重新抛出现有异常而不是它们的副本的规则,可以成功地防止这类错误。
try/catch … finally
你可能已经注意到,在C++语言中,我们有try-catch
块,但没有finally
结构。如果你有C#或Java等语言的经验,你会习惯于使用finally
子句来释放你获取的资源。但这只适用于try
子句先于finally
的使用的特殊情况。
那么在没有finally
的情况下,我们如何在C++中做到这一点呢?让我们回顾一下我们最初的示例,使用open()
和close()
POSIX函数打开和关闭文件:
int main() {
try {
const int fd{open("/tmp/cpp-test-file", O_RDONLY)}; // {1}
if (fd == -1) { return errno; }
// Do something with the file and suddenly
something throws {2}
if (close(fd) == -1) { return errno; } // {3}
} catch (...) {
std::cerr << "Something somewhere went terribly
wrong!\n";
return -1;
}
return 0;
}
正如我们之前在章节中讨论的,使用open()
POSIX方法打开文件会返回文件描述符的ID,如果函数成功打开文件;否则,与许多POSIX函数一样,它会返回-1
;见标记{1}
。
一旦你打开了文件,确保最终,当你完成它时,它将被关闭是你的责任。因此,我们在main()
方法的最后调用close()
方法,以确保文件将被关闭(见标记{3}
),就在我们离开main()
之前。但是你怎么能确定不会发生异常情况,并且在你关闭文件之前不会抛出异常呢?实际上,你能确定不会发生这种情况的唯一情况是,如果你的系统不支持异常。但在我们的测试Linux环境中,并非如此。更糟糕的是,在真实的代码库中工作时,很难确保你在正常业务逻辑执行期间调用的一些方法不会抛出异常。
想象一下,如果你的程序在关闭文件之前抛出异常会发生什么;见标记{2}
。实际上,你会泄露资源。根据经验法则,我们永远不应该泄露资源,无论这是否会导致问题。
但是,如果没有finally
子句,我们如何保护自己不泄露资源呢?让我们来看看C++编程中最典型的技术之一:
void Throw() {
cout << "Ops, I need to throw ...\n";
throw system_error{make_error_code
(errc::bad_file_descriptor)};
}
int main() {
const string_view myFileName{"/tmp/cpp-test-file"}; //{1}
ofstream theFile(myFileName.data()); // {2}
try {
file_guard guard(myFileName, O_RDONLY); // {3}
const auto fd = guard.getFileDescriptor();
Throw(); // {4}
} catch (const exception& e) {
cout << e.what();
return -1;
}
return 0;
}
我们对main()
方法进行了重新设计,只创建一个文件(见标记{2}
),并将其文件名(见标记{1}
)传递给一个新的file_guard
类型的对象(见标记{3}
),我们将在片刻后详细查看。file_guard
对象负责打开和关闭具有特定名称的文件:
using namespace std;
class file_guard final {
public:
file_guard(string_view file, mode_t mode) : // {5}
fd{open(file.data(), mode)}
{
if (fd == -1) {
throw system_error
{make_error_code(errc{errno})};
}
cout << "File '" << file <<
"' with file descriptor '" <<
fd << "' is opened.\n";
}
explicit file_guard(const file_guard&) = delete; // {6}
file_guard& operator=(const file_guard&) = delete;
explicit file_guard(file_guard&& other) noexcept : //{7}
fd{move(other.fd)} { other.fd = -1; }
file_guard& operator=(file_guard&& other) noexcept
{
fd = move(other.fd);
other.fd = -1;
return *this;
}
int getFileDescriptor() const noexcept { // {8}
return fd;
}
~file_guard() noexcept { // {9}
if (fd != -1) {
close(fd);
cout << "File with file descriptor '" << fd <<
"' is closed.\n";
}
}
private:
int fd;
};
这个类在其构造函数中获取文件路径和文件应该被打开的模式;见标记{5}
。在构造函数的初始化列表中,调用了POSIX的open()
方法。结果,即文件描述符ID,被赋值给类的_fd
成员。如果open()
失败,一个异常会从file_guard
构造函数中抛出。在这种情况下,我们不需要关心关闭文件,因为我们并没有成功打开它。
在类的析构函数中,我们有相反的操作;见标记{9}
。如果文件描述符不是-1
,这意味着该文件之前已成功打开,我们就关闭它。
这种C++编程技术称为资源获取即初始化,或简称RAII。它是一种资源管理技术,通过RAII对象的构造过程获取资源,并在该对象的析构过程中释放资源。与Java和C#这类使用自动垃圾回收且资源释放时机对用户不完全清晰的语言不同,C++对象具有精确定义的存储持续时间和生命周期。因此,我们可以依赖这个特性,并利用RAII对象来管理我们的资源。
回到我们的main()
方法,如果文件被打开(见标记{3}
)并且在它被显式关闭之前(见标记{4}
)出现了问题,我们可以确保一旦file_guard
对象超出作用域,它将被自动关闭。
无论系统中是否可用异常,这种技术都被广泛使用。你可以使用RAII包装你的资源,并确保一旦你离开RAII对象所在的作用域,它们将被自动释放。
在我们的file_guard
示例中,我们移除了拷贝构造函数和拷贝赋值操作符,并只留下了移动构造函数和移动操作符,表明这个RAII对象是不可拷贝的。
C++常常因为没有finally
构造而受到质疑。然而,C++的发明者Bjarne Stroustrup解释说,RAII是一个更好的替代方案:https://www.stroustrup.com/bs_faq2.html#finally。
Stroustrup认为,在实际代码库中,有更多的资源获取和释放操作,使用RAII代替finally
会导致更少的代码。此外,它更不容易出错,因为RAII包装器只需要编码一次,不需要记住手动释放资源。
标准库中有许多RAII对象的例子,如std::unique_ptr
、std::lock_guard
和std::fstreams
。
总结
本章讨论了在C++中使用POSIX API时各种错误处理技术。我们讨论了使用errno
,一个线程本地变量,以及strerror
函数。我们还探讨了如何使用std::error_code
和std::error_condition
包装POSIX错误,以及如何使用自定义错误类别使我们能够比较不同来源生成的错误,并开发平台无关的错误处理代码。此外,我们深入研究了C++中的异常以及如何将std::error_code
转换为std::system_error
类型的异常。
我们还研究了处理异常的最佳实践,如通过值抛出它们并通过引用捕获它们,以避免对象切片等问题。最后,我们了解了C++中的RAII技术,它消除了语言中对finally
构造的需要。
在下一章中,我们将探讨C++的并发主题。