在本章中,我们将学习一些高级的 C++ 异常处理技术。这里我们假设您对如何抛出和捕获 C++ 异常有基本的了解。本章将教您一些更高级的 C++ 异常处理技术,而不是专注于 C++ 异常的基础知识。这包括正确使用noexcept
说明符和noexcept
运算符,这样您就可以正确地将您的 APIs 标记为可能抛出异常或者明确地不抛出 C++ 异常,而不是在出现无法处理的错误时调用std::terminate()
。
本章还将解释术语资源获取是初始化 ( RAII )是什么,以及它如何补充 C++ 异常处理。我们还将讨论为什么不应该从类的析构函数中抛出 C++ 异常,以及如何处理这些类型的问题。最后,我们将看看如何创建您自己的自定义 C++ 异常,包括提供一些关于创建自己的异常时应该做什么和不应该做什么的基本指南。
从本章提供的信息中,您将更好地了解 C++ 异常是如何在幕后工作的,以及可以使用 C++ 异常来构建更健壮和可靠的 C++ 程序的类型。
本章中的配方如下:
- 使用 noexcept 说明符
- 使用 noexcept 运算符
- 使用 RAII
- 学习为什么不在析构函数中抛出异常
- 轻松创建自己的异常类
要编译和运行本章中的示例,您必须拥有运行 Ubuntu 18.04 的计算机的管理权限,并且具有功能性互联网连接。在运行这些示例之前,您必须安装以下内容:
sudo apt-get install build-essential git cmake
如果这安装在 Ubuntu 18.04 以外的任何操作系统上,则需要 GCC 7.4 或更高版本以及 CMake 3.6 或更高版本。
noexcept
说明符用于告诉编译器一个函数是否可以抛出 C++ 异常。如果函数标有noexcept
说明符,则不允许引发异常,如果是,则在引发异常时将调用std::terminate()
。如果函数没有noexcept
说明符,可以正常抛出异常。
在本食谱中,我们将探索如何在您自己的代码中使用noexcept
说明符。这个说明符很重要,因为它是您正在创建的应用编程接口和应用编程接口用户之间的契约。当使用noexcept
说明符时,它告诉应用编程接口的用户在使用应用编程接口时不需要考虑异常。它还告诉作者,如果他们将noexcept
说明符添加到他们的 API 中,他们必须确保不抛出任何异常,在某些情况下,这要求作者捕获所有可能的异常,如果异常无法处理,要么处理它们,要么调用std::terminate()
。此外,还有某些操作,例如std::move
,在这些操作中,如果不担心损坏,就不能抛出异常,因为如果抛出异常,移动操作通常不能安全地反转。最后,对于一些编译器来说,在您的 API 中添加noexcept
将减少函数的整体大小,从而导致整体应用更小。
开始之前,请确保满足所有技术要求,包括安装 Ubuntu 18.04 或更高版本,并在终端窗口中运行以下内容:
> sudo apt-get install build-essential git cmake
这将确保您的操作系统拥有适当的工具来编译和执行本食谱中的示例。完成后,打开一个新的终端。我们将使用这个终端来下载、编译和运行我们的示例。
要尝试此配方,请执行以下步骤:
- 从新的终端,运行以下命令下载源代码:
> cd ~/
> git clone https://github.com/PacktPublishing/Advanced-CPP-CookBook.git
> cd Advanced-CPP-CookBook/chapter02
- 要编译源代码,请运行以下命令:
> mkdir build && cd build
> cmake ..
> make recipe01_examples
- 编译源代码后,您可以通过运行以下命令来执行该配方中的每个示例:
> ./recipe01_example01
The answer is: 42
> ./recipe01_example02
terminate called after throwing an instance of 'std::runtime_error'
what(): The answer is: 42
Aborted
> ./recipe01_example03
The answer is: 42
> ./recipe01_example04
terminate called after throwing an instance of 'std::runtime_error'
what(): The answer is: 42
Aborted
> ./recipe01_example05
foo: 18446744069414584320
foo: T is too large
在下一节中,我们将逐一介绍这些示例,并解释每个示例程序的功能以及它与本食谱中所教授的课程之间的关系。
首先,让我们简单回顾一下 C++ 异常是如何抛出和捕获的。在下面的例子中,我们将从一个函数抛出一个异常,然后在我们的main()
函数中捕获该异常:
#include <iostream>
#include <stdexcept>
void foo()
{
throw std::runtime_error("The answer is: 42");
}
int main(void)
{
try {
foo();
}
catch(const std::exception &e) {
std::cout << e.what() << '\n';
}
return 0;
}
如前面的例子所示,我们创建了一个名为foo()
的函数,它抛出了一个异常。这个函数在我们的try
/ catch
块内的main()
函数中被调用,该函数用于捕捉在try
块内执行的代码可能抛出的任何异常,在本例中,该块是foo()
函数。当foo()
功能抛出异常时,成功捕获并输出到stdout
。
所有这些都可以工作,因为我们没有给foo()
函数添加noexcept
说明符。默认情况下,一个函数被允许抛出一个异常,就像我们在这个例子中做的那样。然而,在某些情况下,我们不希望抛出异常,这取决于我们期望函数如何执行。具体来说,函数如何处理异常可以定义为以下内容(称为异常安全):
- 不抛出保证:函数不能抛出异常,如果在内部抛出异常,必须捕捉并处理异常,包括分配失败。
- 强异常安全:函数可以抛出异常,如果抛出异常,任何被函数修改的状态都会回滚或者撤销,没有任何副作用。
- 基本异常安全:函数可以抛出异常,如果抛出异常,任何被函数修改过的状态都会回滚或者撤销,但是有可能产生副作用。应该注意的是,这些副作用不包括不变量,这意味着程序处于有效的、未损坏的状态。
- 无异常安全:函数可以抛出异常,如果抛出异常,程序可能会进入损坏状态。
一般来说,如果一个函数有不抛出的保证,就用noexcept
标注;否则,就不是了。异常安全如此重要的一个例子是std::move
。例如,假设我们有两个std::vector
的例子,我们希望将一个向量移动到另一个向量中。要执行移动,std::vector
可能会将向量的每个元素从一个实例移动到另一个实例。如果对象在移动时被允许抛出,那么向量可能会在移动过程中出现异常(也就是说,向量中一半的对象被成功移动)。当异常发生时,std::vector
显然会尝试在返回异常之前,通过将这些动作移回原始向量来撤销已经执行的动作。问题是,试图将对象移回需要std::move()
,这可能会再次抛出异常,导致嵌套异常。在实践中,将一个std::vector
实例移动到另一个实例实际上并不会执行逐对象移动,但是调整大小会执行,在这个特定的问题中,标准库需要使用std::move_if_noexcept
来处理这种情况,以提供异常安全,当允许对象的移动构造函数抛出时,这又会返回到副本。
noexcept
说明符通过明确声明函数不允许抛出异常来克服这些类型的问题。这不仅告诉应用编程接口的用户,他们可以安全地使用该函数,而不必担心抛出异常并可能破坏程序的执行,而且还迫使函数的作者安全地处理所有可能的异常或调用std::terminate()
。虽然noexcept
依赖于编译器,也通过在定义时减少应用的整体大小来提供优化,但它的主要用途是陈述函数的异常安全性,以便其他函数可以推断函数将如何执行。
在下面的例子中,我们将noexcept
说明符添加到前面定义的foo()
函数中:
#include <iostream>
#include <stdexcept>
void foo() noexcept
{
throw std::runtime_error("The answer is: 42");
}
int main(void)
{
try {
foo();
}
catch(const std::exception &e) {
std::cout << e.what() << '\n';
}
return 0;
}
当这个例子被编译和执行时,我们得到如下结果:
如前例所示,添加了noexcept
说明符,告知编译器不允许foo()
抛出异常。然而,由于foo()
函数确实抛出了一个异常,所以当它被执行时,会调用std::terminate()
。事实上,在这个例子中,std::terminate()
将总是被调用,这是编译器能够检测和警告的事情。
调用std::terminate()
显然不是一个程序想要的结果。在这种特定的情况下,由于作者已经将该功能标记为noexcept
,因此由作者来处理所有可能的异常。这可以通过以下方式实现:
#include <iostream>
#include <stdexcept>
void foo() noexcept
{
try {
throw std::runtime_error("The answer is: 42");
}
catch(const std::exception &e) {
std::cout << e.what() << '\n';
}
}
int main(void)
{
foo();
return 0;
}
如上例所示,异常被包装在try
/ catch
块中,以确保在foo()
函数完成其执行之前安全地处理异常。此外,在本例中,仅捕获源自std::exception()
的异常。这是作者说哪些类型的异常可以安全处理的方式。例如,如果抛出一个整数而不是std::exception()
,std::terminate()
仍然会自动执行,因为noexcept
被添加到了foo()
函数中。换句话说,作为作者,您只需要处理您实际上可以安全处理的异常。剩下的会为你送到std::terminate()
;请理解,通过这样做,您改变了函数的异常安全性。如果您打算用不抛出保证来定义函数,那么该函数根本不会抛出异常。
还需要注意的是,如果将一个函数标记为noexcept
,不仅需要注意自己抛出的异常,还需要注意可能自己抛出的函数。在这种情况下,std::cout
在foo()
函数中使用,这意味着作者必须要么故意忽略std::cout
可能抛出的任何异常,这将导致对std::terminate()
的调用(这就是我们在这里所做的),要么作者需要确定std::cout
可能抛出哪些异常并尝试安全地处理它们,包括std::bad_alloc
之类的异常。
如果所提供的索引超出向量的界限,则std::vector.at()
函数抛出std::out_of_range()
异常。在这种情况下,作者可以捕捉这种类型的异常并返回一个默认值,允许作者安全地将该函数标记为noexcept
。
noexcept
说明符也可以作为一个函数,采用布尔表达式,如下例所示:
#include <iostream>
#include <stdexcept>
void foo() noexcept(true)
{
throw std::runtime_error("The answer is: 42");
}
int main(void)
{
try {
foo();
}
catch(const std::exception &e) {
std::cout << e.what() << '\n';
}
return 0;
}
执行时会产生以下结果:
如前例所示,noexcept
说明符写成了noexcept(true)
。如果表达式评估为真,则好像提供了noexcept
。如果表达式的计算结果为假,就好像省略了noexcept
说明符,允许抛出异常。在前面的例子中,表达式的计算结果为 true,这意味着函数不允许抛出异常,这导致在foo()
抛出异常时调用std::terminate()
。
让我们看一个更复杂的例子来演示如何使用它。在下面的例子中,我们将创建一个名为foo()
的函数,该函数将整数值移位 32 位,并将结果转换为 64 位整数。这个例子将使用模板元编程编写,允许我们在任何整数类型上使用这个函数:
#include <limits>
#include <iostream>
#include <stdexcept>
template<typename T>
uint64_t foo(T val) noexcept(sizeof(T) <= 4)
{
if constexpr(sizeof(T) <= 4) {
return static_cast<uint64_t>(val) << 32;
}
throw std::runtime_error("T is too large");
}
int main(void)
{
try {
uint32_t val1 = std::numeric_limits<uint32_t>::max();
std::cout << "foo: " << foo(val1) << '\n';
uint64_t val2 = std::numeric_limits<uint64_t>::max();
std::cout << "foo: " << foo(val2) << '\n';
}
catch(const std::exception &e) {
std::cout << e.what() << '\n';
}
return 0;
}
执行时会产生以下结果:
如前例所示,foo()
函数的问题是,如果用户提供 64 位整数,它不能移位 32 位而不产生溢出。然而,如果提供的整数是 32 位或更少,则foo()
功能是完全安全的。为了实现foo()
函数,我们使用了noexcept
说明符来声明如果提供的整数是 32 位或更少,则不允许该函数抛出异常。如果提供的整数大于 32 位,则允许抛出异常,在这种情况下,这是一个std::runtime_error()
异常,表示整数太大,无法安全移位。
noexcept
运算符是编译时检查,用于询问编译器某个函数是否被标记为noexcept
。在 C++ 17 中,这可以与编译时的if
语句(即在编译时评估的if
语句,可用于在编译期间从可执行文件中添加/删除代码)配对,以根据是否允许函数引发异常来更改程序的语义。
在本食谱中,我们将探索如何在您自己的代码中使用noexcept
运算符。这个运算符很重要,因为在某些情况下,您可能不知道一个函数是否能够通过简单地查看其定义来引发异常。例如,如果函数使用noexcept
说明符,您的代码可能无法确定函数是否会抛出,因为您可能不知道(基于函数的输入)noexcept
说明符的计算结果。noexcept
操作符为您提供了处理这些类型场景的机制,这是必不可少的,尤其是在元编程时。
开始之前,请确保满足所有技术要求,包括安装 Ubuntu 18.04 或更高版本,并在终端窗口中运行以下内容:
> sudo apt-get install build-essential git cmake
这将确保您的操作系统拥有适当的工具来编译和执行本食谱中的示例。完成后,打开一个新的终端。我们将使用这个终端来下载、编译和运行我们的示例。
执行以下步骤来尝试配方:
- 从新的终端,运行以下命令下载源代码:
> cd ~/
> git clone https://github.com/PacktPublishing/Advanced-CPP-CookBook.git
> cd Advanced-CPP-CookBook/chapter02
- 要编译源代码,请运行以下命令:
> mkdir build && cd build
> cmake ..
> make recipe02_examples
- 编译源代码后,您可以通过运行以下命令来执行该配方中的每个示例:
> ./recipe02_example01
could foo throw: true
> ./recipe02_example02
could foo throw: true
could foo throw: true
could foo throw: false
could foo throw: false
> ./recipe02_example03
terminate called after throwing an instance of 'std::runtime_error'
what(): The answer is: 42
Aborted
> ./recipe02_example04
> ./recipe02_example05
terminate called after throwing an instance of 'std::runtime_error'
what(): The answer is: 42
Aborted
> ./recipe02_example06
could foo throw: true
could foo throw: true
在下一节中,我们将逐一介绍这些示例,并解释每个示例程序的功能以及它与本食谱中所教授的课程之间的关系。
noexcept
运算符用于确定一个函数是否可以抛出。让我们从一个简单的例子开始:
#include <iostream>
#include <stdexcept>
void foo()
{
std::cout << "The answer is: 42\n";
}
int main(void)
{
std::cout << std::boolalpha;
std::cout << "could foo throw: " << !noexcept(foo()) << '\n';
return 0;
}
这将导致以下结果:
如前例所示,我们定义了一个输出到stdout
的foo()
函数。我们实际上并不执行foo()
,而是使用noexcept
运算符来检查foo()
函数是否可以抛出。如你所见,答案是肯定的;这个函数可以抛出。这是因为我们没有用noexcept
来标记foo()
函数,并且,如前一个配方中所述,默认情况下函数可以抛出。
还需要注意的是,我们在noexcept
表达式中加入了!
。这是因为如果函数被标记为noexcept
,则noexcept
返回true
,这意味着该函数不允许抛出。然而,在我们的例子中,我们不是问函数是否不能抛出,而是问函数是否能抛出,因此逻辑布尔反转。
让我们通过在我们的示例中添加几个函数来对此进行扩展。具体来说,在下面的例子中,我们将添加一些抛出的函数以及一些标记为noexcept
的函数:
#include <iostream>
#include <stdexcept>
void foo1()
{
std::cout << "The answer is: 42\n";
}
void foo2()
{
throw std::runtime_error("The answer is: 42");
}
void foo3() noexcept
{
std::cout << "The answer is: 42\n";
}
void foo4() noexcept
{
throw std::runtime_error("The answer is: 42");
}
int main(void)
{
std::cout << std::boolalpha;
std::cout << "could foo throw: " << !noexcept(foo1()) << '\n';
std::cout << "could foo throw: " << !noexcept(foo2()) << '\n';
std::cout << "could foo throw: " << !noexcept(foo3()) << '\n';
std::cout << "could foo throw: " << !noexcept(foo4()) << '\n';
return 0;
}
这将导致以下结果:
如前例所示,如果一个函数标有noexcept
,noexcept
运算符返回true
(在我们的示例中,它输出false
)。更重要的是,敏锐的观察者会注意到抛出异常的函数不会改变noexcept
运算符的输出。也就是说,如果某个功能可以抛出异常,则noexcept
操作符返回false
,否则会抛出异常。这很重要,因为知道函数是否会抛出异常的唯一方法是执行它。noexcept
说明符唯一声明的是函数是否允许抛出异常。没有说明是否会抛出异常*。推而广之,noexcept
运算符不会告诉您函数是否会抛出,而是告诉您该函数是否标有noexcept
说明符(更重要的是,noexcept
说明符的计算结果)。*
*在我们尝试在一个更现实的例子中使用noexcept
说明符之前,让我们看一下下面的例子:
#include <iostream>
#include <stdexcept>
void foo()
{
throw std::runtime_error("The answer is: 42");
}
int main(void)
{
foo();
}
如前面的例子所示,我们已经定义了一个抛出的foo()
函数,然后我们从我们的主函数中调用这个函数,导致std::terminate()
被调用,因为我们在离开程序之前没有处理异常。在更复杂的设置中,我们可能不知道foo()
是否抛出,因此,如果不需要的话,我们可能不想增加额外的异常处理开销。为了更好地解释这一点,让我们检查这个例子的main()
函数的结果汇编代码:
可以看到,main
函数很简单,除了调用foo
函数之外,不包含任何额外的逻辑。具体来说,main
函数没有任何捕捉逻辑。
现在,让我们在一个更具体的例子中使用noexcept
运算符:
#include <iostream>
#include <stdexcept>
void foo()
{
throw std::runtime_error("The answer is: 42");
}
int main(void)
{
if constexpr(noexcept(foo())) {
foo();
}
else {
try {
foo();
}
catch (...)
{ }
}
}
如上例所示,在 C++ 17 中添加的if
语句中,我们将noexcept
运算符与constepxr
运算符结合使用。这让我们可以问编译器foo()
是否允许抛出。如果是,我们在try
/ catch
块中执行foo()
函数,这样我们就可以根据需要处理任何可能的异常。如果我们检查这个函数的程序集,如下面的截图所示,我们可以看到一些额外的catch
逻辑被添加到生成的二进制文件中,以根据需要处理异常:
现在,让我们通过声明foo()
函数不允许使用noexcept
说明符来抛出,从而将同一个示例向前推进一步:
#include <iostream>
#include <stdexcept>
void foo() noexcept
{
throw std::runtime_error("The answer is: 42");
}
int main(void)
{
if constexpr(noexcept(foo())) {
foo();
}
else {
try {
foo();
}
catch (...)
{ }
}
}
如前例所示,由于foo()
函数被标记为noexcept
,程序调用std::terminate()
。此外,如果我们查看最终的装配,我们可以看到main()
功能不再包含额外的try
/ catch
逻辑,这意味着我们的优化成功了:
最后,如果我们不知道被调用的函数是否可以抛出,我们可能不知道如何标记自己的函数。让我们看下面的例子来说明这个问题:
#include <iostream>
#include <stdexcept>
void foo1()
{
std::cout << "The answer is: 42\n";
}
void foo2() noexcept(noexcept(foo1()))
{
foo1();
}
int main(void)
{
std::cout << std::boolalpha;
std::cout << "could foo throw: " << !noexcept(foo1()) << '\n';
std::cout << "could foo throw: " << !noexcept(foo2()) << '\n';
}
这将导致以下结果:
如前例所示,foo1()
函数没有标注noexcept
说明符,这意味着允许它抛出异常。在foo2()
中,我们想要确保我们的noexcept
说明符是正确的,但是我们称之为foo1()
,并且在这个例子中,我们假设我们不知道foo1()
是否是noexcept
。
为了确保foo2()
被正确标记,我们结合本食谱和上一份食谱中的经验教训来正确标记功能。具体来说,我们使用noexcept
运算符来告诉我们foo1()
函数是否会抛出,然后我们使用noexcept
说明符的布尔表达式语法来使用noexcept
运算符的结果来标记foo2()
是否为noexcept
。如果foo1()
标有noexcept
,noexcept
操作者将返回true
,导致foo2()
被标为noexcept(true)
,与简单的说明noexcept
相同。如果foo1()
没有标记为noexcept
,则noexcept
运算符将返回false
,此时noexcept
说明符将标记为noexcept(false)
,这与不添加noexcept
说明符(即允许函数抛出异常)是一样的。
RAII 是一种编程原则,它声明资源与获取资源的对象的生存期相关联。RAII 是 C++ 语言的一个强大特性,它确实有助于将 C++ 与 C 区分开来,有助于防止资源泄漏和一般的不稳定性。
在这个食谱中,我们将深入研究 RAII 是如何工作的,以及如何使用 RAII 来确保 C++ 异常不会导致资源泄漏。RAII 对于任何 C++ 应用来说都是一项关键技术,应该尽可能使用。
开始之前,请确保满足所有技术要求,包括安装 Ubuntu 18.04 或更高版本,并在终端窗口中运行以下内容:
> sudo apt-get install build-essential git cmake
这将确保您的操作系统拥有适当的工具来编译和执行本食谱中的示例。完成后,打开一个新的终端。我们将使用这个终端来下载、编译和运行我们的示例。
您需要执行以下步骤来尝试配方:
- 从新的终端,运行以下命令下载源代码:
> cd ~/
> git clone https://github.com/PacktPublishing/Advanced-CPP-CookBook.git
> cd Advanced-CPP-CookBook/chapter02
- 要编译源代码,请运行以下命令:
> mkdir build && cd build
> cmake ..
> make recipe03_examples
- 编译源代码后,您可以通过运行以下命令来执行该配方中的每个示例:
> ./recipe03_example01
The answer is: 42
> ./recipe03_example02
The answer is: 42
> ./recipe03_example03
The answer is not: 43
> ./recipe03_example04
The answer is: 42
> ./recipe03_example05
step 1: Collect answers
The answer is: 42
在下一节中,我们将逐一介绍这些示例,并解释每个示例程序的功能以及它与本食谱中所教授的课程之间的关系。
为了更好地理解 RAII 是如何工作的,我们必须首先研究 C++ 中的类是如何工作的,因为 C++ 类是用来实现 RAII 的。让我们看一个简单的例子。C++ 类同时支持构造函数和析构函数,如下所示:
#include <iostream>
#include <stdexcept>
class the_answer
{
public:
the_answer()
{
std::cout << "The answer is: ";
}
~the_answer()
{
std::cout << "42\n";
}
};
int main(void)
{
the_answer is;
return 0;
}
在编译和执行时,这会产生以下结果:
在前面的例子中,我们用构造函数和析构函数创建了一个类。当我们创建类的实例时,调用构造函数,当类的实例失去作用域时,类被销毁。这是一个简单的 C++ 模式,自从 C++ 的最初版本由比雅尼·斯特劳斯特鲁普创建以来就一直存在。在幕后,编译器在类第一次实例化时调用构造函数,但更重要的是,当类的实例化失去作用域时,编译器必须向执行销毁函数的程序中注入代码。这里需要理解的重要一点是,这个附加逻辑是由程序员的编译器自动插入到程序中的。
在引入类之前,程序员必须手动向程序添加构造和销毁逻辑,虽然构造是一件相当简单的事情,但销毁却不是。C 语言中这类问题的一个典型例子是存储文件句柄。程序员将添加对open()
函数的调用以打开文件句柄,当文件完成时,将添加对close()
的调用以关闭文件句柄,忘记对所有可能出现的错误情况执行close()
函数。这包括当代码长达数百行,程序中的新成员添加了另一个错误案例时,忘记根据需要调用close()
。
RAII 通过确保一旦类失去作用域,不管控制流路径是什么,获取的资源都会被释放,从而解决了这个问题。让我们看看下面的例子:
#include <iostream>
#include <stdexcept>
class the_answer
{
public:
int *answer{};
the_answer() :
answer{new int}
{
*answer = 42;
}
~the_answer()
{
std::cout << "The answer is: " << *answer << '\n';
delete answer;
}
};
int main(void)
{
the_answer is;
if (*is.answer == 42) {
return 0;
}
return 1;
}
在这个例子中,我们分配一个整数,并在类的构造函数中初始化它。这里需要注意的重要一点是,我们不需要从new
操作员那里检查nullptr
。这是因为如果内存分配失败,new
运算符将抛出异常。如果发生这种情况,不仅构造函数的其余部分不会被执行,而且对象本身也不会被构造。这意味着如果构造函数成功执行,您就知道该类的实例处于有效状态,并且实际上包含一个资源,当该类的实例失去作用域时,该资源将被销毁
该类的析构函数然后输出到stdout
并删除先前分配的内存。这里需要理解的重要一点是,无论代码采用什么控制路径,当类的实例失去作用域时,这个资源都会被释放。程序员只需要担心类的寿命。
资源的生命周期与分配资源的对象的生命周期直接相关,这一思想很重要,因为它解决了存在 C++ 异常时程序控制流的复杂问题。让我们看看下面的例子:
#include <iostream>
#include <stdexcept>
class the_answer
{
public:
int *answer{};
the_answer() :
answer{new int}
{
*answer = 43;
}
~the_answer()
{
std::cout << "The answer is not: " << *answer << '\n';
delete answer;
}
};
void foo()
{
the_answer is;
if (*is.answer == 42) {
return;
}
throw std::runtime_error("");
}
int main(void)
{
try {
foo();
}
catch(...)
{ }
return 0;
}
在这个例子中,我们创建了与前一个例子相同的类,但是,在我们的foo()
函数中,我们抛出了一个异常。但是foo()
函数不需要捕捉这个异常来确保分配的内存被正确释放。相反,析构函数为我们处理这个。在 C++ 中,许多函数可能会抛出,如果没有 RAII,每一个可能抛出的函数都需要包装在一个try
/ catch
块中,以确保分配的任何资源都被正确释放。事实上,我们在 C 代码中经常看到这种模式,尤其是在内核级编程中,使用goto
语句来确保在一个函数中,如果发生错误,该函数可以适当地展开自己,以释放之前可能获得的任何资源。这个结果是一组代码,用于检查程序中每个函数调用的结果以及正确处理错误所需的逻辑。
有了这种类型的编程模型,难怪资源泄漏在 C 中如此常见。RAII 结合 C++ 异常消除了对这种容易出错的逻辑的需求,导致代码不太可能泄漏资源。
在存在 C++ 异常的情况下如何处理 RAII 不在本书的讨论范围之内,因为它需要更深入地研究 C++ 异常支持是如何实现的。需要记住的重要一点是,C++ 异常比检查函数的返回值是否有错误更快(因为 C++ 异常是使用无开销算法实现的),但是当抛出实际的异常时会很慢(因为程序必须展开堆栈并根据需要正确执行每个类的析构函数)。由于这个原因,以及诸如可维护性等其他原因,C++ 异常永远不应该用于有效的控制流。
RAII 可以使用的另一种方式是finally
模式,由 C++ 指南支持库 ( GSL )提供。finally
模式利用 RAI 中只包含析构函数的部分,当函数的控制流复杂或可能抛出时,提供一种简单的机制来执行非基于资源的清理。考虑以下示例:
#include <iostream>
#include <stdexcept>
template<typename FUNC>
class finally
{
FUNC m_func;
public:
finally(FUNC func) :
m_func{func}
{ }
~finally()
{
m_func();
}
};
int main(void)
{
auto execute_on_exit = finally{[]{
std::cout << "The answer is: 42\n";
}};
}
在前面的例子中,我们创建了一个能够存储 lambda 函数的类,当finally
类的一个实例失去作用域时执行该函数。在这种特殊情况下,当finally
类被破坏时,我们输出到stdout
。虽然这使用了类似于 RAII 的模式,但在技术上这不是 RAII,因为没有获得任何资源。
另外,如果确实需要获取资源,应该使用 RAII 而不是finally
模式。相反,finally
模式在您没有获取资源但想要在函数返回时执行代码时很有用,不管程序采取什么控制流路径(条件分支或 C++ 异常)。
为了演示这一点,让我们看一个更复杂的例子:
#include <iostream>
#include <stdexcept>
template<typename FUNC>
class finally
{
FUNC m_func;
public:
finally(FUNC func) :
m_func{func}
{ }
~finally()
{
m_func();
}
};
int main(void)
{
try {
auto execute_on_exit = finally{[]{
std::cout << "The answer is: 42\n";
}};
std::cout << "step 1: Collect answers\n";
throw std::runtime_error("???");
std::cout << "step 3: Profit\n";
}
catch (...)
{ }
}
执行时,我们会得到以下结果:
在前面的例子中,我们希望确保无论代码做什么,我们总是输出到stdout
。在执行过程中,我们抛出了一个异常,即使抛出了异常,我们的finally
代码也是按预期执行的。
在这个食谱中,我们将讨论 C++ 异常的问题,特别是在类析构函数中抛出异常,这是应该不惜一切代价避免的。这个食谱中的经验很重要,因为与其他函数不同,C++ 类析构函数在默认情况下被标记为noexcept
,这意味着如果你不小心在类析构函数中抛出了一个异常,你的程序将调用std::terminate()
,即使析构函数没有被公开标记为noexcept
。
开始之前,请确保满足所有技术要求,包括安装 Ubuntu 18.04 或更高版本,并在终端窗口中运行以下内容:
> sudo apt-get install build-essential git cmake
这将确保您的操作系统拥有适当的工具来编译和执行本食谱中的示例。完成后,打开一个新的终端。我们将使用这个终端来下载、编译和运行我们的示例。
执行以下步骤来尝试配方:
- 从新的终端,运行以下命令下载源代码:
> cd ~/
> git clone https://github.com/PacktPublishing/Advanced-CPP-CookBook.git
> cd Advanced-CPP-CookBook/chapter02
- 要编译源代码,请运行以下命令:
> mkdir build && cd build
> cmake ..
> make recipe04_examples
- 编译源代码后,您可以通过运行以下命令来执行该配方中的每个示例:
> ./recipe04_example01
terminate called after throwing an instance of 'std::runtime_error'
what(): 42
Aborted
> ./recipe04_example02
The answer is: 42
> ./recipe04_example03
terminate called after throwing an instance of 'std::runtime_error'
what(): 42
Aborted
> ./recipe04_example04
# exceptions: 2
The answer is: 42
The answer is: always 42
在下一节中,我们将逐一介绍这些示例,并解释每个示例程序的功能以及它与本食谱中所教授的课程之间的关系。
在这个食谱中,我们将了解为什么在析构函数中抛出异常是一个糟糕的想法,以及为什么类析构函数被默认标记为noexcept
。首先,让我们看一个简单的例子:
#include <iostream>
#include <stdexcept>
class the_answer
{
public:
~the_answer()
{
throw std::runtime_error("42");
}
};
int main(void)
{
try {
the_answer is;
}
catch (const std::exception &e) {
std::cout << "The answer is: " << e.what() << '\n';
}
}
当我们执行此操作时,我们会得到以下结果:
在这个例子中,我们可以看到,如果我们从类析构函数抛出一个异常,就会调用std::terminate()
。这是因为,默认情况下,类析构函数被标记为noexcept
。
我们可以通过将类的析构函数标记为noexcept(false)
来显式允许类的析构函数抛出来改变这一点,如下例所示:
#include <iostream>
#include <stdexcept>
class the_answer
{
public:
~the_answer() noexcept(false)
{
throw std::runtime_error("42");
}
};
int main(void)
{
try {
the_answer is;
}
catch (const std::exception &e) {
std::cout << "The answer is: " << e.what() << '\n';
}
}
如前面的示例所示,当类被销毁时,会引发异常并得到正确处理。即使成功处理了这个问题,我们也要问自己,在我们捕捉到这个异常后,程序的状态是什么?析构函数没有成功完成。如果这个类更复杂,并且有它管理的状态/资源,我们能断定我们关心的状态/资源被正确处理/释放了吗?简短的回答是否定的,这和用锤子破坏硬盘是一样的。如果你用锤子猛击硬盘来破坏它,你真的破坏了硬盘上的数据吗?没有办法知道,因为当你用锤子敲击硬盘时,你打碎了用来回答这个问题的电子设备。当您试图销毁硬盘驱动器时,您需要一个可靠的过程来确保在任何情况下销毁驱动器的过程都不会使数据处于可恢复状态。否则,你没有办法知道自己处于什么状态,没有办法回去。
这同样适用于 C++ 类。销毁一个 C++ 类需要是一个必须提供基本异常安全的操作(也就是说,程序的状态是确定性的,有一些可能的副作用)。否则,唯一的另一个逻辑行动就是调用std::terminate()
,因为你无法确定如果程序继续执行会发生什么。
除了将程序置于未定义状态之外,从析构函数中抛出异常的另一个问题是,如果已经抛出了异常,会发生什么?try
/ catch
区块捕捉到了什么?让我们看一个这类问题的例子:
#include <iostream>
#include <stdexcept>
class the_answer
{
public:
~the_answer() noexcept(false)
{
throw std::runtime_error("42");
}
};
int main(void)
{
try {
the_answer is;
throw std::runtime_error("first exception");
}
catch (const std::exception &e) {
std::cout << "The answer is: " << e.what() << '\n';
}
}
在前面的例子中,我们将析构函数标记为noexcept(false)
,就像我们在前面的例子中所做的那样,但是我们在调用析构函数之前抛出,这意味着,当调用析构函数时,已经有一个异常正在被处理。现在,当我们试图抛出时,虽然析构函数被标记为noexcept(false)
,但是std::terminate()
被调用:
原因是 C++ 库没有办法处理这种情况,因为try
/ catch
块不能处理多个异常。然而,可能有一个以上的未决例外;我们只需要一个try
/ catch
块来处理每个异常。当我们有嵌套异常时,就会出现这种情况,如本例所示:
#include <iostream>
#include <stdexcept>
class nested
{
public:
~nested()
{
std::cout << "# exceptions: " << std::uncaught_exceptions() << '\n';
}
};
class the_answer
{
public:
~the_answer()
{
try {
nested n;
throw std::runtime_error("42");
}
catch (const std::exception &e) {
std::cout << "The answer is: " << e.what() << '\n';
}
}
};
在本例中,我们将从创建一个输出调用std::uncaught_exceptions()
结果的类开始,该类返回当前正在处理的异常总数。然后,我们将创建第二个类,该类创建第一个类,然后从其析构函数中抛出,需要注意的是,析构函数中的所有代码都被包装在一个try
/ catch
块中:
int main(void)
{
try {
the_answer is;
throw std::runtime_error("always 42");
}
catch (const std::exception &e) {
std::cout << "The answer is: " << e.what() << '\n';
}
}
执行此示例时,我们会得到以下结果:
最后,我们将创建这个第二类,并用另一个try
/ catch
块再次抛出。与前面的例子不同,所有的异常都得到了正确的处理,事实上,不需要noexcept(false)
来确保这段代码正确执行,因为对于抛出的每个异常,我们都有一个try
/ catch
块。即使一个异常在析构函数中被抛出,它也得到正确的处理,这意味着析构函数安全地执行并保持noexcept
兼容,即使第二个类在两个正在处理的异常存在的情况下执行。
在本食谱中,您将学习如何轻松创建自己的异常类型。这是需要学习的重要一课,因为虽然 C++ 异常很容易自己创建,但是应该遵循一些准则来确保安全地完成。
开始之前,请确保满足所有技术要求,包括安装 Ubuntu 18.04 或更高版本,并在终端窗口中运行以下内容:
> sudo apt-get install build-essential git cmake
这将确保您的操作系统拥有适当的工具来编译和执行本食谱中的示例。完成后,打开一个新的终端。我们将使用这个终端来下载、编译和运行我们的示例。
执行以下步骤来尝试配方:
- 从新的终端,运行以下命令下载源代码:
> cd ~/
> git clone https://github.com/PacktPublishing/Advanced-CPP-CookBook.git
> cd Advanced-CPP-CookBook/chapter02
- 要编译源代码,请运行以下命令:
> mkdir build && cd build
> cmake ..
> make recipe05_examples
- 编译源代码后,您可以通过运行以下命令来执行该配方中的每个示例:
> ./recipe05_example01
The answer is: 42
> ./recipe05_example02
The answer is: 42
> ./recipe05_example03
The answer is: 42
在下一节中,我们将逐一介绍这些示例,并解释每个示例程序的功能以及它与本食谱中所教授的课程之间的关系。
创建您自己的 C++ 异常允许您过滤出您得到的异常类型。例如,异常是来自您的代码还是 C++ 库?通过创建自己的 C++ 异常,您可以在运行时用自己的代码轻松回答这些问题。让我们看看下面的例子:
#include <iostream>
#include <stdexcept>
class the_answer : public std::exception
{
public:
the_answer() = default;
const char *what() const noexcept
{
return "The answer is: 42";
}
};
int main(void)
{
try {
throw the_answer{};
}
catch (const std::exception &e) {
std::cout << e.what() << '\n';
}
}
如前面的例子所示,我们通过继承std::exception
来创建自己的 C++ 异常。这不是一个要求。从技术上讲,任何东西都可以是 C++ 异常,包括整数。然而,从std::exception
开始,给了你一个标准的工作界面,包括覆盖what()
函数,该函数描述了抛出的异常。
在前面的例子中,我们在what()
函数中返回一个硬编码字符串。这是理想的异常类型(甚至比 C++ 库提供的异常更理想)。这是因为这种类型的异常是nothrow copy-constructable
。具体来说,这意味着可以复制异常本身,而副本不会生成异常,例如由于std::bad_alloc
。C++ 库提供的异常类型支持从std::string()
开始构造,这可能会抛出std::bad_alloc
。
前面的 C++ 异常的问题是,对于您希望提供的每种类型的消息,您都需要1
异常类型。实现安全异常类型的另一种方法是使用以下内容:
#include <iostream>
#include <stdexcept>
class the_answer : public std::exception
{
const char *m_str;
public:
the_answer(const char *str):
m_str{str}
{ }
const char *what() const noexcept
{
return m_str;
}
};
int main(void)
{
try {
throw the_answer("42");
}
catch (const std::exception &e) {
std::cout << "The answer is: " << e.what() << '\n';
}
}
在前面的例子中,我们存储了一个指向const char*
的指针(即 C 风格的字符串)。c 风格的字符串在程序中作为常量全局存储。这种类型的异常满足前面所有相同的规则,并且在构建异常的过程中不会发生分配。还应该注意的是,由于字符串是全局存储的,这种类型的操作是安全的。
使用这种方法可以创建许多类型的异常,包括除了字符串之外的可以通过自定义 getters 访问的东西(也就是说,不必使用what()
函数)。但是,如果前面的这些规则对您来说不是问题,创建自定义 C++ 异常的最简单方法是简单地将现有的 C++ 异常子类化,如std::runtime_error()
,如下例所示:
#include <iostream>
#include <stdexcept>
#include <string.h>
class the_answer : public std::runtime_error
{
public:
explicit the_answer(const char *str) :
std::runtime_error{str}
{ }
};
int main(void)
{
try {
throw the_answer("42");
}
catch (const the_answer &e) {
std::cout << "The answer is: " << e.what() << '\n';
}
catch (const std::exception &e) {
std::cout << "unknown exception: " << e.what() << '\n';
}
}
执行此示例时,我们会得到以下结果:
在前面的例子中,我们通过子类化std::runtime_error()
,只用几行代码就创建了自己的 C++ 异常。然后我们可以使用不同的catch
块来计算抛出了什么类型的异常。请记住,如果您使用std::string
版本的std::runtime_error()
,您可能会在异常本身的构建过程中被抛出std::bad_alloc
。**