C++-高级编程秘籍(全)

C++ 高级编程秘籍(全)

原文:annas-archive.org/md5/24e080e694c59b3f8e0220d0902724b0

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

在本书中,你将学习高级 C++技术,可以应用于自己的 C++项目。本书使用食谱式方法教授 C++,每个食谱都有示例和屏幕截图,可以从 GitHub 下载并自行操作。本书使用 C++17 规范教授 C++,并在最后一章预览了添加到 C++20 的新功能。在一些食谱中,我们甚至会使用反汇编器来更好地理解 C++的编译方式,以及某些决策对应用程序的影响。通过本书,你将掌握 C++的高级概念,并能解决日常问题,从而将你的 C++编程提升到更高水平。

本书适合谁

本书适用于熟悉 C++并希望获得专业技能并成为熟练 C++开发人员的中级 C++开发人员。假定对语言有很好的理解,包括对汇编语言的基本理解。

本书涵盖内容

第一章,开始使用库开发,教你如何开发自己的库,包括最少惊讶原则的解释,如何对所有内容进行命名空间处理,如何编写仅包含头文件的库,以及如何确保其他人将继续使用你的库。

第二章,使用异常进行错误处理,涵盖了 C++异常和错误处理的更高级主题,包括对noexcept说明符和运算符的详细解释,RAII 如何在异常存在的情况下支持资源管理,为什么应避免从析构函数中抛出异常,以及如何编写自己的异常。

第三章,实现移动语义,详细解释了 C++移动语义,包括大五的解释,如何使你的类可移动,如何编写仅移动(和非移动)非复制样式的类,如何正确实现移动构造函数,为什么const &&没有意义,以及如何使用引用资格。

第四章,使用模板进行通用编程,教你如何像专家一样编写模板函数,包括如何实现自己的 SFINAE,如何执行完美转发,如何使用constexpr-if语句,如何利用参数包的元组,如何在编译时循环参数包,如何使用类型特征来实现相同函数的不同版本,如何使用template<auto>,以及如何在自己的应用程序中利用显式类型声明。

第五章,并发和同步,教你如何使用std::mutex(及其相关内容),何时使用原子类型,如何使用mutable关键字处理具有线程安全性的const类,如何编写线程安全类,如何编写线程安全包装器,以及如何编写异步 C++,包括 promises 和 futures。

第六章,优化代码以提高性能,介绍了如何对 C++进行性能分析和基准测试,如何反汇编 C++以更好地理解如何优化代码,如何定位并删除不需要的内存分配,以及为什么noexcept有助于优化。

第七章,调试和测试,指导你如何使用Catch2来对 C++进行单元测试,如何使用 Google 的 ASAN 和 UBSAN 清除器动态分析代码以检测内存损坏和未定义行为,以及如何使用 NDEBUG。

第八章,创建和实现自己的容器,教你如何通过创建始终排序的std::vector来编写自己的容器包装器。

第九章,探索类型擦除,教授了关于类型擦除的一切,包括如何通过继承和使用模板来擦除类型,如何实现类型擦除模式,以及如何实现委托模式。

第十章,深入了解动态分配,教授动态内存分配的高级主题,包括如何正确使用std::unique_ptrstd::shared_ptr,如何处理循环引用,如何对智能指针进行类型转换,以及堆是如何在后台工作以为应用程序提供动态内存的。

第十一章,C++中的常见模式,解释了计算机科学中不同模式在 C++中的实现,包括工厂模式、单例模式、装饰器模式和观察者模式,以及如何实现静态多态性以编写自己的静态接口而无需虚继承。

第十二章,深入了解类型推导,深入探讨了 C++17 中类型推导的执行方式,包括autodecltypetemplate如何自动推断其类型。本章最后还提供了如何编写自己的 C++17 用户定义的推导指南的示例。

第十三章,奖励:使用 C++20 特性,提供了 C++20 即将推出的新特性的预览,包括概念、模块、范围和协程。

充分利用本书

我们假设您以前已经编写过 C++,并且已经熟悉了一些现代 C++特性。

本书使用 Ubuntu 提供示例,您可以在阅读本书时自行编译和运行。我们假设您对 Ubuntu 有一些基本的了解,知道如何安装它,以及如何使用 Linux 终端。

我们在一些示例中使用反汇编器来更好地理解编译器在幕后的工作。虽然您不需要知道如何阅读汇编代码来理解所教授的内容,但对 x86_64 汇编的基本理解将有所帮助。

下载示例代码文件

您可以从www.packt.com的帐户中下载本书的示例代码文件。如果您在其他地方购买了本书,可以访问www.packtpub.com/support注册,直接将文件发送到您的邮箱。

您可以按照以下步骤下载代码文件:

  1. 登录或在www.packt.com注册。

  2. 选择支持选项卡。

  3. 点击代码下载。

  4. 在搜索框中输入书名,然后按照屏幕上的说明操作。

下载文件后,请确保使用最新版本的解压缩软件解压或提取文件夹。

  • Windows 上的 WinRAR/7-Zip

  • Mac 上的 Zipeg/iZip/UnRarX

  • Linux 上的 7-Zip/PeaZip

该书的代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Advanced-CPP-Programming-CookBook。如果代码有更新,将在现有的 GitHub 存储库上进行更新。

我们还有其他代码包,来自我们丰富的图书和视频目录,可在github.com/PacktPublishing/上找到。快去看看吧!

代码实例

访问以下链接查看代码运行的视频:bit.ly/2tQoZyW

使用的约定

本书中使用了许多文本约定。

constexpr:指示文本中的代码字、数字、文件夹名称、文件名、文件扩展名、路径名、虚拟 URL 和用户输入。例如:"noexcept说明符用于告诉编译器函数是否可能引发 C++异常。"

代码块设置如下:

int main(void)
{
    the_answer is;
    return 0;
}

当我们希望引起您对代码块的特定部分的注意时,相关的行或项目会以粗体显示:

int main(void)
{
    auto execute_on_exit = finally{[]{
        std::cout << "The answer is: 42\n";
    }};
}

任何命令行输入或输出都将按以下方式编写:

> mkdir build && cd build
> cmake ..
> make recipe04_examples

粗体:表示一个新术语,一个重要词或您在屏幕上看到的词。例如,重要单词会以这种方式出现在文本中。这里有一个例子:“在这个食谱中,我们将学习为什么在析构函数中抛出异常是一个坏主意。”

警告或重要说明会出现在这样的地方。

提示和技巧会出现在这样的地方。

部分

在这本书中,您会发现一些经常出现的标题(准备好如何做...它是如何工作的...还有更多...另请参阅)。

为了清晰地说明如何完成一个食谱,请按照以下部分进行操作:

准备好

这一部分告诉您在食谱中可以期待什么,并描述如何设置任何软件或食谱所需的任何初步设置。

如何做...

这一部分包含了遵循食谱所需的步骤。

它是如何工作的...

这一部分通常包括了对前一部分发生的事情的详细解释。

还有更多...

这部分包括有关食谱的额外信息,以使您对食谱更加了解。

另请参阅

这一部分为食谱提供了其他有用信息的有用链接。

第一章:开始使用库开发

在本章中,我们将介绍一些有用的配方,用于创建我们自己的库,包括最少惊讶原则的解释,该原则鼓励我们使用用户已经熟悉的语义来实现库。我们还将看看如何对所有内容进行命名空间处理,以确保我们的自定义库不会与其他库发生冲突。此外,我们还将介绍如何创建仅包含头文件的库,以及与库开发相关的一些最佳实践。最后,我们将通过演示 boost 库来结束本章,以向您展示一个大型库的样子以及用户如何在自己的项目中使用它。

在本章中,我们将介绍以下配方:

  • 理解最少惊讶原则

  • 如何对所有内容进行命名空间处理

  • 仅包含头文件的库

  • 学习库开发的最佳实践

  • 学习如何使用 boost API

让我们开始吧!

技术要求

要编译和运行本章中的示例,您必须具有管理访问权限,可以访问运行 Ubuntu 18.04 的计算机,并具有正常的互联网连接。在运行这些示例之前,您必须使用以下命令安装以下软件包:

> sudo apt-get install build-essential git cmake

如果这个安装在除 Ubuntu 18.04 之外的任何操作系统上,那么将需要 GCC 7.4 或更高版本和 CMake 3.6 或更高版本。

理解最少惊讶原则

在使用现有的 C++库或创建自己的库时,理解最少惊讶原则(也称为最少惊讶原则)对于高效和有效地开发源代码至关重要。这个原则简单地指出,C++库提供的任何功能都应该是直观的,并且应该按照开发人员的期望进行操作。另一种说法是,库的 API 应该是自我记录的。尽管这个原则在设计库时至关重要,但它可以并且应该应用于所有形式的软件开发。在本教程中,我们将深入探讨这个原则。

准备工作

与本章中的所有配方一样,确保已满足所有技术要求,包括安装 Ubuntu 18.04 或更高版本,并在终端窗口中运行以下命令:

> sudo apt-get install build-essential git cmake

这将确保您的操作系统具有正确的工具来编译和执行本教程中的示例。完成后,打开一个新的终端。我们将使用这个终端来下载、编译和运行我们的示例。

如何做...

执行以下步骤完成本教程:

  1. 从新的终端运行以下代码来下载源代码:
> cd ~/
> git clone https://github.com/PacktPublishing/Advanced-CPP-CookBook.git
> cd Advanced-CPP-CookBook/chapter01
  1. 要编译源代码,请运行以下代码:
> mkdir build && cd build
> cmake ..
> make recipe01_examples
  1. 一旦源代码被编译,您可以通过运行以下命令来执行本教程中的每个示例:
> ./recipe01_example01
The answer is: 42

> ./recipe01_example02
The answer is: 42

> ./recipe01_example03
The answer is: 42

> ./recipe01_example04
The answer is: 42
The answer is: 42

> ./recipe01_example05
The answer is: 42
The answer is: 42

> ./recipe01_example06
The answer is: 42
The answer is: 42

> ./recipe01_example07
The answer is: 42

> ./recipe01_example08
The answer is: 42

> ./recipe01_example09
The answer is: 42

在下一节中,我们将逐个介绍这些示例,并解释每个示例程序的功能以及它与本教程中所教授的课程的关系。

它是如何工作的...

如前一节所述,最少惊讶原则指出,库的 API 应该直观且自我记录,这个原则通常适用于所有形式的软件开发,而不仅仅是库设计。为了理解这一点,我们将看一些例子。

示例 1

示例 1 演示了最少惊讶原则,如下所示:

#include <iostream>

int sub(int a, int b)
{ return a + b; }

int main(void)
{
    std::cout << "The answer is: " << sub(41, 1) << '\n';
    return 0;
}

如前面的示例所示,我们实现了一个库 API,它可以将两个整数相加并返回结果。问题在于我们将函数命名为sub,大多数开发人员会将其与减法而不是加法联系起来;尽管 API 按设计工作,但它违反了最少惊讶原则,因为 API 的名称不直观。

示例 2

示例 2 演示了最少惊讶原则,如下所示:

#include <iostream>

void add(int a, int &b)
{ b += a; }

int main(void)
{
    int a = 41, b = 1;
    add(a, b);

    std::cout << "The answer is: " << b << '\n';
    return 0;
}

如前面的例子所示,我们已经实现了与上一个练习中实现的相同的库 API;它旨在添加两个数字并返回结果。这个例子的问题在于 API 实现了以下内容:

b += a;

在这个例子中,最少惊讶原则以两种不同的方式被违反:

  • add 函数的参数是a,然后是b,尽管我们会将这个等式写成b += a,这意味着参数的顺序在直觉上是相反的。

  • 对于这个 API 的用户来说,不会立即明显地意识到结果将在b中返回,而不必阅读源代码。

函数的签名应该使用用户已经习惯的语义来记录函数将如何执行,从而降低用户错误执行 API 的概率。

示例 3

示例 3 演示了最少惊讶原则如下:

#include <iostream>

int add(int a, int b)
{ return a + b; }

int main(void)
{
    std::cout << "The answer is: " << add(41, 1) << '\n';
    return 0;
}

如前面的例子所示,我们在这里遵循了最少惊讶原则。API 旨在将两个整数相加并返回结果,API 直观地执行了预期的操作。

示例 4

示例 4 演示了最少惊讶原则如下:

#include <stdio.h>
#include <iostream>

int main(void)
{
    printf("The answer is: %d\n", 42);
    std::cout << "The answer is: " << 42 << '\n';
    return 0;
}

如前面的例子所示,另一个很好的最少惊讶原则的例子是printf()std::cout之间的区别。printf()函数需要添加格式说明符来将整数输出到stdoutprintf()不直观的原因有很多:

  • 对于初学者来说,printf()函数的名称,代表打印格式化,不直观(或者换句话说,函数的名称不是自我说明的)。其他语言通过选择更直观的打印函数名称来避免这个问题,比如print()console(),这些名称更好地遵循了最少惊讶原则。

  • 整数的格式说明符符号是d。对于初学者来说,这是不直观的。在这种特定情况下,d代表十进制,这是说有符号整数的另一种方式。更好的格式说明符可能是i,以匹配语言对int的使用。

std::cout相比,它代表字符输出。虽然与print()console()相比这不太直观,但比printf()更直观。此外,要将整数输出到stdout,用户不必记忆格式说明符表来完成任务。相反,他们可以简单地使用<<运算符。然后,API 会为您处理格式,这不仅更直观,而且更安全(特别是在使用std::cin而不是scanf()时)。

示例 5

示例 5 演示了最少惊讶原则如下:

#include <iostream>

int main(void)
{
    auto answer = 41;

    std::cout << "The answer is: " << ++answer << '\n';
    std::cout << "The answer is: " << answer++ << '\n';

    return 0;
}

在前面的例子中,++运算符遵循最少惊讶原则。尽管初学者需要学习++代表递增运算符,意味着变量增加1,但++与变量的位置相当有帮助。

要理解++variablevariable++之间的区别,用户只需像平常一样从左到右阅读代码。当++在左边时,变量被递增,然后返回变量的内容。当++在右边时,返回变量的内容,然后递增变量。关于++位置的唯一问题是,左边的++通常更有效率(因为实现不需要额外的逻辑来存储递增操作之前的变量值)。

示例 6

示例 6 演示了最少惊讶原则如下:

#include <iostream>

int add(int a, int b)
{ return a + b; }

int Sub(int a, int b)
{ return a - b; }

int main(void)
{
    std::cout << "The answer is: " << add(41, 1) << '\n';
    std::cout << "The answer is: " << Sub(43, 1) << '\n';

    return 0;
}

如前面的代码所示,我们实现了两个不同的 API。第一个是将两个整数相加并返回结果,而第二个是将两个整数相减并返回结果。减法函数的问题有两个:

  • 加法函数是小写的,而减法函数是大写的。这不直观,API 的用户必须学习哪些 API 是小写的,哪些是大写的。

  • C++标准 API 都是蛇形命名法,意思是它们利用小写单词并使用_来表示空格。一般来说,最好设计 C++库 API 时使用蛇形命名法,因为初学者更有可能找到这种方式直观。值得注意的是,尽管这通常是这样,但蛇形命名法的使用是高度主观的,有几种语言不遵循这一指导。最重要的是选择一个约定并坚持下去。

再次确保您的 API 模仿现有语义,确保用户可以快速轻松地学会使用您的 API,同时降低用户错误编写 API 的可能性,从而导致编译错误。

示例 7

示例 7 演示了最小惊讶原则的如下内容:

#include <queue>
#include <iostream>

int main(void)
{
    std::queue<int> my_queue;

    my_queue.emplace(42);
    std::cout << "The answer is: " << my_queue.front() << '\n';
    my_queue.pop();

    return 0;
}

在前面的例子中,我们向您展示了如何使用std::queue将整数添加到队列中,将队列输出到stdout,并从队列中删除元素。这个例子的重点是要突出 C++已经有一套标准的命名约定,应该在 C++库开发过程中加以利用。

如果您正在设计一个新的库,使用 C++已经定义的相同命名约定对您的库的用户是有帮助的。这样做将降低使用门槛,并提供更直观的 API。

示例 8

示例 8 演示了最小惊讶原则的如下内容:

#include <iostream>

auto add(int a, int b)
{ return a + b; }

int main(void)
{
    std::cout << "The answer is: " << add(41, 1) << '\n';
    return 0;
}

如前面的例子所示,我们展示了auto的使用方式,告诉编译器自动确定函数的返回类型,这不符合最小惊讶原则。尽管auto对于编写通用代码非常有帮助,但在设计库 API 时应尽量避免使用。特别是为了让 API 的用户理解 API 的输入和输出,用户必须阅读 API 的实现,因为auto不指定输出类型。

示例 9

示例 9 演示了最小惊讶原则的如下内容:

#include <iostream>

template <typename T>
T add(T a, T b)
{ return a + b; }

int main(void)
{
    std::cout << "The answer is: " << add(41, 1) << '\n';
    return 0;
}

如前面的例子所示,我们展示了一种更合适的方式来支持最小惊讶原则,同时支持通用编程。通用编程(也称为模板元编程或使用 C++模板进行编程)为程序员提供了一种在不声明算法中使用的类型的情况下创建算法的方法。在这种情况下,add函数不会规定输入类型,允许用户添加任何类型的两个值(在这种情况下,类型称为T,可以采用支持add运算符的任何类型)。我们返回一个类型T,而不是返回auto,因为auto不会声明输出类型。尽管T在这里没有定义,因为它代表任何类型,但它告诉 API 的用户,我们输入到这个函数中的任何类型也将被函数返回。这种逻辑在 C++标准库中大量使用。

如何对一切进行命名空间

创建库时,对一切进行命名空间是很重要的。这样做可以确保库提供的 API 不会与用户代码或其他库提供的设施发生名称冲突。在本示例中,我们将演示如何在我们自己的库中做到这一点。

准备工作

与本章中的所有示例一样,请确保已满足所有技术要求,包括安装 Ubuntu 18.04 或更高版本,并在终端窗口中运行以下命令:

> sudo apt-get install build-essential git cmake

这将确保您的操作系统具有编译和执行本示例所需的适当工具。完成后,打开一个新的终端。我们将使用这个终端来下载、编译和运行我们的示例。

如何做...

要完成本文,您需要执行以下步骤:

  1. 从新的终端中,运行以下命令下载源代码:
> cd ~/
> git clone https://github.com/PacktPublishing/Advanced-CPP-CookBook.git
> cd Advanced-CPP-CookBook/chapter01
  1. 要编译源代码,请运行以下代码:
> mkdir build && cd build
> cmake ..
> make recipe02_examples
  1. 一旦源代码被编译,您可以通过运行以下命令来执行本文中的每个示例:
> ./recipe02_example01
The answer is: 42

> ./recipe02_example02
The answer is: 42

在下一节中,我们将逐个介绍这些示例,并解释每个示例程序的作用以及它与本文所教授的课程的关系。

它是如何工作的...

C++提供了将代码包裹在namespace中的能力,这简单地将namespace名称添加到namespace代码中的所有函数和变量(应该注意的是,C 风格的宏不包括在namespace中,并且应该谨慎使用,因为 C 宏是预处理器功能,不会对代码的编译语法产生影响)。为了解释为什么我们在创建自己的库时应该将所有东西都放在namespace中,我们将看一些例子。

示例 1

示例 1 演示了如何在 C++namespace中包裹库的 API:

// Contents of library.h

namespace library_name
{
    int my_api() { return 42; }
    // ...
}

// Contents of main.cpp

#include <iostream>

int main(void)
{
    using namespace library_name;

    std::cout << "The answer is: " << my_api() << '\n';
    return 0;
}

如上例所示,库的内容被包裹在一个namespace中,并存储在头文件中(这个例子演示了一个头文件库,这是一种非常有用的设计方法,因为最终用户不需要编译库,将其安装在他/她的系统上,然后链接到它们)。库用户只需包含库头文件,并使用using namespace library_name语句来解开库的 API。如果用户有多个具有相同 API 名称的库,可以省略此语句以消除任何歧义。

示例 2

示例 2 扩展了上一个示例,并演示了如何在 C++命名空间头文件库中包裹库的 API,同时包括全局变量:

// Contents of library.h

namespace library_name
{
    namespace details { inline int answer = 42; }

    int my_api() { return details::answer; }
    // ...
}

// Contents of main.cpp

#include <iostream>

int main(void)
{
    using namespace library_name;

    std::cout << "The answer is: " << my_api() << '\n';
    return 0;
}

在上面的例子中,利用 C++17 创建了一个包裹在我们库的namespace中的inline全局变量。inline变量是必需的,因为头文件库没有源文件来定义全局变量;没有inline关键字,在头文件中定义全局变量会导致变量被多次定义(也就是说,在编译过程中会出现链接错误)。C++17 通过添加inline全局变量解决了这个问题,这允许头文件库定义全局变量而无需使用 tricky magic(比如从单例样式函数返回静态变量的指针)。

除了库的namespace,我们还将全局变量包裹在details namespace中。这是为了在库的用户声明using namespace library_name的情况下,在库内创建一个private的地方。如果用户这样做,所有被library_name命名空间包裹的 API 和变量都会在main()函数的范围内变得全局可访问。因此,任何不希望用户访问的私有 API 或变量都应该被第二个namespace(通常称为details)包裹起来,以防止它们的全局可访问性。最后,利用 C++17 的inline关键字允许我们在库中创建全局变量,同时仍然支持头文件库的设计。

头文件库

头文件库就像它们的名字一样;整个库都是使用头文件实现的(通常是一个头文件)。头文件库的好处在于,它们很容易包含到您的项目中,只需包含头文件即可(不需要编译库,因为没有需要编译的源文件)。在本配方中,我们将学习在尝试创建头文件库时出现的一些问题以及如何克服这些问题。这个配方很重要,因为如果您计划创建自己的库,头文件库是一个很好的起点,并且可能会增加您的库被下游用户整合到他们的代码库中的几率。

准备工作

与本章中的所有配方一样,请确保已满足所有技术要求,包括安装 Ubuntu 18.04 或更高版本,并在终端窗口中运行以下命令:

> sudo apt-get install build-essential git cmake

这将确保您的操作系统具有正确的工具来编译和执行本配方中的示例。完成这些步骤后,打开一个新的终端。我们将使用这个终端来下载、编译和运行我们的示例。

操作步骤...

完成此配方,您需要执行以下步骤:

  1. 从新的终端中,运行以下命令以下载源代码:
> cd ~/
> git clone https://github.com/PacktPublishing/Advanced-CPP-CookBook.git
> cd Advanced-CPP-CookBook/chapter01
  1. 要编译源代码,请运行以下代码:
> mkdir build && cd build
> cmake ..
> make recipe03_examples
  1. 源代码编译完成后,您可以通过运行以下命令执行本配方中的每个示例:
> ./recipe03_example01
The answer is: 42

> ./recipe03_example02
The answer is: 42

> ./recipe03_example03
The answer is: 42

> ./recipe03_example04
The answer is: 42
The answer is: 2a

> ./recipe03_example05

> ./recipe03_example06
The answer is: 42

> ./recipe03_example07
The answer is: 42

在下一节中,我们将逐个介绍这些示例,并解释每个示例程序的作用以及它们与本配方中所教授的课程的关系。

工作原理...

要创建一个头文件库,只需确保所有代码都在头文件中实现,如下所示:

#ifndef MY_LIBRARY
#define MY_LIBRARY

namespace library_name
{
    int my_api() { return 42; }
}

#endif

前面的示例实现了一个简单的库,其中有一个函数。这个库的整个实现可以在一个头文件中实现,并包含在我们的代码中,如下所示:

#include "my_library.h"
#include <iostream>

int main(void)
{
    using namespace library_name;

    std::cout << "The answer is: " << my_api() << '\n';
    return 0;
}

尽管创建头文件库似乎很简单,但在尝试创建头文件库时会出现一些问题,这些问题应该考虑在内。

如何处理包含

在前面的示例中,您可能已经注意到,当我们使用我们的自定义头文件库时,我们首先包含了库。这是编写头文件库的一个基本步骤。在为头文件库编写示例或测试时,我们的库应该是我们包含的第一件事,以确保所有头文件的依赖关系都在头文件库中定义,而不是在我们的示例或测试中定义。

例如,假设我们将我们的库更改如下:

#ifndef MY_LIBRARY
#define MY_LIBRARY

namespace library_name
{
    void my_api()
    {
        std::cout << "The answer is: 42" << '\n';
    }
}

#endif

如前面的代码片段所示,我们的 API 现在不再返回整数,而是输出到 stdout。我们可以如下使用我们的新 API:

#include <iostream>
#include "my_library.h"

int main(void)
{
    library_name::my_api();
    return 0;
}

尽管前面的代码编译和运行如预期,但代码中存在一个错误,这个错误可能只有您的库的用户才能识别出来。具体来说,如果您的库的用户交换了包含的顺序或者没有#include <iostream>,代码将无法编译并产生以下错误:

这是因为头文件库本身没有包含所有的依赖关系。由于我们的示例将库放在其他包含之后,我们的示例意外地隐藏了这个问题。因此,当创建自己的头文件库时,始终在测试和示例中首先包含库,以确保这种类型的问题永远不会发生在您的用户身上。

全局变量

头文件库的最大限制之一是,在 C++17 之前,没有办法创建全局变量。尽管应尽量避免使用全局变量,但有些情况下是必需的。为了演示这一点,让我们创建一个简单的 API,输出到 stdout 如下:

#ifndef MY_LIBRARY
#define MY_LIBRARY

#include <iostream>
#include <iomanip>

namespace library_name
{
    void my_api(bool show_hex = false)
    {
        if (show_hex) {
            std::cout << std::hex << "The answer is: " << 42 << '\n';
        }
        else {
            std::cout << std::dec << "The answer is: " << 42 << '\n';
        }
    }
}

#endif

前面的示例创建了一个 API,将输出到stdout。如果使用true而不是默认的false执行 API,则将以十六进制而不是十进制格式输出整数。在这个例子中,从十进制到十六进制的转换实际上是我们库中的一个配置设置。然而,如果没有全局变量,我们将不得不采用其他机制来实现这一点,包括宏或前面的示例中的函数参数;后者选择甚至更糟,因为它将库的配置与其 API 耦合在一起,这意味着任何额外的配置选项都会改变 API 本身。

解决这个问题的最佳方法之一是在 C++17 中使用全局变量,如下所示:

#ifndef MY_LIBRARY
#define MY_LIBRARY

#include <iostream>
#include <iomanip>

namespace library_name
{
    namespace config
    {
        inline bool show_hex = false;
    }

    void my_api()
    {
        if (config::show_hex) {
            std::cout << std::hex << "The answer is: " << 42 << '\n';
        }
        else {
            std::cout << std::dec << "The answer is: " << 42 << '\n';
        }
    }
}

#endif

如前面的示例所示,我们在库中添加了一个名为config的新命名空间。我们的 API 不再需要任何参数,并根据内联全局变量确定如何运行。现在,我们可以按以下方式使用此 API:

#include "my_library.h"
#include <iostream>

int main(void)
{
    library_name::my_api();
    library_name::config::show_hex = true;
    library_name::my_api();

    return 0;
}

以下是输出的结果:

需要注意的是,我们将配置设置放在config命名空间中,以确保我们的库命名空间不会因名称冲突而被污染,从而确保全局变量的意图是明显的。

C 风格宏的问题

C 风格宏的最大问题在于,如果将它们放在 C++命名空间中,它们的名称不会被命名空间修饰。这意味着宏总是污染全局命名空间。例如,假设您正在编写一个需要检查变量值的库,如下所示:

#ifndef MY_LIBRARY
#define MY_LIBRARY

#include <cassert>

namespace library_name
{
    #define CHECK(a) assert(a == 42)

    void my_api(int val)
    {
        CHECK(val);
    }
}

#endif

如前面的代码片段所示,我们创建了一个简单的 API,它在实现中使用了 C 风格的宏来检查整数值。前面示例的问题在于,如果您尝试在自己的库中使用单元测试库,很可能会遇到命名空间冲突。

C++20 可以通过使用 C++20 模块来解决这个问题,并且这是我们将在第十三章中更详细讨论的一个主题,奖励-使用 C++20 功能。具体来说,C++20 模块不会向库的用户公开 C 风格的宏。这样做的积极方面是,您将能够使用宏而不会出现命名空间问题,因为您的宏不会暴露给用户。这种方法的缺点是,许多库作者使用 C 风格的宏来配置库(例如,在包含库之前定义宏以更改其默认行为)。这种类型的库配置在 C++模块中将无法工作,除非在编译库时在命令行上定义了这些宏。

直到 C++20 可用,如果需要使用宏,请确保手动向宏名称添加修饰,如下所示:

#define LIBRARY_NAME__CHECK(a) assert(a == 42)

前面的代码行将执行与宏位于 C++命名空间内相同的操作,确保您的宏不会与其他库的宏或用户可能定义的宏发生冲突。

如何将大型库实现为仅头文件

理想情况下,头文件库应使用单个头文件实现。也就是说,用户只需将单个头文件复制到其源代码中即可使用该库。这种方法的问题在于,对于非常大的项目,单个头文件可能会变得非常庞大。一个很好的例子是 C++中一个流行的 JSON 库,位于此处:github.com/nlohmann/json/blob/develop/single_include/nlohmann/json.hpp

在撰写本文时,上述库的代码行数超过 22,000 行。尝试对一个有 22,000 行代码的文件进行修改将是非常糟糕的(即使您的编辑器能够处理)。一些项目通过使用多个头文件实现其仅包含头文件库,并使用单个头文件根据需要包含各个头文件来解决这个问题(例如,Microsoft 的 C++指南支持库就是这样实现的)。这种方法的问题在于用户必须复制和维护多个头文件,随着复杂性的增加,这开始破坏头文件库的目的。

另一种处理这个问题的方法是使用诸如 CMake 之类的工具从多个头文件中自动生成单个头文件。例如,在下面的示例中,我们有一个仅包含头文件的库,其中包含以下头文件:

#include "config.h"

namespace library_name
{
    void my_api()
    {
        if (config::show_hex) {
            std::cout << std::hex << "The answer is: " << 42 << '\n';
        }
        else {
            std::cout << std::dec << "The answer is: " << 42 << '\n';
        }
    }
}

如前面的代码片段所示,这与我们的配置示例相同,唯一的区别是示例的配置部分已被替换为对config.h文件的包含。我们可以按照以下方式创建这个第二个头文件:

namespace library_name
{
    namespace config
    {
        inline bool show_hex = false;
    }
}

这实现了示例的剩余部分。换句话说,我们已经将我们的头文件分成了两个头文件。我们仍然可以像下面这样使用我们的头文件:

#include "apis.h"

int main(void)
{
    library_name::my_api();
    return 0;
}

然而,问题在于我们的库的用户需要拥有两个头文件的副本。为了解决这个问题,我们需要自动生成一个头文件。有许多方法可以做到这一点,但以下是使用 CMake 的一种方法:

file(STRINGS "config.h" CONFIG_H)
file(STRINGS "apis.h" APIS_H)

list(APPEND MY_LIBRARY_SINGLE
    "${CONFIG_H}"
    ""
    "${APIS_H}"
)

file(REMOVE "my_library_single.h")
foreach(LINE IN LISTS MY_LIBRARY_SINGLE)
    if(LINE MATCHES "#include \"")
        file(APPEND "my_library_single.h" "// ${LINE}\n")
    else()
        file(APPEND "my_library_single.h" "${LINE}\n")
    endif()
endforeach()

上面的代码使用file()函数将两个头文件读入 CMake 变量。这个函数将每个变量转换为 CMake 字符串列表(每个字符串是文件中的一行)。然后,我们将两个文件合并成一个列表。为了创建我们的新的自动生成的单个头文件,我们遍历列表,并将每一行写入一个名为my_library_single.h的新头文件。最后,如果我们看到对本地包含的引用,我们将其注释掉,以确保没有引用我们的额外头文件。

现在,我们可以像下面这样使用我们的新单个头文件:

#include "my_library_single.h"

int main(void)
{
    library_name::my_api();
    return 0;
}

使用上述方法,我们可以开发我们的库,使用尽可能多的包含,并且我们的构建系统可以自动生成我们的单个头文件,这将被最终用户使用,为我们提供了最好的两全其美。

学习库开发最佳实践

在编写自己的库时,所有库作者都应该遵循某些最佳实践。在本教程中,我们将探讨一些优先级较高的最佳实践,并总结一些关于一个专门定义这些最佳实践的项目的信息,包括一个注册系统,为您的库提供编译的评分。这个教程很重要,因为它将教会您如何制作最高质量的库,确保强大和充满活力的用户群体。

准备工作

与本章中的所有示例一样,请确保所有技术要求都已满足,包括安装 Ubuntu 18.04 或更高版本,并在终端窗口中运行以下命令:

> sudo apt-get install build-essential git cmake clang-tidy valgrind

这将确保您的操作系统具有正确的工具来编译和执行本教程中的示例。完成后,打开一个新的终端。我们将使用这个终端来下载、编译和运行我们的示例。

如何做...

您需要执行以下步骤来完成本教程:

  1. 从新的终端运行以下命令以下载源代码:
> cd ~/
> git clone https://github.com/PacktPublishing/Advanced-CPP-CookBook.git
> cd Advanced-CPP-CookBook/chapter01
  1. 要编译源代码,请运行以下代码:
> mkdir build && cd build
> cmake ..
> make recipe04_examples
  1. 一旦源代码被编译,您可以通过运行以下命令来执行本教程中的每个示例:
> ./recipe04_example01 
21862

在下一节中,我们将逐个介绍这些示例,并解释每个示例程序的作用以及它与本教程中所教授的课程的关系。

它是如何工作的...

每个图书馆的作者都应该确保他们的图书馆易于使用并且可以整合到用户自己的项目中。这样做将确保您的用户继续使用您的图书馆,从而导致用户群随着时间的推移不断增长。让我们来看看其中一些最佳实践。

警告呢?

任何图书馆作者的最低挂果是确保您的代码尽可能多地编译。遗憾的是,GCC 并没有简化这个过程,因为没有一个警告标志可以统治所有警告,特别是因为 GCC 有许多对于现代 C ++版本来说并不有用的警告标志(换句话说,它们在某种程度上是相互排斥的)。开始的最佳地方是以下警告:

-Wall -Wextra -pedantic -Werror

这将打开大部分重要的警告,同时确保您的示例或测试编译时生成错误的任何警告。然而,对于一些库来说,这还不够。在撰写本文时,微软的指南支持库使用以下标志:

-Wall -Wcast-align -Wconversion -Wctor-dtor-privacy -Werror -Wextra -Wpedantic -Wshadow -Wsign-conversion

GSL 使用的另一个警告是转换警告,它会在您在不同的整数类型之间转换时告诉您。如果您使用 Clang,这个过程可能会更容易,因为它提供了-Weverything。如果筛选 GCC 提供的所有警告太麻烦,解决这个问题的一种方法是确保您的库在打开此警告的情况下与 Clang 编译器编译,这将确保您的代码与 GCC 提供的大部分警告一起编译。这样,当用户必须确保他们的代码中启用了特定警告时,您的用户在使用您的库时就不会遇到麻烦,因为您已经尽可能地测试了其中的许多。

静态和动态分析

除了测试警告之外,库还应该使用静态和动态分析工具进行测试。再次强调,作为图书馆的作者,您必须假设您的用户可能会使用静态和动态分析工具来加强他们自己应用程序的质量。如果您的库触发了这些工具,您的用户更有可能寻找经过更彻底测试的替代方案。

对于 C ++,有大量工具可用于分析您的库。在本教程中,我们将专注于 Clang Tidy 和 Valgrind,它们都是免费使用的。让我们看看以下简单的例子:

#include <iostream>

int universe()
{
    auto i = new int;
    int the_answer;
    return the_answer;
}

int main()
{
    std::cout << universe() << '\n';
    return 0;
}

在前面的例子中,我们创建了一个名为universe()的函数,它返回一个整数并分配一个整数。在我们的主函数中,我们的universe()函数将结果输出到stdout

要对前面的代码进行静态分析,我们可以使用 CMake,如下所示:

set(CMAKE_CXX_CLANG_TIDY clang-tidy)

前面的代码告诉 CMake 在编译前面的示例时使用clang-tidy。当我们编译代码时,我们得到以下结果:

如果您的库的用户已经打开了使用 Clang Tidy 进行静态分析,这可能是他们会收到的错误,即使他们的代码完全正常。如果您正在使用别人的库并遇到了这个问题,克服这个问题的一种方法是将库包含为系统包含,这告诉 Clang Tidy 等工具忽略这些错误。然而,这并不总是有效,因为有些库需要使用宏,这会将库的逻辑暴露给您自己的代码,导致混乱。一般来说,如果您是库开发人员,尽可能多地对您的库进行静态分析,因为您不知道您的用户可能如何使用您的库。

动态分析也是一样。前面的分析没有检测到明显的内存泄漏。为了识别这一点,我们可以使用valgrind,如下所示:

如前面的屏幕截图所示,valgrind能够检测到我们代码中的内存泄漏。实际上,valgrind还检测到我们在universe()函数中从未初始化临时变量的事实,但输出内容过于冗长,无法在此展示。再次强调,如果你未能识别出这些类型的问题,你最终会暴露这些错误给你的用户。

文档

文档对于任何良好的库来说都是绝对必要的。除了有 bug 的代码,缺乏文档也会绝对阻止其他人使用你的库。库应该易于设置和安装,甚至更容易学习和融入到你自己的应用程序中。使用现有的 C++库最令人沮丧的一点就是缺乏文档。

CII 最佳实践

在这个示例中,我们涉及了一些所有库开发者都应该在其项目中应用的常见最佳实践。除了这些最佳实践,CII 最佳实践项目在这里提供了更完整的最佳实践清单:bestpractices.coreinfrastructure.org/en

CII 最佳实践项目提供了一个全面的最佳实践清单,随着时间的推移进行更新,库开发者(以及一般的应用程序)可以利用这些最佳实践。这些最佳实践分为通过、银和金三个级别,金级实践是最难实现的。你的得分越高,用户使用你的库的可能性就越大,因为这显示了承诺和稳定性。

学习如何使用 boost API

boost 库是一组设计用于与标准 C++库配合使用的库。事实上,目前由 C++提供的许多库都起源于 boost 库。boost 库提供了从容器、时钟和定时器到更复杂的数学 API,如图形和 CRC 计算等一切。在这个示例中,我们将学习如何使用 boost 库,特别是演示一个大型库的样子以及如何将这样的库包含在用户的项目中。这个示例很重要,因为它将演示一个库可以变得多么复杂,教会你如何相应地编写你自己的库。

准备工作

与本章中的所有示例一样,请确保已满足所有技术要求,包括安装 Ubuntu 18.04 或更高版本,并在终端窗口中运行以下命令:

> sudo apt-get install build-essential git cmake libboost-all-dev

这将确保你的操作系统具有编译和执行本教程中示例所需的正确工具。完成后,打开一个新的终端。我们将使用这个终端来下载、编译和运行我们的示例。

如何做到...

你需要执行以下步骤来完成这个示例:

  1. 从新的终端运行以下命令以下载源代码:
> cd ~/
> git clone https://github.com/PacktPublishing/Advanced-CPP-CookBook.git
> cd Advanced-CPP-CookBook/chapter01
  1. 要编译源代码,请运行以下代码:
> mkdir build && cd build
> cmake ..
> make recipe05_examples
  1. 源代码编译完成后,你可以通过运行以下命令来执行本教程中的每个示例:
> ./recipe05_example01
Date/Time: 1553894555446451393 nanoseconds since Jan 1, 1970
> ./recipe05_example02
[2019-03-29 15:22:36.756819] [0x00007f5ee158b740] [debug] debug message
[2019-03-29 15:22:36.756846] [0x00007f5ee158b740] [info] info message

在接下来的部分,我们将逐个介绍这些例子,并解释每个示例程序的作用,以及它们与本教程中所教授的课程的关系。

工作原理...

boost 库提供了一组用户 API,实现了大多数程序中常用的功能。这些库可以包含在你自己的项目中,简化你的代码,并提供一个完成的库可能是什么样子的示例。为了解释你自己的库如何被他人利用,让我们看一些如何使用 boost 库的示例。

例子 1

在这个例子中,我们使用 boost API 将当前日期和时间输出到stdout,如下所示:

#include <iostream>
#include <boost/chrono.hpp>

int main(void)
{
    using namespace boost::chrono;

    std::cout << "Date/Time: " << system_clock::now() << '\n';
    return 0;
}

如前面的示例所示,当前日期和时间以自 Unix 纪元(1970 年 1 月 1 日)以来的纳秒总数的形式被输出到stdout。除了在源代码中包含 boost,你还必须将你的应用程序链接到 boost 库。在这种情况下,我们需要链接到以下内容:

-lboost_chrono -lboost_system -lpthread

如何完成这一步骤的示例可以在随这个示例一起下载的CMakeLists.txt文件中看到。一旦这些库被链接到你的项目中,你的代码就能够利用它们内部的 API。这个额外的步骤就是为什么仅包含头文件的库在创建自己的库时可以如此有用,因为它们消除了额外链接的需要。

示例 2

在这个例子中,我们演示了如何使用 boost 的 trivial logging APIs 来记录到控制台,如下所示:

#include <boost/log/trivial.hpp>

int main(void)
{
    BOOST_LOG_TRIVIAL(debug) << "debug message";
    BOOST_LOG_TRIVIAL(info) << "info message";
    return 0;
}

如前面的示例所示,"debug message""info message"消息被输出到stdout。除了链接正确的 boost 库,我们还必须在编译过程中包含以下定义:

-DBOOST_LOG_DYN_LINK -lboost_log -lboost_system -lpthread

再次,链接这些库可以确保你在代码中使用的 API(如前面的示例所示)存在于可执行文件中。

另请参阅

有关 boost 库的更多信息,请查看www.boost.org/

第二章:使用异常处理错误

在本章中,我们将学习一些高级的 C++异常处理技术。我们在这里假设您已经基本了解如何抛出和捕获 C++异常。本章不是专注于 C++异常的基础知识,而是教会您一些更高级的 C++异常处理技术。这包括正确使用noexcept指定符和noexcept运算符,以便您可以正确地标记您的 API,要么可能抛出异常,要么明确地不抛出 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指定符

noexcept指定符用于告诉编译器一个函数是否可能抛出 C++异常。如果一个函数标记有noexcept指定符,它是不允许抛出异常的,如果抛出异常,将会调用std::terminate()。如果函数没有noexcept指定符,异常可以像平常一样被抛出。

在这个配方中,我们将探讨如何在自己的代码中使用noexcept指定符。这个指定符很重要,因为它是你正在创建的 API 和 API 的用户之间的一个合同。当使用noexcept指定符时,它告诉 API 的用户在使用 API 时不需要考虑异常。它还告诉作者,如果他们将noexcept指定符添加到他们的 API 中,他们必须确保不会抛出任何异常,这在某些情况下需要作者捕获所有可能的异常并处理它们,或者在无法处理异常时调用std::terminate()。此外,有一些操作,比如std::move,在这些操作中不能抛出异常,因为移动操作通常无法安全地被逆转。最后,对于一些编译器,将noexcept添加到你的 API 中将减少函数的总体大小,从而使应用程序的总体大小更小。

准备工作

开始之前,请确保满足所有的技术要求,包括安装 Ubuntu 18.04 或更高版本,并在终端窗口中运行以下命令:

> sudo apt-get install build-essential git cmake

这将确保您的操作系统具有正确的工具来编译和执行本配方中的示例。完成后,打开一个新的终端。我们将使用这个终端来下载、编译和运行我们的示例。

如何做...

要尝试这个配方,请执行以下步骤:

  1. 从新的终端中运行以下命令以下载源代码:
> cd ~/
> git clone https://github.com/PacktPublishing/Advanced-CPP-CookBook.git
> cd Advanced-CPP-CookBook/chapter02
  1. 要编译源代码,请运行以下命令:
> mkdir build && cd build
> cmake ..
> make recipe01_examples
  1. 一旦源代码被编译,您可以通过运行以下命令来执行本食谱中的每个示例:
> ./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()的函数,它会抛出一个异常。这个函数在我们的main()函数中被调用,位于一个try/catch块中,用于捕获在try块中执行的代码可能抛出的任何异常,这种情况下是foo()函数。当foo()函数抛出异常时,它被成功捕获并输出到stdout

所有这些都是因为我们没有向foo()函数添加noexcept说明符。默认情况下,函数允许抛出异常,就像我们在这个例子中所做的那样。然而,在某些情况下,我们不希望允许抛出异常,这取决于我们期望函数执行的方式。具体来说,函数如何处理异常可以定义为以下内容(称为异常安全性):

  • 无抛出保证:函数不能抛出异常,如果内部抛出异常,必须捕获和处理异常,包括分配失败。

  • 强异常安全性:函数可以抛出异常,如果抛出异常,函数修改的任何状态都将被回滚或撤消,没有副作用。

  • 基本异常安全性:函数可以抛出异常,如果抛出异常,函数修改的任何状态都将被回滚或撤消,但可能会有副作用。应该注意,这些副作用不包括不变量,这意味着程序处于有效的、非损坏的状态。

  • 无异常安全性:函数可以抛出异常,如果抛出异常,程序可能会进入损坏的状态。

一般来说,如果一个函数具有无抛出保证,它会被标记为noexcept;否则,它不会。异常安全性如此重要的一个例子是std::move。例如,假设我们有两个std::vector实例,我们希望将一个向量移动到另一个向量中。为了执行移动,std::vector可能会将向量的每个元素从一个实例移动到另一个实例。如果在移动时允许对象抛出异常,向量可能会在移动过程中出现异常(也就是说,向量中的一半对象被成功移动)。当异常发生时,std::vector显然会尝试撤消已经执行的移动,将这些移回原始向量,然后返回异常。问题是,尝试将对象移回将需要std::move(),这可能再次抛出异常,导致嵌套异常。实际上,将一个std::vector实例移动到另一个实例并不实际执行逐个对象的移动,但调整大小会,而在这个特定问题中,标准库要求使用std::move_if_noexcept来处理这种情况以提供异常安全性,当对象的移动构造函数允许抛出时,会退回到复制。

noexcept说明符通过明确声明函数不允许抛出异常来解决这些问题。这不仅告诉 API 的用户他们可以安全地使用该函数,而不必担心抛出异常可能会破坏程序的执行,而且还迫使函数的作者安全地处理所有可能的异常或调用std::terminate()。尽管noexcept根据编译器的不同还提供了通过减少应用程序的整体大小来进行优化,但它的主要用途是说明函数的异常安全性,以便其他函数可以推断函数的执行方式。

在下面的示例中,我们为之前定义的foo()函数添加了noexcept说明符:

#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(),由于foo()函数添加了noexceptstd::terminate()仍然会自动执行。换句话说,作为作者,你只需要处理你确实能够安全处理的异常。其余的将被发送到std::terminate();只需理解,这样做会改变函数的异常安全性。如果你打算定义一个不抛出异常的函数,那么该函数就不能抛出异常。

还需注意的是,如果将函数标记为noexcept,不仅需要关注自己抛出的异常,还需要关注可能抛出异常的函数。在这种情况下,foo()函数内部使用了std::cout,这意味着作者要么故意忽略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)。如果表达式求值为 true,则就好像提供了noexcept一样。如果表达式求值为 false,则就好像省略了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运算符是一个编译时检查,用于询问编译器一个函数是否被标记为noexcept。在 C++17 中,这可以与编译时if语句配对使用(即在编译时评估的if语句,可用于根据函数是否允许抛出异常来改变程序的语义)来改变程序的语义。

在本教程中,我们将探讨如何在自己的代码中使用noexcept运算符。这个运算符很重要,因为在某些情况下,你可能无法通过简单地查看函数的定义来确定函数是否能够抛出异常。例如,如果一个函数使用了noexcept说明符,你的代码可能无法确定该函数是否会抛出异常,因为你可能无法根据函数的输入来确定noexcept说明符将求值为什么。noexcept运算符为你提供了处理这些情况的机制,这是至关重要的,特别是在元编程时。

准备工作

在开始之前,请确保满足所有技术要求,包括安装 Ubuntu 18.04 或更高版本,并在终端窗口中运行以下命令:

> sudo apt-get install build-essential git cmake

这将确保您的操作系统具有适当的工具来编译和执行本教程中的示例。完成后,打开一个新的终端。我们将使用这个终端来下载、编译和运行我们的示例。

如何做...

按照以下步骤尝试本教程:

  1. 从新的终端中,运行以下命令下载源代码:
> cd ~/
> git clone https://github.com/PacktPublishing/Advanced-CPP-CookBook.git
> cd Advanced-CPP-CookBook/chapter02
  1. 要编译源代码,请运行以下命令:
> mkdir build && cd build
> cmake ..
> make recipe02_examples
  1. 源代码编译后,可以通过运行以下命令来执行本教程中的每个示例:
> ./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;
}

这将导致以下结果:

如前面的例子所示,我们定义了一个输出到stdoutfoo()函数。我们实际上没有执行foo(),而是使用noexcept操作符来检查foo()函数是否可能抛出异常。如你所见,答案是肯定的;这个函数可能会抛出异常。这是因为我们没有用noexcept标记foo()函数,正如前面的例子所述,函数默认可以抛出异常。

还应该注意到我们在noexcept表达式中添加了!。这是因为如果函数被标记为noexceptnoexcept会返回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;
}

结果如下:

在前面的例子中,如果一个函数被标记为noexceptnoexcept操作符会返回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逻辑被添加到生成的二进制文件中,以根据需要处理异常:

现在,让我们进一步说明,使用noexcept指定符来声明foo()函数不允许抛出异常:

#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 (...)
        { }
    }
}

如前面的示例所示,程序调用了std::terminate(),因为foo()函数被标记为noexcept。此外,如果我们查看生成的汇编代码,我们可以看到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()被标记为noexceptnoexcept运算符将返回true,导致foo2()被标记为noexcept(true),这与简单地声明noexcept相同。如果foo1()没有被标记为noexceptnoexcept运算符将返回false,在这种情况下,noexcept指定符将被标记为noexcept(false),这与不添加noexcept指定符相同(即,函数允许抛出异常)。

使用 RAII

RAII 是一种编程原则,它规定资源与获取资源的对象的生命周期绑定。RAII 是 C++语言的一个强大特性,它真正有助于将 C++与 C 区分开来,有助于防止资源泄漏和一般不稳定性。

在这个示例中,我们将深入探讨 RAII 的工作原理以及如何使用 RAII 来确保 C++异常不会引入资源泄漏。RAII 对于任何 C++应用程序来说都是至关重要的技术,应该尽可能地使用。

准备工作

在开始之前,请确保满足所有技术要求,包括安装 Ubuntu 18.04 或更高版本,并在终端窗口中运行以下命令:

> sudo apt-get install build-essential git cmake

这将确保您的操作系统具有编译和执行本示例所需的适当工具。完成后,打开一个新的终端。我们将使用这个终端来下载、编译和运行我们的示例。

操作步骤...

您需要执行以下步骤来尝试这个示例:

  1. 从新的终端中运行以下命令来下载源代码:
> cd ~/
> git clone https://github.com/PacktPublishing/Advanced-CPP-CookBook.git
> cd Advanced-CPP-CookBook/chapter02
  1. 要编译源代码,请运行以下命令:
> mkdir build && cd build
> cmake ..
> make recipe03_examples
  1. 一旦源代码编译完成,您可以通过运行以下命令来执行本示例中的每个示例:
> ./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++模式,自从 Bjarne Stroustrup 创建了最初的 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模式利用了 RAII 的仅析构函数部分,提供了一个简单的机制,在函数的控制流复杂或可能抛出异常时执行非基于资源的清理。考虑以下例子:

#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";
    }};
}

在前面的例子中,我们创建了一个能够存储在finally类实例失去作用域时执行的 lambda 函数的类。在这种特殊情况下,当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

这将确保您的操作系统具有适当的工具来编译和执行本食谱中的示例。完成后,打开一个新的终端。我们将使用这个终端来下载、编译和运行我们的示例。

如何做...

执行以下步骤来尝试这个食谱:

  1. 从新的终端中运行以下命令来下载源代码:
> cd ~/
> git clone https://github.com/PacktPublishing/Advanced-CPP-CookBook.git
> cd Advanced-CPP-CookBook/chapter02
  1. 要编译源代码,请运行以下命令:
> mkdir build && cd build
> cmake ..
> make recipe04_examples
  1. 源代码编译完成后,您可以通过运行以下命令在本食谱中执行每个示例:
> ./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

这将确保您的操作系统具有编译和执行本示例所需的适当工具。完成后,打开一个新的终端。我们将使用此终端来下载、编译和运行示例。

如何做到...

按照以下步骤尝试本示例:

  1. 从新的终端中运行以下命令以下载源代码:
> cd ~/
> git clone https://github.com/PacktPublishing/Advanced-CPP-CookBook.git
> cd Advanced-CPP-CookBook/chapter02
  1. 要编译源代码,请运行以下命令:
> mkdir build && cd build
> cmake ..
> make recipe05_examples
  1. 源代码编译完成后,您可以通过运行以下命令来执行本示例中的每个示例:
> ./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 风格字符串作为常量存储在程序中。这种类型的异常满足了所有先前的规则,并且在构造异常期间不会发生任何分配。还应该注意,由于字符串是全局存储的,这种操作是安全的。

使用这种方法可以创建许多类型的异常,包括通过自定义 getter 访问的字符串以外的其他内容(即,无需使用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::runtime_error()std::string版本,您可能会在异常本身的构造过程中遇到std::bad_alloc的情况。

第三章:实现移动语义

在本章中,我们将学习一些高级的 C++移动语义。我们将首先讨论大五,这是一种鼓励程序员显式定义类的销毁和移动/复制语义的习语。接下来,我们将学习如何定义移动构造函数和移动赋值运算符;移动语义的不同组合(包括仅移动和不可复制);不可移动的类;以及如何实现这些类以及它们的重要性。

本章还将讨论一些常见的陷阱,比如为什么const &&移动毫无意义,以及如何克服左值与右值引用类型。本章的示例非常重要,因为一旦启用 C++11 或更高版本,移动语义就会启用,这会改变 C++在许多情况下处理类的方式。本章的示例为在 C++中编写高效的代码提供了基础,使其行为符合预期。

本章的示例如下:

  • 使用编译器生成的特殊类成员函数和大五

  • 使您的类可移动

  • 仅移动类型

  • 实现noexcept移动构造函数

  • 学会谨慎使用const &&

  • 引用限定的成员函数

  • 探索无法移动或复制的对象

技术要求

要编译和运行本章中的示例,您必须具有管理权限的计算机运行 Ubuntu 18.04,并具有正常的互联网连接。在运行这些示例之前,您必须安装以下内容:

> sudo apt-get install build-essential git cmake 

如果这是安装在 Ubuntu 18.04 以外的任何操作系统上,则需要 GCC 7.4 或更高版本和 CMake 3.6 或更高版本。

使用编译器生成的特殊类成员函数和大五

在使用 C++11 或更高版本时,如果您没有在类定义中显式提供它们,编译器将为您的 C++类自动生成某些函数。在本示例中,我们将探讨这是如何工作的,编译器将为您创建哪些函数,以及这如何影响您程序的性能和有效性。总的来说,本示例的目标是证明每个类应该至少定义大五,以确保您的类明确地说明了您希望如何管理资源。

准备工作

开始之前,请确保满足所有的技术要求,包括安装 Ubuntu 18.04 或更高版本,并在终端窗口中运行以下命令:

> sudo apt-get install build-essential git

这将确保您的操作系统具有编译和执行本示例中的示例所需的适当工具。完成后,打开一个新的终端。我们将使用这个终端来下载、编译和运行我们的示例。

如何做...

您需要执行以下步骤来尝试这个示例:

  1. 从新的终端运行以下命令以下载源代码:
> cd ~/
> git clone https://github.com/PacktPublishing/Advanced-CPP-CookBook.git
> cd Advanced-CPP-CookBook/chapter03
  1. 编译源代码,请运行以下命令:
> cmake .
> make recipe01_examples
  1. 源代码编译完成后,您可以通过运行以下命令来执行本示例中的每个示例:
> ./recipe01_example01
The answer is: 42

> ./recipe01_example02
The answer is: 42

> ./recipe01_example03
The answer is: 42

> ./recipe01_example04
The answer is: 42

在下一节中,我们将逐个介绍这些示例,并解释每个示例程序的作用以及它与本示例中所教授的课程的关系。

它是如何工作的...

在这个示例中,我们将探讨移动和复制之间的区别,以及这与大五的关系,大五是指所有类都应该显式定义的五个函数。首先,让我们先看一个简单的例子,一个在其构造函数中输出整数值的类:

class the_answer
{
    int m_answer{42};

public:

    ~the_answer()
    {
        std::cout << "The answer is: " << m_answer << '\n';
    }
};

在前面的示例中,当类被销毁时,它将输出到stdout。该类还有一个在构造时初始化的整数成员变量。前面示例的问题在于,我们定义了类的析构函数,因此隐式的复制和移动语义被抑制了。

大五是以下函数,每个类都应该定义这些函数中的至少一个(也就是说,如果你定义了一个,你必须定义它们全部):

~the_answer() = default;

the_answer(the_answer &&) noexcept = default;
the_answer &operator=(the_answer &&) noexcept = default;

the_answer(const the_answer &) = default;
the_answer &operator=(const the_answer &) = default;

如上所示,Big Five 包括析构函数、移动构造函数、移动赋值运算符、复制构造函数和复制赋值运算符。这些类的作者不需要实现这些函数,而是应该至少定义这些函数,明确说明删除、复制和移动应该如何进行(如果有的话)。这确保了如果这些函数中的一个被定义,类的其余移动、复制和销毁语义是正确的,就像这个例子中一样:

class the_answer
{
    int m_answer{42};

public:

    the_answer()
    {
        std::cout << "The answer is: " << m_answer << '\n';
    }

public:

    virtual ~the_answer() = default;

    the_answer(the_answer &&) noexcept = default;
    the_answer &operator=(the_answer &&) noexcept = default;

    the_answer(const the_answer &) = default;
    the_answer &operator=(const the_answer &) = default;
};

在前面的示例中,通过定义虚拟析构函数(意味着该类能够参与运行时多态),将类标记为virtual。不需要实现(通过将析构函数设置为default),但定义本身是显式的,告诉编译器我们希望该类支持虚拟函数。这告诉类的用户,可以使用该类的指针来删除从它派生的任何类的实例。它还告诉用户,继承将利用运行时多态而不是组合。该类还声明允许复制和移动。

让我们看另一个例子:

class the_answer
{
    int m_answer{42};

public:

    the_answer()
    {
        std::cout << "The answer is: " << m_answer << '\n';
    }

public:

    ~the_answer() = default;

    the_answer(the_answer &&) noexcept = default;
    the_answer &operator=(the_answer &&) noexcept = default;

    the_answer(const the_answer &) = delete;
    the_answer &operator=(const the_answer &) = delete;
};

在前面的示例中,复制被明确删除(这与定义移动构造函数但未定义复制语义相同)。这定义了一个仅移动的类,这意味着该类只能被移动;它不能被复制。标准库中的一个这样的类的例子是std::unique_ptr

下一个类实现了相反的功能:

class the_answer
{
    int m_answer{42};

public:

    the_answer()
    {
        std::cout << "The answer is: " << m_answer << '\n';
    }

public:

    ~the_answer() = default;

    the_answer(the_answer &&) noexcept = delete;
    the_answer &operator=(the_answer &&) noexcept = delete;

    the_answer(const the_answer &) = default;
    the_answer &operator=(const the_answer &) = default;
};

在前面的示例中,我们明确定义了一个仅复制的类。

有许多不同的 Big Five 的组合。这个教程的重点是显示明确定义这五个函数可以确保类的作者对类本身的意图是明确的。这涉及到它应该如何操作以及用户应该如何使用类。明确确保类的作者并不打算获得一种类型的行为,而是因为编译器将根据编译器的实现和 C++规范的定义隐式构造类,而获得另一种类型的行为。

使您的类可移动

在 C++11 或更高版本中,对象可以被复制或移动,这可以用来决定对象的资源是如何管理的。复制和移动之间的主要区别很简单:复制会创建对象管理的资源的副本,而移动会将资源从一个对象转移到另一个对象。

在本教程中,我们将解释如何使一个类可移动,包括如何正确添加移动构造函数和移动赋值运算符。我们还将解释可移动类的一些微妙细节以及如何在代码中使用它们。这个教程很重要,因为在很多情况下,移动对象而不是复制对象可以提高程序的性能并减少内存消耗。然而,如果不正确使用可移动对象,可能会引入一些不稳定性。

准备工作

开始之前,请确保满足所有技术要求,包括安装 Ubuntu 18.04 或更高版本,并在终端窗口中运行以下命令:

> sudo apt-get install build-essential git

这将确保您的操作系统具有编译和执行本教程中示例所需的适当工具。完成后,打开一个新的终端。我们将使用这个终端来下载、编译和运行我们的示例。

如何做...

您需要执行以下步骤来尝试这个教程:

  1. 从新的终端中,运行以下命令来下载源代码:
> cd ~/
> git clone https://github.com/PacktPublishing/Advanced-CPP-CookBook.git
> cd Advanced-CPP-CookBook/chapter03
  1. 要编译源代码,请运行以下命令:
> cmake .
> make recipe02_examples
  1. 一旦源代码编译完成,您可以通过运行以下命令来执行本教程中的每个示例:
> ./recipe02_example01
The answer is: 42
> ./recipe02_example02
The answer is: 42
The answer is: 42

The answer is: 42

在下一节中,我们将逐个介绍这些示例,并解释每个示例程序的作用以及它与本教程所教授的课程的关系。

工作原理...

在这个示例中,我们将学习如何使一个类可移动。首先,让我们来看一个基本的类定义:

#include <iostream>

class the_answer
{
    int m_answer{42};

public:

    the_answer() = default;

public:

    ~the_answer()
    {
        std::cout << "The answer is: " << m_answer << '\n';
    }
};

int main(void)
{
    the_answer is;
    return 0;
}

在前面的例子中,我们创建了一个简单的类,它有一个私有的整数成员,被初始化。然后我们定义了一个默认构造函数和一个析构函数,当类的实例被销毁时,它会输出到stdout。默认情况下,这个类是可移动的,但移动操作模拟了一个复制(换句话说,这个简单的例子中移动和复制没有区别)。

要真正使这个类可移动,我们需要添加移动构造函数和移动赋值运算符,如下所示:

the_answer(the_answer &&other) noexcept;
the_answer &operator=(the_answer &&other) noexcept;

一旦我们添加了这两个函数,我们就能够使用以下方法将我们的类从一个实例移动到另一个实例:

instance2 = std::move(instance1);

为了支持这一点,在前面的类中,我们不仅添加了移动构造函数和赋值运算符,还实现了一个默认构造函数,为我们的示例类提供了一个有效的移动状态,如下所示:

#include <iostream>

class the_answer
{
    int m_answer{};

public:

    the_answer() = default;

    explicit the_answer(int answer) :
        m_answer{answer}
    { }

如上所示,该类现在有一个默认构造函数和一个显式构造函数,它接受一个整数参数。默认构造函数初始化整数内存变量,表示我们的移动来源或无效状态:

public:

    ~the_answer()
    {
        if (m_answer != 0) {
            std::cout << "The answer is: " << m_answer << '\n';
        }
    }

如前面的例子所示,当类被销毁时,我们输出整数成员变量的值,但在这种情况下,我们首先检查整数变量是否有效:

    the_answer(the_answer &&other) noexcept
    {
        *this = std::move(other);
    }

    the_answer &operator=(the_answer &&other) noexcept
    {
        if (&other == this) {
            return *this;
        }

        m_answer = std::exchange(other.m_answer, 0);        
        return *this;
    }

    the_answer(const the_answer &) = default;
    the_answer &operator=(const the_answer &) = default;
};

最后,我们实现了移动构造函数和赋值运算符。移动构造函数简单地调用移动赋值运算符,以防止重复(因为它们执行相同的操作)。移动赋值运算符首先检查我们是否在将自己移动。这是因为这样做会导致损坏,因为用户期望类仍然包含一个有效的整数,但实际上,内部整数会无意中被设置为0

然后我们交换整数值并将原始值设置为0。这是因为,再一次强调,移动不是复制。移动将值从一个实例转移到另一个实例。在这种情况下,被移动到的实例开始为0,并被赋予一个有效的整数,而被移出的实例开始有一个有效的整数,移动后被设置为0,导致只有1个实例包含一个有效的整数。

还应该注意,我们必须定义复制构造函数和赋值运算符。这是因为,默认情况下,如果你提供了移动构造函数和赋值运算符,C++会自动删除复制构造函数和赋值运算符,如果它们没有被显式定义的话。

在这个例子中,我们将比较移动和复制,因此我们定义了复制构造函数和赋值运算符,以确保它们不会被隐式删除。一般来说,最好的做法是为你定义的每个类定义析构函数、移动构造函数和赋值运算符,以及复制构造函数和赋值运算符。这确保了你编写的每个类的复制/移动语义都是明确和有意义的:

int main(void)
{
    {
        the_answer is;
        the_answer is_42{42};
        is = is_42;
    }

    std::cout << '\n';

    {
        the_answer is{23};
        the_answer is_42{42};
        is = std::move(is_42);
    }

    return 0;
}

当执行上述代码时,我们得到了以下结果:

在我们的主函数中,我们运行了两个不同的测试:

  • 第一个测试创建了我们类的两个实例,并将一个实例的内容复制到另一个实例。

  • 第二个测试创建了我们类的两个实例,然后将一个实例的内容移动到另一个实例。

当执行这个例子时,我们看到第一个测试的输出被写了两次。这是因为我们的类的第一个实例得到了第二个实例的一个副本,而第二个实例有一个有效的整数值。第二个测试的输出只被写了一次,因为我们正在将一个实例的有效状态转移到另一个实例,导致在任何给定时刻只有一个实例具有有效状态。

这里有一些值得一提的例子:

  • 移动构造函数和赋值运算符不应该抛出异常。具体来说,移动操作将一个类型的实例的有效状态转移到该类型的另一个实例。在任何时候,这个操作都不应该失败,因为没有状态被创建或销毁。它只是被转移。此外,往往很难撤消移动操作。因此,这些函数应该始终被标记为noexcept(参考github.com/isocpp/CppCoreGuidelines/blob/master/CppCoreGuidelines.md#Rc-move-noexcept)。

  • 移动构造函数和赋值运算符在其函数签名中不包括const类型,因为被移动的实例不能是const,因为其内部状态正在被转移,这暗示着写操作正在发生。更重要的是,如果将移动构造函数或赋值运算符标记为const,则可能会发生复制。

  • 除非您打算创建一个副本,否则应该使用移动,特别是对于大型对象。就像将const T&作为函数参数传递以防止发生复制一样,当调用函数时,当资源被移动到另一个变量而不是被复制时,应该使用移动代替复制。

  • 编译器在可能的情况下会自动生成移动操作而不是复制操作。例如,如果您在函数中创建一个对象,配置该对象,然后返回该对象,编译器将自动执行移动操作。

现在您知道如何使您的类可移动了,在下一个食谱中,我们将学习什么是只可移动类型,以及为什么您可能希望在应用程序中使用它们。

只可移动类型

在这个食谱中,我们将学习如何使一个类成为只可移动的。一个很好的例子是std::unique_ptrstd::shared_ptr之间的区别。

std::unique_ptr的目的是强制动态分配类型的单一所有者,而std::shared_ptr允许动态分配类型的多个所有者。两者都允许用户将指针类型的内容从一个实例移动到另一个实例,但只有std::shared_ptr允许用户复制指针(因为复制指针会创建多个所有者)。

在这个食谱中,我们将使用这两个类来展示如何制作一个只可移动的类,并展示为什么这种类型的类在 C++中被如此广泛地使用(因为大多数时候我们希望移动而不是复制)。

准备工作

开始之前,请确保满足所有技术要求,包括安装 Ubuntu 18.04 或更高版本,并在终端窗口中运行以下命令:

> sudo apt-get install build-essential git

这将确保您的操作系统具有正确的工具来编译和执行本食谱中的示例。完成后,打开一个新的终端。我们将使用这个终端来下载、编译和运行我们的示例。

如何做...

您需要执行以下步骤来尝试这个食谱:

  1. 从新的终端运行以下命令以下载源代码:
> cd ~/
> git clone https://github.com/PacktPublishing/Advanced-CPP-CookBook.git
> cd Advanced-CPP-CookBook/chapter03
  1. 要编译源代码,请运行以下命令:
> cmake .
> make recipe03_examples
  1. 一旦源代码编译完成,您可以通过运行以下命令来执行本食谱中的每个示例:
> ./recipe03_example01
The answer is: 42

> ./recipe03_example03
count: 2
The answer is: 42
The answer is: 42

count: 1
The answer is: 42

在下一节中,我们将逐个介绍每个示例,并解释每个示例程序的作用以及它与本食谱中所教授的课程的关系。

工作原理...

只可移动类是一种可以移动但不能复制的类。为了探索这种类型的类,让我们在以下示例中包装std::unique_ptr,它本身是一个只可移动的类:

class the_answer
{
    std::unique_ptr<int> m_answer;

public:

    explicit the_answer(int answer) :
        m_answer{std::make_unique<int>(answer)}
    { }

    ~the_answer()
    {
        if (m_answer) {
            std::cout << "The answer is: " << *m_answer << '\n';
        }
    }

public:

    the_answer(the_answer &&other) noexcept
    {
        *this = std::move(other);
    }

    the_answer &operator=(the_answer &&other) noexcept
    {
        m_answer = std::move(other.m_answer);
        return *this;
    }
};

前面的类将std::unique_ptr作为成员变量存储,并在构造时用整数值实例化内存变量。在销毁时,类会检查std::unique_ptr是否有效,如果有效,则将值输出到stdout

乍一看,我们可能会想知道为什么我们必须检查 std::unique_ptr 的有效性,因为 std::unique_ptr 总是被构造。std::unique_ptr 可能变得无效的原因是在移动期间。由于我们正在创建一个只能移动的类(而不是一个不可复制、不可移动的类),我们实现了移动构造函数和移动赋值运算符,它们移动 std::unique_ptrstd::unique_ptr 在移动时将其内部指针的内容从一个类转移到另一个类,导致该类从存储无效指针(即 nullptr)移动。换句话说,即使这个类不能被空构造,如果它被移动,它仍然可以存储 nullptr,就像下面的例子一样:

int main(void)
{
    the_answer is_42{42};
    the_answer is = std::move(is_42);

    return 0;
}

正如前面的例子所示,只有一个类输出到 stdout,因为只有一个实例是有效的。与 std::unique_ptr 一样,只能移动的类确保你总是有一个资源被创建的总数与实际发生的实例化总数之间的 1:1 关系。

需要注意的是,由于我们使用了 std::unique_ptr,我们的类无论我们是否喜欢,都变成了一个只能移动的类。例如,尝试添加复制构造函数或复制赋值运算符以启用复制功能将导致编译错误:

the_answer(const the_answer &) = default;
the_answer &operator=(const the_answer &) = default;

换句话说,每个包含只能移动的类作为成员的类也会成为只能移动的类。尽管这可能看起来不太理想,但你首先必须问自己:你真的需要一个可复制的类吗?很可能答案是否定的。实际上,在大多数情况下,即使在 C++11 之前,我们使用的大多数类(如果不是全部)都应该是只能移动的。当一个类应该被移动而被复制时,会导致资源浪费、损坏等问题,这也是为什么在规范中添加了移动语义的原因之一。移动语义允许我们定义我们希望分配的资源如何处理,并且它为我们提供了一种在编译时强制执行所需语义的方法。

你可能会想知道前面的例子如何转换以允许复制。以下示例利用了 shared pointer 来实现这一点:

#include <memory>
#include <iostream>

class the_answer
{
    std::shared_ptr<int> m_answer;

public:

    the_answer() = default;

    explicit the_answer(int answer) :
        m_answer{std::make_shared<int>(answer)}
    { }

    ~the_answer()
    {
        if (m_answer) {
            std::cout << "The answer is: " << *m_answer << '\n';
        }
    }

    auto use_count()
    { return m_answer.use_count(); }

前面的类使用了 std::shared_ptr 而不是 std::unique_ptr。在内部,std::shared_ptr 跟踪被创建的副本数量,并且只有在总副本数为 0 时才删除它存储的指针。实际上,你可以使用 use_count() 函数查询总副本数。

接下来,我们定义移动构造函数,移动赋值运算符,复制构造函数和复制赋值运算符,如下所示:

public:

    the_answer(the_answer &&other) noexcept
    {
        *this = std::move(other);
    }

    the_answer &operator=(the_answer &&other) noexcept
    {
        m_answer = std::move(other.m_answer);
        return *this;
    }

    the_answer(const the_answer &other)
    {
        *this = other;
    }

    the_answer &operator=(const the_answer &other)
    {
        m_answer = other.m_answer;
        return *this;
    }
};

这些定义也可以使用 = 默认语法来编写,因为这些实现是相同的。最后,我们使用以下方式测试这个类:

int main(void)
{
    {
        the_answer is_42{42};
        the_answer is = is_42;
        std::cout << "count: " << is.use_count() << '\n';
    }

    std::cout << '\n';

    {
        the_answer is_42{42};
        the_answer is = std::move(is_42);
        std::cout << "count: " << is.use_count() << '\n';
    }

    return 0;
}

如果我们执行前面的代码,我们会得到以下结果:

在前面的测试中,我们首先创建了一个类的副本,并输出了总副本数,以查看实际上创建了两个副本。第二个测试执行了 std::move() 而不是复制,结果只创建了一个预期中的副本。

实现 noexcept 移动构造函数

在本示例中,我们将学习如何确保移动构造函数和移动赋值运算符永远不会抛出异常。C++ 规范并不阻止移动构造函数抛出异常(因为确定这样的要求实际上太难以强制执行,即使在标准库中也存在太多合法的例子)。然而,在大多数情况下,确保不会抛出异常应该是可能的。具体来说,移动通常不会创建资源,而是转移资源,因此应该可能提供强异常保证。一个创建资源的好例子是 std::list,即使在移动时也必须提供有效的 end() 迭代器。

准备工作

开始之前,请确保满足所有技术要求,包括安装 Ubuntu 18.04 或更高版本,并在终端窗口中运行以下命令:

> sudo apt-get install build-essential git

这将确保您的操作系统具有编译和执行本文示例所需的适当工具。完成后,打开一个新的终端。我们将使用这个终端来下载、编译和运行我们的示例。

如何做...

您需要执行以下步骤来尝试这个示例:

  1. 从新的终端中运行以下命令来下载源代码:
> cd ~/
> git clone https://github.com/PacktPublishing/Advanced-CPP-CookBook.git
> cd Advanced-CPP-CookBook/chapter03
  1. 要编译源代码,请运行以下命令:
> cmake .
> make recipe04_examples
  1. 一旦源代码编译完成,您可以通过运行以下命令来执行本文中每个示例:
> ./recipe04_example01
failed to move

The answer is: 42

在下一节中,我们将逐个介绍每个示例,并解释每个示例程序的作用以及它与本文所教授的课程的关系。

工作原理...

如前所述,移动不应该抛出异常,以确保强异常保证(即,移动对象的行为不会破坏对象),在大多数情况下,这是可能的,因为移动(不像复制)不会创建资源,而是转移资源。确保您的移动构造函数和移动赋值操作符不会抛出异常的最佳方法是只使用std::move()来转移成员变量,就像以下示例中所示的那样:

m_answer = std::move(other.m_answer);

假设您移动的成员变量不会抛出异常,那么您的类也不会。使用这种简单的技术将确保您的移动构造函数和操作符永远不会抛出异常。但如果这个操作不能使用怎么办?让我们通过以下示例来探讨这个问题:

#include <vector>
#include <iostream>

class the_answer
{
    std::vector<int> m_answer;

public:

    the_answer() = default;

    explicit the_answer(int answer) :
        m_answer{{answer}}
    { }

    ~the_answer()
    {
        if (!m_answer.empty()) {
            std::cout << "The answer is: " << m_answer.at(0) << '\n';
        }
    }

在前面的示例中,我们创建了一个具有向量作为成员变量的类。向量可以通过默认方式初始化为空,或者可以初始化为单个元素。在销毁时,如果向量有值,我们将该值输出到stdout。我们实现move构造函数和操作符如下:

public:

    the_answer(the_answer &&other) noexcept
    {
        *this = std::move(other);
    }

    the_answer &operator=(the_answer &&other) noexcept
    {
        if (&other == this) {
            return *this;
        }

        try {
            m_answer.emplace(m_answer.begin(), other.m_answer.at(0));
            other.m_answer.erase(other.m_answer.begin());
        }
        catch(...) {
            std::cout << "failed to move\n";
        }

        return *this;
    }
};

如图所示,移动操作符将单个元素从一个实例转移到另一个实例(这不是实现移动的最佳方式,但这种实现可以演示要点而不会过于复杂)。如果向量为空,这个操作将抛出异常,就像下面的例子一样:

int main(void)
{
    {
        the_answer is_42{};
        the_answer is_what{};

        is_what = std::move(is_42);
    }

    std::cout << '\n';

    {
        the_answer is_42{42};
        the_answer is_what{};

        is_what = std::move(is_42);
    }

    return 0;
}

最后,我们尝试在两个不同的测试中移动这个类的一个实例。在第一个测试中,两个实例都是默认构造的,这导致空的类,而第二个测试构造了一个带有单个元素的向量,这导致有效的移动。在这种情况下,我们能够防止移动抛出异常,但应该注意的是,结果类实际上并没有执行移动,导致两个对象都不包含所需的状态。这就是为什么移动构造函数不应该抛出异常。即使我们没有捕获异常,也很难断言抛出异常后程序的状态。移动是否发生?每个实例处于什么状态?在大多数情况下,这种类型的错误应该导致调用std::terminate(),因为程序进入了一个损坏的状态。

复制不同,因为原始类保持不变。复制是无效的,程序员可以优雅地处理这种情况,因为被复制的实例的原始状态不受影响(因此我们将其标记为const)。

然而,由于被移动的实例是可写的,两个实例都处于损坏状态,没有很好的方法来知道如何处理程序的继续运行,因为我们不知道原始实例是否处于可以正确处理的状态。

学会谨慎使用 const&&

在这个食谱中,我们将学习为什么移动构造函数或操作符不应标记为const(以及为什么复制构造函数/操作符总是标记为const)。这很重要,因为它涉及到移动和复制之间的区别。C++中的移动语义是其最强大的特性之一,了解为什么它如此重要以及它实际上在做什么对于编写良好的 C++代码至关重要。

准备工作

开始之前,请确保满足所有技术要求,包括安装 Ubuntu 18.04 或更高版本,并在终端窗口中运行以下命令:

> sudo apt-get install build-essential git

这将确保您的操作系统具有适当的工具来编译和执行本食谱中的示例。完成后,打开一个新的终端。我们将使用这个终端来下载、编译和运行我们的示例。

如何做…

您需要执行以下步骤来尝试这个食谱:

  1. 从新的终端中运行以下命令来下载源代码:
> cd ~/
> git clone https://github.com/PacktPublishing/Advanced-CPP-CookBook.git
> cd Advanced-CPP-CookBook/chapter03
  1. 要编译源代码,请运行以下命令:
> cmake .
> make recipe05_examples
  1. 一旦源代码编译完成,您可以通过运行以下命令来执行本食谱中的每个示例:
> ./recipe05_example01
copy

在下一节中,我们将逐个介绍这些示例,并解释每个示例程序的作用以及它与本食谱中所教授的课程的关系。

工作原理…

在这个食谱中,我们将学习为什么const&&构造函数或操作符没有意义,并将导致意外行为。移动会转移资源,这就是为什么它标记为非const。这是因为转移假定两个实例都被写入(一个实例接收资源,而另一个实例被取走资源)。复制会创建资源,这就是为什么它们并不总是标记为noexcept(创建资源绝对可能会抛出异常),并且它们被标记为const(因为原始实例被复制,而不是修改)。const&&构造函数声称是一个不转移的移动,这必须是一个复制(如果您没有写入原始实例,您不是在移动—您在复制),就像这个例子中一样:

#include <iostream>

class copy_or_move
{
public:

    copy_or_move() = default;

public:

    copy_or_move(copy_or_move &&other) noexcept
    {
        *this = std::move(other);
    }

    copy_or_move &operator=(copy_or_move &&other) noexcept
    {
        std::cout << "move\n";
        return *this;
    }

    copy_or_move(const copy_or_move &other)
    {
        *this = other;
    }

    copy_or_move &operator=(const copy_or_move &other)
    {
        std::cout << "copy\n";
        return *this;
    }
};

int main(void)
{
    const copy_or_move test1;
    copy_or_move test2;

    test2 = std::move(test1);
    return 0;
}

在前面的示例中,我们创建了一个实现默认移动和复制构造函数/操作符的类。唯一的区别是我们向stdout添加了输出,告诉我们是执行了复制还是移动。

然后我们创建了两个类的实例,实例被移动,从被标记为const。然后我们执行移动,输出的是一个复制。这是因为即使我们要求移动,编译器也使用了复制。我们可以实现一个const &&移动构造函数/操作符,但没有办法将移动写成移动,因为我们标记了被移动的对象为const,所以我们无法获取它的资源。这样的移动实际上会被实现为一个复制,与编译器自动为我们做的没有区别。

在下一个食谱中,我们将学习如何向我们的成员函数添加限定符。

引用限定成员函数

在这个食谱中,我们将学习什么是引用限定的成员函数。尽管 C++语言的这一方面使用和理解较少,但它很重要,因为它为程序员提供了根据类在调用函数时处于 l-value 还是 r-value 状态来处理资源操作的能力。

准备工作

开始之前,请确保满足所有技术要求,包括安装 Ubuntu 18.04 或更高版本,并在终端窗口中运行以下命令:

> sudo apt-get install build-essential git

这将确保您的操作系统具有适当的工具来编译和执行本食谱中的示例。完成后,打开一个新的终端。我们将使用这个终端来下载、编译和运行我们的示例。

如何做…

您需要执行以下步骤来尝试这个食谱:

  1. 从新的终端中运行以下命令来下载源代码:
> cd ~/
> git clone https://github.com/PacktPublishing/Advanced-CPP-CookBook.git
> cd Advanced-CPP-CookBook/chapter03
  1. 要编译源代码,请运行以下命令:
> cmake .
> make recipe06_examples
  1. 源代码编译后,您可以通过运行以下命令来执行本文中每个示例:
> ./recipe06_example01
the answer is: 42
the answer is not: 0
the answer is not: 0

在下一节中,我们将逐个介绍这些示例,并解释每个示例程序的作用以及它与本文所教授的课程的关系。

工作原理...

在这个例子中,我们将看看什么是引用限定的成员函数。为了解释什么是引用限定的成员函数,让我们看下面的例子:

#include <iostream>

class the_answer
{
public:

 ~the_answer() = default;

 void foo() &
 {
 std::cout << "the answer is: 42\n";
 }

 void foo() &&
 {
 std::cout << "the answer is not: 0\n";
 }

public:

 the_answer(the_answer &&other) noexcept = default;
 the_answer &operator=(the_answer &&other) noexcept = default;

 the_answer(const the_answer &other) = default;
 the_answer &operator=(const the_answer &other) = default;
};

在这个例子中,我们实现了一个 foo() 函数,但是我们有两个不同的版本。第一个版本在末尾有 &,而第二个版本在末尾有 &&foo() 函数的执行取决于实例是 l-value 还是 r-value,就像下面的例子中一样:

int main(void)
{
    the_answer is;

    is.foo();
    std::move(is).foo();
    the_answer{}.foo();
}

执行时会得到以下结果:

如前面的例子所示,foo() 的第一次执行是一个 l-value,因为执行了 foo() 的 l-value 版本(即末尾带有 & 的函数)。foo() 的最后两次执行是 r-value,因为执行了 foo() 的 r-value 版本。

参考限定成员函数可用于确保函数仅在正确的上下文中调用。使用这些类型的函数的另一个原因是确保只有当存在 l-value 或 r-value 引用时才调用该函数。

例如,您可能不希望允许 foo() 作为 r-value 被调用,因为这种类型的调用并不能确保类的实例在调用本身之外实际上具有生命周期,就像前面的例子中所示的那样。

在下一个示例中,我们将学习如何创建一个既不能移动也不能复制的类,并解释为什么要这样做。

探索不能移动或复制的对象

在本文中,我们将学习如何创建一个既不能移动也不能复制的对象,以及为什么要创建这样一个类。复制一个类需要能够复制类的内容,在某些情况下可能是不可能的(例如,复制内存池并不简单)。移动一个类假设该类被允许存在于潜在的无效状态(例如,std::unique_ptr 移动时会取得一个 nullptr 值,这是无效的)。这样的情况也可能是不希望发生的(现在必须检查有效性)。一个既不能移动也不能复制的类可以克服这些问题。

准备工作

开始之前,请确保满足所有技术要求,包括安装 Ubuntu 18.04 或更高版本,并在终端窗口中运行以下命令:

> sudo apt-get install build-essential git

这将确保您的操作系统具有正确的工具来编译和执行本文中的示例。完成后,打开一个新的终端。我们将使用这个终端来下载、编译和运行我们的示例。

操作步骤...

您需要执行以下步骤来尝试这个示例:

  1. 从新的终端中运行以下命令以下载源代码:
> cd ~/
> git clone https://github.com/PacktPublishing/Advanced-CPP-CookBook.git
> cd Advanced-CPP-CookBook/chapter03
  1. 要编译源代码,请运行以下命令:
> cmake .
> make recipe07_examples
  1. 源代码编译后,您可以通过运行以下命令来执行本文中每个示例:
> ./recipe07_example01
The answer is: 42
Segmentation fault (core dumped)

在下一节中,我们将逐个介绍这些示例,并解释每个示例程序的作用以及它与本文所教授的课程的关系。

工作原理...

仅移动类可以阻止类被复制,在某些情况下,这可能是性能的提升。仅移动类还确保了创建的资源与分配的资源之间的 1:1 关系,因为副本是不存在的。然而,移动类可能导致类变为无效,就像这个例子中一样:

#include <iostream>

class the_answer
{
    std::unique_ptr<int> m_answer;

public:

    explicit the_answer(int answer) :
        m_answer{std::make_unique<int>(answer)}
    { }

    ~the_answer()
    {
        std::cout << "The answer is: " << *m_answer << '\n';
    }

public:

    the_answer(the_answer &&other) noexcept = default;
    the_answer &operator=(the_answer &&other) noexcept = default;
};

int main(void)
{
    the_answer is_42{42};
    the_answer is_what{42};

    is_what = std::move(is_42);
    return 0;
}

如果我们运行上述代码,我们会得到以下结果:

在上面的例子中,我们创建了一个可以移动的类,它存储了std::unique_ptr。在类的析构函数中,我们对类进行了解引用并输出了它的值。我们没有检查std::unique_ptr的有效性,因为我们编写了一个强制有效std::unique_ptr的构造函数,忘记了移动可能会撤消这种显式的有效性。结果是,当执行移动操作时,我们会得到一个分段错误。

为了克服这一点,我们需要提醒自己做出了以下假设:

class the_answer
{
 std::unique_ptr<int> m_answer;

public:

 explicit the_answer(int answer) :
 m_answer{std::make_unique<int>(answer)}
 { }

 ~the_answer()
 {
 std::cout << "The answer is: " << *m_answer << '\n';
 }

public:

 the_answer(the_answer &&other) noexcept = delete;
 the_answer &operator=(the_answer &&other) noexcept = delete;

 the_answer(const the_answer &other) = delete;
 the_answer &operator=(const the_answer &other) = delete;
};

前面的类明确删除了复制和移动操作,这是我们期望的意图。现在,如果我们意外地移动这个类,我们会得到以下结果:

/home/user/book/chapter03/recipe07.cpp: In function ‘int main()’:
/home/user/book/chapter03/recipe07.cpp:106:30: error: use of deleted function ‘the_answer& the_answer::operator=(the_answer&&)’
is_what = std::move(is_42);
^
/home/user/book/chapter03/recipe07.cpp:95:17: note: declared here
the_answer &operator=(the_answer &&other) noexcept = delete;
^~~~~~~~

这个错误告诉我们,假设这个类是有效的,因此不支持移动。我们要么需要正确地支持移动(这意味着我们必须维护对无效的std::unique_ptr的支持),要么我们需要删除move操作。正如所示,一个不能被移动或复制的类可以确保我们的代码按预期工作,为编译器提供一种机制,当我们对类做了我们不打算做的事情时,它会警告我们。

第四章:使用模板进行通用编程

在本章中,我们将学习高级模板编程技术。这些技术包括根据提供的类型来改变模板类的实现方式,如何处理不同类型的参数以及如何正确地转发它们,如何在运行时和编译时优化代码,以及如何使用 C++17 中添加的一些新特性。这很重要,因为它可以更好地理解模板编程的工作原理,以及如何确保模板的性能符合预期。

经常情况下,我们编写模板代码时假设它以某种方式执行,而实际上它以另一种方式执行,可能会生成不可靠的代码、意外的性能损失,或者两者兼而有之。本章将解释如何避免这些问题,并为编写正确的通用程序奠定基础。

本章中的示例如下:

  • 实现 SFINAE

  • 学习完美转发

  • 使用if constexpr

  • 使用元组处理参数包

  • 使用特性来改变模板实现的行为

  • 学习如何实现template<auto>

  • 使用显式模板声明

技术要求

要编译和运行本章中的示例,您必须具有管理权限的计算机,运行 Ubuntu 18.04,并具有正常的互联网连接。在运行这些示例之前,安装以下内容:

> sudo apt-get install build-essential git cmake

如果这安装在 Ubuntu 18.04 以外的任何操作系统上,则需要 GCC 7.4 或更高版本和 CMake 3.6 或更高版本。

实现 SFINAE

在这个示例中,我们将学习如何使用Substitution Failure Is Not An ErrorSFINAE)。这个示例很重要,因为我们经常创建模板时没有确保传递给模板的类型是我们期望的。这可能导致意外行为、性能不佳,甚至是错误的、不可靠的代码。

SFINAE 允许我们明确指定我们在模板中期望的类型。它还为我们提供了一种根据我们提供的类型来改变模板行为的方法。对于一些人来说,SFINAE 的问题在于这个概念很难理解。我们在本示例中的目标是揭开 SFINAE 的神秘面纱,并展示您如何在自己的代码中使用它。

准备工作

开始之前,请确保满足所有技术要求,包括安装 Ubuntu 18.04 或更高版本,并在终端窗口中运行以下命令:

> sudo apt-get install build-essential git cmake

这将确保您的操作系统具有编译和执行本示例中示例的必要工具。完成后,打开一个新的终端。我们将使用这个终端来下载、编译和运行我们的示例。

操作步骤...

要尝试这个示例,您需要执行以下步骤:

  1. 从新的终端中运行以下命令以下载源代码:
> cd ~/
> git clone https://github.com/PacktPublishing/Advanced-CPP-CookBook.git
> cd Advanced-CPP-CookBook/chapter04
  1. 要编译源代码,请运行以下命令:
> cmake .
> make recipe01_examples
  1. 编译源代码后,您可以通过运行以下命令来执行本示例中的每个示例:
> ./recipe01_example01
The answer is: 23
The answer is: 42

> ./recipe01_example02
The answer is: 42

> ./recipe01_example03
The answer is: 42

> ./recipe01_example04
The answer is: 42

> ./recipe01_example05
The answer is: 42
The answer is: 42
The answer is: 42.12345678

在下一节中,我们将逐个介绍这些示例,并解释每个示例程序的作用以及它与本示例中所教授的课程的关系。

工作原理...

在本示例中,您将学习如何在自己的代码中使用 SFINAE。首先,我们必须先了解 SFINAE 是什么,以及标准库如何使用它来实现type特性。如果不了解type特性是如何实现的,就很难理解如何使用它们。

首先,理解 SFINAE 最重要的事情是理解它的名字,即substitution failure is not an error。这意味着当模板类型被替换时,如果发生失败,编译器将不会生成错误。例如,我们可以编写以下内容:

#include <iostream>

struct the_answer
{
    using type = unsigned;
};

template<typename T>
void foo(typename T::type t)
{
    std::cout << "The answer is not: " << t << '\n';
}

template<typename T>
void foo(T t)
{
    std::cout << "The answer is: " << t << '\n';
}

int main(void)
{
    foo<the_answer>(23);
    foo<int>(42);

    return 0;
}

每个示例的输出如下所示:

The answer is: 23
The answer is: 42

在这个例子中,我们创建了foo()函数的两个版本。第一个版本接受具有我们用来创建函数参数的type别名的T类型。第二个版本只接受T类型本身。然后我们使用foo()函数的两个版本,一个使用整数,另一个使用定义了type别名的结构。

从前面的例子中可以得出的结论是,当我们调用foo<int>()版本的foo()函数时,编译器在尝试将int类型与具有type别名的foo()函数的版本进行匹配时不会生成错误。这就是 SFINAE。它只是说,当编译器尝试获取给定类型并将其与模板匹配时,如果发生失败,编译器不会生成错误。唯一会发生错误的情况是,如果编译器找不到合适的替换。例如,如果我们注释掉foo()的第二个版本会发生什么?让我们看看:

从前面的错误输出中可以看出,编译器甚至说错误是一个替换错误。我们提供的模板不是基于提供的类型的有效候选。

从这个例子中得出的另一个重要结论是,编译器能够根据提供的类型在两个不同版本的foo()函数之间进行选择。我们可以利用这一点。具体来说,这给了我们根据提供的类型做不同事情的能力。我们所需要的只是一种方法来编写我们的foo()函数,以便我们可以根据我们提供的类型启用/禁用模板的不同版本。

这就是std::enable_if发挥作用的地方。std::enable_if将 SFINAE 的思想推向了下一步,允许我们在其参数为 true 时定义一个类型。否则,它将生成一个替换错误,故意迫使编译器选择模板的不同版本。std::enable_if的定义如下:

template<bool B, class T = void>
struct enable_if {};

template<class T>
struct enable_if<true, T> { typedef T type; };

首先定义了一个结构,它接受bool B和一个默认为voidT类型。然后定义了这个struct类型的一个特化,当bool为 true 时。具体来说,当bool值为true时,返回提供的类型,这个类型默认为void。为了看到这是如何使用的,让我们看一个例子:

#include <iostream>
#include <type_traits>

template<typename T>
constexpr auto is_int()
{ 
    return false; 
}

template<>
constexpr auto is_int<int>()
{ 
    return true; 
}

template<
    typename T,
    std::enable_if_t<is_int<T>(), int> = 0
    >
void the_answer(T is)
{
    std::cout << "The answer is: " << is << '\n';
}

int main(void)
{
    the_answer(42);
    return 0;
}

输出如下:

在这个例子中,我们创建了一个名为is_int()的函数,它总是返回false。然后我们为int创建了这个函数的模板特化,返回true。接下来,我们创建了一个接受任何类型的函数,但我们在使用我们的is_int()函数的模板定义中添加了std::enable_if_t(添加的_t部分是 C++17 中为::type添加的简写)。如果提供的T类型是int,我们的is_int()函数将返回true

std::enable_if默认情况下什么也不做。但如果它为true,它会返回一个type别名,在前面的例子中,就是我们作为std::enable_if第二个参数传递的int类型。这意味着如果std::enable_iftrue,它将返回一个int类型。然后我们将这个int类型设置为0,这是一个有效的操作。这不会产生失败;我们的模板函数成为一个有效的替换,因此被使用。总之,如果Tint类型,std::enable_if会变成一个int类型本身,然后我们将其设置为0,这样就可以编译而不会出现问题。如果我们的T类型不是intstd::enable_if会变成什么也没有。试图将什么也没有设置为0会导致编译错误,但由于这是 SFINAE,编译器错误不会变成更多的替换错误。

让我们看看错误的情况。如果我们将42设置为42.0,这是一个double,而不是int,我们会得到以下结果:

正如您从上面的错误中看到的,编译器说在enable_if中没有名为type的类型。如果您查看std::enable_if的定义,这是预期的,因为如果为 false,std::enable_if不会执行任何操作。它只有在为 true 时才创建一个名为type的类型。

为了更好地理解这是如何工作的,让我们看另一个例子:

#include <iostream>
#include <type_traits>

template<
    typename T,
    std::enable_if_t<std::is_integral_v<T>>* = nullptr
    >
void the_answer(T is)
{
    std::cout << "The answer is: " << is << '\n';
}

int main(void)
{
    the_answer(42);
    return 0;
}

输出如下:

在上面的示例中,我们使用了std::is_integral_v,它与我们的is_int()函数做了相同的事情,不同之处在于它是由标准库提供的,并且可以处理 CV 类型。事实上,标准库有一个巨大的不同版本的这些函数列表,包括不同的类型、继承属性、CV 属性等等。如果您需要检查任何类型的type属性,很有可能标准库有一个std:is_xxx函数可以使用。

上面的例子几乎与我们之前的例子相同,不同之处在于我们在std::enable_if方法中不返回int。相反,我们使用* = nullptr。这是因为std::enable_if默认返回void*字符将这个 void 转换为一个 void 指针,然后我们将其设置为nullptr

在下一个例子中,我们展示了另一个变化:

#include <iostream>
#include <type_traits>

template<typename T>
std::enable_if_t<std::is_integral_v<T>>
the_answer(T is)
{
    std::cout << "The answer is: " << is << '\n';
}

int main(void)
{
    the_answer(42);
    return 0;
}

输出如下:

在这个例子中,我们的函数的void是由std::enable_if创建的。如果T不是整数,就不会返回void,我们会看到这个错误(而不是首先编译和允许我们执行它):

总之,std::enable_if将创建一个名为type的类型,该类型基于您提供的类型。默认情况下,这是void,但您可以传入任何您想要的类型。这种功能不仅可以用于强制执行模板的类型,还可以根据我们提供的类型定义不同的函数,就像在这个示例中所示的那样:

#include <iostream>
#include <type_traits>
#include <iomanip>

template<
    typename T,
    std::enable_if_t<std::is_integral_v<T>>* = nullptr
    >
void the_answer(T is)
{
    std::cout << "The answer is: " << is << '\n';
}

template<
    typename T,
    std::enable_if_t<std::is_floating_point_v<T>>* = nullptr
    >
void the_answer(T is)
{
    std::cout << std::setprecision(10);
    std::cout << "The answer is: " << is << '\n';
}

int main(void)
{
    the_answer(42);
    the_answer(42U);
    the_answer(42.12345678);

    return 0;
}

上面代码的输出如下:

就像本教程中的第一个例子一样,我们创建了相同函数的两个不同版本。SFINAE 允许编译器根据提供的类型选择最合适的版本。

学习完美转发

在这个教程中,我们将学习如何使用完美转发。这个教程很重要,因为在编写模板时,通常我们将模板参数传递给其他函数。如果我们不使用完美转发,我们可能会无意中将 r 值引用转换为 l 值引用,导致潜在的复制发生,而不是移动,在某些情况下,这可能是不太理想的。完美转发还为编译器提供了一些提示,可以用来改进函数内联和展开。

准备工作

在开始之前,请确保满足所有技术要求,包括安装 Ubuntu 18.04 或更高版本,并在终端窗口中运行以下命令:

> sudo apt-get install build-essential git cmake

这将确保您的操作系统具有编译和执行本教程中示例所需的适当工具。完成后,打开一个新的终端。我们将使用这个终端来下载、编译和运行我们的示例。

如何做...

您需要执行以下步骤来尝试这个教程:

  1. 从一个新的终端,运行以下命令来下载源代码:
> cd ~/
> git clone https://github.com/PacktPublishing/Advanced-CPP-CookBook.git
> cd Advanced-CPP-CookBook/chapter04
  1. 要编译源代码,请运行以下命令:
> cmake .
> make recipe02_examples
  1. 一旦源代码编译完成,您可以通过运行以下命令来执行本教程中的每个示例:
> ./recipe02_example01
l-value
l-value

> ./recipe02_example02
l-value
r-value

> ./recipe02_example03
l-value: 42
r-value: 42

在下一节中,我们将逐个介绍这些示例,并解释每个示例程序的作用以及它与本教程中所教授的课程的关系。

工作原理...

在这个示例中,我们将学习如何使用完美转发来确保当我们在模板中传递参数时(也就是转发我们的参数),我们以不会抹去 r-value 特性的方式进行。为了更好地理解这个问题,让我们看下面的例子:

#include <iostream>

struct the_answer
{ };

void foo2(const the_answer &is)
{
    std::cout << "l-value\n";
}

void foo2(the_answer &&is)
{
    std::cout << "r-value\n";
}

template<typename T>
void foo1(T &&t)
{
    foo2(t);
}

int main(void)
{
    the_answer is;
    foo1(is);
    foo1(the_answer());

    return 0;
}

输出如下:

在前面的示例中,我们有foo()函数的两个不同版本:一个接受 l-value 引用,一个接受 r-value 引用。然后我们从模板函数中调用foo()。这个模板函数接受一个转发引用(也称为通用引用),它是一个 r-value 引用,配合auto或模板函数。最后,从我们的主函数中,我们调用我们的模板来看哪个foo()函数被调用。第一次调用我们的模板时,我们传入一个 l-value。由于我们得到了一个 l-value,通用引用变成了 l-value,并且调用了我们的foo()函数的 l-value 版本。问题是,第二次调用我们的模板函数时,我们给它一个 r-value,但它调用了我们的foo()函数的 l-value 版本,即使它得到了一个 r-value。

这里的常见错误是,即使模板函数接受一个通用引用,我们也有一个接受 r-value 的foo()函数的版本,我们假设会调用这个foo()函数。Scott Meyers 在他关于通用引用的许多讲座中很好地解释了这一点。问题在于,一旦使用通用引用,它就变成了 l-value。传递names参数的行为,意味着它必须是 l-value。它迫使编译器将其转换为 l-value,因为它看到你在使用它,即使你只是在传递参数。值得注意的是,我们的示例在优化时无法编译,因为编译器可以安全地确定变量没有被使用,从而可以优化掉 l-value。

为了防止这个问题,我们需要告诉编译器我们希望转发参数。通常,我们会使用std::move()来实现。问题是,如果我们最初得到的是 l-value,我们不能使用std::move(),因为那样会将 l-value 转换为 r-value。这就是标准库有std::forward()的原因,它是使用以下方式实现的:

static_cast<T&&>(t)

std::forward()的作用如下:将参数强制转换回其原始引用类型。这告诉编译器明确地将参数视为 r-value,如果它最初是 r-value,就像以下示例中一样:

#include <iostream>

struct the_answer
{ };

void foo2(const the_answer &is)
{
    std::cout << "l-value\n";
}

void foo2(the_answer &&is)
{
    std::cout << "r-value\n";
}

template<typename T>
void foo1(T &&t)
{
    foo2(std::forward<T>(t));
}

int main(void)
{
    the_answer is;
    foo1(is);
    foo1(the_answer());

    return 0;
}

输出如下:

前面的示例与第一个示例相同,唯一的区别是我们在模板函数中使用std::forward()传递参数。这一次,当我们用 r-value 调用我们的模板函数时,它调用我们的foo()函数的 r-value 版本。这被称为完美转发。它确保我们在传递参数时保持 CV 属性和 l-/r-value 属性。值得注意的是,完美转发只在使用模板函数或auto时有效。这意味着完美转发通常只在编写包装器时有用。标准库包装器的一个很好的例子是std::make_unique()

std::make_unique()这样的包装器的一个问题是,你可能不知道需要传递多少个参数。也就是说,你可能最终需要在你的包装器中使用可变模板参数。完美转发通过以下方式支持这一点:

#include <iostream>

struct the_answer
{ };

void foo2(const the_answer &is, int i)
{
    std::cout << "l-value: " << i << '\n';
}

void foo2(the_answer &&is, int i)
{
    std::cout << "r-value: " << i << '\n';
}

template<typename... Args>
void foo1(Args &&...args)
{
    foo2(std::forward<Args>(args)...);
}

int main(void)
{
    the_answer is;

    foo1(is, 42);
    foo1(the_answer(), 42);

    return 0;
}

输出如下:

前面的示例之所以有效,是因为传递给我们的foo()函数的可变模板参数被替换为逗号分隔的完美转发列表。

使用 if constexpr

在这个教程中,我们将学习如何使用 C++17 中的一个新特性constexpr if。这个教程很重要,因为它将教会你如何创建在运行时评估的if语句。具体来说,这意味着分支逻辑是在编译时选择的,而不是在运行时。这允许您在编译时更改函数的行为,而不会牺牲性能,这是过去只能通过宏来实现的,而在模板编程中并不实用,正如我们将展示的那样。

准备工作

开始之前,请确保满足所有技术要求,包括安装 Ubuntu 18.04 或更高版本,并在终端窗口中运行以下命令:

> sudo apt-get install build-essential git

这将确保您的操作系统具有编译和执行本教程中示例所需的适当工具。完成后,打开一个新的终端。我们将使用这个终端来下载、编译和运行我们的示例。

操作步骤...

您需要执行以下步骤来尝试这个教程:

  1. 从新的终端运行以下命令来下载源代码:
> cd ~/
> git clone https://github.com/PacktPublishing/Advanced-CPP-CookBook.git
> cd Advanced-CPP-CookBook/chapter04
  1. 要编译源代码,请运行以下命令:
> cmake .
> make recipe03_examples
  1. 源代码编译完成后,您可以通过运行以下命令来执行本教程中的每个示例:
> ./recipe03_example01
The answer is: 42

> ./recipe03_example02
The answer is: 42
The answer is: 42.12345678

在下一节中,我们将逐个介绍这些示例,并解释每个示例程序的作用,以及它与本教程所教授的课程的关系。

工作原理...

有时,我们希望改变程序的行为,但我们创建的代码始终是常量,这意味着编译器能够确定分支本身的值,就像这个示例中所示的那样:

if (!NDEBUG) {}

这是一个常见的if语句,在很多代码中都有,包括标准库。如果启用了调试,这段代码将评估为true。我们可以通过向代码添加调试语句来使用它,这些语句可以被关闭。编译器足够聪明,能够看到NDEBUGtrue还是false,并且会添加代码或完全删除代码。换句话说,编译器可以进行简单的优化,减小代码的大小,并且在运行时永远不会改变这个if语句的值。问题是,这个技巧依赖于编译器的智能。逻辑的移除是隐式信任的,这经常导致对编译器正在做什么的假设。C++17 添加了一个constexpr if语句,允许我们明确地进行。它允许我们告诉编译器:我提供的语句应该在编译时而不是在运行时进行评估。这真正强大的地方在于,当这个假设不成立时,我们会在编译时获得编译时错误,这意味着我们以前隐式信任编译器执行的优化,现在可以在编译时进行验证,如果假设是错误的,我们会得到通知,以便我们可以解决问题,就像这个示例中所示的那样:

#include <iostream>

constexpr auto answer = 42;

int main(void)
{
    if constexpr (answer == 42) {
        std::cout << "The answer is: " << answer << '\n';
    }
    else {
        std::cout << "The answer is not: " << answer << '\n';
    }

    return 0;
}

输出如下:

在前面的示例中,我们创建了constexpr并在编译时而不是在运行时进行了评估。如果我们将constexpr更改为实际变量,constexpr if将导致以下错误:

然后我们可以在我们的模板函数中使用它来根据我们给定的类型改变我们的模板函数的行为,就像这个示例中所示的那样:

#include <iostream>
#include <iomanip>

template<typename T>
constexpr void foo(T &&t)
{
    if constexpr (std::is_floating_point_v<T>) {
        std::cout << std::setprecision(10);
    }

    std::cout << "The answer is: " << std::forward<T>(t) << '\n';
}

int main(void)
{
    foo(42);
    foo(42.12345678);
    return 0;
}

在前面的示例中,我们使用std::is_floating_point_v类型特征来确定我们提供的类型是否是浮点类型。如果类型不是浮点类型,这将返回constexpr false,编译器可以优化掉。由于我们使用了constexpr if,我们可以确保我们的if语句实际上是constexpr而不是运行时条件。

使用元组处理参数包

在本教程中,我们将学习如何使用std::tuple处理可变参数列表。这很重要,因为可变参数列表是用于包装函数的,包装器不知道传递给它的参数,而是将这些参数转发给了解这些参数的东西。然而,也有一些用例,你会关心传递的参数,并且必须有一种方法来处理这些参数。本教程将演示如何做到这一点,包括如何处理任意数量的参数。

准备工作

开始之前,请确保满足所有技术要求,包括安装 Ubuntu 18.04 或更高版本,并在终端窗口中运行以下命令:

> sudo apt-get install build-essential git cmake

这将确保您的操作系统具有正确的工具来编译和执行本教程中的示例。完成后,打开一个新的终端。我们将使用这个终端来下载、编译和运行我们的示例。

操作步骤

您需要执行以下步骤来尝试本教程:

  1. 从新的终端中运行以下命令以下载源代码:
> cd ~/
> git clone https://github.com/PacktPublishing/Advanced-CPP-CookBook.git
> cd Advanced-CPP-CookBook/chapter04
  1. 要编译源代码,请运行以下命令:
> cmake .
> make recipe04_examples
  1. 源代码编译完成后,可以通过运行以下命令执行本教程中的每个示例:
> ./recipe04_example01

> ./recipe04_example02
the answer is: 42

> ./recipe04_example03
The answer is: 42

> ./recipe04_example04
2
2

> ./recipe04_example05
The answer is: 42

在下一节中,我们将逐个介绍这些示例,并解释每个示例程序的作用以及它与本教程中所教授的内容的关系。

工作原理

可变模板使程序员能够定义模板函数,而无需定义所有参数。这些在包装函数中被广泛使用,因为它们防止包装器必须了解函数的参数,如下例所示:

#include <iostream>

template<typename... Args>
void foo(Args &&...args)
{ }

int main(void)
{
    foo("The answer is: ", 42);
    return 0;
}

如前面的示例所示,我们创建了一个可以接受任意数量参数的foo函数。在这个例子中,我们使用了通用引用符号Args &&...args,它确保了 CV 限定符和 l-/r-值性得到保留,这意味着我们可以使用std::forward()将可变参数列表传递给任何其他函数,尽可能少地降低性能损失。诸如std::make_unique()之类的函数大量使用可变参数。

然而,有时您可能希望访问提供的参数列表中的一个参数。为此,我们可以使用std::tuple。这是一个接受可变数量参数并提供std::get()函数从std::tuple获取任何数据的数据结构,如下例所示:

#include <tuple>
#include <iostream>

int main(void)
{
    std::tuple t("the answer is: ", 42);
    std::cout << std::get<0>(t) << std::get<1>(t) << '\n';
    return 0;
}

输出如下:

在前面的示例中,我们创建了std::tuple,然后使用std::get()函数将std::tuple的内容输出到stdout。如果尝试访问超出范围的数据,编译器将在编译时知道,并给出类似于以下的错误:

使用std::tuple,我们可以按以下方式访问可变参数列表中的数据:

#include <tuple>
#include <iostream>

template<typename... Args>
void foo(Args &&...args)
{
    std::tuple t(std::forward<Args>(args)...);
    std::cout << std::get<0>(t) << std::get<1>(t) << '\n';
}

int main(void)
{
    foo("The answer is: ", 42);
    return 0;
}

输出如下:

在前面的示例中,我们创建了一个带有可变参数列表的函数。然后,我们使用std::forward()传递此列表以保留 l-/r-值性到std::tuple。最后,我们使用std::tuple来访问这些参数。如果我们不使用std::forward(),我们将得到传递给std::tuple的数据的 l-value 版本。

上面例子的明显问题是,我们在std::tuple中硬编码了01索引。可变参数不是运行时的、动态的参数数组。相反,它们是一种说“我不关心我收到的参数”的方式,这就是为什么它们通常被包装器使用的原因。包装器是包装一些关心参数的东西。在std::make_unique()的情况下,该函数正在创建std::unique_ptr。为此,std::make_unique()将为您分配std::unique_ptr,使用可变参数列表来初始化新分配的类型,然后将指针提供给std::unique_ptr,就像这个例子中所示的那样:

template<
    typename T, 
    typename... Args
    >
void make_unique(Args &&...args)
{
    return unique_ptr<T>(new T(std::forward<Args>(args)...));
}

包装器不关心传递的参数。T的构造函数关心。如果你尝试访问可变参数,你就是在说“我关心这些参数”,在这种情况下,如果你关心,你必须对传递的参数的布局有一些想法。

有一些技巧可以让你处理未知数量的参数,然而。尝试这样做的最大问题是处理可变参数的库设施最好在运行时使用,这在大多数情况下并不起作用,就像这个例子中所示的那样:

#include <tuple>
#include <iostream>

template<typename... Args>
void foo(Args &&...args)
{
    std::cout << sizeof...(Args) << '\n';
    std::cout << std::tuple_size_v<std::tuple<Args...>> << '\n';
}

int main(void)
{
    foo("The answer is: ", 42);
    return 0;
}

输出如下:

在上面的例子中,我们试图获取可变参数列表中参数的总大小。我们可以使用sizeof()函数的可变版本,也可以使用std::tuple_size特性来实现这一点。问题是,这并不能在编译时帮助我们,因为我们无法使用这个大小信息来循环遍历参数(因为编译时逻辑没有for循环)。

为了克服这一点,我们可以使用一种称为编译时递归的技巧。这个技巧使用模板来创建一个递归模板函数,它将循环遍历可变参数列表中的所有参数。看看这个例子:

#include <tuple>
#include <iostream>

template<
    std::size_t I = 0,
    typename ... Args,
    typename FUNCTION
    >
constexpr void
for_each(const std::tuple<Args...> &t, FUNCTION &&func)
{
    if constexpr (I < sizeof...(Args)) {
        func(std::get<I>(t));
        for_each<I + 1>(t, std::forward<FUNCTION>(func));
    }
}

我们从一个执行所有魔术的模板函数开始。第一个模板参数是I,它是一个从0开始的整数。接下来是一个可变模板参数,最后是一个函数类型。我们的模板函数接受我们希望迭代的std::tuple(在这种情况下,我们展示了一个常量版本,但我们也可以重载它以提供一个非常量版本),以及我们希望对std::tuple中的每个元素调用的函数。换句话说,这个函数将循环遍历std::tuple中的每个元素,并对每个迭代的元素调用提供的函数,就像我们在其他语言或 C++库中运行时使用的for_each()一样。

在这个函数内部,我们检查是否已经达到了元组的总大小。如果没有,我们获取元组中当前值为I的元素,将其传递给提供的函数,然后再次调用我们的for_each()函数,传入I++。要使用这个for_each()函数,我们可以这样做:

template<typename... Args>
void foo(Args &&...args)
{
    std::tuple t(std::forward<Args>(args)...);
    for_each(t, [](const auto &arg) {
        std::cout << arg;
    });
}

在这里,我们得到了一个可变参数列表,我们希望迭代这个列表并将每个参数输出到stdout。为此,我们创建了std::tuple,就像以前一样,但这次,我们将std::tuple传递给我们的for_each()函数:

int main(void)
{
    foo("The answer is: ", 42);
    std::cout << '\n';

    return 0;
}

输出如下:

就像我们在之前的例子中所做的那样,我们调用我们的foo函数,并传入一些文本,我们希望将其输出到stdout,从而演示如何使用std:tuple处理可变函数参数,即使我们不知道将收到的参数的总数。

使用类型特征来重载函数和对象

C++11 创建时,C++需要处理的一个问题是如何处理std::vector的调整大小,它能够接受任何类型,包括从std::move()抛出异常的类型。调整大小时,会创建新的内存,并将旧向量的元素移动到新向量。这很好地工作,因为如果std::move()不能抛出异常,那么一旦调整大小函数开始将元素从一个数组移动到另一个数组,就不会发生错误。

然而,如果std::move()可能会抛出异常,那么在循环进行到一半时可能会发生错误。然而,resize()函数无法将旧内存恢复正常,因为尝试移动到旧内存也可能会引发异常。在这种情况下,resize()执行复制而不是移动。复制确保旧内存有每个对象的有效副本;因此,如果抛出异常,原始数组保持不变,并且可以根据需要抛出异常。

在本示例中,我们将探讨如何通过更改模板类的行为来实现这一点。

准备工作

开始之前,请确保满足所有技术要求,包括安装 Ubuntu 18.04 或更高版本,并在终端窗口中运行以下命令:

> sudo apt-get install build-essential git cmake

这将确保您的操作系统具有编译和执行本示例的适当工具。完成后,打开一个新的终端。我们将使用此终端来下载、编译和运行示例。

如何做...

要尝试此示例,需要执行以下步骤:

  1. 从新的终端运行以下命令以下载源代码:
> cd ~/
> git clone https://github.com/PacktPublishing/Advanced-CPP-CookBook.git
> cd Advanced-CPP-CookBook/chapter04
  1. 要编译源代码,请运行以下命令:
> cmake .
> make recipe05_examples
  1. 编译源代码后,可以通过运行以下命令来执行本示例中的每个示例:
> ./recipe05_example01
noexcept: r-value
can throw: l-value

> ./recipe05_example02
move
move
move
move
move
--------------
copy
copy
copy
copy
copy

在下一节中,我们将逐步介绍每个示例,并解释每个示例程序的作用以及它与本示例中所教授的课程的关系。

工作原理...

C++添加了一个名为std::move_if_noexcept()的函数。如果移动构造函数/赋值运算符不能抛出异常,此函数将转换为右值,否则将转换为左值。例如,看一下以下代码:

#include <iostream>

struct the_answer_noexcept
{
    the_answer_noexcept() = default;

    the_answer_noexcept(const the_answer_noexcept &is) noexcept
    {
        std::cout << "l-value\n";
    }

    the_answer_noexcept(the_answer_noexcept &&is) noexcept
    {
        std::cout << "r-value\n";
    }
};

要尝试这样做,我们将执行以下步骤:

  1. 首先,我们将创建一个类,该类具有一个不能抛出异常的移动/复制构造函数:
struct the_answer_can_throw
{
    the_answer_can_throw() = default;

    the_answer_can_throw(const the_answer_can_throw &is)
    {
        std::cout << "l-value\n";
    }

    the_answer_can_throw(the_answer_can_throw &&is)
    {
        std::cout << "r-value\n";
    }
};
  1. 接下来,我们将提供一个具有可能抛出异常的移动/复制构造函数的类。最后,让我们使用std::move_if_noexcept()来查看在尝试移动这些先前类的实例时是发生移动还是复制:
int main(void)
{
    the_answer_noexcept is1;
    the_answer_can_throw is2;

    std::cout << "noexcept: ";
    auto is3 = std::move_if_noexcept(is1);

    std::cout << "can throw: ";
    auto is4 = std::move_if_noexcept(is2);

    return 0;
}

上述代码的输出如下:

如前面的示例所示,在一种情况下,调用移动构造函数,在另一种情况下,调用复制构造函数,这取决于类型在执行移动时是否会抛出异常。

  1. 现在,让我们创建一个简单的模拟向量,并添加一个调整大小函数,以演示如何使用特性更改我们的template类的行为:
#include <memory>
#include <iostream>
#include <stdexcept>

template<typename T>
class mock_vector
{
public:
    using size_type = std::size_t;

    mock_vector(size_type s) :
        m_size{s},
        m_buffer{std::make_unique<T[]>(m_size)}
    { }

    void resize(size_type size)
        noexcept(std::is_nothrow_move_constructible_v<T>)
    {
        auto tmp = std::make_unique<T[]>(size);

        for (size_type i = 0; i < m_size; i++) {
            tmp[i] = std::move_if_noexcept(m_buffer[i]);
        }

        m_size = size;
        m_buffer = std::move(tmp);
    }

private:
    size_type m_size{};
    std::unique_ptr<T[]> m_buffer{};
};

我们的模拟向量有一个内部缓冲区和一个大小。当创建向量时,我们使用给定的大小分配内部缓冲区。然后我们提供一个resize()函数,可以用来调整内部缓冲区的大小。我们首先创建新的内部缓冲区,然后循环遍历每个元素,并将一个缓冲区的元素复制到另一个缓冲区。如果T不能抛出异常,在循环执行过程中不会触发任何异常,此时新缓冲区将是有效的。如果T可以抛出异常,将会发生复制。如果发生异常,旧缓冲区尚未被新缓冲区替换。相反,新缓冲区将被删除,以及所有被复制的元素。

要使用这个,让我们创建一个在移动构造函数/赋值运算符中可能抛出异常的类:

struct suboptimal
{
    suboptimal() = default;

    suboptimal(suboptimal &&other)
    {
        *this = std::move(other);
    }

    suboptimal &operator=(suboptimal &&)
    {
        std::cout << "move\n";
        return *this;
    }

    suboptimal(const suboptimal &other)
    {
        *this = other;
    }

    suboptimal &operator=(const suboptimal &)
    {
        std::cout << "copy\n";
        return *this;
    }
};

让我们还添加一个在移动构造函数/赋值运算符中不能抛出异常的类:

struct optimal
{
    optimal() = default;

    optimal(optimal &&other) noexcept
    {
        *this = std::move(other);
    }

    optimal &operator=(optimal &&) noexcept
    {
        std::cout << "move\n";
        return *this;
    }

    optimal(const optimal &other)
    {
        *this = other;
    }

    optimal &operator=(const optimal &)
    {
        std::cout << "copy\n";
        return *this;
    }
};

最后,我们将使用这两个类创建一个向量,并尝试调整其大小:

int main(void)
{
    mock_vector<optimal> d1(5);
    mock_vector<suboptimal> d2(5);

    d1.resize(10);
    std::cout << "--------------\n";
    d2.resize(10);

    return 0;
}

前面的代码的输出如下:

如前面的示例所示,当我们尝试调整类的大小时,如果移动不能抛出异常,则执行移动操作,否则执行复制操作。换句话说,类的行为取决于T类型的特征。

学习如何实现 template

C++很长时间以来就具有创建模板的能力,这使程序员可以根据类型创建类和函数的通用实现。但是,您也可以提供非类型参数。

在 C++17 中,您现在可以使用auto来提供通用的非类型模板参数。在本示例中,我们将探讨如何使用此功能。这很重要,因为它允许您在代码中创建更通用的模板。

准备工作

在开始之前,请确保满足所有技术要求,包括安装 Ubuntu 18.04 或更高版本,并在终端窗口中运行以下命令:

> sudo apt-get install build-essential git cmake

这将确保您的操作系统具有适当的工具来编译和执行本示例中的示例。完成后,打开一个新的终端。我们将使用此终端来下载、编译和运行示例。

操作步骤...

您需要执行以下步骤来尝试此示例:

  1. 从新的终端运行以下命令以下载源代码:
> cd ~/
> git clone https://github.com/PacktPublishing/Advanced-CPP-CookBook.git
> cd Advanced-CPP-CookBook/chapter04
  1. 要编译源代码,请运行以下命令:
> cmake .
> make recipe06_examples
  1. 源代码编译完成后,可以通过运行以下命令来执行本文中的每个示例:
> ./recipe06_example01
The answer is: 42
> ./recipe06_example02
The answer is: 42
The answer is: 42
> ./recipe06_example03
The answer is: 42

在下一节中,我们将逐个介绍每个示例,并解释每个示例程序的作用以及它与本示例中所教授的课程的关系。

工作原理...

在 C++17 之前,您可以在模板中提供非类型模板参数,但是您必须在定义中声明变量类型,就像本示例中所示的那样:

#include <iostream>

template<int answer>
void foo()
{
    std::cout << "The answer is: " << answer << '\n';
}

int main(void)
{
    foo<42>();
    return 0;
}

输出如下:

在前面的示例中,我们创建了一个int类型的模板参数变量,并将此变量的值输出到stdout。在 C++17 中,我们现在可以这样做:

#include <iostream>

template<auto answer>
void foo()
{
    std::cout << "The answer is: " << answer << '\n';
}

int main(void)
{
    foo<42>();
    return 0;
}

以下是输出:

如前所示,我们现在可以使用auto而不是int。这使我们能够创建一个可以接受多个非类型模板参数的函数。我们还可以使用类型特征来确定允许使用哪些非类型参数,就像本示例中所示的那样:

#include <iostream>
#include <type_traits>

template<
    auto answer,
 std::enable_if_t<std::is_integral_v<decltype(answer)>, int> = 0
 >
void foo()
{
    std::cout << "The answer is: " << answer << '\n';
}

int main(void)
{
    foo<42>();
    return 0;
}

输出如下:

在前面的示例中,我们的模板非类型参数只能是整数类型。

使用显式模板声明

在本示例中,我们将探讨如何通过创建显式模板声明来加快模板类的编译速度。这很重要,因为模板需要编译器根据需要创建类的实例。在某些情况下,显式模板声明可能为程序员提供一种加快编译速度的方法,通过缓存最有可能使用的模板类型,从而避免包含整个模板定义的需要。

准备工作

在开始之前,请确保满足所有技术要求,包括安装 Ubuntu 18.04 或更高版本,并在终端窗口中运行以下命令:

> sudo apt-get install build-essential git cmake

这将确保您的操作系统具有适当的工具来编译和执行本示例中的示例。完成后,打开一个新的终端。我们将使用此终端来下载、编译和运行示例。

操作步骤...

您需要执行以下步骤来尝试此示例:

  1. 从新的终端运行以下命令以下载源代码:
> cd ~/
> git clone https://github.com/PacktPublishing/Advanced-CPP-CookBook.git
> cd Advanced-CPP-CookBook/chapter04
  1. 要编译源代码,请运行以下命令:
> cmake .
> make recipe07_examples
  1. 源代码编译完成后,可以通过运行以下命令来执行本文中的每个示例:
> ./recipe07_example01 
The answer is: 42
The answer is: 42
The answer is: 42.1
> ./recipe07_example02 
The answer is: 4

在下一节中,我们将逐个介绍这些例子,并解释每个例子程序的作用以及它与本教程中所教授的课程的关系。

工作原理...

每当编译器看到使用给定类型的模板类时,它会隐式地创建该类型的一个版本。然而,这可能会发生多次,降低编译器的速度。然而,如果预先知道要使用的类型,这个问题可以通过显式模板特化来解决。看看这个例子:

#include <iostream>

template<typename T>
class the_answer
{
public:
    the_answer(T t)
    {
        std::cout << "The answer is: " << t << '\n';
    }
};

之前,我们创建了一个简单的结构,在构造过程中输出到stdout。通常,一旦看到类的第一个特化,编译器就会创建这个类。然而,我们可以执行以下操作:

template class the_answer<int>;
template class the_answer<unsigned>;
template class the_answer<double>;

这类似于一个类原型,它明确地创建了我们期望使用的特化。这些必须在它们在代码中使用之前声明(这意味着它们通常在模板的定义之后声明);然而,一旦声明了,它们可以如下使用:

int main(void)
{
    the_answer{42};
    the_answer{42U};
    the_answer{42.1};

    return 0;
}

代码的输出如下:

在前面的示例中,我们可以像平常一样创建模板的实例,但是在这种情况下,我们可以加快编译器在大量使用这个类的情况下的速度。这是因为在源代码中,我们不需要包含模板的实现。为了证明这一点,让我们看另一个更复杂的例子。在一个头文件(名为recipe07.h)中,我们将使用以下内容创建我们的模板:

template<typename T>
struct the_answer
{
    T m_answer;

    the_answer(T t);
    void print();
};

如你所见,我们有一个没有提供函数实现的template类。然后,我们将提供这个模板的实现,使用以下内容在它自己的源文件中:

#include <iostream>
#include "recipe07.h"

template<typename T>
the_answer<T>::the_answer(T t) :
    m_answer{t}
{ }

template<typename T>
void the_answer<T>::print()
{
    std::cout << "The answer is: " << m_answer << '\n';
}

template class the_answer<int>;

正如你在前面的例子中所看到的,我们添加了显式的模板声明。这确保我们生成了我们期望的类的实现。编译器将为我们期望的类显式地创建实例,就像我们通常编写的任何其他源代码一样。不同之处在于,我们可以明确地为任何类型定义这个类。最后,我们将调用这段代码如下:

#include "recipe07.h"

int main(void)
{
    the_answer is{42};
    is.print();

    return 0;
}

输出如下:

如你所见,我们可以以与使用显式类型定义的类相同的方式调用我们的类,而不是使用一个小型的头文件,它没有完整的实现,从而使编译器加快速度。

第五章:并发和同步

在本章中,我们将学习如何正确处理 C++中的并发、同步和并行。在这里,您需要对 C++和 C++线程有一般的了解。本章很重要,因为在处理 C++时通常需要使用共享资源,如果没有正确实现线程安全,这些资源很容易变得损坏。我们将首先对std::mutexes进行广泛的概述,它提供了一种同步 C++线程的方法。然后我们将研究原子数据类型,它提供了另一种安全处理并行性的机制。

本章包含了演示如何处理 C++线程的不同场景的示例,包括处理const &、线程安全包装、阻塞与异步编程以及 C++ promises 和 futures。这是很重要的,因为在处理多个执行线程时,这些知识是至关重要的。

本章涵盖了以下示例:

  • 使用互斥锁

  • 使用原子数据类型

  • 了解在多个线程的上下文中const & mutable 的含义

  • 使类线程安全

  • 同步包装器及其实现方法

  • 阻塞操作与异步编程

  • 使用 promises 和 futures

技术要求

要编译和运行本章中的示例,您必须具有管理权限的计算机运行 Ubuntu 18.04,并具有正常的互联网连接。在运行这些示例之前,您必须安装以下内容:

> sudo apt-get install build-essential git cmake

如果此软件安装在 Ubuntu 18.04 以外的任何操作系统上,则需要 GCC 7.4 或更高版本和 CMake 3.6 或更高版本。

使用互斥锁

在本示例中,我们将学习为什么以及如何在 C++中使用互斥锁。在 C++中使用多个线程时,通常会建立线程之间共享的资源。正如我们将在本示例中演示的那样,尝试同时使用这些共享资源会导致可能损坏资源的竞争条件。

互斥锁(在 C++中写作std::mutex)是一个用于保护共享资源的对象,确保多个线程可以以受控的方式访问共享资源。这可以防止资源损坏。

准备工作

在开始之前,请确保满足所有技术要求,包括安装 Ubuntu 18.04 或更高版本,并在终端窗口中运行以下命令:

> sudo apt-get install build-essential git

这将确保您的操作系统具有编译和执行本示例所需的正确工具。完成后,打开一个新的终端。我们将使用此终端来下载、编译和运行我们的示例。

如何做...

您需要执行以下步骤来尝试此示例:

  1. 从新终端运行以下命令以下载源代码:
> cd ~/
> git clone https://github.com/PacktPublishing/Advanced-CPP-CookBook.git
> cd Advanced-CPP-CookBook/chapter05
  1. 要编译源代码,请运行以下命令:
> cmake .
> make recipe01_examples
  1. 源代码编译完成后,您可以通过运行以下命令来执行本示例中的每个示例:
> ./recipe01_example01
The answer is: 42
The answer is: 42
The answer is: 42
The
 answer is: 42
The answer is: 42
...

> ./recipe01_example02
The answer is: 42
The answer is: 42
The answer is: 42
The answer is: 42
The answer is: 42
...

> ./recipe01_example03
...

> ./recipe01_example04
The answer is: 42

> ./recipe01_example05
The answer is: 42
The answer is: 42
The answer is: 42
The answer is: 42
The answer is: 42
...

> ./recipe01_example06
The answer is: 42
The answer is: 42

> ./recipe01_example07

> ./recipe01_example08
lock acquired
lock failed

在下一节中,我们将逐个介绍这些示例,并解释每个示例程序的作用以及它与本示例教授的课程的关系。

工作原理...

在本示例中,我们将学习如何使用std::mutex来保护共享资源,防止其损坏。首先,让我们首先回顾一下当多个线程同时访问资源时资源如何变得损坏:

#include <thread>
#include <string>
#include <iostream>

void foo()
{
    static std::string msg{"The answer is: 42\n"};
    while(true) {
        for (const auto &c : msg) {
            std::clog << c;
        }
    }
}

int main(void)
{
    std::thread t1{foo};
    std::thread t2{foo};

    t1.join();
    t2.join();

    // Never reached
    return 0;
}

执行时,我们得到以下输出:

在上面的示例中,我们创建了一个在无限循环中输出到stdout的函数。然后我们创建了两个线程,每个线程执行先前定义的函数。正如您所看到的,当两个线程同时执行时,结果输出变得损坏。这是因为当一个线程正在将其文本输出到stdout时,另一个线程同时输出到stdout,导致一个线程的输出与另一个线程的输出混合在一起。

要解决这个问题,我们必须确保一旦其中一个线程尝试将其文本输出到stdout,在另一个线程能够输出之前,它应该被允许完成输出。换句话说,每个线程必须轮流输出到stdout。当一个线程输出时,另一个线程必须等待轮到它。为了做到这一点,我们将利用一个std::mutex对象。

std::mutex

互斥锁是一个用来保护共享资源的对象,以确保对共享资源的使用不会导致损坏。为了实现这一点,std::mutex有一个lock()函数和一个unlock()函数。lock()函数获取对共享资源的访问(有时称为临界区)。unlock()释放先前获取的访问。任何尝试在另一个线程已经执行lock()之后执行lock()函数的操作都将导致线程必须等待,直到执行unlock()函数为止。

std::mutex的实现取决于 CPU 的架构和操作系统;但是,一般来说,互斥锁可以用一个简单的整数来实现。如果整数为0lock()函数将把整数设置为1并返回,这告诉互斥锁它已被获取。如果整数为1,意味着互斥锁已经被获取,lock()函数将等待(即阻塞),直到整数变为0,然后它将把整数设置为1并返回。如何实现这种等待取决于操作系统。例如,wait()函数可以循环直到整数变为0,这被称为自旋锁,或者它可以执行sleep()函数并等待一段时间,允许其他线程和进程在互斥锁被锁定时执行。释放函数总是将整数设置为0,这意味着互斥锁不再被获取。确保互斥锁正常工作的诀窍是确保使用原子操作读/写整数。如果使用非原子操作,整数本身将遭受与互斥锁试图防止的相同的共享资源损坏。

例如,考虑以下情况:

#include <mutex>
#include <thread>
#include <string>
#include <iostream>

std::mutex m{};

void foo()
{
    static std::string msg{"The answer is: 42\n"};
    while(true) {
        m.lock();
        for (const auto &c : msg) {
            std::clog << c;
        }
        m.unlock();
    }
}

int main(void)
{
    std::thread t1{foo};
    std::thread t2{foo};

    t1.join();
    t2.join();

    // Never reached
    return 0;
}

此示例运行时输出以下内容:

在前面的例子中,我们创建了一个输出到stdout的相同函数。不同之处在于,在我们输出到stdout之前,我们通过执行lock()函数来获取std::mutex。一旦我们完成了对stdout的输出,我们通过执行unlock()函数来释放互斥锁。在lock()unlock()函数之间的代码称为临界区。临界区中的任何代码只能由一个线程在任何给定时间执行,确保我们对stdout的使用不会变得损坏。

通过控制对共享资源的访问(例如使用互斥锁)来确保共享资源不会变得损坏称为同步。尽管大多数需要线程同步的情况并不复杂,但有些情况可能导致需要整个大学课程来覆盖的线程同步方案。因此,线程同步被认为是计算机科学中极其困难的范式,需要正确编程。

在本教程中,我们将涵盖其中一些情况。首先,让我们讨论一下死锁。当一个线程在调用lock()函数时进入无休止的等待状态时,就会发生死锁。死锁通常非常难以调试,是由于几个原因造成的,包括以下原因:

  • 由于程序员错误或获取互斥锁的线程崩溃,导致线程从未调用unlock()

  • 同一个线程在调用unlock()之前多次调用lock()函数

  • 每个线程以不同的顺序锁定多个互斥锁

为了证明这一点,让我们看一下以下例子:

#include <mutex>
#include <thread>

std::mutex m{};

void foo()
{
    m.lock();
}

int main(void)
{
    std::thread t1{foo};
    std::thread t2{foo};

    t1.join();
    t2.join();

    // Never reached
    return 0;
}

在前面的例子中,我们创建了两个线程,它们都试图锁定互斥量,但从未调用unlock()。结果,第一个线程获取了互斥量,然后返回而没有释放它。当第二个线程尝试获取互斥量时,它被迫等待第一个线程执行unlock(),但第一个线程从未执行,导致死锁(即程序永远不会返回)。

在这个例子中,死锁很容易识别和纠正;然而,在现实场景中,识别死锁要复杂得多。让我们看下面的例子:

#include <array>
#include <mutex>
#include <thread>
#include <string>
#include <iostream>

std::mutex m{};
std::array<int,6> numbers{4,8,15,16,23,42};

int foo(int index)
{
    m.lock();
    auto element = numbers.at(index);
    m.unlock();

    return element;
}

int main(void)
{
    std::cout << "The answer is: " << foo(5) << '\n';
    return 0;
}

在前面的例子中,我们编写了一个函数,根据索引返回数组中的元素。此外,我们获取了保护数组的互斥量,并在返回之前释放了互斥量。挑战在于我们必须在函数可以返回的地方unlock()互斥量,这不仅包括从函数返回的每种可能分支,还包括抛出异常的所有可能情况。在前面的例子中,如果提供的索引大于数组大小,std::array对象将抛出异常,导致函数在调用unlock()之前返回,如果另一个线程正在共享此数组,将导致死锁。

std::lock_guard

C++提供了std::lock_guard对象来简化对std::mutex对象的使用,而不是在代码中到处使用try/catch块来防止死锁,这假设程序员甚至能够确定每种可能发生死锁的情况而不出错。

例如,考虑以下代码:

#include <mutex>
#include <thread>
#include <iostream>

std::mutex m{};

void foo()
{
    static std::string msg{"The answer is: 42\n"};

    while(true) {
        std::lock_guard lock(m);
        for (const auto &c : msg) {
            std::clog << c;
        }
    }
}

int main(void)
{
    std::thread t1{foo};
    std::thread t2{foo};

    t1.join();
    t2.join();

    // Never reached
    return 0;
}

执行时,我们看到以下结果:

如前面的例子所示,当我们通常在互斥量上调用lock()时,使用std::lock_guardstd::lock_guard在创建时调用互斥量的lock()函数,然后在销毁时调用互斥量的unlock()函数(一种称为资源获取即初始化RAII的习惯用法)。无论函数如何返回(无论是正常返回还是异常),互斥量都将被释放,确保死锁不可能发生,避免程序员必须准确确定函数可能返回的每种可能情况。

尽管std::lock_guard能够防止在从未调用unlock()的情况下发生死锁,但它无法防止在调用lock()多次之后再调用unlock()之前发生死锁的情况。为了处理这种情况,C++提供了std::recursive_mutex

std::recursive_mutex

递归互斥量每次同一线程调用lock()函数时都会增加互斥量内部存储的整数,而不会导致lock()函数等待。例如,如果互斥量被释放(即,互斥量中的整数为0),当线程#1调用lock()函数时,互斥量中的整数被设置为1。通常情况下,如果线程#1再次调用lock()函数,lock()函数会看到整数为1并进入等待状态,直到整数被设置为0。相反,递归互斥量将确定调用lock()函数的线程,并且如果获取互斥量的线程与调用lock()函数的线程相同,则使用原子操作再次增加互斥量中的整数(现在结果为2)。要释放互斥量,线程必须调用unlock(),这将使用原子操作递减整数,直到互斥量中的整数为0

递归互斥锁允许同一个线程调用lock()函数多次,防止多次调用lock()函数并导致死锁,但代价是lock()unlock()函数必须包括一个额外的函数调用来获取线程的id()实例,以便互斥锁可以确定是哪个线程在调用lock()unlock()

例如,考虑以下代码片段:

#include <mutex>
#include <thread>
#include <string>
#include <iostream>

std::recursive_mutex m{};

void foo()
{
    m.lock();
    m.lock();

    std::cout << "The answer is: 42\n";

    m.unlock();
    m.unlock();
}

int main(void)
{
    std::thread t1{foo};
    std::thread t2{foo};

    t1.join();
    t2.join();

    return 0;
}

前面的例子会导致以下结果:

在前面的例子中,我们定义了一个函数,该函数调用递归互斥锁的lock()函数两次,输出到stdout,然后再调用unlock()函数两次。然后我们创建两个执行此函数的线程,结果是stdout没有腐败,也没有死锁。

std::shared_mutex

直到这一点,我们的同步原语已经对共享资源进行了序列化访问。也就是说,每个线程在访问临界区时必须一次执行一个。虽然这确保了腐败是不可能的,但对于某些类型的场景来说效率不高。为了更好地理解这一点,我们必须研究是什么导致了腐败。

让我们考虑一个整数变量,它被两个线程同时增加。增加整数变量的过程如下:i = i + 1

让我们将其写成如下形式:

int i = 0;

auto tmp = i;
tmp++;
i = tmp; // i == 1

为了防止腐败,我们使用互斥锁来确保两个线程同步地增加整数:

auto tmp_thread1 = i;
tmp_thread1++;
i = tmp_thread1; // i == 1

auto tmp_thread2 = i;
tmp_thread2++;
i = tmp_thread2; // i == 2

当这些操作混合在一起时(也就是说,当两个操作在不同的线程中同时执行时),就会发生腐败。例如,考虑以下代码:

auto tmp_thread1 = i; // 0
auto tmp_thread2 = i; // 0
tmp_thread1++; // 1
tmp_thread2++; // 1
i = tmp_thread1; // i == 1
i = tmp_thread2; // i == 1

与整数为2不同,它是1,因为在第一个增量允许完成之前整数被读取。这种情况是可能的,因为两个线程都试图写入同一个共享资源。我们称这些类型的线程为生产者

然而,如果我们创建了 100 万个同时读取共享资源的线程会发生什么。由于整数永远不会改变,无论线程以什么顺序执行,它们都会读取相同的值,因此腐败是不可能的。我们称这些线程为消费者。如果我们只有消费者,我们就不需要线程同步,因为腐败是不可能的。

最后,如果我们有相同的 100 万个消费者,但是我们在其中添加了一个生产者会发生什么?现在,我们必须使用线程同步,因为可能在生产者试图将一个值写入整数的过程中,消费者也试图读取,这将导致腐败的结果。为了防止这种情况发生,我们必须使用互斥锁来保护整数。然而,如果我们使用std::mutex,那么所有 100 万个消费者都必须互相等待,即使消费者们自己可以在不担心腐败的情况下同时执行。只有当生产者尝试执行时,我们才需要担心。

为了解决这个明显的性能问题,C++提供了std::shared_mutex对象。例如,考虑以下代码:

#include <mutex>
#include <shared_mutex>
#include <thread>
#include <iostream>

int count_rw{};
const auto &count_ro = count_rw;

std::shared_mutex m{};

void reader()
{
    while(true) {
        std::shared_lock lock(m);
        if (count_ro >= 42) {
            return;
        }
    }
}

void writer()
{
    while(true) {
        std::unique_lock lock(m);
        if (++count_rw == 100) {
            return;
        }
    }
}

int main(void)
{
    std::thread t1{reader};
    std::thread t2{reader};
    std::thread t3{reader};
    std::thread t4{reader};
    std::thread t5{writer};

    t1.join();
    t2.join();
    t3.join();
    t4.join();
    t5.join();

    return 0;
}

在前面的例子中,我们创建了一个生产者函数(称为reader函数)和一个消费者函数(称为writer函数)。生产者使用std::unique_lock()锁定互斥锁,而消费者使用std::shared_lock()锁定互斥锁。每当使用std::unique_lock()锁定互斥锁时,所有其他线程都必须等待(无论是生产者还是消费者)。然而,如果使用std::shared_lock()锁定互斥锁,使用std::shared_lock()再次尝试锁定互斥锁不会导致线程等待。

只有在调用std::unique_lock()时才需要等待。这允许消费者在不等待彼此的情况下执行。只有当生产者尝试执行时,消费者必须等待,防止消费者相互串行化,最终导致更好的性能(特别是如果消费者的数量是 100 万)。

应该注意,我们使用const关键字来确保消费者不是生产者。这个简单的技巧确保程序员不会在不经意间认为他们已经编写了一个消费者,而实际上他们已经创建了一个生产者,因为如果发生这种情况,编译器会警告程序员。

std::timed_mutex

最后,我们还没有处理线程获取互斥锁后崩溃的情况。在这种情况下,任何尝试获取相同互斥锁的线程都会进入死锁状态,因为崩溃的线程永远没有机会调用unlock()。预防这种问题的一种方法是使用std::timed_mutex

例如,考虑以下代码:

#include <mutex>
#include <thread>
#include <iostream>

std::timed_mutex m{};

void foo()
{
    using namespace std::chrono;

    if (m.try_lock_for(seconds(1))) {
        std::cout << "lock acquired\n";
    }
    else {
        std::cout << "lock failed\n";
    }
}

int main(void)
{
    std::thread t1{foo};
    std::thread t2{foo};

    t1.join();
    t2.join();

    return 0;
}

当执行这个时,我们得到以下结果:

在上面的例子中,我们告诉 C++线程只允许等待 1 秒。如果互斥锁已经被获取,并且在 1 秒后没有被释放,try_lock_for()函数将退出并返回 false,允许线程优雅地退出并处理错误,而不会进入死锁状态。

使用原子数据类型

在这个食谱中,我们将学习如何在 C++中使用原子数据类型。原子数据类型提供了读写简单数据类型(即布尔值或整数)的能力,而无需线程同步(即使用std::mutex和相关工具)。为了实现这一点,原子数据类型使用特殊的 CPU 指令来确保当执行操作时,它是作为单个原子操作执行的。

例如,递增一个整数可以写成如下:

int i = 0;

auto tmp = i;
tmp++;
i = tmp; // i == 1

原子数据类型确保这个递增是以这样的方式执行的,即没有其他尝试同时递增整数的操作可以交错,并因此导致损坏。CPU 是如何做到这一点的超出了本书的范围。这是因为在现代的超标量、流水线化的 CPU 中,支持在多个核心和插槽上并行、乱序和推测性地执行指令,这是非常复杂的。

准备工作

在开始之前,请确保满足所有技术要求,包括安装 Ubuntu 18.04 或更高版本,并在终端窗口中运行以下命令:

> sudo apt-get install build-essential git

这将确保您的操作系统具有编译和执行此食谱中示例所需的适当工具。完成后,打开一个新的终端。我们将使用这个终端来下载、编译和运行我们的示例。

如何做...

您需要执行以下步骤来尝试这个食谱:

  1. 从一个新的终端,运行以下命令来下载源代码:
> cd ~/
> git clone https://github.com/PacktPublishing/Advanced-CPP-CookBook.git
> cd Advanced-CPP-CookBook/chapter05
  1. 要编译源代码,请运行以下命令:
> cmake .
> make recipe02_examples
  1. 一旦源代码编译完成,您可以通过运行以下命令来执行这个食谱中的每个示例:
> ./recipe02_example01
count: 711
atomic count: 1000

在下一节中,我们将逐个介绍这些示例,并解释每个示例程序的作用以及它与本食谱中所教授的课程的关系。

工作原理...

在这个食谱中,我们将学习如何使用 C++的原子数据类型。原子数据类型仅限于简单的数据类型,如整数,由于这些数据类型非常复杂,只支持简单的操作,如加法、减法、递增和递减。

让我们看一个简单的例子,不仅演示了如何在 C++中使用原子数据类型,还演示了为什么原子数据类型如此重要:

#include <atomic>
#include <thread>
#include <iostream>

int count{};
std::atomic<int> atomic_count{};

void foo()
{
    do {
        count++;
        atomic_count++;
    }
    while (atomic_count < 99999);
}

int main(void)
{
    std::thread t1{foo};
    std::thread t2{foo};

    t1.join();
    t2.join();

    std::cout << "count: " << count << '\n';
    std::cout << "atomic count: " << atomic_count << '\n';

    return 0;
}

当执行这段代码时,我们得到以下结果:

在上面的示例中,我们有两个整数。第一个整数是普通的 C/C++整数类型,而第二个是原子数据类型(整数类型)。然后,我们定义一个循环,直到原子数据类型为1000为止。最后,我们从两个线程中执行这个函数,这意味着我们的全局整数会被两个线程同时增加。

如您所见,这个简单测试的输出显示,简单的 C/C++整数数据类型与原子数据类型的值不同,但两者都增加了相同次数。这个原因可以在这个函数的汇编中看到(在 Intel CPU 上),如下所示:

要增加一个整数(未启用优化),编译器必须将内存内容移动到寄存器中,将1添加到寄存器中,然后将寄存器的结果写回内存。由于这段代码同时在两个不同的线程中执行,这段代码交错执行,导致损坏。原子数据类型不会遇到这个问题。这是因为增加原子数据类型的过程发生在一个单独的特殊指令中,CPU 确保执行,而不会将其内部状态与其他指令的相同内部状态交错在一起,也不会在其他 CPU 上交错。

原子数据类型通常用于实现同步原语,例如std::mutex(尽管在实践中,std::mutex是使用测试和设置指令实现的,这些指令使用类似的原理,但通常比原子指令执行得更快)。这些数据类型还可以用于实现称为无锁数据结构的特殊数据结构,这些数据结构能够在多线程环境中运行,而无需std::mutex。无锁数据结构的好处是在处理线程同步时没有等待状态,但会增加更复杂的 CPU 硬件和其他类型的性能惩罚(当 CPU 遇到原子指令时,大多数由硬件提供的 CPU 优化必须暂时禁用)。因此,就像计算机科学中的任何东西一样,它们都有其时机和地点。

在多线程的上下文中理解 const & mutable 的含义

在这个示例中,我们将学习如何处理被标记为const的对象,但包含必须使用std::mutex来确保线程同步的对象。这个示例很重要,因为将std::mutex存储为类的私有成员是很有用的,但是,一旦你这样做了,将这个对象的实例作为常量引用(即const &)传递将导致编译错误。在这个示例中,我们将演示为什么会发生这种情况以及如何克服它。

准备工作

在我们开始之前,请确保满足所有的技术要求,包括安装 Ubuntu 18.04 或更高版本,并在终端窗口中运行以下命令:

> sudo apt-get install build-essential git

这将确保您的操作系统具有编译和执行本示例中示例的正确工具。完成后,打开一个新的终端。我们将使用这个终端来下载、编译和运行我们的示例。

如何做...

您需要执行以下步骤来尝试这个示例:

  1. 从一个新的终端中,运行以下命令来下载源代码:
> cd ~/
> git clone https://github.com/PacktPublishing/Advanced-CPP-CookBook.git
> cd Advanced-CPP-CookBook/chapter05
  1. 要编译源代码,请运行以下命令:
> cmake .
> make recipe03_examples
  1. 源代码编译完成后,您可以通过运行以下命令来执行本示例中的每个示例:
> ./recipe03_example01
The answer is: 42

> ./recipe03_example03
The answer is: 42

在下一节中,我们将逐个介绍这些示例,并解释每个示例程序的作用以及它与本示例中所教授的课程的关系。

它是如何工作的...

在本示例中,我们将学习如何将std::mutex添加到类的私有成员中,同时仍然能够处理const情况。一般来说,确保对象是线程安全的有两种方法。第一种方法是将std::mutex放在全局级别。这样做可以确保对象可以作为常量引用传递,或者对象本身可以有一个标记为const的函数。

为此,请考虑以下代码示例:

#include <mutex>
#include <thread>
#include <iostream>

std::mutex m{};

class the_answer
{
public:
    void print() const
    {
        std::lock_guard lock(m);
        std::cout << "The answer is: 42\n";
    }
};

int main(void)
{
    the_answer is;
    is.print();

    return 0;
}

在前面的例子中,当执行print()函数时,我们创建了一个对象,该对象输出到stdoutprint()函数被标记为const,这告诉编译器print()函数不会修改任何类成员(即函数是只读的)。由于std::mutex是全局的,对象的 const 限定符被维持,代码可以编译和执行而没有问题。

全局std::mutex对象的问题在于,对象的每个实例都必须使用相同的std::mutex对象。如果用户打算这样做,那没问题,但如果您希望对象的每个实例都有自己的std::mutex对象(例如,当对象的相同实例可能被多个线程执行时),该怎么办?

为此,让我们看看如何使用以下示例发生的情况:

#include <mutex>
#include <thread>
#include <iostream>

class the_answer
{
    std::mutex m{};

public:
    void print() const
    {
        std::lock_guard lock(m);
        std::cout << "The answer is: 42\n";
    }
};

int main(void)
{
    the_answer is;
    is.print();

    return 0;
}

如果我们尝试编译这个,我们会得到以下结果:

在前面的例子中,我们所做的只是将前面的例子中的std::mutex移动到类内部作为私有成员。结果是,当我们尝试编译类时,我们会得到一个编译器错误。这是因为print()函数被标记为const,这告诉编译器print()函数不会修改类的任何成员。问题在于,当您尝试锁定std::mutex时,您必须对其进行修改,从而导致编译器错误。

为了克服这个问题,我们必须告诉编译器忽略这个错误,方法是将std::mutex标记为 mutable。将成员标记为 mutable 告诉编译器允许修改该成员,即使对象被作为常量引用传递或对象定义了常量函数。

例如,这是const标记为mutable的代码示例:

#include <mutex>
#include <thread>
#include <iostream>

class the_answer
{
    mutable std::mutex m{};

public:
    void print() const
    {
        std::lock_guard lock(m);
        std::cout << "The answer is: 42\n";
    }
};

int main(void)
{
    the_answer is;
    is.print();

    return 0;
}

如前面的例子所示,一旦我们将std::mutex标记为 mutable,代码就会像我们期望的那样编译和执行。值得注意的是,std::mutex是少数几个可以接受 mutable 使用的例子之一。mutable 关键字很容易被滥用,导致代码无法编译或操作不符合预期。

使类线程安全

在本示例中,我们将学习如何使一个类线程安全(即如何确保一个类的公共成员函数可以随时被任意数量的线程同时调用)。大多数类,特别是由 C++标准库提供的类,都不是线程安全的,而是假设用户会根据需要添加线程同步原语,如std::mutex对象。这种方法的问题在于,每个对象都有两个实例,必须在代码中进行跟踪:类本身和它的std::mutex。用户还必须用自定义版本包装对象的每个函数,以使用std::mutex保护类,结果不仅有两个必须管理的对象,还有一堆 C 风格的包装函数。

这个示例很重要,因为它将演示如何通过创建一个线程安全的类来解决代码中的这些问题,将所有内容合并到一个单一的类中。

准备工作

在开始之前,请确保满足所有技术要求,包括安装 Ubuntu 18.04 或更高版本,并在终端窗口中运行以下命令:

> sudo apt-get install build-essential git

这将确保您的操作系统具有编译和执行本示例的正确工具。完成后,打开一个新的终端。我们将使用这个终端来下载、编译和运行我们的示例。

如何做...

您需要执行以下步骤来尝试这个教程:

  1. 从新的终端中运行以下命令来下载源代码:
> cd ~/
> git clone https://github.com/PacktPublishing/Advanced-CPP-CookBook.git
> cd Advanced-CPP-CookBook/chapter05
  1. 要编译源代码,请运行以下命令:
> cmake .
> make recipe04_examples
  1. 源代码编译完成后,您可以通过运行以下命令来执行本教程中的每个示例:
> ./recipe04_example01

在接下来的部分中,我们将逐个介绍这些示例,并解释每个示例程序的作用,以及它们与本教程中所教授的课程的关系。

它是如何工作的...

在本教程中,我们将学习如何通过实现自己的线程安全栈来制作一个线程安全的类。C++标准库不提供线程安全的数据结构,因此,如果您希望在多个线程中使用数据结构作为全局资源,您需要手动添加线程安全性。这可以通过实现包装函数或创建包装类来实现。

创建包装函数的优势在于,对于全局对象,通常所需的代码量更少,更容易理解,而线程安全类的优势在于,您可以创建类的多个实例,因为std::mutex是自包含的。

可以尝试以下代码示例:

#include <mutex>
#include <stack>
#include <iostream>

template<typename T>
class my_stack
{
    std::stack<T> m_stack;
    mutable std::mutex m{};

public:

    template<typename ARG>
    void push(ARG &&arg)
    {
        std::lock_guard lock(m);
        m_stack.push(std::forward<ARG>(arg));
    }

 void pop()
    {
        std::lock_guard lock(m);
        m_stack.pop();
    }

    auto empty() const
    {
        std::lock_guard lock(m);
        return m_stack.empty();
    }
};

在前面的示例中,我们实现了自己的栈。这个栈有std::stackstd::mutex作为成员变量。然后,我们重新实现了std::stack提供的一些函数。这些函数中的每一个首先尝试获取std::mutex,然后调用std::stack中的相关函数。在push()函数的情况下,我们利用std::forward来确保传递给push()函数的参数被保留。

最后,我们可以像使用std::stack一样使用我们的自定义栈。例如,看一下以下代码:

int main(void)
{
    my_stack<int> s;

    s.push(4);
    s.push(8);
    s.push(15);
    s.push(16);
    s.push(23);
    s.push(42);

    while(s.empty()) {
        s.pop();
    }

    return 0;
}

正如您所看到的,std::stack和我们的自定义栈之间唯一的区别是我们的栈是线程安全的。

同步包装器及其实现方式

在本教程中,我们将学习如何制作线程安全的同步包装器。默认情况下,C++标准库不是线程安全的,因为并非所有应用程序都需要这种功能。确保 C++标准库是线程安全的一种机制是创建一个线程安全类,它将您希望使用的数据结构以及std::mutex作为私有成员添加到类中,然后重新实现数据结构的函数以首先获取std::mutex,然后转发函数调用到数据结构。这种方法的问题在于,如果数据结构是全局资源,程序中会添加大量额外的代码,使得最终的代码难以阅读和维护。

这个教程很重要,因为它将演示如何通过制作线程安全的同步包装器来解决代码中的这些问题。

准备工作

在开始之前,请确保满足所有技术要求,包括安装 Ubuntu 18.04 或更高版本,并在终端窗口中运行以下命令:

> sudo apt-get install build-essential git

这将确保您的操作系统具有正确的工具来编译和执行本教程中的示例。完成后,打开一个新的终端。我们将使用这个终端来下载、编译和运行我们的示例。

如何操作...

您需要执行以下步骤来尝试这个教程:

  1. 从新的终端中运行以下命令来下载源代码:
> cd ~/
> git clone https://github.com/PacktPublishing/Advanced-CPP-CookBook.git
> cd Advanced-CPP-CookBook/chapter05
  1. 要编译源代码,请运行以下命令:
> cmake .
> make recipe05_examples
  1. 源代码编译完成后,您可以通过运行以下命令来执行本教程中的每个示例:
> ./recipe05_example01

在接下来的部分中,我们将逐个介绍这些示例,并解释每个示例程序的作用,以及它们与本教程中所教授的课程的关系。

它是如何工作的...

在本教程中,我们将学习如何创建线程安全的同步包装器,这允许我们向 C++标准库数据结构添加线程安全性,而默认情况下这些数据结构是不安全的。

为此,我们将为 C++标准库中的每个函数创建包装函数。这些包装函数将首先尝试获取std::mutex,然后将相同的函数调用转发到 C++标准库数据结构。

为此,请考虑以下代码示例:

#include <mutex>
#include <stack>
#include <iostream>

std::mutex m{};

template<typename S, typename T>
void push(S &s, T &&t)
{
    std::lock_guard lock(m);
    s.push(std::forward<T>(t));
}

template<typename S>
void pop(S &s)
{
    std::lock_guard lock(m);
    s.pop();
}

template<typename S>
auto empty(S &s)
{
    std::lock_guard lock(m);
    return s.empty();
}

在前面的例子中,我们为push()pop()empty()函数创建了一个包装函数。这些函数在调用数据结构之前会尝试获取我们的全局std::mutex对象,这里是一个模板。使用模板创建了一个概念。我们的包装函数可以被实现了push()pop()empty()的任何数据结构使用。另外,请注意我们在push()函数中使用std::forward来确保被推送的参数的 l-valueness 和 CV 限定符保持不变。

最后,我们可以像使用数据结构的函数一样使用我们的包装器,唯一的区别是数据结构作为第一个参数传递。例如,看一下以下代码块:

int main(void)
{
    std::stack<int> mystack;

    push(mystack, 4);
    push(mystack, 8);
    push(mystack, 15);
    push(mystack, 16);
    push(mystack, 23);
    push(mystack, 42);

    while(empty(mystack)) {
        pop(mystack);
    }

    return 0;
}

正如前面的例子中所示,使用我们的同步包装器是简单的,同时确保我们创建的堆栈现在是线程安全的。

阻塞操作与异步编程

在本示例中,我们将学习阻塞操作和异步操作之间的区别。这个示例很重要,因为阻塞操作会使每个操作在单个 CPU 上串行执行。如果每个操作的执行必须按顺序执行,这通常是可以接受的;然而,如果这些操作可以并行执行,异步编程可以是一个有用的优化,确保在一个操作等待时,其他操作仍然可以在同一个 CPU 上执行。

准备工作

在开始之前,请确保满足所有技术要求,包括安装 Ubuntu 18.04 或更高版本,并在终端窗口中运行以下命令:

> sudo apt-get install build-essential git

这将确保您的操作系统具有编译和执行本示例中的示例的适当工具。完成后,打开一个新的终端。我们将使用这个终端来下载、编译和运行我们的示例。

如何做...

您需要执行以下步骤来尝试这个示例:

  1. 从一个新的终端,运行以下命令来下载源代码:
> cd ~/
> git clone https://github.com/PacktPublishing/Advanced-CPP-CookBook.git
> cd Advanced-CPP-CookBook/chapter05
  1. 要编译源代码,请运行以下命令:
> cmake .
> make recipe06_examples
  1. 源代码编译后,您可以通过运行以下命令执行本示例中的每个示例:
> time ./recipe06_example01
999999
999999
999999
999999

real 0m1.477s
...

> time ./recipe06_example02
999999
999999
999999
999999

real 0m1.058s
...

> time ./recipe06_example03
999999
999999
999998
999999

real 0m1.140s
...

在下一节中,我们将逐个介绍这些示例,并解释每个示例程序的作用以及它与本示例中所教授的课程的关系。

工作原理...

阻塞操作是指必须在下一个操作发生之前完成的操作。大多数程序是按顺序编写的,这意味着每个指令必须在下一个指令之前执行。然而,问题在于有些操作可以并行执行(即同时或异步执行)。串行化这些操作在最好的情况下可能会导致性能不佳,并且在某些情况下实际上可能会导致死锁(程序进入无休止的等待状态),如果阻塞的操作正在等待另一个从未有机会执行的操作。

为了演示一个阻塞操作,让我们来看一下以下内容:

#include <vector>
#include <iostream>
#include <algorithm>

constexpr auto size = 1000000;

int main(void)
{
    std::vector<int> numbers1(size);
    std::vector<int> numbers2(size);
    std::vector<int> numbers3(size);
    std::vector<int> numbers4(size);

前面的代码创建了一个主函数,其中有四个int类型的std::vector对象。在接下来的步骤中,我们将使用这些向量来演示一个阻塞操作。

  1. 首先,我们创建四个可以存储整数的向量:
    std::generate(numbers1.begin(), numbers1.end(), []() {
      return rand() % size;
    });
    std::generate(numbers2.begin(), numbers2.end(), []() {
      return rand() % size;
    });
    std::generate(numbers3.begin(), numbers3.end(), []() {
      return rand() % size;
    });
    std::generate(numbers4.begin(), numbers4.end(), []() {
      return rand() % size;
    });
  1. 接下来,我们使用std::generate用随机数填充每个数组,结果是一个带有数字和随机顺序的数组:
    std::sort(numbers1.begin(), numbers1.end());
    std::sort(numbers2.begin(), numbers2.end());
    std::sort(numbers3.begin(), numbers3.end());
    std::sort(numbers4.begin(), numbers4.end());
  1. 接下来,我们对整数数组进行排序,这是本例的主要目标,因为这个操作需要一段时间来执行:
    std::cout << numbers1.back() << '\n';
    std::cout << numbers2.back() << '\n';
    std::cout << numbers3.back() << '\n';
    std::cout << numbers4.back() << '\n';

    return 0;
}
  1. 最后,我们输出每个数组中的最后一个条目,通常会是999999(但不一定,因为数字是使用随机数生成器生成的)。

前面示例的问题在于操作可以并行执行,因为每个数组是独立的。为了解决这个问题,我们可以异步执行这些操作,这意味着数组将并行创建、填充、排序和输出。例如,考虑以下代码:

#include <future>
#include <thread>
#include <vector>
#include <iostream>
#include <algorithm>

constexpr auto size = 1000000;

int foo()
{
    std::vector<int> numbers(size);
    std::generate(numbers.begin(), numbers.end(), []() {
      return rand() % size;
    });

    std::sort(numbers.begin(), numbers.end());
    return numbers.back();
}

我们首先要做的是实现一个名为foo()的函数,该函数创建我们的向量,用随机数填充它,对列表进行排序,并返回数组中的最后一个条目(与前面的示例相同,唯一的区别是我们一次只处理一个数组,而不是4个):

int main(void)
{
    auto a1 = std::async(std::launch::async, foo);
    auto a2 = std::async(std::launch::async, foo);
    auto a3 = std::async(std::launch::async, foo);
    auto a4 = std::async(std::launch::async, foo);

    std::cout << a1.get() << '\n';
    std::cout << a2.get() << '\n';
    std::cout << a3.get() << '\n';
    std::cout << a4.get() << '\n';

    return 0;
}

然后,我们使用std::async四次执行这个foo()函数,得到与前面示例相同的四个数组。在这个示例中,std::async()函数做的事情与手动执行四个线程相同。std::aync()的结果是一个std::future对象,它在函数执行完成后存储函数的结果。在这个示例中,我们做的最后一件事是使用get()函数在函数准备好后返回函数的值。

如果我们计时这些函数的结果,我们会发现异步版本比阻塞版本更快。以下代码显示了这一点(real时间是查找时间):

std::async()函数也可以用来在同一个线程中异步执行我们的数组函数。例如,考虑以下代码:

int main(void)
{
    auto a1 = std::async(std::launch::deferred, foo);
    auto a2 = std::async(std::launch::deferred, foo);
    auto a3 = std::async(std::launch::deferred, foo);
    auto a4 = std::async(std::launch::deferred, foo);

    std::cout << a1.get() << '\n';
    std::cout << a2.get() << '\n';
    std::cout << a3.get() << '\n';
    std::cout << a4.get() << '\n';

    return 0;
}

如前面的示例所示,我们将操作从std::launch::async更改为std::launch::deferred,这将导致每个函数在需要函数结果时执行一次(即调用get()函数时)。如果不确定函数是否需要执行(即仅在需要时执行函数),这将非常有用,但缺点是程序的执行速度较慢,因为线程通常不用作优化方法。

使用承诺和未来

在本配方中,我们将学习如何使用 C++承诺和未来。C++ promise是 C++线程的参数,而 C++ future是线程的返回值,并且可以用于手动实现std::async调用的相同功能。这个配方很重要,因为对std::aync的调用要求每个线程停止执行以获取其结果,而手动实现 C++ promisefuture允许用户在线程仍在执行时获取线程的返回值。

准备工作

在开始之前,请确保满足所有技术要求,包括安装 Ubuntu 18.04 或更高版本,并在终端窗口中运行以下命令:

> sudo apt-get install build-essential git

这将确保您的操作系统具有编译和执行本配方中示例所需的适当工具。完成后,打开一个新的终端。我们将使用此终端来下载、编译和运行示例。

如何做...

您需要执行以下步骤来尝试这个配方:

  1. 从新的终端运行以下命令以下载源代码:
> cd ~/
> git clone https://github.com/PacktPublishing/Advanced-CPP-CookBook.git
> cd Advanced-CPP-CookBook/chapter05
  1. 要编译源代码,请运行以下命令:
> cmake .
> make recipe07_examples
  1. 编译源代码后,可以通过运行以下命令来执行本配方中的每个示例:
> ./recipe07_example01
The answer is: 42

> ./recipe07_example02
The answer is: 42

在下一节中,我们将逐个介绍每个示例,并解释每个示例程序的作用及其与本配方中所教授的课程的关系。

它是如何工作的...

在本配方中,我们将学习如何手动使用 C++ promisefuture来提供一个并行执行带有参数的函数,并获取函数的返回值。首先,让我们演示如何以最简单的形式完成这个操作,使用以下代码:

#include <thread>
#include <iostream>
#include <future>

void foo(std::promise<int> promise)
{
    promise.set_value(42);
}

int main(void)
{
    std::promise<int> promise;
    auto future = promise.get_future();

    std::thread t{foo, std::move(promise)};
    t.join();

    std::cout << "The answer is: " << future.get() << '\n';

    return 0;
}

执行前面的示例会产生以下结果:

正如您在上面的代码中所看到的,C++的promise是作为函数的参数进行线程化的。线程通过设置promise参数来返回其值,而promise又设置了一个 C++的future,用户可以从提供给线程的promise参数中获取。需要注意的是,我们使用std::move()来防止promise参数被复制(编译器会禁止,因为 C++的promise是一个只能移动的类)。最后,我们使用get()函数来获取线程的结果,就像使用std::async执行线程的结果一样。

手动使用promisefuture的一个好处是,可以在线程完成之前获取线程的结果,从而允许线程继续工作。例如,看下面的例子:

#include <thread>
#include <iostream>
#include <future>

void foo(std::promise<int> promise)
{
    promise.set_value(42);
    while (true);
}

int main(void)
{
    std::promise<int> promise;
    auto future = promise.get_future();

    std::thread t{foo, std::move(promise)};

    future.wait();
    std::cout << "The answer is: " << future.get() << '\n';

    t.join();

    // Never reached
    return 0;
}

执行时会得到以下结果:

在上面的例子中,我们创建了相同的线程,但在线程中无限循环,意味着线程永远不会返回。然后我们以相同的方式创建线程,但在 C++的future准备好时立即输出结果,我们可以使用wait()函数来确定。

第六章:为性能优化您的代码

优化代码以提高性能可以确保您的代码充分利用了 C++所能提供的功能。与其他高级语言不同,C++能够提供高级的语法自由,而不会牺牲性能,尽管诚然会增加学习曲线的成本。

本章很重要,因为它将演示更高级的优化代码方法,包括如何在单元级别对软件进行基准测试,如何检查编译器为潜在优化而生成的结果汇编代码,如何减少应用程序使用的内存资源数量,以及为什么编译器提示(如noexcept)很重要。阅读完本章后,您将具备编写更高效 C++代码的技能。

在本章中,我们将涵盖以下配方:

  • 对代码进行基准测试

  • 查看汇编代码

  • 减少内存分配的数量

  • 声明 noexcept

技术要求

要编译和运行本章中的示例,您必须具有管理访问权限,可以访问运行 Ubuntu 18.04 的计算机,并具有功能正常的互联网连接。在运行这些示例之前,您必须安装以下内容:

> sudo apt-get install build-essential git cmake valgrind

如果这是安装在 Ubuntu 18.04 以外的任何操作系统上,则需要 GCC 7.4 或更高版本和 CMake 3.6 或更高版本。

对代码进行基准测试

在本配方中,您将学习如何对源代码进行基准测试和优化。优化源代码将导致更高效的 C++,从而增加电池寿命,提高性能等。这个配方很重要,因为优化源代码的过程始于确定您计划优化的资源,这可能包括速度、内存甚至功耗。没有基准测试工具,要比较解决同一个问题的不同方法是非常困难的。

对于 C++程序员来说,有无数的基准测试工具(任何测量程序的单个属性的工具),包括 Boost、Folly 和 Abseil 等 C++ API,以及诸如 Intel 的 vTune 之类的特定于 CPU 的工具。还有一些性能分析工具(任何帮助您了解程序行为的工具),如 valgrind 和 gprof。在本配方中,我们将重点关注其中的两个:Hayai 和 Valgrind。Hayai 提供了一个微基准测试的简单示例,而 Valgrind 提供了一个更完整、但更复杂的动态分析/性能分析工具的示例。

准备工作

在开始之前,请确保已满足所有技术要求,包括安装 Ubuntu 18.04 或更高版本,并在终端窗口中运行以下命令:

> sudo apt-get install build-essential git valgrind cmake

这将确保您的操作系统具有适当的工具来编译和执行本配方中的示例。完成此操作后,打开一个新的终端。我们将使用这个终端来下载、编译和运行我们的示例。

操作步骤

执行以下步骤完成这个配方:

  1. 从新的终端中运行以下命令以下载源代码:
> cd ~/
> git clone https://github.com/PacktPublishing/Advanced-CPP-CookBook.git
> cd Advanced-CPP-CookBook/chapter06
  1. 要编译源代码,请运行以下命令:
> cmake -DCMAKE_BUILD_TYPE=Debug .
> make recipe01_examples
  1. 一旦源代码被编译,您可以通过运行以下命令来执行本配方中的每个示例:
> ./recipe01_example01
[==========] Running 2 benchmarks.
[ RUN ] vector.push_back (10 runs, 100 iterations per run)
[ DONE ] vector.push_back (0.200741 ms)
...
[ RUN ] vector.emplace_back (10 runs, 100 iterations per run)
[ DONE ] vector.emplace_back (0.166699 ms)
...

> ./recipe01_example02

在下一节中,我们将逐个介绍这些示例,并解释每个示例程序的作用以及它与本配方中所教授的课程的关系。

工作原理

应用于 C++的最常见优化是执行速度。为了优化 C++的速度,我们必须首先开发不同的方法来解决同一个问题,然后对每个解决方案进行基准测试,以确定哪个解决方案执行速度最快。基准测试工具,如 GitHub 上的基于 C++的基准测试库 Hayai,有助于做出这一决定。为了解释这一点,让我们看一个简单的例子:

#include <string>
#include <vector>
#include <hayai.hpp>

std::vector<std::string> data;

BENCHMARK(vector, push_back, 10, 100)
{
    data.push_back("The answer is: 42");
}

BENCHMARK(vector, emplace_back, 10, 100)
{
    data.emplace_back("The answer is: 42");
}

当我们执行上述代码时,我们会得到以下输出:

在前面的示例中,我们使用 Hayai 库来基准测试使用push_back()emplace_back()向向量添加字符串之间的性能差异。push_back()emplace_back()之间的区别在于,push_back()创建对象,然后将其复制或移动到向量中,而emplace_back()在向量中创建对象本身,而无需临时对象和随后的复制/移动。也就是说,如果使用push_back(),必须构造对象,然后将其复制或移动到向量中。如果使用emplace_back(),则只需构造对象。如预期的那样,emplace_back()优于push_back(),这就是为什么诸如 Clang-Tidy 之类的工具建议尽可能使用emplace_back()而不是push_back()

基准库,如 Hayai,使用简单,对帮助程序员优化源代码非常有效,并且不仅能够对速度进行基准测试,还能够对资源使用进行基准测试。这些库的问题在于它们更适合在单元级别而不是集成系统级别进行利用;也就是说,要测试整个可执行文件,这些库不适合帮助程序员,因为随着测试规模的增加,它们的扩展性不佳。为了分析整个可执行文件而不是单个函数,存在诸如 Valgrind 之类的工具,它可以帮助您分析哪些函数在优化方面需要最多的关注。然后,可以使用基准测试工具来分析需要最多关注的函数。

Valgrind 是一种动态分析工具,能够检测内存泄漏并跟踪程序的执行。为了看到这一点,让我们看下面的示例:

volatile int data = 0;

void foo()
{
 data++;
}

int main(void)
{
 for (auto i = 0; i < 100000; i++) {
 foo();
 }
}

在前面的示例中,我们从名为foo()的函数中递增一个全局变量(标记为 volatile 以确保编译器不会优化掉该变量),然后执行这个函数100,000次。要分析这个示例,请运行以下命令(使用callgrind输出程序中每个函数被调用的次数):

> valgrind --tool=callgrind ./recipe01_example02
> callgrind_annotate callgrind.out.*

这导致以下输出:

正如我们所看到的,foo()函数在前面的输出中位于最前面(动态链接器的_dl_lookup_symbol_x()函数被调用最多,用于在执行之前链接程序)。值得注意的是,程序列表(在左侧)中foo()函数的指令总数为800,000。这是因为foo()函数有8条汇编指令,并且被执行了100,000次。例如,让我们使用objdump(一种能够输出可执行文件编译汇编的工具)来查看foo()函数的汇编,如下所示:

使用 Valgrind,可以对可执行文件进行分析,以确定哪些函数执行时间最长。例如,让我们看看ls

> valgrind --tool=callgrind ls
> callgrind_annotate callgrind.out.*

这导致以下输出:

正如我们所看到的,strcmp函数被频繁调用。这些信息可以与单元级别的基准测试 API 相结合,以确定是否可以编写更快的strcmp版本(例如,使用手写汇编和特殊的 CPU 指令)。使用诸如 Hayai 和 Valgrind 之类的工具,可以分离出程序中消耗最多 CPU、内存甚至电源的函数,并重写它们以提供更好的性能,同时将精力集中在将提供最佳投资回报的优化上。

查看汇编代码

在本教程中,我们将从两种不同的优化中查看生成的汇编:循环展开和传引用参数。这个教程很重要,因为它将教会你如何深入了解编译器是如何将 C++转换为可执行代码的。这些信息将揭示为什么 C++规范(如 C++核心指南)对于优化和性能做出了推荐。当你试图编写更好的 C++代码时,尤其是当你想要优化它时,这通常是至关重要的。

准备工作

在开始之前,请确保已满足所有技术要求,包括安装 Ubuntu 18.04 或更高版本,并在终端窗口中运行以下命令:

> sudo apt-get install build-essential git cmake

这将确保您的操作系统具有正确的工具来编译和执行本教程中的示例。完成这些操作后,打开一个新的终端。我们将使用这个终端来下载、编译和运行我们的示例。

如何做到这一点...

执行以下步骤来完成本教程:

  1. 从新的终端中,运行以下命令来下载源代码:
> cd ~/
> git clone https://github.com/PacktPublishing/Advanced-CPP-CookBook.git
> cd Advanced-CPP-CookBook/chapter06
  1. 要编译源代码,请运行以下命令:
> cmake -DCMAKE_BUILD_TYPE=Debug .
> make recipe02_examples
  1. 一旦源代码被编译,您可以通过运行以下命令来执行本教程中的每个示例:
> ./recipe02_example01

> ./recipe02_example02

> ./recipe02_example03

> ./recipe02_example04

> ./recipe02_example05

在下一节中,我们将逐个介绍这些例子,并解释每个例子程序的作用以及它与本教程中所教授的课程的关系。

它是如何工作的...

学习如何优化 C++代码的最佳方法之一是学习如何分析编译器在编译后生成的汇编代码。在本教程中,我们将学习如何通过查看两个不同的例子来进行这种分析:循环展开和传引用参数。

在我们查看这些例子之前,让我们先看一个简单的例子:

int main(void)
{ }

在上面的例子中,我们只有一个main()函数。我们没有包含任何 C 或 C++库,main()函数本身是空的。如果我们编译这个例子,我们会发现生成的二进制文件仍然非常大:

在这种情况下,这个例子的大小是22kb。为了显示编译器为这段代码生成的汇编代码,我们可以这样做:

> objdump -d recipe02_example01

前面命令的输出结果应该令人惊讶,因为对于一个完全没有任何功能的应用程序来说,代码量很大。

为了更好地了解有多少代码,我们可以通过使用grep来细化输出,这是一个让我们从任何命令中过滤文本的工具。让我们看看代码中的所有函数:

正如我们所看到的,编译器会自动为您添加几个函数。这包括_init()_fini()_start()函数。我们还可以查看特定的函数,比如我们的主函数,如下所示:

在上面的例子中,我们搜索objdump的输出,查找main>:RETQ。所有函数名都以>:结尾,而每个函数的最后一条指令(通常)是在 Intel 64 位系统上的RETQ

以下是生成的汇编:

  401106: push %rbp
  401107: mov %rsp,%rbp

首先,它将当前的堆栈帧指针(rbp)存储到堆栈中,并将堆栈帧指针加载到main()函数的堆栈的当前地址(rsp)。

这可以在每个函数中看到,并称为函数的前言。main()执行的唯一代码是return 0,这是编译器自动添加的代码:

  40110a: mov $0x0,%eax

最后,这个函数中的最后一个汇编包含了函数的结尾,它恢复了堆栈帧指针并返回:


  40110f: pop %rbp
  401110: retq

现在我们对如何获取和阅读编译后的 C++程序的汇编结果有了更好的理解,让我们来看一个循环展开的例子,循环展开是用其等效的指令版本替换循环指令的过程。为了做到这一点,确保示例是在发布模式下编译的(也就是启用了编译器优化),通过以下命令进行配置:

> cmake -DCMAKE_BUILD_TYPE=Release .
> make

为了理解循环展开,让我们看一下以下代码:

volatile int data[1000];

int main(void)
{
    for (auto i = 0U; i < 1000; i++) {
        data[i] = 42;
    }
}

当编译器遇到循环时,生成的汇编代码包含以下代码:

让我们来分解一下:

  401020: xor %eax,%eax
  401022: nopw 0x0(%rax,%rax,1)

前两条指令属于代码的for (auto i = 0U;部分。在这种情况下,i变量存储在EAX寄存器中,并使用XOR指令将其设置为0(在 Intel 上,XOR指令比MOV指令更快地将寄存器设置为 0)。NOPW指令可以安全地忽略。

接下来的几条指令是交错的,如下所示:

  401028: mov %eax,%edx
  40102a: add $0x1,%eax
  40102d: movl $0x2a,0x404040(,%rdx,4)

这些指令代表了i++;data[i] = 42;的代码。第一条指令存储了i变量的当前值,然后将其加一,然后再将42存储到由i索引的内存地址中。方便的是,这个汇编结果展示了一个优化的可能机会,因为编译器可以使用以下方式实现相同的功能:

 movl $0x2a,0x404040(,%rax,4)
 add $0x1,%eax

前面的代码在执行i++之前存储了值42,因此不再需要以下内容:

  mov %eax,%edx

存在多种方法来实现这种潜在的优化,包括使用不同的编译器或手写汇编。下一组指令执行我们for循环的i < 1000;部分:

  401038: cmp $0x3e8,%eax
  40103d: jne 401028 <main+0x8>

CMP指令检查i变量是否为1000,如果不是,则使用JNE指令跳转到函数顶部继续循环。否则,剩下的代码执行:

  40103f: xor %eax,%eax
  401041: retq 

为了了解循环展开的工作原理,让我们将循环的迭代次数从1000改为4,如下所示:

volatile int data[4];

int main(void)
{
    for (auto i = 0U; i < 4; i++) {
        data[i] = 42;
    }
}

我们可以看到,除了循环迭代次数之外,代码是相同的。汇编结果如下:

我们可以看到,CMPJNE指令都不见了。现在,以下代码被编译了(但还有更多!):

    for (auto i = 0U; i < 4; i++) {
        data[i] = 42;
    }

编译后的代码转换为以下代码:

        data[0] = 42;
        data[1] = 42;
        data[2] = 42;
        data[3] = 42;

return 0;出现在赋值之间的汇编中。这是允许的,因为函数的返回值与赋值无关(因为赋值指令从不触及RAX),这为 CPU 提供了额外的优化(因为它可以并行执行return 0;,尽管这是本书范围之外的话题)。值得注意的是,循环展开并不要求使用少量的循环迭代。一些编译器会部分展开循环以实现优化(例如,以4个为一组而不是一次执行1次循环)。

我们的最后一个例子将研究按引用传递而不是按值传递。首先,在调试模式下重新编译代码:

> cmake -DCMAKE_BUILD_TYPE=Debug .
> make

让我们看一下以下例子:

struct mydata {
    int data[100];
};

void foo(mydata d)
{
    (void) d;
}

int main(void)
{
    mydata d;
    foo(d);
}

在这个例子中,我们创建了一个大型结构体,并按值传递给了我们主函数中名为foo()的函数。主函数的汇编结果如下:

前面示例中的重要指令如下:

  401137: rep movsq %ds:(%rsi),%es:(%rdi)
  40113a: callq 401106 <_Z3foo6mydata>

前面的指令将大型结构体复制到堆栈上,然后调用我们的foo()函数。复制是因为结构体是按值传递的,这意味着编译器必须执行复制。顺便说一句,如果您想以可读的格式而不是混淆的格式看到输出,可以在选项中添加C,如下所示:

最后,让我们按引用传递来看看结果的改善:

struct mydata {
    int data[100];
};

void foo(mydata &d)
{
    (void) d;
}

int main(void)
{
    mydata d;
    foo(d);
}

如我们所见,我们通过引用传递结构而不是按值传递。生成的汇编代码如下:

在这里,代码要少得多,导致可执行文件更快。正如我们所学到的,如果我们希望了解编译器生成了什么,检查编译器生成的内容是有效的,因为这提供了有关您可以进行的潜在更改的更多信息,以编写更有效的 C++代码。

减少内存分配的数量

C++在应用程序运行时会一直产生隐藏的内存分配。本教程将教你如何确定 C++何时分配内存以及如何在可能的情况下删除这些分配。了解如何删除内存分配很重要,因为new()delete()malloc()free()等函数不仅速度慢,而且它们提供的内存也是有限的。删除不需要的分配不仅可以提高应用程序的整体性能,还有助于减少其整体内存需求。

准备工作

开始之前,请确保已满足所有技术要求,包括安装 Ubuntu 18.04 或更高版本,并在终端窗口中运行以下命令:

> sudo apt-get install build-essential git valgrind cmake

这将确保您的操作系统具有适当的工具来编译和执行本教程中的示例。完成后,打开一个新的终端。我们将使用此终端来下载、编译和运行示例。

如何做...

执行以下步骤以完成本教程:

  1. 从新的终端运行以下命令以下载源代码:
> cd ~/
> git clone https://github.com/PacktPublishing/Advanced-CPP-CookBook.git
> cd Advanced-CPP-CookBook/chapter06
  1. 要编译源代码,请运行以下命令:
> cmake .
> make recipe03_examples
  1. 源代码编译完成后,您可以通过运行以下命令来执行本教程中的每个示例:
> ./recipe03_example01

> ./recipe03_example02

> ./recipe03_example03

> ./recipe03_example04

> ./recipe03_example05

> ./recipe03_example06

> ./recipe03_example07

在下一节中,我们将逐个步骤地介绍每个示例,并解释每个示例程序的作用以及它与本教程中所教授的课程的关系。

它是如何工作的...

在本教程中,我们将学习如何监视应用程序消耗的内存量,以及 C++在幕后分配内存的不同方式。首先,让我们看一个什么都不做的简单应用程序:

int main(void)
{
}

如我们所见,这个应用程序什么也没做。要查看应用程序使用了多少内存,我们将使用动态分析工具 Valgrind,如下所示:

如前面的示例所示,我们的应用程序已经分配了堆内存(即使用new()/delete()malloc()/free()分配的内存)。要确定此分配发生的位置,让我们再次使用 Valgrind,但这次我们将启用一个名为Massif的工具,它将跟踪内存分配的来源:

要查看上述示例的输出,我们必须输出一个为我们自动创建的文件:

> cat massif.out.*

这导致我们检索到以下输出:

如我们所见,动态链接器的init()函数正在执行分配,大小为72,704字节。为了进一步演示如何使用 Valgrind,让我们看一个简单的例子,其中我们执行自己的分配:

int main(void)
{
    auto ptr = new int;
    delete ptr;
}

要查看上述源代码的内存分配,我们需要再次运行 Valgrind:

如我们所见,我们已经分配了72,708字节。由于我们知道应用程序将自动为我们分配72,704字节,我们可以看到 Valgrind 成功检测到我们分配的4字节(在运行 Linux 的 Intel 64 位系统上是整数的大小)。要查看此分配发生的位置,让我们再次使用 Massif:

正如我们所看到的,我们在命令行选项中添加了--threshold=0.1,这告诉 Valgrind 任何占.1%分配的分配都应该被记录。让我们cat一下结果(cat程序只是将文件的内容回显到控制台):

> cat massif.out.*

通过这样做,我们得到以下输出:

正如我们所看到的,Valgrind 检测到了init()函数和我们的main()函数的内存分配。

现在我们知道如何分析应用程序所做的内存分配,让我们看一些不同的 C++ API,看看它们在幕后做了什么类型的内存分配。首先,让我们看一个std::vector,如下所示:

#include <vector>
std::vector<int> data;

int main(void)
{
    for (auto i = 0; i < 10000; i++) {
        data.push_back(i);
    }
}

在这里,我们创建了一个整数的全局向量,然后向向量添加了10,000个整数。使用 Valgrind,我们得到以下输出:

在这里,我们可以看到有16次分配,总共203,772字节。我们知道应用程序将为我们分配72,704字节,所以我们必须从总数中去掉这部分,留下131,068字节的内存。我们还知道我们分配了10,000个整数,总共40,000字节。所以,问题是,其他91,068字节来自哪里?

答案在于std::vector在幕后的工作方式。std::vector必须始终确保内存的连续视图,这意味着当插入发生并且std::vector空间不足时,它必须分配一个新的更大的缓冲区,然后将旧缓冲区的内容复制到新缓冲区。问题在于std::vector不知道在所有插入完成时缓冲区的总大小,因此当执行第一次插入时,它创建一个小缓冲区以确保不浪费内存,然后以小增量增加std::vector的大小,导致多次内存分配和内存复制。

为了防止发生这种分配,C++提供了reserve()函数,该函数允许std::vector的用户估计他们认为他们将需要多少内存。例如,考虑以下代码:

#include <vector>
std::vector<int> data;

int main(void)
{
    data.reserve(10000);  // <--- added optimization 

    for (auto i = 0; i < 10000; i++) {
        data.push_back(i);
    }
}

在前面的例子中,代码与之前的例子相同,唯一的区别是我们添加了对reserve()函数的调用,该函数告诉std::vector我们认为向量将有多大。Valgrind 的输出如下:

正如我们所看到的,应用程序分配了112,704字节。如果我们去掉应用程序默认创建的72,704字节,我们剩下40,000字节,这正是我们预期的大小(因为我们向向量添加了10,000个整数,每个整数的大小为4字节)。

数据结构不是 C++标准库 API 的唯一一种执行隐藏分配的类型。让我们看一个std::any,如下所示:

#include <any>
#include <string>

std::any data;

int main(void)
{
    data = 42;
    data = std::string{"The answer is: 42"};
}

在这个例子中,我们创建了一个std::any,并将其分配给一个整数和一个std::string。让我们看一下 Valgrind 的输出:

正如我们所看到的,发生了3次分配。第一次分配是默认发生的,而第二次分配是由std::string产生的。最后一次分配是由std::any产生的。这是因为std::any必须调整其内部存储以适应它看到的任何新的随机数据类型。换句话说,为了处理通用数据类型,C++必须执行分配。如果我们不断改变数据类型,情况会变得更糟。例如,考虑以下代码:

#include <any>
#include <string>

std::any data;

int main(void)
{
    data = 42;
    data = std::string{"The answer is: 42"};
    data = 42;                                 // <--- keep swapping
    data = std::string{"The answer is: 42"};   // <--- keep swapping
    data = 42;                                 // <--- keep swapping
    data = std::string{"The answer is: 42"};   // ...
    data = 42;
    data = std::string{"The answer is: 42"};
}

前面的代码与之前的例子相同,唯一的区别是我们在不同的数据类型之间进行了交换。Valgrind 产生了以下输出:

正如我们所看到的,发生了9次分配,而不是3次。为了解决这个问题,我们需要使用std::variant而不是std::any,如下所示:

#include <variant>
#include <string>

std::variant<int, std::string> data;

int main(void)
{
    data = 42;
    data = std::string{"The answer is: 42"};
}

std::anystd::variant之间的区别在于,std::variant要求用户声明变体必须支持的类型,从而在赋值时消除了动态内存分配的需要。Valgrind 的输出如下:

现在,我们只有2个分配,正如预期的那样(默认分配和从std::string分配)。正如本教程所示,包括 C++标准库在内的库可以隐藏内存分配,可能会减慢代码速度并使用比预期更多的内存资源。诸如 Valgrind 之类的工具可以用于识别这些类型的问题,从而使您能够创建更高效的 C++代码。

声明 noexcept

C++11 引入了noexcept关键字,除了简化异常的一般使用方式外,还包括了更好的 C++异常实现,去除了一些性能损耗。但是,这并不意味着异常不包括开销(即性能惩罚)。在本教程中,我们将探讨异常如何给应用程序增加开销,以及noexcept关键字如何帮助减少这些惩罚(取决于编译器)。

本教程很重要,因为它将演示如果一个函数不会抛出异常,那么应该标记为noexcept,以防止额外的开销影响应用程序的总大小,从而导致应用程序加载更快。

准备工作

在开始之前,请确保已满足所有技术要求,包括安装 Ubuntu 18.04 或更高版本,并在终端窗口中运行以下命令:

> sudo apt-get install build-essential git cmake

这将确保您的操作系统具有适当的工具来编译和执行本教程中的示例。完成后,打开一个新的终端。我们将使用此终端来下载、编译和运行我们的示例。

操作步骤...

执行以下步骤完成本教程:

  1. 在新的终端中,运行以下命令以下载源代码:
> cd ~/
> git clone https://github.com/PacktPublishing/Advanced-CPP-CookBook.git
> cd Advanced-CPP-CookBook/chapter06
  1. 要编译源代码,请运行以下命令:
> cmake .
> make recipe04_examples
  1. 源代码编译完成后,可以通过运行以下命令来执行本教程中的每个示例:
> ./recipe04_example01 

> ./recipe04_example02

在下一节中,我们将逐个介绍这些示例,并解释每个示例程序的作用以及它与本教程所教授的课程的关系。

工作原理

在本教程中,我们将学习为什么将函数标记为noexcept非常重要,如果它不应该抛出异常。这是因为它去除了对异常支持的额外开销,可以改善执行时间、应用程序大小,甚至加载时间(这取决于编译器、使用的标准库等)。为了证明这一点,让我们创建一个简单的示例:

class myclass
{
    int answer;

public:
    ~myclass()
    {
        answer = 42;
    }
};

我们需要做的第一件事是创建一个类,在销毁时设置一个private成员变量,如下所示:

void foo()
{
    throw 42;
}

int main(void) 
{
    myclass c;

    try {
        foo();
    }
    catch (...) {
    }
}

现在,我们可以创建两个函数。第一个函数抛出一个异常,而第二个函数是我们的主函数。这个函数创建了我们类的一个实例,并在try/catch块中调用foo()函数。换句话说,main()函数在任何时候都不会抛出异常。如果我们查看主函数的汇编代码,我们会看到以下内容:

正如我们所看到的,我们的主函数调用了_Unwind_Resume,这是异常解开器使用的。这额外的逻辑是因为 C++必须在函数末尾添加额外的异常逻辑。为了去除这额外的逻辑,告诉编译器main()函数不会抛出异常:

int main(void) noexcept
{
    myclass c;

    try {
        foo();
    }
    catch (...) {
    }
}

添加noexcept告诉编译器不能抛出异常。结果,该函数不再包含处理异常的额外逻辑,如下所示:

正如我们所看到的,取消函数不再存在。值得注意的是,存在对 catch 函数的调用,这是由于try/catch块而不是异常的开销。

第七章:调试和测试

在本章中,您将学习如何正确测试和调试您的 C++应用程序。这很重要,因为没有良好的测试和调试,您的 C++应用程序很可能包含难以检测的错误,这将降低它们的整体可靠性、稳定性和安全性。

本章将从全面概述单元测试开始,这是在单元级别测试代码的行为,并且还将介绍如何利用现有库加快编写测试的过程。接下来,它将演示如何使用 ASAN 和 UBSAN 动态分析工具来检查内存损坏和未定义行为。最后,本章将简要介绍如何在自己的代码中利用NDEBUG宏来添加调试逻辑以解决问题。

本章包含以下教程:

  • 掌握单元测试

  • 使用 ASAN,地址检查器

  • 使用 UBSAN,未定义行为检查器

  • 使用#ifndef NDEBUG条件性地执行额外的检查

技术要求

要编译和运行本章中的示例,您必须具有管理访问权限的计算机,该计算机运行 Ubuntu 18.04,并具有功能正常的互联网连接。在运行这些示例之前,您必须安装以下内容:

> sudo apt-get install build-essential git cmake

如果这是安装在 Ubuntu 18.04 以外的任何操作系统上,则需要 GCC 7.4 或更高版本和 CMake 3.6 或更高版本。

本章的代码文件可以在github.com/PacktPublishing/Advanced-CPP-CookBook/tree/master/chapter07找到。

掌握单元测试

在这个教程中,我们将学习如何对我们的 C++代码进行单元测试。有几种不同的方法可以确保您的 C++代码以可靠性、稳定性、安全性和规范性执行。

单元测试是在基本单元级别测试代码的行为,是任何测试策略的关键组成部分。这个教程很重要,不仅因为它将教会您如何对代码进行单元测试,还因为它将解释为什么单元测试如此关键,以及如何利用现有库加快对 C++代码进行单元测试的过程。

准备工作

在开始之前,请确保满足所有技术要求,包括安装 Ubuntu 18.04 或更高版本,并在终端窗口中运行以下命令:

> sudo apt-get install build-essential git cmake

这将确保您的操作系统具有编译和执行本教程中示例所需的适当工具。完成后,打开一个新的终端。我们将使用这个终端来下载、编译和运行我们的示例。

如何做...

按照以下步骤进行教程:

  1. 从新的终端运行以下命令以下载源代码:
> cd ~/
> git clone https://github.com/PacktPublishing/Advanced-CPP-CookBook.git
> cd Advanced-CPP-CookBook/chapter07
  1. 要编译源代码,请运行以下命令:
> cmake .
> make recipe01_examples
  1. 源代码编译完成后,您可以通过运行以下命令来执行本教程中的每个示例:
> ./recipe01_example01
===========================================================================
All tests passed (1 assertion in 1 test case)

> ./recipe01_example02
===========================================================================
All tests passed (6 assertions in 1 test case)

> ./recipe01_example03
===========================================================================
All tests passed (8 assertions in 1 test case)

> ./recipe01_example04
===========================================================================
All tests passed (1 assertion in 1 test case)

> ./recipe01_example05
...
===========================================================================
test cases: 1 | 1 passed
assertions: - none -

> ./recipe01_example06
...
===========================================================================
test cases: 5 | 3 passed | 2 failed
assertions: 8 | 6 passed | 2 failed

> ./recipe01_example07
===========================================================================
test cases: 1 | 1 passed
assertions: - none -

> ./recipe01_example08
===========================================================================
All tests passed (3 assertions in 1 test case)

在下一节中,我们将逐个介绍这些示例,并解释每个示例程序的作用以及它与本教程所教授的课程的关系。

它是如何工作的...

仅仅编写您的 C++应用程序,并希望它按预期工作而不进行任何测试,肯定会导致可靠性、稳定性和安全性相关的错误。这个教程很重要,因为在发布之前测试您的应用程序可以确保您的应用程序按预期执行,最终为您节省时间和金钱。

有几种不同的方法可以测试您的代码,包括系统级、集成、长期稳定性以及静态和动态分析等。在这个教程中,我们将专注于单元测试。单元测试将应用程序分解为功能单元,并测试每个单元以确保其按预期执行。通常,在实践中,每个函数和对象(即类)都是一个应该独立测试的单元。

有几种不同的理论,关于如何执行单元测试,整本书都是关于这个主题的。有些人认为应该测试函数或对象中的每一行代码,利用覆盖率工具来确保合规性,而另一些人认为单元测试应该是需求驱动的,采用黑盒方法。一种常见的开发过程称为测试驱动开发,它规定所有测试,包括单元测试,都应该在编写任何源代码之前编写,而行为驱动开发则进一步采用特定的、以故事为驱动的方法来进行单元测试。

每种测试模型都有其优缺点,您选择的方法将基于您正在编写的应用程序类型、您遵循的软件开发过程类型以及您可能需要或不需要遵循的任何政策。不管您做出什么选择,单元测试可能会成为您测试方案的一部分,这个示例将为您提供如何对 C++应用程序进行单元测试的基础。

尽管可以使用标准的 C++进行单元测试(例如,这就是libc++进行单元测试的方法),但单元测试库有助于简化这个过程。在这个示例中,我们将利用Catch2单元测试库,可以在以下网址找到

github.com/catchorg/Catch2.git

尽管我们将回顾 Catch2,但正在讨论的原则适用于大多数可用的单元测试库,甚至适用于标准的 C++,如果您选择不使用辅助库。要利用 Catch2,只需执行以下操作:

> git clone https://github.com/catchorg/Catch2.git catch
> cd catch
> mkdir build
> cd build
> cmake ..
> make
> sudo make install

您还可以使用 CMake 的ExternalProject_Add,就像我们在 GitHub 上的示例中所做的那样,来利用库的本地副本。

要了解如何使用 Catch2,让我们看下面这个简单的例子:

#define CATCH_CONFIG_MAIN
#include <catch.hpp>

TEST_CASE("the answer")
{
   CHECK(true);
}

运行时,我们看到以下输出:

在前面的例子中,我们首先定义了CATCH_CONFIG_MAIN。这告诉 Catch2 库我们希望它为我们创建main()函数。这必须在我们包含 Catch2include语句之前定义,这是我们在前面的代码中所做的。

下一步是定义一个测试用例。每个单元都被分解成测试单元,测试所讨论的单元。每个测试用例的粒度由您决定:有些人选择为每个被测试的单元设置一个单独的测试用例,而其他人,例如,选择为每个被测试的函数设置一个测试用例。TEST_CASE()接受一个字符串,允许您提供测试用例的描述,当测试失败时,这对于帮助您确定测试代码中失败发生的位置是有帮助的,因为 Catch2 将输出这个字符串。我们简单示例中的最后一步是使用CHECK()宏。这个宏执行一个特定的测试。每个TEST_CASE()可能会有几个CHECK()宏,旨在为单元提供特定的输入,然后验证生成的输出。

一旦编译和执行,单元测试库将提供一些输出文本,描述如何执行测试。在这种情况下,库说明所有测试都通过了,这是期望的结果。

为了更好地理解如何在自己的代码中利用单元测试,让我们看下面这个更复杂的例子:

#define CATCH_CONFIG_MAIN
#include <catch.hpp>

#include <vector>
#include <iostream>
#include <algorithm>

TEST_CASE("sort a vector")
{
    std::vector<int> v{4, 8, 15, 16, 23, 42};
    REQUIRE(v.size() == 6);

    SECTION("sort descending order") {
        std::sort(v.begin(), v.end(), std::greater<int>());

        CHECK(v.front() == 42);
        CHECK(v.back() == 4);
    }

    SECTION("sort ascending order") {
        std::sort(v.begin(), v.end(), std::less<int>());

        CHECK(v.front() == 4);
        CHECK(v.back() == 42);
    }
}

像前面的例子一样,我们使用CATCH_CONFIG_MAIN宏包含 Catch2,然后定义一个带有描述的单个测试用例。在这个例子中,我们正在测试对向量进行排序的能力,所以这是我们提供的描述。我们在测试中要做的第一件事是创建一个包含预定义整数列表的整数向量。

接下来我们使用REQUIRE()宏进行测试,确保向量中有6个元素。REQUIRE()宏类似于CHECK(),因为两者都检查宏内部的语句是否为真。不同之处在于,CHECK()宏将报告错误,然后继续执行,而REQUIRE()宏将停止执行,中止单元测试。这对于确保单元测试基于测试可能做出的任何假设正确构建是有用的。随着时间的推移,单元测试的成熟度越来越重要,其他程序员会添加和修改单元测试,以确保单元测试不会引入错误,因为没有比测试和调试单元测试更糟糕的事情了。

SECTION()宏用于进一步分解我们的测试,并提供添加每个测试的常见设置代码的能力。在前面的示例中,我们正在测试向量的sort()函数。sort()函数可以按不同的方向排序,这个单元测试必须验证。如果没有SECTION()宏,如果测试失败,将很难知道失败是由于按升序还是按降序排序。此外,SECTION()宏确保每个测试不会影响其他测试的结果。

最后,我们使用CHECK()宏来确保sort()函数按预期工作。单元测试也应该检查异常。在下面的示例中,我们将确保异常被正确抛出:

#define CATCH_CONFIG_MAIN
#include <catch.hpp>

#include <vector>
#include <iostream>
#include <algorithm>

void foo(int val)
{
    if (val != 42) {
        throw std::invalid_argument("The answer is: 42");
    }
}

TEST_CASE("the answer")
{
    CHECK_NOTHROW(foo(42));
    REQUIRE_NOTHROW(foo(42));

    CHECK_THROWS(foo(0));
    CHECK_THROWS_AS(foo(0), std::invalid_argument);
    CHECK_THROWS_WITH(foo(0), "The answer is: 42");

    REQUIRE_THROWS(foo(0));
    REQUIRE_THROWS_AS(foo(0), std::invalid_argument);
    REQUIRE_THROWS_WITH(foo(0), "The answer is: 42");
}

与前面的示例一样,我们定义了CATCH_CONFIG_MAIN宏,添加了我们需要的包含文件,并定义了一个TEST_CASE()。我们还定义了一个foo()函数,如果foo()函数的输入无效,则会抛出异常。

在我们的测试用例中,我们首先使用有效的输入测试foo()函数。由于foo()函数没有输出(即函数返回void),我们通过使用CHECK_NOTHROW()宏来确保函数已经正确执行,确保没有抛出异常。值得注意的是,与CHECK()宏一样,CHECK_NOTHROW()宏有等效的REQUIRE_NOTHROW(),如果检查失败,将停止执行。

最后,我们确保foo()函数在其输入无效时抛出异常。有几种不同的方法可以做到这一点。CHECK_THROWS()宏只是确保抛出了异常。CHECK_THROWS_AS()宏确保不仅抛出了异常,而且异常是std::runtime_error类型。这两者都必须为测试通过。最后,CHECK_THROWS_WITH()宏确保抛出异常,并且异常的what()字符串返回与我们期望的异常匹配。与其他版本的CHECK()宏一样,每个宏也有REQUIRE()版本。

尽管 Catch2 库提供了宏,让您深入了解每种异常类型的具体细节,但应该注意,除非异常类型和字符串在您的 API 要求中明确定义,否则应该使用通用的CHECK_THROWS()宏。例如,规范中定义了at()函数在索引无效时始终返回std::out_of_range异常。在这种情况下,应该使用CHECK_THROWS_AS()宏来确保at()函数符合规范。规范中未指定此异常返回的字符串,因此应避免使用CHECK_THROWS_WITH()。这很重要,因为编写单元测试时常见的错误是编写过度规范的单元测试。过度规范的单元测试通常在被测试的代码更新时必须进行更新,这不仅成本高,而且容易出错。

单元测试应该足够详细,以确保单元按预期执行,但又足够通用,以确保对源代码的修改不需要更新单元测试本身,除非 API 的要求发生变化,从而产生一组能够长期使用的单元测试,同时仍然提供确保可靠性、稳定性、安全性甚至合规性所必需的测试。

一旦您有一组单元测试来验证每个单元是否按预期执行,下一步就是确保在修改代码时执行这些单元测试。这可以手动完成,也可以由持续集成CI)服务器自动完成,例如 TravisCI;然而,当您决定这样做时,请确保单元测试返回正确的错误代码。在前面的例子中,当单元测试通过并打印简单的字符串表示所有测试都通过时,单元测试本身退出时使用了EXIT_SUCCESS。对于大多数 CI 来说,这已经足够了,但在某些情况下,让 Catch2 以易于解析的格式输出结果可能是有用的。

例如,考虑以下代码:

#define CATCH_CONFIG_MAIN
#include <catch.hpp>

TEST_CASE("the answer")
{
    CHECK(true);
}

让我们用以下方式运行:

> ./recipe01_example01 -r xml

如果我们这样做,我们会得到以下结果:

在前面的例子中,我们创建了一个简单的测试用例(与本配方中的第一个例子相同),并指示 Catch2 使用-r xml选项将测试结果输出为 XML。Catch2 有几种不同的输出格式,包括 XML 和 JSON。

除了输出格式之外,Catch2 还可以用来对我们的代码进行基准测试。例如,考虑以下代码片段:

#define CATCH_CONFIG_MAIN
#define CATCH_CONFIG_ENABLE_BENCHMARKING
#include <catch.hpp>

#include <vector>
#include <iostream>

TEST_CASE("the answer")
{
    std::vector<int> v{4, 8, 15, 16, 23, 42};

    BENCHMARK("sort vector") {
        std::sort(v.begin(), v.end());
    };
}

在上面的例子中,我们创建了一个简单的测试用例,对预定义的向量数字进行排序。然后我们在BENCHMARK()宏中对这个列表进行排序,当执行时会得到以下输出:

如前面的屏幕截图所示,Catch2 执行了该函数多次,平均花费197纳秒来对向量进行排序。BENCHMARK()宏对于确保代码不仅按预期执行并给出特定输入的正确输出,而且还确保代码在特定时间内执行非常有用。配合更详细的输出格式,比如 XML 或 JSON,这种类型的信息可以用来确保随着源代码的修改,生成的代码执行时间保持不变或更快。

为了更好地理解单元测试如何真正改进您的 C++,我们将用两个额外的例子来结束这个配方,这些例子旨在提供更真实的场景。

在第一个例子中,我们将创建一个向量。与 C++中的std::vector不同,它是一个动态的 C 风格数组,数学中的向量是n维空间中的一个点(在我们的例子中,我们将其限制为 2D 空间),其大小是点与原点(即 0,0)之间的距离。我们在示例中实现这个向量如下:

#define CATCH_CONFIG_MAIN
#include <catch.hpp>

#include <cmath>
#include <climits>

class vector
{
    int m_x{};
    int m_y{};

除了通常的宏和包含之外,我们要做的第一件事是定义一个带有xy坐标的类:

public:

    vector() = default;

    vector(int x, int y) :
        m_x{x},
        m_y{y}
    { }

    auto x() const
    { return m_x; }

    auto y() const
    { return m_y; }

    void translate(const vector &p)
    {
        m_x += p.m_x;
        m_y += p.m_y;
    }

    auto magnitude()
    {
        auto a2 = m_x * m_x;
        auto b2 = m_y * m_y;

        return sqrt(a2 + b2);
    }
};

接下来,我们添加一些辅助函数和构造函数。默认构造函数创建一个没有方向或大小的向量,因为xy被设置为原点。为了创建具有方向和大小的向量,我们还提供了另一个构造函数,允许您提供向量的初始xy坐标。为了获取向量的方向,我们提供了返回向量xy值的 getter。最后,我们提供了两个辅助函数。第一个辅助函数translates向量,在数学上是改变向量的xy坐标的另一个术语。最后一个辅助函数返回向量的大小,即如果向量的xy值用于构造三角形的斜边的长度(也就是说,我们必须使用勾股定理来计算向量的大小)。接下来,我们继续添加运算符,具体如下:

bool operator== (const vector &p1, const vector &p2)
{ return p1.x() == p2.x() && p1.y() == p2.y(); }

bool operator!= (const vector &p1, const vector &p2)
{ return !(p1 == p2); }

constexpr const vector origin;

我们添加了一些等价运算符,用于检查两个向量是否相等。我们还定义了一个表示原点的向量,其xy值都为 0。

为了测试这个向量,我们添加了以下测试:

TEST_CASE("default constructor")
{
    vector p;

    CHECK(p.x() == 0);
    CHECK(p.y() == 0);
}

TEST_CASE("origin")
{
    CHECK(vector{0, 0} == origin);
    CHECK(vector{1, 1} != origin);
}

TEST_CASE("translate")
{
    vector p{-4, -8};
    p.translate({46, 50});

    CHECK(p.x() == 42);
    CHECK(p.y() == 42);
}

TEST_CASE("magnitude")
{
    vector p(1, 1);
    CHECK(Approx(p.magnitude()).epsilon(0.1) == 1.4);
}

TEST_CASE("magnitude overflow")
{
    vector p(INT_MAX, INT_MAX);
    CHECK(p.magnitude() == 65536);
}

第一个测试确保默认构造的向量实际上是原点。我们的下一个测试确保我们的全局origin向量是原点。这很重要,因为我们不应该假设原点是默认构造的,也就是说,未来有人可能会意外地将原点更改为0,0之外的其他值。这个测试用例确保原点实际上是0,0,这样在未来,如果有人意外更改了这个值,这个测试就会失败。由于原点必须导致xy都为 0,所以这个测试并没有过度规定。

接下来,我们测试 translate 和 magnitude 函数。在 magnitude 测试用例中,我们使用Approx()宏。这是因为返回的大小是一个浮点数,其大小和精度取决于硬件,并且与我们的测试无关。Approx()宏允许我们声明要验证magnitude()函数结果的精度级别,该函数使用epsilon()修饰符来实际声明精度。在这种情况下,我们只希望验证到小数点后一位。

最后一个测试用例用于演示这些函数的所有输入应该被测试。如果一个函数接受一个整数,那么应该测试所有有效的、无效的和极端的输入。在这种情况下,我们为xy都传递了INT_MAX。结果的magnitude()函数没有提供有效的结果。这是因为计算大小的过程溢出了整数类型。这种类型的错误应该在代码中考虑到(也就是说,您应该检查可能的溢出并抛出异常),或者 API 的规范应该指出这些类型的问题(也就是说,C++规范可能会声明这种类型输入的结果是未定义的)。无论哪种方式,如果一个函数接受一个整数,那么所有可能的整数值都应该被测试,并且这个过程应该对所有输入类型重复。

这个测试的结果如下:

如前面的屏幕截图所示,该单元测试未通过最后一个测试。如前所述,为了解决这个问题,magnitude 函数应该被更改为在发生溢出时抛出异常,找到防止溢出的方法,或者删除测试并声明这样的输入是未定义的。

在我们的最后一个例子中,我们将演示如何处理不返回值而是操作输入的函数。

让我们通过创建一个写入文件的类和另一个使用第一个类将字符串写入该文件的类来开始这个例子,如下所示:

#define CATCH_CONFIG_MAIN
#include <catch.hpp>

#include <string>
#include <fstream>

class file
{
    std::fstream m_file{"test.txt", std::fstream::out};

public:

    void write(const std::string &str)
    {
        m_file.write(str.c_str(), str.length());
    }
};

class the_answer
{
public:

    the_answer(file &f)
    {
        f.write("The answer is: 42\n");
    }
};

如前面的代码所示,第一个类写入一个名为test.txt的文件,而第二个类将第一个类作为输入,并使用它来向文件中写入一个字符串。

我们测试第二个类如下:

TEST_CASE("the answer")
{
    file f;
    the_answer{f};
}

前面测试的问题在于我们没有任何CHECK()宏。这是因为除了CHECK_NOTHROW()之外,我们没有任何需要检查的东西。在这个测试中,我们测试以确保the_answer{}类调用file{}类和write()函数正确。我们可以打开test.txt文件并检查它是否用正确的字符串写入,但这是很多工作。这种类型的检查也会过度指定,因为我们不是在测试file{}类,我们只是在测试the_answer{}类。如果将来我们决定file{}类应该写入网络文件而不是磁盘上的文件,单元测试将不得不改变。

为了克服这个问题,我们可以利用一个叫做mocking的概念。Mock类是一个假装是输入类的类,为单元测试提供了seams,允许单元测试验证测试的结果。这与Stub不同,后者提供了虚假的输入。不幸的是,与其他语言相比,C++对 mocking 的支持并不好。辅助库,如 GoogleMock,试图解决这个问题,但需要所有可 mock 的类都包含一个 vTable(即继承纯虚拟基类)并在你的代码中定义每个可 mock 的类两次(一次在你的代码中,一次在你的测试中,使用 Google 定义的一组 API)。这远非最佳选择。像 Hippomocks 这样的库试图解决这些问题,但需要一些 vTable 黑魔法,只能在某些环境中工作,并且当出现问题时几乎不可能进行调试。尽管 Hippomocks 可能是最好的选择之一(即直到 C++启用本地 mocking),但以下示例是使用标准 C++进行 mocking 的另一种方法,唯一的缺点是冗长:

#define CATCH_CONFIG_MAIN
#include <catch.hpp>

#include <string>
#include <fstream>

class file
{
    std::fstream m_file{"test.txt", std::fstream::out};

public:
    VIRTUAL ~file() = default;

    VIRTUAL void write(const std::string &str)
    {
        m_file.write(str.c_str(), str.length());
    }
};

class the_answer
{
public:
    the_answer(file &f)
    {
        f.write("The answer is: 42\n");
    }
};

与我们之前的示例一样,我们创建了两个类。第一个类写入一个文件,而第二个类使用第一个类向该文件写入一个字符串。不同之处在于我们添加了VIRTUAL宏。当代码编译到我们的应用程序中时,VIRTUAL被设置为空,这意味着它被编译器从代码中移除。然而,当代码在我们的测试中编译时,它被设置为virtual,这告诉编译器给类一个 vTable。由于这只在我们的测试期间完成,所以额外的开销是可以接受的。

现在我们的类在我们的测试用例中支持继承,我们可以创建我们的file{}类的一个子类版本如下:

class mock_file : public file
{
public:
    void write(const std::string &str)
    {
        if (str == "The answer is: 42\n") {
            passed = true;
        }
        else {
            passed = false;
        }
    }

    bool passed{};
};

前面的类定义了我们的 mock。我们的 mock 不是写入文件,而是检查特定的字符串是否被写入我们的假文件,并根据测试的结果设置一个全局变量为truefalse

然后我们可以测试我们的the_answer{}类如下:

TEST_CASE("the answer")
{
    mock_file f;
    REQUIRE(f.passed == false);

    f.write("The answer is not: 43\n");
    REQUIRE(f.passed == false);

    the_answer{f};
    CHECK(f.passed);
}

当执行此操作时,我们会得到以下结果:

如前面的屏幕截图所示,我们现在可以检查我们的类是否按预期写入文件。值得注意的是,我们使用REQUIRE()宏来确保在执行我们的测试之前,mock 处于false状态。这确保了如果我们的实际测试被注册为通过,那么它确实已经通过,而不是因为我们测试逻辑中的错误而被注册为通过。

使用 ASAN,地址消毒剂

在这个示例中,我们将学习如何利用谷歌的地址消毒剂ASAN)——这是一个动态分析工具——来检查代码中的内存损坏错误。这个示例很重要,因为它提供了一种简单的方法来确保你的代码既可靠又稳定,而对你的构建系统的更改数量很少。

准备工作

开始之前,请确保满足所有技术要求,包括安装 Ubuntu 18.04 或更高版本,并在终端窗口中运行以下命令:

> sudo apt-get install build-essential git cmake

这将确保您的操作系统具有编译和执行本食谱中示例所需的适当工具。完成后,打开一个新的终端。我们将使用这个终端来下载、编译和运行我们的示例。

如何操作...

按照以下步骤执行该食谱:

  1. 从新的终端运行以下命令以下载源代码:
> cd ~/
> git clone https://github.com/PacktPublishing/Advanced-CPP-CookBook.git
> cd Advanced-CPP-CookBook/chapter07
  1. 要编译源代码,请运行以下命令:
> cmake -DCMAKE_BUILD_TYPE=ASAN ..
> make recipe02_examples
  1. 编译源代码后,可以通过运行以下命令执行本食谱中的每个示例:
> ./recipe02_example01
...

> ./recipe02_example02
...

> ./recipe02_example03
...

> ./recipe02_example04
...

> ./recipe02_example05
...

在下一节中,我们将逐个介绍这些示例,并解释每个示例程序的作用以及它与本食谱中所教授的课程的关系。

它是如何工作的...

Google 的地址消毒剂是对 GCC 和 LLVM 编译器的一组修改,以及一组必须在测试时链接到应用程序中的库。为了实现这一点,我们在编译用于测试的代码时必须添加以下编译器标志(但不要将这些标志添加到生产版本中):

-fsanitize=address 
-fno-optimize-sibling-calls 
-fsanitize-address-use-after-scope 
-fno-omit-frame-pointer 
-g -O1

这里需要特别注意的最重要的标志是-fsanitize=address标志,它告诉编译器启用 ASAN。其余的标志是卫生间所需的,最值得注意的标志是-g-01-g标志启用调试,-O1标志将优化级别设置为 1,以提供一些性能改进。请注意,一旦启用 ASAN 工具,编译器将自动尝试链接到 ASAN 库,这些库必须存在于您的计算机上。

为了演示这个消毒剂是如何工作的,让我们看几个例子。

内存泄漏错误

AddressSanitizer是一种动态分析工具,旨在识别内存损坏错误。它类似于 Valgrind,但直接内置到您的可执行文件中。最容易用一个示例来演示这一点(也是最常见的错误类型之一)是内存泄漏,如下所示:

int main(void)
{
    new int;
}

这导致以下输出:

在上面的示例中,我们在程序中使用new运算符分配了一个整数,但在退出程序之前我们将永远不会释放这个分配的内存。ASAN 工具能够检测到这个问题,并在应用程序完成执行时输出错误。

内存两次删除

检测内存泄漏的能力非常有帮助,但这并不是 ASAN 能够检测到的唯一类型的错误。另一种常见的错误类型是多次删除内存。例如,考虑以下代码片段:

int main(void)
{
    auto p = new int;
    delete p;

    delete p;
}

执行后,我们看到以下输出:

在上面的示例中,我们使用new运算符分配了一个整数,然后使用删除运算符删除了该整数。由于先前分配的内存的指针仍然在我们的p变量中,我们可以再次删除它,这是我们在退出程序之前所做的。在某些系统上,这将生成一个分段错误,因为这是未定义的行为。ASAN 工具能够检测到这个问题,并输出一个错误消息,指出发生了double-free错误。

访问无效内存

另一种错误类型是尝试访问从未分配的内存。这通常是由代码尝试对空指针进行解引用引起的,但也可能发生在指针损坏时,如下所示:

int main(void)
{
    int *p = (int *)42;
    *p = 0;
}

这导致以下输出:

在前面的示例中,我们创建了一个指向整数的指针,然后为它提供了一个损坏的值42(这不是一个有效的指针)。然后我们尝试对损坏的指针进行解引用,结果导致分段错误。应该注意的是,ASAN 工具能够检测到这个问题,但它无法提供任何有用的信息。这是因为 ASAN 工具是一个库,它钩入内存分配例程,跟踪每个分配以及分配的使用方式。如果一个分配从未发生过,它将不会有任何关于发生了什么的信息,除了典型的 Unix 信号处理程序已经提供的信息,其他动态分析工具,比如 Valgrind,更适合处理这些情况。

在删除后使用内存

为了进一步演示地址消毒剂的工作原理,让我们看看以下示例:

int main(void)
{
    auto p = new int;
    delete p;

    *p = 0;
}

当我们执行这个时,我们会看到以下内容:

前面的示例分配了一个整数,然后删除了这个整数。然后我们尝试使用先前删除的内存。由于这个内存位置最初是分配的,ASAN 已经缓存了地址。当对先前删除的内存进行解引用时,ASAN 能够检测到这个问题,作为heap-use-after-free错误。它之所以能够检测到这个问题,是因为这块内存先前被分配过。

删除从未分配的内存

最后一个例子,让我们看看以下内容:

int main(void)
{
    int *p = (int *)42;
    delete p;
}

这导致了以下结果:

在前面的示例中,我们创建了一个指向整数的指针,然后再次为它提供了一个损坏的值。与我们之前的示例不同,在这个示例中,我们尝试删除这个损坏的指针,结果导致分段错误。再一次,ASAN 能够检测到这个问题,但由于从未发生过分配,它没有任何有用的信息。

应该注意的是,C++核心指南——这是一个现代 C++的编码标准——在防止我们之前描述的问题类型方面非常有帮助。具体来说,核心指南规定new()delete()malloc()free()和其他函数不应该直接使用,而应该使用std::unique_ptrstd::shared_ptr来进行所有内存分配。这些 API 会自动为您分配和释放内存。如果我们再次看一下前面的示例,很容易看出,使用这些 API 来分配内存而不是手动使用new()delete()可以防止这些问题发生,因为大多数前面的示例都与无效使用new()delete()有关。

使用 UBSAN,未定义行为消毒剂

在这个配方中,我们将学习如何在我们的 C++应用程序中使用 UBSAN 动态分析工具,它能够检测未定义的行为。在我们的应用程序中可能会引入许多不同类型的错误,未定义的行为很可能是最常见的类型,因为 C 和 C++规范定义了几种可能发生未定义行为的情况。

这个配方很重要,因为它将教会你如何启用这个简单的功能,以及它如何在你的应用程序中使用。

准备工作

开始之前,请确保满足所有技术要求,包括安装 Ubuntu 18.04 或更高版本,并在终端窗口中运行以下命令:

> sudo apt-get install build-essential git cmake

这将确保您的操作系统具有适当的工具来编译和执行本配方中的示例。完成后,打开一个新的终端。我们将使用这个终端来下载、编译和运行我们的示例。

如何做...

按照以下步骤进行配方:

  1. 从一个新的终端,运行以下命令来下载源代码:
> cd ~/
> git clone https://github.com/PacktPublishing/Advanced-CPP-CookBook.git
> cd Advanced-CPP-CookBook/chapter07
  1. 要编译源代码,请运行以下命令:
> cmake -DCMAKE_BUILD_TYPE=UBSAN .
> make recipe03_examples
  1. 源代码编译后,可以通过运行以下命令来执行本示例中的每个示例:
> ./recipe03_example01
Floating point exception (core dumped)

> ./recipe03_example02
Segmentation fault (core dumped)

> ./recipe03_example03
Segmentation fault (core dumped)

> ./recipe03_example04

在下一节中,我们将逐个讲解这些示例,并解释每个示例程序的作用以及它与本示例中所教授的课程的关系。

工作原理...

UBSAN 工具能够检测到几种类型的未定义行为,包括以下内容:

  • 越界错误

  • 浮点错误

  • 除零

  • 整数溢出

  • 空指针解引用

  • 缺少返回值

  • 有符号/无符号转换错误

  • 不可达代码

在这个示例中,我们将看一些这样的例子,但首先,我们必须在我们的应用程序中启用 UBSAN 工具。为此,我们必须在应用程序的构建系统中启用以下标志:

-fsanitize=undefined

这个标志将告诉 GCC 或 LLVM 使用 UBSAN 工具,它会向我们的应用程序添加额外的逻辑,并链接到 UBSAN 库。值得注意的是,UBSAN 工具的功能会随着时间的推移而增强。因此,GCC 和 LLVM 对 UBSAN 的支持水平不同。为了充分利用这个工具,你的应用程序应该同时针对 GCC 和 LLVM 进行编译,并且应该尽可能使用最新的编译器。

除零错误

使用 UBSAN 最容易演示的一个例子是除零错误,如下所示:

int main(void)
{
    int n = 42;
    int d = 0;

    auto f = n/d;
}

当运行时,我们看到以下内容:

在上面的示例中,我们创建了两个整数(一个分子和一个分母),分母设置为0。然后我们对分子和分母进行除法运算,导致除零错误,UBSAN 检测到并在程序崩溃时输出。

空指针解引用

在 C++中更常见的问题类型是空指针解引用,如下所示:

int main(void)
{
    int *p = 0;
    *p = 42;
}

这导致了以下结果:

在上面的示例中,我们创建了一个指向整数的指针,并将其设置为0(即NULL指针)。然后我们对NULL指针进行解引用并设置其值,导致分段错误,UBSAN 能够检测到程序崩溃。

越界错误

前面的两个示例都可以使用 Unix 信号处理程序来检测。在下一个示例中,我们将访问一个超出边界的数组,这在 C++规范中是未定义的,而且更难以检测:

int main(void)
{
    int numbers[] = {4, 8, 15, 16, 23, 42};
    numbers[10] = 0;
}

执行时,我们得到以下结果:

如上面的示例所示,我们创建了一个有 6 个元素的数组,然后尝试访问数组中的第 10 个元素,这个元素并不存在。尝试访问数组中的这个元素并不一定会生成分段错误。不管怎样,UBSAN 能够检测到这种类型的错误,并在退出时将问题输出到stderr

溢出错误

最后,我们还可以检测有符号整数溢出错误,这在 C++中是未定义的,但极不可能导致崩溃,而是会导致程序进入一个损坏的状态(通常产生无限循环、越界错误等)。考虑以下代码:

#include <climits>

int main(void)
{
    int i = INT_MAX;
    i++;
}

这导致了以下结果:

如上面的示例所示,我们创建了一个整数,并将其设置为最大值。然后我们尝试增加这个整数,这通常会翻转整数的符号,这是 UBSAN 能够检测到的错误。

使用#ifndef NDEBUG 条件执行额外检查

在这个示例中,我们将学习如何利用NDEBUG宏,它代表no debug。这个示例很重要,因为大多数构建系统在编译发布生产版本时会自动定义这个宏,这可以用来在创建这样的构建时禁用调试逻辑。

准备就绪

开始之前,请确保满足所有技术要求,包括安装 Ubuntu 18.04 或更高版本,并在终端窗口中运行以下命令:

> sudo apt-get install build-essential git cmake

这将确保您的操作系统具有正确的工具来编译和执行本配方中的示例。完成后,打开一个新的终端。我们将使用此终端来下载、编译和运行我们的示例。

如何做...

按照以下步骤来完成这个配方:

  1. 从新的终端运行以下命令来下载源代码:
> cd ~/
> git clone https://github.com/PacktPublishing/Advanced-CPP-CookBook.git
> cd Advanced-CPP-CookBook/chapter07
  1. 要编译源代码,请运行以下命令:
> cmake .
> make recipe04_examples
  1. 一旦源代码编译完成,您可以通过运行以下命令来执行本配方中的每个示例:
> ./recipe04_example01
The answer is: 42

> ./recipe04_example02
recipe04_example02: /home/user/book/chapter07/recipe04.cpp:45: int main(): Assertion `42 == 0' failed.
Aborted (core dumped)

在下一节中,我们将逐个介绍这些示例,并解释每个示例程序的作用以及它与本配方中所教授的课程的关系。

工作原理...

NDEBUG宏源自 C 语言,用于更改assert()函数的行为。assert()函数可以编写如下:

void __assert(int val, const char *str)
{
    if (val == 0) {
        fprintf(stderr, "Assertion '%s' failed.\n", str);
        abort();
    }
}

#ifndef NDEBUG
    #define assert(a) __assert(a, #a)
#else
    #define assert(a)
#endif 

如前面的代码所示,如果__assert()函数得到一个求值为false的布尔值(在 C 中,这是一个等于0的整数),则会向stderr输出错误消息,并中止应用程序。然后使用NDEBUG宏来确定assert()函数是否存在,如果应用程序处于发布模式,则会删除所有断言逻辑,从而减小应用程序的大小。在使用 CMake 时,我们可以使用以下命令启用NDEBUG标志:

> cmake -DCMAKE_BUILD_TYPE=Release ..

这将自动定义NDEBUG宏并启用优化。要防止定义此宏,我们可以做相反的操作:

> cmake -DCMAKE_BUILD_TYPE=Debug ..

上面的 CMake 代码将定义NDEBUG宏,而是启用调试,并禁用大多数优化(尽管这取决于编译器)。

在我们自己的代码中,assert宏可以如下使用:

#include <cassert>

int main(void)
{
    assert(42 == 0);
}

结果如下:

如前面的示例所示,我们创建了一个应用程序,该应用程序使用assert()宏来检查一个错误的语句,结果是应用程序中止。

尽管NDEBUG宏被assert()函数使用,但您也可以自己使用它,如下所示:

int main(void)
{
#ifndef NDEBUG
    std::cout << "The answer is: 42\n";
#endif
}

如前面的代码所示,如果应用程序未以release模式编译(即在编译时未在命令行上定义NDEBUG宏),则应用程序将输出到stdout。您可以在整个代码中使用相同的逻辑来创建自己的调试宏和函数,以确保在release模式下删除调试逻辑,从而可以根据需要添加任意数量的调试逻辑,而无需修改交付给客户的最终应用程序。

第八章:创建和实现自己的容器

在本章中,你将学习如何通过利用 C++标准模板库已经提供的现有容器来创建自己的自定义容器。这一章很重要,因为在很多情况下,你的代码将对标准模板库容器执行常见操作,这些操作在整个代码中都是重复的(比如实现线程安全)。本章的食谱将教你如何将这些重复的代码轻松地封装到一个自定义容器中,而无需从头开始编写自己的容器,也不会在代码中散布难以测试和验证的重复逻辑。

在整个本章中,你将学习实现自定义包装器容器所需的技能,能够确保std::vector始终保持排序顺序。第一个食谱将教你如何创建这个包装器的基础知识。第二个食谱将在第一个基础上展开,教你如何根据容器的操作方式重新定义容器的接口。在这种情况下,由于容器始终是有序的,你将学习为什么提供push_back()函数是没有意义的,即使我们只是创建一个包装器(包装器的添加改变了容器本身的概念)。在第三个食谱中,你将学习使用迭代器的技能,以及为什么在这个例子中只能支持const迭代器。最后,我们将向我们的容器添加几个额外的 API,以提供完整的实现。

本章中的食谱如下:

  • 使用简单的 std::vector 包装器

  • 添加 std::set API 的相关部分

  • 使用迭代器

  • 添加 std::vector API 的相关部分

技术要求

要编译和运行本章中的示例,读者必须具有对运行 Ubuntu 18.04 的计算机的管理访问权限,并且有一个正常的互联网连接。在运行这些示例之前,读者必须安装以下内容:

> sudo apt-get install build-essential git cmake

如果这安装在 Ubuntu 18.04 以外的任何操作系统上,则需要 GCC 7.4 或更高版本和 CMake 3.6 或更高版本。

本章的代码文件可以在github.com/PacktPublishing/Advanced-CPP-CookBook/tree/master/chapter08找到。

使用简单的 std::vector 包装器

在本食谱中,我们将学习如何通过包装现有的标准模板库容器来创建自己的自定义容器,以提供所需的自定义功能。在后续的食谱中,我们将在这个自定义容器的基础上构建,最终创建一个基于std::vector的完整容器。

这个食谱很重要,因为经常情况下,利用现有容器的代码伴随着每次使用容器时都会重复的常见逻辑。这个食谱(以及整个章节)将教会你如何将这些重复的逻辑封装到你自己的容器中,以便可以独立测试。

准备工作

在开始之前,请确保满足所有的技术要求,包括安装 Ubuntu 18.04 或更高版本,并在终端窗口中运行以下命令:

> sudo apt-get install build-essential git cmake

这将确保您的操作系统具有编译和执行本食谱中示例的必要工具。完成后,打开一个新的终端。我们将使用这个终端来下载、编译和运行我们的示例。

如何做...

按照以下步骤尝试本食谱:

  1. 从一个新的终端,运行以下命令来下载源代码:
> cd ~/
> git clone https://github.com/PacktPublishing/Advanced-CPP-CookBook.git
> cd Advanced-CPP-CookBook/chapter08
  1. 要编译源代码,请运行以下命令:
> cmake .
> make recipe01_examples
  1. 一旦源代码编译完成,你可以通过运行以下命令来执行本食谱中的每个示例:
> ./recipe01_example01
1
2
3
4
5
6
7
8

> ./recipe01_example02
1
2
3

> ./recipe01_example03
3
elements: 4 42 
3
elements: 4 8 15 42 
3
elements: 4 8 15 16 23 42 

在下一节中,我们将逐步介绍每个示例,并解释每个示例的作用以及它与本食谱中所教授的课程的关系。

它是如何工作的...

在本教程中,我们将学习如何在std::vector周围创建一个简单的包装容器。大多数情况下,标准模板库STL)容器足以执行应用程序可能需要的任务,通常应避免创建自己的容器,因为它们很难正确实现。

然而,有时您可能会发现自己在容器上重复执行相同的操作。当发生这种情况时,将这些常见操作封装到一个包装容器中通常是有帮助的,可以独立进行单元测试,以确保容器按预期工作。例如,STL 容器不是线程安全的。如果您需要一个容器在每次访问时都能够与线程安全一起使用,您首先需要确保您对容器有独占访问权限(例如,通过锁定std::mutex),然后才能进行容器操作。这种模式将在您的代码中重复出现,增加了进入死锁的机会。通过创建一个容器包装器,为容器的每个公共成员添加一个std::mutex,可以避免这个问题。

在本教程中,让我们考虑一个例子,我们创建一个向量(即,在连续内存中有直接访问权限的元素数组),它必须始终保持排序状态。首先,我们需要一些头文件:

#include <vector>
#include <algorithm>
#include <iostream>

为了实现我们的容器,我们将利用std::vector。虽然我们可以从头开始实现自己的容器,但大多数情况下这是不需要的,应该避免,因为这样的任务非常耗时和复杂。我们将需要algorithm头文件用于std::sortiostream用于测试。因此让我们添加如下内容:

template<
    typename T,
    typename Compare = std::less<T>,
    typename Allocator = std::allocator<T>
    >
class container
{
    using vector_type = std::vector<T, Allocator>;
    vector_type m_v;

public:

容器的定义将从其模板定义开始,与std::vector的定义相同,增加了一个Compare类型,用于定义我们希望容器排序的顺序。默认情况下,容器将按升序排序,但可以根据需要进行更改。最后,容器将有一个私有成员变量,即该容器包装的std::vector的实例。

为了使容器能够与 C++工具、模板函数甚至一些关键语言特性正常工作,容器需要定义与std::vector相同的别名,如下所示:

    using value_type = typename vector_type::value_type;
    using allocator_type = typename vector_type::allocator_type;
    using size_type = typename vector_type::size_type;
    using difference_type = typename vector_type::difference_type;
    using const_reference = typename vector_type::const_reference;
    using const_pointer = typename vector_type::const_pointer;
    using compare_type = Compare;

如您所见,我们无需手动定义别名。相反,我们可以简单地从std::vector本身转发别名的声明。唯一的例外是compare_type别名,因为这是我们添加到包装容器中的一个别名,表示模板类用于比较操作的类型,最终将提供给std::sort

我们也不包括引用别名的非 const 版本。原因是我们的容器必须始终保持std::vector处于排序状态。如果我们为用户提供对std::vector中存储的元素的直接写访问权限,用户可能会使std::vector处于无序状态,而我们的自定义容器无法按需重新排序。

接下来,让我们定义我们的构造函数(与std::vector提供的相同构造函数相对应)。

默认构造函数

以下是我们的默认构造函数的定义:

    container() noexcept(noexcept(Allocator()))
    {
        std::cout << "1\n";
    }

由于std::vector的默认构造函数产生一个空向量,我们不需要添加额外的逻辑,因为空向量默认是排序的。接下来,我们必须定义一个接受自定义分配器的构造函数。

自定义分配器构造函数

我们的自定义分配器构造函数定义如下:

    explicit container(
        const Allocator &alloc
    ) noexcept :
        m_v(alloc)
    {
        std::cout << "2\n";
    }

与前一个构造函数一样,这个构造函数创建一个空向量,但使用已经存在的分配器。

计数构造函数

接下来的两个构造函数允许 API 的用户设置向量的最小大小如下:

    container(
        size_type count,
        const T &value,
        const Allocator &alloc = Allocator()
    ) :
        m_v(count, value, alloc)
    {
        std::cout << "3\n";
    }

    explicit container(
        size_type count,
        const Allocator &alloc = Allocator()
    ) :
        m_v(count, alloc)
    {
        std::cout << "4\n";
    }

第一个构造函数将创建一个包含count个元素的向量,所有元素都用value的值初始化,而第二个构造函数将使用它们的默认值创建元素(例如,整数向量将被初始化为零)。

复制/移动构造函数

为了支持复制和移动容器的能力,我们需要实现一个复制和移动构造函数,如下所示:

    container(
        const container &other,
        const Allocator &alloc
    ) :
        m_v(other.m_v, alloc)
    {
        std::cout << "5\n";
    }

    container(
        container &&other
    ) noexcept :
        m_v(std::move(other.m_v))
    {
        std::cout << "6\n";
    }

由于我们的自定义包装容器必须始终保持排序顺序,因此将一个容器复制或移动到另一个容器不会改变容器中元素的顺序,这意味着这些构造函数也不需要进行排序操作。然而,我们需要特别注意确保通过复制或移动我们的容器封装的内部std::vector来正确进行复制或移动。

为了完整起见,我们还提供了一个移动构造函数,允许我们像std::vector一样在提供自定义分配器的同时移动。

    container(
        container &&other,
        const Allocator &alloc
    ) :
        m_v(std::move(other.m_v), alloc)
    {
        std::cout << "7\n";
    }

接下来,我们将提供一个接受初始化列表的构造函数。

初始化列表构造函数

最后,我们还将添加一个接受初始化列表的构造函数,如下所示:

    container(
        std::initializer_list<T> init,
        const Allocator &alloc = Allocator()
    ) :
        m_v(init, alloc)
    {
        std::sort(m_v.begin(), m_v.end(), compare_type());
        std::cout << "8\n";
    }

如前面的代码所示,初始化列表可以以任何顺序为std::vector提供初始元素。因此,我们必须在向量初始化后对列表进行排序。

用法

让我们测试这个容器,以确保每个构造函数都按预期工作:

int main(void)
{
    auto alloc = std::allocator<int>();

    container<int> c1;
    container<int> c2(alloc);
    container<int> c3(42, 42);
    container<int> c4(42);
    container<int> c5(c1, alloc);
    container<int> c6(std::move(c1));
    container<int> c7(std::move(c2), alloc);
    container<int> c8{4, 42, 15, 8, 23, 16};

    return 0;
}

如前面的代码块所示,我们通过调用每个构造函数来测试它们,结果如下:

如您所见,每个构造函数都成功按预期执行。

向容器添加元素

构造函数就位后,我们还需要提供手动向容器添加数据的能力(例如,如果我们最初使用默认构造函数创建了容器)。

首先,让我们专注于std::vector提供的push_back()函数:

    void push_back(const T &value)
    {
        m_v.push_back(value);
        std::sort(m_v.begin(), m_v.end(), compare_type());

        std::cout << "1\n";
    }

    void push_back(T &&value)
    {
        m_v.push_back(std::move(value));
        std::sort(m_v.begin(), m_v.end(), compare_type());

        std::cout << "2\n";
    }

如前面的代码片段所示,push_back()函数具有与std::vector提供的版本相同的函数签名,允许我们简单地将函数调用转发到std::vector。问题是,向std::vector的末尾添加值可能导致std::vector进入无序状态,需要我们在每次推送时重新排序std::vector(要求std::vector始终保持排序状态的结果)。

解决这个问题的一种方法是向容器包装器添加另一个成员变量,用于跟踪std::vector何时被污染。实现这些函数的另一种方法是按排序顺序添加元素(即按照排序顺序遍历向量并将元素放在适当的位置,根据需要移动剩余元素)。如果很少向std::vector添加元素,那么这种方法可能比调用std::sort更有效。然而,如果向std::vector频繁添加元素,那么污染的方法可能表现更好。

创建容器包装器的一个关键优势是,可以实现和测试这些类型的优化,而不必更改依赖于容器本身的代码。可以实现、测试和比较这两种实现(或其他实现),以确定哪种优化最适合您的特定需求,而使用容器的代码永远不会改变。这不仅使代码更清晰,而且这种增加的封装打击了面向对象设计的核心,确保代码中的每个对象只有一个目的。对于容器包装器来说,其目的是封装维护std::vector的排序顺序的操作。

为了完整起见,我们还将添加push_back()emplace_back()版本,就像std::vector一样:

    template<typename... Args>
    void emplace_back(Args&&... args)
    {
        m_v.emplace_back(std::forward<Args>(args)...);
        std::sort(m_v.begin(), m_v.end(), compare_type());

        std::cout << "3\n";
    }

std::vector等效的emplace_back()函数的区别在于,我们的版本不返回对创建的元素的引用。这是因为排序会使引用无效,从而无法返回有效的引用。

push/emplace 的用法

最后,让我们测试我们的push_back()emplace函数,以确保它们被正确调用,如下所示:

int main(void)
{
    int i = 42;
    container<int> c;

    c.push_back(i);
    c.push_back(std::move(i));
    c.emplace_back(42);

    return 0;
}

如前面的代码片段所示,我们调用了push_back()的每个版本以及emplace_back()函数,以确保它们被正确调用,结果如下:

我们可以进一步添加更好的测试数据到我们的测试容器,如下所示:

int main(void)
{
    int i = 42;
    container<int> c;

    c.emplace_back(4);
    c.push_back(i);
    c.emplace_back(15);
    c.push_back(8);
    c.emplace_back(23);
    c.push_back(std::move(16));

    return 0;
}

如前面的代码片段所示,我们向我们的向量添加整数4421582316。在下一个示例中,我们将从std::set中窃取 API,以提供更好的pushemplaceAPI 给我们的容器,以及一个输出函数,以更好地了解std::vector包含的内容以及其包含元素的顺序。

向 std::set API 添加相关部分

在本示例中,我们将学习如何从std::set中添加 API 到我们在第一个示例中创建的自定义容器。具体来说,我们将学习为什么std::vector::push_back()std::vector::emplace_back()在与始终保持内部元素排序顺序的自定义容器一起使用时是没有意义的。

准备工作

开始之前,请确保满足所有技术要求,包括安装 Ubuntu 18.04 或更高版本,并在终端窗口中运行以下命令:

> sudo apt-get install build-essential git cmake

这将确保您的操作系统具有编译和执行本示例中的示例所需的适当工具。完成后,打开一个新的终端。我们将使用这个终端来下载、编译和运行我们的示例。

操作步骤...

按照以下步骤尝试这个示例:

  1. 从新的终端运行以下命令以下载源代码:
> cd ~/
> git clone https://github.com/PacktPublishing/Advanced-CPP-CookBook.git
> cd Advanced-CPP-CookBook/chapter08
  1. 编译源代码,运行以下命令:
> cmake .
> make recipe02_examples
  1. 源代码编译完成后,可以通过运行以下命令来执行本示例中的每个示例:
> ./recipe02_example01 
elements: 4 
elements: 4 42 
elements: 4 15 42 
elements: 4 8 15 42 
elements: 4 8 15 23 42 
elements: 4 8 15 16 23 42 

在下一节中,我们将逐步介绍每个示例,并解释每个示例程序的作用,以及它与本示例中所教授的课程的关系。

工作原理...

在本章的第一个示例中,我们创建了一个自定义容器包装器,模拟了std::vector,但确保向量中的元素始终保持排序顺序,包括添加std::vector::push_back()函数和std::vector::emplace_back()函数。在本示例中,我们将向我们的自定义容器添加std::set::insert()std::set::emplace()函数。

由于我们的容器包装器始终确保std::vector处于排序状态,因此无论将元素添加到向量的前端、后端还是中间,都没有区别。无论将元素添加到向量的哪个位置,都必须在访问向量之前对其进行排序,这意味着无论将元素添加到哪个位置,其添加顺序都可能会发生变化。

对于添加元素的位置,我们不必担心,这与std::set类似。std::set向集合添加元素,然后根据被测试的元素是否是集合的成员,稍后返回truefalsestd::set提供了insert()emplace()函数来向集合添加元素。让我们向我们的自定义容器添加这些 API,如下所示:

    void insert(const T &value)
    {
        push_back(value);
    }

    void insert(T &&value)
    {
        push_back(std::move(value));
    }

    template<typename... Args>
    void emplace(Args&&... args)
    {
        emplace_back(std::forward<Args>(args)...);
    }

如前面的代码片段所示,我们添加了一个insert()函数(包括复制和移动),以及一个emplace()函数,它们只是调用它们的push_back()emplace_back()等效函数,确保正确转发传递给这些函数的参数。这些 API 与我们在上一个教程中添加的 API 之间唯一的区别是函数本身的名称。

尽管这样的改变可能看起来微不足道,但这对于重新定义容器的 API 与用户之间的概念是很重要的。push_back()emplace_back()函数表明元素被添加到向量的末尾,但实际上并非如此。相反,它们只是简单地添加到std::vector中,并且std::vector的顺序会根据添加的元素值而改变。因此,需要push_back()emplace_back()函数,但应将它们重命名或标记为私有,以确保用户只使用insert()emplace()版本来正确管理期望。在编写自己的容器时(即使是包装器),重要的是要遵循最少惊讶原则,以确保用户使用的 API 将按照 API 可能暗示的方式工作。

使用迭代器

在本教程中,我们将学习如何为我们在第一个教程中开始的自定义容器添加迭代器支持,该容器包装了一个std::vector,确保其内容始终保持排序顺序。

为了添加迭代器支持,我们将学习如何转发std::vector已提供的迭代器(我们不会从头开始实现迭代器,因为这超出了本书的范围,从头开始实现容器非常困难)。

准备工作

开始之前,请确保满足所有技术要求,包括安装 Ubuntu 18.04 或更高版本,并在终端窗口中运行以下命令:

> sudo apt-get install build-essential git cmake

这将确保您的操作系统具有编译和执行本教程中示例所需的正确工具。完成后,打开一个新的终端。我们将使用此终端来下载、编译和运行我们的示例。

操作步骤

要尝试本教程,需要按照以下步骤进行:

  1. 从新终端运行以下命令以下载源代码:
> cd ~/
> git clone https://github.com/PacktPublishing/Advanced-CPP-CookBook.git
> cd Advanced-CPP-CookBook/chapter08
  1. 要编译源代码,请运行以下命令:
> cmake .
> make recipe03_examples
  1. 源代码编译完成后,可以通过运行以下命令来执行本教程中的每个示例:
> ./recipe03_example01 
elements: 4 8 15 16 23 42 

> ./recipe03_example02 
elements: 4 8 15 16 23 42 
elements: 4 8 15 16 23 42 
elements: 42 23 16 15 8 4 
elements: 1 4 8 15 16 23 42 
elements: 4 8 15 16 23 42 
elements: 

在下一节中,我们将逐个介绍这些示例,并解释每个示例程序的作用以及它与本教程中所教授的课程的关系。

工作原理

我们的自定义容器包装的std::vector已经提供了一个有效的迭代器实现,可以用于处理我们的容器。但是,我们需要转发std::vector提供的特定部分 API,以确保迭代器正常工作,包括关键的 C++特性,如基于范围的 for 循环。

首先,让我们向我们的自定义容器添加std::vector提供的最后一个剩余构造函数:

    template <typename Iter>
    container(
        Iter first,
        Iter last,
        const Allocator &alloc = Allocator()
    ) :
        m_v(first, last, alloc)
    {
        std::sort(m_v.begin(), m_v.end(), compare_type());
    }

如前面的代码片段所示,我们得到的迭代器类型未定义。迭代器可以来自我们容器的另一个实例,也可以直接来自std::vector,后者不会按排序顺序存储其元素。即使迭代器来自我们自定义容器的一个实例,迭代器存储元素的顺序可能与容器元素的顺序不同。因此,我们必须在初始化后对std::vector进行排序。

除了构造之外,我们的自定义容器还必须包括std::vector提供的基于迭代器的别名,因为这些别名对于容器与 C++ API 的正确工作是必需的。以下是一个示例代码片段:

    using const_iterator = typename vector_type::const_iterator;
    using const_reverse_iterator = typename vector_type::const_reverse_iterator;

正如前面的代码片段所示,与第一个示例中定义的别名一样,我们只需要前向声明std::vector已经提供的别名,以便我们的自定义容器也可以利用它们。不同之处在于,我们不包括这些迭代器别名的非 const 版本。由于我们的自定义容器必须始终保持有序,我们必须限制用户直接修改迭代器内容的能力,因为这可能导致更改容器元素的顺序,而我们的容器无法根据需要重新排序。相反,对容器的修改应通过使用insert()emplace()erase()来进行。

基于 C++模板的函数依赖于这些别名来正确实现它们的功能,这也包括基于范围的 for 循环。

最后,有一系列基于迭代器的成员函数,std::vector提供了这些函数,也应该通过我们的自定义容器进行转发。以下代码描述了这一点:

    const_iterator begin() const noexcept
    {
        return m_v.begin();
    }

    const_iterator cbegin() const noexcept
    {
        return m_v.cbegin();
    }

第一组成员函数是begin()函数,它提供表示std::vector中第一个元素的迭代器。与别名一样,我们不转发这些成员函数的非 const 版本。此外,出于完整性考虑,我们包括这些函数的c版本。在 C++17 中,这些是可选的,如果愿意,可以使用std::as_const()代替。接下来的迭代器是end()迭代器,它提供表示std::vector末尾的迭代器(不要与表示std::vector中最后一个元素的迭代器混淆)。以下代码显示了这一点:

    const_iterator end() const noexcept
    {
        return m_v.end();
    }

    const_iterator cend() const noexcept
    {
        return m_v.cend();
    }

正如前面的代码片段所示,与大多数这些成员函数一样,我们只需要将 API 转发到我们的自定义容器封装的私有std::vector。这个过程也可以重复用于rbegin()rend(),它们提供与之前相同的 API,但返回一个反向迭代器,以相反的顺序遍历std::vector

接下来,我们实现基于迭代器的emplace()函数,如下所示:

    template <typename... Args>
    void emplace(const_iterator pos, Args&&... args)
    {
        m_v.emplace(pos, std::forward<Args>(args)...);
        std::sort(m_v.begin(), m_v.end(), compare_type());
    }

尽管提供emplace() API 提供了更完整的实现,但应该注意的是,只有在进一步优化以利用元素添加到容器的预期位置的方式时,它才会有用。这与更好地排序std::vector的方法相结合。

尽管前面的实现是有效的,但它可能与我们在第一个示例中实现的emplace()版本表现类似。由于自定义容器始终保持排序顺序,因此将元素插入std::vector的位置是无关紧要的,因为std::vector的新顺序将改变添加元素的位置。当然,除非位置参数的添加提供了一些额外的支持来更好地优化添加,而我们的实现没有这样做。因此,除非使用pos参数进行优化,前面的函数可能是多余且不必要的。

与前面的emplace()函数一样,我们不尝试返回表示添加到容器的元素的迭代器,因为在排序后,此迭代器将变为无效,并且关于添加到std::vector的内容的信息不足以重新定位迭代器(例如,如果存在重复项,则无法知道实际添加的是哪个元素)。

最后,我们实现了erase函数,如下所示:

    const_iterator erase(const_iterator pos)
    {
        return m_v.erase(pos);
    }

    const_iterator erase(const_iterator first, const_iterator last)
    {
        return m_v.erase(first, last);
    }

emplace()函数不同,从std::vector中移除元素不会改变std::vector的顺序,因此不需要排序。还应该注意的是,我们的erase()函数版本返回const版本。再次强调,这是因为我们无法支持迭代器的非 const 版本。

最后,现在我们有能力访问容器中存储的元素,让我们创建一些测试逻辑,以确保我们的容器按预期工作:

int main(void)
{
    container<int> c{4, 42, 15, 8, 23, 16};

首先,我们将从不带顺序的整数初始化列表创建一个容器。创建完容器后,存储这些元素的std::vector应该是有序的。为了证明这一点,让我们循环遍历容器并输出结果:

    std::cout << "elements: ";

    for (const auto &elem : c) {
        std::cout << elem << ' ';
    }

    std::cout << '\n';

如前面的代码片段所示,我们首先向stdout输出一个标签,然后使用范围 for 循环遍历我们的容器,逐个输出每个元素。最后,在所有元素都输出到stdout后,我们输出一个新行,导致以下输出:

elements: 4 8 15 16 23 42

此输出按预期的顺序排序。

需要注意的是,我们的范围 for 循环必须将每个元素定义为const。这是因为我们不支持迭代器的非 const 版本。任何尝试使用这些迭代器的非 const 版本都会导致编译错误,如下例所示:

    for (auto &elem : c) {
        elem = 42;
    }

上述代码将导致以下编译错误(这是预期的):

/home/user/book/chapter08/recipe03.cpp: In function ‘int main()’:
/home/user/book/chapter08/recipe03.cpp:396:14: error: assignment of read-only reference ‘elem’
  396 | elem = 42;

发生这种编译错误的原因是因为范围 for 循环也可以写成以下形式:

    std::cout << "elements: ";

    for (auto iter = c.begin(); iter != c.end(); iter++) {
        auto &elem = *iter;
        std::cout << elem << ' ';
    }

    std::cout << '\n';

如前面的代码片段所示,元素未标记为const,因为范围 for 循环使用begin()end()成员函数,导致读写迭代器(除非您明确声明为const)。

我们还可以为我们的新emplace()函数创建一个测试,如下所示:

    c.emplace(c.cend(), 1);

    std::cout << "elements: ";
    for (const auto &elem : c) {
        std::cout << elem << ' ';
    }
    std::cout << '\n';

这将产生以下输出:

elements: 1 4 8 15 16 23 42

如前面的输出所示,数字1按预期的顺序被添加到我们的容器中,即使我们告诉容器将我们的元素添加到std::vector的末尾。

我们还可以反转上述操作并验证我们的erase()函数是否正常工作,如下所示:

    c.erase(c.cbegin());

    std::cout << "elements: ";
    for (const auto &elem : c) {
        std::cout << elem << ' ';
    }
    std::cout << '\n';

这将产生以下输出:

elements: 4 8 15 16 23 42

如您所见,新添加的1已成功被移除。

添加 std::vector API 的相关部分

在本文中,我们将通过添加std::vector已经提供的剩余 API 来完成我们在本章前三个示例中构建的自定义容器。在此过程中,我们将删除不合理的 API,或者我们无法支持的 API,因为我们的自定义容器必须保持std::vector中的元素有序。

本文很重要,因为它将向您展示如何正确创建一个包装容器,该容器可用于封装现有容器的逻辑(例如,线程安全,或者在我们的情况下,元素顺序)。

准备工作

开始之前,请确保满足所有技术要求,包括安装 Ubuntu 18.04 或更高版本,并在终端窗口中运行以下命令:

> sudo apt-get install build-essential git cmake

这将确保您的操作系统具有编译和执行本文示例所需的适当工具。完成后,打开一个新的终端。我们将使用此终端来下载、编译和运行我们的示例。

如何做...

按照以下步骤尝试本文:

  1. 从新的终端运行以下命令来下载源代码:
> cd ~/
> git clone https://github.com/PacktPublishing/Advanced-CPP-CookBook.git
> cd Advanced-CPP-CookBook/chapter08
  1. 要编译源代码,请运行以下命令:
> cmake .
> make recipe04_examples
  1. 源代码编译完成后,可以通过运行以下命令来执行本文中的每个示例:
> ./recipe04_example01 
elements: 4 8 15 16 23 42 
elements: 4 8 15 16 23 42 
elements: 4 8 15 16 23 42 
elements: 42 
elements: 4 8 15 16 23 42 
elements: 4 8 15 16 23 42 
c1.at(0): 4
c1.front(): 4
c1.back(): 42
c1.data(): 0xc01eb0
c1.empty(): 0
c1.size(): 6
c1.max_size(): 2305843009213693951
c1.capacity(): 42
c1.capacity(): 6
c1.size(): 0
c1.size(): 42
c1.size(): 0
c1.size(): 42
elements: 4 8 15 16 23 
==: 0
!=: 1
 <: 1
<=: 1
 >: 0
>=: 0

在接下来的部分中,我们将逐个介绍每个示例,并解释每个示例程序的作用以及它与本文教授的课程的关系。

工作原理...

目前,我们的自定义容器能够被构建、添加、迭代和擦除。然而,该容器不支持直接访问容器或支持简单操作,比如std::move()或比较。为了解决这些问题,让我们首先添加缺失的operator=()重载:

    constexpr container &operator=(const container &other)
    {
        m_v = other.m_v;
        return *this;
    }

    constexpr container &operator=(container &&other) noexcept
    {
        m_v = std::move(other.m_v);
        return *this;
    }    

第一个operator=()重载支持复制赋值,而第二个重载支持移动赋值。由于我们只有一个提供适当复制和移动语义的私有成员变量,我们不需要担心自赋值(或移动),因为std::vector函数的复制和移动实现会为我们处理这个问题。

如果您自己的自定义容器有额外的私有元素,可能需要进行自赋值检查。例如,考虑以下代码:

    constexpr container &operator=(container &&other) noexcept
    {
        if (&other == this) {
            return *this;
        }

        m_v = std::move(other.m_v);
        m_something = other.m_something;

        return *this;
    }

剩下的operator=()重载接受一个初始化列表,如下所示:

    constexpr container &operator=(std::initializer_list<T> list)
    {
        m_v = list;
        std::sort(m_v.begin(), m_v.end(), compare_type());

        return *this;
    }

在上面的代码片段中,与初始化列表构造函数一样,我们必须在赋值后重新排序std::vector,因为初始化列表可以以任何顺序提供。

要实现的下一个成员函数是assign()函数。以下代码片段显示了这一点:

    constexpr void assign(size_type count, const T &value)
    {
        m_v.assign(count, value);
    }

    template <typename Iter>
    constexpr void assign(Iter first, Iter last)
    {
        m_v.assign(first, last);
        std::sort(m_v.begin(), m_v.end(), compare_type());
    }

    constexpr void assign(std::initializer_list<T> list)
    {
        m_v.assign(list);
        std::sort(m_v.begin(), m_v.end(), compare_type());
    }

这些函数类似于operator=()重载,但不提供返回值或支持其他功能。让我们看看:

  • 第一个assign()函数用特定的value次数填充std::vector。由于值永远不会改变,std::vector将始终按排序顺序排列,在这种情况下,不需要对列表进行排序。

  • 第二个assign()函数接受与构造函数版本相似的迭代器范围。与该函数类似,传递给此函数的迭代器可以来自原始std::vector或我们自定义容器的另一个实例,但排序顺序不同。因此,我们必须在赋值后对std::vector进行排序。

  • 最后,assign()函数还提供了与我们的operator=()重载相同的初始化列表版本。

还应该注意到,我们已经为每个函数添加了constexpr。这是因为我们自定义容器中的大多数函数只是将调用从自定义容器转发到std::vector,并且在某些情况下调用std::sort()。添加constexpr告诉编译器将代码视为编译时表达式,使其能够在启用优化时(如果可能)优化掉额外的函数调用,确保我们的自定义包装器具有尽可能小的开销。

过去,这种优化是使用inline关键字执行的。在 C++11 中添加的constexpr不仅能够向编译器提供inline提示,还告诉编译器这个函数可以在编译时而不是运行时使用(这意味着编译器可以在代码编译时执行函数以执行自定义的编译时逻辑)。然而,在我们的例子中,std::vector的运行时使用是不可能的,因为需要分配。因此,使用constexpr只是为了优化,在大多数编译器上,inline关键字也会提供类似的好处。

std::vector还支持许多其他函数,例如get_allocator()empty()size()max_size(),所有这些都只是直接转发。让我们专注于直到现在为止从我们的自定义容器中缺失的访问器:

    constexpr const_reference at(size_type pos) const
    {
        return m_v.at(pos);
    }

我们提供的第一个直接访问std::vector的函数是at()函数。与我们的大多数成员函数一样,这是一个直接转发。但与std::vector不同的是,我们没有计划添加std::vector提供的operator[]()重载。at()函数和operator[]()重载之间的区别在于,operator[]()不会检查提供的索引是否在范围内(也就是说,它不会访问std::vector范围之外的元素)。

operator[]()重载的设计类似于标准 C 数组。这个运算符(称为下标运算符)的问题在于缺乏边界检查,这为可靠性和安全性错误进入程序打开了大门。因此,C++核心指南不鼓励使用下标运算符或任何其他形式的指针算术(任何试图通过指针计算数据位置而没有显式边界检查的东西)。

为了防止使用operator[]()重载,我们不包括它。

std::vector一样,我们也可以添加front()back()访问器,如下所示:

    constexpr const_reference front() const
    {
        return m_v.front();
    }

    constexpr const_reference back() const
    {
        return m_v.back();
    }

前面的额外访问器支持获取我们的std::vector中的第一个和最后一个元素。与at()函数一样,我们只支持std::vector已经提供的这些函数的const_reference版本的使用。

现在让我们看一下data()函数的代码片段:

    constexpr const T* data() const noexcept
    {
        return m_v.data();
    }

data()函数也是一样的。我们只能支持这些成员函数的const版本,因为提供这些函数的非 const 版本将允许用户直接访问std::vector,从而使他们能够插入无序数据,而容器无法重新排序。

现在让我们专注于比较运算符。我们首先定义比较运算符的原型,作为我们容器的友元。这是必要的,因为比较运算符通常被实现为非成员函数,因此需要对容器进行私有访问,以比较它们包含的std::vector实例。

例如,考虑以下代码片段:

    template <typename O, typename Alloc>
    friend constexpr bool operator==(const container<O, Alloc> &lhs,
                                     const container<O, Alloc> &rhs);

    template <typename O, typename Alloc>
    friend constexpr bool operator!=(const container<O, Alloc> &lhs,
                                     const container<O, Alloc> &rhs);

    template <typename O, typename Alloc>
    friend constexpr bool operator<(const container<O, Alloc> &lhs,
                                    const container<O, Alloc> &rhs);

    template <typename O, typename Alloc>
    friend constexpr bool operator<=(const container<O, Alloc> &lhs,
                                     const container<O, Alloc> &rhs);

    template <typename O, typename Alloc>
    friend constexpr bool operator>(const container<O, Alloc> &lhs,
                                    const container<O, Alloc> &rhs);

    template <typename O, typename Alloc>
    friend constexpr bool operator>=(const container<O, Alloc> &lhs,
                                     const container<O, Alloc> &rhs);

最后,我们按照以下方式实现比较运算符:

template <typename O, typename Alloc>
bool constexpr operator==(const container<O, Alloc> &lhs,
                          const container<O, Alloc> &rhs)
{
    return lhs.m_v == rhs.m_v;
}

template <typename O, typename Alloc>
bool constexpr operator!=(const container<O, Alloc> &lhs,
                          const container<O, Alloc> &rhs)
{
    return lhs.m_v != rhs.m_v;
}

与成员函数一样,我们只需要将调用转发到std::vector,因为没有必要实现自定义逻辑。剩下的比较运算符也是一样。

例如,我们可以按照以下方式实现><>=<=比较运算符:

template <typename O, typename Alloc>
bool constexpr operator<(const container<O, Alloc> &lhs,
                         const container<O, Alloc> &rhs)
{
    return lhs.m_v < rhs.m_v;
}

template <typename O, typename Alloc>
bool constexpr operator<=(const container<O, Alloc> &lhs,
                          const container<O, Alloc> &rhs)
{
    return lhs.m_v <= rhs.m_v;
}

template <typename O, typename Alloc>
bool constexpr operator>(const container<O, Alloc> &lhs,
                         const container<O, Alloc> &rhs)
{
    return lhs.m_v > rhs.m_v;
}

template <typename O, typename Alloc>
bool constexpr operator>=(const container<O, Alloc> &lhs,
                          const container<O, Alloc> &rhs)
{
    return lhs.m_v >= rhs.m_v;
}

就是这样!这就是通过利用现有容器来实现自己的容器的方法。

正如我们所看到的,在大多数情况下,除非你需要的容器无法使用 C++标准模板库已经提供的容器来实现,否则没有必要从头开始实现一个容器。

使用这种方法,不仅可以创建自己的容器,更重要的是可以将代码中重复的功能封装到一个单独的容器中,这样可以独立测试和验证。这不仅提高了应用程序的可靠性,而且还使其更易于阅读和维护。

在下一章中,我们将探讨如何在 C++中使用智能指针。

第九章:探索类型擦除

在本章中,您将学习类型擦除(也称为类型擦除)是什么,以及如何在自己的应用程序中使用它。本章很重要,因为类型擦除提供了在不需要对象共享公共基类的情况下使用不同类型对象的能力。

本章从简单解释类型擦除开始,解释了在 C 语言中类型擦除的工作原理,以及如何在 C++中使用继承来执行类型擦除。下一个示例将提供使用 C++模板的不同方法来进行类型擦除,这将教会您如何使用 C++概念来定义类型的规范,而不是类型本身。

接下来,我们将学习经典的 C++类型擦除模式。本示例将教会您擦除类型信息的技能,从而能够创建类型安全的通用代码。最后,我们将通过一个全面的示例来结束,该示例使用类型擦除来实现委托模式,这是一种提供包装任何类型的可调用对象的能力的模式,并且被诸如 ObjC 等语言广泛使用。

本章的示例如下:

  • 如何使用继承来擦除类型

  • 使用 C++模板编写通用函数

  • 学习 C++类型擦除模式

  • 实现委托模式

技术要求

要编译和运行本章中的示例,您必须具有对运行 Ubuntu 18.04 的计算机的管理访问权限,并且具有正常的互联网连接。在运行这些示例之前,您必须安装以下内容:

> sudo apt-get install build-essential git cmake

如果这安装在 Ubuntu 18.04 以外的任何操作系统上,则需要 GCC 7.4 或更高版本和 CMake 3.6 或更高版本。

本章的代码文件可以在github.com/PacktPublishing/Advanced-CPP-CookBook/tree/master/chapter09找到。

如何使用继承来擦除类型

在本示例中,我们将学习如何使用继承来擦除类型。当讨论类型擦除时,通常不考虑继承,但实际上,继承是 C++中最常见的类型擦除形式。本示例很重要,因为它将讨论类型擦除是什么,以及为什么它在日常应用中非常有用,而不仅仅是简单地移除类型信息——这在 C 中很常见。

准备工作

在开始之前,请确保满足所有技术要求,包括安装 Ubuntu 18.04 或更高版本,并在终端窗口中运行以下命令:

> sudo apt-get install build-essential git cmake

这将确保您的操作系统具有正确的工具来编译和执行本示例中的示例。完成后,打开一个新的终端。我们将使用这个终端来下载、编译和运行我们的示例。

如何做...

让我们尝试按照以下步骤进行本示例:

  1. 从新的终端运行以下命令以下载源代码:
> cd ~/
> git clone https://github.com/PacktPublishing/Advanced-CPP-CookBook.git
> cd Advanced-CPP-CookBook/chapter09
  1. 要编译源代码,请运行以下命令:
> cmake .
> make recipe01_examples
  1. 一旦源代码编译完成,您可以通过运行以下命令来执行本示例中的每个示例:
> ./recipe01_example01 
1
0

在接下来的部分,我们将逐个介绍这些示例,并解释每个示例程序的作用以及它与本示例中所教授的课程的关系。

工作原理...

类型擦除(或类型擦除)简单地是移除、隐藏或减少有关对象、函数等的类型信息。在 C 语言中,类型擦除经常被使用。看看这个例子:

int array[10];
memset(array, 0, sizeof(array));

在上面的例子中,我们创建了一个包含10个元素的数组,然后使用memset()函数将数组清零。在 C 中,memset()函数看起来像这样:

void *memset(void *ptr, int value, size_t num)
{
    size_t i;
    for (i = 0; i < num; i++) {
        ((char *)ptr)[i] = value;    
    }

    return ptr;
}

在上面的代码片段中,memset()函数的第一个参数是void*。然而,在我们之前的例子中,数组是一个整数数组。memset()函数实际上并不关心你提供的是什么类型,只要你提供了指向该类型的指针和表示该类型总字节数的大小。然后,memset()函数将提供的指针强制转换为表示字节的类型(在 C 中通常是char或无符号char),然后逐字节设置类型的值。

在 C 中使用void*是一种类型擦除的形式。在 C++中,这种类型(双关语)的擦除通常是不鼓励的,因为要恢复类型信息的唯一方法是使用dynamic_cast(),这很慢(需要运行时类型信息查找)。尽管有许多种方法可以在 C++中执行类型擦除而不需要void*,让我们专注于继承。

继承在大多数文献中通常不被描述为类型擦除,但它很可能是最广泛使用的形式之一。为了更好地探讨这是如何工作的,让我们看一个常见的例子。假设我们正在创建一个游戏,其中用户可以选择多个超级英雄。每个超级英雄在某个时候都必须攻击坏家伙,但超级英雄如何攻击坏家伙因英雄而异。

例如,考虑以下代码片段:

class spiderman
{
public:
    bool attack(int x, int) const
    {
        return x == 0 ? true : false;
    }
};

如上所示,在我们的第一个英雄中,不关心坏家伙是在地面上还是在空中(也就是说,无论坏家伙的垂直距离如何,英雄都能成功击中坏家伙),但如果坏家伙不在特定的水平位置,英雄就会错过坏家伙。同样,我们可能还有另一个英雄如下:

class captain_america
{
public:
    bool attack(int, int y) const
    {
        return y == 0 ? true : false;
    }
};

第二个英雄与我们的第一个完全相反。这个英雄可以成功地击中地面上的坏家伙,但如果坏家伙在地面以上的任何地方,他就会错过(英雄可能无法到达他们)。

在下面的例子中,两个超级英雄同时与坏家伙战斗:

    for (const auto &h : heroes) {
        std::cout << h->attack(0, 42) << '\n';
    }

虽然我们可以在战斗中一个一个地召唤每个超级英雄,但如果我们可以只循环遍历每个英雄并检查哪个英雄击中了坏家伙,哪个英雄错过了坏家伙,那将更加方便。

在上面的例子中,我们有一个假想的英雄数组,我们循环遍历,检查哪个英雄击中了,哪个英雄错过了。在这个例子中,我们不关心英雄的类型(也就是说,我们不关心英雄是否特别是我们的第一个还是第二个英雄),我们只关心每个英雄实际上是一个英雄(而不是一个无生命的物体),并且英雄能够攻击坏家伙。换句话说,我们需要一种方法来擦除每个超级英雄的类型,以便我们可以将两个英雄放入单个数组中(除非每个英雄都是相同的,否则这是不可能的)。

正如你可能已经猜到的那样,在 C++中实现这一点的最常见方法是使用继承(但正如我们将在本章后面展示的那样,这并不是唯一的方法)。首先,我们必须定义一个名为hero的基类,每个英雄都将从中继承,如下所示:

class hero
{
public:
    virtual ~hero() = default;
    virtual bool attack(int, int) const = 0;
};

在我们的例子中,每个英雄之间唯一的共同函数是它们都可以攻击坏家伙,attack()函数对所有英雄都是相同的。因此,我们创建了一个纯虚基类,其中包含一个名为attack()的单个纯虚函数,每个英雄都必须实现。还应该注意的是,为了使一个类成为纯虚类,所有成员函数必须设置为0,并且类的析构函数必须显式标记为virtual

现在我们已经定义了什么是英雄,我们可以修改我们的英雄,使其继承这个纯虚基类,如下所示:

class spiderman : public hero
{
public:
    bool attack(int x, int) const override
    {
        return x == 0 ? true : false;
    }
};

class captain_america : public hero
{
public:
    bool attack(int, int y) const override
    {
        return y == 0 ? true : false;
    }
};

如上所示,两个英雄都继承了英雄的纯虚定义,并根据需要重写了attack()函数。通过这种修改,我们现在可以按以下方式创建我们的英雄列表:

int main(void)
{
    std::array<std::unique_ptr<hero>, 2> heros {
        std::make_unique<spiderman>(),
        std::make_unique<captain_america>()
    };

    for (const auto &h : heros) {
        std::cout << h->attack(0, 42) << '\n';
    }

    return 0;
}

从上面的代码中,我们观察到以下内容:

  • 我们创建了一个hero指针数组(使用std::unique_ptr来存储英雄的生命周期,这是下一章将讨论的一个主题)。

  • 然后,该数组被初始化为包含两个英雄(每个英雄一个)。

  • 最后,我们循环遍历每个英雄,看英雄是否成功攻击坏人或者错过。

  • 当调用hero::attack()函数时,调用会自动路由到正确的spiderman::attack()captain_america::attack()函数,通过继承来实现。

该数组以类型安全的方式擦除了每个英雄的类型信息,将每个英雄放入单个容器中。

使用 C++模板编写通用函数

在本示例中,我们将学习如何使用 C++模板来擦除(或忽略)类型信息。您将学习如何使用 C++模板来实现 C++概念,以及这种类型擦除在 C++标准库中的使用。这个示例很重要,因为它将教会您如何更好地设计您的 API,使其不依赖于特定类型(或者换句话说,如何编写通用代码)。

准备工作

开始之前,请确保满足所有技术要求,包括安装 Ubuntu 18.04 或更高版本,并在终端窗口中运行以下命令:

> sudo apt-get install build-essential git cmake

这将确保您的操作系统具有适当的工具来编译和执行本示例中的示例。完成后,打开一个新的终端。我们将使用这个终端来下载、编译和运行我们的示例。

如何做...

让我们按照以下步骤尝试这个示例:

  1. 从新的终端运行以下命令以下载源代码:
> cd ~/
> git clone https://github.com/PacktPublishing/Advanced-CPP-CookBook.git
> cd Advanced-CPP-CookBook/chapter09
  1. 要编译源代码,请运行以下命令:
> cmake .
> make recipe02_examples
  1. 源代码编译后,可以通过运行以下命令来执行本文中的每个示例:
> ./recipe02_example01 
hero won fight
hero lost the fight :(

在接下来的部分中,我们将逐个步骤地介绍每个示例,并解释每个示例程序的作用以及它与本示例中所教授的课程的关系。

工作原理...

C++最古老和最广泛使用的功能之一是 C++模板。与继承一样,C++模板通常不被描述为一种类型擦除,但它们实际上是。类型擦除只不过是删除或在这种情况下忽略类型信息的行为。

然而,与 C 语言不同,C++中的类型擦除通常试图避免删除类型信息,而是绕过类型的严格定义,同时保留类型安全。实现这一点的一种方法是通过使用 C++模板。为了更好地解释这一点,让我们从一个 C++模板的简单示例开始:

template<typename T>
T pow2(T t)
{
    return t * t;
}

在上面的示例中,我们创建了一个简单的函数,用于计算任何给定输入的平方。例如,我们可以这样调用这个函数:

std::cout << pow2(42U) << '\n'
std::cout << pow2(-1) << '\n'

当编译器看到pow2()函数的使用时,它会在幕后自动生成以下代码:

unsigned pow2(unsigned t)
{
    return t * t;
}

int pow2(int t)
{
    return t * t;
}

在上面的代码片段中,编译器创建了pow2()函数的两个版本:一个接受无符号值并返回无符号值,另一个接受整数并返回整数。编译器创建了这两个版本,是因为我们第一次使用pow2()函数时,我们提供了一个无符号值,而第二次使用pow2()函数时,我们提供了int

就我们的代码而言,我们实际上并不关心函数提供的类型是什么,只要提供的类型能够成功执行operator*()。换句话说,pow2()函数的使用者和pow2()函数的作者都安全地忽略(或擦除)了从概念上传递给函数的类型信息。然而,编译器非常清楚正在提供的类型,并且必须根据需要安全地处理每种类型。

这种类型擦除形式在 API 的规范处执行擦除,在 C++中,这种规范被称为概念。与大多数 API 不同,后者规定了输入和输出类型(例如,sleep()函数接受一个无符号整数,只接受无符号整数),概念特别忽略类型,而是定义了给定类型必须提供的属性。

例如,前面的pow2()函数有以下要求:

  • 提供的类型必顺要么是整数类型,要么提供operator *()

  • 提供的类型必须是可复制构造或可移动构造的。

如前面的代码片段所示,pow2()函数不关心它所接收的类型,只要所提供的类型满足一定的最小要求。让我们来看一个更复杂的例子,以演示 C++模板如何被用作类型擦除的一种形式。假设我们有两个不同的英雄在与一个坏家伙战斗,每个英雄都提供了攻击坏家伙的能力,如下所示:

class spiderman
{
public:
    bool attack(int x, int) const
    {
        return x == 0 ? true : false;
    }
};

class captain_america
{
public:
    bool attack(int, int y) const
    {
        return y == 0 ? true : false;
    }
};

如前面的代码片段所示,每个英雄都提供了攻击坏家伙的能力,但除了两者都提供具有相同函数签名的attack()函数之外,两者没有任何共同之处。我们也无法为每个英雄添加继承(也许我们的设计无法处理继承所增加的额外vTable开销,或者英雄定义是由其他人提供的)。

现在假设我们有一个复杂的函数,必须为每个英雄调用attack()函数。我们可以为每个英雄编写相同的逻辑(即手动复制逻辑),或者我们可以编写一个 C++模板函数来处理这个问题,如下所示:

template<typename T>
auto attack(const T &t, int x, int y)
{
    if (t.attack(x, y)) {
        std::cout << "hero won fight\n";
    }
    else {
        std::cout << "hero lost the fight :(\n";
    }
}

如前面的代码片段所示,我们可以利用 C++模板的类型擦除特性,将我们的攻击逻辑封装到一个单一的模板函数中。前面的代码不关心所提供的类型是什么,只要该类型提供了一个接受两个整数类型并返回一个整数类型(最好是bool,但任何整数都可以)的attack()函数。换句话说,只要所提供的类型符合约定的概念,这个模板函数就会起作用,为编译器提供一种处理类型特定逻辑的方法。

我们可以按照以下方式调用前面的函数:

int main(void)
{
    attack(spiderman{}, 0, 42);
    attack(captain_america{}, 0, 42);

    return 0;
}

这将产生以下输出:

尽管这个示例展示了 C++模板如何被用作类型擦除的一种形式(至少用于创建概念的规范),但是当讨论类型擦除时,有一种特定的模式称为类型擦除模式或者只是类型擦除。在下一个示例中,我们将探讨如何利用我们在前两个示例中学到的知识来擦除类型信息,同时仍然支持诸如容器之类的简单事物。

还有更多...

在这个示例中,我们学习了如何使用概念来忽略(或擦除)特定类型的知识,而是要求类型实现一组最小的特性。这些特性可以使用 SFINAE 来强制执行,这是我们在第四章中更详细讨论的一个主题,使用模板进行通用编程

另请参阅

在第十三章中,奖励-使用 C++20 功能,我们还将讨论如何使用 C++20 新增的功能来执行概念的强制执行。

学习 C++类型擦除模式

在本菜谱中,我们将学习 C++中类型擦除模式是什么,以及我们如何利用它来通用地擦除类型信息,而不会牺牲类型安全性或要求我们的类型继承纯虚拟基类。这个菜谱很重要,因为类型擦除模式在 C++标准库中被大量使用,并提供了一种简单的方式来封装不共享任何共同之处的数据类型,除了提供一组类似的 API,同时还支持诸如容器之类的东西。

准备工作

开始之前,请确保满足所有技术要求,包括安装 Ubuntu 18.04 或更高版本,并在终端窗口中运行以下命令:

> sudo apt-get install build-essential git cmake

这将确保您的操作系统具有编译和执行本菜谱中示例所需的适当工具。完成后,打开一个新的终端。我们将使用这个终端来下载、编译和运行我们的示例。

如何做...

让我们尝试以下步骤来制作这个菜谱:

  1. 从一个新的终端中,运行以下命令来下载源代码:
> cd ~/
> git clone https://github.com/PacktPublishing/Advanced-CPP-CookBook.git
> cd Advanced-CPP-CookBook/chapter09
  1. 编译源代码,请运行以下命令:
> cmake .
> make recipe03_examples
  1. 源代码编译完成后,您可以通过运行以下命令来执行本菜谱中的每个示例:
> ./recipe03_example01 
1
0

在下一节中,我们将逐个介绍这些示例,并解释每个示例程序的作用,以及它与本菜谱中所教授的课程的关系。

工作原理...

当我们通常考虑 C++类型擦除时,这就是我们想到的例子。当我们必须将一组对象视为相关对象使用时,可能并不共享一个共同的基类(也就是说,它们要么不使用继承,要么如果使用继承,可能它们不继承自相同的一组类)时,就需要类型擦除模式。

例如,假设我们有以下类:

class spiderman
{
public:
    bool attack(int x, int) const
    {
        return x == 0 ? true : false;
    }
};

class captain_america
{
public:
    bool attack(int, int y) const
    {
        return y == 0 ? true : false;
    }
};

如前面的代码片段所示,每个类定义了不同类型的英雄。我们想要做的事情如下:

for (const auto &h : heros) {
    // something
}

问题是,每个类都不继承自相似的基类,所以我们不能只创建每个类的实例并将它们添加到std::array中,因为编译器会抱怨这些类不相同。我们可以在std::array中存储每个类的原始void *指针,但是当使用void *时,我们将不得不使用dynamic_cast()来将其转换回每种类型以执行任何有用的操作,如下所示:

    std::array<void *, 2> heros {
        new spiderman,
        new captain_america
    };

    for (const auto &h : heros) {
        if (ptr = dynamic_cast<spiderman>(ptr)) {
            // something
        }

        if (ptr = dynamic_cast<captain_america>(ptr)) {
            // something
        }
    }

使用void *是一种类型擦除的形式,但这远非理想,因为使用dynamic_cast()很慢,每添加一种新类型都只会增加if语句的数量,而且这种实现远非符合 C++核心指南。

然而,还有另一种方法可以解决这个问题。假设我们希望运行attack()函数,这个函数在每个英雄类之间是相同的(也就是说,每个英雄类至少遵循一个共享概念)。如果每个类都使用了以下基类,我们可以使用继承,如下所示:

class base
{
public:
    virtual ~base() = default;
    virtual bool attack(int, int) const = 0;
};

问题是,我们的英雄类没有继承这个基类。因此,让我们创建一个继承它的包装器类,如下所示:

template<typename T>
class wrapper :
    public base
{
    T m_t;

public:
    bool attack(int x, int y) const override
    {
        return m_t.attack(x, y);
    }
};

如前面的代码片段所示,我们创建了一个模板包装类,它继承自我们的基类。这个包装器存储给定类型的实例,然后覆盖了在纯虚拟基类中定义的attack()函数,该函数将调用转发给包装器存储的实例。

现在,我们可以创建我们的数组,如下所示:

    std::array<std::unique_ptr<base>, 2> heros {
        std::make_unique<wrapper<spiderman>>(),
        std::make_unique<wrapper<captain_america>>()
    };

std::array存储了指向我们基类的std::unique_ptr,然后我们使用每种需要的类型创建我们的包装器类(它继承自基类),以存储在数组中。编译器为我们需要存储在数组中的每种类型创建了包装器的版本,由于包装器继承了基类,无论我们给包装器什么类型,数组总是可以按需存储结果包装器。

现在,我们可以从这个数组中执行以下操作:

    for (const auto &h : heros) {
        std::cout << h->attack(0, 42) << '\n';
    }

就是这样:C++中的类型擦除。这种模式利用 C++模板,即使对象本身没有直接使用继承,也可以给对象赋予继承的相同属性。

使用类型擦除实现委托

在这个示例中,我们将学习如何实现委托模式,这是一个已经存在多年的模式(并且被一些其他语言,比如 ObjC,广泛使用)。这个示例很重要,因为它将教会你什么是委托,以及如何在你自己的应用程序中利用这种模式,以提供更好的可扩展性,而不需要你的 API 使用继承。

准备工作

在开始之前,请确保满足所有技术要求,包括安装 Ubuntu 18.04 或更高版本,并在终端窗口中运行以下命令:

> sudo apt-get install build-essential git cmake

这将确保您的操作系统具有编译和执行本示例中的示例所需的适当工具。完成后,打开一个新的终端。我们将使用这个终端来下载、编译和运行我们的示例。

如何做...

让我们按照以下步骤尝试这个示例:

  1. 从一个新的终端中,运行以下命令来下载源代码:
> cd ~/
> git clone https://github.com/PacktPublishing/Advanced-CPP-CookBook.git
> cd Advanced-CPP-CookBook/chapter09
  1. 要编译源代码,请运行以下命令:
> cmake .
> make recipe04_examples
  1. 一旦源代码编译完成,您可以通过运行以下命令执行本示例中的每个示例:
> ./recipe04_example01
1
0

> ./recipe04_example02
1
0

> ./recipe04_example03
1
0

> ./recipe04_example04
0
1
0

在下一节中,我们将逐个介绍这些示例,并解释每个示例程序的作用以及它与本示例中所教授的课程的关系。

它是如何工作的...

如果你曾经读过一本关于 C++的书,你可能已经看过苹果和橙子的例子,它演示了面向对象编程的工作原理。思路如下:

  • 苹果是一种水果。

  • 橙子是一种水果。

  • 苹果不是橙子,但两者都是水果。

这个例子旨在教你如何使用继承将代码组织成逻辑对象。一个苹果和一个橙子共享的逻辑被写入一个叫做fruit的对象中,而特定于苹果或橙子的逻辑被写入继承自基类fruitappleorange对象中。

这个例子也展示了如何扩展水果的功能。通过对水果进行子类化,我可以创建一个苹果,它能够做比fruit基类更多的事情。这种扩展类功能的想法在 C++中很常见,通常我们会考虑使用继承来实现它。在这个示例中,我们将探讨如何在不需要苹果或橙子使用继承的情况下实现这一点,而是使用一种称为委托的东西。

假设你正在创建一个游戏,并希望实现一个英雄和坏人在战斗中战斗的战场。在代码的某个地方,战斗中的每个英雄都需要攻击坏人。问题是英雄在战斗中来来去去,因为他们需要时间恢复,所以你真的需要维护一个能够攻击坏人的英雄列表,并且你只需要循环遍历这个动态变化的英雄列表,看看他们的攻击是否成功。

每个英雄都可以存储一个子类化共同基类的英雄列表,然后运行一个attack()函数,每个英雄都会重写,但这将需要使用继承,这可能不是期望的。我们也可以使用类型擦除模式来包装每个英雄,然后存储指向我们包装器的基类的指针,但这将特定于我们的attack()函数,并且我们相信将需要其他这些类型的扩展的情况。

进入委托模式,这是类型擦除模式的扩展。使用委托模式,我们可以编写如下代码:

int main(void)
{
    spiderman s;
    captain_america c;

    std::array<delegate<bool(int, int)>, 3> heros {
        delegate(attack),
        delegate(&s, &spiderman::attack),
        delegate(&c, &captain_america::attack)
    };

    for (auto &h : heros) {
        std::cout << h(0, 42) << '\n';
    }

    return 0;
}

如前面的代码片段所示,我们定义了两个不同的类的实例,然后创建了一个存储三个委托的数组。委托的模板参数采用bool(int, int)的函数签名,而委托本身似乎是从函数指针以及我们之前创建的类实例的两个成员函数指针创建的。然后我们能够循环遍历每个委托并调用它们,有效地独立调用函数指针和每个成员函数指针。

委托模式提供了将不同的可调用对象封装到一个具有共同类型的单个对象中的能力,该对象能够调用可调用对象,只要它们共享相同的函数签名。更重要的是,委托可以封装函数指针和成员函数指针,为 API 的用户提供了必要时存储私有状态的能力。

为了解释这是如何工作的,我们将从简单的开始,然后逐步构建我们的示例,直到达到最终实现。让我们从一个基类开始:

template<
    typename RET,
    typename... ARGS
    >
class base
{
public:
    virtual ~base() = default;
    virtual RET func(ARGS... args) = 0;
};

如前面的代码片段所示,我们创建了一个纯虚基类的模板。模板参数是RET(定义返回值)和ARGS...(定义可变参数列表)。然后我们创建了一个名为func()的函数,它接受我们的参数列表并返回模板返回类型。

接下来,让我们定义一个从基类继承的包装器,使用类型擦除模式(如果您还没有阅读之前的示例,请现在阅读):

template<
    typename T,
    typename RET,
    typename... ARGS
    >
class wrapper :
    public base<RET, ARGS...>
{
    T m_t{};
    RET (T::*m_func)(ARGS...);

public:

    wrapper(RET (T::*func)(ARGS...)) :
        m_func{func}
    { }

    RET func(ARGS... args) override
    {
        return std::invoke(m_func, &m_t, args...);
    }
};

就像类型擦除模式一样,我们有一个包装器类,它存储我们的类型的实例,然后提供包装器可以调用的函数。不同之处在于可以调用的函数不是静态定义的,而是由提供的模板参数定义的。此外,我们还存储具有相同函数签名的函数指针,该函数指针由包装器的构造函数初始化,并在func()函数中使用std::invoke调用。

与典型的类型擦除示例相比,这个额外的逻辑提供了定义我们希望从我们在包装器中存储的对象中调用的任何函数签名的能力,而不是提前定义(意味着我们希望调用的函数可以在运行时而不是编译时确定)。

然后我们可以创建我们的委托类如下:

template<
    typename RET,
    typename... ARGS
    >
class delegate
{
    std::unique_ptr<base<RET, ARGS...>> m_wrapper;

public:

    template<typename T>
    delegate(RET (T::*func)(ARGS...)) :
        m_wrapper{
            std::make_unique<wrapper<T, RET, ARGS...>>(func)
        }
    { }

    RET operator()(ARGS... args)
    {
        return m_wrapper->func(args...);
    }
};

与类型擦除模式一样,我们将指针存储在包装器中,该包装器是从委托的构造函数中创建的。要注意的重要细节是T类型在委托本身中未定义。相反,T类型仅在创建委托时才知道,用于创建包装器的实例。这意味着每个委托实例都是相同的,即使委托存储了包装不同类型的包装器。这使我们可以像下面这样使用委托。

假设我们有两个英雄,它们没有共同的基类,但提供了相同签名的attack()函数:

class spiderman
{
public:
    bool attack(int x, int)
    {
        return x == 0 ? true : false;
    }
};

class captain_america
{
public:
    bool attack(int, int y)
    {
        return y == 0 ? true : false;
    }
};

我们可以利用我们的委托类来存储我们的英雄类的实例,并调用它们的攻击函数如下:

int main(void)
{
    std::array<delegate<bool, int, int>, 2> heros {
        delegate(&spiderman::attack),
        delegate(&captain_america::attack)
    };

    for (auto &h : heros) {
        std::cout << h(0, 42) << '\n';
    }

    return 0;
}

这导致以下输出:

尽管我们已经在创建我们的委托中取得了重大进展(它至少可以工作),但这个早期实现还存在一些问题:

  • 委托的签名是bool, int, int,这是误导性的,因为我们真正想要的是一个函数签名,比如bool(int, int),这样代码就是自说明的(委托的类型是单个函数签名,而不是三种不同的类型)。

  • 这个委托不能处理标记为const的函数。

  • 我们必须在包装器内部存储被委托对象的实例,这样我们就无法为同一对象创建多个函数的委托。

  • 我们不支持非成员函数。

让我们逐个解决这些问题。

向我们的代理添加函数签名

尽管在不需要 C++17 的情况下可以向我们的代理添加函数签名作为模板参数,但是 C++17 中的用户定义类型推导使这个过程变得简单。以下代码片段展示了这一点:

template<
    typename T,
    typename RET,
    typename... ARGS
    >
delegate(RET(T::*)(ARGS...)) -> delegate<RET(ARGS...)>;

如前所示的代码片段显示,用户定义的类型推导告诉编译器如何将我们的代理构造函数转换为我们希望使用的模板签名。没有这个用户定义的类型推导指南,delegate(RET(T::*)(ARGS...))构造函数将导致代理被推断为delegate<RET, ARGS...>,这不是我们想要的。相反,我们希望编译器推断delegate<RET(ARGS...)>。我们的代理实现的其他方面都不需要改变。我们只需要告诉编译器如何执行类型推断。

向我们的代理添加 const 支持

我们的代理目前无法接受标记为const的成员函数,因为我们没有为我们的代理提供能够这样做的包装器。例如,我们英雄的attack()函数目前看起来像这样:

class spiderman
{
public:
    bool attack(int x, int)
    {
        return x == 0 ? true : false;
    }
};

然而,我们希望我们的英雄attack()函数看起来像以下这样,因为它们不修改任何私有成员变量:

class spiderman
{
public:
    bool attack(int x, int) const
    {
        return x == 0 ? true : false;
    }
};

为了支持这个改变,我们必须创建一个支持这一点的包装器,如下所示:

template<
    typename T,
    typename RET,
    typename... ARGS
    >
class wrapper_const :
    public base<RET, ARGS...>
{
    T m_t{};
    RET (T::*m_func)(ARGS...) const;

public:

    wrapper_const(RET (T::*func)(ARGS...) const) :
        m_func{func}
    { }

    RET func(ARGS... args) override
    {
        return std::invoke(m_func, &m_t, args...);
    }
};

如前所示,这个包装器与我们之前的包装器相同,不同之处在于我们存储的函数签名具有额外的const实例。为了使代理使用这个额外的包装器,我们还必须提供另一个代理构造函数,如下所示:

    template<typename T>
    delegate(RET (T::*func)(ARGS...) const) :
        m_wrapper{
            std::make_unique<wrapper_const<T, RET, ARGS...>>(func)
        }
    { }

这意味着我们还需要另一个用户定义的类型推导指南,如下所示:

template<
    typename T,
    typename RET,
    typename... ARGS
    >
delegate(RET(T::*)(ARGS...) const) -> delegate<RET(ARGS...)>;

通过这些修改,我们现在可以支持标记为const的成员函数。

向我们的代理添加一对多的支持

目前,我们的包装器存储每种类型的实例。这种方法通常与类型擦除一起使用,但在我们的情况下,它阻止了为同一个对象创建多个代理的能力(即不支持一对多)。为了解决这个问题,我们将在我们的包装器中存储对象的指针,而不是对象本身,如下所示:

template<
    typename T,
    typename RET,
    typename... ARGS
    >
class wrapper :
    public base<RET, ARGS...>
{
    const T *m_t{};
    RET (T::*m_func)(ARGS...);

public:

    wrapper(const T *t, RET (T::*func)(ARGS...)) :
        m_t{t},
        m_func{func}
    { }

    RET func(ARGS... args) override
    {
        return std::invoke(m_func, m_t, args...);
    }
};

如前所示,我们所做的唯一改变是我们存储一个指向我们包装的对象的指针,而不是对象本身,这也意味着我们需要在构造函数中初始化这个指针。为了使用这个新的包装器,我们必须修改我们的代理构造函数如下:

    template<typename T>
    delegate(const T *t, RET (T::*func)(ARGS...)) :
        m_wrapper{
            std::make_unique<wrapper<T, RET, ARGS...>>(t, func)
        }
    { }

这又意味着我们必须更新我们的用户定义类型推导指南,如下所示:

template<
    typename T,
    typename RET,
    typename... ARGS
    >
delegate(const T *, RET(T::*)(ARGS...)) -> delegate<RET(ARGS...)>;

通过这些修改,我们现在可以创建我们的代理,如下所示:

int main(void)
{
    spiderman s;
    captain_america c;

    std::array<delegate<bool(int, int)>, 2> heros {
        delegate(&s, &spiderman::attack),
        delegate(&c, &captain_america::attack)
    };

    for (auto &h : heros) {
        std::cout << h(0, 42) << '\n';
    }

    return 0;
}

如前所示,代理接受每个对象的指针,这意味着我们可以创建任意数量的这些代理,包括根据需要创建对其他成员函数指针的代理的能力。

向我们的代理添加对非成员函数的支持

最后,我们需要修改代理以支持非成员函数。看看这个例子:

bool attack(int x, int y)
{
    return x == 42 && y == 42 ? true : false;
}

为了做到这一点,我们只需要添加另一个包装器,如下所示:

template<
    typename RET,
    typename... ARGS
    >
class fun_wrapper :
    public base<RET, ARGS...>
{
    RET (*m_func)(ARGS...);

public:

    fun_wrapper(RET (*func)(ARGS...)) :
        m_func{func}
    { }

    RET func(ARGS... args) override
    {
        return m_func(args...);
    }
};

如前所示,与我们的原始包装器一样,我们存储我们希望调用的函数的指针,但在这种情况下,我们不需要存储对象的指针,因为没有对象(因为这是一个非成员函数包装器)。为了使用这个新的包装器,我们必须添加另一个代理构造函数,如下所示:

    delegate(RET (func)(ARGS...)) :
        m_wrapper{
            std::make_unique<fun_wrapper<RET, ARGS...>>(func)
        }
    { }

这意味着我们还必须提供另一个用户定义的类型推导指南,如下所示:

template<
    typename RET,
    typename... ARGS
    >
delegate(RET(*)(ARGS...)) -> delegate<RET(ARGS...)>;

通过所有这些修改,我们最终能够使用我们在本篇文章开头定义的代理:

int main(void)
{
    spiderman s;
    captain_america c;

    std::array<delegate<bool(int, int)>, 3> heros {
        delegate(attack),
        delegate(&s, &spiderman::attack),
        delegate(&c, &captain_america::attack)
    };

    for (auto &h : heros) {
        std::cout << h(0, 42) << '\n';
    }

    return 0;
}

当这个被执行时,我们得到以下输出:

这个委托可以进一步扩展以支持 lambda 函数,方法是添加另一组包装器,并且可以通过使用一个小缓冲区来替换委托中的std::unique_pointer,从而避免动态内存分配,这个小缓冲区的大小与成员函数包装器相同(或者换句话说,实现小尺寸优化)。

第十章:深入了解动态分配

在本章中,您将学习如何处理动态内存分配。本章很重要,因为并非所有变量都可以在全局范围内或堆栈上(即在函数内部)定义,全局内存应尽可能避免使用,而堆栈内存通常比堆内存(用于动态内存分配的内存)有限得多。然而,使用堆内存已经多年导致了许多关于泄漏和悬空指针的错误。

本章不仅将教你动态内存分配的工作原理,还将教你如何在符合 C++核心指南的情况下正确地从堆中分配内存。

从为什么我们使用智能指针以及它们之间的区别,转换和其他引用开始,我们将在本章中简要解释 Linux 下堆的工作原理以及为什么动态内存分配如此缓慢。

在本章中,我们将涵盖以下示例:

  • 比较 std::shared_ptr 和 std::unique_ptr

  • 从 unique_ptr 转换为 shared_ptr

  • 处理循环引用

  • 使用智能指针进行类型转换

  • 放大堆内存

技术要求

要编译和运行本章中的示例,您必须具有对运行 Ubuntu 18.04 的计算机的管理访问权限,并具有功能正常的互联网连接。在运行这些示例之前,您必须使用以下命令安装 Valgrind:

> sudo apt-get install build-essential git cmake valgrind 

如果这是在 Ubuntu 18.04 之外的任何操作系统上安装的,则需要 GCC 7.4 或更高版本和 CMake 3.6 或更高版本。

本章的代码文件可以在github.com/PacktPublishing/Advanced-CPP-CookBook/tree/master/chapter10找到。

比较 std::shared_ptr 和 std::unique_ptr

在本示例中,我们将学习为什么 C++核心指南不鼓励手动调用 new 和 delete,而是建议使用std::unique_ptrstd::shared_ptr。我们还将了解std::unique_ptrstd::shared_ptr之间的区别,以及为什么std::shared_ptr应该只在某些情况下使用(也就是说,为什么std::unique_ptr很可能是您在大多数情况下应该使用的智能指针类型)。这个示例很重要,因为它将教会你如何在现代 C++中正确分配动态(堆)内存。

准备工作

开始之前,请确保已满足所有技术要求,包括安装 Ubuntu 18.04 或更高版本,并在终端窗口中运行以下命令:

> sudo apt-get install build-essential git cmake

完成此操作后,打开一个新的终端。我们将使用此终端来下载、编译和运行我们的示例。

如何做...

按照以下步骤完成这个示例:

  1. 从新的终端运行以下命令下载源代码:
> cd ~/
> git clone https://github.com/PacktPublishing/Advanced-CPP-CookBook.git
> cd Advanced-CPP-CookBook/chapter10
  1. 要编译源代码,请运行以下命令:
> cmake .
> make recipe01_examples
  1. 源代码编译完成后,您可以通过运行以下命令来执行本示例中的每个示例:
> ./recipe01_example01

> ./recipe01_example02
free(): double free detected in tcache 2
Aborted (core dumped)

> ./recipe01_example03

> ./recipe01_example04

> ./recipe01_example05

> ./recipe01_example06
count: 42

> ./recipe01_example07
count: 33320633

> ./recipe01_example08
count: 42

在下一节中,我们将逐个介绍这些示例,并解释每个示例程序的作用以及它与本示例中所教授的课程的关系。

工作原理...

在 C++中,有三种不同的声明变量的方式:

  • 全局变量:这些是全局可访问的变量。在 Linux 上,这些变量通常存在于可执行文件的.data.rodata.bss部分。

  • 堆变量:这些是你在函数内定义的变量,驻留在应用程序的堆栈内存中,由编译器管理。

  • 堆变量:这些是使用malloc()/free()new()/delete()创建的变量,并使用由动态内存管理算法(例如dlmallocjemalloctcmalloc等)管理的堆内存。

在本章中,我们将专注于堆式内存分配。你可能已经知道,在 C++ 中,内存是使用 new()delete() 分配的,如下所示:

int main(void)
{
    auto ptr = new int;
    *ptr = 42;
}

正如我们所看到的,一个整数指针(也就是指向整数的指针)被分配,然后设置为 42。我们在 C++ 中使用 new() 而不是 malloc() 有以下原因:

  • malloc() 返回 void * 而不是我们关心的类型。这可能导致分配不匹配的 bug(也就是说,你想分配一辆车,但实际上分配了一个橙子)。换句话说,malloc() 不提供类型安全性。

  • malloc() 需要一个大小参数。为了分配内存,我们需要知道为我们关心的类型分配多少字节。这可能导致分配大小不匹配的 bug(也就是说,你想为一辆车分配足够的字节,但实际上只为一个橙子分配了足够的字节)。

  • malloc() 在错误时返回 NULL,需要在每次分配时进行 NULL 检查。

new() 运算符解决了所有这些问题:

  • new() 返回 T*。正如前面的例子所示,这甚至允许使用 auto,避免了冗余,因为 C++ 的类型系统有足够的信息来正确分配和跟踪所需的类型。

  • new() 不需要大小参数。相反,你告诉它你想要分配的类型,这个类型已经隐含地包含了关于类型的大小信息。再一次,通过简单地声明你想要分配的内容,你就得到了你想要分配的内容,包括适当的指针和大小。

  • new() 如果分配失败会抛出异常。这避免了需要进行 NULL 检查。如果下一行代码执行,你可以确保分配成功(假设你没有禁用异常)。

然而,new() 运算符仍然存在一个问题;new() 不跟踪所有权。和 malloc() 一样,new() 运算符返回一个指针,这个指针可以在函数之间传递,而没有实际拥有指针的概念,这意味着当不再需要指针时应该删除指针。

所有权的概念是 C++ 核心指南的关键组成部分(除了内存跨度),旨在解决 C++ 中常见的导致不稳定、可靠性和安全性错误的 bug。让我们来看一个例子:

int main(void)
{
    auto p = new int;
    delete p;

    delete p;
}

在上面的例子中,我们分配了一个整数指针,然后两次删除了指针。在之前的例子中,我们实际上从未在退出程序之前删除整数指针。现在,考虑以下代码块:

int main(void)
{
    auto p = new int;
    delete p;

    *p = 42;
}

在上面的例子中,我们分配了一个整数指针,删除了它,然后使用了它。尽管这些例子看起来简单明了,可以避免,但在大型复杂项目中,这些类型的 bug 经常发生,因此 C++ 社区已经开发了静态和动态分析工具来自动识别这些类型的 bug(尽管它们并不完美),以及 C++ 核心指南本身,试图在第一时间防止这些类型的 bug。

在 C++11 中,标准委员会引入了 std::unique_ptr 来解决 new()delete() 的所有权问题。它的工作原理如下:

#include <memory>

int main(void)
{
    auto ptr = std::make_unique<int>();
    *ptr = 42;
}

在上面的例子中,我们使用 std::make_unique() 函数分配了一个整数指针。这个函数创建了一个 std::unique_ptr 并给它分配了一个使用 new() 分配的指针。在这里,结果指针(大部分情况下)看起来和行为像一个常规指针,唯一的例外是当 std::unique_ptr 失去作用域时,指针会自动被删除。也就是说,std::unique_ptr 拥有使用 std::make_unique() 分配的指针,并负责指针本身的生命周期。在这个例子中,我们不需要手动运行 delete(),因为当 main() 函数完成时(也就是 std::unique_ptr 失去作用域时),delete() 会自动运行。

通过管理所有权的这种简单技巧,可以避免前面代码中显示的大部分错误(我们稍后会讨论)。尽管以下代码不符合 C++核心指南(因为下标运算符不被鼓励),但您也可以使用std::unique_ptr来分配数组,如下所示:

#include <memory>
#include <iostream>

int main(void)
{
    auto ptr = std::make_unique<int[]>(100);
    ptr[0] = 42;
}

如前面的代码所示,我们分配了一个大小为100的 C 风格数组,然后设置了数组中的第一个元素。一般来说,您唯一需要的指针类型是std::unique_ptr。然而,仍然可能出现一些问题:

  • 未正确跟踪指针的生命周期,例如,在函数中分配std::unique_ptr并返回生成的指针。一旦函数返回,std::unique_ptr失去作用域,因此删除了刚刚返回的指针。std::unique_ptr 实现自动垃圾回收。您仍然需要了解指针的生命周期以及它对代码的影响。

  • 尽管更加困难,但仍然有可能泄漏内存,因为从未给std::unique_ptr提供失去作用域的机会;例如,将std::unique_ptr添加到全局列表中,或者在使用new()手动分配的类中分配std::unique_ptr,然后泄漏。再次强调,std::unique_ptr 实现自动垃圾回收,您仍然需要确保在需要时std::unique_ptr失去作用域。

  • std::unique_ptr也无法支持共享所有权。尽管这是一个问题,但这种类型的情况很少发生。在大多数情况下,std::unique_ptr就足以确保适当的所有权。

经常提出的一个问题是,一旦分配了指针,我们如何安全地将该指针传递给其他函数? 答案是,您使用get()函数并将指针作为常规的 C 风格指针传递。std::unique_ptr定义所有权,而不是NULL指针安全。NULL指针安全由指南支持库提供,其中包括gsl::not_null包装器和expects()宏。

如何使用这些取决于您的指针哲学:

  • 有人认为,任何接受指针作为参数的函数都应该检查NULL指针。这种方法的优点是可以快速识别和安全处理NULL指针,而缺点是您引入了额外的分支逻辑,这会降低代码的性能和可读性。

  • 有人认为接受指针作为参数的公共函数应该检查NULL指针。这种方法的优点是,性能得到了改善,因为并非所有函数都需要NULL指针检查。这种方法的缺点是,公共接口仍然具有额外的分支逻辑。

  • 有人认为函数应该简单地记录其期望(称为合同)。这种方法的好处是,assert()expects()宏可以在调试模式下用于检查NULL指针以强制执行此合同,而在发布模式下,不会有性能损失。这种方法的缺点是,在发布模式下,所有的赌注都关闭。

您采取的方法很大程度上取决于您正在编写的应用程序类型。如果您正在编写下一个 Crush 游戏,您可能更关心后一种方法,因为它的性能最佳。如果您正在编写一个将自动驾驶飞机的应用程序,我们都希望您使用第一种方法。

为了演示如何使用std::unique_ptr传递指针,让我们看一下以下示例:

std::atomic<int> count;

void inc(int *val)
{
    count += *val;
}

假设您有一个作为线程执行的超级关键函数,该函数以整数指针作为参数,并将提供的整数添加到全局计数器中。该线程的前面实现是所有的赌注都关闭,交叉双手,然后希望最好的方法。可以实现此函数如下:

void inc(int *val)
{
    if (val != nullptr) {
        count += *val;
    }
    else {
        std::terminate();
    }
}

前面的函数调用std::terminate()(不是一个非常容错的方法),如果提供的指针是NULL指针。正如我们所看到的,这种方法很难阅读,因为这里有很多额外的逻辑。我们可以按照以下方式实现这一点:

void inc(gsl::not_null<int *> val)
{
    count += *val;
}

这与NULL指针检查做的事情相同(取决于您如何定义gsl::not_null的工作方式,因为这也可能会抛出异常)。您也可以按照以下方式实现这一点:

void inc(int *val)
{
    expects(val);
    count += *val;
}

前面的示例总是检查NULL指针,而前面的方法使用了合同方法,允许在发布模式中删除检查。您也可以使用assert()(如果您没有使用 GSL...当然,这绝对不应该是这种情况)。

还应该注意,C++标准委员会正在通过使用 C++合同将expects()逻辑作为语言的核心组件添加到语言中,这是一个遗憾的特性,它在 C++20 中被删除了,但希望它会在未来的标准版本中添加,因为我们可能能够按照以下方式编写前面的函数(并告诉编译器我们希望使用哪种方法,而不必手动编写):

void inc(int *val) [[expects: val]]
{
    count += *val;
}

我们可以按以下方式使用这个函数:

int main(void)
{
    auto ptr = std::make_unique<int>(1);
    std::array<std::thread, 42> threads;

    for (auto &thread : threads) {
        thread = std::thread{inc, ptr.get()};
    }

    for (auto &thread : threads) {
        thread.join();
    }

    std::cout << "count: " << count << '\n';

    return 0;
}

从前面的代码示例中,我们可以观察到以下内容:

  • 我们使用std::make_unique()从堆中分配一个整数指针,它返回std::unique_ptr()

  • 我们创建一个线程数组,并执行每个线程,将新分配的指针传递给每个线程。

  • 最后,我们等待所有线程完成并输出结果计数。由于std::unique_ptr的作用域限于main()函数,我们必须确保线程在main()函数返回之前完成。

前面的示例导致以下输出:

如前面提到的,前面的示例将std::unique_ptr定义为main()函数的作用域,这意味着我们必须确保线程在main()函数返回之前完成。这种情况并非总是如此。让我们看下面的例子:

std::atomic<int> count;

void inc(int *val)
{
    count += *val;
}

在这里,我们创建一个函数,当给定一个整数指针时,它会增加一个计数:

int main(void)
{
    std::array<std::thread, 42> threads;

    {
        auto ptr = std::make_unique<int>(1);

        for (auto &thread : threads) {
            thread = std::thread{inc, ptr.get()};
        }
    }

    for (auto &thread : threads) {
        thread.join();
    }

    std::cout << "count: " << count << '\n';

    return 0;
}

如前面的代码所示,main()函数与我们之前的示例相同,唯一的区别是std::unique_ptr是在自己的作用域中创建的,并在需要完成线程之前释放。这导致以下输出:

如前面的截图所示,由于线程试图从已删除的内存中读取(即,线程被给予了悬空指针),结果输出是垃圾。

尽管这是一个简单的例子,但在更复杂的情况下,这种情况可能会发生,问题的根源是共享所有权。在这个例子中,每个线程都拥有指针。换句话说,没有一个线程试图独占指针(包括分配和执行其他线程的主线程)。尽管这种问题通常发生在具有无主线程设计的多线程应用程序中,但这也可能发生在异步逻辑中,其中指针被分配然后传递给多个异步作业,其生命周期和执行点是未知的。

为了处理这些特定类型的问题,C++提供了std::shared_ptr。这是一个受控对象的包装器。每次复制std::shared_ptr时,受控对象会增加一个内部计数器,用于跟踪指针(受控对象存储的)的所有者数量。每次std::shared_ptr失去作用域时,受控对象会减少内部计数器,并在此计数达到0时删除指针。使用这种方法,std::shared_ptr能够支持一对多的所有权模型,可以处理我们之前定义的情况。

让我们看下面的例子:

std::atomic<int> count;

void inc(std::shared_ptr<int> val)
{
    count += *val;
}

如前面的代码所示,我们有相同的线程函数来增加一个计数器,但不同之处在于它接受std::shared_ptr而不是常规整数指针。现在,我们可以按照前面的示例实现如下:

int main(void)
{
    std::array<std::thread, 42> threads;

    {
        auto ptr = std::make_shared<int>(1);

        for (auto &thread : threads) {
            thread = std::thread{inc, ptr};
        }
    }

    for (auto &thread : threads) {
        thread.join();
    }

    std::cout << "count: " << count << '\n';

    return 0;
}

如前面的代码所示,指针在自己的作用域中创建,然后在需要完成线程之前被移除。然而,与之前的示例不同,这段代码的结果如下:

前面的代码之所以能够正确执行,是因为指针的所有权在所有线程之间共享,并且指针本身在所有线程完成之前不会被删除(即使作用域丢失)。

最后一点说明:当std::unique_ptr应该被使用时,可能会诱人使用std::shared_ptr来代替,因为它具有良好的类型转换 API,并且理论上可以确保函数具有有效的指针。现实情况是,无论使用std::shared_ptr还是std::unique_ptr,函数都必须根据应用程序的需求执行其NULL检查,因为std::shared_ptr仍然可以被创建为NULL指针。

std::shared_ptr也有额外的开销,因为它必须在内部存储所需的删除器。它还需要为受管理对象进行额外的堆分配。std::shared_ptrstd::unique_ptr都定义了指针所有权。它们不提供自动垃圾回收(即它们不自动处理指针的生命周期),也不能保证指针不是NULLstd::shared_ptr应该只在多个东西必须拥有指针的生命周期以确保应用程序的正确执行时使用;否则,请使用std::unique_ptr

std::unique_ptr转换为std::shared_ptr

在这个配方中,我们将学习如何将std::unique_ptr转换为std::shared_ptr。这个配方很重要,因为通常在定义 API 时,接受std::unique_ptr是很方便的,而 API 本身实际上需要std::shared_ptr来进行内部使用。一个很好的例子是创建 GUI API。您可能会将一个小部件传递给 API 来存储和拥有,而不知道以后在 GUI 的实现中可能需要添加线程,这种情况下std::shared_pointer可能是一个更好的选择。这个配方将为您提供将std::unique_ptr转换为std::shared_ptr的技能,如果需要的话,而不必修改 API 本身。

准备工作

开始之前,请确保已满足所有技术要求,包括安装 Ubuntu 18.04 或更高版本,并在终端窗口中运行以下命令:

> sudo apt-get install build-essential git cmake

完成后,打开一个新的终端。我们将使用这个终端来下载、编译和运行我们的示例。

操作步骤

按照以下步骤完成这个配方:

  1. 从一个新的终端中运行以下命令来下载源代码:
> cd ~/
> git clone https://github.com/PacktPublishing/Advanced-CPP-CookBook.git
> cd Advanced-CPP-CookBook/chapter10
  1. 要编译源代码,请运行以下命令:
> cmake .
> make recipe02_examples
  1. 源代码编译完成后,可以通过运行以下命令来执行本配方中的每个示例:
> ./recipe02_example01 
count: 42

在下一节中,我们将逐个介绍这些示例,并解释每个示例程序的作用以及它与本配方中所教授的课程的关系。

工作原理

std::shared_ptr用于管理指针,当多个东西必须拥有指针才能正确执行应用程序时。然而,假设您提供了一个必须接受整数指针的 API,如下所示:

void execute_threads(int *ptr);

前面的 API 表明,调用这个函数的人拥有整数指针。也就是说,调用这个函数的人需要分配整数指针,并在函数完成后删除它。然而,如果我们打算让前面的 API 拥有指针,我们真的应该将这个 API 写成如下形式:

void execute_threads(std::unique_ptr<int> ptr);

这个 API 说,请为我分配一个整数指针,但一旦传递给我,我就拥有它,并将在需要时确保它被删除。现在,假设这个函数将在一个一对多的所有权场景中使用这个指针。你会怎么做?你可以将你的 API 写成下面这样:

void execute_threads(std::shared_ptr<int> ptr);

然而,这将阻止您的 API 在将来优化一对多的关系(也就是说,如果将来能够移除这种关系,您仍将被困在std::shared_ptr中,即使在不修改 API 函数签名的情况下,它也是次优的)。

为了解决这个问题,C++ API 提供了将std::unique_ptr转换为std::shared_ptr的能力,如下所示:

std::atomic<int> count;

void
inc(std::shared_ptr<int> val)
{
    count += *val;
}

假设我们有一个内部函数,暂时以std::shared_ptr的整数指针作为参数,使用它的值来增加count,并将其作为线程执行。然后,我们为其提供一个公共 API 来使用这个内部函数,如下所示:

void
execute_threads(std::unique_ptr<int> ptr)
{
    std::array<std::thread, 42> threads;
    auto shared = std::shared_ptr<int>(std::move(ptr));

    for (auto &thread : threads) {
        thread = std::thread{inc, shared};
    }

    for (auto &thread : threads) {
        thread.join();
    }
}

如前面的代码所示,我们的 API 声明拥有先前分配的整数指针。然后,它创建一系列线程,执行每一个并等待每个线程完成。问题在于我们的内部函数需要一个std::shared_ptr(例如,也许这个内部函数在代码的其他地方被使用,那里有一个一对多的所有权场景,我们目前无法移除)。

为了避免需要用std::shared_ptr定义我们的公共 API,我们可以通过将std::unique_ptr移动到一个新的std::shared_ptr中,然后从那里调用我们的线程来将std::unique_ptr转换为std::shared_ptr

std::move()是必需的,因为传递std::unique_ptr所有权的唯一方法是通过使用std::move()(因为在任何给定时间只有一个std::unique_ptr可以拥有指针)。

现在,我们可以执行这个公共 API,如下所示:

int main(void)
{
    execute_threads(std::make_unique<int>(1));
    std::cout << "count: " << count << '\n';

    return 0;
}

这将产生以下输出:

在未来,我们可能能够消除对std::shared_ptr的需求,并使用get()函数将std::unique_ptr传递给我们的内部函数,当那个时候,我们就不必修改公共 API 了。

处理循环引用

在这个示例中,我们将学习如何处理循环引用。循环引用发生在我们使用多个std::shared_ptr时,每个std::shared_ptr都拥有对另一个的引用。这个示例很重要,因为在处理循环依赖对象时可能会出现这种循环引用(尽管在可能的情况下应该避免)。如果发生了,std::shared_ptr的共享特性会导致内存泄漏。这个示例将教会你如何使用std::weak_ptr来避免这种内存泄漏。

准备工作

在开始之前,请确保已满足所有技术要求,包括安装 Ubuntu 18.04 或更高版本,并在终端窗口中运行以下命令:

> sudo apt-get install build-essential git cmake valgrind 

完成后,打开一个新的终端。我们将使用这个终端来下载、编译和运行我们的示例。

如何做...

要处理循环引用,请执行以下步骤:

  1. 从一个新的终端,运行以下命令下载本示例的源代码:
> cd ~/
> git clone https://github.com/PacktPublishing/Advanced-CPP-CookBook.git
> cd Advanced-CPP-CookBook/chapter10
  1. 要编译源代码,请运行以下命令:
> cmake .
> make recipe03_examples
  1. 一旦源代码编译完成,您可以通过运行以下命令来执行本示例中的每个示例:
> valgrind ./recipe03_example01
...
==7960== HEAP SUMMARY:
==7960== in use at exit: 64 bytes in 2 blocks
==7960== total heap usage: 3 allocs, 1 frees, 72,768 bytes allocated
...

> valgrind ./recipe03_example02
...
==7966== HEAP SUMMARY:
==7966== in use at exit: 64 bytes in 2 blocks
==7966== total heap usage: 4 allocs, 2 frees, 73,792 bytes allocated
...

> valgrind ./recipe03_example03
...
==7972== HEAP SUMMARY:
==7972== in use at exit: 0 bytes in 0 blocks
==7972== total heap usage: 4 allocs, 4 frees, 73,792 bytes allocated
...

> valgrind ./recipe03_example04
...
==7978== HEAP SUMMARY:
==7978== in use at exit: 0 bytes in 0 blocks
==7978== total heap usage: 4 allocs, 4 frees, 73,792 bytes allocated
...

在下一节中,我们将逐个介绍这些示例,并解释每个示例程序的作用以及它与本示例教授的课程的关系。

它是如何工作的...

尽管应该避免,但随着项目变得越来越复杂和庞大,循环引用很可能会发生。当这些循环引用发生时,如果使用共享智能指针,可能会导致难以发现的内存泄漏。为了理解这是如何可能的,让我们看下面的例子:

class car;
class engine;

如前所示,我们从两个类原型开始。循环引用几乎总是以这种方式开始,因为一个类依赖于另一个类,反之亦然,需要使用类原型。

让我们定义一个car如下:

class car
{
    friend void build_car();
    std::shared_ptr<engine> m_engine;

public:
    car() = default;
};

如前所示,这是一个简单的类,它存储了一个指向engine的 shared pointer,并且是build_car()函数的友元。现在,我们可以定义一个engine如下:

class engine
{
    friend void build_car();
    std::shared_ptr<car> m_car;

public:
    engine() = default;
};

如前所示,engine类似于car,不同之处在于 engine 存储了一个指向 car 的 shared pointer。两者都是build_car()函数的友元。它们都创建默认构造的 shared pointers,这意味着它们的 shared pointers 在构造时是NULL指针。

build_car()函数用于完成每个对象的构建,如下所示:

void build_car()
{
    auto c = std::make_shared<car>();
    auto e = std::make_shared<engine>();

    c->m_engine = e;
    e->m_car = c;
}

如前所示,我们创建每个对象,然后设置 car 的 engine,反之亦然。由于 car 和 engine 都限定在build_car()函数中,我们期望一旦build_car()函数返回,这些指针将被删除。现在,我们可以执行build_car()函数如下:

int main(void)
{
    build_car();
    return 0;
}

这似乎是一个简单的程序,但却很难找到内存泄漏。为了证明这一点,让我们在valgrind中运行此应用程序,valgrind是一种动态内存分析工具,能够检测内存泄漏:

如前所示的截图显示,valgrind表示有内存泄漏。如果我们使用--leak-check=full运行valgrind,它会告诉我们内存泄漏出现在 car 和 engine 的 shared pointers 中。这种内存泄漏发生的原因是 car 持有对 engine 的 shared reference。同样的 engine 也持有对 car 本身的 shared reference。

例如,考虑以下代码:

void build_car()
{
    auto c = std::make_shared<car>();
    auto e = std::make_shared<engine>();

    c->m_engine = e;
    e->m_car = c;

    std::cout << c.use_count() << '\n';
    std::cout << e.use_count() << '\n';
}

如前所示,我们添加了对use_count()的调用,它输出std::shared_ptr包含的所有者数量。如果执行此操作,将会看到以下输出:

我们看到两个所有者是因为build_car()函数在这里持有对 car 和 engine 的引用:

    auto c = std::make_shared<car>();
    auto e = std::make_shared<engine>();

由于这个原因,car 持有对 engine 的第二个引用:

    c->m_engine = e;

对于 engine 和 car 也是一样的。当build_car()函数完成时,以下内容首先失去了作用域:

    auto e = std::make_shared<engine>();

然而,engine 不会被删除,因为 car 仍然持有对 engine 的引用。然后,car 失去了作用域:

    auto c = std::make_shared<car>();

然而,car 没有被删除,因为 engine(尚未被删除)也持有对 car 的引用。这导致build_car()返回时,car 和 engine 都没有被删除,因为它们仍然相互持有引用,没有办法告诉任何一个对象删除它们的引用。

尽管在我们的示例中很容易识别出这种循环内存泄漏,但在复杂的代码中很难识别,这是共享指针和循环依赖应该避免的许多原因之一(通常更好的设计可以消除对两者的需求)。如果无法避免,可以使用std::weak_ptr,如下所示:

class car
{
    friend void build_car();
    std::shared_ptr<engine> m_engine;

public:
    car() = default;
};

如前所示,我们仍然定义我们的 car 持有对 engine 的 shared reference。我们这样做是因为我们假设 car 的寿命更长(也就是说,在我们的模型中,你可以有一辆没有发动机的车,但你不能没有车的发动机)。然而,engine 的定义如下:

class engine
{
    friend void build_car();
    std::weak_ptr<car> m_car;

public:
    engine() = default;
};

如前所示,engine 现在存储了对 car 的弱引用。我们的build_car()函数定义如下:

void build_car()
{
    auto c = std::make_shared<car>();
    auto e = std::make_shared<engine>();

    c->m_engine = e;
    e->m_car = c;

    std::cout << c.use_count() << '\n';
    std::cout << e.use_count() << '\n';
}

如前所示,build_car()函数没有改变。现在的区别在于,当我们使用valgrind执行此应用程序时,会看到以下输出:

如前面的屏幕截图所示,没有内存泄漏,汽车的use_count()1,而引擎的use_count()与之前的例子相比仍为2。在引擎类中,我们使用std::weak_ptr,它可以访问std::shared_ptr管理的托管对象,但在创建时不会增加托管对象的内部计数。这使得std::weak_ptr能够查询std::shared_ptr是否有效,而无需持有指针本身的强引用。

内存泄漏被消除的原因是,当引擎失去作用域时,其使用计数从2减少到1。一旦汽车失去作用域,其使用计数仅为1,就会被删除,从而将引擎的使用计数减少到0,这将导致引擎也被删除。

我们在引擎中使用std::weak_ptr而不是 C 风格指针的原因是,std::weak_ptr使我们能够查询托管对象,以查看指针是否仍然有效。例如,假设我们需要检查汽车是否仍然存在,如下所示:

class engine
{
    friend void build_car();
    std::weak_ptr<car> m_car;

public:
    engine() = default;

    void test()
    {
        if (m_car.expired()) {
            std::cout << "car deleted\n";
        }
    }
};

通过使用expired()函数,我们可以在使用汽车之前测试汽车是否仍然存在,这是使用 C 风格指针无法实现的。现在,我们可以编写我们的build_car()函数如下:

void build_car()
{
 auto e = std::make_shared<engine>();

 {
 auto c = std::make_shared<car>();

 c->m_engine = e;
 e->m_car = c;
 }

 e->test();
}

在前面的示例中,我们创建了一个引擎,然后创建了一个创建汽车的新作用域。然后,我们创建了我们的循环引用并失去了作用域。这导致汽车被删除,这是预期的。不同之处在于,我们的引擎尚未被删除,因为我们仍然持有对它的引用。现在,我们可以运行我们的测试函数,当使用valgrind运行时,会得到以下输出:

如前面的屏幕截图所示,没有内存泄漏。std::weak_ptr成功消除了循环引用引入的鸡和蛋问题。因此,std::shared_ptr能够按预期释放内存。通常情况下,应尽量避免循环引用和依赖关系,但如果无法避免,可以使用std::weak_ptr(如本教程所示)来防止内存泄漏。

使用智能指针进行类型转换

在本教程中,我们将学习如何使用std::unique_ptrstd::shared_ptr进行类型转换。类型转换允许将一种类型转换为另一种类型。本教程很重要,因为它演示了在尝试转换智能指针类型(例如,在虚拟继承中进行向上转型或向下转型)时,使用std::unique_ptrstd::shared_ptr处理类型转换的正确方式。

准备工作

开始之前,请确保满足所有技术要求,包括安装 Ubuntu 18.04 或更高版本,并在终端窗口中运行以下命令:

> sudo apt-get install build-essential git cmake

完成后,打开一个新的终端。我们将使用这个终端来下载、编译和运行我们的示例。

如何做...

为了了解类型转换的工作原理,请执行以下步骤:

  1. 从新的终端运行以下命令以下载源代码:
> cd ~/
> git clone https://github.com/PacktPublishing/Advanced-CPP-CookBook.git
> cd Advanced-CPP-CookBook/chapter10
  1. 要编译源代码,请运行以下命令:
> cmake .
> make recipe04_examples
  1. 源代码编译完成后,可以通过运行以下命令来执行本教程中的每个示例:
> ./recipe04_example01
downcast successful!!

> ./recipe04_example02
downcast successful!!

在接下来的部分,我们将逐个介绍这些示例,并解释每个示例程序的作用以及它与本教程所教授的课程的关系。

工作原理...

使用智能指针进行类型转换并不像你期望的那样简单。

为了更好地解释这一点,让我们看一个简单的例子,演示如何使用std::unique_ptr从基类转换为子类:

class base
{
public:
    base() = default;
    virtual ~base() = default;
};

让我们看看这是如何工作的:

  1. 我们从一个虚拟基类开始,如前面的代码所示,然后我们将基类子类化如下:
class subclass : public base
{
public:
    subclass() = default;
    ~subclass() override = default;
};
  1. 接下来,在我们的main()函数中创建一个std::unique_ptr,并将指针传递给一个foo()函数:
int main(void)
{
    auto ptr = std::make_unique<subclass>();
    foo(ptr.get());

    return 0;
}

std::unique_ptr只是简单地拥有指针的生命周期。对指针的任何使用都需要使用get()函数,从那时起将std::unique_ptr转换为普通的 C 风格指针。这是std::unique_ptr的预期用法,因为它不是设计为确保指针安全,而是设计为确保谁拥有指针是明确定义的,最终确定指针何时应该被删除。

  1. 现在,foo()函数可以定义如下:
void foo(base *b)
{
    if (dynamic_cast<subclass *>(b)) {
        std::cout << "downcast successful!!\n";
    }
}

在上面的代码中,foo()函数可以将指针视为普通的 C 风格指针,使用dynamic_cast()从基类指针向下转换回原始子类。

标准 C++的这种类型转换方式在std::shared_ptr中不起作用。原因是需要类型转换版本的std::shared_ptr的代码可能还需要保存指针的引用(即std::shared_ptr的副本以防止删除)。

也就是说,从base *bstd::shared_ptr<subclass>是不可能的,因为std::shared_ptr不持有指针的引用;相反,它持有托管对象的引用,该对象存储对实际指针的引用。由于base *b不存储托管对象,因此无法从中创建std::shared_ptr

然而,C++提供了std::shared_ptr版本的static_cast()reinterpret_cast()const_cast()dynamic_cast()来执行共享指针的类型转换,这样在类型转换时可以保留托管对象。让我们看一个例子:

class base
{
public:
    base() = default;
    virtual ~base() = default;
};

class subclass : public base
{
public:
    subclass() = default;
    ~subclass() override = default;
};

如上所示,我们从相同的基类和子类开始。不同之处在于我们的foo()函数:

void foo(std::shared_ptr<base> b)
{
    if (std::dynamic_pointer_cast<subclass>(b)) {
        std::cout << "downcast successful!!\n";
    }
}

它不再使用base *b,而是使用std::shared_ptr<base>。现在,我们可以使用std::dynamic_pointer_cast()函数而不是dynamic_cast()来将std::shared_ptr<base>向下转换为std::shared_ptr<subclass>std::shared_ptr类型转换函数为我们提供了在需要时进行类型转换并仍然保持对std::shared_ptr的访问权限的能力。

生成的main()函数将如下所示:

int main(void)
{
    auto ptr = std::make_shared<subclass>();
    foo(ptr);

    return 0;
}

这将产生以下输出:

需要注意的是,我们不需要显式上转型,因为这可以自动完成(类似于常规指针)。我们只需要显式下转型。

放大堆

在这个示例中,我们将学习 Linux 中堆的工作原理。我们将深入了解 Linux 在您使用std::unique_ptr时如何提供堆内存。

尽管本示例是为那些具有更高级能力的人准备的,但它很重要,因为它将教会您如何从堆中分配内存(即使用new()/delete())的应用程序,从而向您展示为什么堆分配不应该从时间关键代码中执行,因为它们很慢。本示例将教会您在何时执行堆分配是安全的,以及何时应该避免在您的应用程序中执行堆分配,即使我们检查的一些汇编代码很难跟踪。

准备工作

开始之前,请确保已满足所有技术要求,包括安装 Ubuntu 18.04 或更高版本,并在终端窗口中运行以下命令:

> sudo apt-get install build-essential git cmake

完成后,打开一个新的终端。我们将使用这个终端来下载、编译和运行我们的示例。

如何做到...

要尝试本章的代码文件,请按照以下步骤进行:

  1. 从新的终端运行以下命令以下载源代码:
> cd ~/
> git clone https://github.com/PacktPublishing/Advanced-CPP-CookBook.git
> cd Advanced-CPP-CookBook/chapter10
  1. 要编译源代码,请运行以下命令:
> cmake .
> make recipe05_examples
  1. 源代码编译完成后,可以通过运行以下命令执行本示例中的每个示例:
> ./recipe05_example01

在下一节中,我们将逐个步骤地介绍每个示例,并解释每个示例程序的作用以及它与本示例教授的课程的关系。

工作原理...

为了更好地理解代码需要执行多少次以在堆上分配变量,我们将从以下简单示例开始:

int main(void)
{
    auto ptr = std::make_unique<int>();
}

如前面的示例所示,我们使用std::unique_ptr()分配了一个整数。我们使用std::unique_ptr()作为起点,因为这是大多数 C++核心指南代码在堆上分配内存的方式。

std::make_unique()函数使用以下伪逻辑分配了一个std::unique_ptr(这是一个简化的例子,因为这并没有显示如何处理自定义删除器):

namespace std
{
    template<typename T, typename... ARGS>
    auto make_unique(ARGS... args)
    {
        return std::unique_ptr(new T(std::forward<ARGS>(args)...));
    }
}

如前面的代码所示,std::make_unique()函数创建了一个std::unique_ptr,并为其分配了一个使用new()操作符的指针。一旦std::unique_ptr失去作用域,它将使用delete()删除指针。

当编译器看到new操作符时,它会用对new(unsigned long)的调用替换代码。为了看到这一点,让我们看下面的例子:

int main(void)
{
    auto ptr = new int;
}

在前面的示例中,我们使用new()分配了一个简单指针。现在,我们可以查看生成的汇编代码,如下截图所示:

如下截图所示,调用了_Znwm,这是 C++代码的名称修饰,对应的是operator new(unsigned long),很容易进行名称还原:

new()操作符本身看起来像以下伪代码(请注意,这不考虑禁用异常支持或提供新处理程序的能力):

void* operator new(size_t size)
{
    if (auto ptr = malloc(size)) {
        return ptr;
    }

    throw std::bad_alloc();
}

现在,我们可以查看new操作符,看看malloc()是如何被调用的:

如前面的截图所示,调用了malloc()。如果结果指针不是NULL,则操作符返回;否则,它进入错误状态,这涉及调用新处理程序,最终抛出std::bad_alloc()(至少默认情况下)。

malloc()本身的调用要复杂得多。当应用程序启动时,它首先要做的是保留堆空间。操作系统为每个应用程序提供了一个连续的虚拟内存块,而在 Linux 上,堆是应用程序内存中的最后一个块(也就是说,new()返回的内存来自应用程序内存空间的末尾)。将堆放在这里为操作系统提供了一种在需要时向应用程序添加额外内存的方法(因为操作系统只是扩展应用程序的虚拟内存的末尾)。

应用程序本身使用sbrk()函数在内存不足时向操作系统请求更多内存。调用此函数时,操作系统会从内部页池中分配内存页,并通过移动应用程序的内存空间末尾将此内存映射到应用程序中。映射过程本身很慢,因为操作系统不仅需要从池中分配页,这需要某种搜索和保留逻辑,还必须遍历应用程序的页表,将此额外内存添加到其虚拟地址空间中。

一旦sbrk()提供了额外内存,malloc()引擎接管。正如我们之前提到的,操作系统只是将内存页映射到应用程序中。每个页面的大小可以是 4k 字节,也可以是从 2MB 到 1GB 不等,具体取决于请求。然而,在我们的例子中,我们只分配了一个简单的整数,大小只有4字节。为了将页面转换为小对象而不浪费内存,malloc()本身有一个算法,将操作系统提供的内存分成小块。该引擎还必须处理这些内存块何时被释放,以便它们可以再次使用。这需要复杂的数据结构来管理应用程序的所有内存,并且每次调用malloc()free()new()delete()都必须执行这种逻辑。

使用std::make_unique()创建std::unique_ptr的简单调用必须使用new()分配内存来创建std::unique_ptr,而new()实际上调用malloc(),必须通过复杂的数据结构搜索可用的内存块,最终可以返回,也就是假设malloc()有空闲内存,并且不必使用sbrk()向操作系统请求更多内存。

换句话说,动态(即堆)内存很慢,应该只在需要时使用,并且在时间关键的代码中最好不要使用。

第十一章:C++中的常见模式

在本章中,您将学习 C++中的各种设计模式。设计模式提供了解决不同类型问题的常见方法,通常在互联网上、会议上以及在工作中的水机前讨论设计模式的优缺点。

本章的目标是向您介绍一些更受欢迎、不太受欢迎甚至有争议的模式,让您了解设计模式试图解决的不同类型问题。这是一个重要的章节,因为它将教会您如何通过教授已经存在的解决方案来解决自己应用程序中遇到的常见问题。学习这些设计模式中的任何一种都将为您打下基础,使您能够在自己的应用程序中遇到问题时自行发现其他设计模式。

本章中的示例如下:

  • 学习工厂模式

  • 正确使用单例模式

  • 使用装饰器模式扩展您的对象

  • 使用观察者模式添加通信

  • 通过静态多态性提高性能

技术要求

要编译和运行本章中的示例,您必须具有管理访问权限,可以访问具有功能互联网连接的运行 Ubuntu 18.04 的计算机。在运行这些示例之前,您必须安装以下内容:

> sudo apt-get install build-essential git cmake 

如果这是在 Ubuntu 18.04 之外的任何操作系统上安装的,则需要 GCC 7.4 或更高版本和 CMake 3.6 或更高版本。

本章的代码文件可以在github.com/PacktPublishing/Advanced-CPP-CookBook/tree/master/chapter11找到。

学习工厂模式

在本示例中,我们将学习工厂模式是什么,如何实现它以及何时使用它。这个示例很重要,特别是在单元测试时,因为工厂模式提供了添加接缝(即,代码中提供机会进行更改的有意义的地方)的能力,能够改变另一个对象分配的对象类型,包括分配虚假对象进行测试的能力。

准备工作

开始之前,请确保满足所有技术要求,包括安装 Ubuntu 18.04 或更高版本,并在终端窗口中运行以下命令:

> sudo apt-get install build-essential git cmake

这将确保您的操作系统具有编译和执行本示例所需的适当工具。完成后,打开一个新的终端。我们将使用这个终端来下载、编译和运行我们的示例。

如何做...

按照以下步骤尝试工厂模式的代码:

  1. 从一个新的终端中,运行以下命令来下载源代码:
> cd ~/
> git clone https://github.com/PacktPublishing/Advanced-CPP-CookBook.git
> cd Advanced-CPP-CookBook/chapter11
  1. 要编译源代码,请运行以下命令:
> cmake .
> make recipe01_examples
  1. 一旦源代码编译完成,您可以通过运行以下命令来执行本章中的每个示例:
> ./recipe01_example01

> ./recipe01_example02

> ./recipe01_example03
correct answer: The answer is: 42

> ./recipe01_example04
wrong answer: Not sure

> ./recipe01_example05
correct answer: The answer is: 42

在下一节中,我们将逐个介绍这些示例,并解释每个示例程序的作用以及它与本示例中所教授的课程的关系。

它是如何工作的...

工厂模式提供了一个分配资源的对象,可以更改对象分配的类型。为了更好地理解这种模式的工作原理以及它为什么如此有用,让我们看下面的例子:

class know_it_all
{
public:
    auto ask_question(const char *question)
    {
        (void) question;
        return answer("The answer is: 42");
    }
};

正如前面的代码所示,我们从一个名为know_it_all的类开始,当被问及问题时,它会提供一个答案。在这种情况下,无论问什么问题,它总是返回相同的答案。答案定义如下:

class answer
{
    std::string m_answer;

public:
    answer(std::string str) :
        m_answer{std::move(str)}
    { }
};

如前所示,答案是一个简单的类,它根据一个字符串构造并在内部存储字符串。在这种情况下,重要的是要注意,这个 API 的用户实际上无法提取答案类存储的字符串,这意味着使用这些 API 的方式如下:

int main(void)
{
    know_it_all universe;
    auto ___ = universe.ask_question("What is the meaning of life?");
}

如上所示,我们可以提问,得到一个结果,但我们不确定实际提供了什么结果。这种问题在面向对象编程中经常存在,测试这种逻辑是为什么整本书都写了。模拟是一个专门设计用来验证测试输出的假对象(不像假对象,它只是提供测试输入的对象)。然而,在上面的例子中,模拟仍然需要一种方式来创建,以便验证函数的输出。这就是工厂模式的作用。

让我们修改answer类,如下所示:

class answer
{
    std::string m_answer;

public:
    answer(std::string str) :
        m_answer{std::move(str)}
    { }

    static inline auto make_answer(std::string str)
    { return answer(str); }
};

如上所示的代码中,我们添加了一个静态函数,允许answer类创建自己的实例。我们没有改变answer类不提供提取其内部内容的能力,只是改变了answer类的创建方式。然后我们可以修改know_it_all类,如下所示:

template<factory_t factory = answer::make_answer>
class know_it_all
{
public:
    auto ask_question(const char *question)
    {
        (void) question;
        return factory("The answer is: 42");
    }
};

如上所示的代码中,唯一的区别是know_it_all类接受factory_t的模板参数,并使用它来创建answer类,而不是直接创建answer类。factory_t的定义如下:

using factory_t = answer(*)(std::string str);

这默认使用了我们添加到answer类中的静态make_answer()函数。在最简单的形式下,上面的例子演示了工厂模式。我们不直接创建对象,而是将对象的创建委托给另一个对象。上述实现并不改变这两个类的使用方式,如下所示:

int main(void)
{
    know_it_all universe;
    auto ___ = universe.ask_question("What is the meaning of life?");
}

如上所示,main()逻辑保持不变,但这种新方法确保know_it_all类专注于回答问题,而不必担心如何创建answer类本身,将这个任务留给另一个对象。这个微妙变化背后的真正力量是,我们现在可以为know_it_all类提供一个不同的工厂,从而返回一个不同的answer类。为了演示这一点,让我们创建一个新的answer类,如下所示:

class expected_answer : public answer
{
public:
    expected_answer(std::string str) :
        answer{str}
    {
        if (str != "The answer is: 42") {
            std::cerr << "wrong answer: " << str << '\n';
            exit(1);
        }

        std::cout << "correct answer: " << str << '\n';
    }

    static inline answer make_answer(std::string str)
    { return expected_answer(str); }
};

如上所示,我们创建了一个新的answer类,它是原始answer类的子类。这个新类在构造时检查给定的值,并根据提供的字符串输出成功或失败。然后我们可以使用这个新的answer类,如下所示:

int main(void)
{
    know_it_all<expected_answer::make_answer> universe;
    auto ___ = universe.ask_question("What is the meaning of life?");
}

以下是结果输出:

使用上述方法,我们可以询问不同的问题,以查看know_it_all类是否提供了正确的答案,而无需修改原始的answer类。例如,假设know_it_all类是这样实现的:

template<factory_t factory = answer::make_answer>
class know_it_all
{
public:
    auto ask_question(const char *question)
    {
        (void) question;
        return factory("Not sure");
    }
};

我们测试了这个know_it_all类的版本,如下所示:

int main(void)
{
    know_it_all<expected_answer::make_answer> universe;
    auto ___ = universe.ask_question("What is the meaning of life?");
}

结果将如下所示:

应该注意的是,有几种实现工厂模式的方法。上述方法使用模板参数来改变know_it_all类创建答案的方式,但我们也可以使用运行时方法,就像这个例子中一样:

class know_it_all
{
    std::function<answer(std::string str)> m_factory;

public:
    know_it_all(answer(*f)(std::string str) = answer::make_answer) :
        m_factory{f}
    { }

    auto ask_question(const char *question)
    {
        (void) question;
        return m_factory("The answer is: 42");
    }
};

在上文中,我们首先使用自定义的know_it_all构造函数,它存储了一个指向工厂函数的指针,该函数默认为我们的answer类,但提供了更改工厂的能力,如下所示:

int main(void)
{
    know_it_all universe(expected_answer::make_answer);
    auto ___ = universe.ask_question("What is the meaning of life?");
}

如果需要,我们还可以为这个类添加一个 setter 来在运行时更改这个函数指针。

正确使用单例模式

在这个教程中,我们将学习如何在 C++11 及以上正确实现单例模式,以及何时适合使用单例模式。这个教程很重要,因为它将教会你何时使用单例模式,它提供了对单个全局资源的清晰定义,确保资源保持全局,而不会出现多个副本的可能性。

准备工作

在开始之前,请确保满足所有的技术要求,包括安装 Ubuntu 18.04 或更高版本,并在终端窗口中运行以下命令:

> sudo apt-get install build-essential git cmake

这将确保你的操作系统具有编译和执行本书中示例所需的适当工具。完成后,打开一个新的终端。我们将使用这个终端来下载、编译和运行我们的示例。

如何做...

按照以下步骤尝试单例模式:

  1. 从一个新的终端,运行以下命令来下载源代码:
> cd ~/
> git clone https://github.com/PacktPublishing/Advanced-CPP-CookBook.git
> cd Advanced-CPP-CookBook/chapter11
  1. 要编译源代码,请运行以下命令:
> cmake .
> make recipe01_examples
  1. 一旦源代码被编译,你可以通过运行以下命令来执行本书中的每个示例:
> ./recipe02_example01
memory: 0x4041a0
i1: 0x4041a0
i2: 0x4041a4
i3: 0x4041a8
i4: 0x4041ac

> ./recipe02_example02
memory: 0x4041a0
i1: 0x4041a0
i2: 0x4041a4
i3: 0x4041a0
i4: 0x4041a4

> ./recipe02_example03
memory: 0x4041a0
i1: 0x4041a0
i2: 0x4041a4
i3: 0x4041a8
i4: 0x4041ac

> ./recipe02_example04
memory: 0x4041a0
i1: 0x4041a0
i2: 0x4041a4
i3: 0x4041a8
i4: 0x4041ac

在下一节中,我们将逐个介绍这些示例,并解释每个示例程序的作用以及它与本书所教授的课程的关系。

工作原理...

单例模式在 C++中已经存在了好几年,可以说是 C++中最具争议的模式之一,因为其全局性质会在应用程序中引入耦合(类似于全局变量引入的耦合)。单例模式实现了一个单一的全局资源。具体来说,它创建了一个维持全局范围的对象,同时确保自身没有副本存在。关于是否应该在代码中使用单例模式的争论将不会在本书中得到解答,因为这取决于你的用例,但至少让我们来讨论一下这种模式的一些优缺点。

优点:单例模式为只能包含一个实例的全局资源提供了一个明确定义的接口。不管我们喜欢与否,全局资源存在于我们所有的应用程序中(例如,堆内存)。如果需要这样一个全局资源,并且你有一种处理耦合的机制(例如,Hippomocks 这样的模拟引擎),单例模式是确保全局资源得到正确管理的好方法。

缺点:以下是缺点:

  • 单例模式定义了一个全局资源,就像任何全局资源(例如,全局变量)一样,使用单例对象的任何代码都会与单例对象紧密耦合。在面向对象设计中,耦合应该始终被避免,因为它会阻止你能够伪造代码可能依赖的资源,这会限制测试时的灵活性。

  • 单例模式隐藏了依赖关系。当检查一个对象的接口时,无法确定对象的实现是否依赖于全局资源。大多数人认为这可以通过良好的文档来处理。

  • 单例模式在应用程序的整个生命周期中保持其状态。这在单元测试时尤其明显(也就是说,缺点是显而易见的),因为单例的状态会从一个单元测试传递到下一个单元测试,这被大多数人认为是对单元测试的违反。

一般来说,全局资源应该始终被避免。为了确保你的代码被正确编写以实施单例设计模式,如果你需要一个单一的全局资源。让我们讨论以下的例子。

假设你正在为一个嵌入式设备编写应用程序,你的嵌入式设备有一个额外的内存池,你可以将其映射到你的应用程序中(例如,用于视频或网络设备的设备内存)。现在,假设你只能有一个这样的额外内存池,并且你需要实现一组 API 来从这个池中分配内存。在我们的例子中,我们将使用以下方式来实现这个内存池:

uint8_t memory[0x1000] = {};

接下来,我们将实现一个内存管理器类,以从这个池中分配内存,如下所示:

class mm
{
    uint8_t *cursor{memory};

public:
    template<typename T>
    T *allocate()
    {
        if (cursor + sizeof(T) > memory + 0x1000) {
            throw std::bad_alloc();
        }

        auto ptr = new (cursor) T;
        cursor += sizeof(T);

        return ptr;
    }
};

如前所示的代码,我们创建了一个内存管理器类,它存储指向包含我们单一全局资源的内存缓冲区的指针。然后我们创建一个简单的分配函数,根据需要处理这个内存(没有释放的能力,这使得算法非常简单)。

由于这是一个全局资源,我们可以全局创建这个类,如下所示:

mm g_mm;

最后,我们可以按照以下方式使用我们的新内存管理器:

int main(void)
{
    auto i1 = g_mm.allocate<int>();
    auto i2 = g_mm.allocate<int>();
    auto i3 = g_mm.allocate<int>();
    auto i4 = g_mm.allocate<int>();

    std::cout << "memory: " << (void *)memory << '\n';
    std::cout << "i1: " << (void *)i1 << '\n';
    std::cout << "i2: " << (void *)i2 << '\n';
    std::cout << "i3: " << (void *)i3 << '\n';
    std::cout << "i4: " << (void *)i4 << '\n';
}

在上面的例子中,我们分配了四个整数指针,然后输出我们内存块的地址和整数指针的地址,以确保算法按预期工作,结果如下:

如前所示,内存管理器根据需要正确分配内存。

前面实现的问题在于内存管理器只是一个像其他类一样的类,这意味着它可以被创建多次以及被复制。为了更好地说明这是一个问题,让我们看下面的例子。我们不是创建一个内存管理器,而是创建两个:

mm g_mm1;
mm g_mm2;

接下来,让我们按照以下方式使用这两个内存管理器:

int main(void)
{
    auto i1 = g_mm1.allocate<int>();
    auto i2 = g_mm1.allocate<int>();
    auto i3 = g_mm2.allocate<int>();
    auto i4 = g_mm2.allocate<int>();

    std::cout << "memory: " << (void *)memory << '\n';
    std::cout << "i1: " << (void *)i1 << '\n';
    std::cout << "i2: " << (void *)i2 << '\n';
    std::cout << "i3: " << (void *)i3 << '\n';
    std::cout << "i4: " << (void *)i4 << '\n';
}

如前所示,唯一的区别是现在我们使用两个内存管理器而不是一个。这导致以下输出:

如前所示,内存已经被双重分配,这可能导致损坏和未定义的行为。发生这种情况的原因是内存缓冲区本身是一个全局资源,这是我们无法改变的。内存管理器本身并没有做任何事情来确保这种情况不会发生,因此,这个 API 的用户可能会意外地创建第二个内存管理器。请注意,在我们的例子中,我们明确地创建了第二个副本,但通过简单地传递内存管理器,可能会意外地创建副本。

为了解决这个问题,我们必须处理两种特定的情况:

  • 创建多个内存管理器实例

  • 复制内存管理器

为了解决这两个问题,让我们现在展示单例模式:

class mm
{
    uint8_t *cursor{memory};
    mm() = default;

如前所示,我们从将构造函数标记为private开始。将构造函数标记为private可以防止内存管理器的使用者创建自己的内存管理器实例。相反,要获得内存管理器的实例,我们将使用以下public函数:

    static auto &instance()
    {
        static mm s_mm;
        return s_mm;
    }

这个前面的函数创建了内存管理器的静态(即全局)实例,然后返回对这个实例的引用。使用这个函数,API 的用户只能从这个函数中获得内存管理器的实例,这个函数总是只返回对全局定义资源的引用。换句话说,没有能力创建额外的类实例,否则编译器会报错。

创建单例类的最后一步是以下:

    mm(const mm &) = delete;
    mm &operator=(const mm &) = delete;
    mm(mm &&) = delete;
    mm &operator=(mm &&) = delete;

如前所示,复制和移动构造函数/操作符被明确删除。这解决了第二个问题。通过删除复制构造函数和操作符,就没有能力创建全局资源的副本,确保类只存在为单一全局对象。

要使用这个单例类,我们需要做以下操作:

int main(void)
{
    auto i1 = mm::instance().allocate<int>();
    auto i2 = mm::instance().allocate<int>();
    auto i3 = mm::instance().allocate<int>();
    auto i4 = mm::instance().allocate<int>();

    std::cout << "memory: " << (void *)memory << '\n';
    std::cout << "i1: " << (void *)i1 << '\n';
    std::cout << "i2: " << (void *)i2 << '\n';
    std::cout << "i3: " << (void *)i3 << '\n';
    std::cout << "i4: " << (void *)i4 << '\n';
}

这导致以下输出:

如果我们尝试自己创建另一个内存管理器实例,我们会得到类似以下的错误:

/home/user/book/chapter11/recipe02.cpp:166:4: error: ‘constexpr mm::mm()’ is private within this context
  166 | mm g_mm;

最后,由于单例类是一个单一的全局资源,我们可以创建包装器来消除冗长,如下所示:

template<typename T>
constexpr T *allocate()
{
    return mm::instance().allocate<T>();
}

这个改变可以按照以下方式使用:

int main(void)
{
    auto i1 = allocate<int>();
    auto i2 = allocate<int>();
    auto i3 = allocate<int>();
    auto i4 = allocate<int>();

    std::cout << "memory: " << (void *)memory << '\n';
    std::cout << "i1: " << (void *)i1 << '\n';
    std::cout << "i2: " << (void *)i2 << '\n';
    std::cout << "i3: " << (void *)i3 << '\n';
    std::cout << "i4: " << (void *)i4 << '\n';
}

如前所示,constexpr包装器提供了一种简单的方法来消除我们单例类的冗长,如果内存管理器不是单例的话,这将是很难做到的。

使用装饰器模式扩展您的对象

在这个示例中,我们将学习如何实现装饰器模式,该模式提供了在不需要继承的情况下扩展类功能的能力,这是静态性质的设计。这个示例很重要,因为继承不支持在运行时扩展类的能力,这是装饰器模式解决的问题。

准备工作

在开始之前,请确保满足所有技术要求,包括安装 Ubuntu 18.04 或更高版本,并在终端窗口中运行以下命令:

> sudo apt-get install build-essential git cmake

这将确保您的操作系统具有编译和执行本示例所需的适当工具。完成后,打开一个新的终端。我们将使用这个终端来下载、编译和运行我们的示例。

如何做...

执行以下步骤尝试这个示例:

  1. 从新的终端运行以下命令以下载源代码:
> cd ~/
> git clone https://github.com/PacktPublishing/Advanced-CPP-CookBook.git
> cd Advanced-CPP-CookBook/chapter11
  1. 要编译源代码,请运行以下命令:
> cmake .
> make recipe03_examples
  1. 源代码编译完成后,您可以通过运行以下命令执行本示例中的每个示例:
> ./recipe03_example01
button width: 42

> ./recipe03_example02
button1 width: 10
button2 width: 42

> ./recipe03_example03
button width: 74

> ./recipe03_example04
button width: 42
button content width: 4

在下一节中,我们将逐个介绍这些示例,并解释每个示例程序的作用以及它与本示例中所教授的课程的关系。

它是如何工作的...

在这个示例中,我们将学习如何实现装饰器模式。首先,让我们看一个简单的例子:假设我们正在编写一个 C++应用程序,将托管一个网站。在我们的网站中,我们需要定义一个用户可以点击的按钮,但我们需要计算给定额外边距的按钮的宽度:

class margin
{
public:
    int width()
    {
        return 32;
    }
};

如前所示,我们创建了一个名为margin的类,返回所讨论边距的宽度(我们只关注宽度以简化我们的示例)。然后我们可以按照以下方式定义我们的按钮:

class button : public margin
{
public:
    int width()
    {
        return margin::width() + 10;
    }
};

如前所示,我们按钮的总宽度是按钮本身的宽度加上边距的宽度。然后我们可以按照以下方式获取按钮的宽度:

int main()
{
    auto b = new button();
    std::cout << "button width: " << b->width() << '\n';
}

这将产生以下输出:

前面示例的问题是按钮必须始终具有边距,因为按钮直接继承了边距类。有方法可以防止这种情况发生(例如,我们的按钮可以有一个配置选项,确定按钮是否返回带有边距的宽度),但在这个示例中,我们将使用装饰器模式来解决这个问题,允许我们创建两个按钮:一个带有边距的按钮,一个没有边距的按钮。让我们试试看:

  1. 首先,让我们定义以下纯虚基类如下:
class base
{
public:
    virtual int width() = 0;
};

如前所示,纯虚基类定义了width函数。

  1. 然后我们可以按照以下方式实现我们的按钮:
class button : public base
{
public:
    int width() override
    {
        return 10;
    }
};

如前所示,按钮继承了基类并返回10的宽度。使用上述,我们可以开始button始终是10的宽度,按钮没有边距的概念。

  1. 要向按钮添加边距,我们首先必须创建一个装饰器类,如下所示:
class decorator : public base
{
    std::unique_ptr<base> m_base;

public:
    decorator(std::unique_ptr<base> b) :
        m_base{std::move(b)}
    { }

    int width()
    {
        return m_base->width();
    }
};

装饰器模式从一个私有成员开始,指向一个base指针,该指针在装饰器的构造函数中设置。装饰器还定义了width函数,但将调用转发给基类。

  1. 现在,我们可以创建一个边距类,它是一个装饰器,如下所示:
class margin : public decorator
{
public:
    margin(std::unique_ptr<base> b) :
        decorator{std::move(b)}
    { }

    int width()
    {
        return decorator::width() + 32;
    }
};

如前所示,边距类返回所装饰对象的宽度,并额外添加32

  1. 然后我们可以按照以下方式创建我们的两个按钮:
int main()
{
    auto button1 = std::make_unique<button>();
    auto button2 = std::make_unique<margin>(std::make_unique<button>());

    std::cout << "button1 width: " << button1->width() << '\n';
    std::cout << "button2 width: " << button2->width() << '\n';
}

这将产生以下输出:

装饰器模式的最大优势是它允许我们在运行时扩展一个类。例如,我们可以创建一个带有两个边距的按钮:

int main()
{
    auto b =
        std::make_unique<margin>(
            std::make_unique<margin>(
                std::make_unique<button>()
            )
        );

    std::cout << "button width: " << b->width() << '\n';
}

否则,我们可以创建另一个装饰器。为了演示这一点,让我们扩展我们的基类如下:

class base
{
public:
    virtual int width() = 0;
    virtual int content_width() = 0;
};

前面的基类现在定义了一个宽度和一个内容宽度(我们按钮内部可以实际使用的空间)。现在,我们可以按照以下方式创建我们的按钮:

class button : public base
{
public:
    int width() override
    {
        return 10;
    }

    int content_width() override
    {
        return width() - 1;
    }
};

如前所示,我们的按钮具有静态宽度,内容宽度与宽度本身相同减去 1(为按钮的边框留出空间)。然后我们定义我们的装饰器如下:

class decorator : public base
{
    std::unique_ptr<base> m_base;

public:
    decorator(std::unique_ptr<base> b) :
        m_base{std::move(b)}
    { }

    int width() override
    {
        return m_base->width();
    }

    int content_width() override
    {
        return m_base->content_width();
    }
};

如前所示,唯一的区别是装饰器现在必须转发宽度和内容宽度函数。我们的边距装饰器如下所示:

class margin : public decorator
{
public:
    margin(std::unique_ptr<base> b) :
        decorator{std::move(b)}
    { }

    int width() override
    {
        return decorator::width() + 32;
    }

    int content_width() override
    {
        return decorator::content_width();
    }
};

与 Web 编程一样,边距增加了对象的大小。它不会改变对象内部内容的空间,因此边距返回的是内容宽度,没有进行修改。通过前面的更改,我们现在可以按照以下方式添加填充装饰器:

class padding : public decorator
{
public:
    padding(std::unique_ptr<base> b) :
        decorator{std::move(b)}
    { }

    int width() override
    {
        return decorator::width();
    }

    int content_width() override
    {
        return decorator::content_width() - 5;
    }
};

填充装饰器与边距装饰器相反。它不会改变对象的大小,而是减少了给对象内部内容的总空间。因此,它不会改变宽度,但会减小内容的大小。

使用我们的新装饰器创建一个按钮,我们可以使用以下命令:

int main()
{
    auto b =
        std::make_unique<margin>(
            std::make_unique<padding>(
                std::make_unique<button>()
            )
        );

    std::cout << "button width: " << b->width() << '\n';
    std::cout << "button content width: " << b->content_width() << '\n';
}

如前所示,我们创建了一个具有额外边距和填充的按钮,结果如下输出:

装饰器模式提供了创建不同按钮的能力,而无需编译时继承,这将要求我们为每种可能的按钮类型都有一个不同的按钮定义。然而,需要注意的是,装饰器模式会增加分配和函数调用的重定向成本,因此这种运行时灵活性是有代价的。

添加与观察者模式的通信

在这个食谱中,我们将学习如何实现观察者模式。观察者模式提供了一个类注册到另一个类以接收事件发生时的通知的能力。Qt 语言通过使用其信号和槽机制提供了这一功能,同时需要使用 MOC 编译器使其工作。这个食谱很重要,因为我们将学习如何在不需要 Qt 的情况下实现观察者模式,而是使用标准的 C++。

准备工作

开始之前,请确保满足所有技术要求,包括安装 Ubuntu 18.04 或更高版本,并在终端窗口中运行以下命令:

> sudo apt-get install build-essential git cmake

这将确保您的操作系统具有编译和执行本食谱中示例所需的适当工具。完成后,打开一个新的终端。我们将使用这个终端来下载、编译和运行我们的示例。

操作步骤...

执行以下步骤来尝试这个食谱:

  1. 从一个新的终端,运行以下命令来下载源代码:
> cd ~/
> git clone https://github.com/PacktPublishing/Advanced-CPP-CookBook.git
> cd Advanced-CPP-CookBook/chapter11
  1. 要编译源代码,请运行以下命令:
> cmake .
> make recipe04_examples
  1. 一旦源代码编译完成,您可以通过运行以下命令来执行本食谱中的每个示例:
> ./recipe04_example01 
mom's phone received alarm notification
dad's phone received alarm notification

在下一节中,我们将逐个介绍这些示例,并解释每个示例程序的作用,以及它与本食谱中所教授的课程的关系。

工作原理...

观察者模式提供了观察者在事件发生时被通知的能力。为了解释这是如何工作的,让我们从以下纯虚基类开始:

class observer
{
public:
    virtual void trigger() = 0;
};

如前所示,我们定义了observer,它必须实现trigger()函数。然后我们可以创建两个不同版本的这个纯虚基类,如下所示:

class moms_phone : public observer
{
public:
    void trigger() override
    {
        std::cout << "mom's phone received alarm notification\n";
    }
};

class dads_phone : public observer
{
public:
    void trigger() override
    {
        std::cout << "dad's phone received alarm notification\n";
    }
};

如前所示的代码,我们创建了两个不同的类,它们都是观察者纯虚类的子类,重写了触发函数。然后我们可以实现一个产生观察者可能感兴趣的事件的类,如下所示:

class alarm
{
    std::vector<observer *> m_observers;

public:
    void trigger()
    {
        for (const auto &o : m_observers) {
            o->trigger();
        }
    }

    void add_phone(observer *o)
    {
        m_observers.push_back(o);
    }
};

如前面的代码所示,我们首先使用std::vector来存储任意数量的观察者。然后我们提供一个触发函数,代表我们的事件。当执行此函数时,我们循环遍历所有观察者,并通过调用它们的trigger()函数来通知它们事件。最后,我们提供一个函数,允许观察者订阅相关事件。

以下演示了如何使用这些类:

int main(void)
{
    alarm a;
    moms_phone mp;
    dads_phone dp;

    a.add_phone(&mp);
    a.add_phone(&dp);

    a.trigger();
}

这将产生以下输出:

如前所示,当触发警报类时,观察者将收到事件通知并根据需要处理通知。

使用静态多态性来提高性能

在这个教程中,我们将学习如何创建多态性,而无需虚拟继承。相反,我们将使用编译时继承(称为静态多态性)。这个教程很重要,因为静态多态性不会像运行时虚拟继承那样产生性能和内存使用的惩罚(因为不需要 vTable),但会牺牲可读性和无法利用虚拟子类化的运行时优势。

准备工作

在开始之前,请确保满足所有技术要求,包括安装 Ubuntu 18.04 或更高版本,并在终端窗口中运行以下命令:

> sudo apt-get install build-essential git cmake

这将确保您的操作系统具有编译和执行本教程中示例所需的适当工具。完成后,打开一个新的终端。我们将使用这个终端来下载、编译和运行我们的示例。

如何做...

按照以下步骤尝试本教程:

  1. 从新的终端中运行以下命令来下载源代码:
> cd ~/
> git clone https://github.com/PacktPublishing/Advanced-CPP-CookBook.git
> cd Advanced-CPP-CookBook/chapter11
  1. 要编译源代码,请运行以下命令:
> cmake .
> make recipe05_examples
  1. 源代码编译完成后,您可以通过运行以下命令来执行本教程中的每个示例:
> ./recipe05_example01
subclass1 specific
common
subclass2 specific
common
> ./recipe05_example02
subclass1 specific
common
subclass2 specific
common

在下一节中,我们将逐个介绍这些示例,并解释每个示例程序的作用,以及它与本教程中所教授的课程的关系。

工作原理...

多态性的主要目标之一是它提供了覆盖对象执行特定函数的能力,同时也提供了在一组对象中提供通用逻辑的能力。虚拟继承的问题在于,如果希望使用基类作为接口,覆盖的能力就需要使用 vTable(即虚拟表,这是处理虚拟继承所需的额外内存块)。

例如,考虑以下代码:

class base
{
public:
    virtual void foo() = 0;

    void common()
    {
        std::cout << "common\n";
    }
};

让我们从之前定义的基类开始。它提供了一个foo()函数作为纯虚函数(即,子类必须实现此函数),同时还提供了自己的通用逻辑。然后我们可以创建两个子类,如下所示:

class subclass1 : public base
{
public:
    void foo() override
    {
        std::cout << "subclass1 specific\n";
    }
};

class subclass2 : public base
{
public:
    void foo() override
    {
        std::cout << "subclass2 specific\n";
    }
};

如前所示,我们对基类进行子类化,并使用子类特定功能重写foo()函数。然后我们可以从基类调用子类特定的foo()函数,如下所示:

int main(void)
{
    subclass1 s1;
    subclass2 s2;

    base *b1 = &s1;
    base *b2 = &s2;

    b1->foo();
    b1->common();

    b2->foo();
    b2->common();
}

这将产生以下输出:

这种类型的运行时多态性需要使用 vTable,这不仅增加了每个对象的内存占用,还会导致性能损失,因为每个函数调用都需要进行 vTable 查找。如果不需要虚拟继承的运行时特性,静态多态性可以提供相同的功能而不会产生这些惩罚。

首先,让我们定义基类如下:

template<typename T>
class base
{
public:
    void foo()
    { static_cast<T *>(this)->foo(); }

    void common()
    {
        std::cout << "common\n";
    }
};

与我们之前的示例一样,基类不实现foo()函数,而是要求子类实现此函数(这就允许静态转换将其转换为类型T)。

然后我们可以按以下方式实现我们的子类:

class subclass1 : public base<subclass1>
{
public:
    void foo()
    {
        std::cout << "subclass1 specific\n";
    }
};

class subclass2 : public base<subclass2>
{
public:
    void foo()
    {
        std::cout << "subclass2 specific\n";
    }
};

与前面的例子一样,子类只是实现了foo()函数。不同之处在于,这种情况下继承需要使用模板参数,这消除了foo()函数需要覆盖的需要,因为基类从未使用虚函数。

前面的静态多态性允许我们执行来自基类的foo()函数如下:

template<typename T>
void test(base<T> b)
{
    b.foo();
    b.common();
}

如前所示,test()函数对每个子类都没有任何信息。它只有关于基类(或接口)的信息。这个test()函数可以这样执行:

int main(void)
{
    subclass1 c1;
    subclass2 c2;

    test(c1);
    test(c2);
}

这再次导致相同的输出:

如前所示,如果在编译时知道多态类型,可以使用静态多态性来消除对virtual的需要,从而消除对 vTable 的需要。这种逻辑在使用模板类时特别有帮助,其中基本类型已知但子类类型不知道(并且已提供),允许模板函数只需要基本接口。

第十二章:仔细观察类型推断

在本章中,您将学习 C++中类型推断的所有细节,包括 C++17 中的一些新添加。本章非常重要,因为它将教会您编译器将如何尝试自动推断类型信息。如果不了解 C++中类型推断的工作原理,可能会创建出现预期之外的代码,特别是在使用auto和模板编程时。从本章中获得的知识将为您提供在自己的应用程序中正确利用类型推断的技能。

本章中的示例如下:

  • 使用 auto 和类型推断

  • 学习decltype类型推断规则的工作方式

  • 使用模板函数类型推断

  • 在 C++17 中利用模板类类型推断

  • 在 C++17 中使用用户定义的类型推断

技术要求

要编译和运行本章中的示例,您必须具有对运行 Ubuntu 18.04 的计算机的管理访问权限,并具有功能正常的互联网连接。在运行这些示例之前,您必须安装以下内容:

> sudo apt-get install build-essential git cmake 

如果此软件安装在 Ubuntu 18.04 以外的任何操作系统上,则需要 GCC 7.4 或更高版本和 CMake 3.6 或更高版本。

本章的代码文件可以在github.com/PacktPublishing/Advanced-CPP-CookBook/tree/master/chapter12找到。

使用 auto 和类型推断

在本示例中,我们将学习编译器如何处理auto关键字,特别是类型推断。本示例很重要,因为auto的处理方式并不直观,如果不清楚auto的工作原理,您的代码可能会包含错误和性能问题。本示例中包括auto的一般描述、类型推断、转发(或通用)引用、l 值和 r 值。

准备工作

开始之前,请确保满足所有技术要求,包括安装 Ubuntu 18.04 或更高版本,并在终端窗口中运行以下命令:

> sudo apt-get install build-essential git cmake

这将确保您的操作系统具有编译和执行本示例中的示例所需的适当工具。完成后,打开一个新的终端。我们将使用此终端来下载、编译和运行我们的示例。

如何做...

按照以下步骤尝试本示例:

  1. 从新的终端运行以下命令以下载源代码:
> cd ~/
> git clone https://github.com/PacktPublishing/Advanced-CPP-CookBook.git
> cd Advanced-CPP-CookBook/chapter12
  1. 要编译源代码,请运行以下命令:
> cmake .
> make recipe01_examples
  1. 编译源代码后,可以通过运行以下命令执行本示例中的每个示例:
> ./recipe01_example01
i1 = int
i2 = int
i3 = std::initializer_list<int>
i4 = std::initializer_list<int>
c = char
r = int

> ./recipe01_example02
i1 = int
i2 = const int
i3 = volatile int
i4 = const volatile int

> ./recipe01_example03
i1 = int
i2 = int&
a1 = int
a2 = int
a3 = int
a4 = int&
i3 = int&&
a5 = int&
a6 = int&
a7 = int&
a8 = int&
a9 = int&&
a10 = int&&

> ./recipe01_example04
i1 = int
i2 = const int&
i3 = const int&&

在下一节中,我们将逐个介绍这些示例,并解释每个示例程序的作用及其与本示例中所教授的课程的关系。

它是如何工作的...

auto关键字是 C++11 中添加的一个特性,称为占位类型说明符。换句话说,auto关键字用于告诉编译器变量的类型将从其初始化程序中推断出来。与其他使用占位类型的语言不同,auto关键字仍然必须遵守 C++的严格类型系统,这意味着auto不应与std::any混淆。

例如,使用std::any可以实现以下功能:

std::any i = 42;
i = "The answer is: 42";

以下是不允许使用auto的情况:

auto i = 42;
i = "The answer is: 42";

在第一个示例中,我们定义了std::any,它存储一个整数。然后,我们用 C 风格的字符串替换std::any中的整数。就auto而言,这是不可能的,因为一旦编译器在初始化时推断出变量的类型,类型就不能改变(与 C++中的任何其他变量一样)。

让我们看一个使用auto初始化变量的简单示例:

int main(void)
{
    auto i1 = 42;
    auto i2{42};
    auto i3 = {42};
    auto i4 = {4, 8, 15, 16, 23, 42};

    show_type(i1);
    show_type(i2);
    show_type(i3);
    show_type(i4);

    char c = 0;
    auto r = c + 42;

    show_type(c);
    show_type(r);
}

运行此示例将产生以下输出:

如前面的代码所示,我们使用auto创建了四个变量,对它们进行初始化,然后使用一个名为show_type()的函数返回变量类型的输出。

有关show_type()函数的更多信息,请参阅本章附带的代码(在阅读完整个章节后,这个函数的细节会更容易理解)。

我们示例中的第一个变量i1被推断为整数。这是因为 C++中的数值类型总是被推断为整数,我们在示例中的cr变量中也看到了这一点。原因是编译器允许在编译期间增加任何变量的大小,这意味着当编译器看到c + 42时,它首先将c的值存储在一个临时整数中,然后完成加法。

在我们的示例中,第二个变量i2也被推断为整数,因为{}符号是 C++中任何类型的另一种初始化形式,具有一些额外的规则。具体来说,i3i4被推断为整数的std::initializer_list,因为最后两个使用了= {}符号,根据 C++17 的规定,它们总是被推断为std::initializer_list。值得注意的是,这假设编译器遵守规范,在这个特定的例子中并不总是如此,这就是为什么像 AUTOSAR 这样的关键系统规范不允许这种类型的初始化。

auto关键字也可以与 CV 限定符(即const/volatile)结合使用。看看这个例子:

int main(void)
{
    auto i1 = 42;
    const auto i2 = 42;
    volatile auto i3 = 42;
    const volatile auto i4 = 42;

    show_type(i1);
    show_type(i2);
    show_type(i3);
    show_type(i4);
}

前面的例子产生了以下输出:

如前面的截图所示,每个变量都带有适当的 CV 限定符。

到目前为止,在每个示例中,我们可以简单地用int替换auto,什么都不会改变,这就引出了一个问题,为什么要首先使用auto?有几个原因:

  • 使用除auto之外的东西意味着你的代码很可能会两次指定变量的类型。例如,int *ptr = new int;表示ptr变量是整数两次:一次在变量声明中,一次在变量的初始化中。

  • C++中的一些类型非常长(例如迭代器),使用auto可以极大地简化代码的冗长,例如auto i = v.begin()

  • 在编写模板代码时,auto需要正确处理引用类型,比如转发引用。

处理引用是使用auto变得混乱的地方,也是大多数人犯错误的地方。为了更好地解释,让我们看看以下例子:

int main(void)
{
    int i = 42;

    int i1 = i;
    int &i2 = i;

    show_type(i1);
    show_type(i2);

    auto a1 = i1;
    auto a2 = i2;

    show_type(a1);
    show_type(a2);
}

这导致了以下输出:

i1 = int
i2 = int&
a1 = int
a2 = int

如前面的示例所示,我们创建了一个整数i,并将其设置为42。然后我们创建了另外两个整数:一个是i的副本,另一个是对i的引用。如输出所示,我们得到了预期的类型,intint&。使用auto关键字,我们可以期望,如果我们说类似auto a = i2,我们会得到int&类型,因为i2是对整数的引用,而且由于auto根据初始化方式推断其类型,我们应该得到int&。问题是,我们没有。相反,我们得到了int

这是因为auto的类型是根据它的初始化方式确定的,而不包括引用类型。换句话说,示例中对auto的使用只是捕捉了i2的类型,而没有注意i2是整数还是整数的引用。要强制auto成为整数的引用,我们必须使用以下语法:

auto a3 = i1;
auto &a4 = i2;

show_type(a3);
show_type(a4);

这导致了以下输出:

a3 = int
a4 = int&

这个输出是预期的。相同的规则也适用于右值引用,但会变得更加复杂。例如,考虑以下代码:

int &&i3 = std::move(i);
show_type(i3);

这导致了以下输出:

i3 = int&&

这个输出再次符合预期。根据我们已经学到的知识,我们期望以下内容需要才能获得 r 值引用:

auto &&a5 = i3;
show_type(a6);

问题在于这导致了以下输出:

a5 = int&

如前面的例子所示,我们没有得到预期的 r 值引用。在 C++中,任何标记为auto &&的东西都被认为是一个转发引用(这也被称为通用引用,这是 Scott Meyers 创造的术语)。通用引用将根据初始化的内容推导为 l 值或 r 值引用。

因此,例如,考虑以下代码:

auto &&a6 = i1;
show_type(a6);

这段代码导致了以下结果:

a6 = int&

这是因为i1之前被定义为整数,所以a6变成了i1的 l 值引用。以下也是真的:

auto &&a7 = i2;
show_type(a7);

前面的代码导致了以下结果:

a7 = int&

这是因为i2之前被定义为整数的 l 值引用,这意味着通用引用也变成了整数的 l 值引用。

混乱的结果如下,如前面的代码片段中已经显示的那样:

auto &&a8 = i3;
show_type(a8);

这再次导致了以下结果:

a8 = int&

在这里,i3之前被定义为整数的 r 值引用(根据结果输出),但通用引用没有从i3中转发 r 值。这是因为,尽管i3被定义为 r 值引用,一旦被使用,它就变成了 l 值引用。正如 Scott Meyer 过去所说的,如果一个变量有一个名字(在我们的例子中是i3),它就是一个 l 值,即使它起初是一个 r 值。另一种看待这个问题的方式是,一旦一个变量被使用(即以任何方式被访问),这个变量就是一个 l 值。因此,前面的代码实际上是按照预期工作的。i3,尽管被定义为 r 值,是一个 l 值,因此通用引用变成了整数的 l 值引用,就像i1i2一样。

要使用auto获得 r 值引用,你必须像不使用auto一样做相同的事情:

auto &&a9 = std::move(i3);
show_type(a9);

这导致了以下结果:

a9 = int&&

如前面的代码片段所示,思考auto的最佳方式就是简单地用实际类型(在本例中为int)替换auto,并且实际类型适用的规则也适用于auto。不同之处在于,如果你尝试写int &&blah = i,你会得到一个错误,因为编译器会认识到你试图从一个 l 值引用创建一个 r 值引用,这是不可能的(因为你只能从另一个 r 值引用创建一个 r 值引用)。

前面的例子之所以如此重要,是因为auto不会引起编译器的投诉。相反,它会在你想要创建 r 值时产生一个 l 值,这可能导致效率低下或错误。关于使用auto最重要的一点是,如果它有一个名字,它就是一个 l 值;否则,它就是一个 r 值。

例如,考虑以下代码:

auto &&a10 = 42;
show_type(a10);

这段代码导致了以下结果:

a10 = int&&

由于数值42没有变量名,它是一个常数,因此通用引用变成了整数的 r 值引用。

还应该注意,使用auto在处理引用时会继承 CV 限定符,这可能会让人感到困惑。看看这个例子:

int main(void)
{
    const int i = 42;

    auto i1 = i;
    auto &i2 = i;
    auto &&i3 = std::move(i);

    show_type(i1);
    show_type(i2);
    show_type(i3);
}

这导致了以下结果:

如前面的屏幕截图所示,第一个整数仍然是int类型,因为const int的副本是int。然而,i2i3都变成了对const int的引用。如果我们用auto替换int,我们将得到一个编译器错误,因为您不能创建对const int的非const引用,但是使用auto将乐意将您的非const变量转换为const变量。这样做的问题是,当您尝试修改变量时,您将得到奇怪的错误消息,抱怨变量是只读的,而实际上,您并没有明确地将变量定义为const。一般来说,如果您期望const,则将使用auto定义的变量标记为const,如果您不期望const,则将其标记为非const,以防止这些有时难以识别的错误。

学习 decltype 类型推断规则的工作方式

在本教程中,我们将学习decltype()decltype(auto)的类型推断工作原理,以及如何使用decltype(auto)来避免auto处理引用的问题。

这个教程很重要,因为auto在处理引用时有一些奇怪的行为,而decltype()则提供了一种更可预测地处理类型推断的方式,特别是在使用 C++模板时。

准备工作

开始之前,请确保满足所有技术要求,包括安装 Ubuntu 18.04 或更高版本,并在终端窗口中运行以下命令:

> sudo apt-get install build-essential git cmake

这将确保您的操作系统具有适当的工具来编译和执行本教程中的示例。完成后,打开一个新的终端。我们将使用这个终端来下载、编译和运行我们的示例。

如何做...

执行以下步骤来尝试这个教程:

  1. 从新的终端中,运行以下命令来下载源代码:
> cd ~/
> git clone https://github.com/PacktPublishing/Advanced-CPP-CookBook.git
> cd Advanced-CPP-CookBook/chapter12
  1. 编译源代码,请运行以下命令:
> cmake .
> make recipe02_examples
  1. 源代码编译完成后,您可以通过运行以下命令来执行本教程中的每个示例:
> ./recipe02_example01
i = int

> ./recipe02_example02
i = short int

> ./recipe02_example03
i = short int

> ./recipe02_example04
i1 = int
i2 = int

> ./recipe02_example05
i1 = int
i2 = const int
i3 = volatile int
i4 = const volatile int

> ./recipe02_example06
i1 = int
i2 = int&
i3 = int&&
a1 = int
a2 = int
a3 = int
a4 = int
a5 = int&
a6 = int&&
d1 = int
d2 = int&
d3 = int&&

在下一节中,我们将逐个介绍这些示例,并解释每个示例程序的作用以及它与本教程中所教授的课程的关系。

工作原理...

在 C++中,autotypename都不能提供获取变量类型并使用该信息创建新类型的能力。为了更好地解释为什么您可能想要这样做,让我们看下面的例子:

template<typename FUNC>
auto question(FUNC &&func)
{
    auto x = func() + 10;
    return x;
}

我们从一个接受任何函数作为输入并返回此函数的结果加上10的函数开始。然后我们可以执行此函数如下:

short the_answer()
{
    return 32;
}

int main(void)
{
    auto i = question(the_answer);
    show_type(i);
}

如前面的示例所示,我们将question()函数传递给另一个返回short的函数的指针。在执行此函数时,我们存储结果,然后使用一个名为show_type()的函数,该函数旨在输出所提供类型的类型。结果如下:

这个示例的问题在于返回的类型与我们给定的类型不同。C++允许根据需要增加任何变量的大小,并且通常会对 short 进行增加,特别是当您尝试对具有数值值的 short 执行算术运算时,因为数值值被表示为整数。

由于我们不知道question()函数中提供的函数的返回类型,因此无法解决此问题。输入decltype()。为了解释清楚,让我们更新我们的示例来解决前面的问题:

template<typename FUNC>
auto question(FUNC &&func)
{
    decltype(func()) x = func() + 10;
    return x;
}

如前面的示例所示,我们用decltype(func())替换了auto。这告诉编译器获取func()的返回类型,并使用该类型来定义x。结果,编译器将此模板转换为以下函数:

short question(short(*func)())
{
    short x = func() + 10;
    return x;
}

这发生在最初预期的以下情况:

int question(short(*func)())
{
    int x = func() + 10;
    return x;
}

然后在执行时会得到以下输出:

如前面的屏幕截图所示,我们现在从我们的question()函数中得到了正确的类型返回。使用 C++14,我们可以进一步将此示例编写为:

template<typename FUNC>
constexpr auto question(FUNC &&func) -> decltype(func())
{
    return func() + 10;
}

在前面代码片段的示例中,我们将question()函数转换为constexpr,这允许编译器优化掉函数调用,用func() + 10语句替换对question()的调用。我们还通过显式告诉编译器我们希望函数返回的类型来消除了对基于堆栈的变量的需求,使用-> decltype()函数返回语法。需要注意的是,由于以下内容不会编译,因此需要此语法:

template<typename FUNC>
constexpr decltype(func()) question(FUNC &&func)
{
    return func() + 10;
}

前面的代码将无法编译,因为编译器还没有func()的定义,因此不知道它的类型。->语法通过将返回类型放在函数定义的末尾而不是开头来解决了这个问题。

decltype()说明符也可以用于替代auto,如下所示:

int main(void)
{
    decltype(auto) i1 = 42;
    decltype(auto) i2{42};

    show_type(i1);
    show_type(i2);
}

这导致了以下输出:

在这个例子中,我们使用decltype(auto)创建了两个整数,并将它们初始化为42。在这种特定情况下,decltype(auto)auto的操作完全相同。两者都将占位符类型定义为整数,因为两者都使用了默认的int初始化为数值,这是默认的。

auto一样,您可以使用 CV 限定符(即const/volatile)装饰decltype(auto),如下所示:

int main(void)
{
    decltype(auto) i1 = 42;
    const decltype(auto) i2 = 42;
    volatile decltype(auto) i3 = 42;
    const volatile decltype(auto) i4 = 42;

    show_type(i1);
    show_type(i2);
    show_type(i3);
    show_type(i4);
}

这导致了以下输出:

decltype(auto)的真正魔力在于它如何处理引用。为了证明这一点,让我们从以下示例开始:

int main(void)
{
    int i = 42;

    int i1 = i;
    int &i2 = i;
    int &&i3 = std::move(i);

    show_type(i1);
    show_type(i2);
    show_type(i3);
}

执行后,我们看到以下输出:

i1 = int
i2 = int&
i3 = int&&

如前面的示例所示,我们创建了一个整数,一个整数的左值引用和一个整数的右值引用。让我们看看如果尝试使用auto而不是int会发生什么:

auto a1 = i1;
auto a2 = i2;
auto a3 = std::move(i3);

show_type(a1);
show_type(a2);
show_type(a3);

然后我们看到以下输出:

a1 = int
a2 = int
a3 = int

如前面的示例所示,我们只得到了整数。所有引用都被移除了。使用auto获取引用的唯一方法是如果我们明确定义它们,如下所示:

auto a4 = i1;
auto &a5 = i2;
auto &&a6 = std::move(i3);

show_type(a4);
show_type(a5);
show_type(a6);

这导致了以下预期的输出:

a4 = int
a5 = int&
a6 = int&&

必须添加额外的&运算符来显式定义引用类型的问题在于,这假设在我们的模板代码中,我们实际上知道引用应该是什么。如果没有这些信息,我们将无法编写模板函数,并且不知道是否可以创建左值引用或右值引用,很可能会导致复制。

为了克服这一点,decltype(auto)不仅在初始化期间继承类型和 CV 限定符,还继承引用关系,如下所示:

decltype(auto) d1 = i1;
decltype(auto) d2 = i2;
decltype(auto) d3 = std::move(i3);

show_type(d1);
show_type(d2);
show_type(d3);

执行前面的代码会导致以下结果:

d1 = int
d2 = int&
d3 = int&&

如前面的示例所示,decltype(auto)可以用于继承被初始化的值的所有类型信息,包括引用关系。

使用模板函数类型推断

在本示例中,我们将学习模板函数类型推断的工作原理。具体来说,本示例将教你模板函数类型推断与auto类型推断相同的工作方式,以及如何将函数类型推断与一些奇怪的类型(例如 C 风格数组)一起使用。

这个示例很重要,因为它将教会你如何正确地编写函数模板,消除在调用函数模板时显式定义类型信息的需要。

准备工作

开始之前,请确保满足所有技术要求,包括安装 Ubuntu 18.04 或更高版本,并在终端窗口中运行以下命令:

> sudo apt-get install build-essential git cmake

这将确保您的操作系统具有正确的工具来编译和执行本示例中的示例。完成后,打开一个新的终端。我们将使用此终端来下载、编译和运行我们的示例。

如何做...

按照以下步骤尝试这个配方:

  1. 从新的终端运行以下命令以下载源代码:
> cd ~/
> git clone https://github.com/PacktPublishing/Advanced-CPP-CookBook.git
> cd Advanced-CPP-CookBook/chapter12
  1. 要编译源代码,请运行以下命令:
> cmake .
> make recipe03_examples
  1. 源代码编译后,可以通过运行以下命令执行本文中的每个示例:
> ./recipe03_example01 
t = int
t = int

> ./recipe03_example02
t = const int&

> ./recipe03_example03
t = int&

> ./recipe03_example04
t = int&

> ./recipe03_example05
t = int&&

> ./recipe03_example06
t = int&&

> ./recipe03_example07
t = const int&

> ./recipe03_example08
t = const int&&

> ./recipe03_example09
t = int (&&)[6]

在下一节中,我们将逐个介绍这些示例,并解释每个示例程序的作用以及它与本文所教授的课程的关系。

工作原理...

在 C++11 中,标准委员会添加了根据传递给函数的参数自动推断模板函数类型信息的能力。

看看这个例子:

template<typename T>
void foo(T t)
{
    show_type(t);
}

前面的函数创建了一个标准模板函数,执行一个名为show_type()的函数,用于输出提供的类型信息。

在 C++11 之前,我们会这样使用这个函数:

int main(void)
{
    int i = 42;

    foo<int>(i);
    foo<int>(42);
}

编译器已经知道模板应该将T类型定义为整数,因为这就是函数提供的内容。C++11 去除了这种冗余,允许以下操作:

int main(void)
{
    int i = 42;

    foo(i);
    foo(42);
}

执行时会得到以下输出:

然而,与auto一样,当使用 r 值引用时,这种类型推断变得有趣,如下所示:

template<typename T>
void foo(T &&t)
{
    show_type(t);
}

前面的示例将t定义为转发引用(也称为通用引用)。通用引用接受传递给它的任何引用类型。例如,我们可以这样调用这个函数:

int main(void)
{
    int i = 42;
    foo(i);
}

我们得到以下输出:

前面的输出显示,模板函数得到了一个整数的 l 值引用。这是因为在我们的主函数中,i是一个 l 值,即使函数似乎要求一个 r 值引用。要获得一个 r 值引用,我们必须提供一个 r 值,如下所示:

int main(void)
{
    int i = 42;
    foo(std::move(i));
}

执行时会得到以下输出:

如前面的屏幕截图所示,现在我们已经给了通用引用一个 r 值,我们得到了一个 r 值。应该注意的是,通用引用只有以下签名:

template<typename T>
void foo(T &&t)

例如,以下不是通用引用:

template<typename T>
void foo(const T &&t)

以下也不是通用引用:

void foo(int &&t)

前面的两个例子都是 r 值引用,因此需要提供一个 r 值(换句话说,这两个函数都定义了移动操作)。通用引用将接受 l 值和 r 值引用。尽管这似乎是一个优势,但它的缺点是有时很难知道你的模板函数接收了一个 l 值还是一个 r 值。目前,确保你的模板函数像一个 r 值引用而不是一个通用引用的最佳方法是使用 SFINAE:

std::is_rvalue_reference_v<decltype(t)>

最后,还可以对不常见的类型进行类型推断,比如 C 风格数组,就像这个例子中所示:

template<typename T, size_t N>
void foo(T (&&t)[N])
{
    show_type(t);
}

前面的函数说明我们希望将类型为T且大小为N的 C 风格数组传递给函数,然后在执行时输出其类型。我们可以这样使用这个函数:

int main(void)
{
    foo({4, 8, 15, 16, 23, 42});
}

这自动推断为一个类型为int且大小为6的 C 风格数组的 r 值引用。正如本文所示,C++提供了几种机制,允许编译器确定在模板函数中使用了哪些类型。

在 C++17 中利用模板类类型推断

在本文中,我们将学习 C++17 中类模板的类类型推断是如何工作的。这个配方很重要,因为 C++17 增加了从构造函数中推断模板类类型的能力,从而减少了代码的冗长和冗余。

从这个配方中获得的知识将使您能够编写 C++类,这些类可以从类构造函数中正确推断其类型,而无需显式类型声明。

准备工作

开始之前,请确保满足所有技术要求,包括安装 Ubuntu 18.04 或更高版本,并在终端窗口中运行以下命令:

> sudo apt-get install build-essential git cmake

这将确保您的操作系统具有正确的工具来编译和执行本教程中的示例。完成后,打开一个新的终端。我们将使用这个终端来下载、编译和运行我们的示例。

如何做...

按照以下步骤尝试这个教程:

  1. 从新的终端中,运行以下命令下载源代码:
> cd ~/
> git clone https://github.com/PacktPublishing/Advanced-CPP-CookBook.git
> cd Advanced-CPP-CookBook/chapter12
  1. 要编译源代码,请运行以下命令:
> cmake .
> make recipe04_examples
  1. 源代码编译后,您可以通过运行以下命令来执行本教程中的每个示例:
> ./recipe04_example01
t = int
t = int

> ./recipe04_example02
t = int&

> ./recipe04_example03
t = int&&
t = int&&

> ./recipe04_example04
t = int&&
u = int&

> ./recipe04_example05
t = int&&

> ./recipe04_example06
t = const char (&)[16]
u = int&&

在下一节中,我们将逐个介绍这些示例,并解释每个示例程序的作用,以及它们与本教程中所教授的课程的关系。

它是如何工作的...

类模板类型推断是 C++17 中新增的一个功能,它提供了从构造函数中推断模板类的类型的能力。假设我们有以下类模板:

template<typename T>
class the_answer
{

public:
    the_answer(T t)
    {
        show_type(t);
    }
};

如上面的代码片段所示,我们有一个简单的类模板,在构造时接受一个类型T,并使用show_type()函数输出它所给定的任何类型。在 C++17 之前,这个类将使用以下方式实例化:

int main(void)
{
    the_answer<int> is(42);
}

使用 C++17,我们现在可以实例化这个类如下:

int main(void)
{
    the_answer is(42);
}

这样做的原因是类的构造函数接受一个类型T作为参数。由于我们提供了一个数字整数作为参数,类的类型T被推断为整数。这种类型推断也包括对引用的支持。查看以下示例:

template<typename T>
class the_answer
{

public:
    the_answer(T &t)
    {
        show_type(t);
    }
};

在上面的示例中,我们的类在构造函数中以T&作为参数,这使我们可以实例化类如下:

int main(void)
{
    int i = 42;
    the_answer is(i);
}

执行时会产生以下结果:

如上面的示例所示,类的类型T被推断为整数的左值引用。大多数适用于函数模板的类型推断规则也适用于类模板,但也有一些例外。例如,类模板的构造函数不支持转发引用(通用引用)。考虑以下代码:

template<typename T>
class the_answer
{

public:
    the_answer(T &&t)
    {
        show_type(t);
    }
};

上面的构造函数不是一个通用引用;它是一个 r 值引用,这意味着我们不能做以下操作:

the_answer is(i);

这是不可能的,因为这将尝试将一个左值绑定到一个右值,这是不允许的。相反,像任何其他 r 值引用一样,我们必须使用以下方式实例化类:

the_answer is(std::move(i));

或者我们可以使用以下方式绑定它:

the_answer is(42);

通用引用不支持类模板类型推断的原因是,类模板类型推断使用构造函数来推断类型,然后根据推断出的类型填充类的其余部分的类型,这意味着在构造函数编译时,它看起来像这样:

class the_answer
{

public:
    the_answer(int &&t)
    {
        show_type(t);
    }
};

这定义了一个 r 值引用。

要在构造函数或任何其他函数中获得一个通用引用,您必须使用一个成员函数模板,它本身仍然可以支持类型推断,但不用于推断类的任何类型。查看以下示例:

template<typename T>
class the_answer
{

public:

    template<typename U>
    the_answer(T &&t, U &&u)
    {
        show_type(t);
        show_type(u);
    }
};

在上面的示例中,我们创建了一个带有类型T的类模板,并将构造函数定义为成员函数模板。构造函数本身接受T &&tU &&u。然而,在这种情况下,t是一个 r 值引用,u是一个通用引用,尽管它们看起来相同。在 C++17 中,编译器可以推断两者如下:

int main(void)
{
    int i = 42;
    the_answer is(std::move(i), i);
}

还应该注意,构造函数不必按任何特定顺序具有所有类型才能进行推断。唯一的要求是构造函数的参数中包含所有类型。例如,考虑以下代码:

template<typename T>
class the_answer
{

public:
    the_answer(size_t size, T &&t)
    {
        show_type(t);
    }
};

上面的示例可以实例化如下:

int main(void)
{
    the_answer is_2(42, 42);
}

最后,类型推导还支持多个模板类型,就像这个例子中一样:

template<typename T, typename U>
class the_answer
{

public:
    the_answer(const T &t, U &&u)
    {
        show_type(t);
        show_type(u);
    }
};

上面的示例创建了一个具有两个通用类型的类模板。这个类的构造函数创建了对类型Tconst左值引用,同时还接受了对类型U的右值引用。可以这样实例化这个类:

int main(void)
{
    the_answer is("The answer is: ", 42);
}

这将产生以下输出:

如上例所示,TU都成功推导出来了。

在 C++17 中使用用户定义的类型推导

在这个示例中,我们将学习如何使用用户定义的推导指南来帮助编译器进行类模板类型推导。大多数情况下,不需要用户定义的推导指南,但在某些情况下,为了确保编译器推断出正确的类型,可能需要使用它们。这个示例很重要,因为如果没有用户定义的类型推导,某些类型的模板方案根本不可能,这将会被证明。

准备工作

开始之前,请确保满足所有技术要求,包括安装 Ubuntu 18.04 或更高版本,并在终端窗口中运行以下命令:

> sudo apt-get install build-essential git cmake

这将确保您的操作系统具有编译和执行本示例中的示例所需的适当工具。完成后,打开一个新的终端。我们将使用这个终端来下载、编译和运行我们的示例。

如何做...

执行以下步骤来尝试这个示例:

  1. 从新的终端运行以下命令来下载源代码:
> cd ~/
> git clone https://github.com/PacktPublishing/Advanced-CPP-CookBook.git
> cd Advanced-CPP-CookBook/chapter12
  1. 要编译源代码,请运行以下命令:
> cmake .
> make recipe05_examples
  1. 源代码编译完成后,可以通过运行以下命令来执行本示例中的每个示例:
> ./recipe05_example01
t = unsigned int
t = int

> ./recipe05_example02
t = unsigned int

> ./recipe05_example03
t = std::__cxx11::basic_string<char>

在下一节中,我们将逐个介绍这些示例,并解释每个示例程序的作用以及它与本示例中所教授的课程的关系。

工作原理...

类模板类型推导是 C++17 中一个非常需要的特性,因为它有助于减少我们的 C++中的冗余和冗长。然而,在某些情况下,编译器会推断出错误的类型,如果我们不依赖于类型推导,这个问题是可以解决的。为了更好地理解这种问题,让我们看一下下面的例子:

template<typename T>
class the_answer
{

public:
    the_answer(T t)
    {
        show_type(t);
    }
};

在上面的示例中,我们创建了一个简单的类模板,其构造函数接受类型T,并使用show_type()函数输出给定的任何类型。现在假设我们希望使用这个类来实例化一个接受无符号整数的版本。有两种方法可以做到这一点:

the_answer<unsigned> is(42);

上述方法是最明显的,因为我们明确告诉编译器我们希望拥有的类型,而根本不使用类型推导。获取无符号整数的另一种方法是使用正确的数字文字语法,如下所示:

the_answer is(42U);

在上面的示例中,我们利用了类型推导,但我们必须确保始终将U添加到我们的整数上。这种方法的优点是代码是显式的。这种方法的缺点是,如果我们忘记添加U来表示我们希望有一个无符号整数,我们可能会无意中创建一个具有int类型而不是unsigned类型的类。

为了防止这个问题,我们可以利用用户定义的类型推导来告诉编译器,如果它看到一个整数类型,我们真正想要的是一个无符号类型,如下所示:

the_answer(int) -> the_answer<unsigned>;

上面的语句告诉编译器,如果它看到一个带有int类型的构造函数,int应该产生一个具有unsigned类型的类。

左侧采用构造函数签名,右侧采用类模板签名。

使用这种方法,我们可以将我们看到的任何构造函数签名转换为我们希望的类模板类型,就像这个例子中一样:

the_answer(const char *) -> the_answer<std::string>;

用户定义的类型推导指南告诉编译器,如果它看到一个 C 风格的字符串,应该创建std::string。然后我们可以通过以下方式运行我们的示例:

int main(void)
{
    the_answer is("The answer is: 42");
}

然后我们得到以下输出:

正如前面的屏幕截图所示,该类是使用std::string(或至少是 GCC 内部表示的std::string)构建的,而不是 C 风格的字符串。

第十三章:奖励-使用 C++20 功能

在本章中,您将快速了解一些即将添加到 C++20 中的功能。本章很重要,因为与 C++14 和 C++17 不同,C++20 为语言添加了几个改变游戏规则的功能。

它始于对 C++20 概念的介绍,这是一种定义任意类型要求的新机制。C++20 概念承诺改变我们使用模板和auto编程的方式,提供了一种定义类型要求的机制。然后我们将转向 C++20 模块,这是一个新功能,消除了#include的需要,改变了我们在 C++中定义接口的方式。C++模块是语言的巨大变化,需要完全改变整个标准库以及我们的构建工具。接下来,我们将快速查看std::span和 C++范围。最后,我们将简要介绍 C++20 的另一个改变游戏规则的功能,称为协程。

本章的示例如下:

  • 查看 C++20 中的概念

  • 使用 C++20 中的模块

  • 介绍std::span,数组的新视图

  • 在 C++20 中使用范围

  • 学习如何在 C++20 中使用协程

技术要求

要编译和运行本章中的示例,您必须具有管理访问权限,可以访问具有功能性互联网连接的运行 Ubuntu 19.04 的计算机。请注意,本书的其余部分使用的是 Ubuntu 18.04。由于我们将讨论仍在开发中的 C++20,因此在本章中需要最新和最好的 GCC 版本。在运行这些示例之前,您必须安装以下内容:

> sudo apt-get install build-essential git cmake

如果这是安装在 Ubuntu 18.04 之外的任何操作系统上,则需要 GCC 7.4 或更高版本和 CMake 3.6 或更高版本。

本章的代码文件可以在//github.com/PacktPublishing/Advanced-CPP-CookBook/tree/master/chapter13找到。

查看 C++20 中的概念

在本教程中,我们将讨论 C++20 的即将添加的一个功能,它承诺彻底改变我们对模板编程的思考方式,称为 C++20 概念。如今,C++在很大程度上依赖于使用 SFINAE 来约束适用于任何给定模板函数的类型。正如在第四章中所见,使用模板进行通用编程,SFINAE 很难编写,阅读起来令人困惑,编译速度慢。本教程很重要,因为 C++20 后的模板编程不仅更容易编码和调试,而且还将减少模板编程的人力成本,使其更易于阅读和理解。

准备工作

开始之前,请确保满足所有技术要求,包括安装 Ubuntu 19.04 或更高版本,并在终端窗口中运行以下命令:

> sudo apt-get install build-essential git cmake

这将确保您的操作系统具有编译和执行本教程中示例所需的适当工具。完成后,打开一个新的终端。我们将使用此终端来下载、编译和运行我们的示例。

如何做...

您需要执行以下步骤来尝试本教程:

  1. 从新的终端运行以下命令以下载源代码:
> cd ~/
> git clone https://github.com/PacktPublishing/Advanced-CPP-CookBook.git
> cd Advanced-CPP-CookBook/chapter13
  1. 要编译源代码,请运行以下命令:
> cmake .
> make recipe01_examples
  1. 一旦源代码被编译,您可以通过运行以下命令来执行本教程中的每个示例:
> ./recipe01_example01
The answer is: 42
The answer is not: 43

> ./recipe01_example02
The answer is: 42
The answer is not: 43

在下一节中,我们将逐个介绍这些示例,并解释每个示例程序的作用以及它与本教程中所教授的课程的关系。

工作原理...

为了最好地解释 C++20 概念如何帮助模板编程,我们将从今天在 C++中编程接口的一个简单示例开始。接口在对象导向编程中被广泛使用,用于将 API 的接口与其实现细节分离开来。

让我们从以下纯虚接口开始:

class interface
{
public:
    virtual ~interface() = default;
    virtual void foo() = 0;
};

C++中的前述纯虚接口定义了一个foo()函数。这个 API 的客户端不需要知道foo()是如何实现的。他们关心的只是接口的定义和foo()的函数签名,以了解foo()应该如何行为。使用这个接口,我们可以定义一个接口的实现,如下所示:

class A :
    public interface
{
public:
    void foo() override
    {
        std::cout << "The answer is: 42\n";
    }
};

如前面的示例所示,我们创建了一个名为A的类,它继承了接口并重写了foo()函数以给它一个实现。我们可以用另一个实现做同样的事情,如下所示:

class B :
    public interface
{
public:
    void foo() override
    {
        std::cout << "The answer is not: 43\n";
    }
};

如前面的示例所示,B类提供了接口的另一种实现。这个接口的客户端可以像下面这样使用接口:

class client
{
    interface &m_i;

public:
    client(interface &i) :
        m_i{i}
    { }

    void bar()
    {
        m_i.foo();
    }
};

客户端实际上不需要知道关于AB的任何东西。它只包括接口的定义,并使用接口来访问任何特定的实现。我们可以像下面这样使用这个客户端:

int main(void)
{
    A a;
    B b;

    client c1(a);
    client c2(b);

    c1.bar();
    c2.bar();
}

如前面的示例所示,我们首先创建了AB的实例,然后创建了两个不同的客户端,它们分别给了AB的接口实现。最后,我们执行了每个客户端的bar()函数,得到了以下输出:

如前面的截图所示,客户端并不知道接口是以两种不同的方式定义的,因为客户端只关心接口。这种技术在很多 C++文献中都有展示,特别是为了实现所谓的 S.O.L.I.D 面向对象设计原则。S.O.L.I.D 设计原则代表以下内容:

  • 单一职责原则:这确保了如果一个对象必须改变,它只会因为一个原因而改变(也就是说,一个对象不会提供多于一个的职责)。

  • 开闭原则:这确保了一个对象可以被扩展而不被修改。

  • 里氏替换原则:这确保了在使用继承时,子类实现了它们重写的函数的行为,而不仅仅是函数的签名。

  • 接口隔离原则:这确保了一个对象具有尽可能小的接口,这样对象的客户端就不会被迫依赖他们不使用的 API。

  • 依赖反转原则:这确保了对象只依赖于接口而不依赖于实现。

这些原则的结合旨在确保您在 C++中使用面向对象编程更容易理解和维护。然而,现有的 S.O.L.I.D 和 C++文献存在一个问题,即它倡导大量使用纯虚接口,这是有代价的。每个类必须给出一个额外的虚表(即 vTable),并且所有函数调用都会遇到虚函数重载的额外开销。

解决这个问题的一种方法是使用静态接口(这在现有文献中很少谈到)。为了最好地解释这是如何工作的,让我们从我们接口的定义开始,如下所示:

#include <iostream>

template<typename DERIVED>
class interface
{
public:
    constexpr void foo()
    {
        static_cast<DERIVED *>(this)->foo_override();
    }
};

如前面的示例所示,我们将利用静态多态性来实现我们的接口。前述类采用了一个名为DERIVED的类型,并将接口的实例转换为DERIVED类,调用一个已被重写的foo函数的版本。现在A的实现看起来是这样的:

class A :
    public interface<A>
{
public:
    void foo_override()
    {
        std::cout << "The answer is: 42\n";
    }
};

如前面的示例所示,A现在不再继承接口,而是继承A的接口。当调用接口的foo()函数时,接口将调用Afoo_override()函数。我们可以使用相同的方法实现B

class B :
    public interface<B>
{
public:
    void foo_override()
    {
        std::cout << "The answer is not: 43\n";
    }
};

如前面的示例所示,B能够提供自己的接口实现。需要注意的是,到目前为止,在这种设计模式中,我们还没有使用virtual,这意味着我们已经创建了一个接口及其实现,而不需要虚拟继承,因此这种设计没有额外的开销。事实上,编译器能够消除从foo()foo_override()的调用重定向,确保抽象的使用不会比使用纯虚拟接口带来任何额外的运行时成本。

AB的客户端可以这样实现:

template<typename T>
class client
{
    interface<T> &m_i;

public:
    client(interface<T> &i) :
        m_i{i}
    { }

    void bar()
    {
        m_i.foo();
    }
};

如前面的代码片段所示,这个示例中的客户端与前一个示例中的客户端唯一的区别在于这个客户端是一个模板类。静态多态性要求在编译时知道接口的类型信息。在大多数设计中,这通常是可以接受的,因为早期使用纯虚拟接口并不是因为我们想要执行运行时多态和类型擦除的能力,而是为了确保客户端只遵循接口而不是实现。在这两种情况下,每个客户端的实现都是静态的,并且在编译时已知。

为了使用客户端,我们可以使用一些 C++17 的类类型推导来确保我们的main()函数保持不变,如下所示:

int main(void)
{
    A a;
    B b;

    client c1(a);
    client c2(b);

    c1.bar();
    c2.bar();
}

执行上述示例会得到以下结果:

如前面的截图所示,代码执行相同。两种方法之间唯一的区别在于一种使用纯虚拟继承,这会带来运行时成本,而第二种方法使用静态多态性,这会带来人为成本。特别是对于大多数初学者来说,前面的例子很难理解。在具有嵌套依赖关系的大型项目中,使用静态多态性可能非常难以理解和阅读。

上述示例的另一个问题是编译器对接口及其客户端的信息不足,无法在给出错误类型时提供合理的错误消息。看看这个例子:

int main(void)
{
    client c(std::cout);
}

这导致了以下编译器错误:

/home/user/book/chapter13/recipe01.cpp: In function ‘int main()’:
/home/user/book/chapter13/recipe01.cpp:187:23: error: class template argument deduction failed:
  187 | client c(std::cout);
      | ^
/home/user/book/chapter13/recipe01.cpp:187:23: error: no matching function for call to ‘client(std::ostream&)’
/home/user/book/chapter13/recipe01.cpp:175:5: note: candidate: ‘template<class T> client(interface<T>&)-> client<T>’
  175 | client(interface<T> &i) :
      | ^~~~~~

...

上述错误消息几乎没有用,特别是对于初学者来说。为了克服这些问题,C++20 Concepts 承诺提供一个更清晰的模板编程实现。为了更好地解释这一点,让我们看看如何使用 C++20 Concepts 来实现接口:

template <typename T>
concept interface = requires(T t)
{
    { t.foo() } -> void;
};

如前面的示例所示,我们定义了一个名为interface的 C++20 概念。给定一个类型T,这个概念要求T提供一个名为foo()的函数,不接受任何输入并且不返回任何输出。然后我们可以这样定义A

class A
{
public:
    void foo()
    {
        std::cout << "The answer is: 42\n";
    }
};

如前面的代码片段所示,A不再需要使用继承。它只需提供一个给定普通 C++类定义的foo()函数。B的实现方式也是一样的:

class B
{
public:
    void foo()
    {
        std::cout << "The answer is not: 43\n";
    }
};

再次强调,不再需要继承。这个接口的客户端实现如下:

template<interface T>
class client
{
    T &m_i;

public:
    client(T &i) :
        m_i{i}
    { }

    void bar()
    {
        m_i.foo();
    }
};

如前面的示例所示,我们定义了一个接受模板类型T并调用其foo()函数的类。在我们之前的静态多态示例中,我们可以以完全相同的方式实现客户端。但这种方法的问题在于客户端无法确定类型T是否遵守接口。结合 SFINAE 的静态断言,比如std::is_base_of(),可以解决这个问题,但依赖接口的每个对象都必须包含这个逻辑。然而,使用 C++20 概念,可以在不需要继承或任何复杂的模板技巧(如 SFINAE)的情况下实现这种简单性。因此,让我们看看我们可以使用以下内容代替:

template<typename T>

可以使用以下内容代替:

template<interface T>

当今 C++模板编程的问题在于typename关键字并不能告诉编译器有关类型本身的任何信息。SFINAE 提供了一种解决方法,通过以巨大的人力成本定义有关类型的某些特征,因为 SFINAE 更加复杂难以理解,当出现问题时导致的编译器错误也毫无用处。C++20 概念通过定义类型的属性(称为概念),然后在typename的位置使用该概念,解决了所有这些问题,为编译器提供了确定给定类型是否符合概念的所有信息。当出现问题时,编译器可以提供有关所提供类型缺少什么的简单错误消息。

C++20 概念是一个令人兴奋的新功能,即将推出,它承诺彻底改变我们使用 C++模板的方式,减少了与模板工作相关的整体人力成本,但以更复杂的编译器和 C++规范为代价。

在 C++20 中使用模块

在本示例中,我们将学习有关 C++20 的一个新功能,称为模块。本示例很重要,因为 C++20 模块消除了向前移动#include的需要。当今的 C++代码通常在头文件和源文件之间划分。每个源文件都是单独编译的,并且必须重新编译它包含的头文件(以及包含的头文件包含的任何头文件),导致编译时间缓慢、依赖顺序问题以及对 C 风格宏的过度使用。相反,可以使用 C++20 模块来选择性地包含库,改变我们编写甚至是简单应用程序(如“Hello World”)的方式。

准备工作

开始之前,请确保满足所有技术要求,包括安装 Ubuntu 19.04 或更高版本,并在终端窗口中运行以下命令:

> sudo apt-get install build-essential git cmake

这将确保您的操作系统具有编译和执行本示例所需的适当工具。完成后,打开一个新的终端。我们将使用这个终端来下载、编译和运行我们的示例。

如何做...

您需要执行以下步骤来尝试本示例:

  1. 从新的终端中,运行以下命令下载源代码:
> cd ~/
> git clone https://github.com/PacktPublishing/Advanced-CPP-CookBook.git
> cd Advanced-CPP-CookBook/chapter13
  1. 要编译源代码,请运行以下命令:
> cmake .
> make recipe02_examples
  1. 源代码编译完成后,可以通过运行以下命令来执行本示例中的每个示例:
> ./recipe02_example01
Hello World

> ./recipe02_example03
The answer is: 42

在下一节中,我们将逐个介绍这些示例,并解释每个示例程序的作用以及它与本示例中所教授的课程的关系。需要注意的是,源代码中的示例 2 和 4 在撰写本文时无法编译,因为截至目前,GCC 尚不支持 C++模块。

它是如何工作的...

C++20 模块提供了一种新的方式来包含 C++中使用的 API 的定义。让我们看看如何在 C++中编写一个简单的Hello World应用程序的以下示例:

#include <iostream>

int main(void)
{
    std::cout << "Hello World\n";
}

要使用 C++20 模块编写相同的应用程序,可以执行以下操作:

import std.core;

int main(void)
{
    std::cout << "Hello World\n";
}

尽管差异微妙,但在幕后,为了使前面的代码成为可能,发生了很多变化。让我们看一个更复杂的示例,如下所示:

#include <string>

template<size_t number>
class the_answer
{
public:
    auto operator()() const
    {
        return "The answer is: " + std::to_string(number);
    }
};

#define CHECK(a) (a() == "The answer is: 42")

在前面的代码中,我们定义了一个头文件,定义了一个名为the_answer的类模板。要实现这个模板,我们必须包含string库。我们还在这个头文件中添加了一个宏来测试我们的类。我们可以这样使用这个头文件:

#include <iostream>
#include "header.h"

int main(void)
{
    the_answer<42> is;
    std::cout << is() << '\n';
}

如前面的代码片段所示,我们包括了我们的头文件,创建了我们模板类的一个实例,并用它输出了一条消息。执行时,我们得到了以下输出:

尽管这是一个简单的例子,展示了一个实现 C++函数对象的类模板,但这段代码存在一些问题:

  • the_answer的实现取决于string库。这意味着每当你使用header.h时,你不仅包含了the_answer的定义,还包含了string库的完整定义,包括它的所有依赖项。这种类型的依赖链导致了大量的构建时间成本。

  • CHECK()宏也对客户端可访问。在 C++中,没有办法给宏命名空间,这导致了宏冲突的可能性。

  • 前面的例子很小,因此很容易编译,但假设我们的头文件有30,000行模板代码,混合了几个自己的包含。现在,假设我们必须在数百个源文件中包含我们的头文件。这种情况的结果将是非常长的编译时间,因为每次编译一个源文件时,它都必须一遍又一遍地重新编译相同的庞大头文件。

要了解 C++模块如何解决这些问题,让我们看看使用模块的相同代码会是什么样子:

import std.string;
export module answers;

export
template<size_t number>
class the_answer
{
public:
    auto operator()() const
    {
        return "The answer is: " + std::to_string(number);
    }
};

#define CHECK(a) (a() == "The answer is: 42")

如前面的代码片段所示,我们的自定义库包括了字符串的定义,然后使用export模块创建了一个名为answers的新 C++模块。然后我们使用export定义定义了我们的类模板。每当一个头文件被编译(实际上,每当任何代码被编译)时,编译器通常首先将人类可读的 C++语法转换为一种叫做中间表示IR)的东西。然后将这个 IR 转换为二进制汇编。问题在于头文件包含了无法转换为这种表示的代码(如宏和包含),这意味着每次编译器看到一个头文件时,它都必须将代码转换为 IR,然后再转换为二进制。

C++模块提供了一种语法和一组规则,使编译器能够将头文件转换为 IR,并将这个 IR 的结果与其余的对象文件一起存储。编译器可以使用这个 IR 多次,无需不断执行 IR 转换过程的代码。要了解前面的代码如何使用,让我们看看以下内容:

import answers;
import std.core;

int main(void)
{
    the_answer<42> is;
    std::cout << is();
}

如图所示,我们包括了std::cout的定义和我们的answers模块。不同之处在于main()函数不必将answersstd.core的定义从 C++语法转换为编译器的 IR,从而减少了main()源文件的编译时间。main()源文件还可以创建一个名为CHECK()的宏,而不会与我们的answers模块中的相同宏发生冲突,因为宏无法被导出。

引入 std::span,数组的新视图

在这个示例中,我们将学习如何使用std::span,这是 C++20 中的一个新功能。这个示例很重要,因为std::span是 Guideline Support Library 的gsl::span的后代,它是用于确保你的 C++符合核心指导方针的库的核心组件。在这个示例中,我们不仅介绍了std::span,还解释了如何在自己的代码中使用它,以及为什么它有助于封装一个带有大小的数组,并为处理数组提供了一个方便的 API。

准备就绪

开始之前,请确保满足所有技术要求,包括安装 Ubuntu 19.04 或更高版本,并在终端窗口中运行以下命令:

> sudo apt-get install build-essential git cmake

这将确保您的操作系统具有编译和执行本示例的正确工具。完成后,打开一个新的终端。我们将使用此终端来下载、编译和运行我们的示例。

如何做到...

您需要执行以下步骤来尝试本示例:

  1. 从新的终端运行以下命令以下载源代码:
> cd ~/
> git clone https://github.com/PacktPublishing/Advanced-CPP-CookBook.git
> cd Advanced-CPP-CookBook/chapter13
  1. 要编译源代码,请运行以下命令:
> cmake .
> make recipe03_examples
  1. 编译源代码后,您可以通过运行以下命令执行本示例中的每个示例:
> ./recipe03_example01
4 8 15 16 23 42 

> ./recipe03_example02
4 8 15 16 23 42 

> ./recipe03_example03
4 8 15 16 23 42 

> ./recipe03_example04
4 8 15 16 23 42 

> ./recipe03_example05
size: 6
size (in bytes): 24
size: 6
size (in bytes): 24
size: 6
size (in bytes): 24

> ./recipe03_example06
42 

在下一节中,我们将逐个介绍这些示例,并解释每个示例程序的作用,以及它与本示例中所教授的课程的关系。

工作原理...

在本示例中,我们将探讨std::span是什么以及为什么需要它。在 C++(甚至在 C 中),要将数组传递给函数,实现如下:

void foo(const int *array, size_t size)
{
    for (auto i = 0; i < size; i++) {
        std::cout << array[i] << ' ';
    }

    std::cout << '\n';
}

如前面的示例所示,我们创建了一个名为foo()的函数,该函数接受一个指向数组的指针以及数组的大小。然后我们使用这些信息将数组的内容输出到stdout

我们可以按以下方式执行此函数:

int main(void)
{
    int array[] = {4, 8, 15, 16, 23, 42};
    foo(array, sizeof(array)/sizeof(array[0]));
}

这将产生以下输出:

前面的代码问题在于它不符合 C++核心准则。具体来说,我们被迫独立存储数组的大小。如果数组和其大小不同步(在大型项目中可能会发生),这可能会导致问题。指针与数组相关联还阻止了使用范围for循环,这意味着我们必须手动遍历数组,这也可能导致潜在的稳定性问题,如果for循环没有正确构造。最后,我们需要手动计算数组的大小,这是一个容易出错的操作,使用sizeof()

解决此问题的一种方法是使用模板函数,如下所示:

template<size_t N>
void foo(const int (&array)[N])
{
    for (auto i = 0; i < N; i++) {
        std::cout << array[i] << ' ';
    }

    std::cout << '\n';
}

如前面的代码片段所示,我们定义了一个模板函数,该函数接受大小为N的整数数组的引用。然后我们可以使用N来遍历这个数组。我们甚至可以在数组上使用范围for循环,因为编译器知道数组的大小是在编译时确定的。这段代码可以这样使用:

int main(void)
{
    int array[] = {4, 8, 15, 16, 23, 42};
    foo(array);
}

如此所示,我们进行了几项改进。我们不再传递可能导致NULL指针违规的指针。我们不再使用sizeof()手动计算数组的大小,也不再需要独立存储数组的大小。前面的代码问题在于每次数组的大小发生变化时,我们必须编译一个完全不同版本的foo()函数。如果foo()函数很大,这可能是一个问题。此代码还不支持动态分配的数组(即数组是否使用std::unique_ptr分配)。

为了解决这个问题,C++20 添加了std::span类。查看以下示例:

void foo(const std::span<int> &s)
{
    for (auto i = 0; i < s.size(); i++) {
        std::cout << s[i] << ' ';
    }

    std::cout << '\n';
}

如前面的代码片段所示,我们使用std::span创建了foo()函数,它存储了一个整数数组。与大多数其他 C++容器一样,我们可以获取数组的大小,并且可以使用下标运算符访问数组的各个元素。要使用此函数,我们只需像使用模板函数一样调用它,如下所示:

int main(void)
{
    int array[] = {4, 8, 15, 16, 23, 42};
    foo(array);
}

使用std::span,我们现在可以为不同大小的数组提供相同的foo()函数,并且甚至可以使用动态内存(即std::unique_ptr)分配数组,而无需重新实现foo()函数。范围for循环甚至可以正常工作:

void foo(const std::span<int> &s)
{
    for (const auto &elem : s) {
        std::cout << elem << ' ';
    }

    std::cout << '\n';
}

使用动态内存和foo(),我们可以这样做:

int main(void)
{
    auto ptr1 = new int[6]();
    foo({ptr1, 6});
    delete [] ptr1;

    std::vector<int> v(6);
    foo({v.data(), v.size()});

    auto ptr2 = std::make_unique<int>(6);
    foo({ptr2.get(), 6});
}

如前面的示例所示,我们使用三种不同类型的动态创建的内存运行了foo()函数。第一次运行foo()时,我们使用new()/delete()分配了内存。如果您试图保持符合 C++核心准则,您可能对这种方法不感兴趣。第二和第三种方法分配了使用std::vectorstd::unique_ptr的内存。两者都有其固有的缺点:

  • std::vector存储自己的size(),但也存储其容量,并且默认初始化内存。

  • std::unique_ptr不存储自己的size(),它也会默认初始化内存。

目前,C++没有能够分配未初始化内存的动态数组并存储数组大小(仅存储大小)的数组类型。然而,std::span可以与前述方法的某些组合一起使用,根据您的需求来管理数组。

还应该注意,在前面的示例中,当我们创建std::span时,我们根据元素的总数而不是字节的总数传递了数组的大小。std::span能够为您提供这两者,如下所示:

void foo(const std::span<int> &s)
{
    std::cout << "size: " << s.size() << '\n';
    std::cout << "size (in bytes): " << s.size_bytes() << '\n';
}

如果我们运行上述的foo()实现,并使用前面提到的动态内存示例,我们会得到以下结果:

最后,我们可以使用 span 来创建额外的子 span,如下所示:

void foo2(const std::span<int> &s)
{
    for (const auto &elem : s) {
        std::cout << elem << ' ';
    }

    std::cout << '\n';
}

在前面的foo2()函数中,我们使用 span 并使用范围for循环输出其所有元素。然后我们可以使用以下方法创建子 span:

void foo1(const std::span<int> &s)
{
    foo2(s.subspan(5, 1));
}

subspan()函数的结果是另一个std::span。不同之处在于它内部存储的指针已经提前了5个元素,并且 span 存储的size()现在是1

在 C++20 中使用范围

在本教程中,我们将学习如何使用 C++ Ranges,这是 C++20 带来的新功能集。Ranges 提供了方便的函数,用于处理模拟对象或值范围的任何内容。例如,4, 8, 15, 16, 23, 42 是一个整数范围。在当今的 C++中,根据您的操作,处理范围可能会很麻烦。本教程很重要,因为 C++范围消除了与处理范围相关的许多复杂性,确保您的代码随着时间的推移更容易阅读和维护。

准备工作

开始之前,请确保满足所有技术要求,包括安装 Ubuntu 19.04 或更高版本,并在终端窗口中运行以下命令:

> sudo apt-get install build-essential git cmake

这将确保您的操作系统具有适当的工具来编译和执行本教程中的示例。完成后,打开一个新的终端。我们将使用此终端来下载、编译和运行我们的示例。

如何做...

要执行此操作,请执行以下步骤:

  1. 从新的终端运行以下命令以下载源代码:
> cd ~/
> git clone https://github.com/PacktPublishing/Advanced-CPP-CookBook.git
> cd Advanced-CPP-CookBook/chapter13
  1. 要编译源代码,请运行以下命令:
> cmake .
> make recipe04_examples
  1. 源代码编译后,您可以通过运行以下命令执行本教程中的每个示例:
> ./recipe04_example01 
1

> ./recipe04_example02
42

> ./recipe04_example03
42

> ./recipe04_example04
4 8 15 16 23 42 

> ./recipe04_example05
4 8 15 16 23 42 

> ./recipe04_example06
4 8 15 16 23 42 

在下一节中,我们将逐个介绍这些示例,并解释每个示例程序的作用以及它与本教程中所教授的课程的关系。

工作原理...

C++ Ranges 是 C++20 的一个受欢迎的补充,因为它提供了一种简单的方法来处理任何对象或值列表。为了最好地解释这是如何工作的,让我们看一下以下示例(请注意,在这些示例中,我们将使用 Ranges v3,而我们等待 GCC 支持 Ranges,因为 v3 是 C++20 采用的实现):

#include <iostream>
#include <range/v3/algorithm/count.hpp>

int main(void)
{
    auto list = {4, 8, 15, 16, 23, 42};
    std::cout << ranges::count(list, 42) << '\n';
}

如前面的代码片段所示,我们已经创建了一个整数列表(在这种特定情况下,我们创建了一个简单的初始化列表)。然后我们使用ranges::count()函数来计算列表中值为42的出现次数,得到以下输出:

范围也可以用于搜索:

#include <iostream>
#include <range/v3/algorithm/find.hpp>

int main(void)
{
    auto list = {4, 8, 15, 16, 23, 42};
    if (auto i = ranges::find(list, 42); i != ranges::end(list)) {
        std::cout << *i << '\n';
    }
}

如前面的示例所示,我们创建了相同的整数初始化列表,并使用 ranges 返回一个迭代器。这个迭代器可以用来遍历列表或获取定位的值。初始化列表已经支持迭代器,而 Ranges 所做的一件事是将这个功能扩展到其他类型,包括简单的 C 风格数组:

#include <iostream>
#include <range/v3/algorithm/find.hpp>

int main(void)
{
    int list[] = {4, 8, 15, 16, 23, 42};
    if (auto i = ranges::find(list, 42); i != ranges::end(list)) {
        std::cout << *i << '\n';
    }
}

前面的示例使用了 C 风格数组而不是初始化列表,并且如所示,Ranges 提供了一个迭代器来处理 C 风格数组,这是目前不可能的。

Ranges 还提供了一些方便的算法。例如,考虑以下代码:

#include <iostream>
#include <range/v3/algorithm/for_each.hpp>

int main(void)
{
    auto list = {4, 8, 15, 16, 23, 42};

    ranges::for_each(list, [](const int &val){
        std::cout << val << ' ';
    });

    std::cout << '\n';
}

在前面的示例中,我们创建了一个整数列表。然后我们循环遍历整数的整个范围,并在这个列表上执行一个 lambda。虽然这可以使用传统的循环来完成,比如 C++11 中添加的基于范围的循环,for_each可以简化您的逻辑(取决于您的用例)。

Ranges 还提供了将一个列表转换为另一个列表的能力。考虑以下示例:

#include <iostream>
#include <range/v3/view/transform.hpp>

class my_type
{
    int m_i;

public:
    my_type(int i) :
        m_i{i}
    { }

    auto get() const
    {
        return m_i;
    }
};

我们将从创建我们自己的类型开始这个示例。如前面的代码片段所示,我们有一个名为my_type的新类型,它是用一个整数构造的,并使用get()函数返回整数。然后我们可以扩展我们之前的示例,将我们的整数列表转换为我们自定义类型的列表,如下所示:

int main(void)
{
    using namespace ranges::views;

    auto list1 = {4, 8, 15, 16, 23, 42};
    auto list2 = list1 | transform([](int val){
        return my_type(val);
    });

    for(const auto &elem : list2) {
        std::cout << elem.get() << ' ';
    }

    std::cout << '\n';
}

如前面的示例所示,我们创建了初始的整数列表,然后使用ranges::views::transform函数将这个列表转换为我们自定义类型的第二个列表。然后我们可以使用传统的基于范围的for循环来迭代这个新列表。

最后,Ranges 还提供了一些操作,让您实际修改现有的范围。例如,考虑以下代码:

#include <vector>
#include <iostream>
#include <range/v3/action/sort.hpp>

int main(void)
{
    using namespace ranges;

    std::vector<int> list = {4, 42, 15, 8, 23, 16};
    list |= actions::sort;

    for(const auto &elem : list) {
        std::cout << elem << ' ';
    }

    std::cout << '\n';
}

在前面的示例中,我们使用actions::sort函数对我们的向量列表进行排序,得到以下输出:

如前面的示例所示,C++20 Ranges 为我们提供了一种简单的方法,使用管道运算符而不是使用std::sort来对std::vector进行排序,显式定义我们的起始和结束迭代器。

学习如何在 C++20 中使用协程

在这个示例中,我们将简要介绍 C++20 中即将推出的一个特性,称为协程。与 C++20 中正在添加的其他一些特性不同,协程在当前的 C++中是不可能的。协程提供了暂停函数执行和产生结果的能力。一旦结果被使用,函数可以在离开的地方恢复执行。这个示例很重要,因为 C++20 将为 C++添加一流支持(即新关键字)来支持协程,很可能这个新特性将在不久的将来开始出现在库和示例中。

准备工作

在开始之前,请确保满足所有的技术要求,包括安装 Ubuntu 19.04 或更高版本,并在终端窗口中运行以下命令:

> sudo apt-get install build-essential git cmake

这将确保您的操作系统具有正确的工具来编译和执行本教程中的示例。完成后,打开一个新的终端。我们将使用这个终端来下载、编译和运行我们的示例。

如何做...

要尝试这个示例,请执行以下步骤:

  1. 从一个新的终端,运行以下命令来下载源代码:
> cd ~/
> git clone https://github.com/PacktPublishing/Advanced-CPP-CookBook.git
> cd Advanced-CPP-CookBook/chapter13
  1. 要编译源代码,请运行以下命令:
> cmake .
> make recipe05_examples
  1. 一旦源代码被编译,您可以通过运行以下命令来执行本教程中的每个示例:
> ./recipe05_example01 
0 2 4 6 8 10 

在下一节中,我们将逐个介绍这些示例,并解释每个示例程序的作用以及它与本教程所教授的课程的关系。

它是如何工作的...

如前所述,协程提供了暂停和恢复函数执行的能力。为了演示这在 C++20 中将如何工作,我们将简要地看一个简单的例子:

auto
even_numbers(size_t s, size_t e)
{
    std::vector<int> nums;

    if (s % 2 != 0 || e % 2 != 0) {
        std::terminate();
    }

    for (auto i = s; i <= e; i += 2) {
        nums.push_back(i);
    }

    return nums;
}

在上面的例子中,我们创建了一个名为even_numbers()的函数,给定一个范围,返回偶数的std::vector。我们可以这样使用这个函数:

int main(void)
{
    for (const auto &num : even_numbers(0, 10)) {
        std::cout << num << ' ';
    }

    std::cout << '\n';
}

这导致了以下输出:

前面实现的问题在于这段代码需要使用std::vector来创建一个数字范围进行迭代。有了协程,我们将能够实现这个函数如下:

generator<int>
even_numbers(size_t s, size_t e)
{
    if (s % 2 != 0 || e % 2 != 0) {
        std::terminate();
    }

    for (auto i = s; i < e; i += 2) {
        co_yield i;
    }

    co_return e;
}

从前面的代码中,我们看到了以下内容:

  • 现在我们不再返回std::vector,而是返回generator<int>

  • 当我们在循环中遍历每个偶数值时,我们调用co_yield。这会导致even_numbers()函数返回提供的值,并保存它的位置。

  • 一旦even_numbers()函数被恢复,它会回到最初执行co_yield的地方,这意味着函数现在可以继续执行并产生下一个偶数。

  • 这个过程会一直持续,直到for循环结束并且协程返回最后一个偶数。

要使用这个函数,我们的main()代码不需要改变:

int main(void)
{
    for (const auto &num : even_numbers(0, 10)) {
        std::cout << num << ' ';
    }

    std::cout << '\n';
}

不同之处在于我们不再返回std::vector,而是返回协程提供的整数。

posted @ 2024-05-04 22:45  绝不原创的飞龙  阅读(42)  评论(0编辑  收藏  举报