C++-专家编程(全)

C++ 专家编程(全)

原文:annas-archive.org/md5/57ea316395e58ce0beb229274ec493fc

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

学习路径和技术简介。

这个学习路径适合谁

这个学习路径适合想要提升并学习如何在最新版本的 Java 中构建健壮应用程序的 Java 开发人员。

这个学习路径涵盖了什么

第 1 部分精通 C++编程,介绍了 C17 和 STL 中的最新功能。它鼓励在 C中使用清晰的代码实践,并演示了在 C中开发 GUI 应用程序的选项。您将获得有关使用智能指针避免内存泄漏的提示。第 2 部分,*精通 C多线程*,您将看到多线程编程如何帮助您实现应用程序的并发性。我们首先简要介绍了多线程和并发概念的基础知识。然后我们深入研究了这些概念在硬件级别的工作方式,以及操作系统和框架如何使用这些低级功能。您将学习自 2011 年修订以来 C中可用的本机多线程和并发支持,线程之间的同步和通信,调试并发 C应用程序以及 C++中的最佳编程实践。

第 3 部分C++17 STL Cookbook,您将深入了解 C++标准模板库;我们展示了特定于实现的问题解决方法,这将帮助您快速克服障碍。您将学习核心 STL 概念,如容器、算法、实用类、lambda 表达式、迭代器等,并在实际的实际场景中工作。这些示例将帮助您充分利用 STL,并向您展示如何以更好的方式进行编程。

充分利用这个学习路径

  1. 强烈建议对 C++语言有深入的理解,因为这本书是为有经验的开发人员准备的。

  2. 您需要在您的系统上安装任何操作系统(Windows、Linux 或 macOS)和任何 C++编译器才能开始。

下载示例代码文件

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

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

  1. www.packtpub.com登录或注册。

  2. 选择 SUPPORT 选项卡。

  3. 点击“代码下载和勘误”。

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

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

  • Windows 上的 WinRAR/7-Zip

  • Mac 上的 Zipeg/iZip/UnRarX

  • Linux 上的 7-Zip/PeaZip

学习路径的代码包也托管在 GitHub 上,网址是github.com/PacktPublishing/Learning-Path-Name。我们还有其他代码包来自我们丰富的图书和视频目录,可以在github.com/PacktPublishing/找到。去看看吧!

使用的约定

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

CodeInText:指示文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄。这是一个例子:"将下载的WebStorm-10*.dmg磁盘映像文件挂载为系统中的另一个磁盘。"

代码块设置如下:

html, body, #map {
 height: 100%; 
 margin: 0;
 padding: 0
}

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

[default]
exten => s,1,Dial(Zap/1|30)
exten => s,2,Voicemail(u100)
exten => s,102,Voicemail(b100)
exten => i,1,Voicemail(s0)

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

$ mkdir css
$ cd css

粗体:表示一个新术语、一个重要词或者您在屏幕上看到的词。例如,菜单或对话框中的单词会在文本中出现。这里有一个例子:“从管理面板中选择系统信息。”

警告或重要提示会以这种方式出现。

技巧和窍门会以这种方式出现。

第一章:精通 C++编程

现代 C++ 17 触手可及

第二章:介绍 C++17 标准模板库

正如您所知,C语言是 Bjarne Stroustrup 于 1979 年开发的产物。C编程语言由国际标准化组织(ISO)标准化。最初的标准化于 1998 年发布,通常称为 C98,下一个标准化 C03 于 2003 年发布,主要是一个修复错误的版本,只有一个语言特性用于值初始化。2011 年 8 月,C11 标准发布,对核心语言进行了多项增加,包括对标准模板库(STL)的一些重大有趣的更改;C11 基本上取代了 C03 标准。C14 于 2014 年 12 月发布,带有一些新功能,后来,C17 标准于 2017 年 7 月 31 日发布。在撰写本书时,C17 是 C++编程语言的最新修订版。

本章需要支持 C++17 特性的编译器:gcc 版本 7 或更高。由于 gcc 版本 7 是撰写本书时的最新版本,本章将使用 gcc 版本 7.1.0。

本章将涵盖以下主题:

  • STL 概述

  • STL 架构

  • 容器

  • 迭代器

  • 算法

  • 函数对象

  • STL 容器

  • 序列

  • 关联

  • 无序

  • 适配器

让我们在接下来的章节逐个了解 STL 的主题。

标准模板库架构

C标准模板库(STL)提供了现成的通用容器、可应用于容器的算法以及用于导航容器的迭代器。STL 是用 C模板实现的,模板允许在 C++中进行通用编程。

STL 鼓励 C++开发人员专注于手头的任务,摆脱了编写低级数据结构和算法的束缚。STL 是一个经过时间考验的库,可以实现快速应用程序开发。

STL 是一项有趣的工作和架构。它的秘密公式是编译时多态性。为了获得更好的性能,STL 避免了动态多态性,告别了虚函数。总的来说,STL 有以下四个组件:

  • 算法

  • 函数对象

  • 迭代器

  • 容器

STL 架构将所有上述四个组件连接在一起。它具有许多常用的算法,并提供性能保证。有趣的是,STL 算法可以在不了解包含数据的容器的情况下无缝工作。这是由于迭代器提供了高级遍历 API,完全抽象了容器内部使用的底层数据结构。STL 广泛使用运算符重载。让我们逐个了解 STL 的主要组件,以便对 STL 的概念有一个良好的理解。

算法

STL 算法由 C++模板驱动;因此,相同的算法可以处理任何数据类型,独立于容器中数据的组织方式。有趣的是,STL 算法足够通用,可以使用模板支持内置和用户定义的数据类型。事实上,算法通过迭代器与容器交互。因此,算法关心的是容器支持的迭代器。然而,算法的性能取决于容器内部使用的数据结构。因此,某些算法仅适用于特定的容器,因为 STL 支持的每个算法都期望一种特定类型的迭代器。

迭代器

迭代器是一种设计模式,但有趣的是,STL 的工作开始得早于此

四人帮将他们与设计模式相关的工作发布给了软件社区。迭代器本身是允许遍历容器以访问、修改和操作容器中存储的数据的对象。迭代器以如此神奇的方式进行操作,以至于我们并不意识到或需要知道数据存储和检索的位置和方式。

以下图像直观地表示了一个迭代器:

从前面的图像中,您可以理解每个迭代器都支持begin() API,它返回第一个元素的位置,end() API 返回容器中最后一个元素的下一个位置。

STL 广泛支持以下五种类型的迭代器:

  • 输入迭代器

  • 输出迭代器

  • 前向迭代器

  • 双向迭代器

  • 随机访问迭代器

容器实现了迭代器,让我们可以轻松地检索和操作数据,而不需要深入了解容器的技术细节。

以下表格解释了这五种迭代器中的每一种:

迭代器的类型 描述
输入迭代器
  • 它用于从指定的元素读取数据

  • 它只能用于单次导航,一旦到达容器的末尾,迭代器将失效

  • 它支持前置和后置递增运算符

  • 它不支持递减运算符

  • 它支持解引用

  • 它支持==!=运算符来与其他迭代器进行比较

  • istream_iterator迭代器是输入迭代器

  • 所有的容器都支持这种迭代器

|

输出迭代器
  • 它用于修改指定的元素

  • 它只能用于单次导航,一旦到达容器的末尾,迭代器将失效

  • 它支持前置和后置递增运算符

  • 它不支持递减运算符

  • 它支持解引用

  • 它不支持==!=运算符

  • ostream_iteratorback_inserterfront_inserter迭代器是输出迭代器的例子

  • 所有的容器都支持这种迭代器

|

前向迭代器
  • 它支持输入迭代器和输出迭代器的功能

  • 它允许多次导航

  • 它支持前置和后置递增运算符

  • 它支持解引用

  • forward_list容器支持前向迭代器

|

双向迭代器
  • 它是一个支持双向导航的前向迭代器

  • 它允许多次导航

  • 它支持前置和后置递增运算符

  • 它支持前置和后置递减运算符

  • 它支持解引用

  • 它支持[]运算符

  • listsetmapmultisetmultimap容器支持双向迭代器

|

随机访问迭代器
  • 可以使用任意偏移位置访问元素

  • 它支持前置和后置递增运算符

  • 它支持前置和后置递减运算符

  • 它支持解引用

  • 它是最功能完备的迭代器,因为它支持前面列出的其他类型迭代器的所有功能

  • arrayvectordeque容器支持随机访问迭代器

  • 支持随机访问的容器自然也支持双向和其他类型的迭代器

|

容器

STL 容器通常是动态增长和收缩的对象。容器使用复杂的数据结构来存储数据,并提供高级函数来访问数据,而不需要我们深入了解数据结构的复杂内部实现细节。STL 容器非常高效且经过时间考验。

每个容器使用不同类型的数据结构以高效地存储、组织和操作数据。尽管许多容器可能看起来相似,但它们在内部的行为却有所不同。因此,选择错误的容器会导致应用程序性能问题和不必要的复杂性。

容器有以下几种类型:

  • 顺序

  • 关联

  • 容器适配器

容器中存储的对象是复制或移动的,而不是引用。我们将在接下来的章节中用简单而有趣的示例探索每种类型的容器。

函数对象

函数对象是行为类似于常规函数的对象。美妙之处在于函数对象可以替代函数指针。函数对象是方便的对象,可以让您扩展或补充 STL 函数的行为,而不会违反面向对象编程原则。

函数对象易于实现;您只需重载函数运算符。函数对象也被称为函数对象。

以下代码将演示如何实现一个简单的函数对象:

#include <iostream>
#include <vector>
#include <iterator>
#include <algorithm>
using namespace std;

template <typename T>
class Printer {
public:
  void operator() ( const T& element ) {
    cout << element << "t";
  }
};

int main () {
  vector<int> v = { 10, 20, 30, 40, 50 };

  cout << "nPrint the vector entries using Functor" << endl;

  for_each ( v.begin(), v.end(), Printer<int>() );

  cout << endl;

  return 0;
}

让我们快速使用以下命令编译程序:

g++ main.cpp -std=c++17
./a.out

让我们检查程序的输出:

Print the vector entries using Functor
10  20  30  40  50

希望您意识到函数对象是多么简单和酷。

序列容器

STL 支持一系列有趣的序列容器。序列容器以线性方式存储同类数据类型,可以按顺序访问。STL 支持以下序列容器:

  • 数组

  • 向量

  • 列表

  • forward_list

  • 双端队列

由于存储在 STL 容器中的对象只是值的副本,STL 期望用户定义的数据类型满足一定的基本要求,以便将这些对象存储在容器中。存储在 STL 容器中的每个对象都必须提供以下最低要求:

  • 默认构造函数

  • 一个复制构造函数

  • 赋值运算符

让我们在以下小节中逐一探索序列容器。

数组

STL 数组容器是一个固定大小的序列容器,就像 C/C内置数组一样,只是 STL 数组具有大小感知,并且比内置的 C/C数组更智能。让我们通过一个示例了解 STL 数组:

#include <iostream>
#include <array>
using namespace std;
int main () {
  array<int,5> a = { 1, 5, 2, 4, 3 };

  cout << "nSize of array is " << a.size() << endl;

  auto pos = a.begin();

  cout << endl;
  while ( pos != a.end() ) 
    cout << *pos++ << "t";
  cout << endl;

  return 0;
}

前面的代码可以编译,并且可以使用以下命令查看输出:

g++ main.cpp -std=c++17
./a.out 

程序的输出如下:

Size of array is 5
1     5     2     4     3

代码演示

以下行声明了一个固定大小(5)的数组,并用五个元素初始化数组:

array<int,5> a = { 1, 5, 2, 4, 3 };

一旦声明,大小就无法更改,就像 C/C内置数组一样。array::size()方法返回数组的大小,不管初始化列表中初始化了多少个整数。auto pos = a.begin()方法声明了一个array<int,5>的迭代器,并将数组的起始位置赋给它。array::end()方法指向数组中最后一个元素之后的一个位置。迭代器的行为类似于或模仿 C指针,对迭代器进行解引用会返回迭代器指向的值。迭代器的位置可以向前和向后移动,分别使用++pos--pos

数组中常用的 API

以下表格显示了一些常用的数组 API:

API 描述
at( int index ) 这返回索引指向的位置存储的值。索引是从零开始的。如果索引超出数组的索引范围,此 API 将抛出std::out_of_range异常。
operator [ int index ] 这是一个不安全的方法,如果索引超出数组的有效范围,它不会抛出任何异常。这比at略快,因为此 API 不执行边界检查。
front() 这返回数组中的第一个元素。
back() 这返回数组中的最后一个元素。
begin() 这返回数组中第一个元素的位置
end() 这返回数组中最后一个元素的位置之后的一个位置
rbegin() 这返回反向开始位置,即返回数组中最后一个元素的位置
rend() 这返回反向结束位置,即返回数组中第一个元素之前的一个位置
size() 这返回数组的大小

数组容器支持随机访问;因此,给定一个索引,数组容器可以以*O(1)*或常量时间的运行复杂度获取一个值。

数组容器元素可以使用反向迭代器以反向方式访问:

#include <iostream>
#include <array>
using namespace std;

int main () {

    array<int, 6> a;
    int size = a.size();
    for (int index=0; index < size; ++index)
         a[index] = (index+1) * 100;   

    cout << "nPrint values in original order ..." << endl;

    auto pos = a.begin();
    while ( pos != a.end() )
        cout << *pos++ << "t";
    cout << endl;

    cout << "nPrint values in reverse order ..." << endl;

    auto rpos = a.rbegin();
    while ( rpos != a.rend() )
    cout << *rpos++ << "t";
    cout << endl;

    return 0;
}

我们将使用以下命令来获取输出:

./a.out

输出如下:

Print values in original order ...
100   200   300   400   500   600

Print values in reverse order ...
600   500   400   300   200   100

Vector

向量是一个非常有用的序列容器,它的工作方式与数组完全相同,只是向量可以在运行时增长和缩小,而数组的大小是固定的。然而,在数组和向量底层使用的数据结构是一个简单的内置 C/C++风格数组。

让我们看下面的例子更好地理解向量:

#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;

int main () {
  vector<int> v = { 1, 5, 2, 4, 3 };

  cout << "nSize of vector is " << v.size() << endl;

  auto pos = v.begin();

  cout << "nPrint vector elements before sorting" << endl;
  while ( pos != v.end() )
    cout << *pos++ << "t";
  cout << endl;

  sort( v.begin(), v.end() );

  pos = v.begin();

  cout << "nPrint vector elements after sorting" << endl;

  while ( pos != v.end() )
    cout << *pos++ << "t";
  cout << endl;

  return 0;
}

可以编译上述代码,并使用以下命令查看输出:

g++ main.cpp -std=c++17
./a.out

程序的输出如下:

Size of vector is 5

Print vector elements before sorting
1     5     2     4     3

Print vector elements after sorting
1     2     3     4     5

代码演示

以下行声明了一个向量,并用五个元素初始化了向量:

vector<int> v = { 1, 5, 2, 4, 3 };

然而,向量还允许使用vector::push_back<data_type>( value ) API 将值附加到向量的末尾。sort()算法接受两个表示必须排序的数据范围的随机访问迭代器。由于向量在内部使用内置的 C/C++数组,就像 STL 数组容器一样,向量也支持随机访问迭代器;因此,sort()函数是一个运行时复杂度为对数的高效算法,即O(N log2 (N))

常用的向量 API

以下表格显示了一些常用的向量 API:

API 描述
at ( int index ) 返回存储在索引位置的值。如果索引无效,则会抛出std::out_of_range异常。
operator [ int index ] 返回存储在索引位置的值。这个函数比at( int index )更快,因为它不执行边界检查。
front() 返回向量中存储的第一个值。
back() 返回向量中存储的最后一个值。
empty() 如果向量为空,则返回 true,否则返回 false。
size() 返回向量中存储的值的数量。
reserve( int size ) 这会保留向量的初始大小。当向量大小达到其容量时,插入新值需要向量调整大小。这使得插入消耗*O(N)*的运行复杂度。reserve()方法是对描述的问题的一种解决方法。
capacity() 返回向量的总容量,而大小是向量中实际存储的值。
clear() 这会清除所有的值。
push_back<data_type>( value ) 这会在向量的末尾添加一个新值。

使用istream_iteratorostream_iterator从向量中读取和打印会非常有趣和方便。以下代码演示了向量的使用:

#include <iostream>
#include <vector>
#include <algorithm>
#include <iterator>
using namespace std;

int main () {
    vector<int> v;

    cout << "nType empty string to end the input once you are done feeding the vector" << endl;
    cout << "nEnter some numbers to feed the vector ..." << endl;

    istream_iterator<int> start_input(cin);
    istream_iterator<int> end_input;

    copy ( start_input, end_input, back_inserter( v ) );

    cout << "nPrint the vector ..." << endl;
    copy ( v.begin(), v.end(), ostream_iterator<int>(cout, "t") );
    cout << endl;

    return 0;
}

请注意,程序的输出被跳过,因为输出取决于您输入的输入。请随意在命令行上尝试这些指令。

代码演示

基本上,复制算法接受一系列迭代器,其中前两个参数表示源,第三个参数表示目标,这恰好是向量:

istream_iterator<int> start_input(cin);
istream_iterator<int> end_input;

copy ( start_input, end_input, back_inserter( v ) );

start_input迭代器实例定义了一个从istreamcin接收输入的istream_iterator迭代器,而end_input迭代器实例定义了一个文件结束分隔符,默认情况下是一个空字符串("")。因此,输入可以通过在命令行输入终端中键入""来终止。

同样,让我们了解下面的代码片段:

cout << "nPrint the vector ..." << endl;
copy ( v.begin(), v.end(), ostream_iterator<int>(cout, "t") );
cout << endl;

复制算法用于将向量中的值逐个复制到ostream中,并用制表符(t)分隔输出。

向量的缺陷

每个 STL 容器都有自己的优点和缺点。没有一个 STL 容器在所有情况下都表现更好。向量在内部使用数组数据结构,而在 C/C++中数组的大小是固定的。因此,当您尝试在向量中添加新值时,如果向量的大小已经达到了最大容量,那么向量将分配新的连续位置,可以容纳旧值和新值,并且在连续位置开始复制旧值。一旦所有数据元素都被复制,向量将使旧位置无效。

每当这种情况发生时,向量插入将需要*O(N)*的运行时复杂度。随着向量大小随时间增长,*O(N)*的运行时复杂度将导致性能相当糟糕。如果您知道所需的最大大小,可以预留足够的初始大小来克服这个问题。然而,并不是在所有情况下都需要使用向量。当然,向量支持动态大小和随机访问,在某些情况下具有性能优势,但您正在处理的功能可能并不真正需要随机访问,这种情况下列表、双端队列或其他某些容器可能更适合您。

列表

列表 STL 容器在内部使用双向链表数据结构。因此,列表仅支持顺序访问,在最坏的情况下在列表中搜索随机值可能需要*O(N)的运行时复杂度。然而,如果您确定只需要顺序访问,列表确实提供了自己的好处。列表 STL 容器允许您以常数时间复杂度在末尾、前面或中间插入数据元素,即在最佳、平均和最坏的情况下都是O(1)*的运行时复杂度。

以下图片展示了列表 STL 使用的内部数据结构:

让我们编写一个简单的程序来亲身体验使用列表 STL:

#include <iostream>
#include <list>
#include <iterator>
#include <algorithm>
using namespace std;

int main () {

  list<int> l;

  for (int count=0; count<5; ++count)
    l.push_back( (count+1) * 100 );

  auto pos = l.begin();

  cout << "nPrint the list ..." << endl;
  while ( pos != l.end() )
    cout << *pos++ << "-->";
  cout << " X" << endl;

  return 0;
}

我相信到现在为止,您已经品尝到了 C++ STL 的优雅和强大之处。观察到语法在所有 STL 容器中保持不变,是不是很酷?您可能已经注意到,无论您使用数组、向量还是列表,语法都保持不变。相信我,当您探索其他 STL 容器时,也会有同样的感觉。

话虽如此,前面的代码是不言自明的,因为我们在其他容器中做了几乎相同的事情。

让我们尝试对列表进行排序,如下面的代码所示:

#include <iostream>
#include <list>
#include <iterator>
#include <algorithm>
using namespace std;

int main () {

    list<int> l = { 100, 20, 80, 50, 60, 5 };

    auto pos = l.begin();

    cout << "nPrint the list before sorting ..." << endl;
    copy ( l.begin(), l.end(), ostream_iterator<int>( cout, "-->" ));
    cout << "X" << endl;

    l.sort();

    cout << "nPrint the list after sorting ..." << endl;
    copy ( l.begin(), l.end(), ostream_iterator<int>( cout, "-->" ));
    cout << "X" << endl; 

    return 0;
}

您注意到了sort()方法吗?是的,列表容器有自己的排序算法。列表容器支持自己版本的排序算法的原因是,通用的sort()算法需要一个随机访问迭代器,而列表容器不支持随机访问。在这种情况下,相应的容器将提供自己的高效算法来克服这个缺点。

有趣的是,列表支持的sort算法的运行时复杂度为O(N log2 N)

列表中常用的 API

以下表格显示了 STL 列表中最常用的 API:

API 描述
front() 这返回列表中存储的第一个值
back()  这返回列表中存储的最后一个值
size() 这返回列表中存储的值的数量
empty() 当列表为空时返回true,否则返回false
clear() 这会清除列表中存储的所有值
push_back<data_type>( value ) 这在列表的末尾添加一个值
push_front<data_type>( value ) 这在列表的前面添加一个值
merge( list ) 这将两个相同类型值的排序列表合并
reverse() 这会反转列表
unique() 这从列表中删除重复的值
sort() 这会对列表中存储的值进行排序

Forward list

STL 的forward_list容器是建立在单向链表数据结构之上的;因此,它只支持向前导航。由于forward_list在内存和运行时方面每个节点消耗一个较少的指针,因此与列表容器相比,它被认为更有效。然而,作为性能优势的额外代价,forward_list必须放弃一些功能。

以下图表显示了forward_list中使用的内部数据结构:

让我们来探索以下示例代码:

#include <iostream>
#include <forward_list>
#include <iterator>
#include <algorithm>
using namespace std;

int main ( ) {

  forward_list<int> l = { 10, 10, 20, 30, 45, 45, 50 };

  cout << "nlist with all values ..." << endl;
  copy ( l.begin(), l.end(), ostream_iterator<int>(cout, "t") );

  cout << "nSize of list with duplicates is " << distance( l.begin(), l.end() ) << endl;

  l.unique();

  cout << "nSize of list without duplicates is " << distance( l.begin(), l.end() ) << endl;

  l.resize( distance( l.begin(), l.end() ) );

  cout << "nlist after removing duplicates ..." << endl;
  copy ( l.begin(), l.end(), ostream_iterator<int>(cout, "t") );
  cout << endl;

  return 0;

}

输出可以通过以下命令查看:

./a.out

输出将如下所示:

list with all values ...
10    10    20    30    45    45    50
Size of list with duplicates is 7

Size of list without duplicates is 5

list after removing duplicates ...
10    20   30   45   50

代码演示

以下代码声明并初始化了forward_list容器,其中包含一些唯一的值和一些重复的值:

forward_list<int> l = { 10, 10, 20, 30, 45, 45, 50 };

由于forward_list容器不支持size()函数,我们使用distance()函数来找到列表的大小:

cout << "nSize of list with duplicates is " << distance( l.begin(), l.end() ) << endl;

以下forward_list<int>::unique()函数会移除重复的整数,只保留唯一的值:

l.unique();

forward_list容器中常用的 API

下表显示了常用的forward_list API:

API 描述
front() 这返回forward_list容器中存储的第一个值
empty() forward_list容器为空时返回 true,否则返回 false。
clear() 这会清除forward_list中存储的所有值。
push_front<data_type>( value ) 这会将一个值添加到forward_list的前面。
merge( list ) 这会合并两个排序的forward_list容器,其值类型相同
reverse() 这会颠倒forward_list容器
unique() 这会从forward_list容器中移除重复的值。
sort() 这会对forward_list中存储的值进行排序

让我们再来看一个例子,以更好地理解forward_list容器:

#include <iostream>
#include <forward_list>
#include <iterator>
#include <algorithm>
using namespace std;

int main () {

    forward_list<int> list1 = { 10, 20, 10, 45, 45, 50, 25 };
    forward_list<int> list2 = { 20, 35, 27, 15, 100, 85, 12, 15 };

    cout << "nFirst list before sorting ..." << endl;
    copy ( list1.begin(), list1.end(), ostream_iterator<int>(cout, "t") );
    cout << endl; 

    cout << "nSecond list before sorting ..." << endl;
    copy ( list2.begin(), list2.end(), ostream_iterator<int>(cout, "t") );
    cout << endl;

    list1.sort();
    list2.sort();

    cout << "nFirst list after sorting ..." << endl;
    copy ( list1.begin(), list1.end(), ostream_iterator<int>(cout, "t") );
    cout << endl; 

    cout << "nSecond list after sorting ..." << endl;
    copy ( list2.begin(), list2.end(), ostream_iterator<int>(cout, "t") );
    cout << endl;    

    list1.merge ( list2 );

    cout << "nMerged list ..." << endl;
    copy ( list1.begin(), list1.end(), ostream_iterator<int>(cout, "t") );

    cout << "nMerged list after removing duplicates ..." << endl;
    list1.unique(); 
    copy ( list1.begin(), list1.end(), ostream_iterator<int>(cout, "t") );

    return 0;
}

上面的代码片段是一个有趣的例子,演示了sort()merge()unique() STL 算法的实际用途。

输出可以通过以下命令查看:

./a.out

程序的输出如下:

First list before sorting ...
10   20   10   45   45   50   25
Second list before sorting ...
20   35   27   15   100  85   12   15

First list after sorting ...
10   10   20   25   45   45   50
Second list after sorting ...
12   15   15   20   27   35   85   100

Merged list ...
10   10   12   15   15   20   20   25   27   35   45   45  50   85  100
Merged list after removing duplicates ...
10   12   15   20   25   27   35   45   50   85  100

输出和程序都很容易理解。

Deque

deque 容器是一个双端队列,其使用的数据结构可以是动态数组或向量。在 deque 中,可以在前面和后面插入元素,时间复杂度为O(1),而在向量中,插入元素在后面的时间复杂度为O(1),而在前面的时间复杂度为O(N)。deque 不会遭受向量遭受的重新分配问题。然而,deque 具有向量的所有优点,只是在性能方面略优于向量,因为每一行都有几行动态数组或向量。

以下图表显示了 deque 容器中使用的内部数据结构:

让我们编写一个简单的程序来尝试 deque 容器:

#include <iostream>
#include <deque>
#include <algorithm>
#include <iterator>
using namespace std;

int main () {
  deque<int> d = { 10, 20, 30, 40, 50 };

  cout << "nInitial size of deque is " << d.size() << endl;

  d.push_back( 60 );
  d.push_front( 5 );

  cout << "nSize of deque after push back and front is " << d.size() << endl;

  copy ( d.begin(), d.end(), ostream_iterator<int>( cout, "t" ) );
  d.clear();

  cout << "nSize of deque after clearing all values is " << d.size() <<
endl;

  cout << "nIs the deque empty after clearing values ? " << ( d.empty()
? "true" : "false" ) << endl;

return 0;
}

输出可以通过以下命令查看:

./a.out

程序的输出如下:

Intitial size of deque is 5

Size of deque after push back and front is 7

Print the deque ...
5  10  20  30  40  50  60
Size of deque after clearing all values is 0

Is the deque empty after clearing values ? true

deque 中常用的 API

下表显示了常用的 deque API:

API 描述
at ( int index ) 这返回存储在索引位置的值。如果索引无效,则会抛出std::out_of_range异常。
operator [ int index ] 这返回存储在索引位置的值。与at( int index )相比,此函数不执行边界检查,因此速度更快。
front() 这返回 deque 中存储的第一个值。
back()  这返回 deque 中存储的最后一个值。
empty() 如果 deque 为空则返回true,否则返回false
size()  这返回 deque 中存储的值的数量。
capacity() 这会返回 deque 的总容量,而size()返回 deque 中实际存储的值的数量。
clear() 这会清除所有值。
push_back<data_type>( value ) 这会在 deque 的末尾添加一个新值。

关联容器

关联容器以排序的方式存储数据,与序列容器不同。因此,关联容器不会保留插入数据的顺序。关联容器在搜索值时非常高效,具有*O(log n)*的运行时复杂度。每次向容器添加新值时,如果需要,容器将重新排序内部存储的值。

STL 支持以下类型的关联容器:

  • 集合

  • 映射

  • 多重集

  • 多重映射

  • 无序集合

  • 无序多重集

  • 无序映射

  • 无序多重映射

关联容器将数据组织为键-值对。数据将根据键进行排序,以实现随机和更快的访问。关联容器有两种类型:

  • 有序

  • 无序

以下关联容器属于有序容器,因为它们以特定的方式排序。有序关联容器通常使用某种形式的二叉搜索树BST);通常使用红黑树来存储数据:

  • 集合

  • 映射

  • 多重集

  • 多重映射

以下关联容器属于无序容器,因为它们没有以任何特定方式排序,并且它们使用哈希表:

  • 无序集合

  • 无序映射

  • 无序多重集

  • 无序多重映射

让我们在以下小节中通过示例了解先前提到的容器。

集合

集合容器以排序的方式仅存储唯一的值。集合使用值作为键来组织值。集合容器是不可变的,也就是说,存储在集合中的值不能被修改;但是,值可以被删除。集合通常使用红黑树数据结构,这是一种平衡二叉搜索树。集合操作的时间复杂度保证为O(log N)

让我们使用一个集合编写一个简单的程序:

#include <iostream>
#include <set>
#include <vector>
#include <iterator>
#include <algorithm>
using namespace std;

int main( ) {
    set<int> s1 = { 1, 3, 5, 7, 9 };
    set<int> s2 = { 2, 3, 7, 8, 10 };

    vector<int> v( s1.size() + s2.size() );

    cout << "nFirst set values are ..." << endl;
    copy ( s1.begin(), s1.end(), ostream_iterator<int> ( cout, "t" ) );
    cout << endl;

    cout << "nSecond set values are ..." << endl;
    copy ( s2.begin(), s2.end(), ostream_iterator<int> ( cout, "t" ) );
    cout << endl;

    auto pos = set_difference ( s1.begin(), s1.end(), s2.begin(), s2.end(), v.begin() ); 
    v.resize ( pos - v.begin() );

    cout << "nValues present in set one but not in set two are ..." << endl;
    copy ( v.begin(), v.end(), ostream_iterator<int> ( cout, "t" ) );
    cout << endl; 

    v.clear();

    v.resize ( s1.size() + s2.size() );

    pos = set_union ( s1.begin(), s1.end(), s2.begin(), s2.end(), v.begin() );

    v.resize ( pos - v.begin() );

    cout << "nMerged set values in vector are ..." << endl;
    copy ( v.begin(), v.end(), ostream_iterator<int> ( cout, "t" ) );
    cout << endl; 

    return 0;
}

可以使用以下命令查看输出:

./a.out

程序的输出如下:

First set values are ...
1   3   5   7   9

Second set values are ...
2   3   7   8   10

Values present in set one but not in set two are ...
1   5   9

Merged values of first and second set are ...
1   2   3   5   7   8   9  10

代码演示

以下代码声明并初始化了两个集合s1s2

set<int> s1 = { 1, 3, 5, 7, 9 };
set<int> s2 = { 2, 3, 7, 8, 10 };

以下行将确保向量有足够的空间来存储结果向量中的值:

vector<int> v( s1.size() + s2.size() );

以下代码将打印s1s2中的值:

cout << "nFirst set values are ..." << endl;
copy ( s1.begin(), s1.end(), ostream_iterator<int> ( cout, "t" ) );
cout << endl;

cout << "nSecond set values are ..." << endl;
copy ( s2.begin(), s2.end(), ostream_iterator<int> ( cout, "t" ) );
cout << endl;

set_difference()算法将使用集合s1中仅存在而不在s2中的值填充向量v。迭代器pos将指向向量中的最后一个元素;因此,向量resize将确保向量中的额外空间被移除:

auto pos = set_difference ( s1.begin(), s1.end(), s2.begin(), s2.end(), v.begin() ); 
v.resize ( pos - v.begin() );

以下代码将打印向量v中填充的值:

cout << "nValues present in set one but not in set two are ..." << endl;
copy ( v.begin(), v.end(), ostream_iterator<int> ( cout, "t" ) );
cout << endl;

set_union()算法将合并集合s1s2的内容到向量中,然后调整向量的大小以适应合并后的值:

pos = set_union ( s1.begin(), s1.end(), s2.begin(), s2.end(), v.begin() );
v.resize ( pos - v.begin() );

以下代码将打印向量v中填充的合并值:

cout << "nMerged values of first and second set are ..." << endl;
copy ( v.begin(), v.end(), ostream_iterator<int> ( cout, "t" ) );
cout << endl;

集合中常用的 API

以下表格描述了常用的集合 API:

API 描述
insert( value ) 这会将一个值插入到集合中
clear() 这会清除集合中的所有值
size() 这会返回集合中存在的条目总数
empty() 如果集合为空,则会打印true,否则返回false
find() 这会查找具有指定键的元素并返回迭代器位置
equal_range() 这会返回与特定键匹配的元素范围
lower_bound() 这会返回指向第一个不小于给定键的元素的迭代器
upper_bound() 这会返回指向第一个大于给定键的元素的迭代器

映射

映射按键组织值。与集合不同,映射每个值都有一个专用键。映射通常使用红黑树作为内部数据结构,这是一种平衡的 BST,可以保证在映射中搜索或定位值的*O(log N)*运行时效率。映射中存储的值根据键使用红黑树进行排序。映射中使用的键必须是唯一的。映射不会保留输入的顺序,因为它根据键重新组织值,也就是说,红黑树将被旋转以平衡红黑树高度。

让我们写一个简单的程序来理解映射的用法:

#include <iostream>
#include <map>
#include <iterator>
#include <algorithm>
using namespace std;
int main ( ) {

  map<string, long> contacts;

  contacts["Jegan"] = 123456789;
  contacts["Meena"] = 523456289;
  contacts["Nitesh"] = 623856729;
  contacts["Sriram"] = 993456789;

  auto pos = contacts.find( "Sriram" );

  if ( pos != contacts.end() )
    cout << pos->second << endl;

  return 0;
}

让我们编译并检查程序的输出:

g++ main.cpp -std=c++17
./a.out

输出如下:

Mobile number of Sriram is 8901122334

代码漫游

以下行声明了一个映射,其中string名称作为键,long手机号作为存储在映射中的值:

map< string, long > contacts;

以下代码片段添加了四个按名称组织的联系人:

 contacts[ "Jegan" ] = 1234567890;
 contacts[ "Meena" ] = 5784433221;
 contacts[ "Nitesh" ] = 4567891234;
 contacts[ "Sriram" ] = 8901122334;

以下行将尝试在联系人映射中查找名为Sriram的联系人;如果找到Sriram,则find()函数将返回指向键值对位置的迭代器;否则返回contacts.end()位置:

 auto pos = contacts.find( "Sriram" );

以下代码验证迭代器pos是否已达到contacts.end()并打印联系人号码。由于映射是一个关联容器,它存储key=>value对;因此,pos->first表示键,pos->second表示值:

 if ( pos != contacts.end() )
 cout << "nMobile number of " << pos->first << " is " << pos->second << endl;
 else
 cout << "nContact not found." << endl;

映射中常用的 API

以下表格显示了常用的映射 API:

API 描述
at ( key ) 如果找到键,则返回相应键的值;否则抛出std::out_of_range异常
operator[ key ] 如果找到键,则更新相应键的现有值;否则,将添加一个具有相应key=>value的新条目
empty() 如果映射为空,则返回true,否则返回false
size() 返回映射中存储的key=>value对的数量
clear() 清除映射中存储的条目
count() 返回与给定键匹配的元素数量
find() 查找具有指定键的元素

多重集合

多重集合容器的工作方式与集合容器类似,只是集合只允许存储唯一的值,而多重集合允许存储重复的值。如你所知,在集合和多重集合容器的情况下,值本身被用作键来组织数据。多重集合容器就像一个集合;它不允许修改存储在多重集合中的值。

让我们写一个使用多重集合的简单程序:

#include <iostream>
#include <set>
#include <iterator>
#include <algorithm>
using namespace std;

int main() {
  multiset<int> s = { 10, 30, 10, 50, 70, 90 };

  cout << "nMultiset values are ..." << endl;

  copy ( s.begin(), s.end(), ostream_iterator<int> ( cout, "t" ) );
  cout << endl;

  return 0;
}

可以使用以下命令查看输出:

./a.out

程序的输出如下:

Multiset values are ...
10 30 10 50 70 90

有趣的是,在前面的输出中,你可以看到多重集合包含重复的值。

多重映射

多重映射与映射完全相同,只是多重映射容器允许使用相同的键存储多个值。

让我们用一个简单的例子来探索多重映射容器:

#include <iostream>
#include <map>
#include <vector>
#include <iterator>
#include <algorithm>
using namespace std;

int main() {
  multimap< string, long > contacts = {
    { "Jegan", 2232342343 },
    { "Meena", 3243435343 },
    { "Nitesh", 6234324343 },
    { "Sriram", 8932443241 },
    { "Nitesh", 5534327346 }
  };

  auto pos = contacts.find ( "Nitesh" );
  int count = contacts.count( "Nitesh" );
  int index = 0;

  while ( pos != contacts.end() ) { 
  cout << "\nMobile number of " << pos->first << " is " << 
  pos->second << endl; 
  ++index; 
  ++pos;
  if ( index == count )
     break; 
}
  return 0;
}

该程序可以编译,并且可以使用以下命令查看输出:

g++ main.cpp -std=c++17

./a.out

程序的输出如下:

Mobile number of Nitesh is 6234324343
Mobile number of Nitesh is 5534327346

无序集合

无序集合的工作方式与集合类似,只是这些容器的内部行为不同。集合使用红黑树,而无序集合使用哈希表。集合操作的时间复杂度为O(log N),而无序集合操作的时间复杂度为O(1);因此,无序集合比集合更快。

无序集合中存储的值没有特定的顺序,不像集合那样以排序的方式存储值。如果性能是标准,那么无序集合是一个不错的选择;然而,如果需要以排序的方式迭代值,那么集合是一个不错的选择。

无序映射

无序映射的工作方式类似于映射,只是这些容器的内部行为不同。映射使用红黑树,而无序映射使用哈希表。映射操作的时间复杂度为O(log N),而无序映射操作的时间复杂度为O(1);因此,无序映射比映射更快。

无序映射中存储的值没有任何特定的顺序,不像映射中的值按键排序。

无序多重集

无序多重集的工作方式类似于多重集,只是这些容器的内部行为不同。多重集使用红黑树,而无序多重集使用哈希表。多重集操作的时间复杂度为O(log N),而无序多重集操作的时间复杂度为O(1)。因此,无序多重集比多重集更快。

无序多重集中存储的值没有任何特定的顺序,不像多重集中的值以排序的方式存储。如果性能是标准,无序多重集是一个不错的选择;然而,如果需要以排序的方式迭代值,则多重集是一个不错的选择。

无序多重映射

无序多重映射的工作方式类似于多重映射,只是这些容器的内部行为不同。多重映射使用红黑树,而无序多重映射使用哈希表。多重映射操作的时间复杂度为O(log N),而无序多重映射操作的时间复杂度为O(1);因此,无序多重映射比多重映射更快。

无序多重映射中存储的值没有任何特定的顺序,不像多重映射中的值按键排序。如果性能是标准,那么无序多重映射是一个不错的选择;然而,如果需要以排序的方式迭代值,则多重映射是一个不错的选择。

容器适配器

容器适配器通过组合而不是继承来适配现有容器以提供新的容器。

STL 容器不能通过继承来扩展,因为它们的构造函数不是虚拟的。在整个 STL 中,您可以观察到,虽然在运算符重载和模板方面都使用了静态多态性,但出于性能原因,动态多态性是有意避免的。因此,通过对现有容器进行子类化来扩展 STL 并不是一个好主意,因为容器类并没有设计成像基类一样行为,这会导致内存泄漏。

STL 支持以下容器适配器:

  • 队列

  • 优先队列

让我们在以下小节中探索容器适配器。

栈不是一个新的容器;它是一个模板适配器类。适配器容器包装现有容器并提供高级功能。栈适配器容器提供栈操作,同时隐藏对栈不相关的不必要功能。STL 栈默认使用双端队列容器;然而,在栈实例化期间,我们可以指示栈使用任何满足栈要求的现有容器。

双端队列、列表和向量满足栈适配器的要求。

栈遵循后进先出LIFO)的原则。

栈中常用的 API

以下表格显示了常用的栈 API:

API 描述
top() 这将返回栈中的顶部值,即最后添加的值
push<data_type>( value ) 这将提供的值推送到栈的顶部
pop() 这将从栈中移除顶部的值
size() 这将返回栈中存在的值的数量
empty() 如果栈为空,则返回true;否则返回false

是时候动手了;让我们编写一个简单的程序来使用栈:

#include <iostream>
#include <stack>
#include <iterator>
#include <algorithm>
using namespace std;

int main ( ) {

  stack<string> spoken_languages;

  spoken_languages.push ( "French" );
  spoken_languages.push ( "German" );
  spoken_languages.push ( "English" );
  spoken_languages.push ( "Hindi" );
  spoken_languages.push ( "Sanskrit" );
  spoken_languages.push ( "Tamil" );

  cout << "nValues in Stack are ..." << endl;
  while ( ! spoken_languages.empty() ) {
              cout << spoken_languages.top() << endl;
        spoken_languages.pop();
  }
  cout << endl;

  return 0;
}

程序可以编译,并且可以使用以下命令查看输出:

g++ main.cpp -std=c++17

./a.out

程序的输出如下:

Values in Stack are ...
Tamil
Kannada
Telugu
Sanskrit
Hindi
English
German
French

从前面的输出中,我们可以看到栈的 LIFO 行为。

队列

队列基于先进先出(FIFO)原则工作。队列不是一个新的容器;它是一个模板化的适配器类,它包装了一个现有的容器,并提供了队列操作所需的高级功能,同时隐藏了对队列无关的不必要功能。STL 队列默认使用双端队列容器;然而,我们可以在队列实例化期间指示队列使用满足队列要求的任何现有容器。

在队列中,新值可以添加到后面并从前面删除。双端队列、列表和向量满足队列适配器的要求。

队列中常用的 API

以下表格显示了常用的队列 API:

API 描述
push() 这在队列的后面追加一个新值
pop() 这删除队列前面的值
front() 这返回队列前面的值
back() 这返回队列的后面的值
empty() 当队列为空时返回true;否则返回false
size() 这返回存储在队列中的值的数量

让我们在以下程序中使用队列:

#include <iostream>
#include <queue>
#include <iterator>
#include <algorithm>
using namespace std;

int main () {
  queue<int> q;

  q.push ( 100 );
  q.push ( 200 );
  q.push ( 300 );

  cout << "nValues in Queue are ..." << endl;
  while ( ! q.empty() ) {
    cout << q.front() << endl;
    q.pop();
  }

  return 0;
}

程序可以编译,并且可以使用以下命令查看输出:

g++ main.cpp -std=c++17

./a.out

程序的输出如下:

Values in Queue are ...
100
200
300

从前面的输出中,您可以观察到值以它们被推入的相同顺序弹出,即 FIFO。

优先队列

优先队列不是一个新的容器;它是一个模板化的适配器类,它包装了一个现有的容器,并提供了优先队列操作所需的高级功能,同时隐藏了对优先队列无关的不必要功能。优先队列默认使用向量容器;然而,双端队列容器也满足优先队列的要求。因此,在优先队列实例化期间,您可以指示优先队列也使用双端队列。

优先队列以这样的方式组织数据,使得最高优先级的值首先出现;换句话说,值按降序排序。

双端队列和向量满足优先队列适配器的要求。

优先队列中常用的 API

以下表格显示了常用的优先队列 API:

API 描述
push() 这在优先队列的后面追加一个新值
pop() 这删除优先队列前面的值
empty() 当优先队列为空时返回true;否则返回false
size() 这返回存储在优先队列中的值的数量
top() 这返回优先队列前面的值

让我们编写一个简单的程序来理解priority_queue

#include <iostream>
#include <queue>
#include <iterator>
#include <algorithm>
using namespace std;

int main () {
  priority_queue<int> q;

  q.push( 100 );
  q.push( 50 );
  q.push( 1000 );
  q.push( 800 );
  q.push( 300 );

  cout << "nSequence in which value are inserted are ..." << endl;
  cout << "100t50t1000t800t300" << endl;
  cout << "Priority queue values are ..." << endl;

  while ( ! q.empty() ) {
    cout << q.top() << "t";
    q.pop();
  }
  cout << endl;

  return 0;
}

程序可以编译,并且可以使用以下命令查看输出:

g++ main.cpp -std=c++17

./a.out

程序的输出如下:

Sequence in which value are inserted are ...
100   50   1000  800   300

Priority queue values are ...
1000  800   300   100   50

从前面的输出中,您可以观察到priority_queue是一种特殊类型的队列,它重新排列输入,使得最高值首先出现。

总结

在本章中,您学习了现成的通用容器、函数对象、迭代器和算法。您还学习了集合、映射、多重集和多重映射关联容器,它们的内部数据结构以及可以应用于它们的常见算法。此外,您还学习了如何使用各种容器与实际的代码示例。

在下一章中,您将学习模板编程,这将帮助您掌握模板的基本知识。

第三章:模板编程

在本章中,我们将涵盖以下主题:

  • 泛型编程

  • 函数模板

  • 类模板

  • 重载函数模板

  • 泛型类

  • 显式类特化

  • 部分特化

现在让我们开始学习泛型编程。

泛型编程

泛型编程是一种编程风格,可以帮助您开发可重用的代码或通用算法,可以应用于各种数据类型。每当调用通用算法时,数据类型将以特殊的语法作为参数提供。

假设我们想要编写一个sort()函数,它接受一个需要按升序排序的输入数组。其次,我们需要sort()函数来对intdoublecharstring数据类型进行排序。有几种方法可以解决这个问题:

  • 我们可以为每种数据类型编写四个不同的sort()函数

  • 我们也可以编写一个单一的宏函数

好吧,这两种方法都有各自的优点和缺点。第一种方法的优点是,由于intdoublecharstring数据类型都有专门的函数,如果提供了不正确的数据类型,编译器将能够执行类型检查。第一种方法的缺点是,尽管所有函数的逻辑都相同,但我们必须编写四个不同的函数。如果在算法中发现了错误,必须分别在所有四个函数中进行修复;因此,需要进行大量的维护工作。如果我们需要支持另一种数据类型,我们将不得不编写另一个函数,随着需要支持更多的数据类型,这种情况将不断增加。

第二种方法的优点是,我们可以为所有数据类型编写一个宏。然而,一个非常令人沮丧的缺点是,编译器将无法执行类型检查,这种方法更容易出现错误,并可能引发许多意外的麻烦。这种方法与面向对象的编码原则背道而驰。

C++通过模板支持泛型编程,具有以下优点:

  • 我们只需要使用模板编写一个函数

  • 模板支持静态多态

  • 模板提供了前面两种方法的所有优点,没有任何缺点

  • 泛型编程实现了代码重用

  • 生成的代码是面向对象的

  • C++编译器可以在编译时执行类型检查

  • 易于维护

  • 支持各种内置和用户定义的数据类型

然而,缺点如下:

  • 并不是所有的 C++程序员都感到舒适编写基于模板的代码,但这只是一个初始的阻碍

  • 在某些情况下,模板可能会使代码膨胀并增加二进制占用空间,导致性能问题

函数模板

函数模板允许您对数据类型进行参数化。之所以称之为泛型编程,是因为单个模板函数将支持许多内置和用户定义的数据类型。模板化函数的工作原理类似于C 风格的宏,只是 C++编译器在调用模板函数时会对函数进行类型检查,以确保我们在调用模板函数时提供的数据类型是兼容的。

通过一个简单的例子来更容易理解模板的概念,如下所示:

#include <iostream>
#include <algorithm>
#include <iterator>
using namespace std;

template <typename T, int size>
void sort ( T input[] ) {

     for ( int i=0; i<size; ++i) { 
         for (int j=0; j<size; ++j) {
              if ( input[i] < input[j] )
                  swap (input[i], input[j] );
         }
     }

}

int main () {
        int a[10] = { 100, 10, 40, 20, 60, 80, 5, 50, 30, 25 };

        cout << "nValues in the int array before sorting ..." << endl;
        copy ( a, a+10, ostream_iterator<int>( cout, "t" ) );
        cout << endl;

        ::sort<int, 10>( a );

        cout << "nValues in the int array after sorting ..." << endl;
        copy ( a, a+10, ostream_iterator<int>( cout, "t" ) );
        cout << endl;

        double b[5] = { 85.6d, 76.13d, 0.012d, 1.57d, 2.56d };

        cout << "nValues in the double array before sorting ..." << endl;
        copy ( b, b+5, ostream_iterator<double>( cout, "t" ) );
        cout << endl;

        ::sort<double, 5>( b );

        cout << "nValues in the double array after sorting ..." << endl;
        copy ( b, b+5, ostream_iterator<double>( cout, "t" ) );
        cout << endl;

        string names[6] = {
               "Rishi Kumar Sahay",
               "Arun KR",
               "Arun CR",
               "Ninad",
               "Pankaj",
               "Nikita"
        };

        cout << "nNames before sorting ..." << endl;
        copy ( names, names+6, ostream_iterator<string>( cout, "n" ) );
        cout << endl;

        ::sort<string, 6>( names );

        cout << "nNames after sorting ..." << endl;
        copy ( names, names+6, ostream_iterator<string>( cout, "n" ) );
        cout << endl;

        return 0;
}

运行以下命令:

g++ main.cpp -std=c++17
./a.out

上述程序的输出如下:

Values in the int array before sorting ...
100  10   40   20   60   80   5   50   30   25

Values in the int array after sorting ...
5    10   20   25   30   40   50   60   80   100

Values in the double array before sorting ...
85.6d 76.13d 0.012d 1.57d 2.56d

Values in the double array after sorting ...
0.012   1.57   2.56   76.13   85.6

Names before sorting ...
Rishi Kumar Sahay
Arun KR
Arun CR
Ninad
Pankaj
Nikita

Names after sorting ...
Arun CR
Arun KR
Nikita
Ninad
Pankaj
Rich Kumar Sahay

看到一个模板函数就能完成所有的魔术,是不是很有趣?是的,这就是 C++模板的酷之处!

你是否好奇看到模板实例化的汇编输出?使用命令**g++ -S main.cpp**。

代码演示

以下代码定义了一个函数模板。关键字template <typename T, int size>告诉编译器接下来是一个函数模板:

template <typename T, int size>
void sort ( T input[] ) {

 for ( int i=0; i<size; ++i) { 
     for (int j=0; j<size; ++j) {
         if ( input[i] < input[j] )
             swap (input[i], input[j] );
     }
 }

}

void sort ( T input[] )这一行定义了一个名为sort的函数,返回void,接收类型为T的输入数组。T类型不表示任何特定的数据类型。T将在编译时实例化函数模板时推导出来。

以下代码用一些未排序的值填充一个整数数组,并将其打印到终端上:

 int a[10] = { 100, 10, 40, 20, 60, 80, 5, 50, 30, 25 };
 cout << "nValues in the int array before sorting ..." << endl;
 copy ( a, a+10, ostream_iterator<int>( cout, "t" ) );
 cout << endl;

以下行将实例化一个int数据类型的函数模板实例。此时,typename T被替换,为int创建了一个专门的函数。在sort前面的作用域解析运算符,即::sort(),确保它调用我们在全局命名空间中定义的自定义函数sort();否则,C++编译器将尝试调用std 命名空间中定义的sort()算法,或者如果存在这样的函数,则从任何其他命名空间中调用。<int, 10>变量告诉编译器创建一个函数实例,用int替换typename T10表示模板函数中使用的数组的大小:

::sort<int, 10>( a );

以下行将实例化另外两个支持5个元素的double数组和6个元素的string数组的实例:

::sort<double, 5>( b );
::sort<string, 6>( names );

如果您想了解有关 C++编译器如何实例化函数模板以支持intdoublestring的更多细节,可以尝试使用 Unix 实用程序nmc++filtnm Unix 实用程序将列出符号表中的符号,如下所示:

nm ./a.out | grep sort

00000000000017f1 W _Z4sortIdLi5EEvPT_
0000000000001651 W _Z4sortIiLi10EEvPT_
000000000000199b W _Z4sortINSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEELi6EEvPT_

正如您所看到的,二进制文件中有三个不同的重载sort函数;然而,我们只定义了一个模板函数。由于 C++编译器对函数重载进行了名称混淆,我们很难解释这三个函数中的哪一个是为intdoublestring数据类型设计的。

然而,有一个线索:第一个函数是为double设计的,第二个是为int设计的,第三个是为string设计的。对于double,名称混淆的函数为_Z4sortIdLi5EEvPT_,对于int,为_Z4sortIiLi10EEvPT_,对于string,为_Z4sortINSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEELi6EEvPT_。还有一个很酷的 Unix 实用程序可以帮助您轻松解释函数签名。检查c++filt实用程序的以下输出:

c++filt _Z4sortIdLi5EEvPT_
void sort<double, 5>(double*)

c++filt _Z4sortIiLi10EEvPT_
void sort<int, 10>(int*)

c++filt _Z4sortINSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEELi6EEvPT_
void sort<std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >, 6>(std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >*)

希望您在使用 C模板时会发现这些实用程序有用。我相信这些工具和技术将帮助您调试任何 C应用程序。

重载函数模板

函数模板的重载与 C中的常规函数重载完全相同。但是,我将帮助您回顾 C函数重载的基础知识。

C++编译器对函数重载的规则和期望如下:

  • 重载的函数名称将是相同的。

  • C++编译器将无法区分仅通过返回值不同的重载函数。

  • 重载函数参数的数量、数据类型或它们的顺序应该不同。除了其他规则外,当前项目符号中描述的这些规则中至少应满足一个,但更多的符合也不会有坏处。

  • 重载的函数必须在同一个命名空间或同一个类范围内。

如果上述任何规则没有得到满足,C编译器将不会将它们视为重载函数。如果在区分重载函数时存在任何歧义,C编译器将立即报告它为编译错误。

现在是时候通过以下程序示例来探索一下了:

#include <iostream>
#include <array>
using namespace std;

void sort ( array<int,6> data ) {

     cout << "Non-template sort function invoked ..." << endl;

     int size = data.size();

     for ( int i=0; i<size; ++i ) { 
          for ( int j=0; j<size; ++j ) {
                if ( data[i] < data[j] )
                    swap ( data[i], data[j] );
          }
     }

}

template <typename T, int size>
void sort ( array<T, size> data ) {

     cout << "Template sort function invoked with one argument..." << endl;

     for ( int i=0; i<size; ++i ) {
         for ( int j=0; j<size; ++j ) {
             if ( data[i] < data[j] )
                swap ( data[i], data[j] );
         }
     }

}

template <typename T>
void sort ( T data[], int size ) {
     cout << "Template sort function invoked with two arguments..." << endl;

     for ( int i=0; i<size; ++i ) {
         for ( int j=0; j<size; ++j ) {
             if ( data[i] < data[j] )
                swap ( data[i], data[j] );
         }
     }

}

int main() {

    //Will invoke the non-template sort function
    array<int, 6> a = { 10, 50, 40, 30, 60, 20 };
    ::sort ( a );

    //Will invoke the template function that takes a single argument
    array<float,6> b = { 10.6f, 57.9f, 80.7f, 35.1f, 69.3f, 20.0f };
    ::sort<float,6>( b );

    //Will invoke the template function that takes a single argument
    array<double,6> c = { 10.6d, 57.9d, 80.7d, 35.1d, 69.3d, 20.0d };
    ::sort<double,6> ( c );

    //Will invoke the template function that takes two arguments
    double d[] = { 10.5d, 12.1d, 5.56d, 1.31d, 81.5d, 12.86d };
    ::sort<double> ( d, 6 );

    return 0;

}

运行以下命令:

g++ main.cpp -std=c++17

./a.out

上述程序的输出如下:

Non-template sort function invoked ...

Template sort function invoked with one argument...

Template sort function invoked with one argument...

Template sort function invoked with two arguments...

代码演示

以下代码是我们自定义sort()函数的非模板版本:

void sort ( array<int,6> data ) { 

     cout << "Non-template sort function invoked ..." << endl;

     int size = data.size();

     for ( int i=0; i<size; ++i ) { 
         for ( int j=0; j<size; ++j ) {
             if ( data[i] < data[j] )
                 swap ( data[i], data[j] );
         }
     }

}

非模板函数和模板函数可以共存并参与函数重载。上述函数的一个奇怪行为是数组的大小是硬编码的。

我们的sort()函数的第二个版本是一个模板函数,如下面的代码片段所示。有趣的是,我们在第一个非模板sort()版本中注意到的奇怪问题在这里得到了解决:

template <typename T, int size>
void sort ( array<T, size> data ) {

     cout << "Template sort function invoked with one argument..." << endl;

     for ( int i=0; i<size; ++i ) {
         for ( int j=0; j<size; ++j ) {
             if ( data[i] < data[j] )
                swap ( data[i], data[j] );
         }
     }

}

在上述代码中,数据类型和数组的大小都作为模板参数传递,然后传递给函数调用参数。这种方法使函数通用,因为这个函数可以为任何数据类型实例化。

我们自定义的sort()函数的第三个版本也是一个模板函数,如下面的代码片段所示:

template <typename T>
void sort ( T data[], int size ) {

     cout << "Template sort function invoked with two argument..." << endl;

     for ( int i=0; i<size; ++i ) {
         for ( int j=0; j<size; ++j ) {
             if ( data[i] < data[j] )
                swap ( data[i], data[j] );
         }
     }

}

上述模板函数接受 C 风格数组;因此,它也期望用户指示其大小。然而,数组的大小可以在函数内计算,但出于演示目的,我需要一个接受两个参数的函数。前一个函数不推荐使用,因为它使用了 C 风格数组;理想情况下,我们会使用 STL 容器之一。

现在,让我们理解主函数代码。以下代码声明并初始化了 STL 数组容器,其中包含六个值,然后将其传递给我们在默认命名空间中定义的sort()函数:

 //Will invoke the non-template sort function
 array<int, 6> a = { 10, 50, 40, 30, 60, 20 };
 ::sort ( a );

上述代码将调用非模板sort()函数。需要注意的重要一点是,每当 C遇到函数调用时,它首先寻找非模板版本;如果 C找到匹配的非模板函数版本,它的搜索正确函数定义就在那里结束。如果 C++编译器无法识别与函数调用签名匹配的非模板函数定义,那么它开始寻找任何可以支持函数调用的模板函数,并为所需的数据类型实例化一个专门的函数。

让我们理解以下代码:

//Will invoke the template function that takes a single argument
array<float,6> b = { 10.6f, 57.9f, 80.7f, 35.1f, 69.3f, 20.0f };
::sort<float,6>( b );

这将调用接收单个参数的模板函数。由于没有接收array<float,6>数据类型的非模板sort()函数,C++编译器将从我们定义的接收单个参数的sort()模板函数中实例化这样的函数。

同样,以下代码触发编译器实例化接收array<double, 6>double版本的模板sort()函数:

  //Will invoke the template function that takes a single argument
 array<double,6> c = { 10.6d, 57.9d, 80.7d, 35.1d, 69.3d, 20.0d };
 ::sort<double,6> ( c );

最后,以下代码将实例化一个接收两个参数并调用函数的模板sort()的实例:

 //Will invoke the template function that takes two arguments
 double d[] = { 10.5d, 12.1d, 5.56d, 1.31d, 81.5d, 12.86d };
 ::sort<double> ( d, 6 );

如果您已经走到这一步,我相信您会喜欢迄今为止讨论的 C++模板主题。

类模板

C++模板将函数模板概念扩展到类,使我们能够编写面向对象的通用代码。在前面的部分中,您学习了函数模板和重载的用法。在本节中,您将学习编写模板类,这将开启更有趣的通用编程概念。

class模板允许您通过模板类型表达式在类级别上对数据类型进行参数化。

让我们通过以下示例理解一个class模板:

//myalgorithm.h
#include <iostream>
#include <algorithm>
#include <array>
#include <iterator>
using namespace std;

template <typename T, int size>
class MyAlgorithm {

public:
        MyAlgorithm() { } 
        ~MyAlgorithm() { }

        void sort( array<T, size> &data ) {
             for ( int i=0; i<size; ++i ) {
                 for ( int j=0; j<size; ++j ) {
                     if ( data[i] < data[j] )
                         swap ( data[i], data[j] );
                 }
             }
        }

        void sort ( T data[size] );

};

template <typename T, int size>
inline void MyAlgorithm<T, size>::sort ( T data[size] ) {
       for ( int i=0; i<size; ++i ) {
           for ( int j=0; j<size; ++j ) {
               if ( data[i] < data[j] )
                  swap ( data[i], data[j] );
           }
       }
}

C++模板函数重载是静态或编译时多态的一种形式。

让我们在以下main.cpp程序中使用myalgorithm.h如下:

#include "myalgorithm.h"

int main() {

    MyAlgorithm<int, 10> algorithm1;

    array<int, 10> a = { 10, 5, 15, 20, 25, 18, 1, 100, 90, 18 };

    cout << "nArray values before sorting ..." << endl;
    copy ( a.begin(), a.end(), ostream_iterator<int>(cout, "t") );
    cout << endl;

    algorithm1.sort ( a );

    cout << "nArray values after sorting ..." << endl;
    copy ( a.begin(), a.end(), ostream_iterator<int>(cout, "t") );
    cout << endl;

    MyAlgorithm<int, 10> algorithm2;
    double d[] = { 100.0, 20.5, 200.5, 300.8, 186.78, 1.1 };

    cout << "nArray values before sorting ..." << endl;
    copy ( d.begin(), d.end(), ostream_iterator<double>(cout, "t") );
    cout << endl;

    algorithm2.sort ( d );

    cout << "nArray values after sorting ..." << endl;
    copy ( d.begin(), d.end(), ostream_iterator<double>(cout, "t") );
    cout << endl;

    return 0;  

}

让我们使用以下命令快速编译程序:

g++ main.cpp -std=c++17

./a.out

输出如下:


Array values before sorting ...
10  5   15   20   25   18   1   100   90   18

Array values after sorting ...
1   5   10   15   18   18   20   25   90   100

Array values before sorting ...
100   20.5   200.5   300.8   186.78   1.1

Array values after sorting ...
1.1     20.5   100   186.78  200.5  300.8

代码演示

以下代码声明了一个类模板。关键字template <typename T, int size>可以替换为<class T, int size>。这两个关键字可以在函数和类模板中互换使用;然而,作为行业最佳实践,template<class T>只能用于类模板,以避免混淆:

template <typename T, int size>
class MyAlgorithm 

重载的sort()方法之一内联定义如下:

 void sort( array<T, size> &data ) {
      for ( int i=0; i<size; ++i ) {
          for ( int j=0; j<size; ++j ) {
              if ( data[i] < data[j] )
                 swap ( data[i], data[j] );
          }
      }
 } 

第二个重载的sort()函数只是在类范围内声明,没有任何定义,如下所示:

template <typename T, int size>
class MyAlgorithm {
      public:
           void sort ( T data[size] );
};

前面的sort()函数是在类范围之外定义的,如下面的代码片段所示。奇怪的是,我们需要为在类模板之外定义的每个成员函数重复模板参数:

template <typename T, int size>
inline void MyAlgorithm<T, size>::sort ( T data[size] ) {
       for ( int i=0; i<size; ++i ) {
           for ( int j=0; j<size; ++j ) {
               if ( data[i] < data[j] )
                  swap ( data[i], data[j] );
           }
       }
}

否则,类模板的概念与函数模板的概念相同。

您想看看模板的编译器实例化代码吗?使用**g++ -fdump-tree-original main.cpp -std=c++17**命令。

显式类特化

到目前为止,在本章中,您已经学会了如何使用函数模板和类模板进行通用编程。当您理解类模板时,单个模板类可以支持任何内置和用户定义的数据类型。然而,有时我们需要对某些数据类型进行特殊处理,以便与其他数据类型有所区别。在这种情况下,C++为我们提供了显式类特化支持,以处理具有差异处理的选择性数据类型。

考虑 STL deque容器;虽然deque看起来适合存储,比如说,stringintdoublelong,但如果我们决定使用deque来存储一堆boolean类型,bool数据类型至少占用一个字节,而根据编译器供应商的实现可能会有所不同。虽然一个位可以有效地表示真或假,但布尔值至少占用一个字节,即 8 位,剩下的 7 位没有被使用。这可能看起来没问题;但是,如果您必须存储一个非常大的deque布尔值,这绝对不是一个有效的想法,对吧?您可能会想,有什么大不了的?我们可以为bool编写另一个专门的类或模板类。但这种方法要求最终用户明确为不同的数据类型使用不同的类,这也不是一个好的设计,对吧?这正是 C++的显式类特化派上用场的地方。

显式模板特化也被称为完全模板特化。

如果您还不信服,没关系;下面的例子将帮助您理解显式类特化的必要性以及显式类特化的工作原理。

让我们开发一个DynamicArray类来支持任何数据类型的动态数组。让我们从一个类模板开始,如下面的程序所示:

#include <iostream>
#include <deque>
#include <algorithm>
#include <iterator>
using namespace std;

template < class T >
class DynamicArray {
      private:
           deque< T > dynamicArray;
           typename deque< T >::iterator pos;

      public:
           DynamicArray() { initialize(); }
           ~DynamicArray() { }

           void initialize() {
                 pos = dynamicArray.begin();
           }

           void appendValue( T element ) {
                 dynamicArray.push_back ( element );
           }

           bool hasNextValue() { 
                 return ( pos != dynamicArray.end() );
           }

           T getValue() {
                 return *pos++;
           }

};

前面的DynamicArray模板类在内部使用了 STL deque类。因此,您可以将DynamicArray模板类视为自定义适配器容器。让我们探索如何在main.cpp中使用DynamicArray模板类,以下是代码片段:

#include "dynamicarray.h"
#include "dynamicarrayforbool.h"

int main () {

    DynamicArray<int> intArray;

    intArray.appendValue( 100 );
    intArray.appendValue( 200 );
    intArray.appendValue( 300 );
    intArray.appendValue( 400 );

    intArray.initialize();

    cout << "nInt DynamicArray values are ..." << endl;
    while ( intArray.hasNextValue() )
          cout << intArray.getValue() << "t";
    cout << endl;

    DynamicArray<char> charArray;
    charArray.appendValue( 'H' );
    charArray.appendValue( 'e' );
    charArray.appendValue( 'l' );
    charArray.appendValue( 'l' );
    charArray.appendValue( 'o' );

    charArray.initialize();

    cout << "nChar DynamicArray values are ..." << endl;
    while ( charArray.hasNextValue() )
          cout << charArray.getValue() << "t";
    cout << endl;

    DynamicArray<bool> boolArray;

    boolArray.appendValue ( true );
    boolArray.appendValue ( false );
    boolArray.appendValue ( true );
    boolArray.appendValue ( false );

    boolArray.initialize();

    cout << "nBool DynamicArray values are ..." << endl;
    while ( boolArray.hasNextValue() )
         cout << boolArray.getValue() << "t";
    cout << endl;

    return 0;

}

让我们快速使用以下命令编译程序:

g++ main.cpp -std=c++17

./a.out

输出如下:

Int DynamicArray values are ...
100   200   300   400

Char DynamicArray values are ...
H   e   l   l   o

Bool DynamicArray values are ...
1   0   1   0

太好了!我们自定义的适配器容器似乎工作正常。

代码演示

让我们放大并尝试理解前面的程序是如何工作的。以下代码告诉 C++编译器接下来是一个类模板:

template < class T >
class DynamicArray {
      private:
           deque< T > dynamicArray;
           typename deque< T >::iterator pos;

正如您所看到的,DynamicArray类在内部使用了 STL deque,并且为deque声明了名为pos的迭代器。这个迭代器posDynamic模板类用于提供高级方法,比如initialize()appendValue()hasNextValue()getValue()方法。

initialize()方法将deque迭代器pos初始化为deque中存储的第一个数据元素。appendValue( T element )方法允许您在deque的末尾添加数据元素。hasNextValue()方法告诉DynamicArray类是否有更多的数据值存储--true表示有更多的值,false表示DynamicArray导航已经到达deque的末尾。当需要时,initialize()方法可以用来重置pos迭代器到起始点。getValue()方法返回pos迭代器在那一刻指向的数据元素。getValue()方法不执行任何验证;因此,在调用getValue()之前,必须与hasNextValue()结合使用,以安全地访问存储在DynamicArray中的值。

现在,让我们理解main()函数。以下代码声明了一个存储int数据类型的DynamicArray类;DynamicArray<int> intArray将触发 C++编译器实例化一个专门针对int数据类型的DynamicArray类:

DynamicArray<int> intArray;

intArray.appendValue( 100 );
intArray.appendValue( 200 );
intArray.appendValue( 300 );
intArray.appendValue( 400 );

100200300400依次存储在DynamicArray类中。以下代码确保intArray迭代器指向第一个元素。一旦迭代器初始化,存储在DynamicArray类中的值将通过getValue()方法打印出来,而hasNextValue()确保导航没有到达DynamicArray类的末尾:

intArray.initialize();
cout << "nInt DynamicArray values are ..." << endl;
while ( intArray.hasNextValue() )
      cout << intArray.getValue() << "t";
cout << endl;

在主函数中,创建了一个char DynamicArray类,填充了一些数据,并进行了打印。让我们跳过char DynamicArray,直接转到存储boolDynamicArray类。

DynamicArray<bool> boolArray;

boolArray.appendValue ( "1010" );

boolArray.initialize();

cout << "nBool DynamicArray values are ..." << endl;

while ( boolArray.hasNextValue() )
      cout << boolArray.getValue() << "t";
cout << endl;

从前面的代码片段中,我们可以看到一切都很正常,对吗?是的,前面的代码完全正常;然而,DynamicArray的设计方法存在性能问题。虽然true可以用1表示,false可以用0表示,只需要 1 位,但前面的DynamicArray类却使用了 8 位来表示1和 8 位来表示0,我们必须解决这个问题,而不强迫最终用户选择一个对bool有效率的不同DynamicArray类。

让我们通过使用显式类模板特化来解决这个问题,以下是代码:

#include <iostream>
#include <bitset>
#include <algorithm>
#include <iterator>
using namespace std;

template <>
class DynamicArray<bool> {
      private:
          deque< bitset<8> *> dynamicArray;
          bitset<8> oneByte;
          typename deque<bitset<8> * >::iterator pos;
          int bitSetIndex;

          int getDequeIndex () {
              return (bitSetIndex) ? (bitSetIndex/8) : 0;
          }
      public:
          DynamicArray() {
              bitSetIndex = 0;
              initialize();
          }

         ~DynamicArray() { }

         void initialize() {
              pos = dynamicArray.begin();
              bitSetIndex = 0;
         }

         void appendValue( bool value) {
              int dequeIndex = getDequeIndex();
              bitset<8> *pBit = NULL;

              if ( ( dynamicArray.size() == 0 ) || ( dequeIndex >= ( dynamicArray.size()) ) ) {
                   pBit = new bitset<8>();
                   pBit->reset();
                   dynamicArray.push_back ( pBit );
              }

              if ( !dynamicArray.empty() )
                   pBit = dynamicArray.at( dequeIndex );

              pBit->set( bitSetIndex % 8, value );
              ++bitSetIndex;
         }

         bool hasNextValue() {
              return (bitSetIndex < (( dynamicArray.size() * 8 ) ));
         }

         bool getValue() {
              int dequeIndex = getDequeIndex();

              bitset<8> *pBit = dynamicArray.at(dequeIndex);
              int index = bitSetIndex % 8;
              ++bitSetIndex;

              return (*pBit)[index] ? true : false;
         }
};

你注意到模板类声明了吗?模板类特化的语法是template <> class DynamicArray<bool> { };class模板表达式是空的<>,对于所有数据类型都适用的class模板的名称和适用于bool数据类型的类的名称与模板表达式<bool>保持一致。

如果你仔细观察,你会发现,专门为bool设计的DynamicArray类内部使用了deque<bitset<8>>,即 8 位的bitsetdeque,在需要时,deque会自动分配更多的bitset<8>位。bitset变量是一个内存高效的 STL 容器,只消耗 1 位来表示truefalse

让我们来看一下main函数:

#include "dynamicarray.h"
#include "dynamicarrayforbool.h"

int main () {

    DynamicArray<int> intArray;

    intArray.appendValue( 100 );
    intArray.appendValue( 200 );
    intArray.appendValue( 300 );
    intArray.appendValue( 400 );

    intArray.initialize();

    cout << "nInt DynamicArray values are ..." << endl;

    while ( intArray.hasNextValue() )
          cout << intArray.getValue() << "t";
    cout << endl;

    DynamicArray<char> charArray;

    charArray.appendValue( 'H' );
    charArray.appendValue( 'e' );
    charArray.appendValue( 'l' );
    charArray.appendValue( 'l' );
    charArray.appendValue( 'o' );

    charArray.initialize();

    cout << "nChar DynamicArray values are ..." << endl;
    while ( charArray.hasNextValue() )
          cout << charArray.getValue() << "t";
    cout << endl;

    DynamicArray<bool> boolArray;

    boolArray.appendValue ( true );
    boolArray.appendValue ( false );
    boolArray.appendValue ( true );
    boolArray.appendValue ( false );

    boolArray.appendValue ( true );
    boolArray.appendValue ( false );
    boolArray.appendValue ( true );
    boolArray.appendValue ( false );

    boolArray.appendValue ( true );
    boolArray.appendValue ( true);
    boolArray.appendValue ( false);
    boolArray.appendValue ( false );

    boolArray.appendValue ( true );
    boolArray.appendValue ( true);
    boolArray.appendValue ( false);
    boolArray.appendValue ( false );

    boolArray.initialize();

    cout << "nBool DynamicArray values are ..." << endl;
    while ( boolArray.hasNextValue() )
          cout << boolArray.getValue() ;
    cout << endl;

    return 0;

}

有了类模板特化,我们可以从以下代码中观察到,对于boolchardouble,主要代码似乎是相同的,尽管主模板类DynamicArray和专门化的DynamicArray<bool>类是不同的:

DynamicArray<char> charArray;
charArray.appendValue( 'H' );
charArray.appendValue( 'e' );

charArray.initialize();

cout << "nChar DynamicArray values are ..." << endl;
while ( charArray.hasNextValue() )
cout << charArray.getValue() << "t";
cout << endl;

DynamicArray<bool> boolArray;
boolArray.appendValue ( true );
boolArray.appendValue ( false );

boolArray.initialize();

cout << "nBool DynamicArray values are ..." << endl;
while ( boolArray.hasNextValue() )
      cout << boolArray.getValue() ;
cout << endl;

我相信你会发现这个 C++模板特化功能非常有用。

部分模板特化

与显式模板特化不同,显式模板特化用自己特定数据类型的完整定义替换主模板类,而部分模板特化允许我们专门化主模板类支持的某个子集的模板参数,而其他通用类型可以与主模板类相同。

当部分模板特化与继承结合时,可以做更多的事情,如下例所示:

#include <iostream>
using namespace std;

template <typename T1, typename T2, typename T3>
class MyTemplateClass {
public:
     void F1( T1 t1, T2 t2, T3 t3 ) {
          cout << "nPrimary Template Class - Function F1 invoked ..." << endl;
          cout << "Value of t1 is " << t1 << endl;
          cout << "Value of t2 is " << t2 << endl;
          cout << "Value of t3 is " << t3 << endl;
     }

     void F2(T1 t1, T2 t2) {
          cout << "nPrimary Tempalte Class - Function F2 invoked ..." << endl;
          cout << "Value of t1 is " << t1 << endl;
          cout << "Value of t2 is " << 2 * t2 << endl;
     }
};
template <typename T1, typename T2, typename T3>
class MyTemplateClass< T1, T2*, T3*> : public MyTemplateClass<T1, T2, T3> {
      public:
          void F1( T1 t1, T2* t2, T3* t3 ) {
               cout << "nPartially Specialized Template Class - Function F1 invoked ..." << endl;
               cout << "Value of t1 is " << t1 << endl;
               cout << "Value of t2 is " << *t2 << endl;
               cout << "Value of t3 is " << *t3 << endl;
          }
};

main.cpp文件将包含以下内容:

#include "partiallyspecialized.h"

int main () {
    int x = 10;
    int *y = &x;
    int *z = &x;

    MyTemplateClass<int, int*, int*> obj;
    obj.F1(x, y, z);
    obj.F2(x, x);

    return 0;
}

从前面的代码中,你可能已经注意到,主模板类名称和部分特化类名称与完全或显式模板类特化的情况相同。然而,在模板参数表达式中有一些语法变化。在完全模板类特化的情况下,模板参数表达式将为空,而在部分特化的模板类的情况下,列出的表达式会出现,如下所示:

template <typename T1, typename T2, typename T3>
class MyTemplateClass< T1, T2*, T3*> : public MyTemplateClass<T1, T2, T3> { };

表达式template<typename T1, typename T2, typename T3>是主类模板类中使用的模板参数表达式,MyTemplateClass< T1, T2*, T3*>是第二类所做的部分特化。正如你所看到的,第二类对typename T2typename T3进行了一些特化,因为它们在第二类中被用作指针;然而,typename T1在第二类中被直接使用。

除了迄今为止讨论的事实之外,第二类还继承了主模板类,这有助于第二类重用主模板类的公共和受保护的方法。然而,部分模板特化并不会阻止特化类支持其他函数。

虽然主模板类中的F1函数被部分特化的模板类替换,但它通过继承重用了主模板类中的F2函数。

让我们使用以下命令快速编译程序:

g++ main.cpp -std=c++17

./a.out

程序的输出如下:

Partially Specialized Template Classs - Function F1 invoked ...
Value of t1 is 10
Value of t2 is 10
Value of t3 is 10

Primary Tempalte Class - Function F2 invoked ...
Value of t1 is 10
Value of t2 is 20

希望你觉得部分特化的模板类有用。

总结

在本章中,你学到了以下内容:

  • 你现在知道了使用泛型编程的动机

  • 你现在熟悉了函数模板

  • 你知道如何重载函数模板

  • 你知道类模板

  • 你知道何时使用显式模板特化以及何时使用部分特化的模板特化

恭喜!总的来说,你对 C++的模板编程有很好的理解。

在下一章中,你将学习智能指针。

第四章:智能指针

在上一章中,您了解了模板编程和通用编程的好处。在本章中,您将学习以下智能指针主题:

  • 内存管理

  • 原始指针的问题

  • 循环依赖

  • 智能指针:

  • auto_ptr

  • unique_ptr

  • shared_ptr

  • weak_ptr

让我们探索 C++提供的内存管理设施。

内存管理

在 C中,内存管理通常是软件开发人员的责任。这是因为 C标准不强制在 C编译器中支持垃圾回收;因此,这取决于编译器供应商的选择。特别地,Sun C编译器附带了一个名为libgc的垃圾回收库。

C++语言具有许多强大的功能。其中,指针无疑是最强大和最有用的功能之一。指针虽然非常有用,但它们也有自己的奇怪问题,因此必须负责任地使用。当内存管理没有得到认真对待或者没有做得很好时,会导致许多问题,包括应用程序崩溃、核心转储、分段错误、间歇性的调试困难、性能问题等等。悬空指针或流氓指针有时会干扰其他无关的应用程序,而罪魁祸首应用程序却悄无声息地执行;事实上,受害应用程序可能会被多次指责。内存泄漏最糟糕的部分是,在某些时候它变得非常棘手,即使有经验的开发人员也会花费数小时来调试受害代码,而罪魁祸首代码却毫发未动。有效的内存管理有助于避免内存泄漏,并让您开发出内存高效的高性能应用程序。

由于每个操作系统的内存模型都不同,每个操作系统在同一内存泄漏问题的不同时间点可能会有不同的行为。内存管理是一个大课题,C++提供了许多有效的方法。我们将在以下部分讨论一些有用的技术。

原始指针的问题

大多数 C++开发人员有一个共同点:我们都喜欢编写复杂的东西。你问一个开发人员,“嘿,伙计,你想重用已经存在并且有效的代码,还是想自己开发一个?”虽然大多数开发人员会委婉地说在可能的情况下重用已有的代码,但他们的内心会说,“我希望我能自己设计和开发它。”复杂的数据结构和算法往往需要指针。原始指针在遇到麻烦之前确实很酷。

原始指针在使用前必须分配内存,并且在完成后需要释放内存;就是这么简单。然而,在一个产品中,指针分配可能发生在一个地方,而释放可能发生在另一个地方,事情就会变得复杂起来。如果内存管理决策没有做出正确的选择,人们可能会认为释放内存是调用者或被调用者的责任,有时内存可能在任何地方都没有被释放。另一种可能性是,同一个指针可能会在不同的地方被多次删除,这可能导致应用程序崩溃。如果这种情况发生在 Windows 设备驱动程序中,很可能会导致蓝屏死机。

想象一下,如果应用程序出现异常,并且抛出异常的函数在异常发生之前分配了一堆内存的指针,那会怎么样?任何人都可以猜到:会有内存泄漏。

让我们看一个使用原始指针的简单例子:

#include <iostream>
using namespace std;

class MyClass {
      public:
           void someMethod() {

                int *ptr = new int();
                *ptr = 100;
                int result = *ptr / 0;  //division by zero error expected
                delete ptr;

           }
};

int main ( ) {

    MyClass objMyClass;
    objMyClass.someMethod();

    return 0;

}

现在运行以下命令:

g++ main.cpp -g -std=c++17

查看此程序的输出:

main.cpp: In member function ‘void MyClass::someMethod()’:
main.cpp:12:21: warning: division by zero [-Wdiv-by-zero]
 int result = *ptr / 0;

现在运行以下命令:

./a.out
[1] 31674 floating point exception (core dumped) ./a.out

C++编译器真的很酷。看看警告消息,它指出了问题。我喜欢 Linux 操作系统。Linux 非常聪明,能够找到行为不端的恶意应用程序,并及时将其关闭,以免对其他应用程序或操作系统造成任何损害。核心转储实际上是好事,尽管它被诅咒,而不是庆祝 Linux 的方法。猜猜,微软的 Windows 操作系统同样聪明。当发现某些应用程序进行可疑的内存访问时,它们会进行错误检查,而且 Windows 操作系统也支持迷你转储和完整转储,这相当于 Linux 操作系统中的核心转储。

让我们来看一下 Valgrind 工具的输出,检查内存泄漏问题:

valgrind --leak-check=full --show-leak-kinds=all ./a.out

==32857== Memcheck, a memory error detector
==32857== Copyright (C) 2002-2015, and GNU GPL'd, by Julian Seward et al.
==32857== Using Valgrind-3.12.0 and LibVEX; rerun with -h for copyright info
==32857== Command: ./a.out
==32857== 
==32857== 
==32857== Process terminating with default action of signal 8 (SIGFPE)
==32857== Integer divide by zero at address 0x802D82B86
==32857== at 0x10896A: MyClass::someMethod() (main.cpp:12)
==32857== by 0x1088C2: main (main.cpp:24)
==32857== 
==32857== HEAP SUMMARY:
==32857== in use at exit: 4 bytes in 1 blocks
==32857== total heap usage: 2 allocs, 1 frees, 72,708 bytes allocated
==32857== 
==32857== 4 bytes in 1 blocks are still reachable in loss record 1 of 1
==32857== at 0x4C2E19F: operator new(unsigned long) (in /usr/lib/valgrind/vgpreload_memcheck-amd64-linux.so)
==32857== by 0x108951: MyClass::someMethod() (main.cpp:8)
==32857== by 0x1088C2: main (main.cpp:24)
==32857== 
==32857== LEAK SUMMARY:
==32857== definitely lost: 0 bytes in 0 blocks
==32857== indirectly lost: 0 bytes in 0 blocks
==32857== possibly lost: 0 bytes in 0 blocks
==32857== still reachable: 4 bytes in 1 blocks
==32857== suppressed: 0 bytes in 0 blocks
==32857== 
==32857== For counts of detected and suppressed errors, rerun with: -v
==32857== ERROR SUMMARY: 0 errors from 0 contexts (suppressed: 0 from 0)
[1] 32857 floating point exception (core dumped) valgrind --leak-check=full --show-leak-kinds=all ./a.out

在这个输出中,如果你注意粗体部分的文本,你会注意到 Valgrind 工具指出了导致核心转储的源代码行号。main.cpp文件的第 12 行如下:

 int result = *ptr / 0; //division by zero error expected 

一旦在main.cpp文件的第 12 行发生异常,出现在异常下方的代码将永远不会被执行。在main.cpp文件的第 13 行,出现了一个delete语句,由于异常而永远不会被执行:

 delete ptr;

由于指针指向的内存在堆栈展开过程中没有被释放,因此分配给前述原始指针的内存没有被释放。每当函数抛出异常并且异常没有被同一函数处理时,堆栈展开是有保证的。然而,在堆栈展开过程中只有自动本地变量会被清理,而不是指针指向的内存。这导致内存泄漏。

这是使用原始指针引发的奇怪问题之一;还有许多类似的情况。希望你现在已经相信,使用原始指针的乐趣确实是要付出代价的。但所付出的代价并不值得,因为在 C++中有很好的替代方案来解决这个问题。你是对的,使用智能指针是解决方案,它提供了使用指针的好处,而不需要付出原始指针所附带的代价。

因此,智能指针是在 C++中安全使用指针的方法。

智能指针

在 C++中,智能指针让你专注于手头的问题,摆脱了处理自定义垃圾回收技术的烦恼。智能指针让你安全地使用原始指针。它们负责清理原始指针使用的内存。

C++支持许多类型的智能指针,可以在不同的场景中使用:

  • auto_ptr

  • unique_ptr

  • shared_ptr

  • weak_ptr

auto_ptr智能指针是在 C++11 中引入的。auto_ptr智能指针在其作用域结束时自动释放堆内存。然而,由于auto_ptr从一个auto_ptr实例转移所有权的方式,它已被弃用,并且unique_ptr被引入作为其替代品。shared_ptr智能指针帮助多个共享智能指针引用同一个对象,并负责内存管理负担。weak_ptr智能指针帮助解决由于应用程序设计中存在循环依赖问题而导致的shared_ptr使用时的内存泄漏问题。

还有其他类型的智能指针和相关内容,它们并不常用,并列在下面的项目列表中。然而,我强烈建议你自己探索它们,因为你永远不知道什么时候会发现它们有用:

  • owner_less

  • enable_shared_from_this

  • bad_weak_ptr

  • default_delete

owner_less智能指针帮助比较两个或多个智能指针是否共享相同的原始指向对象。enable_shared_from_this智能指针帮助获取this指针的智能指针。bad_weak_ptr智能指针是一个异常类,意味着使用无效智能指针创建了shared_ptrdefault_delete智能指针是unique_ptr使用的默认销毁策略,它调用delete语句,同时也支持用于数组类型的部分特化,使用delete[]

在本章中,我们将逐一探讨auto_ptrshared_ptrweak_ptrunique-ptr

auto_ptr

auto_ptr智能指针接受一个原始指针,包装它,并确保在auto_ptr对象超出范围时释放原始指针指向的内存。任何时候,只有一个auto_ptr智能指针可以指向一个对象。因此,当一个auto_ptr指针被赋值给另一个auto_ptr指针时,所有权被转移到接收赋值的auto_ptr实例;当复制auto_ptr智能指针时也是如此。

通过一个简单的例子观察这些功能将会很有趣,如下所示:

#include <iostream>
#include <string>
#include <memory>
#include <sstream>
using namespace std;

class MyClass {
      private:
           static int count;
           string name;
      public:
           MyClass() {
                 ostringstream stringStream(ostringstream::ate);
                 stringStream << "Object";
                 stringStream << ++count;
                 name = stringStream.str();
                 cout << "nMyClass Default constructor - " << name << endl;
           }
           ~MyClass() {
                 cout << "nMyClass destructor - " << name << endl;
           }

           MyClass ( const MyClass &objectBeingCopied ) {
                 cout << "nMyClass copy constructor" << endl;
           }

           MyClass& operator = ( const MyClass &objectBeingAssigned ) {
                 cout << "nMyClass assignment operator" << endl;
           }

           void sayHello( ) {
                cout << "Hello from MyClass " << name << endl;
           }
};

int MyClass::count = 0;

int main ( ) {

   auto_ptr<MyClass> ptr1( new MyClass() );
   auto_ptr<MyClass> ptr2( new MyClass() );

   return 0;

}

前面程序的编译输出如下:

g++ main.cpp -std=c++17

main.cpp: In function ‘int main()’:
main.cpp:40:2: warning: ‘template<class> class std::auto_ptr’ is deprecated [-Wdeprecated-declarations]
 auto_ptr<MyClass> ptr1( new MyClass() );

In file included from /usr/include/c++/6/memory:81:0,
 from main.cpp:3:
/usr/include/c++/6/bits/unique_ptr.h:49:28: note: declared here
 template<typename> class auto_ptr;

main.cpp:41:2: warning: ‘template<class> class std::auto_ptr’ is deprecated [-Wdeprecated-declarations]
 auto_ptr<MyClass> ptr2( new MyClass() );

In file included from /usr/include/c++/6/memory:81:0,
 from main.cpp:3:
/usr/include/c++/6/bits/unique_ptr.h:49:28: note: declared here
 template<typename> class auto_ptr;

正如你所看到的,C++编译器警告我们使用auto_ptr已经被弃用。因此,我不建议再使用auto_ptr智能指针;它已被unique_ptr取代。

目前,我们可以忽略警告并继续,如下所示:

g++ main.cpp -Wno-deprecated

./a.out

MyClass Default constructor - Object1

MyClass Default constructor - Object2

MyClass destructor - Object2

MyClass destructor - Object1 

正如你在前面程序输出中看到的,分配在堆中的Object1Object2都被自动删除了。这要归功于auto_ptr智能指针。

代码演示 - 第 1 部分

MyClass的定义中你可能已经了解到,它定义了默认构造函数复制构造函数和析构函数,一个赋值运算符和sayHello()方法,如下所示:

//Definitions removed here to keep it simple 
class MyClass {
public:
      MyClass() { }  //Default constructor
      ~MyClass() { } //Destructor 
      MyClass ( const MyClass &objectBeingCopied ) {} //Copy Constructor 
      MyClass& operator = ( const MyClass &objectBeingAssigned ) { } //Assignment operator
      void sayHello();
}; 

MyClass的方法只是打印声明表明方法已被调用;它们纯粹是为了演示目的而设计的。

main()函数创建了两个指向堆中两个不同MyClass对象的auto_ptr智能指针,如下所示:

int main ( ) {

   auto_ptr<MyClass> ptr1( new MyClass() );
   auto_ptr<MyClass> ptr2( new MyClass() );

   return 0;

}

正如你所理解的,auto_ptr是一个包装原始指针而不是指针的本地对象。当控制流达到return语句时,堆栈展开过程开始,作为这一过程的一部分,堆栈对象ptr1ptr2被销毁。这反过来调用了auto_ptr的析构函数,最终删除了由堆栈对象ptr1ptr2指向的MyClass对象。

我们还没有完成。让我们探索auto_ptr的更多有用功能,如下所示的main函数:

int main ( ) {

    auto_ptr<MyClass> ptr1( new MyClass() );
    auto_ptr<MyClass> ptr2( new MyClass() );

    ptr1->sayHello();
    ptr2->sayHello();

    //At this point the below stuffs happen
    //1\. ptr2 smart pointer has given up ownership of MyClass Object 2
    //2\. MyClass Object 2 will be destructed as ptr2 has given up its 
    //   ownership on Object 2
    //3\. Ownership of Object 1 will be transferred to ptr2
    ptr2 = ptr1;

    //The line below if uncommented will result in core dump as ptr1 
    //has given up its ownership on Object 1 and the ownership of 
    //Object 1 is transferred to ptr2.
    // ptr1->sayHello();

    ptr2->sayHello();

    return 0;

}

代码演示 - 第 2 部分

我们刚刚看到的main()函数代码演示了许多有用的技术和一些auto_ptr智能指针的争议行为。以下代码创建了两个auto_ptr实例,即ptr1ptr2,它们包装了堆中创建的两个MyClass对象:

 auto_ptr<MyClass> ptr1( new MyClass() );
 auto_ptr<MyClass> ptr2( new MyClass() );

接下来,以下代码演示了如何使用auto_ptr调用MyClass支持的方法:

 ptr1->sayHello();
 ptr2->sayHello();

希望你注意到了ptr1->sayHello()语句。它会让你相信auto_ptr ptr1对象是一个指针,但实际上,ptr1ptr2只是作为本地变量在堆栈中创建的auto_ptr对象。由于auto_ptr类已经重载了->指针运算符和*解引用运算符,它看起来像一个指针。事实上,MyClass暴露的所有方法只能使用->指针运算符访问,而所有auto_ptr方法可以像访问堆栈对象一样访问。

以下代码演示了auto_ptr智能指针的内部行为,所以请密切关注;这将会非常有趣:

ptr2 = ptr1;

看起来前面的代码只是一个简单的赋值语句,但它会触发auto_ptr中的许多活动。由于前面的赋值语句,发生了以下活动:

  • ptr2智能指针将放弃对MyClass对象 2 的所有权。

  • MyClass对象 2 将被销毁,因为ptr2已经放弃了对object 2的所有权。

  • object 1的所有权将被转移给ptr2

  • 此时,ptr1既不指向object 1,也不负责管理object 1使用的内存。

以下注释行有一些事实要告诉你:

// ptr1->sayHello();

由于ptr1智能指针已经释放了对object 1的所有权,因此尝试访问sayHello()方法是非法的。这是因为ptr1实际上不再指向object 1,而object 1ptr2拥有。当ptr2超出范围时,释放object 1使用的内存是ptr2智能指针的责任。如果取消注释前面的代码,将导致核心转储。

最后,以下代码让我们使用ptr2智能指针在object 1上调用sayHello()方法:

ptr2->sayHello();
return 0;

我们刚刚看到的return语句将在main()函数中启动堆栈展开过程。这将最终调用ptr2的析构函数,从而释放object 1使用的内存。美妙的是,所有这些都是自动发生的。auto_ptr智能指针在我们专注于手头的问题时在幕后为我们努力工作。

然而,由于以下原因,从C++11开始auto_ptr被弃用:

  • auto_ptr对象不能存储在 STL 容器中

  • auto_ptr的复制构造函数将从原始来源,也就是auto_ptr中移除所有权。

  • auto_ptr复制赋值运算符将从原始来源,也就是auto_ptr中移除所有权。

  • auto_ptr的复制构造函数和赋值运算符违反了原始意图,因为auto_ptr的复制构造函数和赋值运算符将从右侧对象中移除源对象的所有权,并将所有权分配给左侧对象

unique_ptr

unique_ptr智能指针的工作方式与auto_ptr完全相同,只是unique_ptr解决了auto_ptr引入的问题。因此,unique_ptr是从C++11开始取代auto_ptr的。unique_ptr智能指针只允许一个智能指针独占一个堆分配的对象。从一个unique_ptr实例到另一个实例的所有权转移只能通过std::move()函数来完成。

因此,让我们重构我们之前的示例,使用unique_ptr来替代auto_ptr

重构后的代码示例如下:

#include <iostream>
#include <string>
#include <memory>
#include <sstream>
using namespace std;

class MyClass {
      private:
          static int count;
          string name;

      public:
          MyClass() {
                ostringstream stringStream(ostringstream::ate);
                stringStream << "Object";
                stringStream << ++count;
                name = stringStream.str();
                cout << "nMyClass Default constructor - " << name << endl;
          }

          ~MyClass() {
                cout << "nMyClass destructor - " << name << endl;
          }

          MyClass ( const MyClass &objectBeingCopied ) {
                cout << "nMyClass copy constructor" << endl;
          }

          MyClass& operator = ( const MyClass &objectBeingAssigned ) {
                cout << "nMyClass assignment operator" << endl;
          }

          void sayHello( ) {
                cout << "nHello from MyClass" << endl;
          }

};

int MyClass::count = 0;

int main ( ) {

 unique_ptr<MyClass> ptr1( new MyClass() );
 unique_ptr<MyClass> ptr2( new MyClass() );

 ptr1->sayHello();
 ptr2->sayHello();

 //At this point the below stuffs happen
 //1\. ptr2 smart pointer has given up ownership of MyClass Object 2
 //2\. MyClass Object 2 will be destructed as ptr2 has given up its 
 // ownership on Object 2
 //3\. Ownership of Object 1 will be transferred to ptr2
 ptr2 = move( ptr1 );

 //The line below if uncommented will result in core dump as ptr1 
 //has given up its ownership on Object 1 and the ownership of 
 //Object 1 is transferred to ptr2.
 // ptr1->sayHello();

 ptr2->sayHello();

 return 0;
}

前面程序的输出如下:

g++ main.cpp -std=c++17

./a.out

MyClass Default constructor - Object1

MyClass Default constructor - Object2

MyClass destructor - Object2

MyClass destructor - Object1 

在前面的输出中,您可以注意到编译器没有报告任何警告,并且程序的输出与auto_ptr的输出相同。

代码演示

重要的是要注意main()函数中auto_ptrunique_ptr之间的区别。让我们来看看以下代码中main()函数。该代码在堆中创建了两个MyClass对象的实例,分别是ptr1ptr2的两个实例:

 unique_ptr<MyClass> ptr1( new MyClass() );
 unique_ptr<MyClass> ptr2( new MyClass() );

接下来的代码演示了如何使用unique_ptr调用MyClass支持的方法:

 ptr1->sayHello();
 ptr2->sayHello();

就像auto_ptr一样,unique_ptr智能指针ptr1对象已经重载了->指针运算符和*解引用运算符;因此,它看起来像一个指针。

以下代码演示了unique_ptr不支持将一个unique_ptr实例分配给另一个实例,所有权转移只能通过std::move()函数实现:

ptr2 = std::move(ptr1);

move函数触发了以下活动:

  • ptr2智能指针放弃了对MyClass对象 2 的所有权

  • MyClass对象 2 将被销毁,因为ptr2放弃了对object 2的所有权。

  • object 1的所有权已转移到ptr2

  • 此时,ptr1既不指向object 1,也不负责管理object 1使用的内存

如果取消注释以下代码,将导致核心转储:

// ptr1->sayHello();

最后,以下代码让我们使用ptr2智能指针调用object 1sayHello()方法:

ptr2->sayHello();
return 0;

我们刚刚看到的return语句将在main()函数中启动堆栈展开过程。这将最终调用ptr2的析构函数,从而释放object 1使用的内存。请注意,与auto_ptr对象不同,unique_ptr对象可以存储在 STL 容器中。

shared_ptr

当一组shared_ptr对象共享对堆分配对象的所有权时,使用shared_ptr智能指针。当所有shared_ptr实例完成对共享对象的使用时,shared_ptr指针释放共享对象。shared_ptr指针使用引用计数机制来检查对共享对象的总引用;每当引用计数变为零时,最后一个shared_ptr实例将删除共享对象。

让我们通过一个示例来检查shared_ptr的使用,如下所示:

#include <iostream>
#include <string>
#include <memory>
#include <sstream>
using namespace std;

class MyClass {
  private:
    static int count;
    string name;
  public:
    MyClass() {
      ostringstream stringStream(ostringstream::ate);
      stringStream << "Object";
      stringStream << ++count;

      name = stringStream.str();

      cout << "nMyClass Default constructor - " << name << endl;
    }

    ~MyClass() {
      cout << "nMyClass destructor - " << name << endl;
    }

    MyClass ( const MyClass &objectBeingCopied ) {
      cout << "nMyClass copy constructor" << endl;
    }

    MyClass& operator = ( const MyClass &objectBeingAssigned ) {
      cout << "nMyClass assignment operator" << endl;
    }

    void sayHello() {
      cout << "Hello from MyClass " << name << endl;
    }

};

int MyClass::count = 0;

int main ( ) {

  shared_ptr<MyClass> ptr1( new MyClass() );
  ptr1->sayHello();
  cout << "nUse count is " << ptr1.use_count() << endl;

  {
      shared_ptr<MyClass> ptr2( ptr1 );
      ptr2->sayHello();
      cout << "nUse count is " << ptr2.use_count() << endl;
  }

  shared_ptr<MyClass> ptr3 = ptr1;
  ptr3->sayHello();
  cout << "nUse count is " << ptr3.use_count() << endl;

  return 0;
}

前面程序的输出如下:

MyClass Default constructor - Object1
Hello from MyClass Object1
Use count is 1

Hello from MyClass Object1
Use count is 2

Number of smart pointers referring to MyClass object after ptr2 is destroyed is 1

Hello from MyClass Object1
Use count is 2

MyClass destructor - Object1

代码演示

以下代码创建了一个指向MyClass堆分配对象的shared_ptr对象实例。就像其他智能指针一样,shared_ptr也有重载的->*运算符。因此,可以调用所有MyClass对象方法,就像使用原始指针一样。use_count()方法告诉指向共享对象的智能指针数量:

 shared_ptr<MyClass> ptr1( new MyClass() );
 ptr1->sayHello();
 cout << "nNumber of smart pointers referring to MyClass object is "
      << ptr1->use_count() << endl;

在以下代码中,智能指针ptr2的作用域被花括号包围的块内部。因此,ptr2将在以下代码块的末尾被销毁。代码块内预期的use_count函数为 2:

 { 
      shared_ptr<MyClass> ptr2( ptr1 );
      ptr2->sayHello();
      cout << "nNumber of smart pointers referring to MyClass object is "
           << ptr2->use_count() << endl;
 }

在以下代码中,预期的use_count值为 1,因为ptr2已被删除,这将减少引用计数 1:

 cout << "nNumber of smart pointers referring to MyClass object after ptr2 is destroyed is "
 << ptr1->use_count() << endl; 

以下代码将打印一个 Hello 消息,然后use_count为 2。这是因为ptr1ptr3现在引用堆中的MyClass共享对象:

shared_ptr<MyClass> ptr3 = ptr2;
ptr3->sayHello();
cout << "nNumber of smart pointers referring to MyClass object is "
     << ptr2->use_count() << endl;

main函数末尾的return 0;语句将销毁ptr1ptr3,将引用计数减少到零。因此,我们可以观察到MyClass析构函数在输出末尾打印语句。

weak_ptr

到目前为止,我们已经讨论了shared_ptr的正面作用,并举例说明。然而,当应用程序设计中存在循环依赖时,shared_ptr无法清理内存。要么必须重构应用程序设计以避免循环依赖,要么可以使用weak_ptr来解决循环依赖问题。

您可以查看我的 YouTube 频道,了解shared_ptr问题以及如何使用weak_ptr解决:www.youtube.com/watch?v=SVTLTK5gbDc

假设有三个类:A、B 和 C。类 A 和 B 有一个 C 的实例,而 C 有 A 和 B 的实例。这里存在一个设计问题。A 依赖于 C,C 也依赖于 A。同样,B 依赖于 C,C 也依赖于 B。

考虑以下代码:

#include <iostream>
#include <string>
#include <memory>
#include <sstream>
using namespace std;

class C;

class A {
      private:
           shared_ptr<C> ptr;
      public:
           A() {
                 cout << "nA constructor" << endl;
           }

           ~A() {
                 cout << "nA destructor" << endl;
           }

           void setObject ( shared_ptr<C> ptr ) {
                this->ptr = ptr;
           }
};

class B {
      private:
           shared_ptr<C> ptr;
      public:
           B() {
                 cout << "nB constructor" << endl;
           }

           ~B() {
                 cout << "nB destructor" << endl;
           }

           void setObject ( shared_ptr<C> ptr ) {
                this->ptr = ptr;
           }
};

class C {
      private:
           shared_ptr<A> ptr1;
           shared_ptr<B> ptr2;
      public:
           C(shared_ptr<A> ptr1, shared_ptr<B> ptr2) {
                   cout << "nC constructor" << endl;
                   this->ptr1 = ptr1;
                   this->ptr2 = ptr2;
           }

           ~C() {
                   cout << "nC destructor" << endl;
           }
};

int main ( ) {
                shared_ptr<A> a( new A() );
                shared_ptr<B> b( new B() );
                shared_ptr<C> c( new C( a, b ) );

                a->setObject ( shared_ptr<C>( c ) );
                b->setObject ( shared_ptr<C>( c ) );

                return 0;
}

前面程序的输出如下:

g++ problem.cpp -std=c++17

./a.out

A constructor

B constructor

C constructor

在前面的输出中,您可以观察到,即使我们使用了shared_ptr,对象 A、B 和 C 使用的内存也从未被释放。这是因为我们没有看到各自类的析构函数被调用。原因是shared_ptr在内部使用引用计数算法来决定是否共享对象必须被销毁。然而,在这里它失败了,因为除非对象 C 被删除,否则无法删除对象 A。除非删除对象 A,否则无法删除对象 C。同样,除非删除对象 A 和 B,否则无法删除对象 C。同样,除非删除对象 C,否则无法删除对象 A,除非删除对象 C,否则无法删除对象 B。

底线是这是一个循环依赖设计问题。为了解决这个问题,从 C11 开始,C引入了weak_ptrweak_ptr智能指针不是强引用。因此,所引用的对象可以在任何时候被删除,不像shared_ptr

循环依赖

循环依赖是一个问题,如果对象 A 依赖于 B,而对象 B 又依赖于 A。现在让我们看看如何通过shared_ptrweak_ptr的组合来解决这个问题,最终打破循环依赖,如下所示:

#include <iostream>
#include <string>
#include <memory>
#include <sstream>
using namespace std;

class C;

class A {
      private:
 weak_ptr<C> ptr;
      public:
           A() {
                  cout << "nA constructor" << endl;
           }

           ~A() {
                  cout << "nA destructor" << endl;
           }

           void setObject ( weak_ptr<C> ptr ) {
                  this->ptr = ptr;
           }
};

class B {
      private:
 weak_ptr<C> ptr;
      public:
           B() {
               cout << "nB constructor" << endl;
           }

           ~B() {
               cout << "nB destructor" << endl;
           }

           void setObject ( weak_ptr<C> ptr ) {
                this->ptr = ptr;
           }
};

class C {
      private:
           shared_ptr<A> ptr1;
           shared_ptr<B> ptr2;
      public:
           C(shared_ptr<A> ptr1, shared_ptr<B> ptr2) {
                   cout << "nC constructor" << endl;
                   this->ptr1 = ptr1;
                   this->ptr2 = ptr2;
           }

           ~C() {
                   cout << "nC destructor" << endl;
           }
};

int main ( ) {
         shared_ptr<A> a( new A() );
         shared_ptr<B> b( new B() );
         shared_ptr<C> c( new C( a, b ) );

         a->setObject ( weak_ptr<C>( c ) );
         b->setObject ( weak_ptr<C>( c ) );

         return 0;
}

前面重构代码的输出如下:

g++ solution.cpp -std=c++17

./a.out

A constructor

B constructor

C constructor

C destructor

B destructor

A destructor

总结

在本章中,您学习了

  • 由于原始指针而引起的内存泄漏问题

  • 关于auto_ptr在赋值和复制构造函数方面的问题

  • unique_ptr及其优势

  • shared_ptr在内存管理中的作用及其与循环依赖相关的限制。

  • 您还将使用weak_ptr解决循环依赖问题

在下一章中,您将学习如何在 C++中开发 GUI 应用程序。

第五章:在 C++中开发 GUI 应用程序

在本章中,您将学习以下主题:

  • Qt 的简要概述

  • Qt 框架

  • 在 Ubuntu 上安装 Qt

  • 开发 Qt 核心应用程序

  • 开发 Qt GUI 应用程序

  • 在 Qt GUI 应用程序中使用布局

  • 理解事件处理的信号和槽

  • 在 Qt 应用程序中使用多个布局

Qt 是一个用 C++开发的跨平台应用程序框架。它支持包括 Windows、Linux、Mac OS、Android、iOS、嵌入式 Linux、QNX、VxWorks、Windows CE/RT、Integrity、Wayland、X11、嵌入式设备等在内的各种平台。它主要用作人机界面(HMI)或图形用户界面(GUI)框架;然而,它也用于开发命令行界面(CLI)应用程序。Qt 的正确发音方式是可爱。Qt 应用程序框架有两种版本:开源版本和商业许可版本。

Qt 是 Haavard Nord 和 Eirik Chambe-Eng 的原始开发人员,他们于 1991 年开发了 Qt。

由于 C语言本身不支持 GUI,你可能已经猜到了,C语言本身没有原生的事件管理支持。因此,Qt 需要支持自己的事件处理机制,这导致了信号和槽技术的出现。在底层,信号和槽使用了观察者设计模式,允许 Qt 对象相互通信。这听起来太难理解了吗?别担心!信号只是事件,比如按钮点击或窗口关闭,而槽是事件处理程序,可以以你希望的方式对这些事件做出响应。

为了使我们在 Qt 应用程序开发方面的生活更加轻松,Qt 支持各种宏和特定于 Qt 的关键字。由于这些关键字不会被 C理解,Qt 必须将它们和宏转换为纯粹的 C代码,以便 C++编译器可以像往常一样完成其工作。为了使这一切更加顺利,Qt 支持一种称为元对象编译器(Meta-Object Compiler)的东西,也被称为 moc。

对于 C项目来说,Qt 是一个自然的选择,因为它是纯粹的 C代码;因此,作为 C++开发人员,在应用程序中使用 Qt 时会感到非常自在。一个典型的应用程序将同时具有复杂的逻辑和令人印象深刻的 UI。在小型产品团队中,通常一个开发人员会做多种工作,这既有利也有弊。

通常,专业开发人员具有良好的问题解决能力。问题解决能力对于以最佳方式解决复杂问题并选择良好的数据结构和算法至关重要。

开发令人印象深刻的 UI 需要创造性的设计技能。虽然有一定数量的开发人员擅长解决问题或创造性的 UI 设计,但并非所有开发人员都擅长这两者。这就是 Qt 脱颖而出的地方。

假设一家初创公司想要为其内部目的开发一个应用程序。为此,一个简单的 GUI 应用程序就足够了,一个看起来不错的 HMI/GUI 可能适用于团队,因为该应用程序仅用于内部目的。在这种情况下,整个应用程序可以使用 C和 Qt 小部件框架进行开发。唯一的前提是开发团队必须精通 C

然而,在需要开发移动应用的情况下,出色的 HMI 变得必不可少。同样,移动应用可以使用 C和 Qt 小部件进行开发。但是,这个选择有两个方面。好的一面是移动应用团队只需要擅长 C。这个选择的坏处是,并不是所有擅长设计移动应用的 HMI/GUI 的 C++开发人员都能保证做得好。

假设团队有一两个专门的 Photoshop 专业人员,擅长创建引人注目的图像,可以在 GUI 中使用,并且有一两个 UI 设计师,可以使用 Photoshop 专家创建的图像制作出令人印象深刻的 HMI/GUI。通常,UI 设计师擅长前端技术,如 JavaScript、HTML 和 CSS。强大的 Qt 框架可以开发复杂的业务逻辑,而 HMI/GUI 可以在 QML 中开发。

QML 是与 Qt 应用程序框架一起提供的一种声明性脚本语言。它接近 JavaScript,并具有 Qt 特定的扩展。它非常适合快速应用程序开发,并允许 UI 设计师专注于 HMI/GUI,而 C++开发人员专注于可以在 Qt 框架中开发的复杂业务逻辑。

由于 C++ Qt 框架和 QML 都是同一 Qt 应用程序框架的一部分,它们可以无缝地搭配使用。

Qt 是一个庞大而强大的框架;因此,本章将重点介绍 Qt 的基本要点,以帮助您开始使用 Qt。如果您想了解更多信息,您可能想要查看我正在撰写的另一本即将推出的书,即精通 Qt 和 QML 编程

Qt

Qt 框架是用 C开发的,因此可以保证对任何优秀的 C开发人员来说都是易如反掌。它支持 CLI 和基于 GUI 的应用程序开发。在撰写本章时,Qt 应用程序框架的最新版本是 Qt 5.7.0。当您阅读本书时,可能会有不同版本的 Qt 可供您下载。您可以从www.qt.io下载最新版本。

在 Ubuntu 16.04 中安装 Qt 5.7.0

在本章中,我将使用 Ubuntu 16.04 操作系统;但是,本章中列出的程序应该适用于支持 Qt 的任何平台。

有关详细的安装说明,请参考wiki.qt.io/install_Qt_5_on_Ubuntu

此时,您的系统应该已经安装了 C编译器。如果不是这样,请首先确保安装 C编译器,如下所示:

sudo apt-get install build-essential

从 Ubuntu 终端,您应该能够下载 Qt 5.7.0,如下命令所示:

w**get** **http://download.qt.io/official_releases/qt/5.7/5.7.0/qt-
opensource-linux-x64-5.7.0.run** 

为下载的安装程序提供执行权限,如下命令所示:

chmod +x qt-opensource-linux-x64-5.7.0.run 

我强烈建议您安装 Qt 及其源代码。如果您喜欢用极客的方式查找 Qt 帮助,您可以直接从源代码获取帮助。

按照以下命令启动安装程序:

./qt-opensource-linux-x64-5.7.0.run

由于 Qt 使用 OpenGL,请确保在开始编写 Qt 中的第一个程序之前安装以下内容。要安装libfontconfig1,运行以下命令:

 sudo apt-get install libfontconfig1

要安装mesa-common-dev,请运行以下命令:

sudo apt-get install mesa-common-dev  

此时,您应该已经有一个可用的 Qt 设置。您可以通过在 Linux 终端中发出以下命令来验证安装:

图 5.1

如果qmake命令未被识别,请确保导出 Qt 安装文件夹的bin路径,如前面的屏幕截图所示。此外,创建一个软链接也可能很有用。此命令如下:

 sudo ln -s /home/jegan/Qt5.7.0/5.7/gcc_64/bin/qmake /usr/bin/qmake  

Qt 在您系统上安装的路径可能与我的不同,因此请相应地替换 Qt 路径。

Qt Core

Qt Core 是 Qt 支持的模块之一。该模块具有许多有用的类,如QObjectQCoreApplicationQDebug等。几乎每个 Qt 应用程序都需要这个模块,因此它们被 Qt 框架隐式链接。每个 Qt 类都继承自QObject,而QObject类为 Qt 应用程序提供事件处理支持。QObject是支持事件处理机制的关键部分;有趣的是,即使是基于控制台的应用程序也可以在 Qt 中支持事件处理。

编写我们的第一个 Qt 控制台应用程序

如果你得到类似于图 5.1所示的输出,那么你已经准备好动手了。让我们写我们的第一个 Qt 应用程序,如下面的屏幕截图所示:

图 5.2

在第一行中,我们从QtCore模块中包含了QDebug头文件。如果你仔细观察,qDebug()函数类似于 Ccout ostream运算符。在调试代码时,qDebug()函数将成为 Qt 世界中的好朋友。QDebug类已经重载了 Costream运算符,以支持 C++编译器不支持的 Qt 数据类型。

以老派的方式,我有点痴迷于终端,几乎在编码时实现任何功能,而不是使用一些花哨的集成开发环境IDE)。你可能会喜欢或讨厌这种方法,这是很自然的。好处是在你和 Qt/C++之间没有任何障碍,因为你将使用简单而强大的文本编辑器,如 Vim、Emacs、Sublime Text、Atom、Brackets 或 Neovim,因此你将学会几乎所有关于 Qt 项目和 qmake 的基本知识;IDE 会让你的生活变得轻松,但它们隐藏了许多每个严肃的开发人员都必须了解的基本知识。所以这是一个权衡。我把决定权交给你,决定是使用你喜欢的纯文本编辑器还是 Qt Creator IDE 或其他花哨的 IDE。我将坚持使用重构后的 Vim 编辑器 Neovim,它看起来真的很酷。图 5.2将给你一个关于 Neovim 编辑器外观和感觉的想法。

让我们回到正题。让我们看看如何以极客的方式在命令行中编译这段代码。在此之前,你可能想了解一下qmake工具。它是 Qt 的专有make实用程序。qmake实用程序不过是一个 make 工具,但它了解 Qt 特定的东西,因此它知道 moc、signals、slots 等等,而典型的make实用程序则不知道。

以下命令应该帮助你创建一个.pro文件。.pro文件的名称将由qmake实用程序根据项目文件夹名称决定。.pro文件是 Qt Creator IDE 将相关文件组合为单个项目的方式。由于我们不打算使用 Qt Creator,我们将使用.pro文件来创建Makefile,以便编译我们的 Qt 项目,就像编译普通的 C++项目一样。

图 5.3

当你发出qmake -project命令时,qmake 将扫描当前文件夹和当前文件夹下的所有子文件夹,并在Ex1.pro中包含头文件和源文件。顺便说一句,.pro文件是一个纯文本文件,可以使用任何文本编辑器打开,如图 5.4所示:

图 5.4

现在是时候创建Makefile,以Ex1.pro作为输入文件。由于Ex1.pro文件存在于当前目录中,我们不必明确提供Ex1.pro作为输入文件来自动生成Makefile。这个想法是,一旦我们有了一个.pro文件,我们只需要从.pro文件发出命令:qmake来生成Makefile。这将完成创建一个完整的Makefile的魔术,你可以使用make实用程序来构建你的项目,如下面的屏幕截图所示:

图 5.5

这就是我们一直在等待的时刻,对吧?是的,让我们执行我们的第一个 Qt Hello World 程序,如下面的屏幕截图所示:

图 5.6

恭喜!你已经完成了你的第一个 Qt 应用程序。在这个练习中,你学会了如何在 Ubuntu 中设置和配置 Qt,以及如何编写一个简单的 Qt 控制台应用程序,然后构建和运行它。最好的部分是你学会了所有这些都是通过命令行完成的。

Qt 小部件

Qt Widgets 是一个有趣的模块,支持许多小部件,如按钮、标签、编辑、组合、列表、对话框等。QWidget是所有小部件的基类,而QObject是几乎每个 Qt 类的基类。虽然许多编程语言称之为 UI 控件,Qt 将它们称为小部件。尽管 Qt 可以在许多平台上运行,但它的主要平台仍然是 Linux;小部件在 Linux 世界中很常见。

编写我们的第一个 Qt GUI 应用程序

我们的第一个控制台应用程序真的很酷,不是吗?让我们继续深入探索。这一次,让我们编写一个简单的基于 GUI 的 Hello World 程序。程序的步骤几乎相同,只是main.cpp中有一些小的改变。请参考以下完整的代码:

图 5.7

等一下。让我解释一下第 23 行和第 29 行需要QApplication的原因。每个 Qt GUI 应用程序必须有一个QApplication实例。QApplication为我们的应用程序提供了对命令行开关的支持,因此需要提供参数计数argc)和参数值argv)。基于 GUI 的应用程序是事件驱动的,因此它们必须响应 Qt 世界中的事件或者更准确地说是信号。在第 29 行,exec函数启动了事件循环,这确保应用程序等待用户交互,直到用户关闭窗口。其思想是所有用户事件将被QApplication实例接收并存储在事件队列中,然后通知给它的Child小部件。事件队列确保队列中存储的所有事件按照它们发生的顺序进行处理,即先进先出FIFO)。

如果你好奇地想要检查一下,如果你注释掉第 29 行会发生什么,应用程序仍然会编译和运行,但你可能看不到任何窗口。原因是main线程或main函数在第 25 行创建了一个QWidget的实例,这就是我们启动应用程序时看到的窗口。

在第 27 行,窗口实例被显示出来,但在没有第 29 行的情况下,main函数将立即终止应用程序,而不给你检查你的第一个 Qt GUI 应用程序的机会。值得一试,所以继续看看有没有第 29 行会发生什么。

让我们生成Makefile,如下面的截图所示:

图 5.8

现在让我们尝试使用make工具编译我们的项目,如下面的截图所示:

图 5.9

有趣,对吧?我们全新的 Qt GUI 程序无法编译。你注意到致命错误了吗?没关系,让我们了解一下为什么会发生这种情况。原因是我们还没有链接 Qt Widgets 模块,因为QApplication类是 Qt Widgets 模块的一部分。在这种情况下,你可能会想知道为什么我们的第一个 Hello World 程序编译时没有任何问题。在我们的第一个程序中,QDebug类是QtCore模块的一部分,它隐式地被链接,而其他模块必须显式地被链接。让我们看看如何解决这个问题:

图 5.10

我们需要在Ex2.pro文件中添加QT += widgets,这样qmake工具就会理解需要在创建最终可执行文件时链接 Qt Widgets 的共享对象(在 Linux 中是.so文件),在 Windows 中也称为动态链接库.dll文件)。一旦这个问题得到解决,我们必须运行qmake,这样Makefile就能反映我们Ex2.pro文件中的新更改,如下面的截图所示:

图 5.11

很好。现在让我们检查一下我们的第一个基于 GUI 的 Qt 应用程序。在我的系统中,应用程序输出如图 5.12所示;如果一切顺利,你也应该得到类似的输出:

图 5.12

如果我们将窗口的标题设置为Hello Qt,那就太好了,对吧?让我们马上做这个:

图 5.13

在第 26 行添加所示代码,以确保在测试新更改之前使用make实用程序构建项目:

图 5.14

布局

Qt 是跨平台应用程序框架,因此支持布局等概念,用于开发在所有平台上看起来一致的应用程序,而不管不同的屏幕分辨率如何。当我们开发基于 GUI/HMI 的 Qt 应用程序时,在一个系统中开发的应用程序不应该在另一个具有不同屏幕大小和分辨率的系统上看起来不同。这是通过布局在 Qt 框架中实现的。布局有不同的风格。这有助于开发人员通过在窗口或对话框中组织各种小部件来设计一个专业的 HMI/GUI。布局在安排其子小部件的方式上有所不同。当窗口或对话框被调整大小时,布局会调整其子小部件,以便它们不会被截断或失焦。

使用水平布局编写 GUI 应用程序

让我们编写一个 Qt 应用程序,在对话框中放置一些按钮。Qt 支持各种有用的布局管理器,它们充当一个无形的画布,在那里可以将许多QWidgets排列好,然后再将它们附加到窗口或对话框上。每个对话框或窗口只能有一个布局。每个小部件只能添加到一个布局中;然而,可以组合多个布局来设计专业的用户界面。

现在让我们开始编写代码。在这个项目中,我们将以模块化的方式编写代码,因此我们将创建三个文件,分别命名为MyDlg.hMyDlg.cppmain.cpp

我们的计划如下:

  1. 创建QApplication的单个实例。

  2. 通过继承QDialog来创建一个自定义对话框。

  3. 创建三个按钮。

  4. 创建一个水平框布局。

  5. 将这三个按钮添加到不可见的水平框布局中。

  6. 将水平框布局的实例设置为我们对话框的布局。

  7. 显示对话框。

  8. QApplication上启动事件循环。

重要的是,我们要遵循清晰的代码规范,以便我们的代码易于理解,并且可以被任何人维护。由于我们将遵循行业最佳实践,让我们在一个名为MyDlg.h的头文件中声明对话框,在一个名为MyDlg.cpp的源文件中定义对话框,并在具有main函数的main.cpp中使用MyDlg.cpp。每当MyDlg.cpp需要一个头文件时,让我们养成一个习惯,只在MyDlg.h中包含所有的头文件;这样,我们在MyDlg.cpp中看到的唯一头文件将是MyDlg.h

顺便说一句,我有没有告诉过你 Qt 遵循驼峰命名约定?是的,我刚刚提到了。到目前为止,你可能已经注意到所有的 Qt 类都以字母Q开头,因为 Qt 的发明者喜欢 Emacs 中的字母“Q”,他们对这种字体类型如此着迷,以至于决定在 Qt 中到处使用字母 Q。

最后一个建议。如果文件名和类名相似,其他人是否会更容易找到对话框类?我听到你说是。一切准备就绪!让我们开始编写我们的 Qt 应用程序。首先,参考以下截图:

图 5.15

在上面的截图中,我们声明了一个名为MyDlg的类。它有一个布局、三个按钮和一个构造函数。现在参考这个截图:

图 5.16

在上面的屏幕截图中,我们定义了MyDlg构造函数并实例化了布局和三个按钮。在第 27 到 29 行,我们将三个按钮添加到布局中。在第 31 行,我们将布局与对话框关联起来。就是这样。在下面的屏幕截图中,我们定义了我们的main函数,它创建了一个QApplication的实例:

图 5.17

我们随后创建了我们的自定义对话框实例并显示了对话框。最后,在第 27 行,我们启动了event循环,以便MyDlg可以响应用户交互。请参考下面的屏幕截图:

图 5.18

上面的屏幕截图展示了构建和执行过程,这就是我们可爱的应用程序。实际上,您可以尝试使用对话框来更好地理解水平布局。首先,水平拉伸对话框,注意所有按钮的宽度都会增加;然后,尝试减小对话框的宽度,以便注意所有按钮的宽度都会减小。这是任何布局管理器的工作。布局管理器安排小部件并检索窗口的大小,并将高度和宽度平均分配给其所有子小部件。布局管理器不断通知其所有子小部件有关任何调整大小的事件。但是,由于各个子小部件是否要调整大小或忽略布局调整信号是由各个子小部件自行决定的。

要检查此行为,请尝试垂直拉伸对话框。随着对话框高度的增加,对话框的高度应该增加,但按钮不会增加其高度。这是因为每个 Qt 小部件都有自己的首选大小策略;根据其大小策略,它们可能会响应或忽略某些布局调整信号。

如果您希望按钮在垂直方向上也能拉伸,QPushButton提供了一种实现这一点的方法。实际上,QPushButton与任何其他小部件一样都是从QWidget继承而来。setSizePolicy()方法是从其基类QWidget继承到QPushButton的:

图 5.19

您注意到了第 37 行吗?是的,我在MyDlg的构造函数中设置了窗口标题,以使我们的main函数简洁干净。

在启动应用程序之前,请确保使用make工具构建了您的项目:

图 5.20

在突出显示的部分,我们已经覆盖了所有按钮的默认大小策略。在第 27 行,第一个参数QSizePolicy::Expanding是指水平策略,第二个参数是指垂直策略。要查找QSizePolicy的其他可能值,请参考 Qt API 参考中随时可用的助手,如下面的屏幕截图所示:

图 5.21

使用垂直布局编写 GUI 应用程序

在上一节中,您学习了如何使用水平框布局。在本节中,您将看到如何在应用程序中使用垂直框布局。

事实上,水平和垂直框布局只是在安排小部件方面有所不同。例如,水平框布局将以从左到右的水平方式排列其子小部件,而垂直框布局将以从上到下的垂直方式排列其子小部件。

您可以从上一节中复制源代码,因为更改的性质是次要的。复制代码后,您的项目目录应如下所示:

图 5.22

让我从MyDlg.h头文件开始演示更改,如下所示:

图 5.23

我已经用QVBoxLayout替换了QHBoxLayout;就是这样。是的,让我们继续进行与MyDlg.cpp相关的文件更改:

图 5.24

main.cpp中没有要做的更改;但是,我已经为您的参考展示了main.cpp,如下所示:

图 5.25

现在我们需要做的就是自动生成Makefile,然后按照以下步骤进行编译和运行程序:

图 5.26

让我们执行我们全新的程序并检查输出。以下输出演示了QVBoxLayout以垂直从上到下的方式安排小部件。当窗口被拉伸时,所有按钮的宽度将根据窗口是拉伸还是收缩而增加/减少:

图 5.27

使用框布局编写 GUI 应用程序

在前面的部分中,你学会了如何使用QHBoxLayoutQVBoxLayout。实际上,这两个类都是QBoxLayout的便利类。在QHBoxLayout的情况下,QHBoxLayout类已经将QBoxLayout作为子类,并将QBoxLayout::Direction配置为QBoxLayout::LeftToRight,而QVBoxLayout类已经将QBoxLayout作为子类,并将QBoxLayout::Direction配置为QBoxLayout::TopToBottom

除了这些值,QBoxLayout::Direction还支持其他各种值,如下所示:

  • QBoxLayout::LeftToRight:这将从左到右排列小部件

  • QBoxLayout::RightToLeft:这将从右到左排列小部件

  • QBoxLayout::TopToBottom:这将从上到下排列小部件

  • QBoxLayout::BottomToTop:这将从下到上排列小部件

让我们使用QBoxLayout编写一个简单的程序,其中包含五个按钮。

让我们从MyDlg.h头文件开始。我在MyDlg类中声明了五个按钮指针和一个QBoxLayout指针:

图 5.28

让我们来看看我们的MyDlg.cpp源文件。如果你注意到下面截图中的第 21 行,QBoxLayout构造函数需要两个参数。第一个参数是您希望安排小部件的方向,第二个参数是一个可选参数,期望布局实例的父地址。

正如你可能已经猜到的那样,this指针指的是MyDlg实例指针,它恰好是布局的父级。

图 5.29

再次,正如你可能已经猜到的那样,main.cpp文件不会改变,就像我们过去的练习一样,如下面的截图所示:

图 5.30

让我们编译并运行我们的程序,如下所示:

图 5.31

如果你注意到输出,它看起来像是水平框布局的输出,对吧?确实,因为我们已经将方向设置为QBoxLayout::LeftToRight。如果你将方向修改为,比如QBoxLayout::RightToLeft,那么按钮 1 将出现在右侧,按钮 2 将出现在按钮 1 的左侧,依此类推。因此,输出将如下截图所示:

  • 如果方向设置为QBoxLayout::RightToLeft,你会看到以下输出:

图 5.32

  • 如果方向设置为QBoxLayout::TopToBottom,你会看到以下输出:

图 5.33

  • 如果方向设置为QBoxLayout::BottomToTop,你会看到以下输出:

图 5.34

在所有前述的情况中,按钮都是按照相同的顺序添加到布局中,从按钮 1 到按钮 5。然而,根据QBoxLayout构造函数中选择的方向,框布局将安排按钮,因此输出会有所不同。

使用网格布局编写 GUI 应用程序

网格布局允许我们以表格方式排列小部件。这很容易,就像盒式布局一样。我们所需要做的就是指示每个小部件必须添加到布局的行和列。由于行和列索引从零开始,因此行 0 的值表示第一行,列 0 的值表示第一列。理论够了;让我们开始写一些代码。

让我们声明 10 个按钮,并将它们添加到两行和五列中。除了特定的QGridLayout差异,其余的东西将与之前的练习保持一致,所以如果你已经理解了到目前为止讨论的概念,就继续创建MyDlg.hMyDl.cppmain.cpp

让我在以下截图中呈现MyDlg.h源代码:

图 5.35

以下是MyDlg.cpp的代码片段:

图 5.36

main.cpp源文件内容将与我们之前的练习保持一致;因此,我已经跳过了main.cpp的代码片段。由于你已经熟悉了构建过程,我也跳过了它。如果你忘记了这一点,只需查看之前的部分以了解构建过程。

如果你已经正确输入了代码,你应该会得到以下输出:

图 5.37

实际上,网格布局还有更多的功能。让我们探索如何使按钮跨越多个单元格。我保证你将要看到的内容更有趣。

我将修改MyDlg.hMyDlg.cpp,并保持main.cpp与之前的练习相同:

图 5.38

这是我们的MyDlg.cpp

图 5.39

注意 35 到 38 行。现在让我们详细讨论addWidget()函数。

35 行中,pLayout->addWidget ( pBttn1, 0, 0, 1, 1 )代码执行以下操作:

  • 前三个参数将 Button 1 添加到网格布局的第一行和第一列

  • 第四个参数1指示 Button 1 将只占据一行

  • 第五个参数1指示 Button 1 将只占据一列

  • 因此,很明显pBttn1应该呈现在单元格(0, 0)上,并且它应该只占据一个网格单元

36 行中,pLayout->addWidget ( pBttn2, 0, 1, 1, 2 )代码执行以下操作:

  • 前三个参数将Button 2添加到网格布局的第一行和第二列

  • 第四个参数指示Button 2将占据一行

  • 第五个参数指示Button 2将占据两列(即第一行的第二列和第三列)

  • 在底部,Button 2 将呈现在单元格(0,1)上,并且它应该占据一行和两列

37 行中,pLayout->addWidget ( pBttn3, 0, 3, 2, 1 )代码执行以下操作:

  • 前三个参数将 Button 3 添加到网格布局的第一行和第四列

  • 第四个参数指示 Button 3 将占据两行(即第一行和第四列以及第二行和第四列)

  • 第五个参数指示 Button 3 将占据一列

38 行中,pLayout->addWidget ( pBttn4, 1, 0, 1, 3 )代码执行以下操作:

  • 前三个参数将 Button 4 添加到网格布局的第二行和第一列

  • 第四个参数指示 Button 4 将占据一行

  • 第五个参数指示 Button 4 将占据三列(即第二行第一列,然后第二列和第三列)

查看程序的输出:

图 5.40

信号和槽

信号和槽是 Qt 框架的一个组成部分。到目前为止,我们已经编写了一些简单但有趣的 Qt 应用程序,但我们还没有处理事件。现在是时候了解如何在我们的应用程序中支持事件。

让我们编写一个简单的应用程序,只有一个按钮。当按钮被点击时,检查是否可以在控制台上打印一些内容。

MyDlg.h头文件展示了如何声明MyDlg类:

图 5.41

下面的屏幕截图演示了如何定义MyDlg构造函数以向对话框窗口添加一个按钮:

图 5.42

main.cpp如下所示:

图 5.43

让我们构建并运行我们的程序,然后稍后添加对信号和槽的支持。如果您正确地遵循了说明,您的输出应该类似于以下屏幕截图:

图 5.44

如果您点击按钮,您会注意到什么都没有发生,因为我们还没有在我们的应用程序中添加对信号和槽的支持。好的,现在是时候揭示一个秘密指令,它将帮助您使按钮响应按钮点击信号。等一下,现在是时候获取更多信息了。别担心,这与 Qt 有关。

Qt 信号只是事件,而槽函数是事件处理程序函数。有趣的是,信号和槽都是普通的 C++函数;只有当它们被标记为信号或槽时,Qt 框架才能理解它们的目的并提供必要的样板代码。

Qt 中的每个小部件都支持一个或多个信号,并且还可以选择性地支持一个或多个槽。因此,在我们编写任何进一步的代码之前,让我们探索QPushButton支持哪些信号。

让我们使用 Qt 助手进行 API 参考:

图 5.45

如果您观察前面的屏幕截图,它有一个目录部分,似乎涵盖了公共插槽,但我们没有看到任何信号列在那里。这是很多信息。如果目录部分没有列出信号,QPushButton就不会直接支持信号。然而,也许它的基类,也就是QAbstractButton,会支持一些信号。QPushButton类部分提供了大量有用的信息,比如头文件名,必须链接到应用程序的 Qt 模块,也就是必须添加到.pro文件的 qmake 条目等等。它还提到了QPushButton的基类。如果您继续向下滚动,您的 Qt 助手窗口应该看起来像这样:

图 5.46

如果您观察下面的Additional Inherited Members部分,显然 Qt 助手暗示QPushButtonQAbstractButton继承了四个信号。因此,我们需要探索QAbstractButton支持的信号,以支持QPushButton中的信号。

图 5.47

通过 Qt 助手的帮助,如前面的屏幕截图所示,很明显QAbstractButton类支持四个信号,这些信号也适用于QPushButton,因为QPushButtonQAbstractButton的子类。因此,让我们在这个练习中使用clicked()信号。

我们需要在MyDlg.hMyDlg.cpp中进行一些微小的更改,以便使用clicked()信号。因此,我已经在以下屏幕截图中展示了这两个文件的更改部分:

图 5.48

正如你所知,QDebug类用于调试目的。它为 Qt 应用程序提供了类似于cout的功能,但实际上并不需要用于信号和槽。我们在这里使用它们只是为了调试目的。在图 5.48中,第 34 行,void MyDlg::onButtonClicked()是我们打算用作事件处理程序函数的槽函数,必须在按钮点击时调用。

以下截图应该让你了解你需要在MyDlg.cpp中进行哪些更改以支持信号和槽:

图 5.49

如果你观察前面截图中的第 40 到 42 行,MyDlg::onButtonClicked()方法是一个槽函数,必须在按钮被点击时调用。但是除非按钮的clicked()信号映射到MyDlg::onButtonClicked()槽,否则 Qt 框架不会知道它必须在按钮被点击时调用MyDlg::onButtonClicked()。因此,在 32 到 37 行,我们将按钮信号clicked()MyDlg实例的onButtonClicked()槽函数连接起来。connect 函数是从QDialog继承而来的。而QDialog又是从其最终基类QObject继承而来。

这个口头禅是,每个想要参与信号和槽通信的类必须是QObject或其子类。 QObject提供了相当多的信号和槽支持,QObjectQtCore模块的一部分。令人惊奇的是,Qt 框架甚至将信号和槽功能提供给了命令行应用程序。这就是为什么信号和槽支持内置到了最终基类QObject中,它是QtCore模块的一部分。

好的,让我们构建并运行我们的程序,看看信号在我们的应用程序中是否起作用:

图 5.50

有趣的是,我们并没有得到编译错误,但当我们点击按钮时,突出显示的警告消息会自动出现。这是 Qt 框架的一个提示,表明我们错过了一个必要的程序,这是使信号和槽工作的必要程序。

让我们回顾一下我们在头文件和源文件中自动生成Makefile的过程:

  1. qmake -project命令确保当前文件夹中的所有头文件和源文件都包含在.pro文件中。

  2. qmake命令会读取当前文件夹中的.pro文件,并为我们的项目生成Makefile

  3. make命令将调用make实用程序。然后在当前目录中执行Makefile,根据Makefile中定义的制作规则构建我们的项目。

在步骤 1 中,qmake实用程序扫描我们所有的自定义头文件,并检查它们是否需要信号和槽支持。任何具有Q_OBJECT宏的头文件都会提示qmake实用程序需要信号和槽支持。因此,我们必须在我们的MyDlg.h头文件中使用Q_OBJECT宏:

图 5.51

一旦在头文件中完成了推荐的更改,我们需要确保发出qmake命令。现在qmake实用程序将打开Ex8.pro文件,获取我们的项目头文件和源文件。当qmake解析MyDlg.h并找到Q_OBJECT宏时,它将了解到我们的MyDlg.h需要信号和槽,然后它将确保在MyDlg.h上调用 moc 编译器,以便在一个名为moc_MyDlg.cpp的文件中自动生成样板代码。然后,它将继续在Makefile中添加必要的规则,以便自动生成的moc_MyDlg.cpp文件与其他源文件一起构建。

现在你知道了 Qt 信号和槽的秘密,继续尝试这个过程,并检查你的按钮点击是否打印了“Button clicked ...”消息。我已经根据建议进行了项目构建。在下面的截图中,我已经突出显示了幕后发生的有趣的事情;这些是在命令行中工作与使用花哨的 IDE 相比的一些优势:

图 5.52

现在是时候测试我们支持信号和槽的酷而简单的应用程序的输出了。输出如下截图所示:

图 5.53

恭喜!你可以为自己鼓掌。你已经学会了在 Qt 中做一些很酷的东西。

在 Qt 应用程序中使用堆叠布局

由于你已经了解了信号和槽,在这一部分,让我们探讨如何在具有多个窗口的应用程序中使用堆叠布局;每个窗口可以是QWidgetQDialog。每个页面可能有自己的子窗口部件。我们即将开发的应用程序将演示堆叠布局的使用以及如何在堆叠布局中从一个窗口导航到另一个窗口。

图 5.54

这个应用程序将需要相当数量的代码,因此很重要的是要确保我们的代码结构良好,以满足结构和功能质量,尽量避免代码异味。

让我们创建四个可以堆叠在堆叠布局中的小部件/窗口,其中每个页面可以作为一个单独的类分割成两个文件:HBoxDlg.hHBoxDlg.cpp等等。

让我们从HBoxDlg.h开始。由于你熟悉布局,在这个练习中,我们将创建每个对话框与一个布局,这样在导航子窗口之间时,你可以区分页面。否则,堆叠布局和其他布局之间将没有任何连接。

图 5.55

以下代码片段来自HBoxDlg.cpp文件:

图 5.56

同样,让我们按照以下方式编写VBoxDlg.h

图 5.57

让我们按照以下方式创建第三个对话框BoxDlg.h,使用框布局:

图 5.58

相应的BoxDlg.cpp源文件如下:

图 5.59

我们想要堆叠的第四个对话框是GridDlg,所以让我们看看GridDlg.h应该如何编写,如下截图所示:

图 5.60

相应的GridDlg.cpp将如下所示:

图 5.61

很好,我们已经创建了四个可以堆叠在MainDlg中的小部件。MainDlg将使用QStackedLayout,所以这个练习的关键是理解堆叠布局的工作原理。

让我们看看MainDlg.h应该如何编写:

图 5.62

MainDlg中,我们声明了三个槽函数,每个按钮一个,以支持四个窗口之间的导航逻辑。堆叠布局类似于选项卡小部件,不同之处在于选项卡小部件将提供自己的视觉方式来在选项卡之间切换,而在堆叠布局的情况下,切换逻辑由我们提供。

MainDlg.cpp将如下所示:

图 5.63

你可以选择一个框布局来容纳这三个按钮,因为我们希望按钮对齐到右侧。但是,为了确保额外的空间被一些不可见的粘合剂占用,我们在第 44 行添加了一个伸展项。

在 30 至 33 行之间,我们将所有四个子窗口添加到堆叠布局中,以便一次只能显示一个窗口。HBox对话框添加在索引 0 处,VBox对话框添加在索引 1 处,依此类推。

第 53 至 58 行演示了如何将上一个按钮的点击信号与其对应的MainDlg::onPrevPage()槽函数连接起来。类似的连接必须为下一个和退出按钮进行配置:

图 5.64

第 78 行的if条件确保只有在我们处于第二个或更后面的子窗口时才发生切换逻辑。由于水平对话框位于索引 0,所以在当前窗口是水平对话框的情况下,我们无法导航到上一个窗口。类似的验证也适用于在第 85 行切换到下一个子窗口。

堆叠布局支持setCurrentIndex()方法以切换到特定的索引位置;或者,如果在您的情况下更有效,也可以尝试setCurrentWidget()方法。

main.cpp看起来简短而简单,如下所示:

图 5.65

我们main函数的最好之处在于,无论应用逻辑的复杂性如何,main函数都没有任何业务逻辑。这使得我们的代码清晰易懂,易于维护。

代码覆盖率指标是好还是坏?

代码覆盖工具帮助开发者识别其自动化测试用例中的空白。毫无疑问,很多时候它会提供有关缺失测试场景的线索,这最终会进一步加强自动化测试用例。但是,当组织开始将代码覆盖率作为检查测试覆盖率有效性的衡量标准时,有时会导致开发者走向错误的方向。根据我的实际咨询经验,我所学到的是,许多开发者开始为构造函数、私有和受保护函数编写测试用例,以展示更高的代码覆盖率。在这个过程中,开发者开始追求数字,失去了 TDD 的最终目标。

在一个具有 20 个方法的类的特定源中,可能只有 10 个方法适合单元测试,而其他方法是复杂的功能。在这种情况下,代码覆盖工具将只显示 50%的代码覆盖率,这完全符合 TDD 哲学。然而,如果组织政策强制要求最低 75%的代码覆盖率,那么开发者除了为了展示良好的代码覆盖率而对构造函数、析构函数、私有、受保护和复杂函数进行测试外别无选择。

测试私有和受保护方法的麻烦在于它们往往会更改,因为它们被标记为实现细节。当私有和受保护方法发生严重更改时,就需要修改测试用例,这使得开发者在维护测试用例方面更加困难。

因此,代码覆盖工具是非常好的开发者工具,可以找到测试场景的空白,但是是否编写测试用例或忽略某些方法的测试用例取决于方法的复杂性,应该由开发者自行决定。然而,如果代码覆盖率被用作项目指标,往往会导致开发者采取错误的方式来展示更好的覆盖率,导致糟糕的测试用例实践。

编写一个结合多个布局的简单数学应用

在本节中,让我们探讨如何编写一个简单的数学应用。作为这个练习的一部分,我们将使用QLineEditQLabel小部件以及QFormLayout。我们需要设计一个 UI,如下面的截图所示:

图 5.66

QLabel是一个通常用于静态文本的小部件,而QLineEdit允许用户提供单行输入。如前面的屏幕截图所示,我们将使用QVBoxLayout作为主要布局,以便以垂直方式排列QFormLayoutQBoxLayout。当您需要创建一个表单,左侧将有标题,右侧将有一些小部件时,QFormLayout非常方便。QGridLayout也可能能够胜任,但在这种情况下,QFormLayout易于使用。

在这个练习中,我们将创建三个文件,分别是MyDlg.hMyDlg.cppmain.cpp。让我们从MyDlg.h源代码开始,然后转移到其他文件:

图 5.67

在上图中,声明了三种布局。垂直框布局用作主要布局,而框布局用于以右对齐的方式排列按钮。表单布局用于添加标签,即行编辑小部件。这个练习还将帮助您了解如何组合多个布局来设计专业的 HMI。

Qt 没有关于可以在单个窗口中组合的布局数量的记录限制。然而,如果可能的话,考虑使用最少数量的布局设计 HMI 是一个好主意,如果您正在努力开发一个占用内存较小的应用程序。否则,在您的应用程序中使用多个布局当然没有坏处。

在下面的屏幕截图中,您将了解MyDlg.cpp源文件应该如何实现。在MyDlg构造函数中,所有按钮都被实例化并在框布局中进行右对齐。表单布局用于以网格方式容纳QLineEdit小部件及其对应的QLabel小部件。QLineEdit小部件通常用于提供单行输入;在这个特定的练习中,它们帮助我们提供必须根据用户的选择进行加法、减法等操作的数字输入。

图 5.68

我们的main.cpp源文件的最好部分是,它基本上保持不变,无论我们的应用程序的复杂性如何。在这个练习中,我想告诉你一个关于MyDlg的秘密。你有没有注意到MyDlg构造函数是在堆栈中实例化而不是在堆中?这样做的想法是,当main()函数退出时,main函数使用的堆栈会被解开,最终释放堆栈中存在的所有堆栈变量。当MyDlg被释放时,会导致调用MyDlg析构函数。在 Qt 框架中,每个小部件构造函数都接受一个可选的父小部件指针,顶层窗口析构函数使用它来释放其子小部件。有趣的是,Qt 维护一个类似树的数据结构来管理所有子小部件的内存。因此,如果一切顺利,Qt 框架将自动处理释放所有子小部件的内存位置。

这有助于 Qt 开发人员专注于应用程序方面,而 Qt 框架将负责内存管理。

图 5.69

您是不是很兴奋地想要检查我们新应用程序的输出?如果您构建并执行应用程序,那么您应该会得到类似以下屏幕截图的输出。当然,我们还没有添加信号和槽支持,但设计 GUI 以满足我们的要求,然后将焦点转移到事件处理是一个好主意:

图 5.70

如果你仔细观察,尽管按钮是按从右到左的顺序布局在QBoxLayout上,但按钮并没有对齐到右侧。这种行为的原因是当窗口被拉伸时,框布局似乎已经将额外的水平空间分配给了所有的按钮。因此,让我们在框布局的最左侧位置添加一个伸展项,这样伸展项将占据所有额外的空间,使按钮没有空间可以扩展。这样就可以得到右对齐的效果。在添加了伸展项之后,代码将如下屏幕截图所示:

图 5.71

继续检查你的输出是否与下面的屏幕截图一样。有时作为开发人员,我们会兴奋地匆忙看到输出,忘记编译我们的更改,所以确保项目再次构建。如果你在输出中没有看到任何变化,别担心;尝试水平拉伸窗口,你应该会看到右对齐的效果,如下面的屏幕截图所示:

图 5.72

现在我们有了一个看起来不错的应用程序,让我们为按钮点击添加信号和槽支持。让我们不要急于包括添加和减去功能。我们将使用一些qDebug()打印语句来检查信号和槽是否连接正确,然后逐渐用实际功能替换它们。

如果你还记得之前的信号和槽练习,任何有兴趣支持信号和槽的 Qt 窗口都必须是QObject,并且应该在MyDlg.h头文件中包含Q_OBJECT宏,如下面的屏幕截图所示:

图 5.73

从第 41 行到 45 行,私有部分声明了四个槽方法。槽函数是常规的 C函数,可以像其他 C函数一样直接调用。然而,在这种情况下,槽函数只打算与MyDlg一起调用。因此它们被声明为私有函数,但如果你认为其他人可能会发现连接到你的公共槽有用,它们也可以被声明为公共函数。

很好,如果你已经走到这一步,那说明你已经理解了到目前为止讨论的内容。好的,让我们继续在MyDlg.cpp中实现槽函数的定义,然后将clicked()按钮的信号连接到相应的槽函数:

图 5.74

现在是时候将信号连接到它们各自的槽上了。正如你可能已经猜到的那样,我们需要在MyDlg构造函数中使用connect函数,如下面的屏幕截图所示,以将按钮点击传递到相应的槽中:

图 5.75

我们已经准备好了。是的,现在是展示时间。由于我们已经处理了大部分的事情,让我们编译并检查一下我们小小的 Qt 应用程序的输出:

图 5.76

糟糕!我们遇到了一些链接错误。这个问题的根本原因是我们在启用应用程序的信号和槽支持后忘记调用qmake。别担心,让我们调用qmakemake来运行我们的应用程序:

图 5.77

很好,我们已经解决了问题。这次 make 工具似乎没有发出任何声音,我们能够启动应用程序。让我们检查信号和槽是否按预期工作。为此,点击“添加”按钮,看看会发生什么:

图 5.78

哇!当我们点击“添加”按钮时,qDebug()控制台消息确认了MyDlg::onAddButtonClicked()槽被调用。如果你好奇检查其他按钮的槽,请继续尝试点击其他按钮。

我们的应用程序将不完整没有业务逻辑。因此,让我们在MyDlg::onAddButtonClicked()槽函数中添加业务逻辑,执行加法并显示结果。一旦你学会了如何集成添加的业务逻辑,你可以遵循相同的方法并实现其余的槽函数:

图 5.79

MyDlg::onAddButtonClicked()函数中,集成了业务逻辑。在第 82 和 83 行,我们试图提取用户在QLineEdit小部件中输入的值。QLineEdit中的text()函数返回QString QString对象提供了toInt()函数,非常方便地提取了由QString表示的整数值。一旦值被添加并存储在结果变量中,我们需要将结果整数值转换回QString,如第 86 行所示,以便结果可以被输入到QLineEdit中,如第 88 行所示。

同样,你可以继续集成其他数学运算的业务逻辑。一旦你彻底测试了应用程序,你可以删除qDebug()控制台的输出。我们添加了qDebug()消息用于调试目的,因此现在可以清理它们。

摘要

在本章中,你学会了使用 Qt 应用程序框架开发 C++ GUI 应用程序。以下是关键要点。

  • 你学会了在 Linux 中安装 Qt 和所需的工具。

  • 你学会了使用 Qt Framework 编写简单的基于控制台的应用程序。

  • 你学会了使用 Qt Framework 编写简单的基于 GUI 的应用程序。

  • 你学会了使用 Qt 信号和槽机制处理事件,并且了解了元对象编译器如何帮助我们生成信号和槽所需的关键样板代码。

  • 你学会了在应用程序开发中使用各种 Qt 布局,以开发在许多 Qt 支持的平台上看起来很棒的吸引人的 HMI。

  • 你学会了将多个布局组合在一个 HMI 中,以开发专业的 HMI。

  • 你学会了许多 Qt 小部件以及它们如何帮助你开发令人印象深刻的 HMI。

  • 总的来说,你学会了使用 Qt 应用程序框架开发跨平台 GUI 应用程序。

在下一章中,你将学习 C++中的多线程编程和 IPC。

第六章:测试驱动开发

本章将涵盖以下主题:

  • 测试驱动开发的简要概述

  • 关于 TDD 的常见神话和问题

  • 开发人员是否需要更多的工作来编写单元测试

  • 代码覆盖率指标是好是坏

  • TDD 是否适用于复杂的遗留项目?

  • TDD 是否适用于嵌入式产品或涉及硬件的产品

  • C++的单元测试框架

  • Google 测试框架

  • 在 Ubuntu 上安装 Google 测试框架

  • 将 Google 测试和模拟一起构建为一个单一的静态库的过程,而无需安装它们

  • 使用 Google 测试框架编写我们的第一个测试用例

  • 在 Visual Studio IDE 中使用 Google 测试框架

  • TDD 的实践

  • 测试具有依赖关系的遗留代码

让我们深入探讨这些 TDD 主题。

TDD

测试驱动开发TDD)是一种极限编程实践。在 TDD 中,我们从一个测试用例开始,逐步编写所需的生产代码,以使测试用例成功。这个想法是应该一次专注于一个测试用例或场景,一旦测试用例通过,就可以转移到下一个场景。在这个过程中,如果新的测试用例通过,我们不应该修改生产代码。换句话说,在开发新功能或修复错误的过程中,我们只能出于两个原因修改生产代码:要么确保测试用例通过,要么重构代码。TDD 的主要重点是单元测试;然而,它可以在一定程度上扩展到集成和交互测试。

以下图表直观地展示了 TDD 的过程:

当 TDD 被严格遵循时,可以实现代码的功能和结构质量。非常重要的是,在编写生产代码之前先编写测试用例,而不是在开发阶段末尾编写测试用例。这会产生很大的差异。例如,当开发人员在开发结束时编写单元测试用例时,测试用例很可能不会发现代码中的任何缺陷。原因是当测试用例在开发结束时编写时,开发人员会下意识地倾向于证明他们的代码是正确的。而当开发人员提前编写测试用例时,由于尚未编写代码,他们会从最终用户的角度出发思考,这将鼓励他们从需求规范的角度提出许多场景。

换句话说,针对已经编写的代码编写的测试用例通常不会发现任何错误,因为它倾向于证明编写的代码是正确的,而不是根据要求进行测试。开发人员在编写代码之前考虑了各种情况,这有助于他们逐步编写更好的代码,确保代码确实考虑到这些情况。然而,当代码存在漏洞时,测试用例将帮助他们发现问题,因为如果测试用例不符合要求,测试用例将失败。

TDD 不仅仅是使用一些单元测试框架。在开发或修复代码时,它需要文化和心态的改变。开发人员的重点应该是使代码在功能上正确。一旦以这种方式开发了代码,强烈建议开发人员还应专注于通过重构代码来消除任何代码异味;这将确保代码的结构质量也很好。从长远来看,代码的结构质量将使团队更快地交付功能。

关于 TDD 的常见神话和问题

TDD 的许多神话和常见疑问在每个人开始 TDD 之旅时都会出现在脑海中。让我澄清我遇到的大部分问题,因为我咨询了全球许多产品巨头。

开发人员是否需要更多的工作来编写单元测试?

大多数开发人员心中常常产生的疑问之一是,“当我们采用 TDD 时,我应该如何估计我的努力?”由于开发人员应该在 TDD 的一部分写单元和集成测试用例,你担心如何与客户或管理层协商额外的努力,以编写测试用例而不仅仅是编写代码。别担心,你并不孤单;作为一名自由软件顾问,许多开发人员向我提出了这个问题。

作为开发人员,你手动测试你的代码;现在改为编写自动化测试用例。好消息是,这是一次性的努力,保证能在长远帮助你。虽然开发人员需要重复手动测试他们的代码,每次他们改变代码时,已经存在的自动化测试用例将帮助开发人员在集成新的代码时立即给予他们反馈。

底线是,这需要额外的努力,但从长远来看,它有助于减少所需的努力。

TDD 对复杂的遗留项目有效吗?

当然!TDD 适用于任何类型的软件项目或产品。TDD 不仅适用于新产品或项目;它也被证明在复杂的遗留项目或产品中更加有效。在维护项目中,绝大部分时间都要修复缺陷,很少需要支持新功能。即使在这样的遗留代码中,修复缺陷时也可以遵循 TDD。

作为开发人员,你会很容易同意,一旦你能够重现问题,从开发人员的角度来看,问题几乎已经解决了一半。因此,你可以从能够重现问题的测试用例开始,然后调试和修复问题。当你修复问题时,测试用例将开始通过;现在是时候考虑可能会重现相同缺陷的另一个测试用例,并重复这个过程。

TDD 是否适用于嵌入式或涉及硬件的产品?

就像应用软件可以从 TDD 中受益一样,嵌入式项目或涉及硬件交互的项目也可以从 TDD 方法中受益。有趣的是,嵌入式项目或涉及硬件的产品更多地受益于 TDD,因为他们可以通过隔离硬件依赖性来测试大部分代码而无需硬件。TDD 有助于减少上市时间,因为团队可以在不等待硬件的情况下测试大部分软件。由于大部分代码已经在没有硬件的情况下进行了彻底测试,这有助于避免在板卡启动发生时出现最后一分钟的意外或应急情况。这是因为大部分情况已经得到了彻底测试。

根据软件工程的最佳实践,一个良好的设计是松散耦合和高内聚的。虽然我们都努力编写松散耦合的代码,但并不总是可能编写绝对独立的代码。大多数情况下,代码都有某种依赖。在应用软件的情况下,依赖可能是数据库或 Web 服务器;在嵌入式产品的情况下,依赖可能是一块硬件。但是使用依赖反转,可以将被测试的代码CUT)与其依赖隔离开来,使我们能够在没有依赖的情况下测试代码,这是一种强大的技术。只要我们愿意重构代码使其更模块化和原子化,任何类型的代码和项目或产品都将受益于 TDD 方法。

C++的单元测试框架

作为 C++开发人员,在选择单元测试框架时,你有很多选择。虽然还有许多其他框架,但这些是一些流行的框架:CppUnit、CppUnitLite、Boost、MSTest、Visual Studio 单元测试和 Google 测试框架。

尽管是较旧的文章,我建议您查看gamesfromwithin.com/exploring-the-c-unit-testing-framework-jungleaccu.org/index.php/journals/。它们可能会给您一些关于这个主题的见解。

毫无疑问,Google 测试框架是 C++最受欢迎的测试框架之一,因为它支持多种平台,得到积极开发,并且最重要的是由 Google 支持。

在本章中,我们将使用 Google 测试和 Google 模拟框架。然而,本章讨论的概念适用于所有单元测试框架。我们将深入研究 Google 测试框架及其安装过程。

Google 测试框架

Google 测试框架是一个开源的测试框架,适用于许多平台。TDD 只关注单元测试和在一定程度上的集成测试,但 Google 测试框架可以用于各种测试。它将测试用例分类为小型、中型、大型、忠诚度、韧性、精度和其他类型的测试用例。单元测试用例属于小型,集成测试用例属于中型,复杂功能和验收测试用例属于大型。

它还将 Google 模拟框架捆绑在一起。由于它们在技术上来自同一个团队,它们可以无缝地相互配合。然而,Google 模拟框架可以与其他测试框架一起使用,如 CppUnit。

在 Ubuntu 上安装 Google 测试框架

您可以从github.com/google/googletest下载 Google 测试框架的源代码。然而,最佳的下载方式是通过终端命令行中的 Git 克隆:

git clone https://github.com/google/googletest.git

Git 是一个开源的分布式版本控制系统(DVCS)。如果您还没有在系统上安装它,您可以在git-scm.com/上找到更多关于为什么应该安装它的信息。然而,在 Ubuntu 中,可以使用sudo apt-get install git命令轻松安装它。

一旦代码像图 7.1所示下载,您就可以在googletest文件夹中找到 Google 测试框架的源代码:

图 7.1

googletest文件夹中有googletestgooglemock框架分别在不同的文件夹中。现在我们可以调用cmake实用程序来配置我们的构建并自动生成Makefile,如下所示:

cmake CMakeLists.txt

图 7.2

当调用cmake实用程序时,它会检测构建 Google 测试框架所需的 C/C++头文件及其路径。此外,它还会尝试定位构建源代码所需的工具。一旦找到所有必要的头文件和工具,它将自动生成Makefile。一旦有了Makefile,您就可以使用它来编译和安装 Google 测试和 Google 模拟到您的系统上:

sudo make install

以下截图演示了如何在系统上安装 Google 测试:

图 7.3

在上图中,make install命令已经编译并安装了libgmock.alibgtest.a静态库文件到/usr/local/lib文件夹中。由于/usr/local/lib文件夹路径通常在系统的 PATH 环境变量中,因此可以从系统中的任何项目中访问它。

如何将 Google 测试和模拟一起构建为一个单一的静态库而不安装?

如果您不喜欢在常用系统文件夹上安装libgmock.alibgtest.a静态库文件以及相应的头文件,那么还有另一种构建 Google 测试框架的方式。

以下命令将创建三个对象文件,如图 7.4所示:

g++ -c googletest/googletest/src/gtest-all.cc googletest/googlemock/src/gmock-all.cc googletest/googlemock/src/gmock_main.cc -I googletest/googletest/ -I googletest/googletest/include -I googletest/googlemock -I googletest/googlemock/include -lpthread -

图 7.4

下一步是使用以下命令将所有对象文件合并到一个单独的静态库中:

ar crf libgtest.a gmock-all.o gmock_main.o gtest-all.o

如果一切顺利,您的文件夹应该有全新的libgtest.a静态库,如图 7.5所示。让我们了解以下命令说明:

g++ -c googletest/googletest/src/gtest-all.cc    googletest/googlemock/src/gmock-all.cc googletest/googlemock/src/gmock_main.cc -I googletest/googletest/ -I googletest/googletest/include 
-I googletest/googlemock  -I googletest/googlemock/include -lpthread -std=c++14

上述命令将帮助我们创建三个对象文件:gtest-all.ogmock-all.ogmock_main.ogoogletest框架使用了一些 C11 特性,我故意使用了 c14 以确保安全。gmock_main.cc源文件有一个 main 函数,将初始化 Google 模拟框架,然后内部初始化 Google 测试框架。这种方法的最大优点是我们不必为我们的单元测试应用程序提供 main 函数。请注意编译命令包括以下include路径,以帮助 g++编译器定位 Google 测试和 Google 模拟框架中必要的头文件:

-I googletest/googletest
-I googletest/googletest/include
-I googletest/googlemock
-I googletest/googlemock/include

现在下一步是创建我们的libgtest.a静态库,将 gtest 和 gmock 框架捆绑到一个单独的静态库中。由于 Google 测试框架使用了多个线程,因此必须将pthread库链接到我们的静态库中:

ar crv libgtest.a gtest-all.o gmock_main.o gmock-all.o

ar存档命令有助于将所有对象文件合并到一个单独的静态库中。

以下图片在终端上实际演示了讨论的过程:

图 7.5

使用 Google 测试框架编写我们的第一个测试用例

学习 Google 测试框架非常容易。让我们创建两个文件夹:一个用于生产代码,另一个用于测试代码。这样做的目的是将生产代码与测试代码分开。创建了这两个文件夹后,从Math.h头文件开始,如图 7.6所示:

图 7.6

Math类只有一个函数,用于演示单元测试框架的用法。首先,我们的Math类有一个简单的 add 函数,足以理解 Google 测试框架的基本用法。

在 Google 测试框架的位置,您也可以使用 CppUnit,并集成模拟框架,如 Google 模拟框架、mockpp 或 opmock。

让我们在以下Math.cpp源文件中实现我们简单的Math类:

图 7.7

前面的两个文件应该在src文件夹中,如图 7.8所示。所有的生产代码都放在src文件夹中,src文件夹可以包含任意数量的文件。

图 7.8

由于我们已经编写了一些生产代码,让我们看看如何为前面的生产代码编写一些基本的测试用例。作为一般的最佳实践,建议将测试用例文件命名为MobileTestTestMobile,以便任何人都能轻松预测文件的目的。在 C++或 Google 测试框架中,不强制将文件名和类名保持一致,但通常被认为是最佳实践,因为它可以帮助任何人通过查看文件名来定位特定的类。

Google 测试框架和 Google 模拟框架是同一个团队的产品,因此这种组合在大多数平台上,包括嵌入式平台,都能很好地工作。

由于我们已经将 Google 测试框架编译为静态库,让我们直接从MathTest.cpp源文件开始:

图 7.9

图 7.9中,第 18 行,我们包含了来自 Google 测试框架的 gtest 头文件。在 Google 测试框架中,测试用例使用TEST宏编写,该宏接受两个参数。第一个参数,即MathTest,表示测试模块名称,第二个参数是测试用例的名称。测试模块帮助我们将一组相关的测试用例分组到一个模块下。因此,为了提高测试报告的可读性,为测试模块和测试用例命名非常重要。

正如您所知,Math是我们打算测试的类;我们在第 22 行实例化了Math对象。在第 25 行,我们调用了 math 对象上的 add 函数,这个函数应该返回实际结果。最后,在第 27 行,我们检查了预期结果是否与实际结果匹配。Google 测试宏EXPECT_EQ将在预期和实际结果匹配时标记测试用例为通过;否则,框架将标记测试用例的结果为失败。

好了,我们现在已经准备好了。让我们看看如何编译和运行我们的测试用例。以下命令应该帮助您编译测试用例:

g++ -o tester.exe src/Math.cpp test/MathTest.cpp -I googletest/googletest 
-I googletest/googletest/include -I googletest/googlemock     
-I googletest/googlemock/include -I src libgtest.a -lpthread

请注意,编译命令包括以下包含路径:

-I googletest/googletest
-I googletest/googletest/include
-I googletest/googlemock
-I googletest/googlemock/include
-I src

另外,需要注意的是,我们还链接了我们的 Google 测试静态库libgtest.a和 POSIX pthreads 库,因为 Google 测试框架使用了多个。

图 7.10

恭喜!我们成功编译并执行了我们的第一个测试用例。

在 Visual Studio IDE 中使用 Google 测试框架

首先,我们需要从github.com/google/googletest/archive/master.zip下载 Google 测试框架的.zip文件。下一步是在某个目录中解压.zip文件。在我的情况下,我已将其解压到googletest文件夹,并将googletest googletest-mastergoogletest-master的所有内容复制到googletest文件夹中,如图 7.11所示:

图 7.11

现在是在 Visual Studio 中创建一个简单项目的时候了。我使用的是 Microsoft Visual Studio Community 2015。但是,这里遵循的步骤对于其他版本的 Visual Studio 来说基本上是一样的,只是选项可能在不同的菜单中可用。

您需要通过转到新项目| Visual Studio | Windows | Win32 | Win32 控制台应用程序来创建一个名为MathApp的新项目,如图 7.12所示。该项目将成为要测试的生产代码。

图 7.12

让我们将MyMath类添加到MathApp项目中。MyMath类是将在MyMath.h中声明并在MyMath.cpp中定义的生产代码。

让我们来看一下图 7.13中显示的MyMath.h头文件:

图 7.13

MyMath类的定义如图 7.14所示:

图 7.14

由于这是一个控制台应用程序,必须提供主函数,如图 7.15所示:

图 7.15

接下来,我们将向MathApp项目解决方案中添加一个名为GoogleTestLib的静态库项目,如图 7.16所示:

图 7.16

接下来,我们需要将 Google 测试框架的以下源文件添加到我们的静态库项目中:

C:Usersjegangoogletestgoogletestsrcgtest-all.cc
C:Usersjegangoogletestgooglemocksrcgmock-all.cc
C:Usersjegangoogletestgooglemocksrcgmock_main.cc

为了编译静态库,我们需要在GoogleTestLib/Properties/VC++ Directories/Include目录中包含以下头文件路径:

C:Usersjegangoogletestgoogletest
C:Usersjegangoogletestgoogletestinclude
C:Usersjegangoogletestgooglemock
C:Usersjegangoogletestgooglemockinclude

您可能需要根据在系统中复制/安装 Google 测试框架的位置来自定义路径。

现在是时候将MathTestApp Win32 控制台应用程序添加到MathApp解决方案中了。我们需要将MathTestApp设置为StartUp项目,以便可以直接执行此应用程序。在我们向MathTestApp项目添加名为MathTest.cpp的新源文件之前,让我们确保MathTestApp项目中没有源文件。

我们需要配置与我们添加到GoogleTestLib静态库的相同一组 Google 测试框架包含路径。除此之外,我们还必须将MathApp项目目录添加为测试项目将引用MathApp项目中的头文件,如下所示。但是,请根据您在系统中为此项目遵循的目录结构自定义路径:

C:Usersjegangoogletestgoogletest
C:Usersjegangoogletestgoogletestinclude
C:Usersjegangoogletestgooglemock
C:Usersjegangoogletestgooglemockinclude
C:ProjectsMasteringC++ProgrammingMathAppMathApp

MathAppTest项目中,确保您已经添加了对MathAppGoogleTestLib的引用,以便在它们发生更改时,MathAppTest项目将编译其他两个项目。

太好了!我们几乎完成了。现在让我们实现MathTest.cpp,如图 7.17所示:

图 7.17

现在一切准备就绪;让我们运行测试用例并检查结果:

图 7.18

TDD 实践

让我们看看如何开发一个遵循 TDD 方法的逆波兰表达式RPN)计算器应用程序。RPN 也被称为后缀表示法。RPN 计算器应用程序的期望是接受后缀数学表达式作为输入,并将计算结果作为输出返回。

逐步地,我想演示在开发应用程序时如何遵循 TDD 方法。作为第一步,我想解释项目目录结构,然后我们将继续。让我们创建一个名为Ex2的文件夹,具有以下结构:

图 7.19

googletest文件夹是具有必要的gtestgmock头文件的 gtest 测试库。现在libgtest.a是我们在上一个练习中创建的 Google 测试静态库。我们将使用make实用程序来构建我们的项目,因此我已经将Makefile放在项目home目录中。src目录将保存生产代码,而测试目录将保存我们将要编写的所有测试用例。

在我们开始编写测试用例之前,让我们拿一个后缀数学表达式“2 5 * 4 + 3 3 * 1 + /”并了解我们将应用于评估 RPN 数学表达式的标准后缀算法。根据后缀算法,我们将逐个标记地解析 RPN 数学表达式。每当我们遇到一个操作数(数字)时,我们将把它推入堆栈。每当我们遇到一个运算符时,我们将从堆栈中弹出两个值,应用数学运算,将中间结果推回堆栈,并重复该过程,直到 RPN 表达式中的所有标记都被评估。最后,当输入字符串中没有更多的标记时,我们将弹出该值并将其打印为结果。该过程在以下图中逐步演示:

图 7.20

首先,让我们拿一个简单的后缀数学表达式,并将情景转化为一个测试用例:

Test Case : Test a simple addition
Input: "10 15 +"
Expected Output: 25.0

让我们将前述测试用例翻译为测试文件夹中的 Google 测试,如下所示:

test/RPNCalculatorTest.cpp

TEST ( RPNCalculatorTest, testSimpleAddition ) { 
         RPNCalculator rpnCalculator; 
         double actualResult = rpnCalculator.evaluate ( "10 15 +" ); 
         double expectedResult = 25.0; 
         EXPECT_EQ ( expectedResult, actualResult ); 
}

为了编译前述测试用例,让我们在src文件夹中编写所需的最小生产代码:

src/RPNCalculator.h

#include <iostream>
#include <string>
using namespace std;

class RPNCalculator {
  public:
      double evaluate ( string );
};

由于 RPN 数学表达式将作为以空格分隔的字符串提供,因此评估方法将接受一个字符串输入参数:

src/RPNCalculator.cpp

#include "RPNCalculator.h"

double RPNCalculator::evaluate ( string rpnMathExpression ) {
    return 0.0;
}

以下的Makefile类帮助我们在编译生产代码时每次运行测试用例:

图 7.21

现在让我们构建并运行测试用例,并检查测试用例的结果:

图 7.22

在 TDD 中,我们总是从一个失败的测试用例开始。失败的根本原因是预期结果是 25,而实际结果是 0。原因是我们还没有实现 evaluate 方法,因此我们已经硬编码返回 0,而不管任何输入。因此,让我们实现 evaluate 方法,以使测试用例通过。

我们需要修改src/RPNCalculator.hsrc/RPNCalculator.cpp如下:

图 7.23

在 RPNCalculator.h 头文件中,观察包含的新头文件,用于处理字符串标记化和字符串双精度转换,并将 RPN 标记复制到向量中:

图 7.24

根据标准的后缀算法,我们使用一个栈来保存在 RPN 表达式中找到的所有数字。每当我们遇到+数学运算符时,我们从栈中弹出两个值并将它们相加,然后将结果推回栈中。如果标记不是+运算符,我们可以安全地假定它是一个数字,所以我们只需将值推到栈中。

有了前面的实现,让我们尝试测试用例并检查测试用例是否通过:

图 7.25

很好,我们的第一个测试用例如预期地通过了。现在是时候考虑另一个测试用例了。这次,让我们为减法添加一个测试用例:

Test Case : Test a simple subtraction
Input: "25 10 -"
Expected Output: 15.0

让我们将前面的测试用例翻译成测试文件中的 Google 测试,如下所示:

test/RPNCalculatorTest.cpp

TEST ( RPNCalculatorTest, testSimpleSubtraction ) { 
         RPNCalculator rpnCalculator; 
         double actualResult = rpnCalculator.evaluate ( "25 10 -" ); 
         double expectedResult = 15.0; 
         EXPECT_EQ ( expectedResult, actualResult ); 
}

通过将前面的测试用例添加到test/RPNCalculatorTest,现在应该是这样的:

图 7.26

让我们执行测试用例并检查我们的新测试用例是否通过:

图 7.27

如预期的那样,新的测试用例失败了,因为我们还没有在应用程序中添加对减法的支持。这是非常明显的,基于 C++异常,因为代码试图将减法-运算符转换为数字。让我们在 evaluate 方法中添加对减法逻辑的支持:

图 7.28

是时候测试了。让我们执行测试用例并检查事情是否正常:

图 7.29

酷!你有没有注意到我们的测试用例在这种情况下失败了?等一下。如果测试用例失败了,为什么我们会兴奋呢?我们应该高兴的原因是,我们的测试用例发现了一个 bug;毕竟,这是 TDD 的主要目的,不是吗?

图 7.30

失败的根本原因是栈是基于后进先出LIFO)操作,而我们的代码假设是先进先出。你有没有注意到我们的代码假设它会先弹出第一个数字,而实际上它应该先弹出第二个数字?有趣的是,这个 bug 在加法操作中也存在;然而,由于加法是可结合的,这个 bug 被抑制了,但减法测试用例检测到了它。

图 7.31

让我们按照上面的截图修复 bug,并检查测试用例是否通过:

图 7.32

太棒了!我们修复了 bug,我们的测试用例似乎证实了它们已经修复。让我们添加更多的测试用例。这次,让我们添加一个用于验证乘法的测试用例:

Test Case : Test a simple multiplication
Input: "25 10 *"
Expected Output: 250.0

让我们将前面的测试用例翻译成测试文件中的谷歌测试,如下所示:

test/RPNCalculatorTest.cpp

TEST ( RPNCalculatorTest, testSimpleMultiplication ) { 
         RPNCalculator rpnCalculator; 
         double actualResult = rpnCalculator.evaluate ( "25 10 *" ); 
         double expectedResult = 250.0; 
         EXPECT_EQ ( expectedResult, actualResult ); 
}

我们知道这次测试用例肯定会失败,所以让我们快进,看看除法测试用例:

Test Case : Test a simple division
Input: "250 10 /"
Expected Output: 25.0

让我们将前面的测试用例翻译成测试文件中的谷歌测试,如下所示:

test/RPNCalculatorTest.cpp

TEST ( RPNCalculatorTest, testSimpleDivision ) { 
         RPNCalculator rpnCalculator; 
         double actualResult = rpnCalculator.evaluate ( "250 10 /" ); 
         double expectedResult = 25.0; 
         EXPECT_EQ ( expectedResult, actualResult );
}

让我们跳过测试结果,继续进行最终的复杂表达式测试,涉及许多操作:

Test Case : Test a complex rpn expression
Input: "2  5  *  4  + 7  2 -  1  +  /"
Expected Output: 25.0

让我们将前面的测试用例翻译成测试文件中的谷歌测试,如下所示:

test/RPNCalculatorTest.cpp

TEST ( RPNCalculatorTest, testSimpleDivision ) { 
         RPNCalculator rpnCalculator; 
         double actualResult = rpnCalculator.evaluate ( "250 10 /" ); 
         double expectedResult = 25.0; 
         EXPECT_EQ ( expectedResult, actualResult );
}

让我们检查一下我们的 RPNCalculator 应用程序是否能够评估一个复杂的 RPN 表达式,其中包括加法、减法、乘法和除法在一个表达式中,使用以下测试用例:

test/RPNCalculatorTest.cpp

TEST ( RPNCalculatorTest, testComplexExpression ) { 
         RPNCalculator rpnCalculator; 
         double actualResult = rpnCalculator.evaluate ( "2  5  *  4  +  7  2 - 1 +  /" ); 
         double expectedResult = 2.33333; 
         ASSERT_NEAR ( expectedResult, actualResult, 4 );
}

在前面的测试用例中,我们正在检查预期结果是否与实际结果匹配,精确到小数点后四位。如果超出这个近似值,那么测试用例应该失败。

现在让我们检查一下测试用例的输出:

图 7.33

太棒了!所有的测试用例都通过了。

现在让我们看一下我们的生产代码,并检查是否有改进的空间:

图 7.34

代码在功能上很好,但有很多代码异味。这是一个长方法,有嵌套的if-else条件和重复的代码。TDD 不仅仅是关于测试自动化;它也是关于编写没有代码异味的好代码。因此,我们必须重构代码,使其更模块化,减少代码复杂度。

我们可以在这里应用多态性或策略设计模式,而不是嵌套的if-else条件。此外,我们可以使用工厂方法设计模式来创建各种子类型。还可以使用空对象设计模式。

最好的部分是,在重构过程中我们不必担心破坏我们的代码,因为我们有足够数量的测试用例来在我们破坏代码时给我们反馈。

首先,让我们了解一下如何重构图 7.35中所示的 RPNCalculator 设计:

图 7.35

根据前面的设计重构方法,我们可以将 RPNCalculator 重构如图 7.36所示:

图 7.36

如果你比较重构前后的RPNCalculator代码,你会发现重构后代码复杂度有所降低。

MathFactory类可以按照图 7.37中所示实现:

图 7.37

尽可能地,我们必须努力避免if-else条件,或者一般地说,我们必须尽量避免代码分支。因此,STL map 用于避免if-else条件。这也促进了相同的 Math 对象的重复使用,无论 RPN 表达式的复杂程度如何。

如果你参考图 7.38,你将了解到MathOperator Add类是如何实现的:

图 7.38

Add类的定义如图 7.39所示:

图 7.39

减法、乘法和除法类可以像Add类一样实现。重点是,在重构后,我们可以将单个RPNCalculator类重构为更小、可维护的类,可以单独进行测试。

让我们看一下重构后的Makefile类在图 7.40中是如何实现的,并在重构过程完成后测试我们的代码:

图 7.40

如果一切顺利,重构后我们应该看到所有测试用例通过,如果没有功能出现问题,就像图 7.41中所示的那样:

图 7.41

太棒了!所有的测试用例都通过了,因此我们保证在重构过程中没有破坏功能。TDD 的主要目的是编写可测试的代码,既在功能上又在结构上是清晰的。

测试具有依赖关系的旧代码

在上一节中,CUT 是独立的,没有依赖,因此它测试代码的方式很直接。然而,让我们讨论一下如何对具有依赖关系的 CUT 进行单元测试。为此,请参考以下图片:

图 7.42

图 7.42中,很明显Mobile依赖于Camera,而MobileCamera之间的关联是组合。让我们看看遗留应用程序中Camera.h头文件是如何实现的:

图 7.43

为了演示目的,让我们来看一下这个简单的Camera类,它具有ON()OFF()功能。假设 ON/OFF 功能将在内部与相机硬件交互。查看图 7.44中的Camera.cpp源文件:

图 7.44

为了调试目的,我添加了一些打印语句,这些语句在我们测试powerOn()powerOff()功能时会派上用场。现在让我们检查图 7.45中的Mobile类头文件:

图 7.45

我们继续移动实现,如图 7.46所示:

图 7.46

Mobile构造函数的实现中,很明显手机有一个相机,或者更确切地说是组合关系。换句话说,Mobile类是构造Camera对象的类,如图 7.46第 21 行所示,在构造函数中。让我们尝试看看测试MobilepowerOn()功能所涉及的复杂性;依赖关系与Mobile的 CUT 具有组合关系。

假设相机已成功打开,让我们编写powerOn()测试用例,如下所示:

TEST ( MobileTest, testPowerOnWhenCameraONSucceeds ) {

     Mobile mobile;
     ASSERT_TRUE ( mobile.powerOn() );

}

现在让我们尝试运行Mobile测试用例并检查测试结果,如图 7.47所示:

图 7.47

图 7.47,我们可以理解MobilepowerOn()测试用例已经通过。然而,我们也了解到Camera类的真正ON()方法也被调用了。这反过来将与相机硬件交互。归根结底,这不是一个单元测试,因为测试结果并不完全取决于 CUT。如果测试用例失败,我们将无法确定失败是由于MobilepowerOn()逻辑中的代码还是由于相机的ON()逻辑中的代码,这将违背我们测试用例的目的。理想的单元测试应该使用依赖注入将 CUT 与其依赖项隔离,并测试代码。这种方法将帮助我们识别 CUT 在正常或异常情况下的行为。理想情况下,当单元测试用例失败时,我们应该能够猜测失败的根本原因,而无需调试代码;只有当我们设法隔离 CUT 的依赖项时才有可能做到这一点。

这种方法的关键好处是,CUT 可以在依赖项实现之前进行测试,这有助于在没有依赖项的情况下测试 60~70%的代码。这自然减少了将软件产品上市的时间。

这就是 Google mock 或 gmock 派上用场的地方。让我们看看如何重构我们的代码以实现依赖注入。虽然听起来很复杂,但重构代码所需的工作并不复杂。实际上,重构生产代码所需的工作可能更复杂,但这是值得的。让我们看看图 7.48中显示的重构后的Mobile类:

图 7.48

Mobile类中,我添加了一个以相机为参数的重载构造函数。这种技术称为构造函数依赖注入。让我们看看这种简单而强大的技术如何在测试MobilepowerOn()功能时帮助我们隔离相机依赖关系。

此外,我们必须重构Camera.h头文件,并声明ON()OFF()方法为虚拟方法,以便 gmock 框架帮助我们存根这些方法,如图 7.49所示:

图 7.49

现在让我们根据图 7.50对我们的测试用例进行重构:

图 7.50

我们已经准备好构建和执行测试用例。测试结果如图 7.51所示:

图 7.51

太棒了!我们的测试用例不仅通过了,而且我们还隔离了我们的 CUT 与其相机依赖,这很明显,因为我们没有看到相机的ON()方法的打印语句。最重要的是,您现在已经学会了如何通过隔离其依赖来对代码进行单元测试。

愉快的 TDD!

摘要

在本章中,您对 TDD 有了相当多的了解,以下是关键要点的摘要:

  • TDD 是一种极限编程(XP)实践

  • TDD 是一种自下而上的方法,鼓励我们从一个测试用例开始,因此通常被称为小写测试优先开发

  • 您学会了如何在 Linux 和 Windows 中使用 Google Test 和 Google Mock 框架编写测试用例

  • 您还学会了如何在 Linux 和 Windows 平台上的 Visual Studio 中编写遵循 TDD 的应用程序

  • 您学会了依赖反转技术以及如何使用 Google Mock 框架隔离其依赖来对代码进行单元测试

  • Google Test 框架支持单元测试、集成测试、回归测试、性能测试、功能测试等。

  • TDD 主要坚持单元测试、集成测试和交互测试,而复杂的功能测试必须通过行为驱动开发来完成

  • 您学会了如何将代码异味重构为干净的代码,同时您编写的单元测试用例会给出持续的反馈

您已经学会了 TDD 以及如何自下而上地自动化单元测试用例、集成测试用例和交互测试用例。有了 BDD,您将学习自上而下的开发方法,编写端到端的功能和测试用例以及我们在讨论 TDD 时没有涵盖的其他复杂测试场景。

在下一章中,您将学习有关行为驱动开发的知识。

第七章:行为驱动开发

本章涵盖以下主题:

  • 行为驱动开发简要概述

  • TDD 与 BDD

  • C++ BDD 框架

  • Gherkin 语言

  • 在 Ubuntu 中安装cucumber-cpp

  • 特性文件

  • Gherkin 支持的口语

  • 推荐的cucumber-cpp项目文件夹结构

  • 编写我们的第一个 Cucumber 测试用例

  • 运行我们的 Cucumber 测试用例

  • BDD——一种测试先开发的方法

在接下来的章节中,让我们以实用的方式逐个讨论每个主题,并提供易于理解和有趣的代码示例。

行为驱动开发

行为驱动开发BDD)是一种由外而内的开发技术。BDD 鼓励将需求描述为一组场景或用例,描述最终用户如何使用功能。场景将准确表达输入和功能预期响应。BDD 最好的部分是它使用称为Gherkin领域特定语言DSL)来描述 BDD 场景。

Gherkin 是所有 BDD 测试框架使用的类似英语的语言。Gherkin 是一种业务可读的 DSL,帮助您描述测试用例场景,排除实现细节。Gherkin 语言关键字是一堆英语单词;因此,技术和非技术成员都可以理解涉及软件产品或项目团队的场景。

我有告诉你,用 Gherkin 语言编写的 BDD 场景既可以作为文档,也可以作为测试用例吗?由于 Gherkin 语言易于理解并使用类似英语的关键字,产品需求可以直接被捕捉为 BDD 场景,而不是无聊的 Word 或 PDF 文档。根据我的咨询和行业经验,我观察到大多数公司在设计在一段时间内得到重构时,从不更新需求文档。这导致陈旧和未更新的文档,开发团队将不信任这些文档作为参考。因此,为准备需求、高级设计文档和低级设计文档所付出的努力最终会付诸东流,而 Cucumber 测试用例将始终保持更新和相关。

TDD 与 BDD

TDD 是一种由内而外的开发技术,而 BDD 是一种由外而内的开发技术。TDD 主要侧重于单元测试和集成测试用例自动化。

BDD 侧重于端到端功能测试用例和用户验收测试用例。然而,BDD 也可以用于单元测试、冒烟测试,以及实际上的任何类型的测试。

BDD 是 TDD 方法的扩展;因此,BDD 也强烈鼓励先测试开发。在同一个产品中同时使用 BDD 和 TDD 是非常自然的;因此,BDD 并不是 TDD 的替代品。BDD 可以被视为高级设计文档,而 TDD 是低级设计文档。

C++ BDD 框架

在 C++中,TDD 测试用例是使用测试框架(如 CppUnit、gtest 等)编写的,这需要技术背景来理解,因此通常只由开发人员使用。

在 C++中,BDD 测试用例是使用一个名为 cucumber-cpp 的流行测试框架编写的。cucumber-cpp 框架期望测试用例是用 Gherkin 语言编写的,而实际的测试用例实现可以使用任何测试框架,比如 gtest 或 CppUnit。

然而,在本书中,我们将使用 cucumber-cpp 与 gtest 框架。

Gherkin 语言

Gherkin 是每个 BDD 框架使用的通用语言,用于各种支持 BDD 的编程语言。

Gherkin 是一种面向行的语言,类似于 YAML 或 Python。Gherkin 将根据缩进解释测试用例的结构。

在 Gherkin 中,#字符用于单行注释。在撰写本书时,Gherkin 支持大约 60 个关键字。

Gherkin 是 Cucumber 框架使用的 DSL。

在 Ubuntu 中安装 cucumber-cpp

在 Linux 中安装 cucumber-cpp 框架非常简单。您只需要下载或克隆最新版本的 cucumber-cpp 即可。

以下命令可用于克隆 cucumber-cpp 框架:

git clone https://github.com/cucumber/cucumber-cpp.git

cucumber-cpp 框架支持 Linux、Windows 和 Macintosh。它可以与 Windows 上的 Visual Studio 或 macOS 上的 Xcode 集成。

以下截图演示了 Git 克隆过程:

由于 cucumber-cpp 依赖于一种 wire 协议,允许以 C++语言编写 BDD 测试用例步骤定义,因此我们需要安装 Ruby。

安装 cucumber-cpp 框架的先决条件软件

以下命令可帮助您在 Ubuntu 系统上安装 Ruby。这是 cucumber-cpp 框架所需的先决条件软件之一:

sudo apt install ruby

以下截图演示了 Ruby 安装过程:

安装完成后,请通过检查其版本来确保 Ruby 已正确安装。以下命令应打印安装在您的系统上的 Ruby 版本:

ruby --version

为了完成 Ruby 安装,我们需要安装ruby-dev软件包,如下所示:

sudo apt install ruby-dev

接下来,我们需要确保安装了 bundler 工具,以便 bundler 工具无缝安装 Ruby 依赖项:

sudo gem install bundler
bundle install

如果一切顺利,您可以继续检查是否正确安装了 Cucumber、Ruby 和 Ruby 的工具。bundle install命令将确保安装了 Cucumber 和其他 Ruby 依赖项。确保您不要以 sudo 用户身份安装bundle install;这将阻止非 root 用户访问 Ruby gem 包:

我们几乎完成了,但还没有完成。我们需要构建 cucumber-cpp 项目;作为其中的一部分,让我们获取最新的 cucumber-cpp 框架测试套件:

git submodule init
git submodule update

在我们开始构建之前,我们需要安装 ninja 和 boost 库。尽管在本章中我们不打算使用 boost 测试框架,但travis.sh脚本文件会寻找 boost 库。因此,我建议作为 Cucumber 的一部分通常安装 boost 库:

sudo apt install ninja-build
sudo apt-get install libboost-all-dev

构建和执行测试用例

现在,是时候构建 cucumber-cpp 框架了。让我们创建build文件夹。在cucumber-cpp文件夹中,将有一个名为travis.sh的 shell 脚本。您需要执行该脚本来构建和执行测试用例:

sudo ./travis.sh

尽管先前的方法有效,但我个人偏好和建议是以下方法。推荐以下方法的原因是build文件夹应该由非 root 用户创建,一旦cucumber-cpp设置完成,任何人都应该能够执行构建。您应该能够在cucumber-cpp文件夹下的README.md文件中找到说明:

git submodule init
git submodule update
cmake -E make_directory build
cmake -E chdir build cmake --DCUKE_ENABLE_EXAMPLES=on ..
cmake --build build
cmake --build build --target test
cmake --build build --target features

如果您能够完全按照先前的安装步骤进行操作,那么您就可以开始使用cucumber-cpp了。恭喜!!!

功能文件

每个产品功能都将有一个专用的功能文件。功能文件是一个带有.feature扩展名的文本文件。功能文件可以包含任意数量的场景,每个场景相当于一个测试用例。

让我们来看一个简单的功能文件示例:

1   # language: en
2
3   Feature: The Facebook application should authenticate user login.
4
5     Scenario: Successful Login
6        Given I navigate to Facebook login page https://www.facebook.com
7        And I type jegan@tektutor.org as Email
8        And I type mysecretpassword as Password
9        When I click the Login button
10       Then I expect Facebook Home Page after Successful Login

酷,看起来像是普通的英语,对吧?但相信我,这就是 Cucumber 测试用例的编写方式!我理解你的疑问——看起来容易又酷,但这样如何验证功能,并且验证功能的代码在哪里?cucumber-cpp框架是一个很酷的框架,但它并不原生支持任何测试功能;因此,cucumber-cpp依赖于gtestCppUnit和其他测试框架。测试用例的实现是在Steps文件中编写的,在我们的情况下可以使用gtest框架编写 C++。但是,任何测试框架都可以使用。

每个功能文件都以Feature关键字开头,后面跟着一行或多行描述,简要描述功能。在功能文件中,单词FeatureScenarioGivenAndWhenThen都是 Gherkin 关键字。

一个功能文件可以包含任意数量的场景(测试用例)。例如,在我们的情况下,登录是功能,可能有多个登录场景,如下所示:

  • 成功登录

  • 登录失败

  • 无效密码

  • 无效用户名

  • 用户尝试在不提供凭据的情况下登录。

在场景后的每一行将在Steps_definition.cpp源文件中转换为一个函数。基本上,cucumber-cpp框架使用正则表达式将功能文件步骤与Steps_definition.cpp文件中的相应函数进行映射。

Gherkin 支持的语言

Gherkin 支持 60 多种语言。作为最佳实践,功能文件的第一行将指示 Cucumber 框架我们想要使用英语:

1   # language: en

以下命令将列出cucumber-cpp框架支持的所有语言:

cucumber -i18n help

列表如下:

推荐的 cucumber-cpp 项目文件夹结构

与 TDD 一样,Cucumber 框架也推荐项目文件夹结构。推荐的cucumber-cpp项目文件夹结构如下:

src文件夹将包含生产代码,也就是说,所有项目文件将在src目录下维护。BDD 功能文件将在features文件夹下维护,以及其相应的Steps文件,其中包含 boost 测试用例或 gtest 测试用例。在本章中,我们将使用cucumber-cppgtest框架。wire文件包含与 wire 协议相关的连接细节,如端口等。CMakeLists.txt是构建脚本,其中包含构建项目及其依赖项细节的指令,就像MakefileMakeBuild实用程序使用一样。

编写我们的第一个 Cucumber 测试用例

让我们编写我们的第一个 Cucumber 测试用例!由于这是我们的第一个练习,我想保持简短和简单。首先,让我们为我们的HelloBDD项目创建文件夹结构。

要创建 Cucumber 项目文件夹结构,我们可以使用cucumber实用程序,如下所示:

cucumber --init

这将确保featuressteps_definitions文件夹按照 Cucumber 最佳实践创建:

创建基本文件夹结构后,让我们手动创建其余文件:

mkdir src
cd HelloBDD
touch CMakeLists.txt
touch features/hello.feature
touch features/step_definitions/cucumber.wire
touch features/step_definitions/HelloBDDSteps.cpp
touch src/Hello.h
touch src/Hello.cpp

创建文件夹结构和空文件后,项目文件夹结构应如下截图所示:

现在是时候开始将我们的 Gherkin 知识付诸实践了,因此,让我们首先从功能文件开始:

# language: en

Feature: Application should be able to print greeting message Hello BDD!

   Scenario: Should be able to greet with Hello BDD! message
      Given an instance of Hello class is created
      When the sayHello method is invoked
      Then it should return "Hello BDD!"

让我们看一下cucumber.wire文件:

host: localhost
port: 3902

由于 Cucumber 是用 Ruby 实现的,Cucumber 步骤的实现必须用 Ruby 编写。这种方法不鼓励在除 Ruby 以外的平台上使用 cucumber-cpp 框架的项目。 wire 协议是 cucumber-cpp 框架提供的解决方案,用于扩展 cucumber 对非 Ruby 平台的支持。基本上,每当 cucumber-cpp 框架执行测试用例时,它都会寻找步骤定义,但如果 Cucumber 找到一个.wire文件,它将连接到该 IP 地址和端口,以查询服务器是否有步骤描述文件中的定义。这有助于 Cucumber 支持除 Ruby 以外的许多平台。然而,Java 和.NET 都有本地的 Cucumber 实现:Cucumber-JVM 和 Specflow。因此,为了允许用 C++编写测试用例,cucumber-cpp 使用了 wire 协议。

现在让我们看看如何使用 gtest 框架编写步骤文件。

感谢 Google!Google 测试框架(gtest)包括 Google Mock 框架(gmock)。对于 C/C来说,gtest 框架是我遇到的最好的框架之一,因为它与 Java 的 JUnit 和 Mockito/PowerMock 提供的功能非常接近。对于相对现代的语言 Java 来说,与 C相比,借助反射支持模拟应该更容易,但从 C/C的角度来看,没有 C的反射功能,gtest/gmock 与 JUnit/TestNG/Mockito/PowerMock 毫不逊色。

您可以通过以下截图观察使用 gtest 编写的步骤文件:

以下头文件确保了包括编写 Cucumber 步骤所需的 gtest 头文件和 Cucumber 头文件:

#include <gtest/gtest.h>
#include <cucumber-cpp/autodetect.hpp>

现在让我们继续编写步骤:

struct HelloCtx {
     Hello *ptrHello;
     string actualResponse;
};

HelloCtx结构是一个用户定义的测试上下文,它保存了测试对象实例及其测试响应。cucumber-cpp 框架提供了一个智能的ScenarioScope类,允许我们在 Cucumber 测试场景的所有步骤中访问测试对象及其输出。

对于特征文件中编写的每个GivenWhenThen语句,步骤文件中都有一个相应的函数。与GivenWhenThen对应的适当的 cpp 函数是通过正则表达式进行映射的。

例如,考虑特征文件中的以下Given行:

Given an instance of Hello class is created

这对应于以下的 cpp 函数,它通过正则表达式进行映射。在正则表达式中的^字符意味着模式以an开头,$字符意味着模式以created结尾:

GIVEN("^an instance of Hello class is created$")
{
       ScenarioScope<HelloCtx> context;
       context->ptrHello = new Hello();
}

正如GIVEN步骤所说,在这一点上,我们必须确保创建一个Hello对象的实例;相应的 C++代码是在这个函数中编写的,用于实例化Hello类的对象。

同样,以下When步骤及其相应的 cpp 函数也由 cucumber-cpp 进行映射:

When the sayHello method is invoked

重要的是正则表达式要完全匹配;否则,cucumber-cpp 框架将报告找不到步骤函数:

WHEN("^the sayHello method is invoked$")
{
       ScenarioScope<HelloCtx> context;
       context->actualResponse = context->ptrHello->sayHello();
}

现在让我们来看一下Hello.h文件:

#include <iostream>
#include <string>
using namespace std;

class Hello {
public:
       string sayHello();
};

这是相应的源文件,即Hello.cpp

#include "Hello.h"

string Hello::sayHello() {
     return "Hello BDD!";
}

作为行业最佳实践,应该在源文件中包含其对应的头文件。其余所需的头文件应该放在对应源文件的头文件中。这有助于开发团队轻松定位头文件。BDD 不仅仅是关于测试自动化;预期的最终结果是干净、无缺陷和可维护的代码。

最后,让我们编写CMakeLists.txt

第一行意味着项目的名称。第三行确保了 Cucumber 头文件目录和我们项目的include_directoriesINCLUDE路径中。第五行基本上指示cmake实用程序从src文件夹中的文件创建一个库,即Hello.cpp和它的Hello.h文件。第七行检测我们的系统上是否安装了 gtest 框架,第八行确保编译HelloBDDSteps.cpp文件。最后,在第九行,将链接所有HelloBDD库,其中包含我们的生产代码、HelloBDDSteps对象文件和相应的 Cucumber 和 gtest 库文件。

将我们的项目集成到 cucumber-cpp 的 CMakeLists.txt 中

在我们开始构建项目之前,还有最后一个配置需要完成:

基本上,我已经注释了examples子目录,并在CMakeLists.txt中添加了我们的HelloBDD项目,该文件位于cucumber-cpp文件夹下,如前所示。

由于我们已经按照 cucumber-cpp 的最佳实践创建了项目,让我们转到HelloBDD项目主目录并发出以下命令:

cmake --build  build

注释add_subdirectory(examples)并不是强制性的。但是注释确实有助于我们专注于我们的项目。

以下截图显示了构建过程:

执行我们的测试用例

现在让我们执行测试用例。这涉及两个步骤,因为我们正在使用 wire 协议。首先让我们以后台模式启动测试用例可执行文件,然后启动 Cucumber,如下所示:

cmake --build build
build/HelloBDD/HelloBDDSteps > /dev/null &
cucumber HelloBDD

重定向到/dev/null并不是真正必要的。重定向到空设备的主要目的是避免应用程序在终端输出中打印语句时分散注意力。因此,这是个人偏好。如果您喜欢看到应用程序的调试或一般打印语句,请随时发出不带重定向的命令:

build/HelloBDD/HelloBDDSteps &

以下截图演示了构建和测试执行过程:

恭喜!我们的第一个 cucumber-cpp 测试用例已经通过。每个场景代表一个测试用例,测试用例包括三个步骤;由于所有步骤都通过了,因此将场景报告为通过。

dry run 您的 cucumber 测试用例

您是否想快速检查功能文件和步骤文件是否正确编写,而不是真正执行它们?Cucumber 有一个快速而酷炫的功能可以做到这一点:

build/HelloBDD/HelloBDDSteps > /dev/null &

这个命令将以后台模式执行我们的测试应用程序。/dev/null是 Linux 操作系统中的一个空设备,我们正在将HelloBDDSteps可执行文件中的所有不需要的打印语句重定向到空设备,以确保在执行 Cucumber 测试用例时不会分散我们的注意力。

下一个命令将允许我们 dry run Cucumber 测试场景:

cucumber --dry-run 

以下截图显示了测试执行:

BDD - 一种测试驱动的开发方法

就像 TDD 一样,BDD 也坚持遵循测试驱动的开发方法。因此,在本节中,让我们探讨如何以 BDD 方式遵循测试驱动的开发方法编写端到端功能!

让我们举一个简单的例子,帮助我们理解 BDD 编码风格。我们将编写一个RPNCalculator应用程序,它可以进行加法、减法、乘法、除法以及涉及许多数学运算的复杂数学表达式。

让我们按照 Cucumber 标准创建我们的项目文件夹结构:

mkdir RPNCalculator
cd RPNCalculator
cucumber --init
tree
mkdir src
tree

以下截图以可视化方式演示了该过程:

太好了!文件夹结构现在已经创建。现在,让我们使用 touch 实用程序创建空文件,以帮助我们可视化最终的项目文件夹结构以及文件:

touch features/rpncalculator.feature
touch features/step_definitions/RPNCalculatorSteps.cpp
touch features/step_definitions/cucumber.wire
touch src/RPNCalculator.h
touch src/RPNCalculator.cpp
touch CMakeLists.txt

一旦创建了虚拟文件,最终项目文件夹结构将如下截图所示:

像往常一样,Cucumber wire 文件将如下所示。实际上,在本章的整个过程中,该文件将保持不变:

host: localhost
port: 3902

现在,让我们从rpncalculator.feature文件开始,如下截图所示:

正如您所看到的,特性描述可能非常详细。您注意到了吗?我在场景的地方使用了Scenario OutlineScenario Outline的有趣之处在于它允许在Examples Cucumber 部分的表格中描述一组输入和相应的输出。

如果您熟悉 SCRUM,Cucumber 场景看起来是否与用户故事非常接近?是的,这就是想法。理想情况下,SCRUM 用户故事或用例可以编写为 Cucumber 场景。Cucumber 特性文件是一个可以执行的实时文档。

我们需要在cucumber-cpp主目录的CMakeLists.txt文件中添加我们的项目,如下所示:

确保RPNCalculator文件夹下的CMakeLists.txt如下所示:

现在,让我们使用cucumber-cpp主目录中的以下命令构建我们的项目:

cmake --build build

让我们使用以下命令执行我们全新的RPNCalculator Cucumber 测试用例:

build/RPNCalculator/RPNCalculatorSteps &

cucumber RPNCalculator

输出如下所示:

在上述截图中,我们为特征文件中编写的每个GivenWhenThen语句提供了两个建议。第一个版本适用于 Ruby,第二个版本适用于 C++;因此,我们可以安全地忽略步骤建议,如下所示:

Then(/^the actualResult should match the (d+).(d+)$/) do |arg1, arg2|
 pending # Write code here that turns the phrase above into concrete actions
end 

由于我们尚未实现RPNCalculatorSteps.cpp文件,Cucumber 框架建议我们为先前的步骤提供实现。让我们将它们复制粘贴到RPNCalculatorSteps.cpp文件中,并完成步骤的实现,如下所示:

REGEX_PARAMcucumber-cpp BDD 框架支持的宏,它非常方便地从正则表达式中提取输入参数并将它们传递给 Cucumber 步骤函数。

现在,让我们尝试使用以下命令再次构建我们的项目:

cmake --build  build

构建日志如下所示:

每个成功的开发人员或顾问背后的秘密公式是他们具有强大的调试和解决问题的能力。分析构建报告,特别是构建失败,是成功应用 BDD 所需的关键素质。每个构建错误都会教会我们一些东西!

构建错误很明显,因为我们尚未实现RPNCalculator,因为文件是空的。让我们编写最少的代码,使代码可以编译:

BDD 导致增量设计和开发,不同于瀑布模型。瀑布模型鼓励预先设计。通常,在瀑布模型中,设计是最初完成的,并且占整个项目工作量的 30-40%。预先设计的主要问题是我们最初对功能了解较少;通常,我们对功能了解模糊,但随着时间的推移,了解会得到改善。因此,在设计活动上投入更多精力并不是一个好主意;相反,要随时准备根据需要重构设计和代码。

因此,BDD 是复杂项目的自然选择。

有了这个最小的实现,让我们尝试构建和运行测试用例:

酷!由于代码编译无误,让我们现在执行测试用例并观察发生了什么:

错误以红色突出显示,如前面的截图所示,由 cucumber-cpp 框架。这是预期的;测试用例失败,因为RPNCalculator::evaluate方法被硬编码为返回0.0

理想情况下,我们只需要编写最少的代码使其通过,但我假设你在阅读当前章节之前已经阅读了第七章《测试驱动开发》。在那一章中,我详细演示了每一步,包括重构。

现在,让我们继续实现代码,使这个测试用例通过。修改后的RPNCalculator头文件如下所示:

相应的RPNCalculator源文件如下所示:

根据 BDD 实践,注意我们只实现了支持加法操作所需的代码,根据我们当前的黄瓜场景要求。与 TDD 一样,在 BDD 中,我们应该只编写满足当前场景的所需代码;这样,我们可以确保每一行代码都被有效的测试用例覆盖。

让我们构建并运行我们的 BDD 测试用例

现在让我们构建和测试。以下命令可用于分别构建、在后台运行步骤和运行带有 wire 协议的黄瓜测试用例:

cmake --build build
 build/RPNCalculator/RPNCalculatorSteps &

cucumber RPNCalculator

以下截图展示了构建和执行黄瓜测试用例的过程:

太棒了!我们的测试场景现在全部是绿色的!让我们继续进行下一个测试场景。

让我们在特性文件中添加一个场景来测试减法操作,如下所示:

测试输出如下:

我们之前见过这个,对吧?我相信你猜对了;预期结果是85,而实际结果是0,因为我们还没有添加减法的支持。现在,让我们在应用程序中添加必要的代码来添加减法逻辑:

通过这个代码更改,让我们重新运行测试用例,看看测试结果如何:

好的,测试报告又变成绿色了!

让我们继续在特性文件中添加一个场景来测试乘法操作:

是时候运行测试用例了,如下截图所示:

你猜对了;是的,我们需要在我们的生产代码中添加对乘法的支持。好的,让我们立刻做,如下截图所示:

现在是测试时间!

以下命令帮助您分别构建、启动步骤应用程序和运行黄瓜测试用例。准确地说,第一个命令构建测试用例,而第二个命令以后台模式启动 Cucumber 步骤测试可执行文件。第三个命令执行我们为RPNCalculator项目编写的 Cucumber 测试用例。RPNCalculatorSteps可执行文件将作为 Cucumber 可以通过 wire 协议与之通信的服务器。Cucumber 框架将从step_definitions文件夹下的cucumber.wire文件中获取服务器的连接详细信息:

cmake --build build
 build/RPNCalculator/RPNCalculatorSteps &

cucumber RPNCalculator

以下截图展示了黄瓜测试用例的执行过程:

我相信你已经掌握了 BDD!是的,BDD 非常简单和直接。现在让我们在特性文件中添加一个场景来测试除法操作,如下截图所示:

让我们快速运行测试用例,观察测试结果,如下截图所示:

是的,我听到你说你知道失败的原因。让我们快速添加对除法的支持,并重新运行测试用例,看看它是否全部变成绿色!BDD 让编码变得真正有趣。

我们需要在RPNCalculator.cpp中添加以下代码片段:

else if ( *token == "/" ) {
      secondNumber = numberStack.top();
      numberStack.pop();
      firstNumber = numberStack.top();
      numberStack.pop();

      result = firstNumber / secondNumber;

      numberStack.push ( result );
}

通过这个代码更改,让我们检查测试输出:

cmake --build build
build/RPNCalculator/RPNCalculatorSteps &
cucumber RPNCalculator

以下截图直观地演示了该过程:

到目前为止一切都很好。到目前为止,我们测试过的所有场景都通过了,这是一个好迹象。但让我们尝试一个涉及许多数学运算的复杂表达式。例如,让我们尝试10.0 5.0 * 1.0 + 100.0 2.0 / -

你知道吗?

逆波兰表达式(后缀表示法)被几乎每个编译器用来评估数学表达式。

以下截图演示了复杂表达式测试用例的集成:

让我们再次运行测试场景,因为这将是迄今为止实现的整个代码的真正测试,因为这个表达式涉及到我们简单应用程序支持的所有操作。

以下命令可用于在后台模式下启动应用程序并执行黄瓜测试用例:

build/RPNCalculator/RPNCalculatorSteps &
cucumber RPNCalculator

以下截图直观地演示了该过程:

太棒了!如果您已经走到这一步,我相信您已经了解了黄瓜 cpp 和 BDD 编码风格。

重构和消除代码异味

RPNCalculator.cpp代码有太多的分支,这是一个代码异味;因此,代码可以进行重构。好消息是RPNCalculator.cpp可以进行重构以消除代码异味,并且有使用工厂方法、策略和空对象设计模式的空间。

总结

在本章中,您学到了以下内容

  • 简而言之,行为驱动开发被称为 BDD。

  • BDD 是一种自顶向下的开发方法,并使用 Gherkin 语言作为特定领域语言(DSL)。

  • 在一个项目中,BDD 和 TDD 可以并行使用,因为它们互补而不是取代彼此。

  • 黄瓜 cpp BDD 框架利用 wire 协议来支持非 ruby 平台编写测试用例。

  • 通过以测试优先的开发方法实现 RPNCalculator,您以实际方式学习了 BDD。

  • BDD 类似于 TDD,它鼓励通过以增量方式短间隔重构代码来开发清晰的代码。

  • 您学会了使用 Gherkin 编写 BDD 测试用例以及使用 Google 测试框架定义步骤。

在下一章中,您将学习有关 C++调试技术。

第八章:代码异味和清晰代码实践

本章将涵盖以下主题:

  • 代码异味简介

  • 清晰代码的概念

  • 敏捷和清晰代码实践之间的关系

  • SOLID 设计原则

  • 代码重构

  • 将代码异味重构为清晰代码

  • 将代码异味重构为设计模式

清晰代码是功能上准确并且结构良好编写的源代码。通过彻底的测试,我们可以确保代码在功能上是正确的。我们可以通过代码自审、同行代码审查、代码分析,最重要的是通过代码重构来提高代码质量。

以下是一些清晰代码的特点:

  • 易于理解

  • 易于增强

  • 添加新功能不需要太多的代码更改

  • 易于重用

  • 不言自明

  • 在必要时进行评论

最后,编写清晰代码的最大好处是项目或产品中涉及的开发团队和客户都会感到满意。

代码重构

重构有助于提高源代码的结构质量。它不会修改代码的功能,只是改善代码的结构方面的质量。重构使代码更清晰,但有时它可能帮助您改善整体代码性能。但是,您需要明白性能调优与代码重构是不同的。

以下图表展示了开发过程概述:

如何安全地进行代码重构?答案如下:

  • 拥抱 DevOps

  • 适应测试驱动开发

  • 适应行为驱动开发

  • 使用验收测试驱动开发

代码异味

源代码有两个方面的质量,即功能结构。源代码的功能质量可以通过根据客户规格测试代码来实现。大多数开发人员犯的最大错误是他们倾向于在不重构代码的情况下将代码提交到版本控制软件;也就是说,他们一旦认为代码在功能上完成了,就提交代码。

事实上,将代码提交到版本控制通常是一个好习惯,因为这是持续集成和 DevOps 的基础。将代码提交到版本控制后,绝大多数开发人员忽视的是重构代码。重构代码以确保其清晰是非常关键的,没有清晰的代码,敏捷是不可能的。

看起来像面条(意指混乱)的代码需要更多的努力来增强或维护。因此,快速响应客户的请求实际上是不可能的。这就是为什么保持清晰代码对于敏捷至关重要。这适用于您组织中遵循的任何敏捷框架。

什么是敏捷?

敏捷就是快速失败。敏捷团队能够快速响应客户的需求,而不需要开发团队的任何花哨表演。团队使用的敏捷框架并不是很重要:Scrum、看板、XP 或其他框架。真正重要的是,你是否认真地遵循它们?

作为独立的软件顾问,我个人观察到并学习到一般是谁抱怨敏捷,以及为什么他们抱怨敏捷。

由于 Scrum 是最流行的敏捷框架之一,让我们假设一个产品公司,比如 ABC 科技私人有限公司,决定为他们计划开发的新产品采用 Scrum。好消息是,ABC 科技,就像大多数组织一样,也高效地举办冲刺计划会议、每日站立会议、冲刺回顾、冲刺总结和所有其他 Scrum 仪式。假设 ABC 科技已经确保他们的 Scrum 主管是 Scrum 认证的,产品经理是 Scrum 认证的产品负责人。太好了!到目前为止一切听起来都很好。

假设 ABC Tech 产品团队不使用 TDD、BDD、ATDD 和 DevOps。你认为 ABC Tech 产品团队是敏捷的吗?当然不是。事实上,开发团队将面临繁忙和不切实际的时间表,压力会很大。最终,团队将会非常高的离职率,因为团队不会开心。因此,客户也不会开心,产品的质量会受到严重影响。

你认为 ABC Tech 产品团队出了什么问题?

Scrum 有两套流程,即项目管理流程,由 Scrum 仪式覆盖。然后,还有流程的工程方面,大多数组织并不太关注。这可以从 IT 行业对Certified SCRUM Developer(CSD)认证的兴趣或认识中看出。IT 行业对 CSM、CSPO 或 CSP 的兴趣几乎没有对 CSD 的兴趣,而 CSD 对开发人员是必需的。然而,我不认为仅凭认证就能使某人成为专家;它只能显示个人或组织在接受敏捷框架并向客户交付高质量产品方面的严肃性。

除非代码保持清晰,开发团队如何能够快速响应客户的需求?换句话说,除非开发团队的工程师们在产品开发中采用 TDD、BDD、ATDD、持续集成和 DevOps,否则任何团队都无法在 Scrum 或其他敏捷框架中取得成功。

底线是,除非你的组织同等重视工程 Scrum 流程和项目管理 Scrum 流程,否则任何开发团队都不能声称在敏捷中取得成功。

SOLID 设计原则

SOLID 是一组重要的设计原则的首字母缩写,如果遵循,可以避免代码异味,并在结构和功能上提高代码质量。

如果你的软件架构符合 SOLID 设计原则,那么代码异味可以被预防或重构为清晰的代码。以下原则统称为 SOLID 设计原则:

  • 单一职责原则

  • 开闭原则

  • 里氏替换原则

  • 接口隔离

  • 依赖反转

最好的部分是,大多数设计模式也遵循并符合 SOLID 设计原则。

让我们在以下各节中逐一讨论前述设计原则。

单一职责原则

单一职责原则也简称为SRP。SRP 表示每个类必须只有一个责任。换句话说,每个类必须只代表一个对象。当一个类代表多个对象时,它往往会违反 SRP 并为多个代码异味打开机会。

例如,让我们以一个简单的Employee类为例,如下所示:

在前面的类图中,Employee类似乎代表了三个不同的对象:EmployeeAddressContact。因此,它违反了 SRP。根据这个原则,可以从前面的Employee类中提取出另外两个类,即AddressContact,如下所示:

为简单起见,本节中使用的类图不显示各自类支持的任何方法,因为我们的重点是通过一个简单的例子理解 SRP。

在前面重构的设计中,Employee 有一个或多个地址(个人和官方)和一个或多个联系人(个人和官方)。最好的部分是,在重构设计后,每个类都只抽象出一件事;也就是说,它只有一个责任。

开闭原则

当设计支持添加新功能而无需更改代码或不修改现有源代码时,架构或设计符合开闭原则OCP)。正如您所知,根据您的专业行业经验,您遇到的每个项目都以某种方式是可扩展的。这就是您能够向产品添加新功能的方式。然而,当这样的功能扩展是在您不修改现有代码的情况下完成时,设计将符合 OCP。

让我们以一个简单的Item类为例,如下所示的代码。为简单起见,Item类中只捕获了基本细节:

#include <iostream>
#include <string>
using namespace std;
class Item {
       private:
         string name;
         double quantity;
         double pricePerUnit;
       public:
         Item ( string name, double pricePerUnit, double quantity ) {
         this-name = name; 
         this->pricePerUnit = pricePerUnit;
         this->quantity = quantity;
    }
    public double getPrice( ) {
           return quantity * pricePerUnit;
    }
    public String getDescription( ) {
           return name;
    }
};

假设前面的Item类是一个小商店的简单结算应用程序的一部分。由于Item类将能够代表钢笔、计算器、巧克力、笔记本等,它足够通用,可以支持商店处理的任何可计费项目。然而,如果商店老板应该收取商品和服务税GST)或增值税VAT),现有的Item类似乎不支持税收组件。一种常见的方法是修改Item类以支持税收组件。然而,如果我们修改现有代码,我们的设计将不符合 OCP。

因此,让我们重构我们的设计,使用访问者设计模式使其符合 OCP。让我们探索重构的可能性,如下所示:

#ifndef __VISITABLE_H
#define __VISITABLE_H
#include <string>
 using namespace std;
class Visitor;

class Visitable {
 public:
        virtual void accept ( Visitor * ) = 0;
        virtual double getPrice() = 0;
        virtual string getDescription() = 0;
 };
#endif

Visitable类是一个带有三个纯虚函数的抽象类。Item类将继承Visitable抽象类,如下所示:

#ifndef __ITEM_H
#define __ITEM_H
#include <iostream>
#include <string>
using namespace std;
#include "Visitable.h"
#include "Visitor.h"
class Item : public Visitable {
 private:
       string name;
       double quantity;
       double unitPrice;
 public:
       Item ( string name, double quantity, double unitPrice );
       string getDescription();
       double getQuantity();
       double getPrice();
       void accept ( Visitor *pVisitor );
 };

 #endif

接下来,让我们看一下Visitor类,如下所示。它说未来可以实现任意数量的Visitor子类来添加新功能,而无需修改Item类:

class Visitable;
#ifndef __VISITOR_H
#define __VISITOR_H
class Visitor {
 protected:
 double price;

 public:
 virtual void visit ( Visitable * ) = 0;
 virtual double getPrice() = 0;
 };

 #endif

GSTVisitor类是让我们在不修改Item类的情况下添加 GST 功能的类。GSTVisitor的实现如下:

#include "GSTVisitor.h"

void GSTVisitor::visit ( Visitable *pItem ) {
     price = pItem->getPrice() + (0.18 * pItem->getPrice());
}

double GSTVisitor::getPrice() {
     return price;
}

Makefile如下所示:

all: GSTVisitor.o Item.o main.o
     g++ -o gst.exe GSTVisitor.o Item.o main.o

GSTVisitor.o: GSTVisitor.cpp Visitable.h Visitor.h
     g++ -c GSTVisitor.cpp

Item.o: Item.cpp
     g++ -c Item.cpp

main.o: main.cpp
     g++ -c main.cpp

重构后的设计符合 OCP,因为我们将能够在不修改Item类的情况下添加新功能。想象一下:如果 GST 计算随时间变化,我们将能够添加Visitor的新子类并应对即将到来的变化,而无需修改Item类。

Liskov 替换原则

Liskov 替换原则LSP)强调子类遵守基类建立的合同的重要性。在理想的继承层次结构中,随着设计重点向上移动类层次结构,我们应该注意泛化;随着设计重点向下移动类层次结构,我们应该注意专门化。

继承合同是两个类之间的,因此基类有责任制定所有子类都可以遵循的规则,一旦同意,子类同样有责任遵守合同。违背这些设计原则的设计将不符合 LSP。

LSP 说,如果一个方法以基类或接口作为参数,应该能够无条件地替换任何一个子类的实例。

事实上,继承违反了最基本的设计原则:继承是弱内聚和强耦合的。因此,继承的真正好处是多态性,而代码重用与继承相比是微不足道的好处。当 LSP 被违反时,我们无法用其子类实例替换基类实例,最糟糕的是我们无法多态地调用方法。尽管付出使用继承的设计代价,如果我们无法获得多态性的好处,就没有真正使用它的动机。

识别 LSP 违规的技术如下:

  • 子类将具有一个或多个带有空实现的重写方法。

  • 基类将具有专门的行为,这将迫使某些子类,无论这些专门行为是否符合子类的兴趣

  • 并非所有的通用方法都可以多态调用

以下是重构 LSP 违规的方法:

  • 将基类中的专门方法移动到需要这些专门行为的子类中。

  • 避免强迫模糊相关的类参与继承关系。除非子类是基本类型,否则不要仅仅为了代码重用而使用继承。

  • 不要寻找小的好处,比如代码重用,而是在可能的情况下寻找使用多态性或聚合或组合的方法。

接口隔离

接口隔离设计原则建议为特定目的建模许多小接口,而不是建模代表许多事物的一个更大的接口。在 C++中,具有纯虚函数的抽象类可以被视为一个接口。

让我们举一个简单的例子来理解接口隔离:

#include <iostream>
#include <string>
using namespace std;

class IEmployee {
      public:
          virtual string getDoor() = 0;
          virtual string getStreet() = 0;
          virtual string getCity() = 0;
          virtual string getPinCode() = 0;
          virtual string getState() = 0;
          virtual string getCountry() = 0;
          virtual string getName() = 0;
          virtual string getTitle() = 0;
          virtual string getCountryDialCode() = 0;
          virtual string getContactNumber() = 0;
};

在上面的例子中,抽象类展示了一个混乱的设计。设计混乱,因为它似乎代表了许多事物,比如员工、地址和联系方式。上述抽象类可以重构的一种方式是将单一接口分解为三个独立的接口:IEmployeeIAddressIContact。在 C++中,接口只是具有纯虚函数的抽象类:

#include <iostream>
#include <string>
#include <list>
using namespace std;

class IEmployee {
  private:
     string firstName, middleName, lastName,
     string title;
     string employeeCode;
     list<IAddress> addresses;
     list<IContact> contactNumbers;
  public:
     virtual string getAddress() = 0;
     virtual string getContactNumber() = 0;
};

class IAddress {
     private:
          string doorNo, street, city, pinCode, state, country;
     public:
          IAddress ( string doorNo, string street, string city, 
            string pinCode, string state, string country );
          virtual string getAddress() = 0;
};

class IContact {
      private:
           string countryCode, mobileNumber;
      public:
           IContact ( string countryCode, string mobileNumber );
           virtual string getMobileNumber() = 0;
};

在重构后的代码片段中,每个接口都代表一个对象,因此符合接口隔离设计原则。

依赖反转

一个好的设计将是高内聚和低耦合的。因此,我们的设计必须具有较少的依赖性。一个使代码依赖于许多其他对象或模块的设计被认为是一个糟糕的设计。如果依赖反转DI)被违反,那么发生在依赖模块中的任何变化都会对我们的模块产生不良影响,导致连锁反应。

让我们举一个简单的例子来理解 DI 的威力。Mobile类"拥有"一个Camera对象,并且注意到这种拥有的形式是组合。组合是一种独占所有权,其中Camera对象的生命周期由Mobile对象直接控制:

正如您在上图中所看到的,Mobile类具有Camera的实例,而使用的是组合的has a形式,这是一种独占所有权关系。

让我们来看一下Mobile类的实现,如下所示:

#include <iostream>
using namespace std;

class Mobile {
     private:
          Camera camera;
     public:
          Mobile ( );
          bool powerOn();
          bool powerOff();
};

class Camera {
      public:
          bool ON();
          bool OFF();
};

bool Mobile::powerOn() {
       if ( camera.ON() ) {
           cout << "nPositive Logic - assume some complex Mobile power ON logic happens here." << endl;
           return true;
       }
       cout << "nNegative Logic - assume some complex Mobile power OFF logic happens here." << endl;
            << endl;
       return false;
}

bool Mobile::powerOff() {
      if ( camera.OFF() ) {
              cout << "nPositive Logic - assume some complex Mobile power OFF             logic happens here." << endl;
      return true;
 }
      cout << "nNegative Logic - assume some complex Mobile power OFF logic happens here." << endl;
      return false;
}

bool Camera::ON() {
     cout << "nAssume Camera class interacts with Camera hardware heren" << endl;
     cout << "nAssume some Camera ON logic happens here" << endl;
     return true;
}

bool Camera::OFF() {
 cout << "nAssume Camera class interacts with Camera hardware heren" << endl;
 cout << "nAssume some Camera OFF logic happens here" << endl;
 return true;
}

在上述代码中,MobileCamera具有实现级别的了解,这是一个糟糕的设计。理想情况下,Mobile应该通过接口或具有纯虚函数的抽象类与Camera进行交互,因为这样可以将Camera的实现与其契约分离。这种方法有助于替换Camera而不影响Mobile,并且还可以支持一堆Camera子类来代替一个单一的相机。

想知道为什么它被称为依赖注入DI)或控制反转IOC)吗?之所以称之为依赖注入,是因为目前Camera的生命周期由Mobile对象控制;也就是说,CameraMobile对象实例化和销毁。在这种情况下,如果没有Camera,几乎不可能对Mobile进行单元测试,因为MobileCamera有硬性依赖。除非实现了Camera,否则无法测试Mobile的功能,这是一种糟糕的设计方法。当我们反转依赖时,它允许Mobile对象使用Camera对象,同时放弃控制Camera对象的生命周期的责任。这个过程被称为 IOC。优点是你将能够独立单元测试MobileCamera对象,它们由于 IOC 而具有强内聚性和松耦合性。

让我们用 DI 设计原则重构前面的代码:

#include <iostream>
using namespace std;

class ICamera {
 public:
 virtual bool ON() = 0;
 virtual bool OFF() = 0;
};

class Mobile {
      private:
 ICamera *pCamera;
      public:
 Mobile ( ICamera *pCamera );
            void setCamera( ICamera *pCamera ); 
            bool powerOn();
            bool powerOff();
};

class Camera : public ICamera {
public:
            bool ON();
            bool OFF();
};

//Constructor Dependency Injection
Mobile::Mobile ( ICamera *pCamera ) {
 this->pCamera = pCamera;
}

//Method Dependency Injection
Mobile::setCamera( ICamera *pCamera ) {
 this->pCamera = pCamera;
}

bool Mobile::powerOn() {
 if ( pCamera->ON() ) {
            cout << "nPositive Logic - assume some complex Mobile power ON logic happens here." << endl;
            return true;
      }
cout << "nNegative Logic - assume some complex Mobile power OFF logic happens here." << endl;
<< endl;
      return false;
}

bool Mobile::powerOff() {
 if ( pCamera->OFF() ) {
           cout << "nPositive Logic - assume some complex Mobile power OFF logic happens here." << endl;
           return true;
}
      cout << "nNegative Logic - assume some complex Mobile power OFF logic happens here." << endl;
      return false;
}

bool Camera::ON() {
       cout << "nAssume Camera class interacts with Camera hardware heren" << endl;
       cout << "nAssume some Camera ON logic happens here" << endl;
       return true;
}

bool Camera::OFF() {
       cout << "nAssume Camera class interacts with Camera hardware heren" << endl;
       cout << "nAssume some Camera OFF logic happens here" << endl;
       return true;
}

在前面的代码片段中,变化用粗体标出。IOC 是一种非常强大的技术,它让我们解耦依赖,正如刚才演示的;然而,它的实现非常简单。

代码异味

代码异味是一个用来指代缺乏结构质量的代码片段的术语;然而,这段代码可能在功能上是正确的。代码异味违反了 SOLID 设计原则,因此必须认真对待,因为编写不好的代码会导致长期的高昂维护成本。然而,代码异味可以重构为干净的代码。

注释异味

作为一名独立的软件顾问,我有很多机会与优秀的开发人员、架构师、质量保证人员、系统管理员、首席技术官和首席执行官、企业家等进行交流和学习。每当我们的讨论涉及到“什么是干净的代码或好的代码?”这个十亿美元的问题时,我几乎在全球范围内得到了一个共同的回答,“好的代码将会有良好的注释。”虽然这部分是正确的,但问题也正是从这里开始。理想情况下,干净的代码应该是不言自明的,不需要注释。然而,有些情况下,注释可以提高整体的可读性和可维护性。并非所有的注释都是代码异味,因此有必要区分好的注释和坏的注释。看一下以下代码片段:

if ( condition1 ) {
     // some block of code
}
else if ( condition2 ) {
     // some block of code
}
else {
     // OOPS - the control should not reach here ### Code Smell ###
}

我相信你也遇到过这种类型的注释。毋庸置疑,前面的情况是代码异味。理想情况下,开发人员应该重构代码来修复错误,而不是写这样的注释。有一次我在深夜调试一个关键问题,我注意到控制流程到达了一个神秘的空代码块,里面只有一个注释。我相信你也遇到过更有趣的代码,并能想象它带来的挫败感;有时,你也会写这种类型的代码。

一个好的注释将表达代码以特定方式编写的原因,而不是表达代码如何做某事。传达代码如何做某事的注释是代码异味,而传达代码为什么这样做的注释是好的注释,因为代码没有表达为什么部分;因此,好的注释提供了附加值。

长方法

当一个方法被确定具有多个责任时,它就变得很长。自然而然,代码超过 20-25 行的方法往往具有多个责任。话虽如此,代码行数更多的方法就更长。这并不意味着代码行数少于 25 行的方法就不长。看一下以下代码片段:

void Employee::validateAndSave( ) {
        if ( ( street != "" ) && ( city != "" ) )
              saveEmployeeDetails();
}

显然,前面的方法具有多个责任;也就是说,它似乎在验证和保存细节。虽然在保存之前进行验证并没有错,但同一个方法不应该同时做这两件事。因此,前面的方法可以重构为两个具有单一责任的较小方法:

private:
void Employee::validateAddress( ) {
     if ( ( street == "" ) || ( city == "" ) )
          throw exception("Invalid Address");
}

public:
void Employee::save() {
      validateAddress();
}

在前面的代码中,每个重构后的方法都只负责一个责任。将validateAddress()方法作为谓词方法可能很诱人;也就是说,一个返回布尔值的方法。然而,如果validateAddress()被写成谓词方法,那么客户端代码将被迫进行if检查,这是一个代码异味。通过返回错误代码来处理错误不被认为是面向对象的代码,因此必须使用 C++异常来处理错误。

长参数列表

一个面向对象的方法接收较少的参数,因为一个设计良好的对象将具有较强的内聚性和较松散的耦合性。接收太多参数的方法是一个症状,表明做出决定所需的知识是外部接收的,这意味着当前对象本身没有所有的知识来做出决定。

这意味着当前对象的内聚性较弱,耦合性较强,因为它过于依赖外部数据来做决定。成员函数通常倾向于接收较少的参数,因为它们需要的数据成员通常是成员变量。因此,将成员变量传递给成员函数的需求听起来是不自然的。

让我们看看一个方法倾向于接收太多参数的常见原因。最常见的症状和原因在这里列出:

  • 对象的内聚性较弱,耦合性较强;也就是说,它过于依赖其他对象

  • 这是一个静态方法

  • 这是一个放错位置的方法;也就是说,它不属于那个对象

  • 这不是面向对象的代码

  • SRP 被违反

以下是重构长参数列表(LPL)的方法:

  • 避免分散提取和传递数据;考虑传递整个对象,让方法提取所需的细节

  • 识别提供参数给接收 LPL 方法的对象,并考虑将方法移动到那里

  • 对参数列表进行分组,创建一个参数对象,并将接收 LPL 的方法移到新对象中

重复的代码

重复的代码是一个常见的代码异味,不需要太多解释。光是复制和粘贴代码文化本身就不能完全怪罪重复的代码。重复的代码使得代码维护更加繁琐,因为相同的问题可能需要在多个地方修复,而集成新功能需要太多的代码更改,这往往会破坏意外的功能。重复的代码还会增加应用程序的二进制占用空间,因此必须重构为清晰的代码。

条件复杂性

条件复杂性代码异味是关于复杂的大条件,随着时间的推移往往变得更大更复杂。这种代码异味可以通过策略设计模式来重构。由于策略设计模式涉及许多相关的对象,因此可以使用工厂方法,并且空对象设计模式可以用于处理工厂方法中不支持的子类:

//Before refactoring
void SomeClass::someMethod( ) {
      if (  ! conition1 && condition2 )
         //perform some logic
      else if ( ! condition3 && condition4 && condition5 )
         //perform some logic
      else
         //do something 
} 

//After refactoring
void SomeClass::someMethod() {
     if ( privateMethod1() )
          //perform some logic
     else if ( privateMethod2() )
          //perform some logic
     else
         //do something
}

大类

一个大类代码异味使得代码难以理解,更难以维护。一个大类可能做了太多事情。大类可以通过将其分解为单一职责的小类来重构。

死代码

死代码是被注释掉或者从未被使用或集成的代码。它可以通过代码覆盖工具来检测。通常,开发人员由于缺乏信心而保留这些代码实例,这在遗留代码中更常见。由于每个代码都在版本控制软件工具中被跟踪,死代码可以被删除,如果需要的话,总是可以从版本控制软件中检索回来。

原始执念

原始执念(PO)是一个错误的设计选择:使用原始数据类型来表示复杂的领域实体。例如,如果使用字符串数据类型来表示日期,虽然起初听起来像一个聪明的主意,但从长远来看,它会带来很多维护麻烦。

假设您使用字符串数据类型表示日期,则以下问题将是一个挑战:

  • 您需要根据日期对事物进行排序

  • 引入字符串后,日期算术将变得非常复杂

  • 根据区域设置支持各种日期格式将变得复杂,使用字符串

理想情况下,日期必须由类表示,而不是原始数据类型。

数据类

数据类仅提供 getter 和 setter 函数。虽然它们非常适合将数据从一层传输到另一层,但它们往往会给依赖于数据类的类带来负担。由于数据类不会提供任何有用的功能,与数据类交互或依赖的类最终会使用数据类的数据添加功能。这样,围绕数据类的类违反了 SRP 并且往往会变成一个庞大的类。

特征嫉妒

如果某些类对其他类的内部细节了解过多,则被称为特征嫉妒。通常,当其他类是数据类时,就会发生这种情况。代码异味是相互关联的;消除一个代码异味往往会吸引其他代码异味。

摘要

在本章中,您学习了以下主题:

  • 代码异味和重构代码的重要性

  • SOLID 设计原则:

  • 单一责任原则

  • 开闭原则

  • 里氏替换

  • 接口隔离

  • 依赖注入

  • 各种代码异味:

  • 注释异味

  • 长方法

  • 长参数列表

  • 重复代码

  • 条件复杂性

  • 大类

  • 死代码

  • 面向对象的代码异味:原始执念

  • 数据类

  • 特征嫉妒

您还学习了许多重构技术,这将帮助您保持代码更清晰。愉快编码!

第九章:精通 C++多线程

编写健壮、并发和并行应用程序

第十章:重新审视多线程

如果你正在阅读这本书,很可能你已经在 C中进行了一些多线程编程,或者可能是其他语言。本章旨在从 C的角度纯粹回顾这个主题,通过一个基本的多线程应用程序,同时涵盖我们将在整本书中使用的工具。在本章结束时,你将拥有继续阅读后续章节所需的所有知识和信息。

本章涵盖的主题包括以下内容:

  • 使用本机 API 进行 C++的基本多线程

  • 编写基本的 makefile 和使用 GCC/MinGW

  • 使用make编译程序并在命令行上执行

入门

在本书的过程中,我们将假设使用基于 GCC 的工具链(Windows 上的 GCC 或 MinGW)。如果您希望使用其他工具链(如 clang、MSVC、ICC 等),请查阅这些工具链提供的文档以获取兼容的命令。

为了编译本书提供的示例,将使用 makefile。对于不熟悉 makefile 的人来说,它们是一种简单但功能强大的基于文本的格式,用于与make工具一起自动化构建任务,包括编译源代码和调整构建环境。make于 1977 年首次发布,至今仍然是最受欢迎的构建自动化工具之一。

假设读者熟悉命令行(Bash 或等效工具),推荐使用 MSYS2(Windows 上的 Bash)。

多线程应用程序

在其最基本的形式中,多线程应用程序由一个进程和两个或多个线程组成。这些线程可以以各种方式使用;例如,通过使用一个线程来处理每个传入事件或事件类型,使进程能够以异步方式响应事件,或者通过将工作分配给多个线程来加快数据处理速度。

对事件的异步响应的示例包括在单独的线程上处理图形用户界面(GUI)和网络事件,以便两种类型的事件不必等待对方,或者可以阻止事件及时得到响应。通常,一个线程执行一个任务,比如处理 GUI 或网络事件,或者处理数据。

对于这个基本示例,应用程序将从一个单一线程开始,然后启动多个线程,并等待它们完成。每个新线程在完成之前都会执行自己的任务。

让我们从我们应用程序的包含和全局变量开始:

#include <iostream>
#include <thread>
#include <mutex>
#include <vector>
#include <random>

using namespace std;

// --- Globals
mutex values_mtx;
mutex cout_mtx;
vector<int> values;

I/O 流和向量头文件对于任何使用过 C++的人来说应该是熟悉的:前者在这里用于标准输出(cout),后者用于存储一系列值。

c++11中的 random 头文件是新的,正如其名称所示,它提供了用于生成随机序列的类和方法。我们在这里使用它来使我们的线程做一些有趣的事情。

最后,线程和互斥锁的包含是我们多线程应用程序的核心;它们提供了创建线程的基本手段,并允许它们之间进行线程安全的交互。

接着,我们创建两个互斥锁:一个用于全局向量,另一个用于cout,因为后者不是线程安全的。

接下来,我们创建主函数如下:

int main() {
    values.push_back(42);

我们将一个固定的值推送到向量实例中;这个值将在我们创建的线程中使用:

    thread tr1(threadFnc, 1);
    thread tr2(threadFnc, 2);
    thread tr3(threadFnc, 3);
    thread tr4(threadFnc, 4);

我们创建新线程,并为它们提供要使用的方法的名称,同时传递任何参数--在这种情况下,只是一个整数:


    tr1.join();
    tr2.join();
    tr3.join();
    tr4.join();

接下来,我们等待每个线程完成,然后继续调用每个线程实例上的join()


    cout << "Input: " << values[0] << ", Result 1: " << values[1] << ", Result 2: " << values[2] << ", Result 3: " << values[3] << ", Result 4: " << values[4] << "n";

    return 1;
}

在这一点上,我们期望每个线程都已经完成了它应该做的事情,并将结果添加到向量中,然后我们读取并向用户显示。

当然,这几乎没有显示应用程序中实际发生的事情,主要是使用线程的基本简单性。接下来,让我们看看我们传递给每个线程实例的方法内部发生了什么:

void threadFnc(int tid) {
    cout_mtx.lock();
    cout << "Starting thread " << tid << ".n";
    cout_mtx.unlock();

在前面的代码中,我们可以看到传递给线程方法的整数参数是线程标识符。为了表示线程正在启动,输出包含线程标识符的消息。由于我们为此使用了非线程安全方法,因此我们使用cout_mtx互斥实例来安全地执行此操作,确保只有一个线程可以随时写入cout

    values_mtx.lock();
    int val = values[0];
    values_mtx.unlock();

当我们获取向量中设置的初始值时,我们将其复制到一个局部变量中,以便我们可以立即释放向量的互斥锁,以便其他线程可以使用该向量:

    int rval = randGen(0, 10);
    val += rval;

最后两行包含了创建的线程所做的实质性内容:它们获取初始值,并将随机生成的值添加到其中。randGen()方法接受两个参数,定义返回值的范围:


    cout_mtx.lock();
    cout << "Thread " << tid << " adding " << rval << ". New value: " << val << ".n";
    cout_mtx.unlock();

    values_mtx.lock();
    values.push_back(val);
    values_mtx.unlock();
}

最后,我们(安全地)记录一条消息,通知用户此操作的结果,然后将新值添加到向量中。在这两种情况下,我们使用相应的互斥锁来确保在使用任何其他线程访问资源时不会发生重叠。

一旦方法达到这一点,包含它的线程将终止,主线程将少一个要等待重新加入的线程。线程的加入基本上意味着它停止存在,通常会将返回值传递给创建线程的线程。这可以明确地发生,主线程等待子线程完成,或者在后台进行。

最后,让我们来看看randGen()方法。在这里,我们还可以看到一些多线程特定的添加内容:

int randGen(const int& min, const int& max) {
    static thread_local mt19937 generator(hash<thread::id>()(this_thread::get_id()));
    uniform_int_distribution<int> distribution(min, max);
    return distribution(generator)
}

前面的方法接受一个最小值和一个最大值,如前所述,这限制了该方法可以返回的随机数的范围。在其核心,它使用基于 mt19937 的generator,它采用了一个具有 19937 位状态大小的 32 位Mersenne Twister算法。这对大多数应用来说是一个常见且合适的选择。

这里需要注意的是thread_local关键字的使用。这意味着即使它被定义为静态变量,其范围也将被限制在使用它的线程中。因此,每个线程都将创建自己的generator实例,在使用 STL 中的随机数 API 时这一点很重要。

内部线程标识符的哈希被用作generator的种子。这确保每个线程都为其generator实例获得一个相当独特的种子,从而获得更好的随机数序列。

最后,我们使用提供的最小和最大限制创建一个新的uniform_int_distribution实例,并与generator实例一起使用它来生成随机数,然后将其返回。

Makefile

为了编译前面描述的代码,可以使用集成开发环境,也可以在命令行上输入命令。正如本章开头提到的,我们将在本书的示例中使用 makefile。这样做的最大优势是不必反复输入相同的复杂命令,并且可以在支持make的任何系统上使用。

其他优点包括能够自动删除先前生成的工件,并且只编译那些已更改的源文件,以及对构建步骤有详细的控制。

这个示例的 makefile 相当基本:

GCC := g++

OUTPUT := ch01_mt_example
SOURCES := $(wildcard *.cpp)
CCFLAGS := -std=c++11 -pthread

all: $(OUTPUT)

$(OUTPUT):
    $(GCC) -o $(OUTPUT) $(CCFLAGS) $(SOURCES)

clean:
    rm $(OUTPUT)

.PHONY: all

从上到下,我们首先定义我们将使用的编译器(g++),设置输出二进制文件的名称(在 Windows 上的.exe扩展名将自动添加后缀),然后收集源文件和任何重要的编译器标志。

通配符功能允许一次性收集与其后的字符串匹配的所有文件的名称,而无需单独定义文件夹中每个源文件的名称。

对于编译器标志,我们只对启用c++11功能感兴趣,对于这一点,GCC 仍然需要用户提供这个编译器标志。

对于all方法,我们只需告诉make使用提供的信息运行g++。接下来,我们定义一个简单的清理方法,只需删除生成的二进制文件,最后,我们告诉make不要解释文件夹或文件夹中名为all的文件,而是使用带有.PHONY部分的内部方法。

当我们运行这个 makefile 时,我们会看到以下命令行输出:

$ make
g++ -o ch01_mt_example -std=c++11 ch01_mt_example.cpp

之后,我们会在同一个文件夹中找到一个名为ch01_mt_example的可执行文件(在 Windows 上附加了.exe扩展名)。执行这个二进制文件将会产生类似以下的命令行输出:

$ ./ch01_mt_example.exe

Starting thread 1.

Thread 1 adding 8\. New value: 50.

Starting thread 2.

Thread 2 adding 2\. New value: 44.

Starting thread 3.

Starting thread 4.

Thread 3 adding 0\. New value: 42.

Thread 4 adding 8\. New value: 50.

Input: 42, Result 1: 50, Result 2: 44, Result 3: 42, Result 4: 50

在这里可以看到线程及其输出的异步特性。虽然线程12似乎是同步运行的,按顺序启动和退出,但线程34显然是异步运行的,因为它们在记录动作之前同时启动。因此,特别是在运行时间较长的线程中,几乎不可能确定日志输出和结果将以何种顺序返回。

虽然我们使用一个简单的向量来收集线程的结果,但我们无法确定“结果 1”是否真的来自我们在开始时分配 ID 为 1 的线程。如果我们需要这些信息,我们需要通过使用带有处理线程或类似信息的详细信息结构来扩展我们返回的数据。

例如,可以像这样使用struct

struct result {
    int tid;
    int result;
};

然后,向量将被更改为包含结果实例而不是整数实例。可以直接将初始整数值作为其参数之一直接传递给线程,或者通过其他方式传递它。

其他应用程序

本章的示例主要适用于需要并行处理数据或任务的应用程序。对于前面提到的基于 GUI 的应用程序,具有业务逻辑和网络相关功能,启动所需的线程的基本设置将保持不变。但是,每个线程都将是完全不同的方法,而不是每个线程都相同。

对于这种类型的应用程序,线程布局将如下所示:

正如图表所示,主线程将启动 GUI、网络和业务逻辑线程,后者将与网络线程通信以发送和接收数据。业务逻辑线程还将从 GUI 线程接收用户输入,并发送更新以在 GUI 上显示。

总结

在本章中,我们讨论了使用本地线程 API 在 C++中实现多线程应用程序的基础知识。我们看了如何让多个线程并行执行任务,还探讨了如何在多线程应用程序中正确使用 STL 中的随机数 API。

在下一章中,我们将讨论多线程是如何在硬件和操作系统中实现的。我们将看到这种实现如何根据处理器架构和操作系统而异,以及这如何影响我们的多线程应用程序。

第十一章:处理器和操作系统上的多线程实现

任何多线程应用程序的基础都是由处理器的硬件实现所需功能以及这些功能如何被操作系统转换为应用程序使用的 API 所形成的。了解这个基础对于开发对多线程应用程序的最佳实现方式至关重要。

本章涵盖的主题包括以下内容:

  • 操作系统如何改变使用这些硬件功能

  • 各种架构中内存安全和内存模型背后的概念

  • 操作系统的各种进程和线程模型之间的差异

  • 并发

介绍 POSIX pthreads

Unix、Linux 和 macOS 在很大程度上符合 POSIX 标准。Unix 可移植操作系统接口POSIX)是一个 IEEE 标准,它帮助所有 Unix 和类 Unix 操作系统,即 Linux 和 macOS,通过一个统一的接口进行通信。

有趣的是,POSIX 也受到符合 POSIX 的工具的支持--Cygwin、MinGW 和 Windows 子系统用于 Linux--它们在 Windows 平台上提供了一个伪 Unix 样的运行时和开发环境。

请注意,pthread 是一个在 Unix、Linux 和 macOS 中使用的符合 POSIX 标准的 C 库。从 C11 开始,C通过 C线程支持库和并发库本地支持线程。在本章中,我们将了解如何以面向对象的方式使用 pthread、线程支持和并发库。此外,我们将讨论使用本地 C线程支持和并发库与使用 POSIX pthreads 或其他第三方线程框架的优点。

使用 pthread 库创建线程

让我们直入主题。您需要了解我们将讨论的 pthread API,以便开始动手。首先,此函数用于创建一个新线程:

 #include <pthread.h>
 int pthread_create(
              pthread_t *thread,
              const pthread_attr_t *attr,
              void *(*start_routine)(void*),
              void *arg
 )

以下表格简要解释了前述函数中使用的参数:

API 参数 注释
pthread_t *thread 线程句柄指针
pthread_attr_t *attr 线程属性
void *(*start_routine)(void*) 线程函数指针
void * arg 线程参数

此函数会阻塞调用线程,直到第一个参数中传递的线程退出,如下所示:

int pthread_join ( pthread_t *thread, void **retval )

以下表格简要描述了前述函数中的参数:

API 参数 注释
pthread_t thread 线程句柄
void **retval 输出参数,指示线程过程的退出代码

接下来的函数应该在线程上下文中使用。在这里,retval 是调用此函数的线程的退出代码,表示调用此函数的线程的退出代码:

int pthread_exit ( void *retval )

这是在此函数中使用的参数:

API 参数 注释
void *retval 线程过程的退出代码

以下函数返回线程 ID:

pthread_t pthread_self(void)

让我们编写我们的第一个多线程应用程序:

#include <pthread.h>
#include <iostream>

using namespace std;

void* threadProc ( void *param ) {
  for (int count=0; count<3; ++count)
    cout << "Message " << count << " from " << pthread_self()
         << endl;
  pthread_exit(0);
}

int main() {
  pthread_t thread1, thread2, thread3;

  pthread_create ( &thread1, NULL, threadProc, NULL );
  pthread_create ( &thread2, NULL, threadProc, NULL );
  pthread_create ( &thread3, NULL, threadProc, NULL );

  pthread_join( thread1, NULL );
  pthread_join( thread2, NULL );

  pthread_join( thread3, NULL );

  return 0;

}

如何编译和运行

该程序可以使用以下命令编译:

g++ main.cpp -lpthread

如您所见,我们需要动态链接 POSIX pthread 库。

查看以下截图并可视化多线程程序的输出:

在 ThreadProc 中编写的代码在线程上下文中运行。前面的程序总共有四个线程,包括主线程。我使用pthread_join阻塞了主线程,强制它等待其他三个线程先完成它们的任务,否则主线程会在它们之前退出。当主线程退出时,应用程序也会退出,这会过早地销毁新创建的线程。

尽管我们按照相应的顺序创建了thread1thread2thread3,但不能保证它们将按照创建的确切顺序启动。

操作系统调度程序根据操作系统调度程序使用的算法决定必须启动线程的顺序。有趣的是,在同一系统的不同运行中,线程启动的顺序可能会有所不同。

C++是否本地支持线程?

从 C11 开始,C确实本地支持线程,并且通常被称为 C线程支持库。C线程支持库提供了对 POSIX pthreads C 库的抽象。随着时间的推移,C++本机线程支持已经得到了很大的改善。

我强烈建议您使用 C本机线程而不是 pthread。C线程支持库在所有平台上都受支持,因为它是标准 C++的正式部分,而不是仅在 Unix、Linux 和 macOS 上直接支持的 POSIX pthread库。

最好的部分是 C17 中的线程支持已经成熟到了一个新的水平,并且有望在 C20 中达到下一个水平。因此,在项目中考虑使用 C++线程支持库是一个好主意。

定义进程和线程

基本上,对于操作系统OS),进程由一个或多个线程组成,每个线程处理自己的状态和变量。可以将其视为分层配置,操作系统作为基础,为(用户)进程的运行提供支持。然后,每个进程由一个或多个线程组成。进程之间的通信由操作系统提供的进程间通信IPC)处理。

在图形视图中,这看起来像下面的样子:

操作系统中的每个进程都有自己的状态,进程中的每个线程也有自己的状态,与该进程中的其他线程相关。虽然 IPC 允许进程彼此通信,但线程可以以各种方式与同一进程中的其他线程通信,我们将在接下来的章节中更深入地探讨这一点。这通常涉及线程之间的某种共享内存。

应用程序是从二进制数据中加载的,格式为特定的可执行文件格式,例如可执行和可链接格式ELF),通常用于 Linux 和许多其他操作系统。对于 ELF 二进制文件,应始终存在以下数量的部分:

  • .bss

  • .data

  • .rodata

  • .text

.bss部分基本上是使用未初始化的内存分配的,包括空数组,因此在二进制文件中不占用任何空间,因为在可执行文件中存储纯零行没有意义。类似地,还有.data部分包含初始化数据。其中包含全局表、变量等。最后,.rodata部分类似于.data,但正如其名称所示,是只读的。其中包含硬编码的字符串。

.text部分,我们找到实际的应用程序指令(代码),这些指令将由处理器执行。整个这些将被操作系统加载,从而创建一个进程。这样的进程布局看起来像下面的图表:

这是从 ELF 格式二进制文件启动时进程的样子,尽管在内存中的最终格式在任何操作系统中基本上都是一样的,包括从 PE 格式二进制文件启动的 Windows 进程。二进制文件中的每个部分都加载到它们各自的部分中,BSS 部分分配给指定的大小。.text部分与其他部分一起加载,并且一旦完成,将执行其初始指令,从而启动进程。

在诸如 C++之类的系统语言中,可以看到在这样的进程中,变量和其他程序状态信息是如何存储在堆栈(变量存在于作用域内)和堆(使用 new 运算符)中的。堆栈是内存的一部分(每个线程分配一个),其大小取决于操作系统及其配置。在创建新线程时,通常也可以通过编程方式设置堆栈大小。

在操作系统中,一个进程由一块内存地址组成,其大小是恒定的,并受其内存指针的大小限制。对于 32 位操作系统,这将限制该块为 4GB。在这个虚拟内存空间中,操作系统分配了一个基本的堆栈和堆,两者都可以增长,直到所有内存地址都被耗尽,并且进程进一步尝试分配更多内存将被拒绝。

堆栈对于操作系统和硬件都是一个概念。本质上,它是一组所谓的堆栈帧(stack frames),每个堆栈帧由与任务的执行框架相关的变量、指令和其他数据组成。

在硬件术语中,堆栈是任务(x86)或进程状态(ARM)的一部分,这是处理器定义执行实例(程序或线程)的方式。这个硬件定义的实体包含了一个线程的整个执行状态。有关此内容的更多详细信息,请参见以下各节。

x86(32 位和 64 位)中的任务

在 Intel IA-32 系统编程指南第 3A 卷中,任务定义如下:

“任务是处理器可以分派、执行和挂起的工作单元。它可以用于执行程序、任务或进程、操作系统服务实用程序、中断或异常处理程序,或内核或执行实用程序。”

“IA-32 架构提供了一种保存任务状态、分派任务执行以及从一个任务切换到另一个任务的机制。在保护模式下,所有处理器执行都是从一个任务中进行的。即使是简单的系统也必须定义至少一个任务。更复杂的系统可以使用处理器的任务管理设施来支持多任务应用程序。”

IA-32(Intel x86)手册中的这段摘录总结了硬件如何支持和实现对操作系统、进程以及这些进程之间的切换的支持。

在这里重要的是要意识到,对于处理器来说,没有进程或线程这样的东西。它所知道的只是执行线程,定义为一系列指令。这些指令被加载到内存的某个地方,并且当前位置在这些指令中以及正在创建的变量数据(变量)的跟踪,作为应用程序在进程的数据部分中执行。

每个任务还在硬件定义的保护环中运行,OS 的任务通常在环 0 上运行,用户任务在环 3 上运行。环 1 和 2 很少使用,除了在 x86 架构的现代操作系统中的特定用例。这些环是硬件强制执行的特权级别,例如严格分离内核和用户级任务。

32 位和 64 位任务的任务结构在概念上非常相似。它的官方名称是任务状态结构TSS)。它对 32 位 x86 CPU 的布局如下:

以下是字段:

  • SS0:第一个堆栈段选择器字段

  • ESP0:第一个 SP 字段

对于 64 位 x86_64 CPU,TSS 布局看起来有些不同,因为在这种模式下不支持基于硬件的任务切换:

在这里,我们有类似的相关字段,只是名称不同:

  • RSPn:特权级别 0 到 2 的 SP

  • ISTn:中断堆栈表指针

即使在 32 位模式下,x86 CPU 支持任务之间的硬件切换,大多数操作系统仍然会在每个 CPU 上使用单个 TSS 结构,而不管模式如何,并且在软件中实际执行任务之间的切换。这在一定程度上是由于效率原因(仅交换变化的指针),部分原因是由于只有这种方式才可能的功能,例如测量进程/线程使用的 CPU 时间,并调整线程或进程的优先级。在软件中执行这些操作还简化了代码在 64 位和 32 位系统之间的可移植性,因为前者不支持基于硬件的任务切换。

在软件基础的任务切换(通常通过中断)期间,ESP/RSP 等存储在内存中,并用下一个计划任务的值替换。这意味着一旦执行恢复,TSS 结构现在将具有新任务的堆栈指针SP),段指针,寄存器内容和所有其他细节。

中断的来源可以是硬件或软件。硬件中断通常由设备使用,以向 CPU 发出它们需要操作系统关注的信号。调用硬件中断的行为称为中断请求,或 IRQ。

软件中断可能是由 CPU 本身的异常条件引起的,也可能是 CPU 指令集的特性。操作系统内核通过触发软件中断来执行任务切换的操作。

ARM 中的进程状态

在 ARM 架构中,应用程序通常在非特权的异常级别 0EL0)级别上运行,这与 x86 架构上的 ring 3 相当,而 OS 内核在 EL1 级别上。ARMv7(AArch32,32 位)架构将 SP 放在通用寄存器 13 中。对于 ARMv8(AArch64,64 位),每个异常级别都实现了专用的 SP 寄存器:SP_EL0SP_EL1等。

对于 ARM 架构的任务状态,使用程序状态寄存器PSR)实例来表示当前程序状态寄存器CPSR)或保存的程序状态寄存器SPSR)的程序状态寄存器。PSR 是进程状态PSTATE)的一部分,它是进程状态信息的抽象。

虽然 ARM 架构与 x86 架构有很大不同,但在使用基于软件的任务切换时,基本原则并未改变:保存当前任务的 SP,寄存器状态,并在恢复处理之前将下一个任务的详细信息放在其中。

堆栈

正如我们在前面的部分中看到的,堆栈与 CPU 寄存器一起定义了一个任务。正如前面提到的,这个堆栈由堆栈帧组成,每个堆栈帧定义了该特定任务执行实例的(局部)变量,参数,数据和指令。值得注意的是,尽管堆栈和堆栈帧主要是软件概念,但它是任何现代操作系统的重要特性,在许多 CPU 指令集中都有硬件支持。从图形上看,可以像下面这样进行可视化:

SP(x86 上的 ESP)指向堆栈顶部,另一个指针(x86 上的扩展基指针EBP))。每个帧包含对前一个帧的引用(调用者返回地址),由操作系统设置。

当使用调试器调试 C++应用程序时,当请求回溯时,基本上就是看到了堆栈的各个帧--显示了一直到当前帧的初始堆栈帧。在这里,可以检查每个单独帧的细节。

多线程定义

在过去的几十年中,与计算机处理任务方式相关的许多不同术语已经被创造并广泛使用。其中许多术语也被交替使用,正确与否。其中一个例子是多线程与多处理的比较。

在这里,后者意味着在具有多个物理处理器的系统中每个处理器运行一个任务,而前者意味着在单个处理器上同时运行多个任务,从而产生它们都在同时执行的错觉:

多处理和多任务之间的另一个有趣区别是,后者使用时间片来在单个处理器核心上运行多个线程。这与多线程不同,因为在多任务系统中,没有任务会在同一个 CPU 核心上并发运行,尽管任务仍然可以被中断。

从软件的角度来看,进程和进程内的线程之间共享的内存空间的概念是多线程系统的核心。尽管硬件通常不知道这一点--只看到操作系统中的单个任务。但是,这样的多线程进程包含两个或多个线程。然后,每个线程执行自己的一系列任务。

在其他实现中,例如英特尔的 x86 处理器上的超线程HT),这种多线程是在硬件中实现的,通常被称为 SMT(有关详细信息,请参见*同时多线程(SMT)*部分)。当启用 HT 时,每个物理 CPU 核心被呈现给操作系统为两个核心。硬件本身将尝试同时执行分配给这些所谓的虚拟核心的任务,调度可以同时使用处理核心的不同元素的操作。实际上,这可以在不需要操作系统或应用程序进行任何类型的优化的情况下显着提高性能。

当然,操作系统仍然可以进行自己的调度,以进一步优化任务的执行,因为硬件对执行的指令的许多细节并不了解。

启用 HT 的外观如下所示:

在上述图形中,我们看到内存(RAM)中四个不同任务的指令。其中两个任务(线程)正在同时执行,CPU 的调度程序(在前端)试图安排指令,以便尽可能多地并行执行指令。在这种情况下不可能的情况下,执行硬件空闲时会出现所谓的流水线气泡(白色)。

与内部 CPU 优化一起,这导致指令的吞吐量非常高,也称为每秒指令数IPC)。与 CPU 的 GHz 评级不同,这个 IPC 数字通常更重要,用于确定 CPU 的性能。

弗林分类法

不同类型的计算机架构使用一种系统进行分类,这个系统最早是由迈克尔·J·弗林在 1966 年提出的。这个分类系统有四个类别,根据处理硬件的输入和输出流的数量来定义处理硬件的能力:

  • 单指令,单数据SISD):获取单个指令来操作单个数据流。这是 CPU 的传统模型。

  • 单指令,多数据SIMD):使用这种模型,单个指令可以并行操作多个数据流。这是矢量处理器(如图形处理单元GPU))使用的模型。

  • 多指令,单数据MISD):这种模型最常用于冗余系统,通过不同的处理单元对相同的数据执行相同的操作,最终验证结果以检测硬件故障。这通常由航空电子系统等使用。

  • 多指令,多数据MIMD):对于这种模型,多处理系统非常适用。多个处理器上的多个线程处理多个数据流。这些线程不是相同的,就像 SIMD 的情况一样。

需要注意的一点是,这些类别都是根据多处理来定义的,这意味着它们指的是硬件的固有能力。使用软件技术,几乎可以在常规的 SISD 架构上近似任何方法。然而,这也是多线程的一部分。

对称与非对称多处理

在过去的几十年里,许多系统都包含了多个处理单元。这些可以大致分为对称多处理(SMP)和非对称多处理(AMP)系统。

AMP 的主要特征是第二个处理器作为外围连接到主 CPU。这意味着它不能运行控制软件,而只能运行用户应用程序。这种方法也被用于连接使用不同架构的 CPU,以允许在 Amiga、68k 系统上运行 x86 应用程序,例如。

在 SMP 系统中,每个 CPU 都是对等的,可以访问相同的硬件资源,并以合作的方式设置。最初,SMP 系统涉及多个物理 CPU,但后来,多个处理器核心集成在单个 CPU 芯片上:

随着多核 CPU 的普及,SMP 是嵌入式开发之外最常见的处理类型,而在嵌入式开发中,单处理(单核,单处理器)仍然非常常见。

从技术上讲,系统中的声音、网络和图形处理器可以被认为是与 CPU 相关的非对称处理器。随着通用 GPU 处理的增加,AMP 变得更加相关。

松散和紧密耦合的多处理

多处理系统不一定要在单个系统内实现,而可以由多个连接在网络中的系统组成。这样的集群被称为松散耦合的多处理系统。我们将在第九章《分布式计算中的多线程》中介绍分布式计算。

这与紧密耦合的多处理系统形成对比,紧密耦合的多处理系统是指系统集成在单个印刷电路板(PCB)上,使用相同的低级别、高速总线或类似的方式。

将多处理与多线程结合

几乎任何现代系统都结合了多处理和多线程,这要归功于多核 CPU,它在单个处理器芯片上结合了两个或更多处理核心。对于操作系统来说,这意味着它必须在多个处理核心之间调度任务,同时也必须在特定核心上调度它们,以提取最大性能。

这是任务调度器的领域,我们将在一会儿看一下。可以说这是一个值得一本书的话题。

多线程类型

与多处理一样,多线程也不是单一的实现,而是两种主要的实现。它们之间的主要区别是处理器在单个周期内可以同时执行的线程的最大数量。多线程实现的主要目标是尽可能接近 100%的处理器硬件利用率。多线程利用线程级和进程级并行性来实现这一目标。

有两种类型的多线程,我们将在以下部分进行介绍。

时间多线程

也被称为超线程,时间多线程(TMT)的主要子类型是粗粒度和细粒度(或交错)。前者在不同任务之间快速切换,保存每个任务的上下文,然后切换到另一个任务的上下文。后者在每个周期中切换任务,导致 CPU 流水线包含来自各种任务的指令,从中得到“交错”这个术语。

细粒度类型是在桶处理器中实现的。它们比 x86 和其他架构有优势,因为它们可以保证特定的定时(对于硬实时嵌入式系统很有用),而且由于可以做出一些假设,实现起来更不复杂。

同时多线程(SMT)

SMT 是在超标量 CPU 上实现的(实现指令级并行性),其中包括 x86 和 ARM 架构。SMT 的定义特征也由其名称指出,特别是其能够在每个核心中并行执行多个线程的能力。

通常,每个核心有两个线程是常见的,但一些设计支持每个核心最多八个并发线程。这样做的主要优势是能够在线程之间共享资源,明显的缺点是多个线程的冲突需求,这必须加以管理。另一个优势是由于缺乏硬件资源复制,使得结果 CPU 更加节能。

英特尔的 HT 技术本质上是英特尔的 SMT 实现,从 2002 年的一些奔腾 4 CPU 开始提供基本的双线程 SMT 引擎。

调度程序

存在许多任务调度算法,每个算法都专注于不同的目标。有些可能寻求最大化吞吐量,其他人则寻求最小化延迟,而另一些可能寻求最大化响应时间。哪种调度程序是最佳选择完全取决于系统所用于的应用。

对于桌面系统,调度程序通常尽可能保持通用,通常优先处理前台应用程序,以便为用户提供最佳的桌面体验。

对于嵌入式系统,特别是在实时工业应用中,通常会寻求保证定时。这允许进程在恰好正确的时间执行,这在驱动机械、机器人或化工过程中至关重要,即使延迟几毫秒也可能造成巨大成本甚至是致命的。

调度程序类型还取决于操作系统的多任务状态——合作式多任务系统无法提供关于何时可以切换运行中进程的许多保证,因为这取决于活动进程何时让出。

使用抢占式调度程序,进程在不知情的情况下进行切换,允许调度程序更多地控制进程在哪个时间点运行。

基于 Windows NT 的操作系统(Windows NT、2000、XP 等)使用所谓的多级反馈队列,具有 32 个优先级级别。这种类型的优先级调度程序允许优先处理某些任务,从而使结果体验得到精细调整。

Linux 最初(内核 2.4)也使用了基于多级反馈队列的优先级调度程序,类似于 Windows NT 的 O(n)调度程序。从 2.6 版本开始,这被 O(1)调度程序取代,允许在恒定时间内安排进程。从 Linux 内核 2.6.23 开始,默认调度程序是完全公平调度程序CFS),它确保所有任务获得可比较的 CPU 时间份额。

以下是一些常用或知名操作系统使用的调度算法类型:

操作系统 抢占 算法
Amiga OS 优先级轮转调度
FreeBSD 多级反馈队列
Linux kernel 2.6.0 之前 多级反馈队列
Linux kernel 2.6.0-2.6.23 O(1)调度程序
Linux kernel 2.6.23 之后 完全公平调度程序
经典 Mac OS 9 之前 合作调度程序
Mac OS 9 一些 用于 MP 任务的抢占式调度程序,以及用于进程和线程的合作调度程序
OS X/macOS 多级反馈队列
NetBSD 多级反馈队列
Solaris 多级反馈队列
Windows 3.1x 合作调度程序
Windows 95, 98, Me Half 32 位进程的抢占式调度程序,16 位进程的协作式调度程序
Windows NT(包括 2000、XP、Vista、7 和 Server) 多级反馈队列

(来源:[en.wikipedia.org/wiki/Scheduling_(computing)](https://en.wikipedia.org/wiki/Scheduling_(computing)))

抢占式列指示调度程序是否是抢占式的,下一列提供了更多细节。可以看到,抢占式调度程序非常常见,并且被所有现代桌面操作系统使用。

跟踪演示应用程序

在第一章“重温多线程”的演示代码中,我们看了一个简单的c++11应用程序,它使用四个线程来执行一些处理。在本节中,我们将从硬件和操作系统的角度来看同一个应用程序。

当我们查看main函数中代码的开头时,我们看到我们创建了一个包含单个(整数)值的数据结构:

int main() {
    values.push_back(42);

在操作系统创建新任务和相关的堆栈结构之后,堆栈上分配了一个向量数据结构的实例(针对整数类型进行了定制)。这个大小在二进制文件的全局数据部分(ELF 的 BSS)中指定。

当应用程序使用其入口函数(默认为main())启动执行时,数据结构被修改为包含新的整数值。

接下来,我们创建四个线程,为每个线程提供一些初始数据:

    thread tr1(threadFnc, 1);
    thread tr2(threadFnc, 2);
    thread tr3(threadFnc, 3);
    thread tr4(threadFnc, 4);

对于操作系统来说,这意味着创建新的数据结构,并为每个新线程分配一个堆栈。对于硬件来说,如果不使用基于硬件的任务切换,最初不会改变任何东西。

在这一点上,操作系统的调度程序和 CPU 可以结合起来尽可能高效和快速地执行这组任务(线程),利用硬件的特性,包括 SMP、SMT 等等。

之后,主线程等待,直到其他线程停止执行:

    tr1.join();
    tr2.join();
    tr3.join();
    tr4.join();

这些是阻塞调用,它们标记主线程被阻塞,直到这四个线程(任务)完成执行。在这一点上,操作系统的调度程序将恢复主线程的执行。

在每个新创建的线程中,我们首先在标准输出上输出一个字符串,确保我们锁定互斥锁以确保同步访问:

void threadFnc(int tid) {
    cout_mtx.lock();
    cout << "Starting thread " << tid << ".n";
    cout_mtx.unlock();

互斥锁本质上是一个存储在堆栈或堆上的单个值,然后使用原子操作访问。这意味着需要某种形式的硬件支持。使用这个,任务可以检查它是否被允许继续,或者必须等待并再次尝试。

在代码的最后一个特定部分,这个互斥锁允许我们在标准的 C++输出流上输出,而不会受到其他线程的干扰。

之后,我们将向量中的初始值复制到一个局部变量中,再次确保它是同步完成的:

    values_mtx.lock();
    int val = values[0];
    values_mtx.unlock();

在这里发生的事情是一样的,只是现在互斥锁允许我们读取向量中的第一个值,而不会在我们使用它时冒险另一个线程访问或甚至更改它。

接下来是生成随机数如下:

    int rval = randGen(0, 10);
    val += rval;

这使用了randGen()方法,如下所示:

int randGen(const int& min, const int& max) {
    static thread_local mt19937 generator(hash<thread::id>() (this_thread::get_id()));
    uniform_int_distribution<int> distribution(min, max);
    return distribution(generator);
}

这种方法之所以有趣,是因为它使用了线程本地变量。线程本地存储是线程特定的内存部分,用于全局变量,但必须保持限制在特定线程内。

这对于像这里使用的静态变量非常有用。generator实例是静态的,因为我们不希望每次使用这个方法时都重新初始化它,但我们也不希望在所有线程之间共享这个实例。通过使用线程本地的静态实例,我们可以实现这两个目标。静态实例被创建和使用,但对于每个线程是分开的。

Thread函数最后以相同的一系列互斥锁结束,并将新值复制到数组中。

    cout_mtx.lock();
    cout << "Thread " << tid << " adding " << rval << ". New value: " << val << ".n";
    cout_mtx.unlock();

    values_mtx.lock();
    values.push_back(val);
    values_mtx.unlock();
}

在这里,我们看到对标准输出流的同步访问,然后是对值数据结构的同步访问。

互斥锁实现

互斥排斥是多线程应用程序中数据的线程安全访问的基本原则。可以在硬件和软件中都实现这一点。互斥排斥mutex)是大多数实现中这种功能的最基本形式。

硬件

在单处理器(单处理器核心),非 SMT 系统上最简单的基于硬件的实现是禁用中断,从而防止任务被更改。更常见的是采用所谓的忙等待原则。这是互斥锁背后的基本原理--由于处理器如何获取数据,只有一个任务可以获取和读/写共享内存中的原子值,这意味着,一个变量的大小与 CPU 的寄存器相同(或更小)。这在第十五章中进一步详细说明,原子操作 - 与硬件一起工作

当我们的代码尝试锁定互斥锁时,它所做的是读取这样一个原子内存区域的值,并尝试将其设置为其锁定值。由于这是一个单操作,因此在任何给定时间只有一个任务可以更改该值。其他任务将不得不等待,直到它们可以在这个忙等待周期中获得访问,如图所示:

软件

软件定义的互斥锁实现都基于忙等待。一个例子是Dekker算法,它定义了一个系统,其中两个进程可以同步,利用忙等待等待另一个进程离开临界区。

该算法的伪代码如下:

    variables
        wants_to_enter : array of 2 booleans
        turn : integer

    wants_to_enter[0] ← false
    wants_to_enter[1] ← false
    turn ← 0 // or 1
p0:
    wants_to_enter[0] ← true
    while wants_to_enter[1] {
        if turn ≠ 0 {
            wants_to_enter[0] ← false
            while turn ≠ 0 {
                // busy wait
            }
            wants_to_enter[0] ← true
        }
    }
    // critical section
    ...
    turn ← 1
    wants_to_enter[0] ← false
    // remainder section
p1:
    wants_to_enter[1] ← true
    while wants_to_enter[0] {
        if turn ≠ 1 {
            wants_to_enter[1] ← false
            while turn ≠ 1 {
                // busy wait
            }
            wants_to_enter[1] ← true
        }
    }
    // critical section
    ...
    turn ← 0
    wants_to_enter[1] ← false
    // remainder section

(引用自:en.wikipedia.org/wiki/Dekker's_algorithm)

在前面的算法中,进程指示意图进入临界区,检查是否轮到它们(使用进程 ID),然后在它们进入后将它们的意图设置为 false。只有一旦进程再次将其意图设置为 true,它才会再次进入临界区。如果它希望进入,但turn与其进程 ID 不匹配,它将忙等待,直到条件变为真。

基于软件的互斥排斥算法的一个主要缺点是,它们只在禁用乱序OoO)执行代码时才起作用。OoO 意味着硬件积极重新排序传入的指令,以优化它们的执行,从而改变它们的顺序。由于这些算法要求各种步骤按顺序执行,因此它们不再适用于 OoO 处理器。

并发性

每种现代编程语言都支持并发性,提供高级 API,允许同时执行许多任务。C支持并发性,从 C11 开始,更复杂的 API 在 C14 和 C17 中进一步添加。虽然 C++线程支持库允许多线程,但它需要编写复杂的同步代码;然而,并发性让我们能够执行独立的任务--甚至循环迭代可以在不编写复杂代码的情况下并发运行。底线是,并行化通过并发性变得更加容易。

并发支持库是 C线程支持库的补充。这两个强大库的结合使用使并发编程在 C中变得更加容易。

让我们在以下名为main.cpp的文件中使用 C++并发编写一个简单的Hello World程序:

#include <iostream>
#include <future>
using namespace std;

void sayHello( ) {
  cout << endl << "Hello Concurrency support library!" << endl;
}

int main ( ) {
  future<void> futureObj = async ( launch::async, sayHello );
  futureObj.wait( );

  return 0;
}

让我们试着理解main()函数。Future 是并发模块的一个对象,它帮助调用者函数以异步的方式检索线程传递的消息。future<void>中的 void 表示sayHello()线程函数不会向调用者即main线程函数传递任何消息。async类让我们以launch::asynclaunch::deferred模式执行函数。

launch::async模式允许async对象在单独的线程中启动sayHello()方法,而launch::deferred模式允许async对象在不创建单独线程的情况下调用sayHello()函数。在launch::deferred模式下,直到调用者线程调用future::get()方法之前,sayHello()方法的调用将不同。

futureObj.wait()方法用于阻塞主线程,让sayHello()函数完成其任务。future::wait()函数类似于线程支持库中的thread::join()

如何编译和运行

让我们继续使用以下命令编译程序:

g++ main.cpp -o concurrency.exe -std=c++17 -lpthread

让我们启动concurrency.exe,并了解它的工作原理:

使用并发支持库进行异步消息传递

让我们稍微修改main.cpp,即我们在上一节中编写的 Hello World 程序。让我们了解如何从Thread函数异步地向调用者函数传递消息:

#include <iostream>
#include <future>
using namespace std;

void sayHello( promise<string> promise_ ) {
  promise_.set_value ( "Hello Concurrency support library!" );
}

int main ( ) {
  promise<string> promiseObj;

  future<string> futureObj = promiseObj.get_future( );
  async ( launch::async, sayHello, move( promiseObj ) );
  cout << futureObj.get( ) << endl;

  return 0;
}

在上一个程序中,promiseObjsayHello()线程函数用来异步地向主线程传递消息。注意promise<string>意味着sayHello()函数预期传递一个字符串消息,因此主线程检索future<string>future::get()函数调用将被阻塞,直到sayHello()线程函数调用promise::set_value()方法。

然而,重要的是要理解future::get()只能被调用一次,因为在调用future::get()方法之后,相应的promise对象将被销毁。

你注意到了std::move()函数的使用吗?std::move()函数基本上将promiseObj的所有权转移给了sayHello()线程函数,因此在调用std::move()后,promiseObj不能从main线程中访问。

如何编译和运行

让我们继续使用以下命令编译程序:

g++ main.cpp -o concurrency.exe -std=c++17 -lpthread

通过启动concurrency.exe应用程序来观察concurrency.exe应用程序的工作情况:

正如你可能已经猜到的那样,这个程序的输出与我们之前的版本完全相同。但是这个程序的版本使用了 promise 和 future 对象,而之前的版本不支持消息传递。

并发任务

并发支持模块支持一个称为task的概念。任务是跨线程并发发生的工作。可以使用packaged_task类创建并发任务。packaged_task类方便地连接了thread函数、相应的 promise 和 feature 对象。

让我们通过一个简单的例子来了解packaged_task的用法。以下程序让我们有机会尝试一些函数式编程的味道,使用 lambda 表达式和函数:

#include <iostream>
#include <future>
#include <promise>
#include <thread>
#include <functional>
using namespace std;

int main ( ) {
     packaged_task<int (int, int)>
        addTask ( [] ( int firstInput, int secondInput ) {
              return firstInput + secondInput;
     } );

     future<int> output = addTask.get_future( );
     addTask ( 15, 10 );

     cout << "The sum of 15 + 10 is " << output.get() << endl;
     return 0;
}

在之前展示的程序中,我创建了一个名为addTaskpackaged_task实例。packaged_task< int (int,int)>实例意味着 add 任务将返回一个整数并接受两个整数参数:

addTask ( [] ( int firstInput, int secondInput ) {
              return firstInput + secondInput;
}); 

前面的代码片段表明它是一个匿名定义的 lambda 函数。

有趣的是,main.cpp中的addTask()调用看起来像是普通的函数调用。future<int>对象是从packaged_task实例addTask中提取出来的,然后用于通过 future 对象实例检索addTask的输出,即get()方法。

如何编译和运行

让我们继续使用以下命令编译程序:

g++ main.cpp -o concurrency.exe -std=c++17 -lpthread

让我们快速启动concurrency.exe并观察下面显示的输出:

太棒了!您学会了如何在并发支持库中使用 lambda 函数。

使用带有线程支持库的任务

在上一节中,您学会了如何以一种优雅的方式使用packaged_task。我非常喜欢 lambda 函数。它们看起来很像数学。但并不是每个人都喜欢 lambda 函数,因为它们在一定程度上降低了可读性。因此,如果您不喜欢 lambda 函数,使用并发任务时不一定要使用 lambda 函数。在本节中,您将了解如何使用线程支持库的并发任务,如下所示:

#include <iostream>
#include <future>
#include <thread>
#include <functional>
using namespace std;

int add ( int firstInput, int secondInput ) {
  return firstInput + secondInput;
}

int main ( ) {
  packaged_task<int (int, int)> addTask( add);

  future<int> output = addTask.get_future( );

  thread addThread ( move(addTask), 15, 10 );

  addThread.join( );

  cout << "The sum of 15 + 10 is " << output.get() << endl;

  return 0;
}

如何编译和运行

让我们继续使用以下命令编译程序:

g++ main.cpp -o concurrency.exe -std=c++17 -lpthread

让我们启动concurrency.exe,如下截图所示,并了解前一个程序和当前版本之间的区别:

是的,输出与上一节相同,因为我们只是重构了代码。

太棒了!您刚刚学会了如何将 C++线程支持库与并发组件集成。

将线程过程及其输入绑定到 packaged_task

在本节中,您将学习如何将thread函数及其相应的参数与packaged_task绑定。

让我们从上一节的代码中取出并修改以了解绑定功能,如下所示:

#include <iostream>
#include <future>
#include <string>
using namespace std;

int add ( int firstInput, int secondInput ) {
  return firstInput + secondInput;
}

int main ( ) {

  packaged_task<int (int,int)> addTask( add );
  future<int> output = addTask.get_future();
  thread addThread ( move(addTask), 15, 10);
  addThread.join();
  cout << "The sum of 15 + 10 is " << output.get() << endl;
  return 0;
}

std::bind()函数将thread函数及其参数与相应的任务绑定。由于参数是预先绑定的,因此无需再次提供输入参数 15 或 10。这些是packaged_task在 C++中可以使用的一些便利方式。

如何编译和运行

让我们继续使用以下命令编译程序:

g++ main.cpp -o concurrency.exe -std=c++17 -lpthread

让我们启动concurrency.exe,如下截图所示,并了解前一个程序和当前版本之间的区别:

恭喜!到目前为止,您已经学到了很多关于 C++并发的知识。

使用并发库处理异常

并发支持库还支持通过 future 对象传递异常。

让我们通过一个简单的例子了解异常并发处理机制,如下所示:

#include <iostream>
#include <future>
#include <promise>
using namespace std;

void add ( int firstInput, int secondInput, promise<int> output ) {
  try {
         if ( ( INT_MAX == firstInput ) || ( INT_MAX == secondInput ) )
             output.set_exception( current_exception() ) ;
        }
  catch(...) {}

       output.set_value( firstInput + secondInput ) ;

}

int main ( ) {

     try {
    promise<int> promise_;
          future<int> output = promise_.get_future();
    async ( launch::deferred, add, INT_MAX, INT_MAX, move(promise_) );
          cout << "The sum of INT_MAX + INT_MAX is " << output.get ( ) << endl;
     }
     catch( exception e ) {
  cerr << "Exception occured" << endl;
     }
}

就像我们将输出消息传递给调用者函数/线程一样,并发支持库还允许您设置任务或异步函数中发生的异常。当调用线程调用future::get()方法时,相同的异常将被抛出,因此异常通信变得很容易。

如何编译和运行

让我们继续使用以下命令编译程序。叔叔水果和尤达的麦芽:

g++ main.cpp -o concurrency.exe -std=c++17 -lpthread

您学到了什么?

让我总结一下要点:

  • 并发支持库提供了高级组件,可以并发执行多个任务

  • Future 对象让调用线程检索异步函数的输出

  • 承诺对象由异步函数用于设置输出或异常

  • FUTUREPROMISE对象的类型必须与异步函数设置的值的类型相同

  • 并发组件可以与 C++线程支持库无缝结合使用

  • Lambda 函数和表达式可以与并发支持库一起使用

总结

在本章中,我们看到了进程和线程是如何在操作系统和硬件中实现的。我们还研究了处理器硬件的各种配置以及调度中涉及的操作系统元素,以了解它们如何提供各种类型的任务处理。

最后,我们拿上一章的多线程程序示例,再次运行它,这次考虑的是在执行过程中操作系统和处理器发生了什么。

在下一章中,我们将看一下通过操作系统和基于库的实现提供的各种多线程 API,以及比较这些 API 的示例。

第十二章:C++多线程 API

虽然 C++在标准模板库(STL)中有本地的多线程实现,但基于操作系统和框架的多线程 API 仍然非常常见。这些 API 的示例包括 Windows 和 POSIX(可移植操作系统接口)线程,以及由 Qt、Boost 和 POCO 库提供的线程。

本章将详细介绍每个 API 提供的功能,以及它们之间的相似之处和不同之处。最后,我们将使用示例代码来查看常见的使用场景。

本章涵盖的主题包括以下内容:

  • 可用多线程 API 的比较

  • 每个 API 的使用示例

API 概述

在 C++ 2011(C++11)标准之前,开发了许多不同的线程实现,其中许多限于特定的软件平台。其中一些至今仍然相关,例如 Windows 线程。其他已被标准取代,其中 POSIX 线程(Pthreads)已成为类 UNIX 操作系统的事实标准。这包括基于 Linux 和 BSD 的操作系统,以及 OS X(macOS)和 Solaris。

许多库被开发出来,以使跨平台开发更容易。尽管 Pthreads 有助于使类 UNIX 操作系统更或多或少兼容,但要使软件在所有主要操作系统上可移植,需要一个通用的线程 API。这就是为什么创建了 Boost、POCO 和 Qt 等库。应用程序可以使用这些库,并依赖于库来处理平台之间的任何差异。

POSIX 线程

Pthreads 最初是在 1995 年的 POSIX.1c 标准(线程扩展,IEEE Std 1003.1c-1995)中定义的,作为 POSIX 标准的扩展。当时,UNIX 被选择为制造商中立的接口,POSIX 统一了它们之间的各种 API。

尽管有这种标准化的努力,Pthread 在实现它的操作系统之间仍然存在差异(例如,在 Linux 和 OS X 之间),这是由于不可移植的扩展(在方法名称中标有 _np)。

对于 pthread_setname_np 方法,Linux 实现需要两个参数,允许设置除当前线程以外的线程名称。在 OS X(自 10.6 起),此方法只需要一个参数,允许设置当前线程的名称。如果可移植性是一个问题,就必须注意这样的差异。

1997 年后,POSIX 标准修订由 Austin 联合工作组管理。这些修订将线程扩展合并到主标准中。当前的修订是 7,也称为 POSIX.1-2008 和 IEEE Std 1003.1,2013 版--标准的免费副本可在线获得。

操作系统可以获得符合 POSIX 标准的认证。目前,这些如下表所述:

名称 开发者 自版本 架构(当前) 备注
AIX IBM 5L POWER 服务器操作系统
HP-UX Hewlett-Packard 11i v3 PA-RISC, IA-64 (Itanium) 服务器操作系统
IRIX Silicon Graphics (SGI) 6 MIPS 已停产
Inspur K-UX Inspur 2 X86_64, 基于 Linux
Integrity Green Hills Software 5 ARM, XScale, Blackfin, Freescale Coldfire, MIPS, PowerPC, x86。 实时操作系统
OS X/MacOS Apple 10.5 (Leopard) X86_64 桌面操作系统
QNX Neutrino BlackBerry 1 Intel 8088, x86, MIPS, PowerPC, SH-4, ARM, StrongARM, XScale 实时,嵌入式操作系统
Solaris Sun/Oracle 2.5 SPARC, IA-32 (<11), x86_64, PowerPC (2.5.1) 服务器操作系统
Tru64 DEC, HP, IBM, Compaq 5.1B-4 Alpha 已停产
UnixWare Novell, SCO, Xinuos 7.1.3 x86 服务器操作系统

其他操作系统大多是兼容的。以下是相同的示例:

名称 平台 备注
Android ARM, x86, MIPS 基于 Linux。Bionic C 库。
BeOS (Haiku) IA-32, ARM, x64_64 限于 x86 的 GCC 2.x。
Darwin PowerPC,x86,ARM 使用 macOS 基于的开源组件。
FreeBSD IA-32,x86_64,sparc64,PowerPC,ARM,MIPS 等 基本上符合 POSIX。可以依赖已记录的 POSIX 行为。一般来说,比 Linux 更严格地遵守规范。
Linux Alpha,ARC,ARM,AVR32,Blackfin,H8/300,Itanium,m68k,Microblaze,MIPS,Nios II,OpenRISC,PA-RISC,PowerPC,s390,S+core,SuperH,SPARC,x86,Xtensa 等 一些 Linux 发行版(见前表)被认证为符合 POSIX。这并不意味着每个 Linux 发行版都符合 POSIX。一些工具和库可能与标准不同。对于 Pthreads,这可能意味着在 Linux 发行版之间(不同的调度程序等)以及与实现 Pthreads 的其他操作系统之间的行为有时会有所不同。
MINIX 3 IA-32,ARM 符合 POSIX 规范标准 3(SUSv3,2004 年)。
NetBSD Alpha,ARM,PA-RISC,68k,MIPS,PowerPC,SH3,SPARC,RISC-V,VAX,x86 等 几乎完全兼容 POSX.1(1990),并且大部分符合 POSIX.2(1992)。
核心 RTOS ARM,MIPS,PowerPC,Nios II,MicroBlaze,SuperH 等 Mentor Graphics 的专有 RTOS,旨在嵌入式应用。
NuttX ARM,AVR,AVR32,HCS12,SuperH,Z80 等 轻量级 RTOS,可在 8 到 32 位系统上扩展,专注于 POSIX 兼容性。
OpenBSD Alpha,x86_64,ARM,PA-RISC,IA-32,MIPS,PowerPC,SPARC 等 1995 年从 NetBSD 分叉出来。类似的 POSIX 支持。
OpenSolaris/illumos IA-32,x86_64,SPARC,ARM 与商业 Solaris 发行版兼容认证。
VxWorks ARM,SH-4,x86,x86_64,MIPS,PowerPC 符合 POSIX,并获得用户模式执行环境的认证。

由此可见,遵循 POSIX 规范并不是一件明显的事情,也不能指望自己的代码在每个平台上都能编译。每个平台还将有其自己的标准扩展,用于标准中省略的但仍然有用的功能。然而,Pthreads 在 Linux、BSD 和类似软件中被广泛使用。

Windows 支持

也可以使用 POSIX API,例如以下方式:

名称 兼容性
Cygwin 大部分完整。为 POSIX 应用程序提供完整的运行时环境,可以作为普通的 Windows 应用程序分发。
MinGW 使用 MinGW-w64(MinGW 的重新开发),Pthreads 支持相当完整,尽管可能会缺少一些功能。
Windows Subsystem for Linux WSL 是 Windows 10 的一个功能,允许 Ubuntu Linux 14.04(64 位)镜像的工具和实用程序在其上本地运行,尽管不能使用 GUI 功能或缺少内核功能。否则,它提供与 Linux 类似的兼容性。此功能目前要求运行 Windows 10 周年更新,并按照微软提供的说明手动安装 WSL。

一般不建议在 Windows 上使用 POSIX。除非有充分的理由使用 POSIX(例如,大量现有的代码库),否则最好使用其中一个跨平台 API(本章后面将介绍),这样可以消除任何平台问题。

在接下来的章节中,我们将看一下 Pthreads API 提供的功能。

PThreads 线程管理

这些都是以pthread_pthread_attr_开头的函数。这些函数都适用于线程本身及其属性对象。

使用 Pthreads 的基本方法如下:

#include <pthread.h> 
#include <stdlib.h> 

#define NUM_THREADS     5 

主要的 Pthreads 头文件是pthread.h。这提供了对除了信号量(稍后在本节中讨论)之外的所有内容的访问。我们还在这里定义了一个希望启动的线程数的常量:

void* worker(void* arg) { 
    int value = *((int*) arg); 

    // More business logic. 

    return 0; 
} 

我们定义了一个简单的Worker函数,稍后将把它传递给新线程。为了演示和调试目的,可以首先添加一个简单的基于coutprintf的业务逻辑,以打印发送到新线程的值。

接下来,我们定义main函数如下:

int main(int argc, char** argv) { 
    pthread_t threads[NUM_THREADS]; 
    int thread_args[NUM_THREADS]; 
    int result_code; 

    for (unsigned int i = 0; i < NUM_THREADS; ++i) { 
        thread_args[i] = i; 
        result_code = pthread_create(&threads[i], 0, worker, (void*) &thread_args[i]); 
    } 

我们在上述函数中的循环中创建所有线程。每个线程实例在创建时被分配一个线程 ID(第一个参数),并且pthread_create()函数返回一个结果代码(成功时为零)。线程 ID 是在将来的调用中引用线程的句柄。

函数的第二个参数是pthread_attr_t结构实例,如果没有则为 0。这允许配置新线程的特性,例如初始堆栈大小。当传递零时,将使用默认参数,这些参数因平台和配置而异。

第三个参数是一个指向新线程将启动的函数的指针。此函数指针被定义为一个返回指向 void 数据的指针的函数(即自定义数据),并接受指向 void 数据的指针。在这里,作为参数传递给新线程的数据是线程 ID:

    for (int i = 0; i < NUM_THREADS; ++i) { 
        result_code = pthread_join(threads[i], 0); 
    } 

    exit(0); 
} 

接下来,我们使用pthread_join()函数等待每个工作线程完成。此函数接受两个参数,要等待的线程的 ID,以及Worker函数的返回值的缓冲区(或零)。

管理线程的其他函数如下:

  • void pthread_exit(void *value_ptr):

此函数终止调用它的线程,使提供的参数值可用于调用pthread_join()的任何线程。

  • int pthread_cancel(pthread_t thread):

此函数请求取消指定的线程。根据目标线程的状态,这将调用其取消处理程序。

除此之外,还有pthread_attr_*函数来操作和获取有关pthread_attr_t结构的信息。

互斥锁

这些函数的前缀为pthread_mutex_pthread_mutexattr_。它们适用于互斥锁及其属性对象。

Pthreads 中的互斥锁可以被初始化、销毁、锁定和解锁。它们还可以使用pthread_mutexattr_t结构自定义其行为,该结构具有相应的pthread_mutexattr_*函数用于初始化和销毁其属性。

使用静态初始化的 Pthread 互斥锁的基本用法如下:

static pthread_mutex_t func_mutex = PTHREAD_MUTEX_INITIALIZER; 

void func() { 
    pthread_mutex_lock(&func_mutex); 

    // Do something that's not thread-safe. 

    pthread_mutex_unlock(&func_mutex); 
} 

在这段代码的最后,我们使用了PTHREAD_MUTEX_INITIALIZER宏,它为我们初始化了互斥锁,而无需每次都输入代码。与其他 API 相比,人们必须手动初始化和销毁互斥锁,尽管使用宏在某种程度上有所帮助。

之后,我们锁定和解锁互斥锁。还有pthread_mutex_trylock()函数,它类似于常规锁定版本,但如果引用的互斥锁已经被锁定,它将立即返回而不是等待它被解锁。

在此示例中,互斥锁没有被显式销毁。然而,这是 Pthreads 应用程序中正常内存管理的一部分。

条件变量

这些函数的前缀为pthread_cond_pthread_condattr_。它们适用于条件变量及其属性对象。

Pthreads 中的条件变量遵循相同的模式,除了具有相同的pthread_condattr_t属性结构管理外,还有初始化和destroy函数。

此示例涵盖了 Pthreads 条件变量的基本用法:

#include <pthread.h> 
#include <stdlib.h>
#include <unistd.h>

   #define COUNT_TRIGGER 10 
   #define COUNT_LIMIT 12 

   int count = 0; 
   int thread_ids[3] = {0,1,2}; 
   pthread_mutex_t count_mutex; 
   pthread_cond_t count_cv; 

在上述代码中,我们获取标准头文件,并定义一个计数触发器和限制,其目的将很快变得清楚。我们还定义了一些全局变量:计数变量,我们希望创建的线程的 ID,以及互斥锁和条件变量:

void* add_count(void* t)  { 
    int tid = (long) t; 
    for (int i = 0; i < COUNT_TRIGGER; ++i) { 
        pthread_mutex_lock(&count_mutex); 
        count++; 
        if (count == COUNT_LIMIT) { 
            pthread_cond_signal(&count_cv); 
        } 

        pthread_mutex_unlock(&count_mutex); 
        sleep(1); 
    } 

    pthread_exit(0); 
} 

在获取count_mutex的独占访问权限后,前面的函数本质上只是将全局计数器变量增加。它还检查计数触发值是否已达到。如果是,它将发出条件变量的信号。

为了让也运行此函数的第二个线程有机会获得互斥锁,我们在循环的每个周期中睡眠 1 秒:

void* watch_count(void* t) { 
    int tid = (int) t; 

    pthread_mutex_lock(&count_mutex); 
    if (count < COUNT_LIMIT) { 
        pthread_cond_wait(&count_cv, &count_mutex); 
    } 

    pthread_mutex_unlock(&count_mutex); 
    pthread_exit(0); 
} 

在这个第二个函数中,在检查是否已经达到计数限制之前,我们先锁定全局互斥锁。这是我们的保险,以防此函数运行的线程在计数达到限制之前没有被调用。

否则,我们等待条件变量提供条件变量和锁定的互斥锁。一旦发出信号,我们解锁全局互斥锁,并退出线程。

这里需要注意的一点是,此示例未考虑虚假唤醒。Pthreads 条件变量容易受到这种唤醒的影响,这需要使用循环并检查是否已满足某种条件:

int main (int argc, char* argv[]) { 
    int tid1 = 1, tid2 = 2, tid3 = 3; 
    pthread_t threads[3]; 
    pthread_attr_t attr; 

    pthread_mutex_init(&count_mutex, 0); 
    pthread_cond_init (&count_cv, 0); 

    pthread_attr_init(&attr); 
    pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_JOINABLE); 
    pthread_create(&threads[0], &attr, watch_count, (void *) tid1); 
    pthread_create(&threads[1], &attr, add_count, (void *) tid2); 
    pthread_create(&threads[2], &attr, add_count, (void *) tid3); 

    for (int i = 0; i < 3; ++i) { 
        pthread_join(threads[i], 0); 
    } 

    pthread_attr_destroy(&attr); 
    pthread_mutex_destroy(&count_mutex); 
    pthread_cond_destroy(&count_cv); 
    return 0; 
}  

最后,在main函数中,我们创建三个线程,其中两个运行将计数器增加的函数,第三个运行等待其条件变量被发出信号的函数。

在这种方法中,我们还初始化全局互斥锁和条件变量。我们创建的线程还明确设置了“可连接”属性。

最后,我们等待每个线程完成,然后进行清理,在退出之前销毁属性结构实例、互斥锁和条件变量。

使用pthread_cond_broadcast()函数,还可以向等待条件变量的所有线程发出信号,而不仅仅是队列中的第一个线程。这使得可以更优雅地使用条件变量,例如,当有很多工作线程等待新数据集到达时,无需单独通知每个线程。

同步

实现同步的函数以pthread_rwlock_pthread_barrier_为前缀。这些实现读/写锁和同步屏障。

读/写锁rwlock)与互斥锁非常相似,只是它具有额外的功能,允许无限线程同时读取,而只限制写入访问一个线程。

使用rwlock与使用互斥锁非常相似:

#include <pthread.h> 
int pthread_rwlock_init(pthread_rwlock_t* rwlock, const pthread_rwlockattr_t* attr); 
pthread_rwlock_t rwlock = PTHREAD_RWLOCK_INITIALIZER; 

在最后的代码中,我们包含相同的通用头文件,并使用初始化函数或通用宏。有趣的部分是当我们锁定rwlock时,可以仅进行只读访问:

int pthread_rwlock_rdlock(pthread_rwlock_t* rwlock); 
int pthread_rwlock_tryrdlock(pthread_rwlock_t* rwlock); 

这里,如果锁已经被锁定,第二种变体会立即返回。也可以按以下方式锁定它以进行写访问:

int pthread_rwlock_wrlock(pthread_rwlock_t* rwlock); 
int pthread_rwlock_trywrlock(pthread_rwlock_t * rwlock); 

这些函数基本上是相同的,唯一的区别是在任何给定时间只允许一个写入者,而多个读取者可以获得只读锁定。

屏障是 Pthreads 的另一个概念。这些是类似于一组线程的屏障的同步对象。在这些线程中的所有线程都必须在任何一个线程可以继续执行之前到达屏障。在屏障初始化函数中,指定了线程计数。只有当所有这些线程都使用pthread_barrier_wait()函数调用barrier对象后,它们才会继续执行。

信号量

如前所述,信号量不是原始 Pthreads 扩展的一部分。出于这个原因,它们在semaphore.h头文件中声明。

实质上,信号量是简单的整数,通常用作资源计数。为了使它们线程安全,使用原子操作(检查和锁定)。POSIX 信号量支持初始化、销毁、增加和减少信号量以及等待信号量达到非零值的操作。

线程本地存储(TLC)

使用 Pthreads,TLS 是通过键和设置线程特定数据的方法来实现的:

pthread_key_t global_var_key;

void* worker(void* arg) {
    int *p = new int;
    *p = 1;
    pthread_setspecific(global_var_key, p);
    int* global_spec_var = (int*) pthread_getspecific(global_var_key);
    *global_spec_var += 1;
    pthread_setspecific(global_var_key, 0);
    delete p;
    pthread_exit(0);
}

在工作线程中,我们在堆上分配一个新的整数,并将全局密钥设置为其自己的值。将全局变量增加 1 后,其值将为 2,而不管其他线程做什么。我们可以在此线程完成后将全局变量设置为 0,并删除分配的值:

int main(void) {
    pthread_t threads[5];

    pthread_key_create(&global_var_key, 0);
    for (int i = 0; i < 5; ++i)
        pthread_create(&threads[i],0,worker,0);
    for (int i = 0; i < 5; ++i) {
        pthread_join(threads[i], 0);
    }
    return 0;
}

设置并使用全局密钥来引用 TLS 变量,但我们创建的每个线程都可以为该密钥设置自己的值。

虽然线程可以创建自己的密钥,但与本章中正在查看的其他 API 相比,处理 TLS 的这种方法相当复杂。

Windows 线程

相对于 Pthreads,Windows 线程仅限于 Windows 操作系统和类似系统(例如 ReactOS 和其他使用 Wine 的操作系统)。这提供了一个相当一致的实现,可以轻松地由支持对应的 Windows 版本来定义。

在 Windows Vista 之前,线程支持缺少诸如条件变量之类的功能,同时具有 Pthreads 中找不到的功能。根据一个人的观点,使用 Windows 头文件定义的无数“类型定义”类型可能也会让人感到烦扰。

线程管理

一个使用 Windows 线程的基本示例,从官方 MSDN 文档示例代码中改编而来,看起来像这样:

#include <windows.h> 
#include <tchar.h> 
#include <strsafe.h> 

#define MAX_THREADS 3 
#define BUF_SIZE 255  

在包含一系列 Windows 特定的头文件(用于线程函数、字符字符串等)之后,我们定义了要创建的线程数以及Worker函数中消息缓冲区的大小。

我们还定义了一个结构类型(通过void pointer: LPVOID传递),用于包含我们传递给每个工作线程的示例数据:

typedef struct MyData { 
 int val1; 
 int val2; 
} MYDATA, *PMYDATA;

DWORD WINAPI worker(LPVOID lpParam) { 
    HANDLE hStdout = GetStdHandle(STD_OUTPUT_HANDLE); 
    if (hStdout == INVALID_HANDLE_VALUE) { 
        return 1; 
    } 

    PMYDATA pDataArray =  (PMYDATA) lpParam; 

    TCHAR msgBuf[BUF_SIZE]; 
    size_t cchStringSize; 
    DWORD dwChars; 
    StringCchPrintf(msgBuf, BUF_SIZE, TEXT("Parameters = %d, %dn"),  
    pDataArray->val1, pDataArray->val2);  
    StringCchLength(msgBuf, BUF_SIZE, &cchStringSize); 
    WriteConsole(hStdout, msgBuf, (DWORD) cchStringSize, &dwChars, NULL); 

    return 0;  
}  

Worker函数中,我们将提供的参数转换为我们自定义的结构类型,然后使用它将其值打印到字符串上,然后输出到控制台。

我们还验证是否有活动的标准输出(控制台或类似)。用于打印字符串的函数都是线程安全的。

void errorHandler(LPTSTR lpszFunction) { 
    LPVOID lpMsgBuf; 
    LPVOID lpDisplayBuf; 
    DWORD dw = GetLastError();  

    FormatMessage( 
        FORMAT_MESSAGE_ALLOCATE_BUFFER |  
        FORMAT_MESSAGE_FROM_SYSTEM | 
        FORMAT_MESSAGE_IGNORE_INSERTS, 
        NULL, 
        dw, 
        MAKELANGID(LANG_NEUTRAL, SUBLANG_DEFAULT), 
        (LPTSTR) &lpMsgBuf, 
        0, NULL); 

        lpDisplayBuf = (LPVOID) LocalAlloc(LMEM_ZEROINIT,  
        (lstrlen((LPCTSTR) lpMsgBuf) + lstrlen((LPCTSTR) lpszFunction) + 40) * sizeof(TCHAR));  
        StringCchPrintf((LPTSTR)lpDisplayBuf,  
        LocalSize(lpDisplayBuf) / sizeof(TCHAR), 
        TEXT("%s failed with error %d: %s"),  
        lpszFunction, dw, lpMsgBuf);  
        MessageBox(NULL, (LPCTSTR) lpDisplayBuf, TEXT("Error"), MB_OK);  

        LocalFree(lpMsgBuf); 
        LocalFree(lpDisplayBuf); 
} 

在这里,定义了一个错误处理程序函数,该函数获取最后一个错误代码的系统错误消息。获取最后一个错误的代码后,将格式化要输出的错误消息,并显示在消息框中。最后,释放分配的内存缓冲区。

最后,main函数如下:

int _tmain() {
         PMYDATA pDataArray[MAX_THREADS];
         DWORD dwThreadIdArray[MAX_THREADS];
         HANDLE hThreadArray[MAX_THREADS];
         for (int i = 0; i < MAX_THREADS; ++i) {
               pDataArray[i] = (PMYDATA) HeapAlloc(GetProcessHeap(),
                           HEAP_ZERO_MEMORY, sizeof(MYDATA));                     if (pDataArray[i] == 0) {
                           ExitProcess(2);
             }
             pDataArray[i]->val1 = i;
             pDataArray[i]->val2 = i+100;
             hThreadArray[i] = CreateThread(
                  NULL,          // default security attributes
                  0,             // use default stack size
                  worker,        // thread function name
                  pDataArray[i], // argument to thread function
                  0,             // use default creation flags
                  &dwThreadIdArray[i]);// returns the thread identifier
             if (hThreadArray[i] == 0) {
                         errorHandler(TEXT("CreateThread"));
                         ExitProcess(3);
             }
   }
         WaitForMultipleObjects(MAX_THREADS, hThreadArray, TRUE, INFINITE);
         for (int i = 0; i < MAX_THREADS; ++i) {
               CloseHandle(hThreadArray[i]);
               if (pDataArray[i] != 0) {
                           HeapFree(GetProcessHeap(), 0, pDataArray[i]);
               }
         }
         return 0;
}

main函数中,我们在循环中创建我们的线程,为线程数据分配内存,并在启动线程之前为每个线程生成唯一数据。每个线程实例都传递了自己的唯一参数。

之后,我们等待线程完成并重新加入。这本质上与在 Pthreads 上调用join函数相同——只是这里,一个函数调用就足够了。

最后,关闭每个线程句柄,并清理之前分配的内存。

高级管理

使用 Windows 线程进行高级线程管理包括作业、纤程和线程池。作业基本上允许将多个线程链接在一起成为一个单一单元,从而可以一次性更改所有这些线程的属性和状态。

纤程是轻量级线程,运行在创建它们的线程的上下文中。创建线程预期自己调度这些纤程。纤程还有类似 TLS 的纤程本地存储FLS)。

最后,Windows 线程 API 提供了一个线程池 API,允许在应用程序中轻松使用这样的线程池。每个进程也提供了一个默认的线程池。

同步

使用 Windows 线程,可以使用临界区、互斥锁、信号量、轻量级读写器SRW)锁、屏障和变体来实现互斥和同步。

同步对象包括以下内容:

名称 描述
事件 允许使用命名对象在线程和进程之间进行事件信号传递。
互斥锁 用于线程间和进程同步,协调对共享资源的访问。
信号量 标准信号量计数对象,用于线程间和进程同步。
可等待定时器 可由多个进程使用的定时器对象,具有多种使用模式。
临界区 临界区本质上是互斥锁,限于单个进程,这使得它们比使用互斥锁更快,因为缺少内核空间调用。
轻量级读写锁 SRW 类似于 Pthreads 中的读/写锁,允许多个读取者或单个写入者线程访问共享资源。
交错变量访问 允许对一系列变量进行原子访问,否则不能保证原子性。这使得线程可以共享变量,而无需使用互斥锁。

条件变量

使用 Windows 线程实现条件变量是非常简单的。它使用临界区(CRITICAL_SECTION)和条件变量(CONDITION_VARIABLE)以及条件变量函数来等待特定的条件变量,或者发出信号。

线程本地存储

线程本地存储TLS)与 Windows 线程类似于 Pthreads,首先必须创建一个中央键(TLS 索引),然后各个线程可以使用该全局索引来存储和检索本地值。

与 Pthreads 一样,这涉及相似数量的手动内存管理,因为 TLS 值必须手动分配和删除。

Boost

Boost 线程是 Boost 库集合中相对较小的一部分。然而,它被用作成为 C11 中多线程实现基础的基础,类似于其他 Boost 库最终完全或部分地成为新的 C标准。有关多线程 API 的详细信息,请参阅本章中的 C++线程部分。

C++11 标准中缺少的功能,在 Boost 线程中是可用的,包括以下内容:

  • 线程组(类似于 Windows 作业)

  • 线程中断(取消)

  • 带超时的线程加入

  • 额外的互斥锁类型(在 C++14 中改进)

除非绝对需要这些功能,或者无法使用支持 C11 标准(包括 STL 线程)的编译器,否则没有理由使用 Boost 线程而不是 C11 实现。

由于 Boost 提供了对本机操作系统功能的包装,使用本机 C++线程可能会减少开销,具体取决于 STL 实现的质量。

POCO

POCO 库是对操作系统功能的相当轻量级的包装。它不需要兼容 C++11 的编译器或任何类型的预编译或元编译。

线程类

Thread类是对 OS 级别线程的简单包装。它接受从Runnable类继承的Worker类实例。官方文档提供了一个基本示例,如下所示:

#include "Poco/Thread.h" 
#include "Poco/Runnable.h" 
#include <iostream> 

class HelloRunnable: public Poco::Runnable { 
    virtual void run() { 
        std::cout << "Hello, world!" << std::endl; 
    } 
}; 

int main(int argc, char** argv) { 
    HelloRunnable runnable; 
    Poco::Thread thread; 
    thread.start(runnable); 
    thread.join(); 
    return 0; 
} 

上述代码是一个非常简单的“Hello world”示例,其中一个工作线程仅通过标准输出输出一个字符串。线程实例分配在堆栈上,并在入口函数的范围内等待工作线程完成,使用join()函数。

POCO 的许多线程功能与 Pthreads 非常相似,尽管在配置线程和其他对象等方面有明显的偏差。作为一个 C++库,它使用类方法来设置属性,而不是填充结构并将其作为参数传递。

线程池

POCO 提供了一个默认的线程池,有 16 个线程。这个数字可以动态改变。与常规线程一样,线程池需要传递一个从Runnable类继承的Worker类实例:

#include "Poco/ThreadPool.h" 
#include "Poco/Runnable.h" 
#include <iostream> 

class HelloRunnable: public Poco::Runnable { 
    virtual void run() { 
        std::cout << "Hello, world!" << std::endl; 
    } 
}; 

int main(int argc, char** argv) { 
    HelloRunnable runnable; 
    Poco::ThreadPool::defaultPool().start(runnable); 
    Poco::ThreadPool::defaultPool().joinAll(); 
    return 0; 
} 

工作线程实例被添加到线程池中,并运行它。当我们添加另一个工作线程实例,更改容量或调用joinAll()时,线程池会清理空闲一定时间的线程。结果,单个工作线程将加入,并且没有活动线程,应用程序退出。

线程本地存储(TLS)

在 POCO 中,TLS 被实现为一个类模板,允许人们将其用于几乎任何类型。

正如官方文档所述:

#include "Poco/Thread.h" 
#include "Poco/Runnable.h" 
#include "Poco/ThreadLocal.h" 
#include <iostream> 

class Counter: public Poco::Runnable { 
    void run() { 
        static Poco::ThreadLocal<int> tls; 
        for (*tls = 0; *tls < 10; ++(*tls)) { 
            std::cout << *tls << std::endl; 
        } 
    } 
}; 

int main(int argc, char** argv) { 
    Counter counter1; 
    Counter counter2; 
    Poco::Thread t1; 
    Poco::Thread t2; 
    t1.start(counter1); 
    t2.start(counter2); 
    t1.join(); 
    t2.join(); 
    return 0; 
} 

在上面的 worker 示例中,我们使用ThreadLocal类模板创建了一个静态 TLS 变量,并定义它包含一个整数。

因为我们将它定义为静态的,所以每个线程只会创建一次。为了使用我们的 TLS 变量,我们可以使用箭头(->)或星号(*)运算符来访问它的值。在这个例子中,我们在for循环的每个周期增加 TLS 值,直到达到限制为止。

这个例子表明,两个线程将生成自己的一系列 10 个整数,计数相同的数字而互不影响。

同步

POCO 提供的同步原语如下:

  • 互斥量

  • FastMutex

  • 事件

  • 条件

  • 信号量

  • RWLock

这里需要注意的是FastMutex类。这通常是一种非递归的互斥类型,只是在 Windows 上是递归的。这意味着人们通常应该假设任一类型在同一线程中可以多次锁定同一互斥量。

人们还可以使用ScopedLock类与互斥量一起使用,确保它封装的互斥量在当前作用域结束时被释放。

事件类似于 Windows 事件,只是它们限于单个进程。它们构成了 POCO 中条件变量的基础。

POCO 条件变量的功能与 Pthreads 等方式基本相同,只是它们不会出现虚假唤醒。通常情况下,条件变量会因为优化原因而出现这些随机唤醒。通过不需要显式检查条件变量等待返回时是否满足条件,减轻了开发者的负担。

C++线程

C中的本地多线程支持在第十二章中有详细介绍,*本地 C线程和原语*。

正如本章中 Boost 部分提到的,C++多线程支持在很大程度上基于 Boost 线程 API,使用几乎相同的头文件和名称。API 本身再次让人联想到 Pthreads,尽管在某些方面有显著的不同,比如条件变量。

接下来的章节将专门使用 C++线程支持进行示例。

将它们组合在一起

在本章涵盖的 API 中,只有 Qt 多线程 API 可以被认为是真正高级的。尽管其他 API(包括 C++11)包含一些更高级的概念,包括线程池和异步运行器,不需要直接使用线程,但 Qt 提供了一个完整的信号-槽架构,使得线程间通信异常容易。

正如本章所介绍的,这种便利也伴随着一个代价,即需要开发应用程序以适应 Qt 框架。这可能在项目中是不可接受的。

哪种 API 是正确的取决于个人的需求。然而,可以相对公平地说,当可以使用 C++11 线程、POCO 等 API 时,使用直接的 Pthreads、Windows 线程等并没有太多意义,这些 API 可以在不显著降低性能的情况下轻松地实现跨平台。

所有这些 API 在核心功能上至少在某种程度上是可比较的。

总结

在本章中,我们详细介绍了一些较流行的多线程 API 和框架,将它们并列在一起,以了解它们的优势和劣势。我们通过一些示例展示了如何使用这些 API 来实现基本功能。

在下一章中,我们将详细介绍如何同步线程并在它们之间进行通信。

第十三章:线程同步和通信

一般来说,线程用于相对独立地处理任务,但有许多情况下,人们希望在线程之间传递数据,甚至控制其他线程,比如来自中央任务调度器线程。本章将介绍如何使用 C++11 线程 API 完成这些任务。

本章涵盖的主题包括以下内容:

  • 使用互斥锁、锁和类似的同步结构

  • 使用条件变量和信号来控制线程

  • 安全地传递和共享线程之间的数据

安全第一

并发的核心问题在于确保在线程之间通信时对共享资源的安全访问。还有线程能够进行通信和同步的问题。

多线程编程的挑战在于能够跟踪线程之间的每次交互,并确保每种形式的访问都得到保护,同时不会陷入死锁和数据竞争的陷阱。

在本章中,我们将看一个涉及任务调度程序的相当复杂的例子。这是一种高并发、高吞吐量的情况,许多不同的要求与许多潜在的陷阱相结合,我们将在下面看到。

调度程序

具有大量同步和线程之间通信的多线程良好示例是任务调度。在这里,目标是尽快接受传入任务并将其分配给工作线程。

在这种情况下,有许多不同的方法可行。通常情况下,工作线程会在一个活跃的循环中运行,不断地轮询中央队列以获取新任务。这种方法的缺点包括在轮询上浪费处理器周期,以及在同步机制(通常是互斥锁)上形成的拥塞。此外,当工作线程数量增加时,这种主动轮询方法的扩展性非常差。

理想情况下,每个工作线程都会空闲等待直到再次需要它。为了实现这一点,我们必须从另一方面解决问题:不是从工作线程的角度,而是从队列的角度。就像操作系统的调度程序一样,调度程序既知道需要处理的任务,也知道可用的工作线程。

在这种方法中,一个中央调度程序实例将接受新任务并积极地将它们分配给工作线程。该调度程序实例还可以管理这些工作线程,例如它们的数量和优先级,具体取决于传入任务的数量和任务的类型或其他属性。

高层视图

在其核心,我们的调度程序或调度器非常简单,就像一个队列,所有调度逻辑都内置其中,如下图所示:

从前面的高层视图可以看出,实际上并没有太多内容。然而,正如我们将在下面看到的,实际实现确实有许多复杂之处。

实现

和往常一样,我们从main函数开始,它包含在main.cpp中:

#include "dispatcher.h"
#include "request.h"

#include <iostream>
#include <string>
#include <csignal>
#include <thread>
#include <chrono>

using namespace std;

sig_atomic_t signal_caught = 0;
mutex logMutex; 

我们包含的自定义头文件是我们调度程序实现的头文件,以及我们将使用的request类。

在全局范围内,我们定义了一个用于信号处理程序的原子变量,以及一个将同步输出(在标准输出上)的互斥锁,用于我们的日志方法:

void sigint_handler(int sig) {
    signal_caught = 1;
} 

我们的信号处理函数(用于SIGINT信号)只是设置了我们之前定义的全局原子变量:

void logFnc(string text) {
    logMutex.lock();
    cout << text << "n";
    logMutex.unlock();
} 

在我们的日志函数中,我们使用全局互斥锁来确保对标准输出的写入是同步的:

int main() {
    signal(SIGINT, &sigint_handler);
    Dispatcher::init(10); 

main函数中,我们安装了SIGINT的信号处理程序,以允许我们中断应用程序的执行。我们还在Dispatcher类上调用静态的init()函数来初始化它:

    cout << "Initialised.n";
        int cycles = 0;
    Request* rq = 0;
    while (!signal_caught && cycles < 50) {
        rq = new Request();
        rq->setValue(cycles);
        rq->setOutput(&logFnc);
        Dispatcher::addRequest(rq);
        cycles++;
    } 

接下来,我们设置循环,在其中我们将创建新的请求。在每个循环中,我们创建一个新的Request实例,并使用其setValue()函数设置一个整数值(当前循环编号)。在将此新请求添加到Dispatcher时,我们还在请求实例上设置了我们的日志函数,使用其静态的addRequest()函数。

这个循环将继续,直到达到最大循环次数,或者使用Ctrl+C或类似方法发出SIGINT信号为止:

        this_thread::sleep_for(chrono::seconds(5));
        Dispatcher::stop();
    cout << "Clean-up done.n";
    return 0; 
} 

最后,我们使用线程的sleep_for()函数和chronoSTL 头文件中的chrono::seconds()函数等待 5 秒。

在返回之前,我们还调用了Dispatcher上的stop()函数。

请求类

Dispatcher的请求始终派生自纯虚拟的AbstractRequest类:

#pragma once
#ifndef ABSTRACT_REQUEST_H
#define ABSTRACT_REQUEST_H

class AbstractRequest {
    //
    public:
    virtual void setValue(int value) = 0;
    virtual void process() = 0;
    virtual void finish() = 0;
};
#endif 

这个AbstractRequest类定义了一个具有三个函数的 API,派生类总是必须实现这些函数。其中,process()finish()函数是最通用的,可能在任何实际实现中使用。setValue()函数是特定于此演示实现的,可能会被调整或扩展以适应实际情况。

使用抽象类作为请求的基础的优势在于,它允许Dispatcher类处理许多不同类型的请求,只要它们都遵循相同的基本 API。

使用这个抽象接口,我们实现了一个基本的Request类如下所示:

#pragma once
#ifndef REQUEST_H
#define REQUEST_H

#include "abstract_request.h"

#include <string>

using namespace std;

typedef void (*logFunction)(string text);

class Request : public AbstractRequest {
    int value;
    logFunction outFnc;
    public:    void setValue(int value) { this->value = value; }
    void setOutput(logFunction fnc) { outFnc = fnc; }
    void process();
    void finish();
};
#endif 

在其头文件中,我们首先定义了函数指针的格式。之后,我们实现了请求 API,并在基本 API 中添加了setOutput()函数,该函数接受用于记录日志的函数指针。这两个 setter 函数仅将提供的参数分配给它们各自的私有类成员。

接下来,类函数的实现如下所示:

#include "request.h"
void Request::process() {
    outFnc("Starting processing request " + std::to_string(value) + "...");
    //
}
void Request::finish() {
    outFnc("Finished request " + std::to_string(value));
} 

这两个实现都非常基本;它们仅使用函数指针来输出指示工作线程状态的字符串。

在实际实现中,可以在process()函数中添加业务逻辑,而finish()函数包含完成请求的任何功能,例如将映射写入字符串。

Worker 类

接下来是Worker类。这包含了Dispatcher将调用以处理请求的逻辑。

#pragma once
#ifndef WORKER_H
#define WORKER_H

#include "abstract_request.h"

#include <condition_variable>
#include <mutex>

using namespace std;

class Worker {
    condition_variable cv;
    mutex mtx;
    unique_lock<mutex> ulock;
    AbstractRequest* request;
    bool running;
    bool ready;
    public:
    Worker() { running = true; ready = false; ulock = unique_lock<mutex>(mtx); }
    void run();
    void stop() { running = false; }
    void setRequest(AbstractRequest* request) { this->request = request; ready = true; }
    void getCondition(condition_variable* &cv);
};
#endif 

虽然将请求添加到Dispatcher不需要任何特殊逻辑,但Worker类确实需要使用条件变量来与调度程序同步。对于 C++11 线程 API,这需要一个条件变量、一个互斥锁和一个唯一锁。

唯一的锁封装了互斥锁,并最终将与条件变量一起使用,我们马上就会看到。

除此之外,我们定义了启动和停止工作线程的方法,设置要处理的新请求,并获取其内部条件变量的访问权限。

接下来,其余的实现如下所示:

#include "worker.h"
#include "dispatcher.h"

#include <chrono>

using namespace std;

void Worker::getCondition(condition_variable* &cv) {
    cv = &(this)->cv;
}

void Worker::run() {
    while (running) {
        if (ready) {
            ready = false;
            request->process();
            request->finish();
        }
        if (Dispatcher::addWorker(this)) {
            // Use the ready loop to deal with spurious wake-ups.
            while (!ready && running) {
                if (cv.wait_for(ulock, chrono::seconds(1)) == cv_status::timeout) {
                    // We timed out, but we keep waiting unless  
                    // the worker is 
                    // stopped by the dispatcher. 
                }
            }
        }
    }
} 

除了条件变量的getter函数之外,我们定义了run()函数,dispatcher将在启动每个工作线程时运行它。

其主循环仅检查stop()函数是否已被调用,这会将运行布尔值设置为false,并结束工作线程。这是由Dispatcher在关闭时使用的,允许它终止工作线程。由于布尔值通常是原子的,因此可以同时设置和检查,而无需风险或需要互斥锁。

继续进行,ready变量的检查是为了确保在线程首次运行时实际上有一个请求在等待。在工作线程的第一次运行时,不会有请求在等待,因此,尝试处理一个请求将导致崩溃。在Dispatcher设置新请求时,这个布尔变量将被设置为true

如果有请求在等待,ready变量将再次设置为false,之后请求实例将调用其process()finish()函数。这将在工作线程的线程上运行请求的业务逻辑,并完成它。

最后,工作线程使用其静态的addWorker()函数将自己添加到调度程序。如果没有新请求可用,此函数将返回false,并导致工作线程等待直到有新请求可用。否则,工作线程将继续处理Dispatcher设置的新请求。

如果被要求等待,我们进入一个新的循环。这个循环将确保当条件变量被唤醒时,是因为我们收到了Dispatcher的信号(ready变量设置为true),而不是因为虚假唤醒。

最后,我们使用之前创建的唯一锁实例和超时进入条件变量的实际wait()函数。如果超时发生,我们可以终止线程,或者继续等待。在这里,我们选择什么都不做,只是重新进入等待循环。

调度程序

最后一项是Dispatcher类本身:

    #pragma once
    #ifndef DISPATCHER_H
    #define DISPATCHER_H

    #include "abstract_request.h"
    #include "worker.h"

    #include <queue>
    #include <mutex>
    #include <thread>
    #include <vector>

    using namespace std;

    class Dispatcher {
        static queue<AbstractRequest*> requests;
        static queue<Worker*> workers;
        static mutex requestsMutex;
        static mutex workersMutex;
        static vector<Worker*> allWorkers;
        static vector<thread*> threads;
        public:
        static bool init(int workers);
        static bool stop();
        static void addRequest(AbstractRequest* request);
        static bool addWorker(Worker* worker);
     };
     #endif 

大部分内容都很熟悉。到目前为止,您已经推测到,这是一个完全静态的类。

接下来,它的实现如下:

    #include "dispatcher.h"

    #include <iostream>
    using namespace std;

    queue<AbstractRequest*> Dispatcher::requests;
    queue<Worker*> Dispatcher::workers;
    mutex Dispatcher::requestsMutex;
    mutex Dispatcher::workersMutex;
    vector<Worker*> Dispatcher::allWorkers;
    vector<thread*> Dispatcher::threads; 

    bool Dispatcher::init(int workers) {
        thread* t = 0;
        Worker* w = 0;
        for (int i = 0; i < workers; ++i) {
            w = new Worker;
            allWorkers.push_back(w);
            t = new thread(&Worker::run, w);
            threads.push_back(t);
        }
   return true;
 } 

设置静态类成员后,定义了init()函数。它启动指定数量的工作线程,并在各自的向量数据结构中保留对每个工作线程和线程实例的引用:

    bool Dispatcher::stop() {
        for (int i = 0; i < allWorkers.size(); ++i) {
            allWorkers[i]->stop();
        }
            cout << "Stopped workers.n";
            for (int j = 0; j < threads.size(); ++j) {
            threads[j]->join();
                    cout << "Joined threads.n";
        }
    }

stop()函数中,每个工作实例都调用其stop()函数。这将导致每个工作线程终止,就像我们在Worker类描述中看到的那样。

最后,我们等待每个线程加入(即完成)后再返回:

    void Dispatcher::addRequest(AbstractRequest* request) {
        workersMutex.lock();
        if (!workers.empty()) {
            Worker* worker = workers.front();
            worker->setRequest(request);
            condition_variable* cv;
            worker->getCondition(cv);
            cv->notify_one();
            workers.pop();
            workersMutex.unlock();
        }
        else {
            workersMutex.unlock();
            requestsMutex.lock();
            requests.push(request);
            requestsMutex.unlock();
        }
    } 

addRequest()函数是有趣的地方。在这个函数中,添加了一个新的请求。接下来会发生什么取决于是否有工作线程在等待新请求。如果没有工作线程在等待(工作队列为空),则将请求添加到请求队列。

互斥锁的使用确保对这些队列的访问是安全的,因为工作线程将同时尝试访问这两个队列。

这里需要注意的一个重要问题是死锁的可能性。也就是说,两个线程将持有资源的锁,第二个线程在释放自己的锁之前等待第一个线程释放锁。在单个作用域中使用多个互斥锁的每种情况都存在这种潜力。

在这个函数中,死锁的潜在可能性在于释放工作线程互斥锁,并在获取请求互斥锁时。在这个函数持有工作线程互斥锁并尝试获取请求锁(当没有工作线程可用时),有可能另一个线程持有请求互斥锁(寻找要处理的新请求),同时尝试获取工作线程互斥锁(找不到请求并将自己添加到工作线程队列)。

解决方法很简单:在获取下一个互斥锁之前释放一个互斥锁。在一个人觉得必须持有多个互斥锁的情况下,非常重要的是检查和测试潜在死锁的代码。在这种特殊情况下,当不再需要时,或在获取请求互斥锁之前,显式释放工作线程互斥锁,从而防止死锁。

这段代码的另一个重要方面是它如何通知工作线程。正如我们在 if/else 块的第一部分中看到的,当工作线程队列不为空时,从队列中获取一个工作线程,设置请求,然后引用并发出条件变量的信号或通知。

在内部,条件变量使用我们在Worker类定义中提供的互斥锁,以确保对它的原子访问。当在条件变量上调用notify_one()函数(在其他 API 中通常称为signal())时,它将通知等待条件变量返回并继续的线程队列中的第一个线程。

Worker类的run()函数中,我们将等待此通知事件。收到通知后,工作线程将继续处理新请求。然后,线程引用将从队列中删除,直到它再次添加自己,一旦完成请求处理:

    bool Dispatcher::addWorker(Worker* worker) {
        bool wait = true;
        requestsMutex.lock();
        if (!requests.empty()) {
            AbstractRequest* request = requests.front();
            worker->setRequest(request);
            requests.pop();
            wait = false;
            requestsMutex.unlock();
        }
        else {
            requestsMutex.unlock();
            workersMutex.lock();
            workers.push(worker);
            workersMutex.unlock();
        }
            return wait;
    } 

通过这个最后一个函数,工作线程在完成请求处理后会将自己添加到队列中。它类似于之前的函数,即首先将传入的工作线程与可能在请求队列中等待的任何请求进行匹配。如果没有可用的请求,工作线程将被添加到工作线程队列中。

在这里需要注意的是,我们返回一个布尔值,指示调用线程是否应该等待新请求,还是在尝试将自己添加到队列时已经收到了新请求。

虽然这段代码比之前的函数更简单,但由于在同一范围内处理了两个互斥锁,它仍然存在潜在的死锁问题。在这里,我们首先释放我们持有的互斥锁,然后再获取下一个互斥锁。

Makefile

这个Dispatcher示例的 makefile 非常基本--它收集当前文件夹中的所有 C++源文件,并使用g++将它们编译成一个二进制文件:

    GCC := g++

    OUTPUT := dispatcher_demo
    SOURCES := $(wildcard *.cpp)
    CCFLAGS := -std=c++11 -g3

    all: $(OUTPUT)
        $(OUTPUT):
        $(GCC) -o $(OUTPUT) $(CCFLAGS) $(SOURCES)
        clean:
        rm $(OUTPUT)
        .PHONY: all

输出

编译应用程序后,运行它会产生以下输出,总共有 50 个请求:

    $ ./dispatcher_demo.exe
    Initialised.
    Starting processing request 1...
    Starting processing request 2...
    Finished request 1
    Starting processing request 3...
    Finished request 3
    Starting processing request 6...
    Finished request 6
    Starting processing request 8...
    Finished request 8
    Starting processing request 9...
    Finished request 9
    Finished request 2
    Starting processing request 11...
    Finished request 11
    Starting processing request 12...
    Finished request 12
    Starting processing request 13...
    Finished request 13
    Starting processing request 14...
    Finished request 14
    Starting processing request 7...
    Starting processing request 10...
    Starting processing request 15...
    Finished request 7
    Finished request 15
    Finished request 10
    Starting processing request 16...
    Finished request 16
    Starting processing request 17...
    Starting processing request 18...
    Starting processing request 0...

在这一点上,我们已经清楚地看到,即使每个请求几乎不需要时间来处理,请求显然是并行执行的。第一个请求(请求 0)只在第 16 个请求之后开始处理,而第二个请求在第九个请求之后就已经完成了。

决定首先处理哪个线程和因此哪个请求的因素取决于操作系统调度程序和基于硬件的调度,如第九章中所述,“处理器和操作系统上的多线程实现”。这清楚地显示了即使在单个平台上,也不能对多线程应用程序的执行做出多少假设。

    Starting processing request 5...
    Finished request 5
    Starting processing request 20...
    Finished request 18
    Finished request 20
    Starting processing request 21...
    Starting processing request 4...
    Finished request 21
    Finished request 4   

在前面的代码中,第四个和第五个请求也以相当延迟的方式完成。


    Starting processing request 23...
    Starting processing request 24...
    Starting processing request 22...
    Finished request 24
    Finished request 23
    Finished request 22
    Starting processing request 26...
    Starting processing request 25...
    Starting processing request 28...
    Finished request 26
    Starting processing request 27...
    Finished request 28
    Finished request 27
    Starting processing request 29...
    Starting processing request 30...
    Finished request 30
    Finished request 29
    Finished request 17
    Finished request 25
    Starting processing request 19...
    Finished request 0

在这一点上,第一个请求终于完成了。这可能表明,与后续请求相比,第一个请求的初始化时间总是会延迟。多次运行应用程序可以确认这一点。重要的是,如果处理顺序很重要,这种随机性不会对应用程序产生负面影响。

    Starting processing request 33...
    Starting processing request 35...
    Finished request 33
    Finished request 35
    Starting processing request 37...
    Starting processing request 38...
    Finished request 37
    Finished request 38
    Starting processing request 39...
    Starting processing request 40...
    Starting processing request 36...
    Starting processing request 31...
    Finished request 40
    Finished request 39
    Starting processing request 32...
    Starting processing request 41...
    Finished request 32
    Finished request 41
    Starting processing request 42...
    Finished request 31
    Starting processing request 44...
    Finished request 36
    Finished request 42
    Starting processing request 45...
    Finished request 44
    Starting processing request 47...
    Starting processing request 48...
    Finished request 48
    Starting processing request 43...
    Finished request 47
    Finished request 43
    Finished request 19
    Starting processing request 34...
    Finished request 34
    Starting processing request 46...
    Starting processing request 49...
    Finished request 46
    Finished request 49
    Finished request 45

第 19 个请求也变得相当延迟,再次显示了多线程应用程序有多么不可预测。如果我们在这里并行处理大型数据集,每个请求中都有数据块,我们可能需要在某些时候暂停以考虑这些延迟,否则我们的输出缓存可能会变得太大。

由于这样做会对应用程序的性能产生负面影响,人们可能不得不考虑低级优化,以及在特定处理器核心上对线程进行调度,以防止这种情况发生。

    Stopped workers.
    Joined threads.
    Joined threads.
    Joined threads.
    Joined threads.
    Joined threads.
    Joined threads.
    Joined threads.
    Joined threads.
    Joined threads.
    Joined threads.
    Clean-up done.

最初启动的 10 个工作线程在这里终止,因为我们调用了Dispatcherstop()函数。

数据共享

在本章给出的示例中,我们看到了如何在线程之间共享信息以及同步线程--这是通过从主线程传递给调度程序的请求,每个请求都会传递给不同的线程。

线程之间共享数据的基本思想是要共享的数据以某种方式存在,可以被两个或更多个线程访问。之后,我们必须确保只有一个线程可以修改数据,并且在读取数据时数据不会被修改。通常,我们会使用互斥锁或类似的方法来确保这一点。

使用读写锁

读写锁在这里是一种可能的优化,因为它允许多个线程同时从单个数据源读取。如果一个应用程序中有多个工作线程反复读取相同的信息,使用读写锁比基本的互斥锁更有效,因为读取数据的尝试不会阻塞其他线程。

读写锁因此可以被用作互斥锁的更高级版本,即根据访问类型调整其行为。在内部,它建立在互斥锁(或信号量)和条件变量之上。

使用共享指针

首先通过 Boost 库提供,并在 C++11 中引入,共享指针是使用引用计数对堆分配实例进行内存管理的抽象。它们在某种程度上是线程安全的,因为可以创建多个共享指针实例,但引用的对象本身不是线程安全的。

根据应用程序的情况,这可能就足够了。要使它们真正线程安全,可以使用原子操作。我们将在第十五章中更详细地讨论这个问题,原子操作 - 与硬件交互

总结

在本章中,我们看了如何在一个相当复杂的调度器实现中以安全的方式在线程之间传递数据。我们还看了所述调度器的结果异步处理,并考虑了一些潜在的替代方案和优化方法来在线程之间传递数据。

在这一点上,你应该能够安全地在线程之间传递数据,以及同步访问其他共享资源。

在下一章中,我们将看一下本地 C++线程和基元 API。

第十四章:本地 C++线程和原语

从 2011 年的 C标准修订版开始,多线程 API 正式成为 C标准模板库STL)的一部分。这意味着线程、线程原语和同步机制对于任何新的 C++应用程序都是可用的,无需安装第三方库或依赖操作系统的 API。

本章将介绍本地 API 中可用的多线程功能,直到 2014 年标准添加的功能。将展示一些示例以详细使用这些功能。

本章的主题包括以下内容:

  • C++ STL 中的多线程 API 提供的功能

  • 每个功能的详细使用示例

STL 线程 API

在第十章中,C++多线程 API,我们看了一下在开发多线程 C应用程序时可用的各种 API。在第十一章中,线程同步和通信,我们使用本地 C线程 API 实现了一个多线程调度程序应用程序。

Boost.Thread API

通过包含 STL 中的<thread>头文件,我们可以访问std::thread类,该类具有由其他头文件提供的互斥(互斥锁等)设施。这个 API 本质上与Boost.Thread的多线程 API 相同,主要区别在于对线程的更多控制(带超时的加入,线程组和线程中断),以及在原语(如互斥锁和条件变量)之上实现的一些额外的锁类型。

一般来说,当 C11 支持不可用时,或者这些额外的Boost.Thread功能是应用程序的要求,并且不容易以其他方式添加时,应该使用Boost.Thread作为备用。由于Boost.Thread建立在可用的(本地)线程支持之上,因此与 C11 STL 实现相比,它还可能增加开销。

2011 年标准

C标准的 2011 年修订版(通常称为 C11)增加了许多新功能,其中最关键的是添加了本地多线程支持,这增加了在 C++中创建、管理和使用线程的能力,而无需使用第三方库。

这个标准为核心语言规范了内存模型,允许多个线程共存,并启用了诸如线程本地存储之类的功能。C03 标准中已经添加了初始支持,但 C11 标准是第一个充分利用这一特性的标准。

如前所述,实际的线程 API 本身是在 STL 中实现的。C11(C0x)标准的一个目标是尽可能多地将新功能放入 STL 中,而不是作为核心语言的一部分。因此,为了使用线程、互斥锁等,必须首先包含相关的 STL 头文件。

负责新多线程 API 的标准委员会各自设定了自己的目标,因此一些希望加入的功能最终未能成为标准的一部分。这包括终止另一个线程或线程取消等功能,这些功能受到 POSIX 代表的强烈反对,因为取消线程可能会导致正在销毁的线程资源清理出现问题。

以下是此 API 实现提供的功能:

  • std::thread

  • std::mutex

  • std::recursive_mutex

  • std::condition_variable

  • std::condition_variable_any

  • std::lock_guard

  • std::unique_lock

  • std::packaged_task

  • std::async

  • std::future

接下来,我们将详细介绍每个功能的示例。首先,我们将看看 C++标准的下一个修订版本添加了哪些初始功能。

C++14

2014 年的标准向标准库添加了以下功能:

  • std::shared_lock

  • std::shared_timed_mutex

这两者都在<shared_mutex>STL 头文件中定义。由于锁是基于互斥锁的,因此共享锁依赖于共享互斥锁。

线程类

thread类是整个线程 API 的核心;它包装了底层操作系统的线程,并提供了我们启动和停止线程所需的功能。

通过包含<thread>头文件,可以访问此功能。

基本用法

创建线程后立即启动:

#include <thread> 

void worker() { 
   // Business logic. 
} 

int main () { 
   std::thread t(worker);
   return 0; 
} 

上述代码将启动线程,然后立即终止应用程序,因为我们没有等待新线程执行完毕。

为了正确执行这个操作,我们需要等待线程完成,或者重新加入如下:

#include <thread> 

void worker() { 
   // Business logic. 
} 

int main () { 
   std::thread t(worker); 
   t.join(); 
   return 0; 
} 

这段代码将执行,等待新线程完成,然后返回。

传递参数

也可以向新线程传递参数。这些参数值必须是可移动构造的,这意味着它是一个具有移动或复制构造函数(用于右值引用)的类型。实际上,对于所有基本类型和大多数(用户定义的)类来说,这是成立的:

#include <thread> 
#include <string> 

void worker(int n, std::string t) { 
   // Business logic. 
} 

int main () { 
   std::string s = "Test"; 
   int i = 1; 
   std::thread t(worker, i, s); 
   t.join(); 
   return 0; 
} 

在上述代码中,我们将一个整数和一个字符串传递给thread函数。该函数将接收这两个变量的副本。当传递引用或指针时,生命周期问题、数据竞争等会变得更加复杂,可能会成为一个问题。

返回值

传递给thread类构造函数的函数返回的任何值都将被忽略。要将信息返回给创建新线程的线程,必须使用线程间同步机制(如互斥锁)和某种共享变量。

移动线程

2011 年的标准在<utility>头文件中添加了std::move。使用这个模板方法,可以在对象之间移动资源。这意味着它也可以移动线程实例:

#include <thread> 
#include <string> 
#include <utility> 

void worker(int n, string t) { 
   // Business logic. 
} 

int main () { 
   std::string s = "Test"; 
   std::thread t0(worker, 1, s); 
   std::thread t1(std::move(t0)); 
   t1.join(); 
   return 0; 
} 

在这个版本的代码中,我们在将线程移动到另一个线程之前创建了一个线程。因此线程 0 停止存在(因为它立即完成),并且thread函数的执行在我们创建的新线程中恢复。

因此,我们不必等待第一个线程重新加入,只需要等待第二个线程。

线程 ID

每个线程都有一个与之关联的标识符。这个 ID 或句柄是 STL 实现提供的唯一标识符。可以通过调用thread类实例的get_id()函数或调用std::this_thread::get_id()来获取调用该函数的线程的 ID:

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

 std::mutex display_mutex;

 void worker() {
     std::thread::id this_id = std::this_thread::get_id();

     display_mutex.lock();
     std::cout << "thread " << this_id << " sleeping...n";
     display_mutex.unlock();

     std::this_thread::sleep_for(std::chrono::seconds(1));
 }

 int main() {
    std::thread t1(worker);
    std::thread::id t1_id = t1.get_id();

    std::thread t2(worker);
    std::thread::id t2_id = t2.get_id();

    display_mutex.lock();
    std::cout << "t1's id: " << t1_id << "n";
    std::cout << "t2's id: " << t2_id << "n";
    display_mutex.unlock();

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

    return 0;
 } 

这段代码将产生类似于以下的输出:

t1's id: 2
t2's id: 3
thread 2 sleeping...
thread 3 sleeping...

在这里,可以看到内部线程 ID 是一个整数(std::thread::id类型),相对于初始线程(ID 为 1)。这类似于大多数本机线程 ID,比如 POSIX 的线程 ID。这些也可以使用native_handle()获得。该函数将返回底层的本机线程句柄。当希望使用 STL 实现中不可用的特定 PThread 或 Win32 线程功能时,这是特别有用的。

休眠

可以使用两种方法延迟执行线程(休眠)。一种是sleep_for(),它至少延迟指定的持续时间,但可能更长:

#include <iostream> 
#include <chrono> 
#include <thread> 
        using namespace std::chrono_literals;

        typedef std::chrono::time_point<std::chrono::high_resolution_clock> timepoint; 
int main() { 
         std::cout << "Starting sleep.n"; 

         timepoint start = std::chrono::high_resolution_clock::now(); 

         std::this_thread::sleep_for(2s); 

         timepoint end = std::chrono::high_resolution_clock::now(); 
         std::chrono::duration<double, std::milli> elapsed = end - 
         start; 
         std::cout << "Slept for: " << elapsed.count() << " msn"; 
} 

上述代码展示了如何休眠大约 2 秒,使用具有当前操作系统上可能的最高精度的计数器来测量确切的持续时间。

请注意,我们可以直接指定秒数,使用秒后缀。这是 C14 添加到<chrono>头文件的功能。对于 C11 版本,需要创建一个 stdchronoseconds 的实例并将其传递给sleep_for()函数。

另一种方法是sleep_until(),它接受一个类型为std::chrono::time_point<Clock, Duration>的单个参数。使用这个函数,可以设置线程休眠,直到达到指定的时间点。由于操作系统的调度优先级,这个唤醒时间可能不是指定的确切时间。

屈服

可以告诉操作系统当前线程可以重新调度,以便其他线程可以运行。为此,可以使用std::this_thread::yield()函数。此函数的确切结果取决于底层操作系统实现及其调度程序。在 FIFO 调度程序的情况下,调用线程可能会被放在队列的末尾。

这是一个高度专业化的函数,具有特殊的用例。在未验证其对应用程序性能的影响之前,不应使用它。

分离

启动线程后,可以在线程对象上调用detach()。这实际上将新线程与调用线程分离,这意味着前者将在调用线程退出后继续执行。

交换

使用swap(),可以作为独立方法或作为线程实例的函数,可以交换线程对象的基础线程句柄:

#include <iostream> 
#include <thread> 
#include <chrono> 

void worker() { 
   std::this_thread::sleep_for(std::chrono::seconds(1)); 
} 

int main() { 
         std::thread t1(worker); 
         std::thread t2(worker); 

         std::cout << "thread 1 id: " << t1.get_id() << "n"; 
         std::cout << "thread 2 id: " << t2.get_id() << "n"; 

         std::swap(t1, t2); 

         std::cout << "Swapping threads..." << "n"; 

         std::cout << "thread 1 id: " << t1.get_id() << "n"; 
         std::cout << "thread 2 id: " << t2.get_id() << "n"; 

         t1.swap(t2); 

         std::cout << "Swapping threads..." << "n"; 

         std::cout << "thread 1 id: " << t1.get_id() << "n"; 
         std::cout << "thread 2 id: " << t2.get_id() << "n"; 

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

此代码的可能输出如下:

thread 1 id: 2
thread 2 id: 3
Swapping threads...
thread 1 id: 3
thread 2 id: 2
Swapping threads...
thread 1 id: 2
thread 2 id: 3

其效果是每个线程的状态与另一个线程的状态交换,实质上交换了它们的身份。

互斥锁

<mutex>头文件包含多种类型的互斥锁和锁。互斥锁类型是最常用的类型,提供基本的锁定/解锁功能,没有更多的复杂性。

基本用法

在本质上,互斥锁的目标是排除同时访问的可能性,以防止数据损坏,并防止由于使用非线程安全例程而导致崩溃。

一个需要使用互斥锁的示例代码如下:

#include <iostream> 
#include <thread> 

void worker(int i) { 
         std::cout << "Outputting this from thread number: " << i << "n"; 
} 

int main() { 
         std::thread t1(worker, 1);
         std::thread t2(worker, 2); 

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

   return 0; 
} 

如果一个人尝试直接运行上述代码,就会注意到两个线程的文本输出会被混在一起,而不是依次输出。原因是标准输出(无论是 C 还是 C++风格)不是线程安全的。虽然应用程序不会崩溃,但输出会是一团糟。

对此的修复很简单,如下所示:

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

std::mutex globalMutex; 

void worker(int i) { 
   globalMutex.lock(); 
         std::cout << "Outputting this from thread number: " << i << "n"; 
   globalMutex.unlock(); 
} 

int main() { 
         std::thread t1(worker, 1);
         std::thread t2(worker, 2); 

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

   return 0; 
} 

在这种情况下,每个线程首先需要获取mutex对象的访问权。由于只有一个线程可以访问mutex对象,另一个线程将等待第一个线程完成对标准输出的写入,两个字符串将按预期依次出现。

非阻塞锁定

可能不希望线程阻塞并等待mutex对象可用:例如,当一个人只想知道是否另一个线程已经处理了请求,并且没有必要等待其完成时。

为此,互斥锁带有try_lock()函数,可以做到这一点。

在下面的示例中,我们可以看到两个线程尝试递增相同的计数器,但是当一个线程无法立即访问共享计数器时,它会递增自己的计数器:

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

std::chrono::milliseconds interval(50); 

std::mutex mutex; 
int shared_counter = 0;
int exclusive_counter = 0; 

void worker0() { 
   std::this_thread::sleep_for(interval);

         while (true) { 
               if (mutex.try_lock()) { 
                     std::cout << "Shared (" << job_shared << ")n"; 
                     mutex.unlock(); 
                     return; 
               } 
         else { 
                     ++exclusive_counter; 
                           std::cout << "Exclusive (" << exclusive_counter << ")n"; 
                           std::this_thread::sleep_for(interval); 
               } 
         } 
} 

void worker1() { 
   mutex.lock(); 
         std::this_thread::sleep_for(10 * interval); 
         ++shared_counter; 
         mutex.unlock(); 
} 

int main() { 
         std::thread t1(worker0); 
         std::thread t2(worker1); 

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

在上述示例中,两个线程运行不同的worker函数,但它们都有一个共同点,即它们都会在一段时间内休眠,并在醒来时尝试获取共享计数器的互斥锁。如果成功,它们将增加计数器,但只有第一个工作线程会输出这个事实。

第一个工作线程还会记录当它没有获得共享计数器时,但只增加了它自己的独立计数器。结果输出可能看起来像这样:

Exclusive (1)
Exclusive (2)
Exclusive (3)
Shared (1)
Exclusive (4)

定时互斥锁

定时互斥锁是常规互斥锁类型,但具有一些额外的函数,可以控制在尝试获取锁期间的时间段,即try_lock_fortry_lock_until

前者在指定的时间段(std::chrono对象)内尝试获取锁,然后返回结果(true 或 false)。后者将等待直到将来的特定时间点,然后返回结果。

这些功能的使用主要在于提供常规互斥锁的阻塞(lock)和非阻塞(try_lock)方法之间的中间路径。一个人可能希望使用单个线程等待一些任务,而不知道何时任务将变为可用,或者任务可能在某个特定时间点过期,此时等待它就不再有意义了。

锁卫

锁卫是一个简单的互斥锁包装器,它处理对mutex对象的锁定以及在锁卫超出范围时的释放。这是一个有用的机制,可以确保不会忘记释放互斥锁,并且在必须在多个位置释放相同的互斥锁时,可以帮助减少代码的混乱。

尽管重构,例如大的 if/else 块可以减少需要释放互斥锁的情况,但最好还是使用这个锁卫包装器,不用担心这些细节:

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

int counter = 0; 
std::mutex counter_mutex; 

void worker() { 
         std::lock_guard<std::mutex> lock(counter_mutex); 
   if (counter == 1) { counter += 10; } 
   else if (counter >= 10) { counter += 15; } 
   else if (counter >= 50) { return; } 
         else { ++counter; } 

   std::cout << std::this_thread::get_id() << ": " << counter << 'n'; 
} 

int main() { 
    std::cout << __func__ << ": " << counter << 'n'; 

    std::thread t1(worker); 
    std::thread t2(worker); 

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

    std::cout << __func__ << ": " << counter << 'n'; 
} 

在前面的例子中,我们看到一个小的 if/else 块,其中一个条件导致worker函数立即返回。如果没有锁卫,我们必须确保在从函数返回之前在此条件下也解锁互斥锁。

然而,有了锁卫,我们就不必担心这些细节,这使我们可以专注于业务逻辑,而不是担心互斥锁管理。

唯一锁

唯一锁是一个通用的互斥锁包装器。它类似于定时互斥锁,但具有附加功能,主要是所有权的概念。与其他锁类型不同,唯一锁不一定拥有它包装的互斥锁,如果有的话。互斥锁可以在唯一锁实例之间以及使用swap()函数转移这些互斥锁的所有权。

唯一锁实例是否拥有其互斥锁的所有权,以及它是否被锁定或未锁定,是在创建锁时首先确定的,可以从其构造函数中看到。例如:

std::mutex m1, m2, m3; 
std::unique_lock<std::mutex> lock1(m1, std::defer_lock); 
std::unique_lock<std::mutex> lock2(m2, std::try_lock); 
std::unique_lock<std::mutex> lock3(m3, std::adopt_lock); 

最后一个代码中的第一个构造函数不锁定分配的互斥锁(延迟)。第二个尝试使用try_lock()锁定互斥锁。最后,第三个构造函数假定它已经拥有提供的互斥锁。

除此之外,其他构造函数允许定时互斥锁的功能。也就是说,它将等待一段时间,直到达到某个时间点,或者直到获得锁。

最后,使用release()函数可以断开锁与互斥锁之间的关联,并返回mutex对象的指针。然后调用者负责释放互斥锁上的任何剩余锁,并进一步处理它。

这种类型的锁通常不会单独使用,因为它非常通用。大多数其他类型的互斥锁和锁都要简单得多,并且可能在 99%的情况下满足所有需求。唯一锁的复杂性因此既是优点也是风险。

然而,它通常被 C++11 线程 API 的其他部分使用,例如我们马上就会看到的条件变量。

唯一锁可能有用的一个领域是作为作用域锁,允许使用作用域锁而不必依赖 C++17 标准中的原生作用域锁。看这个例子:

#include <mutex>
std::mutex my_mutex
int count = 0;
int function() {
         std::unique_lock<mutex> lock(my_mutex);
   count++;
}  

当我们进入函数时,我们使用全局互斥锁实例创建一个新的 unique_lock。在这一点上,互斥锁被锁定,之后我们可以执行任何关键操作。

当函数作用域结束时,唯一锁的析构函数被调用,这导致互斥锁再次被解锁。

作用域锁

作用域锁是在 2017 年标准中首次引入的,它是一个互斥锁包装器,用于获取(锁定)提供的互斥锁,并确保在作用域锁超出范围时解锁。它与锁卫的不同之处在于它是多个互斥锁的包装器,而不是一个。

当在单个作用域中处理多个互斥时,这可能是有用的。使用作用域锁的一个原因是为了避免意外引入死锁和其他不愉快的复杂情况,例如一个互斥被作用域锁锁定,另一个锁仍在等待,另一个线程实例具有完全相反的情况。

作用域锁的一个特性是,它试图避免这种情况,从理论上讲,使得这种类型的锁具有死锁安全性。

递归互斥

递归互斥是互斥的另一种子类型。尽管它具有与常规互斥完全相同的功能,但它允许最初锁定互斥的调用线程重复锁定同一互斥。通过这样做,互斥在拥有线程解锁它的次数与锁定它的次数相同之前,不会对其他线程可用。

使用递归互斥的一个很好的理由是,例如在使用递归函数时。使用常规互斥时,需要发明某种进入点,在进入递归函数之前锁定互斥。

使用递归互斥时,递归函数的每次迭代都会再次锁定递归互斥,并在完成一次迭代后解锁互斥。结果是互斥锁定和解锁的次数相同。

因此,这里可能存在的一个复杂情况是,递归互斥可以被锁定的最大次数在标准中没有定义。当达到实现的限制时,如果尝试锁定它,将抛出std::system_error,或者在使用非阻塞的try_lock函数时返回 false。

递归定时互斥

递归定时互斥是,正如其名称所示,定时互斥和递归互斥功能的融合。因此,它允许使用定时条件函数递归锁定互斥。

尽管这增加了确保互斥锁定的次数与线程锁定次数相同的挑战,但它仍然为更复杂的算法提供了可能性,比如前面提到的任务处理程序。

共享互斥

<shared_mutex>头文件是在 2014 年标准中首次添加的,通过添加shared_timed_mutex类。在 2017 年标准中,还添加了shared_mutex类。

自 C++17 以来,共享互斥头文件一直存在。除了通常的互斥访问之外,这个mutex类还增加了提供互斥访问的能力。这允许多个线程对资源进行读访问,而写线程仍然可以获得独占访问。这类似于 Pthreads 的读写锁。

添加到这种互斥类型的函数如下:

  • lock_shared()

  • try_lock_shared()

  • unlock_shared()

这种互斥的共享功能的使用应该是相当不言自明的。理论上,无限数量的读者可以获得对互斥的读访问,同时确保只有一个线程可以随时写入资源。

共享定时互斥

这个头文件自 C++14 以来一直存在。它通过这些函数向定时互斥添加了共享锁定功能:

  • lock_shared()

  • try_lock_shared()

  • try_lock_shared_for()

  • try_lock_shared_until()

  • unlock_shared()

这个类本质上是共享互斥和定时互斥的融合,正如其名称所示。这里有趣的是,它在更基本的共享互斥之前被添加到了标准中。

条件变量

从本质上讲,条件变量提供了一种机制,通过这种机制,一个线程的执行可以被另一个线程控制。这是通过一个共享变量来实现的,一个线程会等待这个变量,直到被另一个线程发出信号。这是我们在第十一章中看到的调度器实现的一个基本部分,线程同步和通信

对于 C++11 API,条件变量及其相关功能在<condition_variable>头文件中定义。

条件变量的基本用法可以从第十一章的调度器代码中总结出来,线程同步和通信

 #include "abstract_request.h"

 #include <condition_variable>
 #include <mutex> 

using namespace std;

 class Worker {
    condition_variable cv;
    mutex mtx;
    unique_lock<mutex> ulock;
    AbstractRequest* request;
    bool running;
    bool ready;
    public:
    Worker() { running = true; ready = false; ulock = unique_lock<mutex>(mtx); }
    void run();
    void stop() { running = false; }
    void setRequest(AbstractRequest* request) { this->request = request; ready = true; }
    void getCondition(condition_variable* &cv);
 }; 

在前面的Worker类声明中定义的构造函数中,我们看到了 C++11 API 中条件变量的初始化方式。步骤如下:

  1. 创建condition_variablemutex实例。

  2. 将互斥锁分配给一个新的unique_lock实例。使用我们在这里用于锁的构造函数,分配的互斥锁也在分配时被锁定。

  3. 条件变量现在可以使用了:

#include <chrono>
using namespace std;
void Worker::run() {
    while (running) {
        if (ready) {
            ready = false;
            request->process();
            request->finish();
        }
        if (Dispatcher::addWorker(this)) {
            while (!ready && running) {
                if (cv.wait_for(ulock, chrono::seconds(1)) == 
                cv_status::timeout) {
                    // We timed out, but we keep waiting unless the 
                    worker is
                    // stopped by the dispatcher.
                }
            }
        }
    }
} 

在这里,我们使用条件变量的wait_for()函数,并传递我们之前创建的唯一锁实例和我们想要等待的时间。这里我们等待 1 秒。如果我们在这个等待中超时,我们可以自由地重新进入等待(就像这里做的那样)在一个连续的循环中,或者继续执行。

还可以使用简单的wait()函数执行阻塞等待,或者使用wait_for()等待到某个特定的时间点。

正如我们之前看到的,这个工作线程的代码使用ready布尔变量的原因是为了检查是否真的是另一个线程发出了条件变量的信号,而不仅仅是一个虚假的唤醒。这是大多数条件变量实现(包括 C++11)都容易受到的不幸的复杂性。

由于这些随机唤醒事件,有必要确保我们确实是有意醒来的。在调度器代码中,这是通过唤醒工作线程的线程也设置一个Boolean值来完成的,工作线程可以唤醒。

我们是否超时,或者被通知,或者遭受虚假唤醒,都可以通过cv_status枚举来检查。这个枚举知道这两种可能的情况:

  • timeout

  • no_timeout

信号或通知本身非常简单:

void Dispatcher::addRequest(AbstractRequest* request) {
    workersMutex.lock();
    if (!workers.empty()) {
          Worker* worker = workers.front();
          worker->setRequest(request);
          condition_variable* cv;
          worker->getCondition(cv);
          cv->notify_one();
          workers.pop();
          workersMutex.unlock();
    }
    else {
          workersMutex.unlock();
          requestsMutex.lock();
          requests.push(request);
          requestsMutex.unlock();
    }
          } 

Dispatcher类的前面的函数中,我们尝试获取一个可用的工作线程实例。如果找到,我们按如下方式获取对工作线程条件变量的引用:

void Worker::getCondition(condition_variable* &cv) {
    cv = &(this)->cv;
 } 

设置工作线程上的新请求也会将ready变量的值更改为 true,从而允许工作线程检查它确实被允许继续。

最后,条件变量被通知,任何等待它的线程现在可以继续使用notify_one()。这个特定的函数将信号传递给条件变量中 FIFO 队列中的第一个线程。在这里,只有一个线程会被通知,但如果有多个线程在等待相同的条件变量,调用notify_all()将允许 FIFO 队列中的所有线程继续。

Condition_variable_any

condition_variable_any类是condition_variable类的泛化。它与后者的不同之处在于它允许使用除unique_lock<mutex>之外的其他互斥机制。唯一的要求是所使用的锁符合BasicLockable的要求,这意味着它提供了lock()unlock()函数。

在线程退出时通知所有

std::notify_all_at_thread_exit()函数允许(分离的)线程通知其他线程它已经完全完成,并且正在销毁其范围内的所有对象(线程本地)。它的功能是在发出提供的条件变量信号之前将提供的锁移动到内部存储中。

结果就像锁被解锁并且在条件变量上调用了notify_all()一样。

可以给出一个基本(非功能性)示例如下:

#include <mutex> 
#include <thread> 
#include <condition_variable> 
using namespace std; 

mutex m; 
condition_variable cv;
bool ready = false; 
ThreadLocal result;

void worker() { 
   unique_lock<mutex> ulock(m); 
   result = thread_local_method(); 
         ready = true; 
         std::notify_all_at_thread_exit(cv, std::move(ulock)); 
} 

int main() { 
         thread t(worker); 
         t.detach(); 

         // Do work here. 

         unique_lock<std::mutex> ulock(m); 
         while(!ready) { 
               cv.wait(ulock); 
         } 

         // Process result 
} 

在这里,工作线程执行一个创建线程本地对象的方法。因此,主线程必须首先等待分离的工作线程完成。如果主线程完成任务时后者尚未完成,它将使用全局条件变量进入等待。在工作线程中,设置ready布尔值后,调用std::notify_all_at_thread_exit()

这样做有两个目的。在调用函数后,不允许更多的线程等待条件变量。它还允许主线程等待分离的工作线程的结果变得可用。

Future

C++11 线程支持 API 的最后一部分在<future>中定义。它提供了一系列类,实现了更高级的多线程概念,旨在更容易地进行异步处理,而不是实现多线程架构。

在这里,我们必须区分两个概念:futurepromise。前者是最终结果(未来的产品),将被读取者/消费者使用。后者是写入者/生产者使用的。

future的一个基本示例是:

#include <iostream>
#include <future>
#include <chrono>

bool is_prime (int x) {
  for (int i = 2; i < x; ++i) if (x%i==0) return false;
  return true;
}

int main () {
  std::future<bool> fut = std::async (is_prime, 444444443);
  std::cout << "Checking, please wait";
  std::chrono::milliseconds span(100);
  while (fut.wait_for(span) == std::future_status::timeout) {               std::cout << '.' << std::flush;
   }

  bool x = fut.get();
  std::cout << "n444444443 " << (x?"is":"is not") << " prime.n";
  return 0;
}

这段代码异步调用一个函数,传递一个参数(可能是质数)。然后它进入一个活动循环,同时等待异步函数调用返回的future完成。它在等待函数上设置了 100 毫秒的超时。

一旦future完成(在等待函数上没有超时),我们就可以获得结果值,本例中告诉我们提供给函数的值实际上是一个质数。

在本章的async部分,我们将更详细地看一下异步函数调用。

Promise

promise允许在线程之间传输状态。例如:

#include <iostream> 
#include <functional>
#include <thread> 
#include <future> 

void print_int (std::future<int>& fut) {
  int x = fut.get();
  std::cout << "value: " << x << 'n';
}

int main () {
  std::promise<int> prom;
  std::future<int> fut = prom.get_future();
  std::thread th1 (print_int, std::ref(fut));
  prom.set_value (10);                            
  th1.join();
  return 0;

上面的代码使用了传递给工作线程的promise实例,以将一个值传输到另一个线程,本例中是一个整数。新线程等待我们从promise创建的future完成,这个future是从主线程接收到的。

当我们在promise上设置值时,promise就完成了。这完成了future并结束了工作线程。

在这个特定的例子中,我们对future对象进行了阻塞等待,但也可以使用wait_for()wait_until(),分别等待一段时间或一个时间点,就像我们在上一个例子中对future进行的操作一样。

共享 future

shared_future就像一个普通的future对象一样,但可以被复制,这允许多个线程读取其结果。

创建一个shared_future与创建一个普通的future类似。

std::promise<void> promise1; 
std::shared_future<void> sFuture(promise1.get_future()); 

最大的区别是普通的future被传递给它的构造函数。

之后,所有可以访问future对象的线程都可以等待它,并获取其值。这也可以用于类似条件变量的方式来通知线程。

包装任务

packaged_task是任何可调用目标(函数、绑定、lambda 或其他函数对象)的包装器。它允许异步执行,并将结果可用于future对象。它类似于std::function,但自动将其结果传输到future对象。

例如:

#include <iostream> 
#include <future> 
#include <chrono>
#include <thread>

using namespace std; 

int countdown (int from, int to) { 
   for (int i = from; i != to; --i) { 
         cout << i << 'n'; 
         this_thread::sleep_for(chrono::seconds(1)); 
   } 

   cout << "Finished countdown.n"; 
   return from - to; 
} 

int main () { 
   packaged_task<int(int, int)> task(countdown);
   future<int> result = task.get_future();
   thread t (std::move(task), 10, 0);

   //  Other logic. 

   int value = result.get(); 

   cout << "The countdown lasted for " << value << " seconds.n"; 

   t.join(); 
   return 0; 
} 

上面的代码实现了一个简单的倒计时功能,从 10 倒数到 0。创建任务并获取其future对象的引用后,我们将其推送到一个线程,同时传递worker函数的参数。

倒计时工作线程的结果在完成后立即可用。我们可以使用future对象的等待函数,方式与promise一样。

Async

promisepackaged_task的更简单的版本可以在std::async()中找到。这是一个简单的函数,它接受一个可调用对象(函数、绑定、lambda 等)以及它的任何参数,并返回一个future对象。

以下是async()函数的一个基本示例:

#include <iostream>
#include <future>

using namespace std; 

bool is_prime (int x) { 
   cout << "Calculating prime...n"; 
   for (int i = 2; i < x; ++i) { 
         if (x % i == 0) { 
               return false; 
         } 
   } 

   return true; 
} 

int main () { 
   future<bool> pFuture = std::async (is_prime, 343321); 

   cout << "Checking whether 343321 is a prime number.n"; 

   // Wait for future object to be ready. 

   bool result = pFuture.get(); 
   if (result) {
         cout << "Prime found.n"; 
   } 
   else { 
         cout << "No prime found.n"; 
   } 

   return 0; 
} 

前面代码中的worker函数确定提供的整数是否为质数。正如我们所看到的,结果代码比使用packaged_taskpromise要简单得多。

启动策略

除了std::async()的基本版本之外,还有第二个版本,允许将启动策略作为其第一个参数进行指定。这是一个std::launch类型的位掩码值,可能的取值如下:

* launch::async 
* launch::deferred 

async标志意味着立即为worker函数创建一个新线程和执行上下文。deferred标志意味着这将被推迟,直到在future对象上调用wait()get()。指定两个标志会导致函数根据当前系统情况自动选择方法。

未明确指定位掩码值的std::async()版本默认为后者,即自动方法。

原子操作

在多线程中,原子操作的使用也非常重要。C++11 STL 出于这个原因提供了一个<atomic>头文件。这个主题在第十五章中得到了广泛覆盖,即原子操作-与硬件交互

总结

在本章中,我们探讨了 C11 API 中的整个多线程支持,以及 C14 和 C++17 中添加的特性。

我们看到了如何使用描述和示例代码来使用每个特性。现在我们可以使用本机 C++多线程 API 来实现多线程、线程安全的代码,以及使用异步执行特性来加速并并行执行函数。

在下一章中,我们将看一下多线程代码实现中不可避免的下一步:调试和验证所得应用程序。

第十五章:调试多线程代码

理想情况下,一个人的代码第一次就能正常工作,并且不包含等待崩溃应用程序、损坏数据或引起其他问题的隐藏错误。现实情况当然是不可能的。因此,开发了一些工具,使得检查和调试多线程应用程序变得容易。

在本章中,我们将研究其中一些内容,包括常规调试器以及 Valgrind 套件的一些工具,特别是 Helgrind 和 DRD。我们还将研究如何对多线程应用程序进行分析,以查找设计中的热点和潜在问题。

本章涵盖的主题包括以下内容:

  • 介绍 Valgrind 工具套件

  • 使用 Helgrind 和 DRD 工具

  • 解释 Helgrind 和 DRD 分析结果

  • 对应用程序进行分析和分析结果

何时开始调试

理想情况下,每次达到特定里程碑时,无论是针对单个模块、多个模块还是整个应用程序,都应该测试和验证自己的代码。重要的是要确定自己的假设是否与最终功能相匹配。

特别是在多线程代码中,一个特定的错误状态在每次运行应用程序时都不能保证达到。实现不当的多线程应用程序可能会导致诸如看似随机崩溃等症状。

当应用程序崩溃并留下核心转储时,人们可能会得到的第一个提示是,某些地方出了问题。这是一个包含应用程序在崩溃时的内存内容的文件,包括堆栈。

这个核心转储可以以几乎与运行进程调试器相同的方式使用。检查我们崩溃的代码位置以及线程位置特别有用。我们也可以通过这种方式检查内存内容。

处理多线程问题的最佳指标之一是应用程序在不同位置从不崩溃(不同的堆栈跟踪),或者总是在执行互斥操作的地方崩溃,例如操作全局数据结构。

首先,我们将更深入地研究使用调试器进行诊断和调试,然后再深入研究 Valgrind 工具套件。

谦逊的调试器

开发人员可能会有许多问题,其中“为什么我的应用程序刚刚崩溃?”可能是最重要的问题之一。这也是调试器最容易回答的问题之一。无论是实时调试进程还是分析崩溃进程的核心转储,调试器都可以(希望)生成回溯,也称为堆栈跟踪。此跟踪包含自应用程序启动以来调用的所有函数的时间顺序列表,就像它们在堆栈上一样(有关堆栈工作原理的详细信息,请参见第九章,处理器和操作系统上的多线程实现)。

因此,回溯的最后几个条目将向我们显示代码的哪个部分出了问题。如果调试信息已编译到二进制文件中,或者提供给调试器,我们还可以看到该行的代码以及变量的名称。

更好的是,由于我们正在查看堆栈帧,我们还可以检查该堆栈帧中的变量。这意味着传递给函数的参数以及任何局部变量和它们的值。

为了使调试信息(符号)可用,必须使用适当的编译器标志编译源代码。对于 GCC,可以选择一系列调试信息级别和类型。通常,会使用-g标志并附加一个指定调试级别的整数,如下所示:

  • -g0:不生成调试信息(否定-g

  • -g1:有关函数描述和外部变量的最少信息

  • -g3:包括宏定义在内的所有信息

这个标志指示 GCC 以 OS 的本机格式生成调试信息。也可以使用不同的标志以特定格式生成调试信息;然而,这对于与 GCC 的调试器(GDB)以及 Valgrind 工具一起使用并不是必需的。

GDB 和 Valgrind 都将使用这些调试信息。虽然在没有调试信息的情况下使用它们是技术上可能的,但最好留给真正绝望的时候来练习。

GDB

用于基于 C 和基于 C++的代码的最常用的调试器之一是 GNU 调试器,简称 GDB。在下面的例子中,我们将使用这个调试器,因为它被广泛使用并且免费提供。最初于 1986 年编写,现在与各种编程语言一起使用,并且已成为个人和专业使用中最常用的调试器。

GDB 最基本的接口是一个命令行 shell,但它也可以与图形前端一起使用,其中还包括一些 IDE,如 Qt Creator、Dev-C++和 Code::Blocks。这些前端和 IDE 可以使管理断点、设置监视变量和执行其他常见操作变得更容易和更直观。然而,并不需要使用它们。

在 Linux 和 BSD 发行版上,gdb 可以很容易地从软件包中安装,就像在 Windows 上使用 MSYS2 和类似的类 UNIX 环境一样。对于 OS X/MacOS,可能需要使用 Homebrew 等第三方软件包管理器安装 gdb。

由于 gdb 在 MacOS 上通常没有代码签名,因此无法获得正常操作所需的系统级访问权限。在这里,可以以 root 身份运行 gdb(不建议),或者按照与您的 MacOS 版本相关的教程。

调试多线程代码

如前所述,有两种方法可以使用调试器,一种是从调试器内启动应用程序(或附加到正在运行的进程),另一种是加载核心转储文件。在调试会话中,可以中断运行的进程(使用Ctrl+C,发送SIGINT信号),或者加载加载的核心转储的调试符号。之后,我们可以检查这个框架中的活动线程:

Thread 1 received signal SIGINT, Interrupt.
0x00007fff8a3fff72 in mach_msg_trap () from /usr/lib/system/libsystem_kernel.dylib
(gdb) info threads
Id   Target Id         Frame 
* 1    Thread 0x1703 of process 72492 0x00007fff8a3fff72 in mach_msg_trap () from /usr/lib/system/libsystem_kernel.dylib
3    Thread 0x1a03 of process 72492 0x00007fff8a406efa in kevent_qos () from /usr/lib/system/libsystem_kernel.dylib
10   Thread 0x2063 of process 72492 0x00007fff8a3fff72 in mach_msg_trap () from /usr/lib/system/libsystem_kernel.dylibs
14   Thread 0x1e0f of process 72492 0x00007fff8a405d3e in __pselect () from /usr/lib/system/libsystem_kernel.dylib
(gdb) c
Continuing.

在上述代码中,我们可以看到在向应用程序发送SIGINT信号之后(一个在 OS X 上运行的基于 Qt 的应用程序),我们请求此时存在的所有线程的列表,以及它们的线程号、ID 和它们当前正在执行的函数。这也清楚地显示了根据后者信息,哪些线程可能正在等待,这在像这样的图形用户界面应用程序中经常发生。在这里,我们还可以看到当前活动的线程,由其编号前的星号标记(线程 1)。

我们还可以使用thread <ID>命令随意在线程之间切换,并在线程的堆栈帧之间移动updown。这使我们能够检查每个线程的每个方面。

当完整的调试信息可用时,通常还会看到线程正在执行的确切代码行。这意味着在应用程序的开发阶段,有尽可能多的调试信息可用是有意义的,以使调试变得更容易。

断点

对于我们在第四章中查看的调度器代码,线程同步和通信,我们可以设置一个断点,以便我们可以检查活动线程:

$ gdb dispatcher_demo.exe 
GNU gdb (GDB) 7.9 
Copyright (C) 2015 Free Software Foundation, Inc. 
Reading symbols from dispatcher_demo.exe...done. 
(gdb) break main.cpp:67 
Breakpoint 1 at 0x4017af: file main.cpp, line 67\. 
(gdb) run 
Starting program: dispatcher_demo.exe 
[New Thread 10264.0x2a90] 
[New Thread 10264.0x2bac] 
[New Thread 10264.0x2914] 
[New Thread 10264.0x1b80] 
[New Thread 10264.0x213c] 
[New Thread 10264.0x2228] 
[New Thread 10264.0x2338] 
[New Thread 10264.0x270c] 
[New Thread 10264.0x14ac] 
[New Thread 10264.0x24f8] 
[New Thread 10264.0x1a90] 

正如我们在上面的命令行输出中所看到的,我们以应用程序的名称作为参数启动 GDB,这里是在 Windows 下的 Bash shell 中。之后,我们可以在这里设置一个断点,使用源文件的文件名和我们希望在(gdb)后面中断的行号作为 gdb 命令行输入。我们选择在发送请求给调度程序的循环之后的第一行,然后运行应用程序。这之后是由 GDB 报告的调度程序创建的新线程的列表。

接下来,我们等待直到断点被触发:

Breakpoint 1, main () at main.cpp:67 
67              this_thread::sleep_for(chrono::seconds(5)); 
(gdb) info threads 
Id   Target Id         Frame 
11   Thread 10264.0x1a90 0x00000000775ec2ea in ntdll!ZwWaitForMultipleObjects () from /c/Windows/SYSTEM32/ntdll.dll 
10   Thread 10264.0x24f8 0x00000000775ec2ea in ntdll!ZwWaitForMultipleObjects () from /c/Windows/SYSTEM32/ntdll.dll 
9    Thread 10264.0x14ac 0x00000000775ec2ea in ntdll!ZwWaitForMultipleObjects () from /c/Windows/SYSTEM32/ntdll.dll 
8    Thread 10264.0x270c 0x00000000775ec2ea in ntdll!ZwWaitForMultipleObjects () from /c/Windows/SYSTEM32/ntdll.dll 
7    Thread 10264.0x2338 0x00000000775ec2ea in ntdll!ZwWaitForMultipleObjects () from /c/Windows/SYSTEM32/ntdll.dll 
6    Thread 10264.0x2228 0x00000000775ec2ea in ntdll!ZwWaitForMultipleObjects () from /c/Windows/SYSTEM32/ntdll.dll 
5    Thread 10264.0x213c 0x00000000775ec2ea in ntdll!ZwWaitForMultipleObjects () from /c/Windows/SYSTEM32/ntdll.dll 
4    Thread 10264.0x1b80 0x0000000064942eaf in ?? () from /mingw64/bin/libwinpthread-1.dll 
3    Thread 10264.0x2914 0x00000000775c2385 in ntdll!LdrUnloadDll () from /c/Windows/SYSTEM32/ntdll.dll 
2    Thread 10264.0x2bac 0x00000000775c2385 in ntdll!LdrUnloadDll () from /c/Windows/SYSTEM32/ntdll.dll 
* 1    Thread 10264.0x2a90 main () at main.cpp:67 
(gdb) bt 
#0  main () at main.cpp:67 
(gdb) c 
Continuing. 

到达断点后,info threads命令列出了活动线程。在这里,我们可以清楚地看到条件变量的使用,其中一个线程在ntdll!ZwWaitForMultipleObjects()中等待。正如第三章中所介绍的,C++多线程 API,这是在 Windows 上使用其本机多线程 API 实现的条件变量。

当我们创建一个回溯(bt命令)时,我们可以看到线程 1(当前线程)的当前堆栈只有一个帧,只有主方法,因为我们从这个起始点没有调用其他函数。

回溯

在正常的应用程序执行期间,例如我们之前看过的 GUI 应用程序,向应用程序发送SIGINT也可以跟随着创建回溯的命令,就像这样:

Thread 1 received signal SIGINT, Interrupt.
0x00007fff8a3fff72 in mach_msg_trap () from /usr/lib/system/libsystem_kernel.dylib
(gdb) bt
#0  0x00007fff8a3fff72 in mach_msg_trap () from /usr/lib/system/libsystem_kernel.dylib
#1  0x00007fff8a3ff3b3 in mach_msg () from /usr/lib/system/libsystem_kernel.dylib
#2  0x00007fff99f37124 in __CFRunLoopServiceMachPort () from /System/Library/Frameworks/CoreFoundation.framework/Versions/A/CoreFoundation
#3  0x00007fff99f365ec in __CFRunLoopRun () from /System/Library/Frameworks/CoreFoundation.framework/Versions/A/CoreFoundation
#4  0x00007fff99f35e38 in CFRunLoopRunSpecific () from /System/Library/Frameworks/CoreFoundation.framework/Versions/A/CoreFoundation
#5  0x00007fff97b73935 in RunCurrentEventLoopInMode ()
from /System/Library/Frameworks/Carbon.framework/Versions/A/Frameworks/HIToolbox.framework/Versions/A/HIToolbox
#6  0x00007fff97b7376f in ReceiveNextEventCommon ()
from /System/Library/Frameworks/Carbon.framework/Versions/A/Frameworks/HIToolbox.framework/Versions/A/HIToolbox
#7  0x00007fff97b735af in _BlockUntilNextEventMatchingListInModeWithFilter ()
from /System/Library/Frameworks/Carbon.framework/Versions/A/Frameworks/HIToolbox.framework/Versions/A/HIToolbox
#8  0x00007fff9ed3cdf6 in _DPSNextEvent () from /System/Library/Frameworks/AppKit.framework/Versions/C/AppKit
#9  0x00007fff9ed3c226 in -[NSApplication _nextEventMatchingEventMask:untilDate:inMode:dequeue:] ()
from /System/Library/Frameworks/AppKit.framework/Versions/C/AppKit
#10 0x00007fff9ed30d80 in -[NSApplication run] () from /System/Library/Frameworks/AppKit.framework/Versions/C/AppKit
#11 0x0000000102a25143 in qt_plugin_instance () from /usr/local/Cellar/qt/5.8.0_1/plugins/platforms/libqcocoa.dylib
#12 0x0000000100cd3811 in QEventLoop::exec(QFlags<QEventLoop::ProcessEventsFlag>) () from /usr/local/opt/qt5/lib/QtCore.framework/Versions/5/QtCore
#13 0x0000000100cd80a7 in QCoreApplication::exec() () from /usr/local/opt/qt5/lib/QtCore.framework/Versions/5/QtCore
#14 0x0000000100003956 in main (argc=<optimized out>, argv=<optimized out>) at main.cpp:10
(gdb) c
Continuing.

在上述代码中,我们可以看到线程 ID 1 的执行从创建开始,通过入口点(main)。每个后续的函数调用都被添加到堆栈中。当一个函数结束时,它就从堆栈中移除了。这既是一个好处,也是一个缺点。虽然它确实保持了回溯的整洁,但也意味着在最后一个函数调用之前发生的历史不再存在。

如果我们使用核心转储文件创建一个回溯,没有这些历史信息可能会非常恼人,并且可能会让人在试图缩小崩溃原因的范围时陷入困境。这意味着需要一定水平的经验才能成功调试。

在应用程序崩溃的情况下,调试器会将我们带到遭受崩溃的线程上。通常,这是有问题的代码所在的线程,但也可能是真正的错误在于另一个线程执行的代码,甚至是变量的不安全使用。如果一个线程改变了另一个线程当前正在读取的信息,后者可能会得到垃圾数据。这可能导致崩溃,甚至更糟糕的是--在应用程序的后续过程中出现损坏。

最坏的情况是堆栈被覆盖,例如,被一个野指针。在这种情况下,堆栈上的缓冲区或类似的东西被写入超出其限制,从而用新数据填充它来擦除堆栈的部分。这就是缓冲区溢出,可能导致应用程序崩溃,或者(恶意)利用应用程序。

动态分析工具

尽管调试器的价值难以忽视,但有时需要不同类型的工具来回答关于内存使用、泄漏以及诊断或预防线程问题等问题。这就是 Valgrind 套件中的工具可以提供很大帮助的地方。作为构建动态分析工具的框架,Valgrind 发行版目前包含以下对我们感兴趣的工具:

  • 内存检查

  • Helgrind

  • DRD

Memcheck 是一个内存错误检测器,它允许我们发现内存泄漏、非法读写,以及分配、释放和类似的与内存相关的问题。

Helgrind 和 DRD 都是线程错误检测器。这基本上意味着它们将尝试检测任何多线程问题,如数据竞争和互斥锁的不正确使用。它们的区别在于 Helgrind 可以检测锁定顺序的违规,而 DRD 支持分离线程,同时使用的内存比 Helgrind 少。

限制

动态分析工具的一个主要限制是它们需要与主机操作系统紧密集成。这是 Valgrind 专注于 POSIX 线程的主要原因,目前无法在 Windows 上运行的主要原因。

Valgrind 网站(valgrind.org/info/platforms.html)对该问题的描述如下:

“Windows 不在考虑范围内,因为将其移植到 Windows 需要进行如此多的更改,几乎可以成为一个独立的项目。(但是,Valgrind + Wine 可以通过一些努力使其工作。)此外,非开源操作系统很难处理;能够看到操作系统和相关(libc)源代码使事情变得更容易。但是,Valgrind 与 Wine 结合使用非常方便,这意味着可以通过一些努力在 Valgrind 下运行 Windows 程序。”

基本上,这意味着可以在 Linux 下使用 Valgrind 调试 Windows 应用程序,但在短期内不太可能使用 Windows 作为操作系统。

Valgrind 可以在 OS X/macOS 上运行,从 OS X 10.8(Mountain Lion)开始。由于苹果公司的更改,对最新版本的 macOS 的支持可能会有些不完整。与 Valgrind 的 Linux 版本一样,通常最好始终使用最新版本的 Valgrind。与 gdb 一样,使用发行版的软件包管理器,或者在 MacOS 上使用 Homebrew 等第三方软件包管理器。

替代方案

在 Windows 和其他平台上,Valgrind 工具的替代方案包括以下表中列出的工具:

名称 类型 平台 许可证
Dr. Memory 内存检查器 所有主要平台 开源
gperftools(Google) 堆,CPU 和调用分析器 Linux(x86) 开源
Visual Leak Detector 内存检查器 Windows(Visual Studio) 开源
Intel Inspector 内存和线程调试器 Windows,Linux 专有
PurifyPlus 内存,性能 Windows,Linux 专有
Parasoft Insure++ 内存和线程调试器 Windows,Solaris,Linux,AIX 专有

Memcheck

当可执行文件的参数中未指定其他工具时,Memcheck 是默认的 Valgrind 工具。Memcheck 本身是一个内存错误检测器,能够检测以下类型的问题:

  • 访问超出分配边界的内存,堆栈溢出以及访问先前释放的内存块

  • 使用未定义值,即未初始化的变量

  • 不正确释放堆内存,包括重复释放块

  • C 和 C++风格内存分配的不匹配使用,以及数组分配器和释放器(new[]delete[]

  • memcpy等函数中重叠源和目标指针

  • 将无效值(例如负值)作为malloc或类似函数的大小参数传递

  • 内存泄漏;即,没有任何有效引用的堆块

使用调试器或简单的任务管理器,几乎不可能检测到前面列表中给出的问题。Memcheck 的价值在于能够在开发的早期检测和修复问题,否则可能会导致数据损坏和神秘崩溃。

基本用法

使用 Memcheck 非常容易。如果我们使用第四章中创建的演示应用程序,线程同步和通信,我们知道通常我们会这样启动它:

$ ./dispatcher_demo

使用默认的 Memcheck 工具运行 Valgrind,并将结果输出到日志文件中,我们可以按照以下方式启动它:

$ valgrind --log-file=dispatcher.log --read-var-info=yes --leak-check=full ./dispatcher_demo

通过上述命令,我们将 Memcheck 的输出记录到一个名为 dispatcher.log 的文件中,并且还启用了对内存泄漏的全面检查,包括详细报告这些泄漏发生的位置,使用二进制文件中可用的调试信息。通过读取变量信息(--read-var-info=yes),我们可以获得更详细的关于内存泄漏发生位置的信息。

不能将日志记录到文件中,但除非是一个非常简单的应用程序,否则 Valgrind 生成的输出可能会非常多,可能无法适应终端缓冲区。将输出作为文件允许将来使用它作为参考,并使用比终端通常提供的更高级的工具进行搜索。

运行完这个之后,我们可以按以下方式检查生成的日志文件的内容:

==5764== Memcheck, a memory error detector
==5764== Copyright (C) 2002-2015, and GNU GPL'd, by Julian Seward et al.
==5764== Using Valgrind-3.11.0 and LibVEX; rerun with -h for copyright info
==5764== Command: ./dispatcher_demo
==5764== Parent PID: 2838
==5764==
==5764==
==5764== HEAP SUMMARY:
==5764==     in use at exit: 75,184 bytes in 71 blocks
==5764==   total heap usage: 260 allocs, 189 frees, 88,678 bytes allocated
==5764==
==5764== 80 bytes in 10 blocks are definitely lost in loss record 1 of 5
==5764==    at 0x4C2E0EF: operator new(unsigned long) (in /usr/lib/valgrind/vgpreload_memcheck-amd64-linux.so)
==5764==    by 0x402EFD: Dispatcher::init(int) (dispatcher.cpp:40)
==5764==    by 0x409300: main (main.cpp:51)
==5764==
==5764== 960 bytes in 40 blocks are definitely lost in loss record 3 of 5
==5764==    at 0x4C2E0EF: operator new(unsigned long) (in /usr/lib/valgrind/vgpreload_memcheck-amd64-linux.so)
==5764==    by 0x409338: main (main.cpp:60)
==5764==
==5764== 1,440 (1,200 direct, 240 indirect) bytes in 10 blocks are definitely lost in loss record 4 of 5
==5764==    at 0x4C2E0EF: operator new(unsigned long) (in /usr/lib/valgrind/vgpreload_memcheck-amd64-linux.so)
==5764==    by 0x402EBB: Dispatcher::init(int) (dispatcher.cpp:38)
==5764==    by 0x409300: main (main.cpp:51)
==5764==
==5764== LEAK SUMMARY:
==5764==    definitely lost: 2,240 bytes in 60 blocks
==5764==    indirectly lost: 240 bytes in 10 blocks
==5764==      possibly lost: 0 bytes in 0 blocks
==5764==    still reachable: 72,704 bytes in 1 blocks
==5764==         suppressed: 0 bytes in 0 blocks
==5764== Reachable blocks (those to which a pointer was found) are not shown.
==5764== To see them, rerun with: --leak-check=full --show-leak-kinds=all
==5764==
==5764== For counts of detected and suppressed errors, rerun with: -v
==5764== ERROR SUMMARY: 3 errors from 3 contexts (suppressed: 0 from 0) 

在这里,我们可以看到总共有三个内存泄漏。其中两个是在第 38 和 40 行的 dispatcher 类中分配的:

w = new Worker; 

另一个是:

t = new thread(&Worker::run, w); 

我们还看到在 main.cpp 的第 60 行分配了一个泄漏:

rq = new Request(); 

虽然这些分配本身没有问题,但是如果我们在应用程序生命周期中跟踪它们,我们会注意到我们从未在这些对象上调用 delete。如果我们要修复这些内存泄漏,我们需要在完成后删除这些 Request 实例,并在 dispatcher 类的析构函数中清理 Workerthread 实例。

在这个演示应用程序中,整个应用程序在运行结束时由操作系统终止和清理,因此这并不是一个真正的问题。对于一个使用相同的调度程序以一种不断生成和添加新请求的方式使用的应用程序,同时可能还动态扩展工作线程的数量,这将是一个真正的问题。在这种情况下,必须小心解决这些内存泄漏。

错误类型

Memcheck 可以检测到各种与内存相关的问题。以下部分总结了这些错误及其含义。

非法读取/非法写入错误

这些错误通常以以下格式报告:

Invalid read of size <bytes>
at 0x<memory address>: (location)
by 0x<memory address>: (location)
by 0x<memory address>: (location)
Address 0x<memory address> <error description>

前面错误消息中的第一行告诉我们是否是无效的读取或写入访问。接下来的几行将是一个回溯,详细说明了发生无效读取或写入的位置(可能还包括源文件中的行),以及从哪里调用了该代码。

最后,最后一行将详细说明发生的非法访问类型,例如读取已释放的内存块。

这种类型的错误表明写入或读取不应访问的内存部分。这可能是因为访问了野指针(即引用随机内存地址),或者由于代码中的早期问题导致计算了错误的内存地址,或者没有尊重内存边界,读取了数组或类似结构的边界之外。

通常,当报告这种类型的错误时,应该非常重视,因为它表明了一个基本问题,不仅可能导致数据损坏和崩溃,还可能导致其他人可以利用的错误。

使用未初始化的值

简而言之,这是一个问题,即在未为变量分配值的情况下使用变量的值。此时,很可能这些内容只是刚刚分配的 RAM 部分中的任何字节。因此,每当使用或访问这些内容时,可能会导致不可预测的行为。

遇到时,Memcheck 将抛出类似于这些的错误:

$ valgrind --read-var-info=yes --leak-check=full ./unval
==6822== Memcheck, a memory error detector
==6822== Copyright (C) 2002-2015, and GNU GPL'd, by Julian Seward et al.
==6822== Using Valgrind-3.11.0 and LibVEX; rerun with -h for copyright info
==6822== Command: ./unval
==6822== 
==6822== Conditional jump or move depends on uninitialised value(s)
==6822==    at 0x4E87B83: vfprintf (vfprintf.c:1631)
==6822==    by 0x4E8F898: printf (printf.c:33)
==6822==    by 0x400541: main (unval.cpp:6)
==6822== 
==6822== Use of uninitialised value of size 8
==6822==    at 0x4E8476B: _itoa_word (_itoa.c:179)
==6822==    by 0x4E8812C: vfprintf (vfprintf.c:1631)
==6822==    by 0x4E8F898: printf (printf.c:33)
==6822==    by 0x400541: main (unval.cpp:6)
==6822== 
==6822== Conditional jump or move depends on uninitialised value(s)
==6822==    at 0x4E84775: _itoa_word (_itoa.c:179)
==6822==    by 0x4E8812C: vfprintf (vfprintf.c:1631)
==6822==    by 0x4E8F898: printf (printf.c:33)
==6822==    by 0x400541: main (unval.cpp:6)
==6822== 
==6822== Conditional jump or move depends on uninitialised value(s)
==6822==    at 0x4E881AF: vfprintf (vfprintf.c:1631)
==6822==    by 0x4E8F898: printf (printf.c:33)
==6822==    by 0x400541: main (unval.cpp:6)
==6822== 
==6822== Conditional jump or move depends on uninitialised value(s)
==6822==    at 0x4E87C59: vfprintf (vfprintf.c:1631)
==6822==    by 0x4E8F898: printf (printf.c:33)
==6822==    by 0x400541: main (unval.cpp:6)
==6822== 
==6822== Conditional jump or move depends on uninitialised value(s)
==6822==    at 0x4E8841A: vfprintf (vfprintf.c:1631)
==6822==    by 0x4E8F898: printf (printf.c:33)
==6822==    by 0x400541: main (unval.cpp:6)
==6822== 
==6822== Conditional jump or move depends on uninitialised value(s)
==6822==    at 0x4E87CAB: vfprintf (vfprintf.c:1631)
==6822==    by 0x4E8F898: printf (printf.c:33)
==6822==    by 0x400541: main (unval.cpp:6)
==6822== 
==6822== Conditional jump or move depends on uninitialised value(s)
==6822==    at 0x4E87CE2: vfprintf (vfprintf.c:1631)
==6822==    by 0x4E8F898: printf (printf.c:33)
==6822==    by 0x400541: main (unval.cpp:6)
==6822== 
==6822== 
==6822== HEAP SUMMARY:
==6822==     in use at exit: 0 bytes in 0 blocks
==6822==   total heap usage: 1 allocs, 1 frees, 1,024 bytes allocated
==6822== 
==6822== All heap blocks were freed -- no leaks are possible
==6822== 
==6822== For counts of detected and suppressed errors, rerun with: -v
==6822== Use --track-origins=yes to see where uninitialised values come from
==6822== ERROR SUMMARY: 8 errors from 8 contexts (suppressed: 0 from 0)

这一系列特定的错误是由以下一小段代码引起的:

#include <cstring>
 #include <cstdio>

 int main() {
    int x;  
    printf ("x = %dn", x); 
    return 0;
 } 

正如我们在前面的代码中看到的,我们从未初始化我们的变量,这将设置为任何随机值。如果幸运的话,它将被设置为零,或者一个同样(希望)无害的值。这段代码展示了我们的任何未初始化变量如何进入库代码。

未初始化变量的使用是否有害很难说,这在很大程度上取决于变量的类型和受影响的代码。然而,简单地分配一个安全的默认值要比追踪和调试可能由未初始化变量(随机)引起的神秘问题要容易得多。

要了解未初始化变量的来源,可以向 Memcheck 传递-track-origins=yes标志。这将告诉它为每个变量保留更多信息,从而使追踪此类问题变得更容易。

未初始化或不可寻址的系统调用值

每当调用一个函数时,可能会传递未初始化的值作为参数,甚至是指向不可寻址的缓冲区的指针。在任何一种情况下,Memcheck 都会记录这一点:

$ valgrind --read-var-info=yes --leak-check=full ./unsyscall
==6848== Memcheck, a memory error detector
==6848== Copyright (C) 2002-2015, and GNU GPL'd, by Julian Seward et al.
==6848== Using Valgrind-3.11.0 and LibVEX; rerun with -h for copyright info
==6848== Command: ./unsyscall
==6848== 
==6848== Syscall param write(buf) points to uninitialised byte(s)
==6848==    at 0x4F306E0: __write_nocancel (syscall-template.S:84)
==6848==    by 0x4005EF: main (unsyscall.cpp:7)
==6848==  Address 0x5203040 is 0 bytes inside a block of size 10 alloc'd
==6848==    at 0x4C2DB8F: malloc (in /usr/lib/valgrind/vgpreload_memcheck-amd64-linux.so)
==6848==    by 0x4005C7: main (unsyscall.cpp:5)
==6848== 
==6848== Syscall param exit_group(status) contains uninitialised byte(s)
==6848==    at 0x4F05B98: _Exit (_exit.c:31)
==6848==    by 0x4E73FAA: __run_exit_handlers (exit.c:97)
==6848==    by 0x4E74044: exit (exit.c:104)
==6848==    by 0x4005FC: main (unsyscall.cpp:8)
==6848== 
==6848== 
==6848== HEAP SUMMARY:
==6848==     in use at exit: 14 bytes in 2 blocks
==6848==   total heap usage: 2 allocs, 0 frees, 14 bytes allocated
==6848== 
==6848== LEAK SUMMARY:
==6848==    definitely lost: 0 bytes in 0 blocks
==6848==    indirectly lost: 0 bytes in 0 blocks
==6848==      possibly lost: 0 bytes in 0 blocks
==6848==    still reachable: 14 bytes in 2 blocks
==6848==         suppressed: 0 bytes in 0 blocks
==6848== Reachable blocks (those to which a pointer was found) are not shown.
==6848== To see them, rerun with: --leak-check=full --show-leak-kinds=all
==6848== 
==6848== For counts of detected and suppressed errors, rerun with: -v
==6848== Use --track-origins=yes to see where uninitialised values come from
==6848== ERROR SUMMARY: 2 errors from 2 contexts (suppressed: 0 from 0)

前面的日志是由以下代码生成的:

#include <cstdlib>
 #include <unistd.h> 

 int main() {  
    char* arr  = (char*) malloc(10);  
    int*  arr2 = (int*) malloc(sizeof(int));  
    write(1, arr, 10 ); 
    exit(arr2[0]);
 } 

与前一节详细介绍的未初始化值的一般使用情况类似,传递未初始化或其他可疑的参数至少是有风险的,而在最坏的情况下,可能会导致崩溃、数据损坏或更糟。

非法释放

非法的释放或删除通常是试图在已经释放的内存块上重复调用free()delete()。虽然不一定有害,但这表明了糟糕的设计,并且绝对必须修复。

当试图使用不指向该内存块开头的指针释放内存块时,也会发生这种情况。这是为什么永远不应该对从malloc()new()调用中获得的原始指针进行指针算术运算,而应该使用副本的主要原因之一。

不匹配的释放

内存块的分配和释放应始终使用匹配函数执行。这意味着当我们使用 C 风格的函数进行分配时,我们使用相同 API 的匹配函数进行释放。对于 C++风格的分配和释放也是如此。

简而言之,这意味着以下内容:

  • 如果我们使用malloccallocvallocreallocmemalign进行分配,我们使用free进行释放

  • 如果我们使用 new 进行分配,我们使用delete进行释放

  • 如果我们使用new[]进行分配,我们使用delete[]进行释放

混合使用这些不一定会引起问题,但这样做是未定义的行为。后一种分配和释放是特定于数组的。不使用delete[]释放使用new[]分配的数组可能会导致内存泄漏,甚至更糟。

重叠的源和目的地

这种类型的错误表明传递给源和目的地内存块的指针重叠(基于预期大小)。这种错误的结果通常是一种形式的损坏或系统崩溃。

可疑的参数值

对于内存分配函数,Memcheck 验证传递给它们的参数是否真的有意义。其中一个例子是传递负大小,或者它将远远超出合理的分配大小:例如,请求分配一百万兆字节的内存。很可能,这些值是代码中早期计算错误的结果。

Memcheck 会像在 Memcheck 手册中的这个例子中报告这个错误:

==32233== Argument 'size' of function malloc has a fishy (possibly negative) value: -3
==32233==    at 0x4C2CFA7: malloc (vg_replace_malloc.c:298)
==32233==    by 0x400555: foo (fishy.c:15)
==32233==    by 0x400583: main (fishy.c:23)

在这里尝试将值-3 传递给malloc,这显然没有多大意义。由于这显然是一个荒谬的操作,这表明代码中存在严重的错误。

内存泄漏检测

对于 Memcheck 报告的内存泄漏,最重要的是,许多报告的泄漏实际上可能并不是泄漏。这反映在 Memcheck 报告它发现的任何潜在问题的方式上,如下所示:

  • 明确丢失

  • 间接丢失

  • 可能丢失

在三种可能的报告类型中,明确丢失类型是唯一一种绝对确定相关内存块不再可达的类型,没有指针或引用剩余,这使得应用程序永远无法释放内存。

间接丢失类型的情况下,我们没有丢失这些内存块本身的指针,而是丢失了指向这些块的结构的指针。例如,当我们直接丢失对数据结构的根节点(如红黑树或二叉树)的访问权限时,就会发生这种情况。结果,我们也失去了访问任何子节点的能力。

最后,可能丢失是一个包罗万象的类型,Memcheck 并不完全确定内存块是否仍然有引用。这可能发生在存在内部指针的情况下,例如特定类型的数组分配。它也可能通过多重继承发生,其中 C++对象使用自引用。

如前面在 Memcheck 的基本使用部分提到的,建议始终使用--leak-check=full来运行 Memcheck,以获取关于内存泄漏位置的详细信息。

Helgrind

Helgrind 的目的是检测多线程应用程序中同步实现的问题。它可以检测到对 POSIX 线程的错误使用,由于错误的锁定顺序而导致的潜在死锁问题,以及数据竞争--在没有线程同步的情况下读取或写入数据。

基本使用

我们以以下方式启动 Helgrind:

$ valgrind --tool=helgrind --read-var-info=yes --log-file=dispatcher_helgrind.log ./dispatcher_demo

与运行 Memcheck 类似,这将运行应用程序并将所有生成的输出记录到日志文件中,同时明确使用二进制文件中的所有可用调试信息。

运行应用程序后,我们检查生成的日志文件:

==6417== Helgrind, a thread error detector
==6417== Copyright (C) 2007-2015, and GNU GPL'd, by OpenWorks LLP et al.
==6417== Using Valgrind-3.11.0 and LibVEX; rerun with -h for copyright info
==6417== Command: ./dispatcher_demo
==6417== Parent PID: 2838
==6417== 
==6417== ---Thread-Announcement------------------------------------------
==6417== 
==6417== Thread #1 is the program's root thread 

在关于应用程序和 Valgrind 版本的初始基本信息之后,我们被告知已创建了根线程:

==6417== 
==6417== ---Thread-Announcement------------------------------------------
==6417== 
==6417== Thread #2 was created
==6417==    at 0x56FB7EE: clone (clone.S:74)
==6417==    by 0x53DE149: create_thread (createthread.c:102)
==6417==    by 0x53DFE83: pthread_create@@GLIBC_2.2.5 (pthread_create.c:679)
==6417==    by 0x4C34BB7: ??? (in /usr/lib/valgrind/vgpreload_helgrind-amd64-linux.so)
==6417==    by 0x4EF8DC2: std::thread::_M_start_thread(std::shared_ptr<std::thread::_Impl_base>, void (*)()) (in /usr/lib/x86_64-linux-gnu/libstdc++.so.6.0.21)
==6417==    by 0x403AD7: std::thread::thread<void (Worker::*)(), Worker*&>(void (Worker::*&&)(), Worker*&) (thread:137)
==6417==    by 0x4030E6: Dispatcher::init(int) (dispatcher.cpp:40)
==6417==    by 0x4090A0: main (main.cpp:51)
==6417== 
==6417== ----------------------------------------------------------------

第一个线程是由调度程序创建并记录的。接下来我们收到第一个警告:

==6417== 
==6417==  Lock at 0x60F4A0 was first observed
==6417==    at 0x4C321BC: ??? (in /usr/lib/valgrind/vgpreload_helgrind-amd64-linux.so)
==6417==    by 0x401CD1: __gthread_mutex_lock(pthread_mutex_t*) (gthr-default.h:748)
==6417==    by 0x402103: std::mutex::lock() (mutex:135)
==6417==    by 0x40337E: Dispatcher::addWorker(Worker*) (dispatcher.cpp:108)
==6417==    by 0x401DF9: Worker::run() (worker.cpp:49)
==6417==    by 0x408FA4: void std::_Mem_fn_base<void (Worker::*)(), true>::operator()<, void>(Worker*) const (in /media/sf_Projects/Cerflet/dispatcher/dispatcher_demo)
==6417==    by 0x408F38: void std::_Bind_simple<std::_Mem_fn<void (Worker::*)()> (Worker*)>::_M_invoke<0ul>(std::_Index_tuple<0ul>) (functional:1531)
==6417==    by 0x408E3F: std::_Bind_simple<std::_Mem_fn<void (Worker::*)()> (Worker*)>::operator()() (functional:1520)
==6417==    by 0x408D47: std::thread::_Impl<std::_Bind_simple<std::_Mem_fn<void (Worker::*)()> (Worker*)> >::_M_run() (thread:115)
==6417==    by 0x4EF8C7F: ??? (in /usr/lib/x86_64-linux-gnu/libstdc++.so.6.0.21)
==6417==    by 0x4C34DB6: ??? (in /usr/lib/valgrind/vgpreload_helgrind-amd64-linux.so)
==6417==    by 0x53DF6B9: start_thread (pthread_create.c:333)
==6417==  Address 0x60f4a0 is 0 bytes inside data symbol "_ZN10Dispatcher12workersMutexE"
==6417== 
==6417== Possible data race during write of size 1 at 0x5CD9261 by thread #1
==6417== Locks held: 1, at address 0x60F4A0
==6417==    at 0x403650: Worker::setRequest(AbstractRequest*) (worker.h:38)
==6417==    by 0x403253: Dispatcher::addRequest(AbstractRequest*) (dispatcher.cpp:70)
==6417==    by 0x409132: main (main.cpp:63)
==6417== 
==6417== This conflicts with a previous read of size 1 by thread #2
==6417== Locks held: none
==6417==    at 0x401E02: Worker::run() (worker.cpp:51)
==6417==    by 0x408FA4: void std::_Mem_fn_base<void (Worker::*)(), true>::operator()<, void>(Worker*) const (in /media/sf_Projects/Cerflet/dispatcher/dispatcher_demo)
==6417==    by 0x408F38: void std::_Bind_simple<std::_Mem_fn<void (Worker::*)()> (Worker*)>::_M_invoke<0ul>(std::_Index_tuple<0ul>) (functional:1531)
==6417==    by 0x408E3F: std::_Bind_simple<std::_Mem_fn<void (Worker::*)()> (Worker*)>::operator()() (functional:1520)
==6417==    by 0x408D47: std::thread::_Impl<std::_Bind_simple<std::_Mem_fn<void (Worker::*)()> (Worker*)> >::_M_run() (thread:115)
==6417==    by 0x4EF8C7F: ??? (in /usr/lib/x86_64-linux-gnu/libstdc++.so.6.0.21)
==6417==    by 0x4C34DB6: ??? (in /usr/lib/valgrind/vgpreload_helgrind-amd64-linux.so)
==6417==    by 0x53DF6B9: start_thread (pthread_create.c:333)
==6417==  Address 0x5cd9261 is 97 bytes inside a block of size 104 alloc'd
==6417==    at 0x4C2F50F: operator new(unsigned long) (in /usr/lib/valgrind/vgpreload_helgrind-amd64-linux.so)
==6417==    by 0x40308F: Dispatcher::init(int) (dispatcher.cpp:38)
==6417==    by 0x4090A0: main (main.cpp:51)
==6417==  Block was alloc'd by thread #1
==6417== 
==6417== ----------------------------------------------------------------

在前面的警告中,Helgrind 告诉我们线程 ID 1 和 2 之间存在大小为 1 的冲突读取。由于 C++11 线程 API 使用了大量模板,跟踪可能有些难以阅读。关键在于这些行:

==6417==    at 0x403650: Worker::setRequest(AbstractRequest*) (worker.h:38) ==6417==    at 0x401E02: Worker::run() (worker.cpp:51) 

这对应以下代码行:

void setRequest(AbstractRequest* request) { this->request = request; ready = true; }
while (!ready && running) { 

这些代码行中唯一大小为 1 的变量是布尔变量ready。由于这是一个布尔变量,我们知道它是一个原子操作(详见第十五章,原子操作-与硬件交互)。因此,我们可以忽略这个警告。

接下来,我们为这个线程收到另一个警告:

==6417== Possible data race during write of size 1 at 0x5CD9260 by thread #1
==6417== Locks held: none
==6417==    at 0x40362C: Worker::stop() (worker.h:37)
==6417==    by 0x403184: Dispatcher::stop() (dispatcher.cpp:50)
==6417==    by 0x409163: main (main.cpp:70)
==6417== 
==6417== This conflicts with a previous read of size 1 by thread #2 ==6417== Locks held: none
==6417==    at 0x401E0E: Worker::run() (worker.cpp:51)
==6417==    by 0x408FA4: void std::_Mem_fn_base<void (Worker::*)(), true>::operator()<, void>(Worker*) const (in /media/sf_Projects/Cerflet/dispatcher/dispatcher_demo)
==6417==    by 0x408F38: void std::_Bind_simple<std::_Mem_fn<void (Worker::*)()> (Worker*)>::_M_invoke<0ul>(std::_Index_tuple<0ul>) (functional:1531)
==6417==    by 0x408E3F: std::_Bind_simple<std::_Mem_fn<void (Worker::*)()> (Worker*)>::operator()() (functional:1520)
==6417==    by 0x408D47: std::thread::_Impl<std::_Bind_simple<std::_Mem_fn<void (Worker::*)()> (Worker*)> >::_M_run() (thread:115)
==6417==    by 0x4EF8C7F: ??? (in /usr/lib/x86_64-linux-gnu/libstdc++.so.6.0.21)
==6417==    by 0x4C34DB6: ??? (in /usr/lib/valgrind/vgpreload_helgrind-amd64-linux.so)
==6417==    by 0x53DF6B9: start_thread (pthread_create.c:333)
==6417==  Address 0x5cd9260 is 96 bytes inside a block of size 104 alloc'd
==6417==    at 0x4C2F50F: operator new(unsigned long) (in /usr/lib/valgrind/vgpreload_helgrind-amd64-linux.so)
==6417==    by 0x40308F: Dispatcher::init(int) (dispatcher.cpp:38)
==6417==    by 0x4090A0: main (main.cpp:51)
==6417==  Block was alloc'd by thread #1 

与第一个警告类似,这也涉及一个布尔变量--这里是Worker实例中的running变量。由于这也是一个原子操作,我们可以再次忽略这个警告。

在收到这个警告后,我们看到其他线程也出现了类似的警告。我们还看到这个警告多次重复出现:

==6417==  Lock at 0x60F540 was first observed
==6417==    at 0x4C321BC: ??? (in /usr/lib/valgrind/vgpreload_helgrind-amd64-linux.so)
==6417==    by 0x401CD1: __gthread_mutex_lock(pthread_mutex_t*) (gthr-default.h:748)
==6417==    by 0x402103: std::mutex::lock() (mutex:135)
==6417==    by 0x409044: logFnc(std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >) (main.cpp:40)
==6417==    by 0x40283E: Request::process() (request.cpp:19)
==6417==    by 0x401DCE: Worker::run() (worker.cpp:44)
==6417==    by 0x408FA4: void std::_Mem_fn_base<void (Worker::*)(), true>::operator()<, void>(Worker*) const (in /media/sf_Projects/Cerflet/dispatcher/dispatcher_demo)
==6417==    by 0x408F38: void std::_Bind_simple<std::_Mem_fn<void (Worker::*)()> (Worker*)>::_M_invoke<0ul>(std::_Index_tuple<0ul>) (functional:1531)
==6417==    by 0x408E3F: std::_Bind_simple<std::_Mem_fn<void (Worker::*)()> (Worker*)>::operator()() (functional:1520)
==6417==    by 0x408D47: std::thread::_Impl<std::_Bind_simple<std::_Mem_fn<void (Worker::*)()> (Worker*)> >::_M_run() (thread:115)
==6417==    by 0x4EF8C7F: ??? (in /usr/lib/x86_64-linux-gnu/libstdc++.so.6.0.21)
==6417==    by 0x4C34DB6: ??? (in /usr/lib/valgrind/vgpreload_helgrind-amd64-linux.so)
==6417==  Address 0x60f540 is 0 bytes inside data symbol "logMutex"
==6417== 
==6417== Possible data race during read of size 8 at 0x60F238 by thread #1
==6417== Locks held: none
==6417==    at 0x4F4ED6F: std::basic_ostream<char, std::char_traits<char> >& std::__ostream_insert<char, std::char_traits<char> >(std::basic_ostream<char, std::char_traits<char> >&, char const*, long) (in /usr/lib/x86_64-linux-gnu/libstdc++.so.6.0.21)
==6417==    by 0x4F4F236: std::basic_ostream<char, std::char_traits<char> >& std::operator<< <std::char_traits<char> >(std::basic_ostream<char, std::char_traits<char> >&, char const*) (in /usr/lib/x86_64-linux-gnu/libstdc++.so.6.0.21)
==6417==    by 0x403199: Dispatcher::stop() (dispatcher.cpp:53)
==6417==    by 0x409163: main (main.cpp:70)
==6417== 
==6417== This conflicts with a previous write of size 8 by thread #7
==6417== Locks held: 1, at address 0x60F540
==6417==    at 0x4F4EE25: std::basic_ostream<char, std::char_traits<char> >& std::__ostream_insert<char, std::char_traits<char> >(std::basic_ostream<char, std::char_traits<char> >&, char const*, long) (in /usr/lib/x86_64-linux-gnu/libstdc++.so.6.0.21)
==6417==    by 0x409055: logFnc(std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >) (main.cpp:41)
==6417==    by 0x402916: Request::finish() (request.cpp:27)
==6417==    by 0x401DED: Worker::run() (worker.cpp:45)
==6417==    by 0x408FA4: void std::_Mem_fn_base<void (Worker::*)(), true>::operator()<, void>(Worker*) const (in /media/sf_Projects/Cerflet/dispatcher/dispatcher_demo)
==6417==    by 0x408F38: void std::_Bind_simple<std::_Mem_fn<void (Worker::*)()> (Worker*)>::_M_invoke<0ul>(std::_Index_tuple<0ul>) (functional:1531)
==6417==    by 0x408E3F: std::_Bind_simple<std::_Mem_fn<void (Worker::*)()> (Worker*)>::operator()() (functional:1520)
==6417==    by 0x408D47: std::thread::_Impl<std::_Bind_simple<std::_Mem_fn<void (Worker::*)()> (Worker*)> >::_M_run() (thread:115)
==6417==  Address 0x60f238 is 24 bytes inside data symbol "_ZSt4cout@@GLIBCXX_3.4"  

这个警告是由于在线程之间没有同步使用标准输出而触发的。尽管这个演示应用程序的日志函数使用互斥锁来同步工作线程记录的文本,但在一些地方我们也以不安全的方式写入标准输出。

这相对容易通过使用一个中央、线程安全的日志函数来修复。尽管这不太可能引起任何稳定性问题,但很可能会导致任何日志输出最终成为一团乱码,无法使用。

对 pthread API 的误用

Helgrind 检测到大量涉及 pthread API 的错误,如其手册所总结的,并列在下面:

  • 解锁无效的互斥锁

  • 解锁未锁定的互斥锁

  • 解锁由不同线程持有的互斥锁

  • 销毁无效或锁定的互斥锁

  • 递归锁定非递归互斥锁

  • 释放包含锁定互斥锁的内存

  • 将互斥锁参数传递给期望读写锁参数的函数,反之亦然

  • POSIX pthread 函数的失败会返回一个必须处理的错误代码

  • 线程在仍持有锁定的情况下退出

  • 使用pthread_cond_wait调用未锁定的互斥锁、无效的互斥锁或被其他线程锁定的互斥锁。

  • 条件变量与其关联的互斥锁之间的不一致绑定

  • 无效或重复初始化 pthread 屏障

  • 在等待线程上初始化 pthread 屏障

  • 销毁从未初始化的 pthread 屏障对象,或者仍在等待线程的 pthread 屏障对象

  • 等待未初始化的 pthread 屏障

此外,如果 Helgrind 本身没有检测到错误,但是 pthread 库本身对 Helgrind 拦截的每个函数返回错误,那么 Helgrind 也会报告错误。

锁定顺序问题

锁定顺序检测使用的假设是一旦一系列锁以特定顺序被访问,它们将永远以这种顺序使用。例如,想象一下,一个资源由两个锁保护。正如我们在第十一章的调度程序演示中看到的,线程同步和通信,我们在其调度程序类中使用两个互斥锁,一个用于管理对工作线程的访问,另一个用于请求实例。

在该代码的正确实现中,我们始终确保在尝试获取另一个互斥锁之前解锁一个互斥锁,因为另一个线程可能已经获得了对第二个互斥锁的访问权,并尝试获取对第一个互斥锁的访问权,从而创建死锁情况。

虽然有用,但重要的是要意识到,在某些领域,这种检测算法目前还不完善。这在使用条件变量时最为明显,条件变量自然使用的锁定顺序往往会被 Helgrind 报告为错误

这里的要点是要检查这些日志消息并判断它们的价值,但与多线程 API 的直接误用不同,报告的问题是否是误报还不那么明确。

数据竞争

实质上,数据竞争是指两个或更多线程在没有任何同步机制的情况下尝试读取或写入相同的资源。在这里,只有并发读取和写入,或两个同时写入,才会真正有害;因此,只有这两种访问类型会被报告。

在早期关于基本 Helgrind 使用的部分,我们在日志中看到了这种类型错误的一些示例。那里涉及同时写入和读取变量。正如我们在该部分中也提到的,Helgrind 并不关心写入或读取是否是原子的,而只是报告潜在问题。

就像锁定顺序问题一样,这意味着人们必须根据每个数据竞争报告的价值来判断,因为许多报告可能是误报。

DRD

DRD 与 Helgrind 非常相似,因为它也可以检测应用程序中的线程和同步问题。DRD 与 Helgrind 的主要区别在于以下几点:

  • DRD 使用的内存较少

  • DRD 不会检测锁定顺序违规

  • DRD 支持分离线程

通常,我们希望同时运行 DRD 和 Helgrind,以便比较两者的输出。由于许多潜在问题是高度不确定的,使用这两种工具通常有助于确定最严重的问题。

基本用法

启动 DRD 与启动其他工具非常相似--我们只需指定我们想要的工具,如下所示:

$ valgrind --tool=drd --log-file=dispatcher_drd.log --read-var-info=yes ./dispatcher_demo

应用程序完成后,我们检查生成的日志文件内容。

==6576== drd, a thread error detector
==6576== Copyright (C) 2006-2015, and GNU GPL'd, by Bart Van Assche.
==6576== Using Valgrind-3.11.0 and LibVEX; rerun with -h for copyright info
==6576== Command: ./dispatcher_demo
==6576== Parent PID: 2838
==6576== 
==6576== Conflicting store by thread 1 at 0x05ce51b1 size 1
==6576==    at 0x403650: Worker::setRequest(AbstractRequest*) (worker.h:38)
==6576==    by 0x403253: Dispatcher::addRequest(AbstractRequest*) (dispatcher.cpp:70)
==6576==    by 0x409132: main (main.cpp:63)
==6576== Address 0x5ce51b1 is at offset 97 from 0x5ce5150\. Allocation context:
==6576==    at 0x4C3150F: operator new(unsigned long) (in /usr/lib/valgrind/vgpreload_drd-amd64-linux.so)
==6576==    by 0x40308F: Dispatcher::init(int) (dispatcher.cpp:38)
==6576==    by 0x4090A0: main (main.cpp:51)
==6576== Other segment start (thread 2)
==6576==    at 0x4C3818C: pthread_mutex_unlock (in /usr/lib/valgrind/vgpreload_drd-amd64-linux.so)
==6576==    by 0x401D00: __gthread_mutex_unlock(pthread_mutex_t*) (gthr-default.h:778)
==6576==    by 0x402131: std::mutex::unlock() (mutex:153)
==6576==    by 0x403399: Dispatcher::addWorker(Worker*) (dispatcher.cpp:110)
==6576==    by 0x401DF9: Worker::run() (worker.cpp:49)
==6576==    by 0x408FA4: void std::_Mem_fn_base<void (Worker::*)(), true>::operator()<, void>(Worker*) const (in /media/sf_Projects/Cerflet/dispatcher/dispatcher_demo)
==6576==    by 0x408F38: void std::_Bind_simple<std::_Mem_fn<void (Worker::*)()> (Worker*)>::_M_invoke<0ul>(std::_Index_tuple<0ul>) (functional:1531)
==6576==    by 0x408E3F: std::_Bind_simple<std::_Mem_fn<void (Worker::*)()> (Worker*)>::operator()() (functional:1520)
==6576==    by 0x408D47: std::thread::_Impl<std::_Bind_simple<std::_Mem_fn<void (Worker::*)()> (Worker*)> >::_M_run() (thread:115)
==6576==    by 0x4F04C7F: ??? (in /usr/lib/x86_64-linux-gnu/libstdc++.so.6.0.21)
==6576==    by 0x4C3458B: ??? (in /usr/lib/valgrind/vgpreload_drd-amd64-linux.so)
==6576==    by 0x53EB6B9: start_thread (pthread_create.c:333)
==6576== Other segment end (thread 2)
==6576==    at 0x4C3725B: pthread_mutex_lock (in /usr/lib/valgrind/vgpreload_drd-amd64-linux.so)
==6576==    by 0x401CD1: __gthread_mutex_lock(pthread_mutex_t*) (gthr-default.h:748)
==6576==    by 0x402103: std::mutex::lock() (mutex:135)
==6576==    by 0x4023F8: std::unique_lock<std::mutex>::lock() (mutex:485)
==6576==    by 0x40219D: std::unique_lock<std::mutex>::unique_lock(std::mutex&) (mutex:415)
==6576==    by 0x401E33: Worker::run() (worker.cpp:52)
==6576==    by 0x408FA4: void std::_Mem_fn_base<void (Worker::*)(), true>::operator()<, void>(Worker*) const (in /media/sf_Projects/Cerflet/dispatcher/dispatcher_demo)
==6576==    by 0x408F38: void std::_Bind_simple<std::_Mem_fn<void (Worker::*)()> (Worker*)>::_M_invoke<0ul>(std::_Index_tuple<0ul>) (functional:1531)
==6576==    by 0x408E3F: std::_Bind_simple<std::_Mem_fn<void (Worker::*)()> (Worker*)>::operator()() (functional:1520)
==6576==    by 0x408D47: std::thread::_Impl<std::_Bind_simple<std::_Mem_fn<void (Worker::*)()> (Worker*)> >::_M_run() (thread:115)
==6576==    by 0x4F04C7F: ??? (in /usr/lib/x86_64-linux-gnu/libstdc++.so.6.0.21)
==6576==    by 0x4C3458B: ??? (in /usr/lib/valgrind/vgpreload_drd-amd64-linux.so) 

前面的总结基本上重复了我们在 Helgrind 日志中看到的内容。我们看到了相同的数据竞争报告(冲突的存储),由于原子性,我们可以安全地忽略它。至少对于这个特定的代码来说,使用 DRD 并没有为我们使用 Helgrind 所知道的内容增添任何新东西。

无论如何,最好同时使用两种工具,以防一种工具发现了另一种工具没有发现的问题。

特性

DRD 将检测以下错误:

  • 数据竞争

  • 锁定争用(死锁和延迟)

  • 错误使用 pthreads API

对于第三点,根据 DRD 的手册,DRD 检测到的错误列表与 Helgrind 的非常相似:

  • 将一个类型的同步对象(例如互斥锁)的地址传递给期望指向另一种类型同步对象(例如条件变量)的 POSIX API 调用

  • 尝试解锁一个未被锁定的互斥锁

  • 尝试解锁另一个线程锁定的互斥锁

  • 尝试递归锁定类型为PTHREAD_MUTEX_NORMAL的互斥锁或自旋锁

  • 销毁或释放被锁定的互斥锁

  • 在与条件变量关联的互斥锁上未持有锁的情况下发送信号给条件变量

  • 在未锁定的互斥锁上调用pthread_cond_wait,即由另一个线程锁定或已递归锁定

  • 通过pthread_cond_wait将两个不同的互斥锁与条件变量关联

  • 销毁或释放正在等待的条件变量

  • 销毁或释放被锁定的读写同步对象

  • 尝试解锁未被调用线程锁定的读写同步对象

  • 尝试递归锁定独占读写同步对象

  • 尝试将用户定义的读写同步对象的地址传递给 POSIX 线程函数

  • 尝试将 POSIX 读写同步对象的地址传递给用户定义的读写同步对象的注释之一

  • 重新初始化互斥锁、条件变量、读写锁、信号量或屏障

  • 销毁或释放正在等待的信号量或屏障

  • 屏障等待和屏障销毁之间的缺少同步

  • 在不先解锁线程锁定的自旋锁、互斥锁或读写同步对象的情况下退出线程

  • 将无效的线程 ID 传递给pthread_joinpthread_cancel

如前所述,DRD 还支持分离线程,这里有帮助的是锁定顺序检查是否重要取决于一个人的应用程序。

C++11 线程支持

DRD 手册中包含了关于 C++11 线程支持的这一部分。

如果要使用c++11std::thread,则需要对该类的实现中使用的std::shared_ptr<>对象进行注释:

  • 在公共头文件的开头或在每个源文件的开头添加以下代码,然后再包含任何 C++头文件:
    #include <valgrind/drd.h>
    #define _GLIBCXX_SYNCHRONIZATION_HAPPENS_BEFORE(addr)
    ANNOTATE_HAPPENS_BEFORE(addr)
    #define _GLIBCXX_SYNCHRONIZATION_HAPPENS_AFTER(addr)
    ANNOTATE_HAPPENS_AFTER(addr)
  • 下载 GCC 源代码,并从源文件libstdc++-v3/src/c++11/thread.cc中复制execute_native_thread_routine()std::thread::_M_start_thread()函数的实现到一个与您的应用程序链接的源文件中。确保在这个源文件中,_GLIBCXX_SYNCHRONIZATION_HAPPENS_*()宏也被正确定义。

在使用 DRD 与使用 C++11 线程 API 的应用程序时,可能会看到很多误报,这将通过前面的修复来解决。

然而,当使用 GCC 5.4 和 Valgrind 3.11(可能也适用于旧版本)时,这个问题似乎不再存在。然而,当使用 C++11 线程 API 时,突然看到很多 DRD 输出中的误报时,这是需要记住的事情。

总结

在本章中,我们看了如何调试多线程应用程序。我们探讨了在多线程环境中使用调试器的基础知识。接下来,我们看到了如何使用 Valgrind 框架中的三种工具,这些工具可以帮助我们追踪多线程和其他关键问题。

在这一点上,我们可以拿之前章节中的信息编写的应用程序进行分析,找出需要修复的问题,包括内存泄漏和不正确使用同步机制。

在下一章中,我们将综合我们所学的知识,探讨多线程编程和一般开发中的一些最佳实践。

第十六章:最佳实践

和大多数事情一样,最好是避免犯错,而不是事后纠正。本章将介绍多线程应用程序中的一些常见错误和设计问题,并展示避免常见和不太常见问题的方法。

本章的主题包括:

  • 常见的多线程问题,如死锁和数据竞争。

  • 互斥锁、锁的正确使用和陷阱。

  • 静态初始化时可能出现的潜在问题。

正确的多线程

在前面的章节中,我们已经看到了编写多线程代码时可能出现的各种潜在问题。这些问题从明显的问题,比如两个线程无法同时写入同一位置,到更微妙的问题,比如互斥锁的不正确使用。

还有许多与多线程代码直接相关的问题,但它们仍然可能导致看似随机的崩溃和其他令人沮丧的问题。其中一个例子是变量的静态初始化。在接下来的章节中,我们将看到所有这些问题以及更多问题,并介绍避免不得不处理这些问题的方法。

和生活中的许多事情一样,它们是有趣的经历,但通常你不想重复它们。

错误的期望 - 死锁

死锁的描述已经相当简洁了。当两个或更多进程试图访问另一个进程持有的资源,而另一个线程同时正在等待访问它持有的资源时,就会发生死锁。

例如:

  1. 线程 1 获得对资源 A 的访问

  2. 线程 1 和 2 都想获得对资源 B 的访问

  3. 线程 2 获胜,现在拥有 B,而线程 1 仍在等待 B

  4. 线程 2 现在想要使用 A,并等待访问。

  5. 线程 1 和 2 都永远等待资源

在这种情况下,我们假设线程最终能够访问每个资源,而事实上却相反,因为每个线程都持有另一个线程需要的资源。

可视化,这个死锁过程会像这样:

这清楚地表明了在防止死锁时的两个基本规则:

  • 尽量不要同时持有多个锁。

  • 尽快释放任何持有的锁。

我们在第十一章中看到了一个现实生活中的例子,线程同步和通信,当我们查看调度程序演示代码时。这段代码涉及两个互斥锁,以保护对两个数据结构的访问:

void Dispatcher::addRequest(AbstractRequest* request) {
    workersMutex.lock();
    if (!workers.empty()) {
          Worker* worker = workers.front();
          worker->setRequest(request);
          condition_variable* cv;
          mutex* mtx;
          worker->getCondition(cv);
          worker->getMutex(mtx);
          unique_lock<mutex> lock(*mtx);
          cv->notify_one();
          workers.pop();
          workersMutex.unlock();
    }
    else {
          workersMutex.unlock();
          requestsMutex.lock();
          requests.push(request);
          requestsMutex.unlock();
    }
 } 

这里的互斥锁是workersMutexrequestsMutex变量。我们可以清楚地看到,在任何时候我们都没有在尝试获取另一个互斥锁之前持有一个互斥锁。我们明确地在方法的开始处锁定workersMutex,这样我们就可以安全地检查工作数据结构是否为空。

如果不为空,我们将新请求交给一个工作线程。然后,当我们完成了对工作数据结构的操作后,我们释放互斥锁。此时,我们不再持有任何互斥锁。这里没有太复杂的东西,因为我们只使用了一个互斥锁。

有趣的是在 else 语句中,当没有等待的工作线程并且我们需要获取第二个互斥锁时。当我们进入这个范围时,我们保留一个互斥锁。我们可以尝试获取requestsMutex并假设它会起作用,但这可能会导致死锁,原因很简单:

bool Dispatcher::addWorker(Worker* worker) {
    bool wait = true;
    requestsMutex.lock();
    if (!requests.empty()) {
          AbstractRequest* request = requests.front();
          worker->setRequest(request);
          requests.pop();
          wait = false;
          requestsMutex.unlock();
    }
    else {
          requestsMutex.unlock();
          workersMutex.lock();
          workers.push(worker);
          workersMutex.unlock();
    }
          return wait;
 } 

与前面的函数相配套的函数也使用了这两个互斥锁。更糟糕的是,这个函数在一个单独的线程中运行。结果,当第一个函数持有workersMutex并尝试获取requestsMutex时,第二个函数同时持有后者,并尝试获取前者时,我们就陷入了死锁。

然而,在这里我们看到的函数中,这两条规则都已成功实施;我们从不同时持有多个锁,并且尽快释放我们持有的任何锁。这可以在两个 else 情况中看到,当我们进入它们时,我们首先释放不再需要的任何锁。

在任一情况下,我们都不需要再分别检查工作线程或请求数据结构;在做其他事情之前,我们可以释放相关的锁。这导致以下可视化效果:

当然,我们可能需要使用两个或更多数据结构或变量中包含的数据;这些数据同时被其他线程使用。很难确保在生成的代码中没有死锁的可能性。

在这里,人们可能希望考虑使用临时变量或类似方法。通过锁定互斥量,复制相关数据,并立即释放锁,就不会出现死锁的可能性。即使必须将结果写回数据结构,也可以在单独的操作中完成。

这增加了防止死锁的两条规则:

  • 尽量不要同时持有多个锁。

  • 尽快释放任何持有的锁。

  • 永远不要持有锁的时间超过绝对必要的时间。

  • 持有多个锁时,要注意它们的顺序。

粗心大意 - 数据竞争

数据竞争,也称为竞争条件,发生在两个或更多线程同时尝试写入同一共享内存时。因此,每个线程执行的指令序列期间和结束时的共享内存状态在定义上是不确定的。

正如我们在第十三章中看到的,“调试多线程代码”,调试多线程应用程序的工具经常报告数据竞争。例如:

    ==6984== Possible data race during write of size 1 at 0x5CD9260 by thread #1
 ==6984== Locks held: none
 ==6984==    at 0x40362C: Worker::stop() (worker.h:37)
 ==6984==    by 0x403184: Dispatcher::stop() (dispatcher.cpp:50)
 ==6984==    by 0x409163: main (main.cpp:70)
 ==6984== 
 ==6984== This conflicts with a previous read of size 1 by thread #2
 ==6984== Locks held: none
 ==6984==    at 0x401E0E: Worker::run() (worker.cpp:51)
 ==6984==    by 0x408FA4: void std::_Mem_fn_base<void (Worker::*)(), true>::operator()<, void>(Worker*) const (in /media/sf_Projects/Cerflet/dispatcher/dispatcher_demo)
 ==6984==    by 0x408F38: void std::_Bind_simple<std::_Mem_fn<void (Worker::*)()> (Worker*)>::_M_invoke<0ul>(std::_Index_tuple<0ul>) (functional:1531)
 ==6984==    by 0x408E3F: std::_Bind_simple<std::_Mem_fn<void (Worker::*)()> (Worker*)>::operator()() (functional:1520)
 ==6984==    by 0x408D47: std::thread::_Impl<std::_Bind_simple<std::_Mem_fn<void (Worker::*)()> (Worker*)> >::_M_run() (thread:115)
 ==6984==    by 0x4EF8C7F: ??? (in /usr/lib/x86_64-linux-gnu/libstdc++.so.6.0.21)
 ==6984==    by 0x4C34DB6: ??? (in /usr/lib/valgrind/vgpreload_helgrind-amd64-linux.so)
 ==6984==    by 0x53DF6B9: start_thread (pthread_create.c:333)
 ==6984==  Address 0x5cd9260 is 96 bytes inside a block of size 104 alloc'd
 ==6984==    at 0x4C2F50F: operator new(unsigned long) (in /usr/lib/valgrind/vgpreload_helgrind-amd64-linux.so)
 ==6984==    by 0x40308F: Dispatcher::init(int) (dispatcher.cpp:38)
 ==6984==    by 0x4090A0: main (main.cpp:51)
 ==6984==  Block was alloc'd by thread #1

生成上述警告的代码如下:

bool Dispatcher::stop() {
    for (int i = 0; i < allWorkers.size(); ++i) {
          allWorkers[i]->stop();
    }
          cout << "Stopped workers.n";
          for (int j = 0; j < threads.size(); ++j) {
          threads[j]->join();
                      cout << "Joined threads.n";
    }
 } 

考虑在Worker实例中的这段代码:

   void stop() { running = false; } 

我们还有:

void Worker::run() {
    while (running) {
          if (ready) {
                ready = false;
                request->process();
                request->finish();
          }
                      if (Dispatcher::addWorker(this)) {
                while (!ready && running) {
                      unique_lock<mutex> ulock(mtx);
                      if (cv.wait_for(ulock, chrono::seconds(1)) == cv_status::timeout) {
                      }
                }
          }
    }
 } 

在这里,running是一个布尔变量,被设置为false(从一个线程写入),表示工作线程应该终止其等待循环,而读取布尔变量是从不同的进程进行的,主线程与工作线程:

这个特定示例的警告是由于一个布尔变量同时被写入和读取。当然,这种特定情况之所以安全,与原子操作有关,详细解释在第八章“原子操作 - 与硬件交互”中。

即使像这样的操作潜在风险很大的原因是,读取操作可能发生在变量仍在更新过程中。例如,对于 32 位整数,根据硬件架构,更新此变量可能是一次完成,或者多次完成。在后一种情况下,读取操作可能读取一个中间值,导致结果不确定:

更有趣的情况是,当多个线程写入一个标准输出时,例如,不使用cout。由于这个流不是线程安全的,结果输出流将包含输入流的片段,每当任一线程有机会写入时:

因此,防止数据竞争的基本规则是:

  • 永远不要向未锁定的、非原子的共享资源中写入

  • 永远不要从未锁定的、非原子的共享资源中读取

这基本上意味着任何写入或读取都必须是线程安全的。如果一个线程写入共享内存,那么其他线程就不应该能够同时写入它。同样,当我们从共享资源中读取时,我们需要确保最多只有其他线程也在读取共享资源。

这种级别的互斥自然是由互斥锁实现的,正如我们在前面的章节中所看到的,读写锁提供了一种改进,允许同时进行读取,同时将写入作为完全互斥的事件。

当然,互斥锁也有一些陷阱,我们将在下一节中看到。

互斥锁并不是魔术

互斥锁构成了几乎所有形式的互斥 API 的基础。在它们的核心,它们似乎非常简单,只有一个线程可以拥有一个互斥锁,其他线程则整齐地等待在队列中,直到它们可以获得互斥锁上的锁。

甚至可以将这个过程想象成如下:

现实当然没有那么美好,主要是由于硬件对我们施加的实际限制。一个明显的限制是同步原语并不是免费的。即使它们是在硬件中实现的,也需要多次调用才能使它们工作。

在硬件中实现互斥锁的两种最常见的方法是使用测试和设置(TAS)或比较和交换(CAS)CPU 特性。

测试和设置通常被实现为两个汇编级指令,这些指令是自主执行的,意味着它们不能被中断。第一条指令测试某个内存区域是否设置为 1 或零。第二条指令只有在值为零(false)时才执行。这意味着互斥锁尚未被锁定。因此,第二条指令将内存区域设置为 1,锁定互斥锁。

在伪代码中,这将如下所示:

bool TAS(bool lock) { 
   if (lock) { 
         return true; 
   } 
   else { 
         lock = true; 
         return false; 
   } 
} 

比较和交换是一个较少使用的变体,它对内存位置和给定值执行比较操作,只有在前两者匹配时才替换该内存位置的内容:

bool CAS(int* p, int old, int new) { 
   if (*p != old) { 
               return false; 
         } 

   *p = new; 
         return true; 
} 

在任何一种情况下,都需要积极重复任一函数,直到返回一个正值:

volatile bool lock = false; 

 void critical() { 
     while (TAS(&lock) == false); 
     // Critical section 
     lock = 0; 
 } 

在这里,使用一个简单的 while 循环来不断轮询内存区域(标记为 volatile 以防止可能有问题的编译器优化)。通常,使用一个算法来慢慢减少轮询的频率。这是为了减少对处理器和内存系统的压力。

这清楚地表明使用互斥锁并不是免费的,而每个等待互斥锁的线程都会积极地使用资源。因此,这里的一般规则是:

  • 确保线程尽可能短暂地等待互斥锁和类似的锁。

  • 对于较长的等待期间,使用条件变量或定时器。

锁是一种高级的互斥锁

正如我们在互斥锁部分中所看到的,使用互斥锁时需要牢记一些问题。当然,当使用基于互斥锁的锁和其他机制时,这些问题也同样适用,即使其中一些问题被这些 API 平滑地解决了。

当首次使用多线程 API 时,人们可能会对不同的同步类型之间的实际区别感到困惑。正如我们在本章前面所介绍的,互斥锁是几乎所有同步机制的基础,只是在它们使用互斥锁来实现所提供的功能的方式上有所不同。

这里的重要一点是它们不是不同的同步机制,而只是基本互斥类型的特殊化。无论是使用常规互斥锁、读/写锁、信号量,甚至像可重入(递归)互斥锁或锁这样奇特的东西,完全取决于试图解决的特定问题。

对于调度器,我们首先在第十一章中遇到,线程同步和通信,我们使用常规互斥锁来保护包含排队工作线程和请求的数据结构。由于任何对任一数据结构的访问可能不仅涉及读取操作,还可能涉及结构的操作,因此在那里使用读/写锁是没有意义的。同样,递归锁也不会对谦虚的互斥锁有任何作用。

对于每个同步问题,因此必须问以下问题:

  • 我有哪些要求?

  • 哪种同步机制最适合这些要求?

因此,选择复杂类型是有吸引力的,但通常最好坚持满足所有要求的更简单的类型。当涉及调试自己的实现时,与使用更直接和低级的 API 相比,可以节省宝贵的时间。

线程与未来

最近,有人开始建议不要使用线程,而是倡导使用其他异步处理机制,比如promise。背后的原因是使用线程和涉及的同步是复杂且容易出错的。通常,人们只想并行运行一个任务,而不用担心结果是如何获得的。

对于只运行短暂的简单任务,这当然是有意义的。基于线程的实现的主要优势始终是可以完全定制其行为。使用promise,可以发送一个要运行的任务,并在最后,从future实例中获取结果。这对于简单的任务很方便,但显然并不涵盖很多情况。

在这里最好的方法是首先充分了解线程和同步机制,以及它们的限制。只有在那之后才真正有意义地考虑是否希望使用promisepackaged_task或完整的线程。

另一个重要考虑因素是,这些更复杂的、基于未来的 API 通常是基于模板的,这可能会使调试和解决可能发生的任何问题变得更加困难,而不像使用更直接和低级的 API 那样容易。

静态初始化顺序

静态变量是只声明一次的变量,基本上存在于全局范围内,尽管可能只在特定类的实例之间共享。也可能有完全静态的类:

class Foo { 
   static std::map<int, std::string> strings; 
   static std::string oneString; 

public: 
   static void init(int a, std::string b, std::string c) { 
         strings.insert(std::pair<int, std::string>(a, b)); 
         oneString = c; 
   } 
}; 

std::map<int, std::string> Foo::strings; 
std::string Foo::oneString; 

正如我们在这里所看到的,静态变量和静态函数似乎是一个非常简单但强大的概念。虽然从本质上讲这是正确的,但在静态变量和类的初始化方面存在一个主要问题,这将会让不注意的人掉入陷阱。这个问题就是初始化顺序。

想象一下,如果我们希望在另一个类的静态初始化中使用前面的类,就像这样:

class Bar { 
   static std::string name; 
   static std::string initName(); 

public: 
   void init(); 
}; 

// Static initializations. 
std::string Bar::name = Bar::initName(); 

std::string Bar::initName() { 
   Foo::init(1, "A", "B"); 
   return "Bar"; 
} 

虽然这似乎会很好地工作,将第一个字符串添加到类的映射结构中,整数作为键,但这段代码很有可能会崩溃。原因很简单,没有保证在调用Foo::init()Foo::string已经初始化。因此,尝试使用未初始化的映射结构将导致异常。

简而言之,静态变量的初始化顺序基本上是随机的,如果不考虑这一点,就会导致非确定性行为。

这个问题的解决方案非常简单。基本上,目标是使更复杂的静态变量的初始化显式,而不是像前面的例子中那样隐式。为此,我们修改了 Foo 类:

class Foo { 
   static std::map<int, std::string>& strings(); 
   static std::string oneString; 

public: 
   static void init(int a, std::string b, std::string c) { 
         static std::map<int, std::string> stringsStatic = Foo::strings(); 
         stringsStatic.insert(std::pair<int, std::string>(a, b)); 
         oneString = c; 
   } 
}; 

std::string Foo::oneString; 

std::map<int, std::string>& Foo::strings() { 
   static std::map<int, std::string>* stringsStatic = new std::map<int, std::string>(); 
   return *stringsStatic; 
} 

从顶部开始,我们看到我们不再直接定义静态映射。相反,我们有一个同名的私有函数。这个函数的实现在这个示例代码的底部找到。在其中,我们有一个指向具有熟悉映射定义的静态指针。

当调用此函数时,如果尚未存在实例,则会创建一个新的映射,因为它是一个静态变量。在修改后的init()函数中,我们看到我们调用strings()函数来获取对此实例的引用。这是显式初始化的部分,因为调用该函数将始终确保在使用之前初始化映射结构,解决了我们先前遇到的问题。

我们还可以看到一个小优化:我们创建的stringsStatic变量也是静态的,这意味着我们只会调用strings()函数一次。这样就不需要重复调用函数,恢复了我们在先前简单但不稳定的实现中所具有的速度。

静态变量初始化的基本规则是,对于非平凡的静态变量,始终使用显式初始化。

摘要

在本章中,我们看了一些编写多线程代码时需要牢记的良好实践和规则,以及一些建议。到这一点,人们应该能够避免一些编写此类代码时的较大陷阱和主要混淆源。

在下一章中,我们将看看如何利用底层硬件来实现原子操作,以及在 C++11 中引入的<atomics>头文件。

第十七章:原子操作-与硬件交互

很多优化和线程安全取决于对底层硬件的理解:从某些架构上的对齐内存访问,到知道哪些数据大小和因此 C++类型可以安全地访问而不会有性能惩罚或需要互斥锁等。

本章探讨了如何利用多种处理器架构的特性,例如,防止使用互斥锁,而原子操作可以防止任何访问冲突。还考察了诸如 GCC 中的特定于编译器的扩展。

本章主题包括:

  • 原子操作的类型以及如何使用它们

  • 如何针对特定处理器架构进行优化

  • 基于编译器的原子操作

原子操作

简而言之,原子操作是处理器可以用单条指令执行的操作。这使得它在某种意义上是原子的,即除了中断外,没有任何干扰,也不会改变任何变量或数据。

应用包括保证指令执行顺序,无锁实现以及指令执行顺序和内存访问保证重要的相关用途。

在 2011 年 C++标准之前,处理器提供的原子操作的访问仅由编译器使用扩展提供。

Visual C++

对于微软的 MSVC 编译器,有原子函数,从 MSDN 文档总结而来,首先是添加功能:

原子函数 描述
InterlockedAdd 对指定的LONG值执行原子加法操作。
InterlockedAddAcquire 对指定的LONG值执行原子加法操作。该操作使用获取内存排序语义执行。
InterlockedAddRelease 对指定的LONG值执行原子加法操作。该操作使用释放内存排序语义执行。
InterlockedAddNoFence 对指定的LONG值执行原子加法操作。该操作是原子执行的,但不使用内存屏障(在本章中介绍)。

这些是该功能的 32 位版本。API 中还有其他方法的 64 位版本。原子函数往往专注于特定的变量类型,但本摘要中省略了此 API 的变体,以保持简洁。

我们还可以看到获取和释放的变体。这些保证了相应的读取或写入访问将受到内存重排序(在硬件级别)的保护,并且任何后续的读取或写入操作都会受到保护。最后,无屏障变体(也称为内存屏障)在不使用任何内存屏障的情况下执行操作。

通常 CPU 执行指令(包括内存读写)是为了优化性能而无序执行的。由于这种行为并不总是理想的,因此添加了内存屏障以防止指令重排序。

接下来是原子AND功能:

原子函数 描述
InterlockedAnd 对指定的LONG值执行原子AND操作。
InterlockedAndAcquire 对指定的LONG值执行原子AND操作。该操作使用获取内存排序语义执行。
InterlockedAndRelease 对指定的LONG值执行原子AND操作。该操作使用释放内存排序语义执行。
InterlockedAndNoFence 对指定的LONG值执行原子AND操作。该操作是原子执行的,但不使用内存屏障。

位测试功能如下:

原子函数 描述
InterlockedBitTestAndComplement 测试指定的LONG值的指定位并对其进行补码。
InterlockedBitTestAndResetAcquire 测试指定LONG值的指定位,并将其设置为0。该操作是原子的,并且使用获取内存排序语义执行。
InterlockedBitTestAndResetRelease 测试指定LONG值的指定位,并将其设置为0。该操作是原子的,并且使用内存释放语义执行。
InterlockedBitTestAndSetAcquire 测试指定LONG值的指定位,并将其设置为1。该操作是原子的,并且使用获取内存排序语义执行。
InterlockedBitTestAndSetRelease 测试指定LONG值的指定位,并将其设置为1。该操作是原子的,并且使用释放内存排序语义执行。
InterlockedBitTestAndReset 测试指定LONG值的指定位,并将其设置为0
InterlockedBitTestAndSet 测试指定LONG值的指定位,并将其设置为1

比较特性可以列举如下:

Interlocked function 描述
InterlockedCompareExchange 对指定数值执行原子比较和交换操作。该函数比较两个指定的 32 位数值,并根据比较结果与另一个 32 位数值进行交换。
InterlockedCompareExchangeAcquire 对指定数值执行原子比较和交换操作。该函数比较两个指定的 32 位数值,并根据比较结果与另一个 32 位数值进行交换。该操作使用获取内存排序语义执行。
InterlockedCompareExchangeRelease 对指定数值执行原子比较和交换操作。该函数比较两个指定的 32 位数值,并根据比较结果与另一个 32 位数值进行交换。交换是使用释放内存排序语义执行的。
InterlockedCompareExchangeNoFence 对指定数值执行原子比较和交换操作。该函数比较两个指定的 32 位数值,并根据比较结果与另一个 32 位数值进行交换。该操作是原子性的,但不使用内存屏障。
InterlockedCompareExchangePointer 对指定指针数值执行原子比较和交换操作。该函数比较两个指定的指针数值,并根据比较结果与另一个指针数值进行交换。
InterlockedCompareExchangePointerAcquire 对指定指针数值执行原子比较和交换操作。该函数比较两个指定的指针数值,并根据比较结果与另一个指针数值进行交换。该操作使用获取内存排序语义执行。
InterlockedCompareExchangePointerRelease 对指定指针数值执行原子比较和交换操作。该函数比较两个指定的指针数值,并根据比较结果与另一个指针数值进行交换。该操作使用释放内存排序语义执行。
InterlockedCompareExchangePointerNoFence 对指定数值执行原子比较和交换操作。该函数比较两个指定的指针数值,并根据比较结果与另一个指针数值进行交换。该操作是原子性的,但不使用内存屏障。

| 减量特性如下:

Interlocked function 描述
InterlockedDecrement 原子操作的方式将指定 32 位变量的值减少 1。
InterlockedDecrementAcquire 原子操作的方式将指定 32 位变量的值减少 1。该操作使用获取内存排序语义执行。
InterlockedDecrementRelease 对指定的 32 位变量的值进行递减(减少一),作为原子操作。该操作使用释放内存排序语义。
InterlockedDecrementNoFence 对指定的 32 位变量的值进行递减(减少一),作为原子操作。该操作是原子的,但不使用内存屏障。

交换(交换)功能包括:

Interlocked function Description
InterlockedExchange 将 32 位变量设置为指定值,作为原子操作。
InterlockedExchangeAcquire 将 32 位变量设置为指定值,作为原子操作。该操作使用获取内存排序语义。
InterlockedExchangeNoFence 将 32 位变量设置为指定值,作为原子操作。该操作是原子的,但不使用内存屏障。
InterlockedExchangePointer 原子交换一对指针值。
InterlockedExchangePointerAcquire 原子交换一对指针值。该操作使用获取内存排序语义。
InterlockedExchangePointerNoFence 原子交换一对地址。该操作是原子的,但不使用内存屏障。
InterlockedExchangeSubtract 执行两个值的原子减法。
InterlockedExchangeAdd 执行两个 32 位值的原子加法。
InterlockedExchangeAddAcquire 执行两个 32 位值的原子加法。该操作使用获取内存排序语义。
InterlockedExchangeAddRelease 执行两个 32 位值的原子加法。该操作使用释放内存排序语义。
InterlockedExchangeAddNoFence 执行两个 32 位值的原子加法。该操作是原子的,但不使用内存屏障。

增量功能包括:

Interlocked function Description
InterlockedIncrement 对指定的 32 位变量的值进行递增(增加一),作为原子操作。
InterlockedIncrementAcquire 使用获取内存排序语义,作为原子操作增加指定 32 位变量的值(增加一)。
InterlockedIncrementRelease 对指定的 32 位变量的值进行递增(增加一),作为原子操作。该操作使用释放内存排序语义。
InterlockedIncrementNoFence 对指定的 32 位变量的值进行递增(增加一),作为原子操作。该操作是原子的,但不使用内存屏障。

OR功能:

Interlocked function Description
InterlockedOr 对指定的LONG值执行原子OR操作。
InterlockedOrAcquire 对指定的LONG值执行原子OR操作。该操作使用获取内存排序语义。
InterlockedOrRelease 对指定的LONG值执行原子OR操作。该操作使用释放内存排序语义。
InterlockedOrNoFence 对指定的LONG值执行原子OR操作。该操作是原子的,但不使用内存屏障。

最后,独占ORXOR)功能包括:

Interlocked function Description
InterlockedXor 对指定的LONG值执行原子XOR操作。
InterlockedXorAcquire 对指定的LONG值执行原子XOR操作。该操作使用获取内存排序语义。
InterlockedXorRelease 对指定的LONG值执行原子XOR操作。该操作使用释放内存排序语义执行。
InterlockedXorNoFence 对指定的LONG值执行原子XOR操作。该操作是原子执行的,但不使用内存屏障。

GCC

与 Visual C一样,GCC 也配备了一组内置的原子函数。这些函数根据所使用的 GCC 版本和标准库的底层架构而异。由于 GCC 在比 VC更多的平台和操作系统上使用,这在考虑可移植性时绝对是一个重要因素。

例如,在 x86 平台上提供的并非每个内置的原子函数都在 ARM 上可用,部分原因是由于架构差异,包括特定 ARM 架构的变化。例如,ARMv6、ARMv7 或当前的 ARMv8,以及 Thumb 指令集等。

在 C++11 标准之前,GCC 使用了__sync-prefixed扩展来进行原子操作:

type __sync_fetch_and_add (type *ptr, type value, ...) 
type __sync_fetch_and_sub (type *ptr, type value, ...) 
type __sync_fetch_and_or (type *ptr, type value, ...) 
type __sync_fetch_and_and (type *ptr, type value, ...) 
type __sync_fetch_and_xor (type *ptr, type value, ...) 
type __sync_fetch_and_nand (type *ptr, type value, ...) 

这些操作从内存中获取一个值,并对其执行指定的操作,返回内存中的值。这些都使用内存屏障。

type __sync_add_and_fetch (type *ptr, type value, ...) 
type __sync_sub_and_fetch (type *ptr, type value, ...) 
type __sync_or_and_fetch (type *ptr, type value, ...) 
type __sync_and_and_fetch (type *ptr, type value, ...) 
type __sync_xor_and_fetch (type *ptr, type value, ...) 
type __sync_nand_and_fetch (type *ptr, type value, ...) 

这些操作与第一组类似,只是它们在指定操作后返回新值。

bool __sync_bool_compare_and_swap (type *ptr, type oldval, type newval, ...) 
type __sync_val_compare_and_swap (type *ptr, type oldval, type newval, ...) 

这些比较操作将在旧值匹配提供的值时写入新值。布尔变体在写入新值时返回 true。

__sync_synchronize (...) 

该函数创建一个完整的内存屏障。

type __sync_lock_test_and_set (type *ptr, type value, ...) 

该方法实际上是一个交换操作,与名称所示不同。它更新指针值并返回先前的值。这不使用完整的内存屏障,而是使用获取屏障,这意味着它不会释放屏障。

void __sync_lock_release (type *ptr, ...) 

该函数释放前一方法获得的屏障。

为了适应 C++11 内存模型,GCC 添加了__atomic内置方法,这也大大改变了 API:

type __atomic_load_n (type *ptr, int memorder) 
void __atomic_load (type *ptr, type *ret, int memorder) 
void __atomic_store_n (type *ptr, type val, int memorder) 
void __atomic_store (type *ptr, type *val, int memorder) 
type __atomic_exchange_n (type *ptr, type val, int memorder) 
void __atomic_exchange (type *ptr, type *val, type *ret, int memorder) 
bool __atomic_compare_exchange_n (type *ptr, type *expected, type desired, bool weak, int success_memorder, int failure_memorder) 
bool __atomic_compare_exchange (type *ptr, type *expected, type *desired, bool weak, int success_memorder, int failure_memorder) 

首先是通用的加载、存储和交换函数。它们相当容易理解。加载函数读取内存中的值,存储函数将值存储在内存中,交换函数交换现有值和新值。比较和交换函数使交换有条件。

type __atomic_add_fetch (type *ptr, type val, int memorder) 
type __atomic_sub_fetch (type *ptr, type val, int memorder) 
type __atomic_and_fetch (type *ptr, type val, int memorder) 
type __atomic_xor_fetch (type *ptr, type val, int memorder) 
type __atomic_or_fetch (type *ptr, type val, int memorder) 
type __atomic_nand_fetch (type *ptr, type val, int memorder) 

这些函数本质上与旧 API 中的函数相同,返回特定操作的结果。

type __atomic_fetch_add (type *ptr, type val, int memorder) 
type __atomic_fetch_sub (type *ptr, type val, int memorder) 
type __atomic_fetch_and (type *ptr, type val, int memorder) 
type __atomic_fetch_xor (type *ptr, type val, int memorder) 
type __atomic_fetch_or (type *ptr, type val, int memorder) 
type __atomic_fetch_nand (type *ptr, type val, int memorder) 

同样的函数,针对新 API 进行了更新。这些函数返回操作前的原始值。

bool __atomic_test_and_set (void *ptr, int memorder) 

与旧 API 中同名函数不同,该函数执行真正的测试和设置操作,而不是旧 API 函数的交换操作,后者仍然需要在之后释放内存屏障。测试是针对某个定义的值。

void __atomic_clear (bool *ptr, int memorder) 

该函数清除指针地址,将其设置为0

void __atomic_thread_fence (int memorder) 

使用该函数可以在线程之间创建同步内存屏障(fence)。

void __atomic_signal_fence (int memorder) 

该函数在线程和同一线程内的信号处理程序之间创建内存屏障。

bool __atomic_always_lock_free (size_t size, void *ptr) 

该函数检查指定大小的对象是否总是为当前处理器架构创建无锁原子指令。

bool __atomic_is_lock_free (size_t size, void *ptr) 

这本质上与之前的函数相同。

内存顺序

在 C11 内存模型中,并非总是使用内存屏障(fences)进行原子操作。在 GCC 内置原子 API 中,这在其函数的memorder参数中反映出来。该参数的可能值直接映射到 C11 原子 API 中的值:

  • __ATOMIC_RELAXED:意味着没有线程间排序约束。

  • __ATOMIC_CONSUME:由于 C++11 对memory_order_consume的语义存在缺陷,目前使用更强的__ATOMIC_ACQUIRE内存顺序来实现。

  • __ATOMIC_ACQUIRE:从释放(或更强)语义存储到此获取加载创建一个线程间 happens-before 约束

  • __ATOMIC_RELEASE: 创建一个跨线程 happens-before 约束,以获取(或更强)语义加载以从此释放存储中读取。

  • __ATOMIC_ACQ_REL: 结合了__ATOMIC_ACQUIRE__ATOMIC_RELEASE的效果。

  • __ATOMIC_SEQ_CST: 强制与所有其他__ATOMIC_SEQ_CST操作进行完全排序。

上述列表是从 GCC 手册的 GCC 7.1 原子章节中复制的。连同该章节中的注释,这清楚地表明在实现 C++11 原子支持以及编译器实现中都做出了权衡。

由于原子依赖于底层硬件支持,永远不会有一个使用原子的代码能够在各种不同的架构上运行。

其他编译器

当然,C/C有许多不同于 VC和 GCC 的编译器工具链,包括英特尔编译器集合(ICC)和其他通常是专有工具。所有这些都有自己的内置原子函数集。幸运的是,由于 C11 标准,我们现在在编译器之间有了一个完全可移植的原子标准。一般来说,这意味着除了非常特定的用例(或者维护现有代码),人们会使用 C标准而不是特定于编译器的扩展。

C++11 原子

为了使用本地 C++11 原子特性,我们只需要包含<atomic>头文件。这将使atomic类可用,该类使用模板来使自己适应所需的类型,并具有大量预定义的 typedef:

Typedef name Full specialization
std::atomic_bool std::atomic<bool>
std::atomic_char std::atomic<char>
std::atomic_schar std::atomic<signed char>
std::atomic_uchar std::atomic<unsigned char>
std::atomic_short std::atomic<short>
std::atomic_ushort std::atomic<unsigned short>
std::atomic_int std::atomic<int>
std::atomic_uint std::atomic<unsigned int>
std::atomic_long std::atomic<long>
std::atomic_ulong std::atomic<unsigned long>
std::atomic_llong std::atomic<long long>
std::atomic_ullong std::atomic<unsigned long long>
std::atomic_char16_t std::atomic<char16_t>
std::atomic_char32_t std::atomic<char32_t>
std::atomic_wchar_t std::atomic<wchar_t>
std::atomic_int8_t std::atomic<std::int8_t>
std::atomic_uint8_t std::atomic<std::uint8_t>
std::atomic_int16_t std::atomic<std::int16_t>
std::atomic_uint16_t std::atomic<std::uint16_t>
std::atomic_int32_t std::atomic<std::int32_t>
std::atomic_uint32_t std::atomic<std::uint32_t>
std::atomic_int64_t std::atomic<std::int64_t>
std::atomic_uint64_t std::atomic<std::uint64_t>
std::atomic_int_least8_t std::atomic<std::int_least8_t>
std::atomic_uint_least8_t std::atomic<std::uint_least8_t>
std::atomic_int_least16_t std::atomic<std::int_least16_t>
std::atomic_uint_least16_t std::atomic<std::uint_least16_t>
std::atomic_int_least32_t std::atomic<std::int_least32_t>
std::atomic_uint_least32_t std::atomic<std::uint_least32_t>
std::atomic_int_least64_t std::atomic<std::int_least64_t>
std::atomic_uint_least64_t std::atomic<std::uint_least64_t>
std::atomic_int_fast8_t std::atomic<std::int_fast8_t>
std::atomic_uint_fast8_t std::atomic<std::uint_fast8_t>
std::atomic_int_fast16_t std::atomic<std::int_fast16_t>
std::atomic_uint_fast16_t std::atomic<std::uint_fast16_t>
std::atomic_int_fast32_t std::atomic<std::int_fast32_t>
std::atomic_uint_fast32_t std::atomic<std::uint_fast32_t>
std::atomic_int_fast64_t std::atomic<std::int_fast64_t>
std::atomic_uint_fast64_t std::atomic<std::uint_fast64_t>
std::atomic_intptr_t std::atomic<std::intptr_t>
std::atomic_uintptr_t std::atomic<std::uintptr_t>
std::atomic_size_t std::atomic<std::size_t>
std::atomic_ptrdiff_t std::atomic<std::ptrdiff_t>
std::atomic_intmax_t std::atomic<std::intmax_t>
std::atomic_uintmax_t std::atomic<std::uintmax_t>

这个atomic类定义了以下通用函数:

函数 描述
operator= 为原子对象赋值。
is_lock_free 如果原子对象是无锁的,则返回 true。
store 用非原子参数原子地替换原子对象的值。
load 原子地获取原子对象的值。
operator T 从原子对象中加载一个值。
exchange 原子地用新值替换对象的值并返回旧值。
compare_exchange_weak``compare_exchange_strong 原子地比较对象的值,如果相等则交换值,否则返回当前值。

随着 C++17 的更新,添加了is_always_lock_free常量。这允许我们查询类型是否总是无锁。

最后,我们有专门的atomic函数:

函数 描述
fetch_add 原子地将参数添加到atomic对象中存储的值并返回旧值。
fetch_sub 原子地从atomic对象中减去参数并返回旧值。
fetch_and 在参数和atomic对象的值之间原子地执行位AND并返回旧值。
fetch_or 在参数和atomic对象的值之间原子地执行位OR并返回旧值。
fetch_xor 在参数和atomic对象的值之间原子地执行位XOR并返回旧值。
operator++``operator++(int)``operator--``operator--(int) 将原子值增加或减少一。
operator+=``operator-=``operator&=``operator&#124;=``operator^= 添加、减去或执行位ANDORXOR操作。

示例

使用fetch_add的基本示例如下:

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

std::atomic<long long> count; 
void worker() { 
         count.fetch_add(1, std::memory_order_relaxed); 
} 

int main() { 
         std::thread t1(worker); 
         std::thread t2(worker); 
         std::thread t3(worker); 
         std::thread t4(worker); 
         std::thread t5(worker); 

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

         std::cout << "Count value:" << count << 'n'; 
} 

这个示例代码的结果将是5。正如我们在这里看到的,我们可以用原子操作来实现一个基本的计数器,而不必使用任何互斥锁或类似的东西来提供线程同步。

非类函数

除了atomic类之外,<atomic>头文件中还定义了一些基于模板的函数,我们可以以更类似于编译器内置的原子函数的方式使用:

函数 描述
atomic_is_lock_free 检查原子类型的操作是否是无锁的。
atomic_storeatomic_store_explicit 原子地用非原子参数替换atomic对象的值。
atomic_load``atomic_load_explicit 原子地获取存储在atomic对象中的值。
atomic_exchange``atomic_exchange_explicit 原子地用非原子参数替换atomic对象的值并返回atomic的旧值。
atomic_compare_exchange_weak``atomic_compare_exchange_weak_explicit``atomic_compare_exchange_strong``atomic_compare_exchange_strong_explicit 原子地比较atomic对象的值和非原子参数,并在相等时执行原子交换,否则执行原子加载。
atomic_fetch_add``atomic_fetch_add_explicit 将非原子值添加到atomic对象中并获取atomic的先前值。
atomic_fetch_sub``atomic_fetch_sub_explicit atomic对象中减去非原子值并获取atomic的先前值。
atomic_fetch_and``atomic_fetch_and_explicit 用非原子参数的逻辑AND结果替换atomic对象并获取原子的先前值。
atomic_fetch_or``atomic_fetch_or_explicit 用非原子参数的逻辑OR结果替换atomic对象,并获取atomic的先前值。
atomic_fetch_xor``atomic_fetch_xor_explicit 用非原子参数的逻辑XOR结果替换atomic对象,并获取atomic的先前值。
atomic_flag_test_and_set``atomic_flag_test_and_set_explicit 原子地将标志设置为true并返回其先前的值。
atomic_flag_clear``atomic_flag_clear_explicit 原子地将标志的值设置为false
atomic_init 默认构造的atomic对象的非原子初始化。
kill_dependency std::memory_order_consume依赖树中移除指定的对象。
atomic_thread_fence 通用的内存顺序相关的栅栏同步原语。
atomic_signal_fence 在同一线程中的线程和信号处理程序之间设置栅栏。

常规和显式函数之间的区别在于后者允许实际设置要使用的内存顺序。前者总是使用memory_order_seq_cst作为内存顺序。

示例

在这个使用atomic_fetch_sub的示例中,一个带索引的容器被多个线程同时处理,而不使用锁:

#include <string> 
#include <thread> 
#include <vector> 
#include <iostream> 
#include <atomic> 
#include <numeric> 

const int N = 10000; 
std::atomic<int> cnt; 
std::vector<int> data(N); 

void reader(int id) { 
         for (;;) { 
               int idx = atomic_fetch_sub_explicit(&cnt, 1, std::memory_order_relaxed); 
               if (idx >= 0) { 
                           std::cout << "reader " << std::to_string(id) << " processed item " 
                                       << std::to_string(data[idx]) << 'n'; 
               }  
         else { 
                           std::cout << "reader " << std::to_string(id) << " done.n"; 
                           break; 
               } 
         } 
} 

int main() { 
         std::iota(data.begin(), data.end(), 1); 
         cnt = data.size() - 1; 

         std::vector<std::thread> v; 
         for (int n = 0; n < 10; ++n) { 
               v.emplace_back(reader, n); 
         } 

         for (std::thread& t : v) { 
               t.join(); 
         } 
} 

这个示例代码使用了一个大小为N的整数向量作为数据源,用 1 填充它。原子计数器对象设置为数据向量的大小。之后,创建了 10 个线程(使用向量的emplace_back C++11 特性就地初始化),运行reader函数。

在该函数中,我们使用atomic_fetch_sub_explicit函数从内存中读取索引计数器的当前值,这使我们能够使用memory_order_relaxed内存顺序。该函数还从这个旧值中减去我们传递的值,将索引减少 1。

只要我们以这种方式获得的索引号大于或等于零,函数就会继续,否则它将退出。一旦所有线程都完成了,应用程序就会退出。

原子标志

std::atomic_flag是一个原子布尔类型。与atomic类的其他特化不同,它保证是无锁的。然而,它不提供任何加载或存储操作。

相反,它提供了赋值运算符,以及清除或test_and_set标志的函数。前者将标志设置为false,后者将测试并将其设置为true

内存顺序

这个属性在<atomic>头文件中被定义为一个枚举:

enum memory_order { 
    memory_order_relaxed, 
    memory_order_consume, 
    memory_order_acquire, 
    memory_order_release, 
    memory_order_acq_rel, 
    memory_order_seq_cst 
}; 

在 GCC 部分,我们已经简要涉及了内存顺序的话题。如前所述,这是底层硬件架构特征的一部分。

基本上,内存顺序决定了非原子内存访问在原子操作周围的顺序(内存访问顺序)。这会影响不同线程在执行指令时如何看到内存中的数据:

枚举 描述
memory_order_relaxed 松散操作:对其他读取或写入没有同步或排序约束,只有这个操作的原子性是有保证的。
memory_order_consume 具有这种内存顺序的加载操作在受影响的内存位置上执行consume 操作:当前加载之前的当前线程中对当前加载的值的依赖变量的读取或写入不能被重新排序。在其他释放相同原子变量的数据依赖变量的写入对当前线程可见。在大多数平台上,这只影响编译器优化。
memory_order_acquire 具有此内存顺序的加载操作在受影响的内存位置上执行获取操作:在此加载之前,当前线程中的任何读取或写入都不能被重新排序。释放相同原子变量的其他线程中的所有写入对于当前线程都是可见的。
memory_order_release 具有此内存顺序的存储操作执行释放操作:在此存储之后,当前线程中的任何读取或写入都不能被重新排序。当前线程中的所有写入对于获取相同原子变量的其他线程都是可见的,并且对原子变量进行依赖的写入也对于消费相同原子的其他线程是可见的。
memory_order_acq_rel 具有此内存顺序的读-修改-写操作既是获取操作又是释放操作。当前线程中的任何内存读取或写入都不能在此存储之前或之后被重新排序。释放相同原子变量的其他线程中的所有写入在修改之前是可见的,并且对于获取相同原子变量的其他线程来说,修改是可见的。
memory_order_seq_cst 具有此内存顺序的任何操作既是获取操作又是释放操作,并且存在一个单一的总顺序,所有线程都以相同的顺序观察到所有修改。

松散排序

在松散内存排序中,并没有对并发内存访问之间的顺序进行强制。这种类型的排序只保证了原子性和修改顺序。

这种类型的排序的典型用途是用于计数器,无论是增加还是减少,就像我们在上一节的示例代码中看到的那样。

释放-获取排序

如果线程 A 中的原子存储标记为memory_order_release,并且线程 B 中从相同变量进行的原子加载标记为memory_order_acquire,则所有内存写入(非原子和松散原子)在线程 A 的视角中发生在原子存储之前,都会成为线程 B 中的可见副作用。也就是说,一旦原子加载完成,线程 B 就能够看到线程 A 写入内存的所有内容。

这种类型的操作在所谓的强顺序架构上是自动的,包括 x86、SPARC 和 POWER。弱顺序架构,如 ARM、PowerPC 和 Itanium,将需要在这里使用内存屏障。

这种类型的内存排序的典型应用包括互斥机制,比如互斥锁或原子自旋锁。

释放-消费排序

如果线程 A 中的原子存储标记为memory_order_release,并且线程 B 中从相同变量进行的原子加载标记为memory_order_consume,则所有内存写入(非原子和松散原子)在线程 A 的视角中在原子存储之前是依赖排序的,这些操作在线程 B 中成为可见副作用,并且加载操作携带依赖性。也就是说,一旦原子加载完成,线程 B 中使用从加载中获得的值的那些运算符和函数都能够看到线程 A 写入内存的内容。

这种类型的排序在几乎所有架构上都是自动的。唯一的主要例外是(已过时的)Alpha 架构。这种类型排序的典型用例是对很少被更改的数据进行读取访问。

截至 C++17,这种类型的内存排序正在进行修订,暂时不建议使用memory_order_consume

顺序一致排序

标记为memory_order_seq_cst的原子操作不仅对内存进行排序(在一个线程中存储之前发生的所有事情都成为了加载线程中的可见副作用),而且还建立了所有被标记的原子操作的单一总修改顺序

这种排序可能在所有消费者必须以完全相同的顺序观察其他线程所做的更改的情况下是必要的。这在多核或多 CPU 系统上需要完整的内存屏障。

由于这种复杂的设置,这种排序比其他类型要慢得多。它还要求每个原子操作都必须带有这种类型的内存排序标记,否则顺序排序将丢失。

volatile 关键字

volatile关键字对于编写复杂的多线程代码的人来说可能非常熟悉。它的基本用途是告诉编译器相关变量应始终从内存中加载,永远不要对其值进行假设。它还确保编译器不会对变量进行任何激进的优化。

对于多线程应用程序,它通常是无效的,但不鼓励使用。volatile 规范的主要问题是它没有定义多线程内存模型,这意味着这个关键字的结果可能在不同平台、CPU 甚至工具链上都不是确定的。

在原子操作领域,不需要使用这个关键字,实际上使用它可能不会有帮助。为了确保获得在多个 CPU 核心和它们的缓存之间共享的变量的当前版本,人们必须使用像atomic_compare_exchange_strongatomic_fetch_addatomic_exchange这样的操作来让硬件获取正确和当前的值。

对于多线程代码,建议不要使用 volatile 关键字,而是使用原子操作,以确保正确的行为。

总结

在本章中,我们看了原子操作以及它们如何集成到编译器中,以使代码尽可能与底层硬件紧密配合。读者现在将熟悉原子操作的类型,内存屏障(围栏)的使用,以及内存排序的各种类型及其影响。

读者现在可以在自己的代码中使用原子操作来实现无锁设计,并正确使用 C++11 内存模型。

在下一章中,我们将总结到目前为止学到的一切,摆脱 CPU,转而看看 GPGPU,即在视频卡(GPU)上对数据进行通用处理。

第十八章:分布式计算中的多线程

分布式计算是多线程编程的最初应用之一。在每台个人电脑只包含单个处理器和单个核心的时代,政府和研究机构,以及一些公司会拥有多处理器系统,通常以集群的形式存在。这些系统可以进行多线程处理;通过将任务分配到处理器上,它们可以加速各种任务,包括模拟、CGI 电影的渲染等。

如今,几乎每台桌面级或更高级别的系统都有多个处理器核心,并且使用廉价的以太网布线非常容易将多台系统组装成集群。结合 OpenMP 和 Open MPI 等框架,很容易将基于 C++(多线程)的应用程序扩展到分布式系统上。

本章的主题包括:

  • 在多线程 C++应用程序中集成 OpenMP 和 MPI

  • 实现分布式多线程应用程序

  • 分布式多线程编程的常见应用和问题

分布式计算简介

当涉及并行处理大型数据集时,如果能够将数据分割成许多小部分,并将其推送到许多线程中,从而显著缩短处理所述数据的总时间,那将是理想的。

分布式计算的理念正是这样:在分布式系统的每个节点上运行我们的应用程序的一个或多个实例,这个应用程序可以是单线程或多线程。由于进程间通信的开销,使用多线程应用程序通常更有效,还有其他可能的优化--由于资源共享。

如果已经有一个准备好使用的多线程应用程序,那么可以直接使用 MPI 使其在分布式系统上运行。否则,OpenMP 是一个编译器扩展(用于 C/C++和 Fortran),可以相对轻松地使应用程序多线程化而无需重构。

为了做到这一点,OpenMP 允许用户标记一个常见的代码段,以便在所有从属线程上执行。主线程创建了许多从属线程,这些线程将同时处理相同的代码段。一个基本的Hello World OpenMP 应用程序看起来像这样:

/******************************************************************************
 * FILE: omp_hello.c
 * DESCRIPTION:
 *   OpenMP Example - Hello World - C/C++ Version
 *   In this simple example, the master thread forks a parallel region.
 *   All threads in the team obtain their unique thread number and print it.
 *   The master thread only prints the total number of threads.  Two OpenMP
 *   library routines are used to obtain the number of threads and each
 *   thread's number.
 * AUTHOR: Blaise Barney  5/99
 * LAST REVISED: 04/06/05
 ******************************************************************************/
 #include <omp.h>
 #include <stdio.h>
 #include <stdlib.h>

 int main (int argc, char *argv[])  {
    int nthreads, tid;

    /* Fork a team of threads giving them their own copies of variables */
 #pragma omp parallel private(nthreads, tid) {
          /* Obtain thread number */
          tid = omp_get_thread_num();
          printf("Hello World from thread = %dn", tid);

          /* Only master thread does this */
          if (tid == 0) {
                nthreads = omp_get_num_threads();
                printf("Number of threads = %dn", nthreads);
                }

    }  /* All threads join master thread and disband */ 
} 

从这个基本示例中很容易看出,OpenMP 通过<omp.h>头文件提供了一个基于 C 的 API。我们还可以看到每个线程将执行的部分,由#pragma omp预处理器宏标记。

OpenMP 相对于我们在前面章节中看到的多线程代码的优势在于,可以轻松地将代码段标记为多线程,而无需进行任何实际的代码更改。这带来的明显限制是,每个线程实例将执行完全相同的代码,并且进一步的优化选项有限。

MPI

为了安排在特定节点上执行代码,MPI消息传递接口)通常被使用。Open MPI 是这方面的一个免费库实现,被许多高级超级计算机使用。MPICH 是另一个流行的实现。

MPI 本身被定义为并行计算编程的通信协议。它目前处于第三个修订版(MPI-3)。

总之,MPI 提供了以下基本概念:

  • 通信器:通信器对象连接了 MPI 会话中的一组进程。它为进程分配唯一标识符,并在有序拓扑中安排进程。

  • 点对点操作:这种操作允许特定进程之间的直接通信。

  • 集体函数:这些函数涉及在进程组内进行广播通信。它们也可以以相反的方式使用,从进程组中获取所有进程的结果,例如在单个节点上对它们进行求和。更具选择性的版本可以确保特定的数据项被发送到特定的节点。

  • 派生数据类型:由于 MPI 集群中的每个节点都不能保证具有相同的定义、字节顺序和数据类型的解释,MPI 要求指定每个数据段的类型,以便 MPI 进行数据转换。

  • 单边通信:这些操作允许在远程内存中写入或读取数据,或者在多个任务之间执行归约操作,而无需在任务之间进行同步。这对于某些类型的算法非常有用,比如涉及分布式矩阵乘法的算法。

  • 动态进程管理:这是一个允许 MPI 进程创建新的 MPI 进程,或者与新创建的 MPI 进程建立通信的功能。

  • 并行 I/O:也称为 MPI-IO,这是分布式系统上 I/O 管理的抽象,包括文件访问,方便与 MPI 一起使用。

其中,MPI-IO、动态进程管理和单边通信是 MPI-2 的特性。由于从基于 MPI-1 的代码迁移和动态进程管理与某些设置不兼容,以及许多应用程序不需要 MPI-2 的特性,MPI-2 的采用速度相对较慢。

实现

MPI 的最初实现是由阿贡国家实验室(ANL)和密西西比州立大学开发的 MPICH。它目前是最受欢迎的实现之一,被用作 MPI 实现的基础,包括 IBM(蓝色基因)、英特尔、QLogic、Cray、Myricom、微软、俄亥俄州立大学(MVAPICH)等公司的实现。

另一个非常常见的实现是 Open MPI,它是由三个 MPI 实现合并而成的:

  • FT-MPI(田纳西大学)

  • 洛斯阿拉莫斯国家实验室(LA-MPI)

  • LAM/MPI(印第安纳大学)

这些术语,以及斯图加特大学的 PACX-MPI 团队,是 Open MPI 团队的创始成员。Open MPI 的主要目标之一是创建一个高质量的开源 MPI-3 实现。

MPI 实现必须支持 C 和 Fortran。C/C++和 Fortran 以及汇编支持非常普遍,还有其他语言的绑定。

使用 MPI

无论选择哪种实现,结果的 API 都将始终符合官方 MPI 标准,只有所选择的库支持的 MPI 版本不同。任何 MPI 实现都应该支持所有 MPI-1(修订版 1.3)的特性。

这意味着无论选择哪个库,MPI 的典型 Hello World(例如,在 MPI 教程网站上找到的)应该都能工作:

#include <mpi.h> 
#include <stdio.h> 

int main(int argc, char** argv) { 
         // Initialize the MPI environment 
         MPI_Init(NULL, NULL); 

         // Get the number of processes 
         int world_size; 
         MPI_Comm_size(MPI_COMM_WORLD, &world_size); 

         // Get the rank of the process 
         int world_rank; 
         MPI_Comm_rank(MPI_COMM_WORLD, &world_rank); 

         // Get the name of the processor 
         char processor_name[MPI_MAX_PROCESSOR_NAME]; 
         int name_len; 
         MPI_Get_processor_name(processor_name, &name_len); 

         // Print off a hello world message 
         printf("Hello world from processor %s, rank %d" 
                     " out of %d processorsn", 
                     processor_name, world_rank, world_size); 

         // Finalize the MPI environment. 
         MPI_Finalize(); 
} 

阅读这个基于 MPI 的应用程序的基本示例时,熟悉 MPI 使用的术语是很重要的,特别是:

  • 世界:这个作业的注册 MPI 进程

  • 通信器:连接会话中所有 MPI 进程的对象

  • :通信器内的进程的标识符

  • 处理器:物理 CPU,多核 CPU 的单个核心,或系统的主机名

在这个 Hello World 的例子中,我们可以看到我们包含了<mpi.h>头文件。无论我们使用哪种实现,这个 MPI 头文件都是一样的。

初始化 MPI 环境只需要调用一次MPI_Init(),此时可以传入两个参数,这两个参数都是可选的。

获取世界的大小(即可用进程数)是下一步。这是使用MPI_Comm_size()完成的,它接受MPI_COMM_WORLD全局变量(由 MPI 为我们定义)并使用第二个参数更新该世界中的进程数。

然后我们获得的排名基本上是 MPI 为该进程分配的唯一 ID。使用MPI_Comm_rank()执行此 UID。同样,这需要MPI_COMM_WORLD变量作为第一个参数,并将我们的数字排名作为第二个参数返回。此排名对于自我识别和进程之间的通信很有用。

获取正在运行的特定硬件的名称也可能很有用,特别是用于诊断目的。为此,我们可以调用MPI_Get_processor_name()。返回的字符串将具有全局定义的最大长度,并且将以某种方式标识硬件。此字符串的确切格式由实现定义。

最后,我们打印出我们收集的信息,并在终止应用程序之前清理 MPI 环境。

编译 MPI 应用程序

为了编译 MPI 应用程序,使用mpicc编译器包装器。这个可执行文件应该是已安装的任何 MPI 实现的一部分。

然而,使用它与使用例如 GCC 是相同的:

    $ mpicc -o mpi_hello_world mpi_hello_world.c

这可以与:

    $ gcc mpi_hello_world.c -lmsmpi -o mpi_hello_world

这将编译和链接我们的 Hello World 示例为一个二进制文件,准备执行。然而,执行此二进制文件不是直接启动它,而是使用启动器,如下所示:

    $ mpiexec.exe -n 4 mpi_hello_world.exe
    Hello world from processor Generic_PC, rank 0 out of 4 processors
    Hello world from processor Generic_PC, rank 2 out of 4 processors
    Hello world from processor Generic_PC, rank 1 out of 4 processors
    Hello world from processor Generic_PC, rank 3 out of 4 processors

前面的输出来自在 Windows 系统上运行的 Bash shell 中的 Open MPI。正如我们所看到的,我们总共启动了四个进程(4 个排名)。处理器名称报告为每个进程的主机名(“PC”)。

用于启动 MPI 应用程序的二进制文件称为 mpiexec 或 mpirun,或 orterun。这些是相同二进制文件的同义词,尽管并非所有实现都具有所有同义词。对于 Open MPI,所有三者都存在,可以使用其中任何一个。

集群硬件

MPI 基于或类似应用程序将运行的系统由多个独立系统(节点)组成,每个系统都使用某种网络接口连接到其他系统。对于高端应用程序,这些往往是具有高速、低延迟互连的定制节点。在光谱的另一端是所谓的 Beowulf 和类似类型的集群,由标准(台式)计算机组成,通常使用常规以太网连接。

在撰写本文时,根据 TOP500 榜单,最快的超级计算机是中国无锡国家超级计算中心的 Sunway TaihuLight 超级计算机。它使用了总共 40,960 个中国设计的 SW26010 多核 RISC 架构 CPU,每个 CPU 有 256 个核心(分为 4 个 64 核心组),以及四个管理核心。术语“多核”是指一种专门的 CPU 设计,它更注重显式并行性,而不是大多数 CPU 核心的单线程和通用重点。这种类型的 CPU 类似于 GPU 架构和矢量处理器。

每个节点都包含一个 SW26010 和 32GB 的 DDR3 内存。它们通过基于 PCIe 3.0 的网络连接,本身由三级层次结构组成:中央交换网络(用于超级节点),超级节点网络(连接超级节点中的所有 256 个节点)和资源网络,提供对 I/O 和其他资源服务的访问。节点之间的网络带宽为 12GB/秒,延迟约为 1 微秒。

以下图表(来自“Sunway TaihuLight 超级计算机:系统和应用”,DOI:10.1007/s11432-016-5588-7)提供了对该系统的视觉概述:

在预算不允许这样复杂和高度定制的系统,或者特定任务不需要这样的方法的情况下,总是可以采用“Beowulf”方法。Beowulf 集群是指由普通计算机系统构建的分布式计算系统。这些可以是基于 Intel 或 AMD 的 x86 系统,现在也流行起了基于 ARM 处理器的系统。

通常希望集群中的每个节点大致相同。虽然可以有不对称的集群,但是当可以对每个节点做出广泛的假设时,管理和作业调度变得更加容易。

至少,希望匹配处理器架构,具有基本的 CPU 扩展,如 SSE2/3,也许还有 AVX 等,所有节点上都通用。这样做可以让您在节点上使用相同的编译二进制文件,以及相同的算法,大大简化作业的部署和代码库的维护。

对于节点之间的网络,以太网是一个非常受欢迎的选项,传输时间以十到百微秒计,成本只是更快选项的一小部分。通常,每个节点都会连接到一个以太网网络,如图所示:

还有一个选项,可以为每个或特定节点添加第二甚至第三个以太网链接,使它们可以访问文件、I/O 和其他资源,而无需在主要网络层上竞争带宽。对于非常大的集群,可以考虑一种类似于 Sunway TaihuLight 和许多其他超级计算机使用的方法:将节点分割成超级节点,每个节点都有自己的节点间网络。这将允许通过限制只与相关节点通信来优化网络流量。

这样优化的 Beowulf 集群的示例将如下所示:

显然,基于 MPI 的集群有各种可能的配置,利用定制的、现成的或两种硬件类型的组合。集群的预期用途通常决定了特定集群的最佳布局,例如运行模拟或处理大型数据集。每种类型的作业都有自己的一套限制和要求,这也反映在软件实现中。

安装 Open MPI

在本章的其余部分,我们将专注于 Open MPI。为了获得 Open MPI 的工作开发环境,需要安装其头文件和库文件,以及支持工具和二进制文件。

Linux 和 BSD

在具有软件包管理系统的 Linux 和 BSD 发行版上,这很容易:只需安装 Open MPI 软件包,一切都应该设置和配置好,准备好使用。查阅特定发行版的手册,了解如何搜索和安装特定软件包。

在基于 Debian 的发行版上,可以使用:

    $ sudo apt-get install openmpi-bin openmpi-doc libopenmpi-dev

上述命令将安装 Open MPI 二进制文件、文档和开发头文件。计算节点上可以省略最后两个软件包。

Windows

在 Windows 上,情况变得稍微复杂,主要是因为 Visual C++和相应的编译器工具链的主导地位。如果希望在 Linux 或 BSD 上使用与之相同的开发环境,使用 MinGW,就需要采取一些额外的步骤。

本章假设使用 GCC 或 MinGW。如果希望使用 Visual Studio 环境开发 MPI 应用程序,请查阅相关文档。

最易于使用且最新的 MinGW 环境是 MSYS2,它提供了一个 Bash shell,以及大多数在 Linux 和 BSD 下熟悉的工具。它还具有 Pacman 软件包管理器,正如 Linux Arch 发行版所知。使用这个软件包管理器,可以轻松安装 Open MPI 开发所需的软件包。

msys2.github.io/安装 MSYS2 环境后,安装 MinGW 工具链:

    $ pacman -S base-devel mingw-w64-x86_64-toolchain

这假设安装了 64 位版本的 MSYS2。对于 32 位版本,请选择 i686 而不是 x86_64。安装了这些软件包后,我们将同时安装 MinGW 和基本开发工具。为了使用它们,使用 MinGW 64 位后缀的名称启动一个新的 shell,可以通过开始菜单中的快捷方式,或者通过 MSYS2 install文件夹中的可执行文件来实现。

准备好 MinGW 后,现在是安装 MS-MPI 版本 7.x 的时候了。这是微软在 Windows 上使用 MPI 的最简单的方法。它是 MPI-2 规范的实现,与 MPICH2 参考实现大部分兼容。由于 MS-MPI 库在不同版本之间不兼容,我们使用这个特定的版本。

尽管 MS-MPI 的第 7 版已经存档,但仍然可以通过 Microsoft 下载中心下载,网址为www.microsoft.com/en-us/download/details.aspx?id=49926

MS-MPI 版本 7 带有两个安装程序,msmpisdk.msiMSMpiSetup.exe。都需要安装。之后,我们应该能够打开一个新的 MSYS2 shell,并找到以下环境变量设置:

    $ printenv | grep "WIN|MSMPI"
    MSMPI_INC=D:DevMicrosoftSDKsMPIInclude
    MSMPI_LIB32=D:DevMicrosoftSDKsMPILibx86
    MSMPI_LIB64=D:DevMicrosoftSDKsMPILibx64
    WINDIR=C:Windows

printenv 命令的输出显示 MS-MPI SDK 和运行时已经正确安装。接下来,我们需要将 Visual C++ LIB 格式的静态库转换为 MinGW A 格式:

    $ mkdir ~/msmpi
    $ cd ~/msmpi
    $ cp "$MSMPI_LIB64/msmpi.lib" .
    $ cp "$WINDIR/system32/msmpi.dll" .
    $ gendef msmpi.dll
    $ dlltool -d msmpi.def -D msmpi.dll -l libmsmpi.a
    $ cp libmsmpi.a /mingw64/lib/.

我们首先将原始 LIB 文件复制到我们的主文件夹中的一个新临时文件夹中,以及运行时 DLL。接下来,我们使用 gendef 工具处理 DLL,以创建我们需要的定义,以便将其转换为新格式。

最后一步是使用 dlltool,它需要使用定义文件和 DLL,输出一个与 MinGW 兼容的静态库文件。然后我们将该文件复制到 MinGW 在链接时可以找到的位置。

接下来,我们需要复制 MPI 头文件:

    $ cp "$MSMPI_INC/mpi.h" .

复制这个头文件后,我们必须打开它并找到以下部分:

typedef __int64 MPI_Aint 

在该行的正上方,我们需要添加以下行:

    #include <stdint.h>

这个包含添加了__int64的定义,这是我们编译代码所需要的。

最后,将头文件复制到 MinGW 的include文件夹中:

    $ cp mpi.h /mingw64/include

有了这些,我们就可以在 MinGW 下进行 MPI 开发所需的库和头文件,从而可以编译和运行之前的 Hello World 示例,并继续进行本章的其余部分。

跨节点分发作业

为了在集群中的节点之间分发 MPI 作业,必须将这些节点作为mpirun/mpiexec命令的参数指定,或者使用主机文件。这个主机文件包含网络上将用于运行的节点的名称,以及主机上可用插槽的数量。

在远程节点上运行 MPI 应用程序的先决条件是在该节点上安装了 MPI 运行时,并且已为该节点配置了无密码访问。这意味着只要主节点安装了 SSH 密钥,它就可以登录到每个节点,以便在其上启动 MPI 应用程序。

设置 MPI 节点

在节点上安装 MPI 后,下一步是为主节点设置无密码 SSH 访问。这需要在节点上安装 SSH 服务器(在基于 Debian 的发行版中属于ssh软件包的一部分)。之后,我们需要生成并安装 SSH 密钥。

一个简单的方法是在主节点和其他节点上有一个公共用户,并使用 NFS 网络共享或类似的方式在计算节点上挂载主节点上的用户文件夹。这样所有节点都将拥有相同的 SSH 密钥和已知主机文件。这种方法的一个缺点是缺乏安全性。对于连接到互联网的集群来说,这不是一个很好的方法。

然而,以相同用户在每个节点上运行作业绝对是一个好主意,以防止任何可能的权限问题,特别是在使用文件和其他资源时。通过在每个节点上创建一个公共用户帐户,并生成 SSH 密钥,我们可以使用以下命令将公钥传输到节点:

    $ ssh-copy-id mpiuser@node1

或者,在设置节点系统时,我们可以将公钥复制到节点系统的authorized_keys文件中。如果要创建和配置大量节点,最好使用镜像复制到每个节点的系统驱动器上,使用设置脚本,或者可能通过 PXE 引导从镜像启动。

完成了这一步,主节点现在可以登录到每个计算节点以运行作业。

创建 MPI 主机文件

如前所述,为了在其他节点上运行作业,我们需要指定这些节点。最简单的方法是创建一个文件,其中包含我们希望使用的计算节点的名称,以及可选参数。

为了让我们能够使用节点的名称而不是 IP 地址,我们首先需要修改操作系统的主机文件:例如,在 Linux 上是/etc/hosts

    192.168.0.1 master
    192.168.0.2 node0
    192.168.0.3 node1

接下来,我们创建一个新文件,这将是用于 MPI 的主机文件:

    master
    node0
    node1

有了这个配置,作业将在两个计算节点以及主节点上执行。我们可以从这个文件中删除主节点,以防止这种情况发生。

如果没有提供任何可选参数,MPI 运行时将使用节点上的所有可用处理器。如果需要,我们可以限制这个数字:

    node0 slots=2
    node1 slots=4

假设两个节点都是四核 CPU,这意味着只有 node0 上的一半核心会被使用,而 node1 上的所有核心都会被使用。

运行作业

在多个 MPI 节点上运行 MPI 作业基本上与仅在本地执行相同,就像本章前面的示例一样:

    $ mpirun --hostfile my_hostfile hello_mpi_world

这个命令会告诉 MPI 启动器使用一个名为my_hostfile的主机文件,并在该主机文件中找到的每个节点的每个处理器上运行指定的 MPI 应用程序的副本。

使用集群调度程序

除了使用手动命令和主机文件在特定节点上创建和启动作业之外,还有集群调度程序应用程序。这些通常涉及在每个节点以及主节点上运行一个守护进程。使用提供的工具,我们可以管理资源和作业,安排分配并跟踪作业状态。

最受欢迎的集群管理调度程序之一是 SLURM,它是 Simple Linux Utility for Resource management 的缩写(尽管现在更名为 Slurm Workload Manager,网站为slurm.schedmd.com/)。它通常被超级计算机以及许多计算机集群所使用。其主要功能包括:

  • 使用时间段为特定用户分配对资源(节点)的独占或非独占访问权限

  • 在一组节点上启动和监视诸如基于 MPI 的应用程序之类的作业

  • 管理待处理作业队列,以调解共享资源的争用

设置集群调度程序对于基本的集群操作并不是必需的,但在运行多个作业同时或者有多个集群用户希望运行自己的作业时,它可能非常有用。

MPI 通信

此时,我们有一个功能齐全的 MPI 集群,可以用于以并行方式执行基于 MPI 的应用程序(以及其他应用程序)。虽然对于某些任务,只需将几十个或几百个进程发送出去并等待它们完成可能是可以的,但很多时候,这些并行进程能够相互通信是至关重要的。

这就是 MPI(“消息传递接口”)的真正含义所在。在 MPI 作业创建的层次结构中,进程可以以各种方式进行通信和共享数据。最基本的是,它们可以共享和接收消息。

MPI 消息具有以下属性:

  • 发送方

  • 接收方

  • 消息标签(ID)

  • 消息中元素的计数

  • 一个 MPI 数据类型

发送方和接收方应该是相当明显的。消息标签是发送方可以设置的数字 ID,接收方可以使用它来过滤消息,例如,允许对特定消息进行优先排序。数据类型确定消息中包含的信息类型。

发送和接收函数如下所示:

int MPI_Send( 
         void* data, 
         int count, 
         MPI_Datatype datatype, 
         int destination, 
         int tag, 
         MPI_Comm communicator) 

int MPI_Recv( 
         void* data, 
         int count, 
         MPI_Datatype datatype, 
         int source, 
         int tag, 
         MPI_Comm communicator, 
         MPI_Status* status) 

这里需要注意的一个有趣的事情是,发送函数中的计数参数表示函数将发送的元素数,而接收函数中的相同参数表示此线程将接受的最大元素数。

通信器指的是正在使用的 MPI 通信器实例,接收函数包含一个最终参数,可用于检查 MPI 消息的状态。

MPI 数据类型

MPI 定义了许多基本类型,可以直接使用:

MPI 数据类型 C 等效
MPI_SHORT short int
MPI_INT int
MPI_LONG long int
MPI_LONG_LONG long long int
MPI_UNSIGNED_CHAR unsigned char
MPI_UNSIGNED_SHORT unsigned short int
MPI_UNSIGNED unsigned int
MPI_UNSIGNED_LONG unsigned long int
MPI_UNSIGNED_LONG_LONG unsigned long long int
MPI_FLOAT float
MPI_DOUBLE double
MPI_LONG_DOUBLE long double
MPI_BYTE char

MPI 保证使用这些类型时,接收方将始终以其期望的格式获取消息数据,而不受字节顺序和其他与平台相关的问题的影响。

自定义类型

除了这些基本格式之外,还可以创建新的 MPI 数据类型。这些使用了许多 MPI 函数,包括MPI_Type_create_struct

int MPI_Type_create_struct( 
   int count,  
   int array_of_blocklengths[], 
         const MPI_Aint array_of_displacements[],  
   const MPI_Datatype array_of_types[], 
         MPI_Datatype *newtype) 

使用此函数,可以创建一个包含结构的 MPI 类型,就像使用基本的 MPI 数据类型一样:

#include <cstdio> 
#include <cstdlib> 
#include <mpi.h> 
#include <cstddef> 

struct car { 
        int shifts; 
        int topSpeed; 
}; 

int main(int argc, char **argv) { 
         const int tag = 13; 
         int size, rank; 

         MPI_Init(&argc, &argv); 
         MPI_Comm_size(MPI_COMM_WORLD, &size); 

         if (size < 2) { 
               fprintf(stderr,"Requires at least two processes.n"); 
               MPI_Abort(MPI_COMM_WORLD, 1); 
         } 

         const int nitems = 2; 
         int blocklengths[2] = {1,1}; 
   MPI_Datatype types[2] = {MPI_INT, MPI_INT}; 
         MPI_Datatype mpi_car_type; 
         MPI_Aint offsets[2]; 

         offsets[0] = offsetof(car, shifts); 
         offsets[1] = offsetof(car, topSpeed); 

         MPI_Type_create_struct(nitems, blocklengths, offsets, types, &mpi_car_type); 
         MPI_Type_commit(&mpi_car_type); 

         MPI_Comm_rank(MPI_COMM_WORLD, &rank); 
         if (rank == 0) { 
               car send; 
               send.shifts = 4; 
               send.topSpeed = 100; 

               const int dest = 1; 

         MPI_Send(&send, 1, mpi_car_type, dest, tag, MPI_COMM_WORLD); 

               printf("Rank %d: sent structure carn", rank); 
         } 

   if (rank == 1) { 
               MPI_Status status; 
               const int src = 0; 

         car recv; 

         MPI_Recv(&recv, 1, mpi_car_type, src, tag, MPI_COMM_WORLD, &status); 
         printf("Rank %d: Received: shifts = %d topSpeed = %dn", rank, recv.shifts, recv.topSpeed); 
    } 

    MPI_Type_free(&mpi_car_type); 
    MPI_Finalize(); 

         return 0; 
} 

在这里,我们看到了一个名为mpi_car_type的新 MPI 数据类型是如何定义和用于在两个进程之间传递消息的。要创建这样的结构类型,我们需要定义结构中的项目数,每个块中的元素数,它们的字节位移以及它们的基本 MPI 类型。

基本通信

MPI 通信的一个简单示例是从一个进程向另一个进程发送单个值。为了做到这一点,需要使用以下列出的代码,并运行编译后的二进制文件以启动至少两个进程。这些进程是在本地运行还是在两个计算节点上运行并不重要。

以下代码感激地借鉴自mpitutorial.com/tutorials/mpi-hello-world/

#include <mpi.h> 
#include <stdio.h> 
#include <stdlib.h> 

int main(int argc, char** argv) { 
   // Initialize the MPI environment. 
   MPI_Init(NULL, NULL); 

   // Find out rank, size. 
   int world_rank; 
   MPI_Comm_rank(MPI_COMM_WORLD, &world_rank); 
   int world_size; 
   MPI_Comm_size(MPI_COMM_WORLD, &world_size); 

   // We are assuming at least 2 processes for this task. 
   if (world_size < 2) { 
               fprintf(stderr, "World size must be greater than 1 for %s.n", argv[0]); 
               MPI_Abort(MPI_COMM_WORLD, 1); 
   } 

   int number; 
   if (world_rank == 0) { 
         // If we are rank 0, set the number to -1 and send it to process 1\. 
               number = -1; 
               MPI_Send(&number, 1, MPI_INT, 1, 0, MPI_COMM_WORLD); 
   }  
   else if (world_rank == 1) { 
               MPI_Recv(&number, 1, MPI_INT, 0, 0,  
                           MPI_COMM_WORLD,  
                           MPI_STATUS_IGNORE); 
               printf("Process 1 received number %d from process 0.n", number); 
   } 

   MPI_Finalize(); 
} 

这段代码并不复杂。我们通过通常的 MPI 初始化,然后检查我们的世界大小是否至少有两个进程。

具有等级 0 的进程将发送一个数据类型为MPI_INT且值为-1的 MPI 消息。等级为1的进程将等待接收此消息。接收进程指定MPI_Status MPI_STATUS_IGNORE以指示该进程不会检查消息的状态。这是一种有用的优化技术。

最后,预期的输出如下:

    $ mpirun -n 2 ./send_recv_demo
    Process 1 received number -1 from process 0

在这里,我们启动了一个总共有两个进程的编译后的演示代码。输出显示第二个进程从第一个进程接收了 MPI 消息,并且值是正确的。

高级通信

对于高级 MPI 通信,可以使用MPI_Status字段来获取有关消息的更多信息。可以使用MPI_Probe在接受消息之前发现消息的大小,然后使用MPI_Recv接受消息。这在不事先知道消息大小的情况下非常有用。

广播

广播消息意味着世界上的所有进程都会收到它。这简化了广播函数相对于发送函数:

int MPI_Bcast( 
   void *buffer,  
   int count,  
   MPI_Datatype datatype, 
         int root,    
   MPI_Comm comm) 

接收进程将简单地使用普通的MPI_Recv函数。广播函数所做的就是优化使用一种算法同时使用多个网络链接发送多条消息,而不是只使用一个。

散射和聚集

散射非常类似于广播消息,但有一个非常重要的区别:它不是在每条消息中发送相同的数据,而是将数组的不同部分发送给每个接收者。其功能定义如下:

int MPI_Scatter( 
         void* send_data, 
         int send_count, 
         MPI_Datatype send_datatype, 
         void* recv_data, 
         int recv_count, 
         MPI_Datatype recv_datatype, 
         int root, 
         MPI_Comm communicator) 

每个接收进程将获得相同的数据类型,但我们可以指定将发送到每个进程的项目数(send_count)。这个函数在发送和接收方都使用,后者只需要定义与接收数据相关的最后一组参数,提供根进程的世界等级和相关的通信器。

聚集是散射的逆过程。在这里,多个进程将发送的数据最终到达单个进程,这些数据按发送它的进程的等级进行排序。其功能定义如下:

int MPI_Gather( 
         void* send_data, 
         int send_count, 
         MPI_Datatype send_datatype, 
         void* recv_data, 
         int recv_count, 
         MPI_Datatype recv_datatype, 
         int root, 
         MPI_Comm communicator) 

人们可能会注意到这个函数看起来与散射函数非常相似。这是因为它基本上是以相同的方式工作,只是这一次发送节点必须填写与发送数据相关的参数,而接收进程必须填写与接收数据相关的参数。

这里需要注意的是recv_count参数与从每个发送进程接收的数据量有关,而不是总大小。

这两个基本功能还有进一步的专业化,但这里不会涉及。

MPI 与线程

有人可能认为最容易的方法是使用 MPI 将 MPI 应用程序的一个实例分配给每个集群节点上的单个 CPU 核心,这是正确的。然而,这并不是最快的解决方案。

尽管在网络上的进程间通信方面,MPI 可能是最佳选择,但在单个系统(单 CPU 或多 CPU 系统)中,使用多线程是非常有意义的。

这主要是因为线程之间的通信比进程间通信要快得多,特别是在使用诸如 MPI 这样的通用通信层时。

可以编写一个使用 MPI 在集群网络上进行通信的应用程序,其中为每个 MPI 节点分配一个应用程序实例。应用程序本身将检测该系统上的 CPU 核心数量,并为每个核心创建一个线程。因此,混合 MPI,通常被称为,因为它提供了以下优势,因此通常被使用:

  • 更快的通信 - 使用快速的线程间通信。

  • 更少的 MPI 消息 - 更少的消息意味着带宽和延迟的减少。

  • 避免数据重复 - 数据可以在线程之间共享,而不是向一系列进程发送相同的消息。

可以通过使用在前几章中看到的 C++11 和后续版本中找到的多线程功能来实现这一点。另一种选择是使用 OpenMP,就像我们在本章的开头看到的那样。

使用 OpenMP 的明显优势是开发者几乎不需要付出什么努力。如果需要的只是运行相同例程的更多实例,只需要对代码进行少量修改,标记代码用于工作线程即可。

例如:

#include <stdio.h>
#include <mpi.h>
#include <omp.h>

int main(int argc, char *argv[]) {
  int numprocs, rank, len;
  char procname[MPI_MAX_PROCESSOR_NAME];
  int tnum = 0, tc = 1;

  MPI_Init(&argc, &argv);
  MPI_Comm_size(MPI_COMM_WORLD, &numprocs);
  MPI_Comm_rank(MPI_COMM_WORLD, &rank);
  MPI_Get_processor_name(procname, &len);

  #pragma omp parallel default(shared) private(tnum, tc) {
      np = omp_get_num_threads();
      tnum = omp_get_thread_num();
      printf("Thread %d out of %d from process %d out of %d on %sn", 
      tnum, tc, rank, numprocs, procname);
  }

  MPI_Finalize();
}

上述代码将 OpenMP 应用程序与 MPI 结合起来。例如,要编译它,我们将运行:

$ mpicc -openmp hellohybrid.c -o hellohybrid

接下来,要运行应用程序,我们将使用 mpirun 或等效命令:

$ export OMP_NUM_THREADS=8
$ mpirun -np 2 --hostfile my_hostfile -x OMP_NUM_THREADS ./hellohybrid

mpirun 命令将使用 hellohybrid 二进制文件运行两个 MPI 进程,并将我们使用-x 标志导出的环境变量传递给每个新进程。然后,该变量中包含的值将由 OpenMP 运行时用于创建相应数量的线程。

假设我们的 MPI 主机文件中至少有两个 MPI 节点,我们将在两个节点上运行两个 MPI 进程,每个进程运行八个线程,这将适合具有超线程的四核 CPU 或八核 CPU。

潜在问题

在编写基于 MPI 的应用程序并在多核 CPU 或集群上执行时,可能会遇到的问题与我们在前面章节中已经遇到的多线程代码问题非常相似。

然而,使用 MPI 的一个额外担忧是依赖网络资源的可用性。由于用于MPI_Send调用的发送缓冲区在网络堆栈处理缓冲区之前无法回收,并且此调用是阻塞类型,发送大量小消息可能导致一个进程等待另一个进程,而另一个进程又在等待调用完成。

在设计 MPI 应用程序的消息传递结构时,应该牢记这种死锁。例如,可以确保一侧没有发送调用积累,这将导致这种情况。提供有关队列深度等的反馈消息可以用于减轻压力。

MPI 还包含使用所谓的屏障的同步机制。这是用于允许 MPI 进程在例如一个任务上进行同步的。使用 MPI 屏障(MPI_Barrier)调用与互斥锁类似,如果 MPI 进程无法实现同步,一切都将在此时挂起。

总结

在本章中,我们详细研究了 MPI 标准,以及其中一些实现,特别是 Open MPI,并了解了如何设置集群。我们还看到如何使用 OpenMP 轻松地为现有代码添加多线程。

到这一点,读者应该能够建立一个基本的贝奥武夫或类似的集群,为 MPI 进行配置,并在其上运行基本的 MPI 应用程序。应该知道如何在 MPI 进程之间进行通信以及如何定义自定义数据类型。此外,读者将意识到在为 MPI 编程时可能遇到的潜在问题。

在下一章中,我们将汇总前面章节的所有知识,并看看如何在最后一章中将它们结合起来,以便研究通用计算机上的视频卡(GPGPU)。

第十九章:使用 GPGPU 进行多线程处理

最近的一个发展是使用视频卡(GPU)进行通用计算(GPGPU)。使用 CUDA 和 OpenCL 等框架,可以加速例如在医疗、军事和科学应用中并行处理大型数据集的处理。在本章中,我们将看看如何使用 C和 OpenCL 来实现这一点,以及如何将这一特性集成到 C中的多线程应用程序中。

本章的主题包括:

  • 将 OpenCL 集成到基于 C++的应用程序中

  • 在多线程环境中使用 OpenCL 的挑战

  • 延迟和调度对多线程性能的影响

GPGPU 处理模型

在第十六章中,使用分布式计算进行多线程处理,我们讨论了在集群系统中跨多个计算节点运行相同任务的情况。这样设置的主要目标是以高度并行的方式处理数据,从理论上讲,相对于具有较少 CPU 核心的单个系统,可以加快处理速度。

GPGPU(图形处理单元上的通用计算)在某些方面与此类似,但有一个主要区别:虽然只有常规 CPU 的计算集群擅长标量任务--即在一个单一数据集上执行一个任务(SISD)--GPU 是擅长 SIMD(单输入,多数据)任务的矢量处理器。

基本上,这意味着可以将大型数据集发送到 GPU,以及单个任务描述,GPU 将在其数百或数千个核心上并行执行该数据的部分相同任务。因此,可以将 GPU 视为一种非常专门的集群:

实现

当首次提出 GPGPU 概念(大约在 2001 年左右)时,编写 GPGPU 程序的最常见方式是使用 GLSL(OpenGL 着色语言)和类似的着色器语言。由于这些着色器语言已经针对 SIMD 任务(图像和场景数据)进行了优化,因此将它们调整为更通用的任务相对比较简单。

自那时起,出现了许多更专业的实现:

名称 所有者 备注
CUDA 2006 NVidia 这是专有的,只能在 NVidia GPU 上运行
Close to Metal 2006 ATi/AMD 这已被放弃,转而采用 OpenCL
DirectCompute 2008 Microsoft 这是与 DX11 一起发布的,可在 DX10 GPU 上运行,并且仅限于 Windows 平台
OpenCL 2009 Khronos Group 这是开放标准,可在 AMD、Intel 和 NVidia GPU 上运行,并且适用于所有主流平台,以及移动平台

OpenCL

在各种当前的 GPGPU 实现中,由于没有限制,OpenCL 是迄今为止最有趣的 GPGPU API。它几乎适用于所有主流 GPU 和平台,甚至在部分移动平台上也得到支持。

OpenCL 的另一个显着特点是它不仅限于 GPGPU。作为其名称的一部分(开放计算语言),它将系统抽象为所谓的计算设备,每个设备都有自己的功能。GPGPU 是最常见的应用,但这一特性使得在 CPU 上进行测试实现变得相当容易,以便进行简单的调试。

OpenCL 的一个可能的缺点是它对内存和硬件细节采用了高度抽象,这可能会对性能产生负面影响,尽管它增加了代码的可移植性。

在本章的其余部分,我们将专注于 OpenCL。

常见的 OpenCL 应用程序

许多程序都包含基于 OpenCL 的代码,以加速操作。这些包括旨在图形处理的程序,以及 3D 建模和 CAD,音频和视频处理。一些例子是:

  • Adobe Photoshop

  • GIMP

  • ImageMagick

  • Autodesk Maya

  • Blender

  • Handbrake

  • Vegas Pro

  • OpenCV

  • Libav

  • Final Cut Pro

  • FFmpeg

在办公应用程序中进一步加速某些操作,包括 LibreOffice Calc 和 Microsoft Excel。

也许更重要的是,OpenCL 也常用于科学计算和密码学,包括 BOINC 和 GROMACS 以及许多其他库和程序。

OpenCL 版本

自 2008 年 12 月 8 日 OpenCL 规范发布以来,迄今已经有五次更新,将其提升到 2.2 版本。这些发布的重要变化将在下文中提到。

OpenCL 1.0

首个公开发布是由苹果作为 macOS X Snow Leopard 发布的一部分于 2009 年 8 月 28 日发布的。

与此发布一起,AMD 宣布将支持 OpenCL 并淘汰其自己的 Close to Metal(CtM)框架。 NVIDIA,RapidMind 和 IBM 还为其自己的框架添加了对 OpenCL 的支持。

OpenCL 1.1

OpenCL 1.1 规范于 2010 年 6 月 14 日由 Khronos Group 批准。它为并行编程和性能增加了额外功能,包括以下内容:

  • 包括 3 组分向量和额外的图像格式在内的新数据类型

  • 处理来自多个主机线程的命令,并在多个设备上处理缓冲区

  • 对缓冲区的区域操作,包括读取、写入和复制 1D、2D 或 3D 矩形区域

  • 增强事件的使用来驱动和控制命令执行

  • 包括整数夹紧、洗牌和异步分步(不连续,但数据之间有间隔)复制等额外的 OpenCL 内置 C 函数

  • 通过链接 OpenCL 和 OpenGL 事件,通过有效共享图像和缓冲区来改进 OpenGL 的互操作性

OpenCL 1.2

OpenCL 1.2 版本于 2011 年 11 月 15 日发布。其最重要的功能包括以下内容:

  • 设备分区:这使应用程序可以将设备分成子设备,直接控制对特定计算单元的工作分配,为高优先级/延迟敏感任务保留设备的一部分,或有效地使用共享硬件资源,如缓存。

  • 对象的分离编译和链接:这提供了传统编译器的功能和灵活性,使得可以创建 OpenCL 程序的库供其他程序链接。

  • 增强的图像支持:这包括对 1D 图像和 1D 和 2D 图像数组的增强支持。此外,OpenGL 共享扩展现在可以从 OpenGL 1D 纹理和 1D 和 2D 纹理数组创建 OpenCL 图像。

  • 内置内核:这代表了专门的或不可编程的硬件以及相关的固件的功能,如视频编码器/解码器和数字信号处理器,使得这些定制设备可以从 OpenCL 框架中驱动并与之紧密集成。

  • DX9 媒体表面共享:这使得 OpenCL 和 DirectX 9 或 DXVA 媒体表面之间的有效共享成为可能。

  • DX11 表面共享:实现 OpenCL 和 DirectX 11 表面之间的无缝共享。

OpenCL 2.0

OpenCL2.0 版本于 2013 年 11 月 18 日发布。此版本具有以下重大变化或增加:

  • 共享虚拟内存:主机和设备内核可以直接共享复杂的、包含指针的数据结构,如树和链表,提供了显著的编程灵活性,并消除了主机和设备之间昂贵的数据传输。

  • 动态并行性:设备内核可以在没有主机交互的情况下将内核排队到同一设备,从而实现灵活的工作调度范式,并避免在设备和主机之间传输执行控制和数据,通常显著减轻主机处理器瓶颈。

  • 通用地址空间:函数可以在不指定参数的命名地址空间的情况下编写,特别适用于那些声明为指向类型的指针的参数,消除了为应用程序中使用的每个命名地址空间编写多个函数的需要。

  • 图像:改进的图像支持,包括 sRGB 图像和 3D 图像写入,内核可以从图像中读取和写入相同的图像,并且可以从 mip-mapped 或多采样的 OpenGL 纹理创建 OpenCL 图像,以改进 OpenGL 互操作性。

  • C11 原子操作:C11 原子操作和同步操作的子集,以便使一个工作项中的赋值对于设备上执行的其他工作项或在设备上执行的工作组之间可见,或者用于在 OpenCL 设备和主机之间共享数据。

  • 管道:管道是以 FIFO 形式存储数据的内存对象,OpenCL 2.0 提供了内建函数,用于内核读取或写入管道,从而提供了对管道数据结构的直接编程,这可以通过 OpenCL 实现者进行高度优化。

  • Android 可安装客户端驱动扩展:使得可以在 Android 系统上发现和加载 OpenCL 实现作为共享对象。

OpenCL 2.1

OpenCL 2.1 对 2.0 标准的修订于 2015 年 11 月 16 日发布。这个版本最显著的是引入了 OpenCL C内核语言,就像 OpenCL 语言最初是基于 C 的扩展一样,C版本是基于 C++14 的子集,同时向后兼容 C 内核语言。

OpenCL API 的更新包括以下内容:

  • 子组:这些使得对硬件线程的控制更加精细,现在已经纳入核心,还增加了额外的子组查询操作,以增加灵活性

  • 内核对象和状态的复制:clCloneKernel 使得可以复制内核对象和状态,以安全地实现包装类中的复制构造函数

  • 低延迟设备定时器查询:这允许在设备和主机代码之间对齐分析数据

  • 运行时的中间 SPIR-V 代码

  • LLVM 到 SPIR-V 之间的双向转换器,以便在工具链中灵活使用这两种中间语言。

  • 一个将 OpenCL C 编译为 LLVM 的编译器,通过上述转换器生成 SPIR-V。

  • SPIR-V 汇编器和反汇编器。

标准可移植中间表示(SPIR)及其后继者 SPIR-V,是为了在 OpenCL 设备上提供设备无关的二进制文件。

OpenCL 2.2

2017 年 5 月 16 日,当前版本的 OpenCL 发布。根据 Khronos Group 的说法,它包括以下更改:

  • OpenCL 2.2 将 OpenCL C++内核语言纳入核心规范,显著提高了并行编程的生产力

  • OpenCL C内核语言是 C14 标准的静态子集,包括类、模板、Lambda 表达式、函数重载和许多其他用于通用和元编程的构造

  • 利用新的 Khronos SPIR-V 1.1 中间语言,完全支持 OpenCL C++内核语言

  • OpenCL 库函数现在可以利用 C++语言,以提供更高的安全性和减少未定义行为,同时访问原子操作、迭代器、图像、采样器、管道和设备队列内置类型和地址空间

  • 管道存储是 OpenCL 2.2 中的一种新的设备端类型,对于 FPGA 实现非常有用,因为它可以在编译时知道连接大小和类型,并且可以在内核之间实现高效的设备范围通信

  • OpenCL 2.2 还包括用于增强生成代码优化的功能:应用程序可以在 SPIR-V 编译时提供专门化常量的值,新的查询可以检测程序范围全局对象的非平凡构造函数和析构函数,用户回调可以在程序释放时设置

  • 可以在任何支持 OpenCL 2.0 的硬件上运行(只需要更新驱动程序)

建立开发环境

无论您使用哪个平台和 GPU,进行 OpenCL 开发的最重要部分是从制造商那里获取适用于自己 GPU 的 OpenCL 运行时。在此,AMD、Intel 和 NVidia 都为所有主流平台提供 SDK。对于 NVidia,OpenCL 支持包含在 CUDA SDK 中。

除了 GPU 供应商的 SDK 外,还可以在其网站上找到有关此 SDK 支持哪些 GPU 的详细信息。

Linux

安装供应商的 GPGPU SDK 后,我们仍然需要下载 OpenCL 头文件。与供应商提供的共享库和运行时文件不同,这些头文件是通用的,可以与任何 OpenCL 实现一起使用。

对于基于 Debian 的发行版,只需执行以下命令行:

    $ sudo apt-get install opencl-headers

对于其他发行版,该软件包可能被称为相同的名称,或者是其他名称。请查阅发行版的手册,了解如何查找软件包名称。

安装 SDK 和 OpenCL 头文件后,我们准备编译我们的第一个 OpenCL 应用程序。

Windows

在 Windows 上,我们可以选择使用 Visual Studio(Visual C++)或 Windows 版的 GCC(MinGW)进行开发。为了与 Linux 版本保持一致,我们将使用 MinGW 以及 MSYS2。这意味着我们将拥有相同的编译器工具链、相同的 Bash shell 和实用程序,以及 Pacman 软件包管理器。

在安装供应商的 GPGPU SDK 后,如前所述,只需在 MSYS2 shell 中执行以下命令行,即可安装 OpenCL 头文件:

    $ pacman -S mingw64/mingw-w64-x86_64-opencl-headers

或者,在使用 32 位 MinGW 版本时,执行以下命令行:

    mingw32/mingw-w64-i686-opencl-headers 

有了这个,OpenCL 头文件已经就位。现在我们只需要确保 MinGW 链接器可以找到 OpenCL 库。使用 NVidia CUDA SDK,您可以使用CUDA_PATH环境变量,或者浏览 SDK 的安装位置,并将适当的 OpenCL LIB 文件从那里复制到 MinGW lib 文件夹中,确保不要混淆 32 位和 64 位文件。

现在共享库也就位了,我们可以编译 OpenCL 应用程序了。

OS X/MacOS

从 OS X 10.7 开始,OS 中提供了 OpenCL 运行时。安装 XCode 以获取开发头文件和库后,可以立即开始 OpenCL 开发。

一个基本的 OpenCL 应用程序

一个常见的 GPGPU 应用示例是计算快速傅立叶变换(FFT)。这种算法通常用于音频处理等领域,允许您将例如从时域转换到频域进行分析。

它的作用是对数据集应用分而治之的方法,以计算 DFT(离散傅立叶变换)。它通过将输入序列分割成固定数量的较小子序列,计算它们的 DFT,并组装这些输出,以组成最终序列。

这是相当高级的数学,但可以说,它非常适合 GPGPU 的原因是它是一种高度并行的算法,采用数据的细分以加快 DFT 的计算,如图所示:

每个 OpenCL 应用程序至少由两部分组成:设置和配置 OpenCL 实例的 C++代码,以及实际的 OpenCL 代码,也称为内核,例如基于维基百科 FFT 演示示例的这个内核:

// This kernel computes FFT of length 1024\.  
// The 1024 length FFT is decomposed into calls to a radix 16 function,  
// another radix 16 function and then a radix 4 function
 __kernel void fft1D_1024 (__global float2 *in,  
                     __global float2 *out,  
                     __local float *sMemx,  
                     __local float *sMemy) {
          int tid = get_local_id(0);
          int blockIdx = get_group_id(0) * 1024 + tid;
          float2 data[16];

          // starting index of data to/from global memory
          in = in + blockIdx;  out = out + blockIdx;

          globalLoads(data, in, 64); // coalesced global reads
          fftRadix16Pass(data);      // in-place radix-16 pass
          twiddleFactorMul(data, tid, 1024, 0);

          // local shuffle using local memory
          localShuffle(data, sMemx, sMemy, tid, (((tid & 15) * 65) + (tid >> 4)));
          fftRadix16Pass(data);               // in-place radix-16 pass
          twiddleFactorMul(data, tid, 64, 4); // twiddle factor multiplication

          localShuffle(data, sMemx, sMemy, tid, (((tid >> 4) * 64) + (tid & 15)));

          // four radix-4 function calls
          fftRadix4Pass(data);      // radix-4 function number 1
          fftRadix4Pass(data + 4);  // radix-4 function number 2
          fftRadix4Pass(data + 8);  // radix-4 function number 3
          fftRadix4Pass(data + 12); // radix-4 function number 4

          // coalesced global writes
    globalStores(data, out, 64);
 } 

这个 OpenCL 内核表明,与 GLSL 着色器语言一样,OpenCL 的内核语言本质上是 C 语言,具有许多扩展。虽然可以使用 OpenCL C++内核语言,但自 OpenCL 2.1(2015)以来,它只能使用,因此对它的支持和示例比 C 内核语言更少。

接下来是 C++应用程序,使用它,我们运行前面的 OpenCL 内核:

#include <cstdio>
 #include <ctime>
 #include "CLopencl.h"

 #define NUM_ENTRIES 1024

 int main() { // (int argc, const char * argv[]) {
    const char* KernelSource = "fft1D_1024_kernel_src.cl"; 

正如我们在这里看到的,我们只需要包含一个头文件,以便访问 OpenCL 函数。我们还指定包含我们的 OpenCL 内核源代码的文件的名称。由于每个 OpenCL 设备可能是不同的架构,当我们加载它时,内核将被编译为目标设备:

          const cl_uint num = 1;
    clGetDeviceIDs(0, CL_DEVICE_TYPE_GPU, 0, 0, (cl_uint*) num); 

   cl_device_id devices[1];
    clGetDeviceIDs(0, CL_DEVICE_TYPE_GPU, num, devices, 0);

接下来,我们必须获取我们可以使用的 OpenCL 设备列表,并通过 GPU 进行过滤:

    cl_context context = clCreateContextFromType(0, CL_DEVICE_TYPE_GPU,  
                                                   0, 0, 0); 

然后,我们使用我们找到的 GPU 设备创建一个 OpenCLcontext。上下文管理一系列设备上的资源:

    clGetDeviceIDs(0, CL_DEVICE_TYPE_DEFAULT, 1, devices, 0);
    cl_command_queue queue = clCreateCommandQueue(context, devices[0], 0, 0); 

最后,我们将创建包含要在 OpenCL 设备上执行的命令的命令队列:

    cl_mem memobjs[] = { clCreateBuffer(context, CL_MEM_READ_ONLY | CL_MEM_COPY_HOST_PTR, sizeof(float) * 2 * NUM_ENTRIES, 0, 0),              
   clCreateBuffer(context, CL_MEM_READ_WRITE, sizeof(float) * 2 * NUM_ENTRIES, 0, 0) }; 

为了与设备通信,我们需要分配将包含我们要复制到它们的内存中的数据的缓冲区对象。在这里,我们将分配两个缓冲区,一个用于读取,一个用于写入:

    cl_program program = clCreateProgramWithSource(context, 1, (const char **)& KernelSource, 0, 0); 

我们现在已经在设备上获得了数据,但仍然需要在设备上加载内核。为此,我们将使用我们之前查看过的 OpenCL 内核源代码创建一个内核,使用我们之前定义的文件名:

    clBuildProgram(program, 0, 0, 0, 0, 0); 

接下来,我们将按以下方式编译源代码:

   cl_kernel kernel = clCreateKernel(program, "fft1D_1024", 0); 

最后,我们将从我们创建的二进制文件中创建实际的内核:

    size_t local_work_size[1] = { 256 };

    clSetKernelArg(kernel, 0, sizeof(cl_mem), (void *) &memobjs[0]);
    clSetKernelArg(kernel, 1, sizeof(cl_mem), (void *) &memobjs[1]);
    clSetKernelArg(kernel, 2, sizeof(float) * (local_work_size[0] + 1) * 16, 0);
    clSetKernelArg(kernel, 3, sizeof(float) * (local_work_size[0] + 1) * 16, 0); 

为了向我们的内核传递参数,我们必须在这里设置它们。在这里,我们将添加指向我们的缓冲区和工作大小维度的指针,如下所示:

    size_t global_work_size[1] = { 256 };
          global_work_size[0] = NUM_ENTRIES;
    local_work_size[0]  =  64;  // Nvidia: 192 or 256
    clEnqueueNDRangeKernel(queue, kernel, 1, 0, global_work_size, local_work_size, 0, 0, 0); 

现在我们可以设置工作项维度并执行内核。在这里,我们将使用一种内核执行方法,允许我们定义工作组的大小:

          cl_mem C = clCreateBuffer(context, CL_MEM_WRITE_ONLY, (size), 0, &ret);
                      cl_int ret = clEnqueueReadBuffer(queue, memobjs[1], CL_TRUE, 0, sizeof(float) * 2 * NUM_ENTRIES, C, 0, 0, 0); 

执行完内核后,我们希望读取生成的信息。为此,我们告诉 OpenCL 将我们传递为内核参数的已分配写入缓冲区复制到一个新分配的缓冲区中。我们现在可以自由地使用这个缓冲区中的数据。

然而,在这个例子中,我们将不使用这些数据:

    clReleaseMemObject(memobjs[0]);
    clReleaseMemObject(memobjs[1]); 
   clReleaseCommandQueue(queue); 
   clReleaseKernel(kernel); 
   clReleaseProgram(program); 
   clReleaseContext(context); 
   free(C);
 } 

最后,我们释放我们分配的资源并退出。

GPU 内存管理

在使用 CPU 时,必须处理多个内存层次结构,从主内存(最慢)到 CPU 缓存(更快),再到 CPU 寄存器(最快)。GPU 也是如此,必须处理一个可能会显著影响应用程序速度的内存层次结构。

GPU 上最快的也是寄存器(或私有)内存,我们拥有的比平均 CPU 多得多。之后是本地内存,这是一种由多个处理单元共享的内存。GPU 本身上最慢的是内存数据缓存,也称为纹理内存。这是卡上的一种内存,通常被称为视频 RAM(VRAM),使用高带宽,但相对高延迟的内存,如 GDDR5。

绝对最慢的是使用主机系统的内存(系统 RAM),因为这需要通过 PCIe 总线和其他各种子系统传输任何数据。相对于设备内存系统,主机设备通信最好称为“冰川”。

对于 AMD、Nvidia 和类似的专用 GPU 设备,内存架构可以像这样进行可视化:

由于这种内存布局,建议以大块传输任何数据,并在可能的情况下使用异步传输。理想情况下,内核将在 GPU 核心上运行,并将数据流式传输到它,以避免任何延迟。

GPGPU 和多线程

将多线程代码与 GPGPU 结合起来要比尝试在 MPI 集群上运行并行应用程序更容易得多。这主要是由于以下工作流程:

  1. 准备数据:准备我们要处理的数据,例如大量图像或单个大图像,通过将其发送到 GPU 的内存中。

  2. 准备内核:加载 OpenCL 内核文件并将其编译成 OpenCL 内核。

  3. 执行内核:将内核发送到 GPU 并指示它开始处理数据。

  4. 读取数据:一旦我们知道处理已经完成,或者达到了特定的中间状态,我们将读取我们传递给 OpenCL 内核作为参数的缓冲区,以获取我们的结果。

由于这是一个异步过程,可以将其视为一种“发射并忘记”的操作,只需有一个专用线程来监视活动内核的过程。

在多线程和 GPGPU 应用程序方面最大的挑战不在于基于主机的应用程序,而在于运行在 GPU 上的 GPGPU 内核或着色器程序,因为它必须在本地和远程处理单元之间协调内存管理和处理,确定根据数据类型使用哪种内存系统,而不会在处理中引起其他问题。

这是一个需要大量试错、分析和优化的精细过程。一个内存复制优化或使用异步操作而不是同步操作可能将处理时间从几个小时减少到几分钟。对内存系统的良好理解对于防止数据饥饿和类似问题至关重要。

由于 GPGPU 通常用于加速持续时间显著的任务(几分钟到几小时甚至更长),因此最好从多线程的角度来看待它,尽管存在一些重要的复杂性,主要是延迟的形式。

延迟

正如我们在早期关于 GPU 内存管理的部分提到的,最好首先使用最接近 GPU 处理单元的内存,因为它们是最快的。这里的最快主要意味着它们具有较低的延迟,即从内存请求信息到接收响应所需的时间。

确切的延迟会因 GPU 而异,但以 Nvidia 的 Kepler(Tesla K20)架构为例,可以期望延迟为:

  • 全局内存:450 个周期。

  • 常量内存缓存:45-125 个周期。

  • 本地共享)内存:45 个周期。

这些测量都是在 CPU 上进行的。对于 PCIe 总线,一旦开始传输多兆字节的缓冲区,每次传输可能需要几毫秒的时间。例如,填充 GPU 内存的一个大小为 1GB 的缓冲区可能需要相当长的时间。

对于 PCIe 总线的简单往返,延迟可能在微秒级别,对于以 1+ GHz 运行的 GPU 核心来说,似乎是一个漫长的时间。这基本上定义了为什么主机和 GPU 之间的通信应该尽可能少,并且高度优化。

潜在问题

GPGPU 应用程序的一个常见错误是在处理完成之前读取结果缓冲区。在将缓冲区传输到设备并执行内核之后,必须插入同步点以向主机发出已完成处理的信号。这些通常应该使用异步方法实现。

正如我们在延迟部分所讨论的,重要的是要记住请求和响应之间潜在的非常大的延迟,这取决于内存子系统或总线。不这样做可能会导致奇怪的故障、冻结和崩溃,以及数据损坏和似乎永远等待的应用程序。

对 GPGPU 应用程序进行分析是至关重要的,以便了解 GPU 利用率如何,以及流程是否接近最佳状态。

调试 GPGPU 应用程序

GPGPU 应用程序最大的挑战在于调试内核。CUDA 带有模拟器,允许在 CPU 上运行和调试内核。OpenCL 允许在 CPU 上运行内核而无需修改,尽管这可能无法获得与在特定 GPU 设备上运行时相同的行为(和错误)。

一个稍微更高级的方法涉及使用专用调试器,比如 Nvidia 的 Nsight,它有适用于 Visual Studio(developer.nvidia.com/nvidia-nsight-visual-studio-edition)和 Eclipse(developer.nvidia.com/nsight-eclipse-edition)的版本。

根据 Nsight 网站上的营销宣传:

英伟达 Nsight Visual Studio Edition 将 GPU 计算引入了 Microsoft Visual Studio(包括多个 VS2017 实例)。这款用于 GPU 的应用程序开发环境允许您构建、调试、分析和跟踪使用 CUDA C/C++、OpenCL、DirectCompute、Direct3D、Vulkan API、OpenGL、OpenVR 和 Oculus SDK 构建的异构计算、图形和虚拟现实应用程序。

以下截图显示了一个活跃的 CUDA 调试会话:

这样一个调试器工具的一个重要优势是,它允许用户通过识别瓶颈和潜在问题来监视、分析和优化自己的 GPGPU 应用程序。

总结

在本章中,我们看了如何将 GPGPU 处理集成到 C++应用程序中,以 OpenCL 的形式。我们还研究了 GPU 内存层次结构以及这如何影响性能,特别是在主机-设备通信方面。

现在您应该熟悉 GPGPU 的实现和概念,以及如何创建一个 OpenCL 应用程序,以及如何编译和运行它。如何避免常见错误也应该是已知的。

作为本书的最后一章,希望所有主要问题都已得到解答,并且前面的章节以及本章在某种程度上都是有益和有帮助的。

从这本书中继续,读者可能对更详细地探讨其中涉及的任何主题感兴趣,这方面有很多在线和离线资源可用。多线程和相关领域的主题非常广泛,涉及到许多应用,从商业到科学、艺术和个人应用。

读者可能想要建立自己的 Beowulf 集群,或者专注于 GPGPU,或者将两者结合起来。也许有一个复杂的应用程序他们想要写一段时间了,或者只是想玩编程。

第二十章:C++17 STL Cookbook

发现函数式编程和 lambda 表达式的最新增强

第二十一章:新的 C++17 功能

在本章中,我们将涵盖以下内容:

  • 使用结构化绑定来解包捆绑的返回值

  • 将变量范围限制为ifswitch语句

  • 从新的括号初始化规则中获益

  • 让构造函数自动推断结果模板类类型

  • 使用 constexpr-if 简化编译时决策

  • 使用内联变量启用仅头文件库

  • 使用折叠表达式实现方便的辅助函数

介绍

C在 C11、C14 和最近的 C17 中增加了很多内容。到目前为止,它与十年前完全不同。C标准不仅标准化了语言,因为它需要被编译器理解,还标准化了 C标准模板库(STL)。

本书将解释如何通过大量示例充分利用 STL。但首先,本章将集中讨论最重要的新语言特性。掌握它们将极大地帮助您编写可读、可维护和富有表现力的代码。

我们将看到如何使用结构化绑定舒适地访问对、元组和结构的单个成员,以及如何使用新的ifswitch变量初始化功能来限制变量范围。C++11 引入了新的括号初始化语法,它看起来与初始化列表相同,引入了语法上的歧义,这些问题已经通过新的括号初始化规则得到解决。现在可以从实际构造函数参数中推断模板类实例的确切类型,如果模板类的不同特化将导致完全不同的代码,现在可以使用 constexpr-if 轻松表达。在许多情况下,使用新的折叠表达式可以使模板函数中的可变参数包处理变得更加容易。最后,使用新的内联变量声明静态全局可访问对象在仅头文件库中变得更加舒适,这在之前只对函数可行。

本章中的一些示例对库的实现者可能更有趣,而对于实现应用程序的开发人员来说可能不那么重要。虽然出于完整性的原因我们将研究这些特性,但不需要立即理解本章的所有示例就能理解本书的其余部分。

使用结构化绑定来解包捆绑的返回值

C++17 带来了一个新特性,结合了语法糖和自动类型推断:结构化绑定。这有助于将对、元组和结构的值分配给单独的变量。在其他编程语言中,这也被称为解包

如何做...

应用结构化绑定以从一个捆绑结构中分配多个变量始终是一步。让我们首先看看 C17 之前是如何做的。然后,我们可以看一下多个示例,展示了我们如何在 C17 中做到这一点:

  • 访问std::pair的单个值:假设我们有一个数学函数divide_remainder,它接受被除数除数参数,并返回两者的分数以及余数。它使用std::pair捆绑返回这些值:
        std::pair<int, int> divide_remainder(int dividend, int divisor);

考虑以下访问结果对的单个值的方式:

        const auto result (divide_remainder(16, 3));
        std::cout << "16 / 3 is " 
                  << result.first << " with a remainder of " 
                  << result.second << 'n';

我们现在可以使用有表达力的名称将单个值分配给单独的变量,这样阅读起来更好:

 auto [fraction, remainder] = divide_remainder(16, 3);
        std::cout << "16 / 3 is " 
                  << fraction << " with a remainder of "       
                  << remainder << 'n';
  • 结构化绑定也适用于std::tuple:让我们看看以下示例函数,它可以获取在线股票信息:
        std::tuple<std::string, 
                   std::chrono::system_clock::time_point, unsigned>
        stock_info(const std::string &name);

将其结果分配给单独的变量看起来就像前面的示例:

 const auto [name, valid_time, price] = stock_info("INTC");
  • 结构化绑定也适用于自定义结构:假设有以下结构:
        struct employee {
            unsigned id;
            std::string name;
            std::string role;
            unsigned salary;
        };

现在,我们可以使用结构化绑定访问这些成员。假设我们有一个整个向量:

        int main()
        {
            std::vector<employee> employees {
                /* Initialized from somewhere */};

            for (const auto &[id, name, role, salary] : employees) {
                std::cout << "Name: "   << name
                          << "Role: "   << role
                          << "Salary: " << salary << 'n';
            }
        }

它是如何工作的...

结构化绑定总是以相同的模式应用:

auto [var1, var2, ...] = <pair, tuple, struct, or array expression>;
  • 变量列表var1, var2, ...必须与被赋值的表达式包含的变量数量完全匹配。

  • <pair, tuple, struct, or array expression>必须是以下之一:

  • 一个std::pair

  • 一个std::tuple

  • 一个struct。所有成员必须是非静态的,并且定义在同一个基类中。第一个声明的成员被分配给第一个变量,第二个成员被分配给第二个变量,依此类推。

  • 固定大小的数组。

  • 类型可以是autoconst autoconst auto&,甚至auto&&

不仅出于性能的考虑,始终确保通过在适当的情况下使用引用来最小化不必要的复制。

如果我们在方括号之间写入太多太少的变量,编译器将报错,告诉我们我们的错误:

std::tuple<int, float, long> tup {1, 2.0, 3};
auto [a, b] = tup; // Does not work

这个例子显然试图将一个包含三个成员的元组变量塞入只有两个变量的情况中。编译器立即对此进行了处理,并告诉我们我们的错误:

error: type 'std::tuple<int, float, long>' decomposes into 3 elements, but only 2 names were provided
auto [a, b] = tup;

还有更多...

STL 中的许多基本数据结构都可以立即使用结构化绑定进行访问,而无需我们改变任何内容。例如,考虑一个循环,打印出std::map的所有项:

std::map<std::string, size_t> animal_population {
    {"humans",   7000000000},
    {"chickens", 17863376000},
    {"camels",   24246291},
    {"sheep",    1086881528},
    /* … */
};

for (const auto &[species, count] : animal_population) {
    std::cout << "There are " << count << " " << species 
              << " on this planet.n";
}

这个特定的例子之所以有效,是因为当我们遍历一个std::map容器时,我们在每次迭代步骤上得到std::pair<const key_type, value_type>节点。正是这些节点使用结构化绑定功能(key_typespecies字符串,value_type是人口计数size_t)进行拆包,以便在循环体中单独访问它们。

在 C++17 之前,可以使用std::tie来实现类似的效果:

int remainder;
std::tie(std::ignore, remainder) = divide_remainder(16, 5);
std::cout << "16 % 5 is " << remainder << 'n';

这个例子展示了如何将结果对拆分成两个变量。std::tie在某种意义上比结构化绑定功能弱,因为我们必须在之前定义我们想要绑定的所有变量。另一方面,这个例子展示了std::tie的一个优势,结构化绑定没有:值std::ignore充当一个虚拟变量。结果的小数部分被分配给它,这导致该值被丢弃,因为在这个例子中我们不需要它。

在使用结构化绑定时,我们没有tie虚拟变量,因此我们必须将所有的值绑定到命名变量。尽管如此,忽略其中一些是有效的,因为编译器可以轻松地优化未使用的绑定。

回到过去,divide_remainder函数可以以以下方式实现,使用输出参数:

bool divide_remainder(int dividend, int divisor, 
                      int &fraction, int &remainder);

访问它看起来像这样:

int fraction, remainder;
const bool success {divide_remainder(16, 3, fraction, remainder)};
if (success) {
    std::cout << "16 / 3 is " << fraction << " with a remainder of " 
              << remainder << 'n';
}

很多人仍然更喜欢这种方式,而不是返回像对、元组和结构这样的复杂结构,他们认为这样代码会更,因为避免了这些值的中间复制。对于现代编译器来说,这不再是真的,因为它们会优化掉中间复制。

除了 C 语言中缺少的语言特性外,通过返回值返回复杂结构长时间被认为是慢的,因为对象必须在返回函数中初始化,然后复制到应该包含返回值的变量中。现代编译器支持返回值优化(RVO),可以省略中间复制。

将变量范围限制在 if 和 switch 语句中

尽可能限制变量的范围是一个很好的风格。然而,有时候,我们首先需要获取一些值,只有在符合某种条件的情况下,才能进一步处理。

为此,C++17 提供了带有初始化程序的ifswitch语句。

如何做...

在这个示例中,我们在支持的上下文中都使用了初始化程序语法,以便看到它们如何整理我们的代码:

  • if语句:假设我们想要使用std::mapfind方法在字符映射中找到一个字符:
       if (auto itr (character_map.find(c)); itr != character_map.end()) {
           // *itr is valid. Do something with it.
       } else {
           // itr is the end-iterator. Don't dereference.
       }
       // itr is not available here at all

  • switch语句:这是从输入中获取字符并同时在switch语句中检查值以控制计算机游戏的样子。
       switch (char c (getchar()); c) {
           case 'a': move_left();  break;
           case 's': move_back();  break;
           case 'w': move_fwd();   break;
           case 'd': move_right(); break;
           case 'q': quit_game();  break;

           case '0'...'9': select_tool('0' - c); break;

           default:
               std::cout << "invalid input: " << c << 'n';
       }

工作原理...

带有初始化器的ifswitch语句基本上只是语法糖。以下两个示例是等效的:

C++17 之前

{
    auto var (init_value);
    if (condition) {
        // branch A. var is accessible
    } else {
        // branch B. var is accessible
    }
    // var is still accessible
}

C++17:

if (auto var (init_value); condition) {
    // branch A. var is accessible
} else {
    // branch B. var is accessible
}
// var is not accessible any longer

同样适用于switch语句:

在 C++17 之前:

{
    auto var (init_value);
    switch (var) {
    case 1: ...
    case 2: ...
    ...
    }
    // var is still accessible
}

自 C++17 以来:

switch (auto var (init_value); var) {
case 1: ...
case 2: ...
  ...
}
// var is not accessible any longer

这个特性非常有用,可以使变量的作用域尽可能短。在 C17 之前,只能在代码周围使用额外的大括号来实现这一点,正如 C17 之前的示例所示。短暂的生命周期减少了作用域中的变量数量,使我们的代码整洁,并且更容易重构。

还有更多...

另一个有趣的用例是临界区的有限作用域。考虑以下例子:

if (std::lock_guard<std::mutex> lg {my_mutex}; some_condition) {
    // Do something
}

首先,创建一个std::lock_guard。这是一个接受互斥体参数作为构造函数参数的类。它在其构造函数中锁定互斥体,并且当它超出作用域时,在其析构函数中再次解锁它。这样,忘记解锁互斥体是不可能的。在 C++17 之前,需要一对额外的大括号来确定它再次解锁的作用域。

另一个有趣的用例是弱指针的作用域。考虑以下情况:

if (auto shared_pointer (weak_pointer.lock()); shared_pointer != nullptr) {
    // Yes, the shared object does still exist
} else {
    // shared_pointer var is accessible, but a null pointer
}
// shared_pointer is not accessible any longer

这是另一个例子,我们会有一个无用的shared_pointer变量泄漏到当前作用域,尽管它在if条件块外部或有嘈杂的额外括号时可能是无用的!

带有初始化器的if语句在使用遗留API 和输出参数时特别有用:

if (DWORD exit_code; GetExitCodeProcess(process_handle, &exit_code)) {
    std::cout << "Exit code of process was: " << exit_code << 'n';
}
// No useless exit_code variable outside the if-conditional

GetExitCodeProcess是 Windows 内核 API 函数。它返回给定进程句柄的退出代码,但只有在该句柄有效时才会返回。离开这个条件块后,变量就变得无用了,所以我们不再需要它在任何作用域中。

能够在if块中初始化变量在许多情况下显然非常有用,特别是在处理使用输出参数的遗留 API 时。

使用ifswitch语句的初始化器来保持作用域紧凑。这样可以使您的代码更紧凑,更易于阅读,并且在代码重构会话中,移动代码会更容易。

从新的大括号初始化规则中获益

C11 带来了新的大括号初始化语法{}。它的目的是允许聚合初始化,但也允许通常的构造函数调用。不幸的是,当将这个语法与auto变量类型结合使用时,很容易表达错误的事情。C17 带来了增强的初始化规则。在本教程中,我们将阐明如何在 C++17 中使用哪种语法正确初始化变量。

如何做...

变量在一步中初始化。使用初始化语法,有两种不同的情况:

  • 在不带有auto类型推断的大括号初始化语法中:
       // Three identical ways to initialize an int:
       int x1 = 1;
       int x2  {1};
       int x3  (1);

       std::vector<int> v1   {1, 2, 3}; // Vector with three ints: 1, 2, 3
       std::vector<int> v2 = {1, 2, 3}; // same here
       std::vector<int> v3   (10, 20);  // Vector with 10 ints, 
                                        // each have value 20
  • 使用带有auto类型推断的大括号初始化语法:
       auto v   {1};         // v is int
       auto w   {1, 2};      // error: only single elements in direct 
                             // auto initialization allowed! (this is new)
       auto x = {1};         // x is std::initializer_list<int>
       auto y = {1, 2};      // y is std::initializer_list<int>
       auto z = {1, 2, 3.0}; // error: Cannot deduce element type

工作原理...

没有auto类型推断时,在使用大括号{}操作符初始化常规类型时,不会有太多令人惊讶的地方。当初始化容器如std::vectorstd::list等时,大括号初始化将匹配该容器类的std::initializer_list构造函数。它以贪婪的方式进行匹配,这意味着不可能匹配非聚合构造函数(非聚合构造函数是通常的构造函数,与接受初始化列表的构造函数相对)。

std::vector,例如,提供了一个特定的非聚合构造函数,它可以用相同的值填充任意多个项目:std::vector<int> v (N, value)。当写成std::vector<int> v {N, value}时,将选择initializer_list构造函数,它将用两个项目Nvalue初始化向量。这是一个特殊的陷阱,人们应该知道。

与使用普通的()括号调用构造函数相比,{}操作符的一个好处是它们不进行隐式类型转换:int x (1.2);int x = 1.2; 会将x初始化为值1,通过将浮点值四舍五入并将其转换为 int。相比之下,int x {1.2}; 不会编译,因为它要完全匹配构造函数类型。

人们可以就哪种初始化样式是最好的进行有争议的讨论。

支持大括号初始化样式的人说,使用大括号使得变量被构造函数调用初始化非常明确,并且这行代码不会重新初始化任何东西。此外,使用{}大括号将选择唯一匹配的构造函数,而使用()括号的初始化行则尝试匹配最接近的构造函数,甚至进行类型转换以进行匹配。

C17 引入的附加规则影响了使用auto类型推断的初始化--虽然 C11 会正确地将变量auto x {123};的类型推断为只有一个元素的std::initializer_list<int>,但这很少是我们想要的。C++17 会将相同的变量推断为int

经验法则:

  • auto var_name {one_element}; 推断var_nameone_element的类型相同

  • auto var_name {element1, element2, ...}; 是无效的,无法编译

  • auto var_name = {element1, element2, ...}; 推断为一个std::initializer_list<T>,其中T与列表中所有元素的类型相同

C++17 使得意外定义初始化列表变得更加困难。

在 C11/C14 模式下尝试使用不同的编译器将会显示一些编译器实际上将auto x {123};推断为int,而其他编译器将其推断为std::initializer_list<int>。编写这样的代码可能会导致可移植性问题!

让构造函数自动推断出结果模板类的类型

C中的许多类通常是专门针对类型进行特化的,这些类型可以很容易地从用户在构造函数调用中放入的变量类型中推断出来。然而,在 C17 之前,这不是一个标准化的特性。C++17 允许编译器从构造函数调用中自动推断模板类型。

如何做...

这种情况的一个非常方便的用例是构造std::pairstd::tuple实例。这些可以在一步中进行专门化和实例化:

std::pair  my_pair  (123, "abc");       // std::pair<int, const char*>
std::tuple my_tuple (123, 12.3, "abc"); // std::tuple<int, double,
                                        //            const char*>

它是如何工作的...

让我们定义一个示例类,其中自动模板类型推断将会有价值:

template <typename T1, typename T2, typename T3>
class my_wrapper {
    T1 t1;
    T2 t2;
    T3 t3;

public:
    explicit my_wrapper(T1 t1_, T2 t2_, T3 t3_) 
        : t1{t1_}, t2{t2_}, t3{t3_}
    {}

    /* … */
};

好吧,这只是另一个模板类。以前我们必须这样写才能实例化它:

my_wrapper<int, double, const char *> wrapper {123, 1.23, "abc"};

现在我们可以省略模板专门化部分:

my_wrapper wrapper {123, 1.23, "abc"};

在 C++17 之前,只能通过实现make 函数助手来实现这一点:

my_wrapper<T1, T2, T3> make_wrapper(T1 t1, T2 t2, T3 t3)
{
    return {t1, t2, t3};
}

使用这样的辅助函数,可以实现类似的效果:

auto wrapper (make_wrapper(123, 1.23, "abc"));

STL 已经提供了许多类似的辅助函数,如std::make_sharedstd::make_uniquestd::make_tuple等。在 C++17 中,这些现在大多可以被视为过时。当然,它们将继续提供以确保兼容性。

还有更多...

我们刚刚学到的是隐式模板类型推断。在某些情况下,我们不能依赖隐式类型推断。考虑以下示例类:

template <typename T>
struct sum {
    T value;

    template <typename ... Ts>
    sum(Ts&& ... values) : value{(values + ...)} {}
};

这个结构sum接受任意数量的参数,并使用折叠表达式将它们相加(稍后在本章中查看折叠表达式示例,以获取有关折叠表达式的更多详细信息)。结果的和保存在成员变量value中。现在的问题是,T是什么类型?如果我们不想明确指定它,它肯定需要依赖于构造函数中提供的值的类型。如果我们提供字符串实例,它需要是std::string。如果我们提供整数,它需要是int。如果我们提供整数、浮点数和双精度浮点数,编译器需要找出哪种类型适合所有值而不会丢失信息。为了实现这一点,我们提供了一个显式推导指南

template <typename ... Ts>
sum(Ts&& ... ts) -> sum<std::common_type_t<Ts...>>;

这个推导指南告诉编译器使用std::common_type_t特性,它能够找出适合所有值的公共类型。让我们看看如何使用它:

sum s          {1u, 2.0, 3, 4.0f};
sum string_sum {std::string{"abc"}, "def"};

std::cout << s.value          << 'n'
          << string_sum.value << 'n';

在第一行中,我们使用unsigneddoubleintfloat类型的构造函数参数实例化了一个sum对象。std::common_type_t返回double作为公共类型,所以我们得到一个sum<double>实例。在第二行中,我们提供了一个std::string实例和一个 C 风格的字符串。根据我们的推导指南,编译器构造了一个sum<std::string>类型的实例。

运行此代码时,它将打印数字和字符串的和。

使用 constexpr-if 简化编译时决策

在模板化的代码中,通常需要根据模板专门化的类型来做一些不同的事情。C++17 带来了 constexpr-if 表达式,它大大简化了这种情况下的代码。

如何做...

在这个示例中,我们将实现一个小的辅助模板类。它可以处理不同的模板类型专门化,因为它能够根据我们为其专门化的类型在某些段落中选择完全不同的代码:

  1. 编写通用部分的代码。在我们的例子中,这是一个简单的类,支持使用add函数将类型U的值添加到类型T的成员值中:
       template <typename T>
       class addable
       { 
           T val;

       public:
           addable(T v) : val{v} {}

           template <typename U>
           T add(U x) const {
               return val + x;
           }
       };
  1. 假设类型Tstd::vector<something>,类型U只是int。将整数添加到整个向量意味着什么?我们说这意味着我们将整数添加到向量中的每个项目。这将在循环中完成:
       template <typename U>
       T add(U x) 
       {
           auto copy (val); // Get a copy of the vector member
           for (auto &n : copy) { 
               n += x;
           }
           return copy;
       }
  1. 下一步,也是最后一步是结合两个世界。如果TU项的向量,则执行循环变体。如果不是,则只需实现正常的加法:
       template <typename U>
       T add(U x) const {
           if constexpr (std::is_same_v<T, std::vector<U>>) {
               auto copy (val);
               for (auto &n : copy) { 
                   n += x;
               }
               return copy;
           } else {
               return val + x;
           }
       }

  1. 现在可以使用该类。让我们看看它如何与完全不同的类型一起工作,例如intfloatstd::vector<int>std::vector<string>
       addable<int>{1}.add(2);               // is 3
       addable<float>{1.0}.add(2);           // is 3.0
       addable<std::string>{"aa"}.add("bb"); // is "aabb"

       std::vector<int> v {1, 2, 3};
       addable<std::vector<int>>{v}.add(10); 
           // is std::vector<int>{11, 12, 13}

       std::vector<std::string> sv {"a", "b", "c"};
       addable<std::vector<std::string>>{sv}.add(std::string{"z"}); 
           // is {"az", "bz", "cz"}

它是如何工作的...

新的 constexpr-if 的工作方式与通常的 if-else 结构完全相同。不同之处在于它测试的条件必须在编译时进行评估。编译器从我们的程序创建的所有运行时代码都不包含来自 constexpr-if 条件语句的任何分支指令。也可以说它的工作方式类似于预处理器#if#else文本替换宏,但对于这些宏,代码甚至不需要在语法上是良好形式的。constexpr-if 结构的所有分支都需要语法上良好形式,但采取的分支不需要语义上有效

为了区分代码是否应该将值x添加到向量中,我们使用类型特征std::is_same。表达式std::is_same<A, B>::valueAB是相同类型时求值为布尔值true。我们的条件是std::is_same<T, std::vector<U>>::value,如果用户将类专门化为T = std::vector<X>并尝试使用类型U = X的参数调用add,则求值为true

当然,constexpr-if-else 块中可以有多个条件(注意ab必须依赖于模板参数,而不仅仅是编译时常量):

if constexpr (a) {
    // do something
} else if constexpr (b) {
    // do something else 
} else {
    // do something completely different
}

使用 C++17,许多元编程情况更容易表达和阅读。

还有更多...

为了说明 constexpr-if 结构对 C的改进有多大,我们可以看看在 C17之前如何实现相同的事情:

template <typename T>
class addable
{
    T val;

public:
    addable(T v) : val{v} {}

    template <typename U>
 std::enable_if_t<!std::is_same<T, std::vector<U>>::value, T>
    add(U x) const { return val + x; }

    template <typename U>
 std::enable_if_t<std::is_same<T, std::vector<U>>::value, 
                     std::vector<U>>
    add(U x) const {
        auto copy (val);
        for (auto &n : copy) { 
            n += x;
        }
        return copy;
    }
};

在不使用 constexpr-if 的情况下,这个类适用于我们希望的所有不同类型,但看起来非常复杂。它是如何工作的?

两个不同add函数的实现看起来很简单。它们的返回类型声明使它们看起来复杂,并且包含一个技巧--例如std::enable_if_t<condition, type>表达式在conditiontrue时评估为type。否则,std::enable_if_t表达式不会评估为任何东西。这通常被认为是一个错误,但我们将看到为什么它不是。

对于第二个add函数,相同的条件以反转的方式使用。这样,它只能同时对两个实现中的一个为true

当编译器看到具有相同名称的不同模板函数并且必须选择其中一个时,一个重要的原则就会发挥作用:SFINAE,它代表替换失败不是错误。在这种情况下,这意味着如果其中一个函数的返回值无法从错误的模板表达式中推导出(如果其条件评估为false,则std::enable_if是错误的),则编译器不会报错。它将简单地继续寻找并尝试其他函数实现。这就是诀窍;这就是它是如何工作的。

真是麻烦。很高兴看到这在 C++17 中变得如此容易。

使用内联变量启用仅头文件库

虽然在 C中一直可以声明单独的函数内联,但 C17 还允许我们声明变量内联。这使得实现仅头文件库变得更加容易,这在以前只能使用变通方法实现。

它是如何实现的...

在这个示例中,我们创建了一个示例类,它可以作为典型的仅头文件库的成员。目标是使用inline关键字以静态成员的方式实例化它,并以全局可用的方式使用它,这在 C++17 之前是不可能的。

  1. process_monitor类应该同时包含一个静态成员并且本身应该是全局可访问的,这将在从多个翻译单元包含时产生双重定义的符号:
       // foo_lib.hpp 

       class process_monitor { 
       public: 
           static const std::string standard_string 
               {"some static globally available string"}; 
       };

       process_monitor global_process_monitor;
  1. 如果我们现在在多个.cpp文件中包含这个以便编译和链接它们,这将在链接阶段失败。为了解决这个问题,我们添加inline关键字:
       // foo_lib.hpp 

       class process_monitor { 
       public: 
           static const inline std::string standard_string 
               {"some static globally available string"}; 
       };

       inline process_monitor global_process_monitor;

看,就是这样!

它是如何工作的...

C程序通常由多个 C源文件组成(这些文件具有.cpp.cc后缀)。这些文件被单独编译为模块/对象文件(通常具有.o 后缀)。然后将所有模块/对象文件链接在一起成为单个可执行文件或共享/静态库是最后一步。

在链接阶段,如果链接器可以找到一个特定符号的定义多次,则被视为错误。例如,我们有一个带有int foo();签名的函数。如果两个模块定义了相同的函数,那么哪一个是正确的?链接器不能随意选择。嗯,它可以,但这很可能不是任何程序员想要发生的事情。

提供全局可用函数的传统方法是在头文件中声明它们,这些头文件将被任何需要调用它们的 C++模块包含。然后,这些函数的定义将被放入单独的模块文件中一次。然后,这些模块与希望使用这些函数的模块一起链接在一起。这也被称为一次定义规则ODR)。查看以下插图以更好地理解:

然而,如果这是唯一的方法,那么就不可能提供仅包含头文件的库。仅包含头文件的库非常方便,因为它们只需要使用#include包含到任何 C++程序文件中,然后立即可用。为了使用不是仅包含头文件的库,程序员还必须调整构建脚本,以便链接器将库模块与自己的模块文件一起链接。特别是对于只有非常短函数的库,这是不必要的不舒服。

对于这种情况,inline关键字可以用来做一个例外,以允许在不同模块中多次定义相同的符号。如果链接器找到具有相同签名的多个符号,但它们被声明为内联,它将只选择第一个并相信其他符号具有相同的定义。所有相等的内联符号都完全相等的定义基本上是程序员的承诺

关于我们的 reciple 示例,链接器将在每个包含foo_lib.hpp的模块中找到process_monitor::standard_string符号。没有inline关键字,它将不知道选择哪一个,因此它将中止并报告错误。对global_process_monitor符号也是一样。哪一个才是正确的?

在声明两个符号inline后,它将只接受每个符号的第一次出现,并丢弃所有其他出现。

在 C17 之前,唯一的干净方法是通过额外的 C模块文件提供此符号,这将迫使我们的库用户在链接步骤中包含此文件。

inline关键字传统上还有另一个功能。它告诉编译器可以通过获取其实现并直接将其放在调用它的地方来消除函数调用。这样,调用代码包含一个函数调用少,这通常被认为更快。如果函数非常短,生成的汇编代码也会更短(假设执行函数调用的指令数量,保存和恢复堆栈等比实际有效载荷代码更高)。如果内联函数非常长,二进制大小将增长,这有时甚至可能不会导致最终更快的代码。

因此,编译器只会将inline关键字作为提示,并可能通过内联来消除函数调用。但它也可以内联一些函数,而不需要程序员声明为内联。

还有更多...

在 C++17 之前的一个可能的解决方法是提供一个static函数,它返回一个static对象的引用:

class foo {
public:
    static std::string& standard_string() {
        static std::string s {"some standard string"};
        return s;
    }
};

这样,将头文件包含在多个模块中是完全合法的,但仍然可以在任何地方访问到完全相同的实例。然而,对象并不是在程序开始时立即构造的,而是只有在第一次调用此 getter 函数时才会构造。对于某些用例,这确实是一个问题。想象一下,我们希望静态的全局可用对象的构造函数在程序开始时做一些重要的事情(就像我们的 reciple 示例库类),但由于 getter 在程序结束时被调用,这就太晚了。

另一个解决方法是将非模板类foo变为模板类,这样它就可以从与模板相同的规则中获益。

这两种策略在 C++17 中都可以避免。

使用折叠表达式实现方便的辅助函数

自 C11 以来,有可变模板参数包,它们使得实现接受任意多个参数的函数成为可能。有时,这些参数都被合并成一个表达式,以便从中导出函数结果。这在 C17 中变得非常容易,因为它带有折叠表达式。

如何做...

让我们实现一个函数,它接受任意多个参数并返回它们的总和:

  1. 首先,我们定义它的签名:
      template <typename ... Ts>
      auto sum(Ts ... ts);
  1. 所以,现在我们有一个参数包ts,函数应该展开所有参数并使用折叠表达式将它们相加。如果我们使用任何操作符(在这个例子中是+)与...一起,以便将其应用于参数包的所有值,我们需要用括号括起表达式:
      template <typename ... Ts>
      auto sum(Ts ... ts)
      {
          return (ts + ...);
      }
  1. 我们现在可以这样调用它:
      int the_sum {sum(1, 2, 3, 4, 5)}; // Value: 15
  1. 它不仅适用于int类型;我们可以用任何实现了+运算符的类型来调用它,比如std::string
      std::string a {"Hello "};
      std::string b {"World"};

      std::cout << sum(a, b) << 'n'; // Output: Hello World

它是如何工作的...

我们刚刚做的是对其参数进行简单的递归应用二元运算符(+)。这通常被称为折叠。C++17 带有折叠表达式,它可以用更少的代码表达相同的想法。

这种类型的表达式称为一元折叠。C++17 支持使用以下二元操作符对参数包进行折叠:+-*/%^&|=<><<>>+=-=*=/=%=^=&=|=<<=>>===!=<=>=&&||,.*->*

顺便说一句,在我们的示例代码中,如果我们写(ts + ...)(… + ts)都没有关系;两者都可以。然而,在其他情况下可能会有所不同--如果点在操作符的右侧,则折叠称为折叠。如果它们在左侧,则是折叠。

在我们的sum示例中,一元左折叠展开为1 + (2 + (3 + (4 + 5))),而一元右折叠将展开为(((1 + 2) + 3) + 4) + 5。根据使用的操作符,这可能会有所不同。当添加数字时,它并不会有所不同。

还有更多...

如果有人用没有参数调用sum(),则变参参数包不包含可以折叠的值。对于大多数操作符来说,这是一个错误(对于一些操作符来说不是;我们将在一分钟内看到)。然后我们需要决定这是否应该保持为错误,或者空的总和是否应该导致特定的值。显而易见的想法是,什么都没有的总和是0

这就是它的实现方式:

template <typename ... Ts>
auto sum(Ts ... ts)
{
    return (ts + ... + 0);
}

这样,sum()的结果是0sum(1, 2, 3)的结果是(1 + (2 + (3 + 0)))。这种带有初始值的折叠称为二进制折叠

同样,如果我们写(ts + ... + 0)(0 + ... + ts),它也可以工作,但这会使二进制折叠再次成为二进制折叠或二进制折叠。看看下面的图表:

当使用二进制折叠来实现无参数情况时,单位元素的概念通常很重要--在这种情况下,将0添加到任何数字都不会改变任何东西,这使0成为单位元素。由于这个属性,我们可以使用+-运算符将0添加到任何折叠表达式中,这将导致在参数包中没有参数的情况下结果为0。从数学的角度来看,这是正确的。从实现的角度来看,我们需要根据需要定义什么是正确的。

相同的原则适用于乘法。在这里,单位元素是1

template <typename ... Ts>
auto product(Ts ... ts)
{
    return (ts * ... * 1);
}

product(2, 3)的结果是6,没有参数的product()的结果是1

逻辑(&&)和(||)操作符带有内置单位元素。使用&&对空参数包进行折叠的结果是true,使用||对空参数包进行折叠的结果是false

另一个操作符,当应用于空参数包时默认为某个表达式的逗号操作符(,),然后默认为void()

为了激发一些灵感,让我们看看我们可以使用这个特性实现的一些更多的小助手。

匹配范围与单个项目

如何编写一个函数,告诉我们某个范围是否包含我们提供的变参参数中的至少一个值:

template <typename R, typename ... Ts>
auto matches(const R& range, Ts ... ts)
{
    return (std::count(std::begin(range), std::end(range), ts) + ...);
}

帮助函数使用 STL 中的std::count函数。该函数接受三个参数:前两个参数是某个可迭代范围的beginend迭代器,作为第三个参数,它接受一个value,该值将与范围内的所有项目进行比较。然后,std::count方法返回范围内等于第三个参数的所有元素的数量。

在我们的折叠表达式中,我们总是将相同参数范围的beginend迭代器传递给std::count函数。然而,作为第三个参数,每次我们都将参数包中的另一个参数放入其中。最后,函数将所有结果相加并将其返回给调用者。

我们可以这样使用它:

std::vector<int> v {1, 2, 3, 4, 5};

matches(v,         2, 5);          // returns 2
matches(v,         100, 200);      // returns 0
matches("abcdefg", 'x', 'y', 'z'); // returns 0
matches("abcdefg", 'a', 'd', 'f'); // returns 3

正如我们所看到的,matches帮助函数非常灵活--它可以直接在向量或字符串上调用。它还可以在初始化列表、std::liststd::arraystd::set等实例上工作!

检查多次插入集合是否成功

让我们编写一个帮助函数,将任意数量的可变参数插入到std::set中,并返回所有插入是否成功

template <typename T, typename ... Ts>
bool insert_all(T &set, Ts ... ts)
{
    return (set.insert(ts).second && ...);
}

那么,这是如何工作的呢?std::setinsert函数具有以下签名:

std::pair<iterator, bool> insert(const value_type& value);

文档表示,当我们尝试插入一个项目时,insert函数将返回一个对中的iteratorbool变量。如果插入成功,bool值为true。如果成功,迭代器指向集合中的新元素。否则,迭代器指向现有项目,它将与要插入的项目冲突

我们的帮助函数在插入后访问.second字段,这只是反映成功或失败的bool变量。如果所有插入在所有返回对中都导致true,那么所有插入都成功了。折叠表达式使用&&运算符将所有插入结果组合在一起并返回结果。

我们可以这样使用它:

std::set<int> my_set {1, 2, 3};

insert_all(my_set, 4, 5, 6); // Returns true
insert_all(my_set, 7, 8, 2); // Returns false, because the 2 collides

请注意,如果我们尝试插入三个元素,但第二个元素已经无法插入,&& ...折叠将会短路并停止插入所有其他元素:

std::set<int> my_set {1, 2, 3};

insert_all(my_set, 4, 2, 5); // Returns false
// set contains {1, 2, 3, 4} now, without the 5!

检查所有参数是否在某个范围内

如果我们可以检查一个变量是否在某个特定范围内,我们也可以使用折叠表达式来对多个变量执行相同的操作。

template <typename T, typename ... Ts>
bool within(T min, T max, Ts ...ts)
{
    return ((min <= ts && ts <= max) && ...);
}

表达式(min <= ts && ts <= max)确实告诉了参数包的每个值是否在minmax之间(包括minmax)。我们选择&&运算符将所有布尔结果减少为单个结果,只有当所有个别结果都为true时才为true

这就是它的实际效果:

within( 10,  20,  1, 15, 30);    // --> false
within( 10,  20,  11, 12, 13);   // --> true
within(5.0, 5.5,  5.1, 5.2, 5.3) // --> true

有趣的是,这个函数非常灵活,因为它对我们使用的类型的唯一要求是它们可以使用<=运算符进行比较。例如,std::string也满足这个要求:

std::string aaa {"aaa"};
std::string bcd {"bcd"};
std::string def {"def"};
std::string zzz {"zzz"};

within(aaa, zzz,  bcd, def); // --> true
within(aaa, def,  bcd, zzz); // --> false

将多个项目推入向量

还可以编写一个不减少任何结果但处理相同类型的多个操作的帮助函数。比如将项目插入到std::vector中,它不返回任何结果(std::vector::insert()通过抛出异常来表示错误):

template <typename T, typename ... Ts>
void insert_all(std::vector<T> &vec, Ts ... ts)
{
    (vec.push_back(ts), ...);
}

int main()
{
    std::vector<int> v {1, 2, 3};
    insert_all(v, 4, 5, 6);
}

请注意,我们使用逗号(,)运算符来将参数包展开为单独的vec.push_back(...)调用,而不是折叠实际结果。这个函数也很好地处理了参数包,因为逗号运算符具有隐式的单位元素void(),它转换为什么也不做

第二十二章:STL 容器

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

  • std::vector上使用擦除-删除习惯用法

  • 在*O(1)*时间内从未排序的std::vector中删除项目

  • 以快速或安全的方式访问std::vector实例

  • 保持std::vector实例排序

  • 有效地和有条件地将项目插入std::map

  • 了解std::map::insert的新插入提示语义

  • 有效地修改std::map项的键

  • 使用std::unordered_map与自定义类型

  • 使用std::set从用户输入中过滤重复项并按字母顺序打印它们

  • 使用std::stack实现简单的逆波兰计算器

  • 使用std::map实现词频计数器

  • 使用std::set实现用于在文本中查找非常长的句子的写作风格辅助工具

  • 使用std::priority_queue实现个人待办事项列表

std::vector上使用擦除-删除习惯用法

许多初学者 C++程序员了解std::vector,它基本上就像一个自动增长的数组,然后就停在那里。后来,他们只查找它的文档,以了解如何做非常具体的事情,例如删除项目。像这样使用 STL 容器只会触及它们帮助编写清晰可维护快速代码的表面。

本节重点是从向量实例中间删除项目。当一个项目从向量中消失,并且坐在其他项目的中间之间时,那么右边的所有项目都必须向移动一个插槽(这使得这个任务的运行成本在O(n)内)。许多初学者程序员会使用循环来做到这一点,因为这也不是一件很难做的事情。不幸的是,他们在这样做的过程中可能会忽略很多优化潜力。最后,手工制作的循环既不如 STL 方式,也不如美观,我们将在下面看到。

如何做...

在本节中,我们正在用一些示例整数填充std::vector实例,然后从中删除一些特定的项目。我们正在做的方式被认为是从向量中删除多个项目的正确方式。

  1. 当然,在我们做任何事情之前,我们需要包括一些头文件。
      #include <iostream>
      #include <vector>
      #include <algorithm>
  1. 然后我们声明我们正在使用std命名空间,以节省一些输入。
      using namespace std;
  1. 现在我们创建一个整数向量,并用一些示例项目填充它。
      int main()
      {
          vector<int> v {1, 2, 3, 2, 5, 2, 6, 2, 4, 8};
  1. 下一步是删除项目。我们要删除什么?有多个2值。让我们把它们删除。
          const auto new_end (remove(begin(v), end(v), 2));
  1. 有趣的是,这只是两步中的一步。向量仍然具有相同的大小。下一行使它实际上更短。
          v.erase(new_end, end(v));
  1. 让我们在这里停下来,以便将向量的内容打印到终端,然后继续。
          for (auto i : v) {
              cout << i << ", ";
          }
          cout << 'n';
  1. 现在,让我们删除整个的项目,而不是特定的。为了做到这一点,我们首先定义一个谓词函数,它接受一个数字作为参数,并在它是奇数时返回true
          const auto odd ([](int i) { return i % 2 != 0; });
  1. 现在我们使用remove_if函数,并将其与谓词函数一起使用。与之前的两步删除不同,我们现在只需一步。
          v.erase(remove_if(begin(v), end(v), odd), end(v));
  1. 现在所有奇数项都消失了,但向量的容量仍然是旧的 10 个元素。在最后一步中,我们还将其减少到向量的实际当前大小。请注意,这可能导致向量代码分配一个适合的新内存块,并将所有项目从旧内存块移动到新内存块。
          v.shrink_to_fit();
  1. 现在,让我们在第二次删除项目后打印内容,就这样。
          for (auto i : v) {
              cout << i << ", ";
          }
          cout << 'n';
      }
  1. 编译和运行程序产生了两种删除项目方法的以下两行输出。
      $ ./main 
      1, 3, 5, 6, 4, 8, 
      6, 4, 8,

它是如何工作的...

在配方中显而易见的是,当从向量中间删除项目时,它们首先需要被删除,然后擦除。至少我们使用的函数有这样的名称。这显然令人困惑,但让我们仔细看看它,以理解这些步骤。

从向量中删除所有值为2的代码如下:

const auto new_end (remove(begin(v), end(v), 2));
v.erase(new_end, end(v));

std::beginstd::end函数都接受一个向量实例作为参数,并返回指向第一个项目和最后一个项目之后的迭代器,就像即将出现的图表中所示的那样。

在将这些值和值2传递给std::remove函数后,它将将非2值向前移动,就像我们可以使用手动编程的循环来做的那样。该算法将严格保留所有非2值的顺序。快速查看插图可能有点令人困惑。在第 2 步中,仍然有一个值为2,而且向量应该变得更短,因为有四个值为2,它们都应该被移除。相反,初始数组中的48被复制了。这是怎么回事?

让我们只看看所有在范围内的项目,从插图上的begin迭代器到new_end迭代器。new_end迭代器指向的项目是范围之外的第一个项目,因此不包括在内。只集中在这个区域(这些只是从1到包括8的项目),我们意识到是从中删除所有2值的正确*范围。

这就是erase调用发挥作用的地方:我们必须告诉向量,它不再应该认为从new_endend的所有项目是向量的项目。这个顺序对于向量来说很容易遵循,因为它只需将其end迭代器指向new_end的位置,就完成了。请注意,new_endstd::remove调用的返回值,所以我们可以直接使用它。

请注意,向量所做的不仅仅是移动内部指针。如果该向量是更复杂对象的向量,它将调用所有要删除的项目的析构函数。

之后,向量看起来像图表中的第 3 步:它现在被认为是更小的。现在超出范围的旧项目仍然在内存中。

为了使向量只占用所需的内存,我们在最后进行shrink_to_fit调用。在该调用期间,它将分配所需的内存,移动所有项目并删除我们不再需要的较大块。

在第 8 步中,我们定义了一个谓词函数,并在一步中使用它与std::remove_if一起使用。这是有效的,因为无论删除函数返回什么迭代器,都可以安全地在向量的 erase 函数中使用。即使没有找到奇数项std::remove_if函数也将什么也不做,并返回end迭代器。然后,像v.erase(end, end);这样的调用也不会做任何事情,因此它是无害的。

还有更多...

std::remove函数也适用于其他容器。当与std::array一起使用时,请注意它不支持调用erase的第二步,因为它们没有自动大小处理。仅仅因为std::remove有效地只是移动项目而不执行它们的实际删除,它也可以用于不支持调整大小的数据结构,例如数组。在数组的情况下,可以使用类似于字符串的哨兵值(例如'')覆盖新的结束迭代器之后的值。

在 O(1)时间内从未排序的 std::vector 中删除项目

std::vector中间某处删除项目需要*O(n)*时间。这是因为删除项目后产生的间隙必须由将在间隙后面的所有项目向左移动一个插槽来填充。

在像这样移动项目的过程中,如果它们是复杂的和/或非常大的,并包括许多项目,这可能是昂贵的,我们保留它们的顺序。如果保留顺序不重要,我们可以优化这一点,正如本节所示。

如何做...

在本节中,我们将使用一些示例数字填充一个std::vector实例,并实现一个快速删除函数,它可以在*O(1)*时间内从向量中删除任何项目。

  1. 首先,我们需要包含所需的头文件。
      #include <iostream>
      #include <vector>
      #include <algorithm>
  1. 然后,我们定义一个主函数,在其中实例化一个包含示例数字的向量。
      int main()
      {
          std::vector<int> v {123, 456, 789, 100, 200};
  1. 下一步是删除索引为2的值(当然是从零开始计数,所以是第三个数字789)。我们将使用的函数还没有实现。我们稍后再做。之后,我们打印向量的内容。
          quick_remove_at(v, 2);

          for (int i : v) {
              std::cout << i << ", ";
          }                                           
          std::cout << 'n';
  1. 现在,我们将删除另一个项目。它将是值为123,假设我们不知道它的索引。因此,我们将使用std::find函数,它接受一个范围(向量)和一个值,然后搜索该值的位置。然后,它会返回一个指向123值的迭代器。我们将使用相同的quick_remove_at函数,但这是先前接受迭代器重载版本。它也还没有实现。
          quick_remove_at(v, std::find(std::begin(v), std::end(v), 123));

          for (int i : v) {
              std::cout << i << ", ";
          }
          std::cout << 'n';
      }
  1. 除了两个quick_remove_at函数,我们已经完成了。所以让我们来实现这些。(请注意,它们应该至少在主函数之前被声明。所以让我们在那里定义它们。)

这两个函数都接受一个something(在我们的例子中是int值)的向量的引用,所以我们不确定用户会使用什么样的向量。对我们来说,它是一个T值的向量。我们使用的第一个quick_remove_at函数接受索引值,这些值是数字,所以接口看起来像下面这样:

      template <typename T>
      void quick_remove_at(std::vector<T> &v, std::size_t idx)
      {
  1. 现在来到食谱的核心部分——我们如何快速删除项目而不移动太多其他项目?首先,我们简单地取出向量中最后一个项目的值,并用它来覆盖将要删除的项目。其次,我们切断向量的最后一个项目。这就是两个步骤。我们在这段代码周围加上了一些健全性检查。如果索引值显然超出了向量范围,我们就什么也不做。否则,例如在空向量上,代码会崩溃。
          if (idx < v.size()) {
              v[idx] = std::move(v.back());
              v.pop_back();
          }
      }
  1. quick_remove_at的另一个实现方式类似。它不是接受一个数字索引,而是接受std::vector<T>的迭代器。以通用方式获取其类型并不复杂,因为 STL 容器已经定义了这样的类型。
      template <typename T>
      void quick_remove_at(std::vector<T> &v, 
                           typename std::vector<T>::iterator it)
      {

  1. 现在,我们将访问迭代器指向的值。就像在另一个函数中一样,我们将用向量中的最后一个元素来覆盖它。因为这次我们处理的不是数字索引,而是迭代器,所以我们需要以稍有不同的方式检查迭代器的位置是否合理。如果它指向人为结束的位置,我们就不能对其进行解引用。
          if (it != std::end(v)) {
  1. 在那个 if 块中,我们做的事情和之前一样——我们用最后一个位置的项目的值来覆盖要删除的项目,然后我们从向量中切断最后一个元素:
              *it = std::move(v.back());
              v.pop_back();
          }
      }
  1. 就是这样。编译和运行程序会产生以下输出:
      $ ./main 
      123, 456, 200, 100,                           
      100, 456, 200,

它是如何工作的...

quick_remove_at函数可以快速删除项目,而不会触及太多其他项目。它以相对创造性的方式做到这一点:它在某种程度上交换实际项目,即将被删除的项目和向量中的最后一个项目。尽管最后一个项目与实际选择的项目没有关联,但它处于特殊位置:删除最后一个项目是便宜的!向量的大小只需要减少一个位置,就完成了。在这一步中没有移动任何项目。看一下下面的图表,它有助于想象这是如何发生的:

食谱代码中的两个步骤看起来像这样:

v.at(idx) = std::move(v.back());
v.pop_back();

这是迭代器版本,看起来几乎一样:

*it = std::move(v.back());
v.pop_back();

逻辑上,我们交换所选项目和最后一个项目。但代码并不交换项目,而是将最后一个项目移动到第一个项目上。为什么?如果我们交换项目,那么我们将不得不将所选项目存储在一个临时变量中,将最后一个项目移动到所选项目上,然后再将临时值存储在最后一个位置上。这似乎是无用的,因为我们正要删除最后一个项目。

好的,交换是没有用的,一次性覆盖是更好的选择。看到这一点,我们可以说这一步也可以用简单的*it = v.back();来完成,对吗?是的,这完全是正确的,但是想象一下,我们在每个槽中存储了一些非常大的字符串,甚至是另一个向量或映射--在这种情况下,这个小赋值将导致非常昂贵的复制。中间的std::move调用只是一个优化:字符串的示例情况下,字符串项内部指向中的一个大字符串。我们不需要复制它。相反,当移动一个字符串时,移动的目标指向另一个字符串的数据。移动源项目保持不变,但处于无用状态,这没关系,因为我们无论如何都要删除它。

以快速或安全的方式访问 std::vector 实例

std::vector 可能是 STL 中使用最广泛的容器,因为它像数组一样保存数据,并在该表示周围添加了很多便利。然而,对向量的错误访问仍然可能是危险的。如果一个向量包含 100 个元素,并且我们的代码意外地尝试访问索引 123 处的元素,这显然是不好的。这样的程序可能会崩溃,这可能是最好的情况,因为这种行为会非常明显地表明存在错误!如果它没有崩溃,我们可能会观察到程序偶尔表现得奇怪,这可能会比崩溃的程序带来更多的头痛。有经验的程序员可能会在任何直接索引的向量访问之前添加一些检查。这些检查不会增加代码的可读性,而且很多人不知道std::vector已经内置了边界检查!

如何做...

在本节中,我们将使用两种不同的方式来访问std::vector,然后看看如何利用它们来编写更安全的程序而不降低可读性。

  1. 让我们包括所有需要的头文件,并用123的值填充一个示例向量1000次,这样我们就有了可以访问的东西:
      #include <iostream>
      #include <vector>

      using namespace std;

      int main()
      {
          const size_t container_size {1000};
          vector<int> v (container_size, 123);
  1. 现在,我们使用[]运算符越界访问向量:
         cout << "Out of range element value: " 
              << v[container_size + 10] << 'n';
  1. 接下来,我们使用at函数越界访问它:
          cout << "Out of range element value: " 
               << v.at(container_size + 10) << 'n';
      }
  1. 让我们运行程序看看会发生什么。错误消息是特定于 GCC 的。其他编译器会发出不同但类似的错误消息。第一次读取以一种奇怪的方式成功了。它没有导致程序崩溃,但它是一个完全不同的,而不是123。我们看不到其他访问的输出行,因为它故意崩溃了整个程序。如果那个越界访问是一个意外,我们会更早地捕捉到它!
      Out of range element value: -726629391
      terminate called after throwing an instance of 'std::out_of_range'
        what():  array::at: __n (which is 1010) >= _Nm (which is 1000)
      Aborted (core dumped)

它是如何工作的...

std::vector提供了[]运算符和at函数,它们基本上做的工作是一样的。然而,at函数执行额外的边界检查,并且如果超出向量边界,则抛出异常。这在我们这种情况下非常有用,但也会使程序变得稍微一些。

特别是在进行需要非常快速的索引成员的数值计算时,最好坚持使用[]索引访问。在任何其他情况下,at函数有助于发现通常可以忽略的性能损失的错误。

默认情况下使用at函数是一个好习惯。如果生成的代码太慢但已经被证明没有错误,那么在性能敏感的部分可以使用[]运算符。

还有更多...

当然,我们可以处理越界访问,而不是让整个应用程序崩溃。为了处理它,我们捕获异常,以防它被at函数抛出。捕获这样的异常很简单。我们只需用try块包围at调用,并在catch块中定义错误处理。

try {
    std::cout << "Out of range element value: " 
              << v.at(container_size + 10) << 'n';
} catch (const std::out_of_range &e) {
     std::cout << "Ooops, out of range access detected: " 
               << e.what() << 'n';
}

顺便说一下,std::array也提供了at函数。

保持 std::vector 实例排序

数组和向量本身不会对它们的有效负载对象进行排序。但是如果我们需要这样做,并不意味着我们总是必须切换到自动执行排序的数据结构。如果std::vector非常适合我们的用例,那么以排序方式向其中添加项目仍然非常简单和实用。

如何做到...

在本节中,我们将用随机单词填充一个std::vector,对其进行排序,然后在保持向量排序单词顺序不变的同时插入更多单词。

  1. 让我们首先包含我们将需要的所有头文件。
      #include <iostream>
      #include <vector>
      #include <string>
      #include <algorithm>
      #include <iterator> 
      #include <cassert>
  1. 我们还声明我们正在使用std命名空间,以避免一些std::前缀:
      using namespace std;
  1. 然后我们编写一个小的主函数,用一些随机字符串填充一个向量。
      int main()
      {
          vector<string> v {"some", "random", "words", 
                            "without", "order", "aaa", 
                            "yyy"};
  1. 接下来我们要做的是对该向量进行排序。在此之前,让我们使用 STL 的is_sorted函数和一些断言来检查向量在之前确实没有排序,但之后已经排序。
          assert(false == is_sorted(begin(v), end(v)));
          sort(begin(v), end(v));
          assert(true == is_sorted(begin(v), end(v)));
  1. 现在,我们最终使用一个新的insert_sorted函数将一些随机单词添加到排序后的向量中,之后我们仍然需要实现这个函数。这些单词应该放在正确的位置,以便向量在之后仍然是排序的:
          insert_sorted(v, "foobar");
          insert_sorted(v, "zzz");
  1. 因此,让我们现在在源文件中稍早实现insert_sorted
      void insert_sorted(vector<string> &v, const string &word)
      {
          const auto insert_pos (lower_bound(begin(v), end(v), word));
          v.insert(insert_pos, word);
      }
  1. 现在回到我们停下的主函数中,我们现在可以继续打印向量,并看到插入过程的工作情况:
          for (const auto &w : v) { 
              cout << w << " ";
          }
          cout << 'n';
      }
  1. 编译和运行程序会产生以下很好排序的输出:
      aaa foobar order random some without words yyy zzz

工作原理...

整个程序围绕insert_sorted函数构建,该函数执行本节所述的操作:对于任何新字符串,它定位排序向量中的位置,必须将其插入以保持向量中字符串的顺序。但是,我们假设向量在之前已经排序。否则,这将无法工作。

定位步骤由 STL 函数lower_bound完成,该函数接受三个参数。前两个参数表示底层范围的开始结束。在这种情况下,范围是我们的单词向量。第三个参数是要插入的单词。然后函数找到范围中第一个大于或等于第三个参数的项目,并返回指向它的迭代器。

有了正确的位置,我们将其提供给std::vector成员方法insert,该方法只接受两个参数。第一个参数是一个迭代器,指向向量中应插入第二个参数的位置。我们可以使用刚刚从lower_bound函数中获得的相同迭代器,这似乎非常方便。第二个参数当然是要插入的项目。

还有更多...

insert_sorted函数非常通用。如果我们泛化其参数的类型,它也将适用于其他容器有效负载类型,甚至适用于其他容器,例如std::setstd::dequestd::list等等!(请注意,set 有自己的lower_bound成员函数,执行与std::lower_bound相同的操作,但效率更高,因为它专门为集合进行了优化。)

template <typename C, typename T>
void insert_sorted(C &v, const T &item)
{
    const auto insert_pos (lower_bound(begin(v), end(v), item));
    v.insert(insert_pos, item);
}

当尝试从std::vector切换到其他类型的容器时,请注意并非所有容器都支持std::sort。该算法需要随机访问容器,例如std::list就不满足这个要求。

高效地和有条件地向 std::map 中插入项目

有时我们想要用键值对填充一个映射,并且在填充映射的过程中,可能会遇到两种不同的情况:

  1. 关键尚不存在。创建一个全新的键值对。

  2. 关键已经存在。获取现有项目并修改它。

我们可以简单地使用mapinsertemplace方法,并查看它们是否成功。如果不成功,我们就会遇到第二种情况,并修改现有的项目。在这两种情况下,insert 和 emplace 都会创建我们尝试插入的项目,而在第二种情况下,新创建的项目会被丢弃。在这两种情况下,我们都会得到一个无用的构造函数调用。

自 C++17 以来,有try_emplace函数,它使我们能够仅在插入时有条件地创建项目。让我们实现一个程序,该程序获取亿万富翁名单并构造一个告诉我们每个国家的亿万富翁数量的映射。除此之外,它还存储每个国家最富有的人。我们的示例不包含昂贵的创建项目,但是每当我们在现实项目中遇到这种情况时,我们都知道如何使用try_emplace来掌握它。

如何做...

在本节中,我们将实现一个应用程序,该应用程序从亿万富翁名单中创建一个映射。该映射将每个国家映射到该国最富有的人的引用以及告诉该国有多少亿万富翁的计数器。

  1. 和往常一样,我们首先需要包含一些头文件,并声明我们默认使用std命名空间。
      #include <iostream>
      #include <functional>
      #include <list>
      #include <map>

      using namespace std;
  1. 让我们定义一个代表我们名单上亿万富翁物品的结构。
      struct billionaire {
          string name;
          double dollars;
          string country;
      };
  1. 在主函数中,我们首先定义亿万富翁名单。世界上有很多亿万富翁,所以让我们构建一个有限的名单,其中只包含一些国家中最富有的人。这个名单已经排序。排名实际上来自《福布斯》2017 年《世界亿万富翁》名单www.forbes.com/billionaires/list/:
      int main()
      {
          list<billionaire> billionaires {
              {"Bill Gates", 86.0, "USA"},
              {"Warren Buffet", 75.6, "USA"},
              {"Jeff Bezos", 72.8, "USA"},
              {"Amancio Ortega", 71.3, "Spain"},
              {"Mark Zuckerberg", 56.0, "USA"},
              {"Carlos Slim", 54.5, "Mexico"},
              // ...
              {"Bernard Arnault", 41.5, "France"},
              // ...
              {"Liliane Bettencourt", 39.5, "France"},
              // ...
              {"Wang Jianlin", 31.3, "China"},
              {"Li Ka-shing", 31.2, "Hong Kong"}
              // ...
          };
  1. 现在,让我们定义映射。它将国家字符串映射到一对。该对包含我们名单中每个国家的第一个亿万富翁的(const)副本。这自动是每个国家最富有的亿万富翁。对中的另一个变量是一个计数器,我们将为该国家的每个后续亿万富翁递增。
          map<string, pair<const billionaire, size_t>> m;
  1. 现在,让我们遍历列表,并尝试为每个国家插入一个新的有效负载对。该对包含我们当前正在查看的亿万富翁的引用和计数器值1
          for (const auto &b : billionaires) {
              auto [iterator, success] = m.try_emplace(b.country, b, 1);
  1. 如果该步骤成功,那么我们就不需要做其他任何事情了。我们提供了构造函数参数b, 1的对已经被构造并插入到映射中。如果插入成功,因为国家键已经存在,那么这对就不会被构造。如果我们的亿万富翁结构非常庞大,这将为我们节省复制它的运行时成本。

然而,在不成功的情况下,我们仍然需要递增该国家的计数器。

              if (!success) {
                  iterator->second.second += 1;
              }
          }
  1. 好的,就是这样。我们现在可以打印每个国家有多少亿万富翁,以及每个国家最富有的人是谁。
          for (const auto & [key, value] : m) {
              const auto &[b, count] = value;

              cout << b.country << " : " << count 
                   << " billionaires. Richest is "
                   << b.name << " with " << b.dollars 
                   << " B$n";
          }
      }
  1. 编译和运行程序产生以下输出。(当然,输出是有限的,因为我们限制了输入映射。)
      $ ./efficient_insert_or_modify
      China : 1 billionaires. Richest is Wang Jianlin with 31.3 B$
      France : 2 billionaires. Richest is Bernard Arnault with 41.5 B$
      Hong Kong : 1 billionaires. Richest is Li Ka-shing with 31.2 B$
      Mexico : 1 billionaires. Richest is Carlos Slim with 54.5 B$
      Spain : 1 billionaires. Richest is Amancio Ortega with 71.3 B$
      USA : 4 billionaires. Richest is Bill Gates with 86 B$

它是如何工作的...

整个配方围绕着std::maptry_emplace函数展开,这是 C++17 的新功能。它具有以下签名:

std::pair<iterator, bool> try_emplace(const key_type& k, Args&&... args);

因此,被插入的键是参数k,关联的值是从参数包args构造的。如果我们成功插入该项,那么函数将返回一个迭代器,该迭代器指向映射中的新节点,并与设置为true的布尔值配对。如果插入成功,则返回对中的布尔值设置为false,并且迭代器指向新项将与之冲突的项。

这种特征在我们的情况下非常有用--当我们第一次看到来自特定国家的亿万富翁时,那么这个国家在映射中还不是一个键。在这种情况下,我们必须插入它,并附带将新计数器设置为1。如果我们已经看到来自特定国家的亿万富翁,我们必须获取对其现有计数器的引用,以便对其进行递增。这正是第 6 步发生的事情:

if (!success) {
    iterator->second.second += 1;
}

请注意,std::mapinsertemplace函数的工作方式完全相同。一个关键的区别是,如果键已经存在,try_emplace构造与键关联的对象。这在类型的对象昂贵创建时提高了性能。

还有更多...

如果我们将地图的类型从std::map切换到std::unordered_map,整个程序仍然可以工作。这样,我们可以从一种实现简单地切换到另一种实现,它们具有不同的性能特征。在这个示例中,唯一可观察到的区别是,亿万富翁地图不再按字母顺序打印,因为哈希映射不像搜索树那样对对象进行排序。

了解 stdmapinsert 的新插入提示语义。

std::map中查找项目需要O(log(n))时间。对于插入新项目也是一样,因为必须查找插入它们的位置。因此,天真地插入M个新项目将需要*O(M * log(n))*的时间。

为了使这更有效,std::map插入函数接受一个可选的插入提示参数。插入提示基本上是一个迭代器,它指向即将插入的项目的未来位置附近。如果提示是正确的,那么我们就会得到摊销的*O(1)*插入时间。

如何做...

在本节中,我们将向std::map中插入多个项目,并为此使用插入提示,以减少查找次数。

  1. 我们将字符串映射到数字,因此需要包含std::mapstd::string的头文件。
      #include <iostream>
      #include <map>
      #include <string>
  1. 下一步是实例化一个地图,其中已经包含了一些示例字符。
      int main()
      {
          std::map<std::string, size_t> m {{"b", 1}, {"c", 2}, {"d", 3}};
  1. 现在我们将插入多个项目,并且对于每个项目,我们将使用插入提示。由于一开始我们没有提示可以使用,我们将首先插入指向地图的end迭代器。
          auto insert_it (std::end(m));
  1. 现在,我们将按字母表的顺序向地图中插入项目,始终使用我们拥有的迭代器提示,然后将其重新初始化为insert函数的返回值。下一个项目将被插入到提示的前面
          for (const auto &s : {"z", "y", "x", "w"}) {
              insert_it = m.insert(insert_it, {s, 1});
          }
  1. 为了展示应该这样做,我们插入一个字符串,它将被放在地图中最左边的位置,但给它一个完全错误的提示,它指向地图中最右边的位置——end
          m.insert(std::end(m), {"a", 1});
  1. 最后,我们只是打印我们拥有的东西。
          for (const auto & [key, value] : m) {
              std::cout << """ << key << "": " << value << ", ";
          }
          std::cout << 'n';
      }
  1. 当我们编译和运行程序时,这是我们得到的输出。显然,错误的插入提示并没有造成太大的伤害,因为地图的顺序仍然是正确的。
      "a": 1, "b": 1, "c": 2, "d": 3, "w": 1, "x": 1, "y": 1, "z": 1,

它是如何工作的...

在这个示例中,与普通地图插入的唯一区别是额外的提示迭代器。我们谈到了正确错误的提示。

正确的提示将指向一个现有元素,该元素大于要插入的元素,以便新插入的键将刚好在提示之前。如果这不适用于用户在插入时提供的提示,插入函数将退回到非优化的插入,再次产生*O(log(n))*的性能。

对于第一次插入,我们得到了地图的end迭代器,因为我们没有更好的提示可以使用。在树中安装了一个“z”之后,我们知道安装“y”将在“z”的前面插入一个新项目,这使它成为一个正确的提示。如果在插入“y”之后将“x”放入树中,也是如此。这就是为什么可以使用由上次插入返回的迭代器进行下次插入。

重要的是要知道,在 C++11 之前,插入提示被认为是正确的,当它们指向新插入的项目的位置之前时。

还有更多...

有趣的是,错误的提示甚至不会破坏或干扰地图中项目的顺序,那么这是如何工作的,这意味着什么,插入时间是摊销*O(1)*吗?

std::map通常使用二叉搜索树实现。将新键插入搜索树时,将其与其他节点的键进行比较,从顶部开始。如果键比一个节点的键小或大,那么搜索算法将向左或向右分支,以进入下一个更深的节点。在这样做的同时,搜索算法将在达到当前树的最大深度的地方停止,在那里将新节点与其键放置。这一步可能破坏了树的平衡,因此之后也会使用重新平衡算法来纠正这一点,作为一项日常任务。

当我们将具有直接相邻键值的项目插入树中时(就像整数1是整数2的邻居一样,因为它们之间没有其他整数),它们通常也可以被插入到树中的相邻位置。可以轻松检查某个键和相应提示是否适用这种情况。如果适用,搜索算法步骤可以省略,这可以节省一些关键的运行时间。之后,重新平衡算法可能仍然需要运行。

当这样的优化通常可以完成,但并非总是如此时,这仍然可能导致平均性能提升。可以展示出在多次插入后稳定下来的结果运行时复杂度,然后称之为摊销复杂度

如果插入提示错误,插入函数将简单地放弃提示,并重新使用搜索算法开始。这样做是正确的,但显然会更慢。

高效地修改 std::map 项的键

由于std::map数据结构以一种使键始终唯一且排序的方式映射到值,因此用户无法修改已插入的地图节点的键是至关重要的。为了防止用户修改完全排序的地图节点的键项,将const限定符添加到键类型中。

这种限制是完全合理的,因为它使用户更难以错误使用std::map。但是,如果我们真的需要更改一些映射项的键,我们该怎么办呢?

在 C++17 之前,我们必须从树中删除需要更改键值的项目,然后重新插入它们。这种方法的缺点是这总是不必要地重新分配一些内存,这在性能方面听起来很糟糕。

自 C++17 以来,我们可以删除和重新插入地图节点而不进行任何内存重新分配。我们将在本教程中看到它是如何工作的。

如何做...

我们实现了一个小应用程序,它以std::map结构对虚构比赛中的驾驶员的位置进行排序。在比赛中,当驾驶员相互超越时,我们需要更改他们的位置键,这是我们以新的 C++17 方式做的。

  1. 让我们首先包括必要的头文件,并声明我们使用std命名空间。
      #include <iostream>
      #include <map>      

      using namespace std;
  1. 我们将在操纵地图结构之前和之后打印比赛名次,因此让我们为此实现一个小助手函数。
      template <typename M>
      void print(const M &m)
      {
          cout << "Race placement:n";
          for (const auto &[placement, driver] : m) {
              cout << placement << ": " << driver << 'n';
          }
      }
  1. 在主函数中,我们实例化和初始化一个映射,将整数值映射到包含驾驶员姓名的字符串。我们还打印地图,因为我们将在接下来的步骤中对其进行修改。
      int main()
      {
          map<int, string> race_placement {
              {1, "Mario"}, {2, "Luigi"}, {3, "Bowser"},
              {4, "Peach"}, {5, "Yoshi"}, {6, "Koopa"},
              {7, "Toad"}, {8, "Donkey Kong Jr."}
          };

          print(race_placement);
  1. 假设在一圈比赛中,鲍泽发生了一点小事故,掉到了最后一名,唐克·孔·朱尼尔趁机从最后一名跳到第三名。在这种情况下,我们首先需要从地图中提取它们的地图节点,因为这是操纵它们的键的唯一方法。extract函数是 C++17 的新功能。它可以从地图中删除项目而不产生任何与分配相关的副作用。让我们为这个任务打开一个新的范围。
          {
              auto a (race_placement.extract(3));
              auto b (race_placement.extract(8));
  1. 现在我们可以交换 Bowser 和 Donkey Kong Jr.的键。虽然地图节点的键通常是不可变的,因为它们被声明为const,但我们可以修改使用extract方法提取的项目的键。
              swap(a.key(), b.key());
  1. 在 C++17 中,std::mapinsert方法得到了一个新的重载,可以接受提取节点的句柄,以便在不触及分配器的情况下插入它们。
              race_placement.insert(move(a));
              race_placement.insert(move(b));
          }
  1. 离开作用域后,我们完成了。我们打印新的比赛排名,然后让应用程序终止。
          print(race_placement);
      }
  1. 编译和运行程序产生以下输出。我们首先在新的地图实例中看到了比赛排名,然后在交换 Bowser 和 Donkey Kong Jr.的位置后再次看到它。
      $ ./mapnode_key_modification 
      Race placement:
      1: Mario
      2: Luigi
      3: Bowser
      4: Peach
      5: Yoshi
      6: Koopa
      7: Toad
      8: Donkey Kong Jr.
      Race placement:
      1: Mario
      2: Luigi
      3: Donkey Kong Jr.
      4: Peach
      5: Yoshi
      6: Koopa
      7: Toad
      8: Bowser

工作原理...

在 C++17 中,std::map获得了一个新的成员函数 extract。它有两种形式:

node_type extract(const_iterator position);
node_type extract(const key_type& x);

在本示例中,我们使用了第二种方法,它接受一个键,然后查找并提取与键参数匹配的地图节点。第一个方法接受一个迭代器,这意味着它更快,因为它不需要搜索项目。

如果我们尝试使用第二种方法(使用键进行搜索)提取不存在的项目,则会返回一个node_type实例。empty()成员方法返回一个布尔值,告诉我们node_type实例是否为空。访问空实例上的任何其他方法会导致未定义的行为。

在提取节点之后,我们能够使用key()方法修改它们的键,这为我们提供了对键的非 const 访问,尽管键通常是 const 的。

请注意,为了重新将节点插入地图中,我们必须将它们移动insert函数中。这是有道理的,因为extract的目的是避免不必要的复制和分配。请注意,虽然我们移动了一个node_type实例,但这并不会导致任何容器值的实际移动。

还有更多...

使用提取方法提取的地图节点实际上非常灵活。我们可以从map实例中提取节点并将其插入到任何其他map甚至multimap实例中。它也可以在unordered_mapunordered_multimap实例之间,以及set/multiset和相应的unordered_set/unordered_multiset之间工作。

为了在不同的地图/集合结构之间移动项目,键、值和分配器的类型需要相同。请注意,即使是这种情况,我们也不能从map移动节点到unordered_map,或者从set移动节点到unordered_set

使用自定义类型的 std::unordered_map

如果我们使用std::unordered_map而不是std::map,我们可以对要使用的键类型进行不同程度的自由选择。std::map要求所有键项之间存在自然顺序。这样,项目可以排序。但是,如果我们想要,例如,将数学向量作为键类型呢?对于这种类型,没有较小<关系是没有意义的,因为向量(0, 1)不比(1, 0)。它们只是指向不同的方向。这对于std::unordered_map来说完全没问题,因为它不会通过它们的较小/较大的顺序关系来区分项目,而是通过哈希值。我们唯一需要做的就是为我们自己的类型实现一个哈希函数,以及一个相等==运算符实现,告诉我们两个对象是否相同。本节将通过一个示例来演示这一点。

如何做...

在本节中,我们将定义一个简单的coord结构,它没有默认哈希函数,因此我们需要自己定义它。然后我们通过将coord值映射到数字来使用它。

  1. 我们首先包含了打印和使用std::unordered_map所需的内容。
      #include <iostream>
      #include <unordered_map>
  1. 然后我们定义了我们自己的自定义结构,它不是通过现有哈希函数轻松哈希的:
      struct coord {
          int x;
          int y;
      };
  1. 我们不仅需要一个哈希函数才能将结构用作哈希映射的键,它还需要一个比较运算符的实现:
      bool operator==(const coord &l, const coord &r)
      {
          return l.x == r.x && l.y == r.y;
      }
  1. 为了扩展 STL 自己的哈希能力,我们将打开std命名空间,并创建我们自己的std::hash模板结构专门化。它包含与其他哈希专门化相同的using类型别名子句。
      namespace std
      {

      template <>
      struct hash<coord>
      {
          using argument_type = coord;
          using result_type   = size_t;
  1. 这个struct的核心是operator()的定义。我们只是添加了struct coord的数值成员值,这是一种较差的哈希技术,但为了展示如何实现它,这已经足够了。一个好的哈希函数试图尽可能均匀地分布值在整个值范围内,以减少哈希冲突的数量。
          result_type operator()(const argument_type &c) const
          {
              return static_cast<result_type>(c.x) 
                   + static_cast<result_type>(c.y);
          }
      };

      }
  1. 现在我们可以实例化一个新的std::unordered_map实例,它接受struct coord实例作为键,并将其映射到任意值。由于这个方法是关于使我们自己的类型适用于std::unordered_map,这已经足够了。让我们用我们自己的类型实例化一个基于哈希的映射,填充它一些项目,并打印它的:
      int main()
      {

          std::unordered_map<coord, int> m {{{0, 0}, 1}, {{0, 1}, 2}, 
                                            {{2, 1}, 3}};

          for (const auto & [key, value] : m) {
              std::cout << "{(" << key.x << ", " << key.y 
                        << "): " << value << "} ";
          }
          std::cout << 'n';
      }
  1. 编译和运行程序产生了以下输出:
      $ ./custom_type_unordered_map
      {(2, 1): 3} {(0, 1): 2} {(0, 0): 1}

它是如何工作的...

通常,当我们实例化一个基于哈希的映射实现,比如std::unordered_map时,我们会写:

std::unordered_map<key_type, value_type> my_unordered_map;

当编译器创建我们的std::unordered_map专门化时,背后发生了很多魔法,这并不太明显。因此,让我们来看一下它的完整模板类型定义:

template<
    class Key,
    class T,
    class Hash      = std::hash<Key>,
    class KeyEqual  = std::equal_to<Key>,
    class Allocator = std::allocator< std::pair<const Key, T> >
> class unordered_map;

前两个模板类型是我们用coordint填充的,这是简单和明显的部分。另外三个模板类型是可选的,因为它们会自动填充现有的标准模板类,这些类本身采用模板类型。这些类以我们对前两个参数的选择作为默认值。

关于这个方法,class Hash模板参数是有趣的:当我们没有明确定义其他任何东西时,它将专门化为std::hash<key_type>。STL 已经包含了许多类型的std::hash专门化,比如std::hash<std::string>std::hash<int>std::hash<unique_ptr>等等。这些类知道如何处理这些特定类型,以计算出最佳的哈希值。

然而,STL 并不知道如何从我们的struct coord计算哈希值。因此,我们所做的是定义另一个专门化,它知道如何处理它。编译器现在可以遍历它所知道的所有std::hash专门化列表,并找到我们的实现来匹配我们提供的键类型。

如果我们没有添加一个新的std::hash<coord>专门化,并将其命名为my_hash_type,我们仍然可以使用以下实例化行:

std::unordered_map<coord, value_type, my_hash_type> my_unordered_map;

这显然需要输入更多的内容,而且不像编译器自己找到正确的哈希实现那样容易阅读。

从用户输入中过滤重复项并按字母顺序打印它们与 std::set

std::set是一个奇怪的容器:它的工作方式有点像std::map,但它只包含键作为值,没有键值对。因此,它几乎不能用作将一种类型的值映射到另一种类型的值。看起来,只是因为它的用例不太明显,很多开发人员甚至不知道它的存在。然后他们开始自己实现东西,尽管std::set在其中的一些情况下会非常有帮助。

这一部分展示了如何在一个示例中使用std::set,在这个示例中,我们收集了许多不同的项目,以过滤它们并输出唯一的选择。

如何做...

在这一部分,我们将从标准输入中读取一系列单词。所有唯一的单词都被放入一个std::set实例中。这样我们就可以列举出流中的所有唯一单词。

  1. 我们将使用多种不同的 STL 类型,因此需要包含多个头文件。
      #include <iostream>
      #include <set>
      #include <string>
      #include <iterator>
  1. 为了节省一些输入,我们将声明我们正在使用std命名空间:
      using namespace std;
  1. 现在我们已经开始编写实际的程序,它以main函数实例化一个存储字符串的std::set开始。
      int main()
      {
          set<string> s;
  1. 接下来要做的事情是获取用户输入。我们只需从标准输入读取,并使用方便的istream_iterator
          istream_iterator<string> it {cin};
          istream_iterator<string> end;
  1. 拥有一对beginend迭代器,代表用户输入,我们可以使用std::inserter从中填充集合。
          copy(it, end, inserter(s, s.end()));
  1. 就是这样。为了看到我们从标准输入得到的独特单词,我们只需打印我们集合的内容。
          for (const auto word : s) {
              cout << word << ", ";
          }
          cout << 'n';
      }
  1. 让我们用以下输入编译和运行我们的程序。对于前面的输入,我们得到以下输出,其中所有重复项都被剔除,而独特的单词按字母顺序排序。
      $ echo "a a a b c foo bar foobar foo bar bar" | ./program
      a, b, bar, c, foo, foobar,

它是如何工作的...

这个程序由两个有趣的部分组成。第一部分是使用std::istream_iterator来访问用户输入,第二部分是将其与我们的std::set实例结合起来,使用std::copy算法,然后将其包装成std::inserter实例!也许令人惊讶的是,只有一行代码就可以完成标记化输入、将其放入按字母顺序排序的集合中,并删除所有重复项的所有工作。

std::istream_iterator

这个类在我们想要从流中处理大量相同类型的数据时非常有趣,这正是这个示例的情况:我们逐个单词解析整个输入,并将其以std::string实例的形式放入集合中。

std::istream_iterator接受一个模板参数。那就是我们想要的输入类型。我们选择了std::string,因为我们假设是文本单词,但也可以是float数字,例如。基本上可以是任何可以写成cin >> var;的类型。构造函数接受一个istream实例。标准输入由全局输入流对象std::cin表示,在这种情况下是一个可接受的istream参数。

istream_iterator<string> it {cin};

我们实例化的输入流迭代器it能够做两件事:当它被解引用(*it)时,它会产生当前的输入符号。由于我们通过模板参数将迭代器类型化为std::string,所以该符号将是一个包含一个单词的字符串。当它被增加(++it)时,它将跳到下一个单词,我们可以通过再次解引用来访问它。

但是等等,在我们再次解引用之前,我们需要在每次增量之后小心。如果标准输入为空,迭代器就不应该再次被解引用。相反,我们应该终止我们解引用迭代器以获取每个单词的循环。让我们知道迭代器变得无效的中止条件是与end迭代器的比较。如果it == end成立,我们就超出了输入的末尾。

我们通过使用其无参数标准构造函数创建std::istream_iterator实例来创建结束迭代器。它的目的是作为每次迭代中的中止条件的对应物:

istream_iterator<string> end;

一旦std::cin为空,我们的it迭代器实例将注意到并与end进行比较,返回true

std::inserter

我们在std::copy调用中使用itend对作为输入迭代器。第三个参数必须是一个输出迭代器。对于这一点,我们不能只取s.begin()s.end()。在一个空集合中,两者是相同的,所以我们甚至不能解引用它,无论是用于从中读取还是分配给它。

这就是std::inserter发挥作用的地方。它是一个返回std::insert_iterator的函数,它的行为类似于迭代器,但做的事情与通常的迭代器不同。当我们增加它时,它什么也不做。当我们解引用它并将某物赋给它时,它将取得它所附属的容器,并将该值作为项插入其中!

通过std::inserter实例化std::insert_iterator需要两个参数:

auto insert_it = inserter(s, s.end());

s是我们的集合,s.end()是一个迭代器,指向新项应该插入的位置。对于我们开始的空集合,这和s.begin()一样有意义。当用于其他数据结构如向量或列表时,第二个参数对于定义插入迭代器应该插入新项的位置至关重要。

将它放在一起

最后,所有的操作都发生在std::copy调用期间:

copy(input_iterator_begin, input_iterator_end, insert_iterator);

这个调用从std::cin中通过输入迭代器取出下一个单词标记,并将其推入我们的std::set中。然后,它递增两个迭代器,并检查输入迭代器是否等于输入结束迭代器的对应项。如果不相等,那么标准输入中仍然有单词,所以它将重复

重复的单词会自动被丢弃。如果集合已经包含特定单词,再次添加它将没有效果。这在std::multiset中是不同的,因为它会接受重复项。

使用 std::stack 实现一个简单的逆波兰表示法计算器

std::stack是一个适配器类,它允许用户像在真正的对象堆栈上一样将对象推入它,然后再从中弹出对象。在这一部分,我们围绕这个数据结构构建了一个逆波兰表示法(RPN)计算器,以展示如何使用它。

逆波兰表示法是一种可以用来以非常简单的方式解析数学表达式的表示法。在逆波兰表示法中,1 + 2表示为1 2 +。首先是操作数,然后是操作符。另一个例子:(1 + 2) * 3在逆波兰表示法中是1 2 + 3 *,这已经显示了为什么它更容易解析,因为我们不需要使用括号来定义子表达式。

如何做...

在这一部分,我们将从标准输入中读取一个逆波兰表示法的数学表达式,然后将其传递给一个评估函数。最后,我们将数值结果打印回给用户。

  1. 我们将使用 STL 中的许多辅助函数,所以有一些包含:
      #include <iostream>
      #include <stack>
      #include <iterator>
      #include <map>
      #include <sstream>
      #include <cassert>
      #include <vector>
      #include <stdexcept>
      #include <cmath>
  1. 我们还声明我们使用std命名空间,以节省一些输入。
      using namespace std;
  1. 然后,我们立即开始实现我们的逆波兰表示法解析器。它将接受一个迭代器对,表示以字符串形式的数学表达式的开始和结束,这将逐个标记消耗掉。
      template <typename IT>
      double evaluate_rpn(IT it, IT end)
      {
  1. 当我们遍历标记时,我们需要记住一路上的所有操作数,直到看到一个操作符。这就是我们需要一个堆栈的地方。所有的数字都将被解析并保存为双精度浮点数,所以它将是一个double值的堆栈。
          stack<double> val_stack;
  1. 为了方便地访问堆栈上的元素,我们实现了一个辅助函数。它通过从堆栈中取出最高项来修改堆栈,然后返回该项。这样我们可以在以后的一个步骤中执行这个任务。
          auto pop_stack ([&](){ 
              auto r (val_stack.top()); 
              val_stack.pop(); 
              return r; 
          });
  1. 另一个准备工作是定义所有支持的数学运算。我们将它们保存在一个映射中,将每个操作标记与实际操作关联起来。这些操作由可调用的 lambda 表示,它们接受两个操作数,例如相加或相乘,然后返回结果。
          map<string, double (*)(double, double)> ops {
              {"+", [](double a, double b) { return a + b; }},
              {"-", [](double a, double b) { return a - b; }},
              {"*", [](double a, double b) { return a * b; }},
              {"/", [](double a, double b) { return a / b; }},
              {"^", [](double a, double b) { return pow(a, b); }},
              {"%", [](double a, double b) { return fmod(a, b); }},
          };
  1. 现在我们终于可以遍历输入了。假设输入迭代器给我们的是字符串,我们为每个标记提供一个新的std::stringstream,因为它可以解析数字。
          for (; it != end; ++it) {
              stringstream ss {*it};
  1. 现在对于每个标记,我们尝试从中获取一个double值。如果成功,我们就有了操作数,我们将其推入堆栈。
              if (double val; ss >> val) {
                  val_stack.push(val);
              }
  1. 如果它成功,那么它必须是其他东西而不是操作符;在这种情况下,它只能是操作数。知道我们支持的所有操作都是二元的,我们需要从堆栈中弹出最后的两个操作数。
              else {
                  const auto r {pop_stack()};
                  const auto l {pop_stack()};
  1. 现在我们从解引用迭代器it中获取操作数,它已经发出了字符串。通过查询ops映射,我们得到一个接受两个操作数lr作为参数的 lambda 对象。
                  try {
                      const auto & op     (ops.at(*it));
                      const double result {op(l, r)};
                      val_stack.push(result);
                  }
  1. 我们用try子句包围了数学部分的应用,这样我们就可以捕获可能发生的异常。映射的at调用将在用户提供我们不知道的数学操作时抛出out_of_range异常。在这种情况下,我们将重新抛出一个不同的异常,该异常说invalid argument并携带了我们不知道的操作字符串。
                  catch (const out_of_range &) {
                      throw invalid_argument(*it);
                  }
  1. 这就是全部。一旦循环终止,我们就在堆栈上得到了最终结果。所以我们就返回那个。 (在这一点上,我们可以断言堆栈大小是否为 1。如果不是,那么就会缺少操作。)
              }
          }

          return val_stack.top();
      }
  1. 现在我们可以使用我们的小 RPN 解析器。为了做到这一点,我们将标准输入包装成一个std::istream_iterator对,并将其传递给 RPN 解析器函数。最后,我们打印结果:
      int main()
      {
          try {
              cout << evaluate_rpn(istream_iterator<string>{cin}, {}) 
                   << 'n';
          }
  1. 我们再次将该行包装到try子句中,因为仍然有可能用户输入包含我们没有实现的操作。在这种情况下,我们必须捕获我们在这种情况下抛出的异常,并打印错误消息:
          catch (const invalid_argument &e) {
              cout << "Invalid operator: " << e.what() << 'n';
          }
      }
  1. 编译程序后,我们可以尝试一下。输入"3 1 2 + * 2 /"代表表达式( 3 * (1 + 2) ) / 2,并产生了正确的结果:
      $ echo "3 1 2 + * 2 /" | ./rpn_calculator
      4.5

它是如何工作的...

整个算法围绕着将操作数推送到堆栈上直到我们在输入中看到一个操作。在这种情况下,我们再次从堆栈中弹出最后两个操作数,对它们应用操作,然后再次将结果推送到堆栈上。为了理解这个算法中的所有代码,重要的是要理解我们如何从输入中区分操作数操作,如何处理我们的堆栈,以及如何选择和应用正确的数学操作。

堆栈处理

我们将项目推送到堆栈上,只需使用std::stackpush函数:

val_stack.push(val);

从中弹出值看起来有点复杂,因为我们为此实现了一个 lambda,它捕获了对val_stack对象的引用。让我们看看相同的代码,增加一些注释:

auto pop_stack ([&](){
    auto r (val_stack.top()); // Get top value copy
    val_stack.pop();          // Throw away top value
    return r;                 // Return copy
});

这个 lambda 是必要的,以便一步获取堆栈的顶部值并从中删除它。std::stack的接口设计并不允许在单个调用中执行此操作。但是,定义一个 lambda 很快很容易,所以我们现在可以这样获取值:

double top_value {pop_stack()};

从用户输入中区分操作数和操作

evaluate_rpn的主循环中,我们从迭代器中获取当前的字符串标记,然后查看它是否是操作数。如果字符串可以解析为double变量,那么它就是一个数字,因此也是一个操作数。我们认为所有不能轻松解析为数字的东西(例如"+")都是操作

用于这个任务的裸代码框架如下:

stringstream ss {*it};
if (double val; ss >> val) {
    // It's a number!
} else {
    // It's something else than a number - an operation!
}

流操作符>>告诉我们它是否是一个数字。首先,我们将字符串包装到std::stringstream中。然后我们使用stringstream对象的能力从std::string流到double变量,这涉及解析。如果解析失败,我们知道它是因为我们要求它将某些东西解析为一个数字,而这不是一个数字。

选择和应用正确的数学操作

在我们意识到当前用户输入标记不是一个数字之后,我们只是假设它是一个操作,比如+*。然后我们查询我们称为ops的映射,查找该操作并返回一个函数,该函数接受两个操作数,并返回总和,或乘积,或适当的其他内容。

映射本身的类型看起来相对复杂:

map<string, double (*)(double, double)> ops { ... };

它从string映射到double (*)(double, double)。后者是什么意思?这种类型描述应该读作“指向一个接受两个 double 并返回一个 double 的函数的指针”。想象一下,(*)部分就是函数的名称,比如double sum(double, double),这样就更容易阅读。这里的技巧是,我们的 lambda [](double, double) { return /* some double */ } 可以转换为实际匹配该指针描述的函数指针。通常不捕获任何内容的 lambda 都可以转换为函数指针。

这样,我们可以方便地向映射询问正确的操作:

const auto & op     (ops.at(*it));
const double result {op(l, r)};

映射隐式地为我们做了另一项工作:如果我们说ops.at("foo"),那么"foo"是一个有效的键值,但我们没有存储任何名为这样的操作。在这种情况下,映射将抛出一个异常,我们在配方中捕获它。每当我们捕获它时,我们重新抛出一个不同的异常,以便提供对这种错误情况的描述性含义。用户将更清楚地知道无效参数异常意味着什么,而不是超出范围异常。请注意,evaluate_rpn函数的用户可能没有阅读其实现,因此可能不知道我们根本在内部使用映射。

还有更多...

由于evaluate_rpn函数接受迭代器,因此很容易用不同于标准输入流的输入来提供输入。这使得测试或适应不同的用户输入来源非常容易。

例如,通过从字符串流或字符串向量中使用迭代器进行输入,看起来像以下代码,evaluate_rpn根本不需要更改:

int main()
{
    stringstream s {"3 2 1 + * 2 /"};
    cout << evaluate_rpn(istream_iterator<string>{s}, {}) << 'n';

    vector<string> v {"3", "2", "1", "+", "*", "2", "/"};
    cout << evaluate_rpn(begin(v), end(v)) << 'n';
}

在合适的地方使用迭代器。这样可以使您的代码非常可组合和可重用。

使用std::map实现单词频率计数器

std::map在对数据进行统计时非常有用。通过将可修改的有效负载对象附加到表示对象类别的每个键上,可以很容易地实现例如单词频率的直方图。这就是我们将在本节中做的事情。

如何做到...

在这一部分,我们将从标准输入中读取所有用户输入,例如可能是包含文章的文本文件。我们将输入标记化为单词,以便统计每个单词出现的次数。

  1. 和往常一样,我们需要包括我们将要使用的数据结构的所有头文件。
      #include <iostream>
      #include <map> 
      #include <vector> 
      #include <algorithm> 
      #include <iomanip>
  1. 为了节省一些输入,我们声明使用std命名空间。
      using namespace std;
  1. 我们将使用一个辅助函数来裁剪可能附加的逗号、句号或冒号。
      string filter_punctuation(const string &s)
      {
          const char *forbidden {".,:; "};
          const auto  idx_start (s.find_first_not_of(forbidden));
          const auto  idx_end   (s.find_last_not_of(forbidden));

          return s.substr(idx_start, idx_end - idx_start + 1);
      }
  1. 现在我们开始实际的程序。我们将收集一个映射,将我们看到的每个单词与该单词频率的计数器关联起来。此外,我们还维护一个记录迄今为止我们见过的最长单词的大小的变量,这样当我们在程序结束时打印单词频率表时,我们可以很好地缩进它。
      int main()
      {
          map<string, size_t> words;
          int max_word_len {0};
  1. 当我们从std::cin流入一个std::string变量时,输入流会在途中去除空格。这样我们就可以逐个单词获取输入。
          string s;
          while (cin >> s) {
  1. 现在我们所拥有的单词,可能包含逗号、句号或冒号,因为它可能出现在句子的结尾或类似位置。我们使用之前定义的辅助函数来过滤掉这些。
              auto filtered (filter_punctuation(s));
  1. 如果这个单词是迄今为止最长的单词,我们需要更新max_word_len变量。
              max_word_len = max<int>(max_word_len, filtered.length());
  1. 现在我们将增加words映射中该单词的计数值。如果它是第一次出现,我们会在增加之前隐式地创建它。
              ++words[filtered];
          }
  1. 循环结束后,我们知道我们已经在words映射中保存了输入流中的所有唯一单词,并与表示每个单词频率的计数器配对。映射使用单词作为键,并按它们的字母顺序排序。我们想要的是按频率排序打印所有单词,因此频率最高的单词应该首先出现。为了实现这一点,我们首先实例化一个向量,将所有这些单词频率对放入其中,并将它们从映射移动到向量中。
          vector<pair<string, size_t>> word_counts;
          word_counts.reserve(words.size());
          move(begin(words), end(words), back_inserter(word_counts));
  1. 现在向量仍然以与words映射维护它们相同的顺序包含所有单词频率对。现在我们再次对其进行排序,以便将最频繁出现的单词放在开头,将最不频繁的单词放在末尾。
          sort(begin(word_counts), end(word_counts),
              [](const auto &a, const auto &b) { 
                  return a.second > b.second; 
              });
  1. 现在所有数据都已经排序好了,所以我们将其推送到用户终端。使用std::setw流操作符,我们以漂亮的缩进格式格式化数据,使其看起来有点像表格。
          cout << "# " << setw(max_word_len) << "<WORD>" << " #<COUNT>n";
          for (const auto & [word, count] : word_counts) {
              cout << setw(max_word_len + 2) << word << " #" 
                   << count << 'n';
          }
      }
  1. 编译程序后,我们可以将任何文本文件输入到其中以获得频率表。
      $ cat lorem_ipsum.txt | ./word_frequency_counter
      #       <WORD> #<COUNT>
                  et #574
               dolor #302
                 sed #273
                diam #273
                 sit #259
               ipsum #259
      ...

它是如何工作的...

这个方法集中在收集所有单词到std::map中,然后将所有项目从映射中推出并放入std::vector中,然后以不同的方式进行排序,以便打印数据。为什么?

让我们来看一个例子。当我们统计字符串"a a b c b b b d c c"中的单词频率时,我们会得到以下的映射内容:

a -> 2
b -> 4
c -> 3
d -> 1

然而,这不是我们想要向用户展示的顺序。程序应该首先打印b,因为它的频率最高。然后是c,然后是a,最后是d。不幸的是,我们无法要求映射给我们“具有最高关联值的键”,然后是“具有第二高关联值的键”,依此类推。

在这里,向量就派上用场了。我们将向量定义为包含字符串和计数器值对的对。这样它可以以与映射中的形式完全相同的形式保存项目。

vector<pair<string, size_t>> word_counts;

然后我们使用std::move算法填充向量,使用单词频率对。这样做的好处是,保存在堆上的字符串部分不会被复制,而是从映射移动到向量中。这样我们就可以避免大量的复制。

move(begin(words), end(words), back_inserter(word_counts));

一些 STL 实现使用了短字符串优化--如果字符串不太长,它将不会被分配到堆上,而是直接存储在字符串对象中。在这种情况下,移动并不更快。但移动也永远不会更慢!

下一个有趣的步骤是排序操作,它使用 lambda 作为自定义比较运算符:

sort(begin(word_counts), end(word_counts),
        [](const auto &a, const auto &b) { return a.second > b.second; });

排序算法将成对地取出项目,并进行比较,这就是排序算法的工作原理。通过提供 lambda 函数,比较不仅仅是比较a是否小于b(这是默认实现),还比较a.second是否大于b.second。请注意,所有对象都是字符串和它们的计数器值的对,通过写a.second我们可以访问单词的计数器值。这样我们就将所有高频单词移动到向量的开头,将低频单词移动到向量的末尾。

实现一个写作风格辅助工具,用于在文本中查找非常长的句子,使用 std::multimap

每当需要以排序方式存储大量项目,并且它们按照键进行排序的时候,std::multimap是一个不错的选择。

让我们找一个例子使用情况:在德语写作中,使用非常长的句子是可以的。但在英语写作中,是不可以的。我们将实现一个工具,帮助德语作者分析他们的英语文本文件,重点关注所有句子的长度。为了帮助作者改进文本风格,它将根据句子的长度对句子进行分组。这样作者就可以选择最长的句子并将其拆分。

如何做...

在本节中,我们将从标准输入中读取所有用户输入,我们将通过整个句子而不是单词对其进行标记化。然后我们将所有句子收集到一个std::multimap中,并与其长度一起输出给用户。然后,我们将所有句子按其长度排序后返回给用户。

  1. 像往常一样,我们需要包括所有需要的头文件。std::multimap来自与std::map相同的头文件。
      #include <iostream>
      #include <iterator>
      #include <map>
      #include <algorithm>
  1. 我们使用了很多来自std命名空间的函数,因此我们自动声明其使用。
      using namespace std;
  1. 当我们通过提取文本中句号之间的内容来对字符串进行标记化时,我们将得到由空格(如空格、换行符等)包围的文本句子。这些会以错误的方式增加它们的大小,因此我们使用一个辅助函数来过滤它们,现在我们定义它。
      string filter_ws(const string &s)
      {
          const char *ws {" rnt"};
          const auto a (s.find_first_not_of(ws));
          const auto b (s.find_last_not_of(ws));
          if (a == string::npos) {
              return {};
          }
          return s.substr(a, b);
      }
  1. 实际的句子长度计数函数应该接受一个包含所有文本的巨大字符串,然后返回一个std::multimap,将排序后的句子长度映射到句子。
      multimap<size_t, string> get_sentence_stats(const string &content)
      {
  1. 我们首先声明multimap结构,这是预期的返回值,以及一些迭代器。由于我们将有一个循环,我们需要一个end迭代器。然后我们使用两个迭代器来指向文本中连续的句号。两者之间的所有内容都是一个文本句子。
          multimap<size_t, string> ret;

          const auto end_it (end(content));
          auto it1 (begin(content));
          auto it2 (find(it1, end_it, '.'));
  1. it2始终比it1多一个句号。只要it1没有到达文本的末尾,我们就没问题。第二个条件检查it2是否真的至少有一些字符。如果不是这样,它们之间就没有字符可读了。
          while (it1 != end_it && distance(it1, it2) > 0) {
  1. 我们从迭代器之间的所有字符创建一个字符串,并过滤掉其开头和结尾的所有空格,以便计算纯句子的长度。
              string s {filter_ws({it1, it2})};
  1. 可能句子中除了空格以外什么都没有。在这种情况下,我们只是丢弃它。否则,我们通过确定有多少个单词来计算其长度。这很容易,因为所有单词之间都有单个空格。然后我们将单词计数与句子一起保存在multimap中。
              if (s.length() > 0) {
                  const auto words (count(begin(s), end(s), ' ') + 1);
                  ret.emplace(make_pair(words, move(s)));
              }
  1. 对于下一个循环迭代,我们将主迭代器it1放在下一个句子的句号字符上。接下来的迭代器it2放在主迭代器的位置之后一个字符。
              it1 = next(it2, 1);
              it2 = find(it1, end_it, '.');
          }
  1. 循环结束后,multimap包含所有句子及其单词计数,并且可以返回。
          return ret;
      }
  1. 现在我们开始使用该函数。首先,我们告诉std::cin不要跳过空格,因为我们希望句子中的空格保持完整。为了读取整个文件,我们从输入流迭代器初始化一个std::string,它封装了std::cin
      int main()
      {
          cin.unsetf(ios::skipws);
          string content {istream_iterator<char>{cin}, {}};
  1. 由于我们只需要multimap的结果进行打印,我们直接在循环中调用get_sentence_stats并将其与我们的字符串一起使用。在循环体中,我们逐行打印项目。
          for (const auto & [word_count, sentence] 
                   : get_sentence_stats(content)) {
              cout << word_count << " words: " << sentence << ".n";
          }
      }
  1. 编译代码后,我们可以从任何文本文件中输入文本到应用程序中。例如 Lorem Ipsum 文本产生以下输出。由于长文本有很多句子,输出非常长,因此它首先打印最短的句子,最后打印最长的句子。这样我们就可以先看到最长的句子,因为终端通常会自动滚动到输出的末尾。
      $ cat lorem_ipsum.txt | ./sentence_length
      ...
      10 words: Nam quam nunc, blandit vel, luctus pulvinar, 
      hendrerit id, lorem.
      10 words: Sed consequat, leo eget bibendum sodales, 
      augue velit cursus nunc,.
      12 words: Cum sociis natoque penatibus et magnis dis 
      parturient montes, nascetur ridiculus mus.
      17 words: Maecenas tempus, tellus eget condimentum rhoncus, 
      sem quam semper libero, sit amet adipiscing sem neque sed ipsum.

它是如何工作的...

整个过程集中在将一个大字符串分解为文本句子,对其长度进行评估,然后在multimap中排序。因为std::multimap本身非常容易使用,程序的复杂部分是循环,它遍历句子:

const auto end_it (end(content));
auto it1 (begin(content));         // (1) Beginning of string
auto it2 (find(it1, end_it, '.')); // (1) First '.' dot

while (it1 != end_it && std::distance(it1, it2) > 0) {
    string sentence {it1, it2};

    // Do something with the sentence string...

    it1 = std::next(it2, 1);      // One character past current '.' dot
    it2 = find(it1, end_it, '.'); // Next dot, or end of string
}

让我们在看下面的代码时,考虑以下图表,其中包含三个句子:

it1it2始终一起向前移动。这样它们总是指向一个句子的开头和结尾。std::find算法在这方面帮助了我们很多,因为它的工作方式是“从当前位置开始,然后返回到下一个句号字符的迭代器。如果没有,返回结束迭代器。”

在提取句子字符串后,我们确定它包含多少个单词,以便将其插入multimap中。我们使用单词数作为映射节点的,并将字符串本身作为与之关联的有效负载对象。很容易有多个长度相同的句子。这将使我们无法将它们全部插入一个std::map中。但由于我们使用std::multimap,这不是问题,因为它可以轻松处理相同值的多个键。它将保持它们全部有序,这正是我们需要通过它们的长度枚举所有句子并将它们输出给用户。

还有更多...

在将整个文件读入一个大字符串后,我们遍历字符串并再次创建每个句子的副本。这是不必要的,因为我们也可以使用std::string_view,这将在本书的后面介绍。

另一种迭代获取两个连续点之间的字符串的方法是std::regex_iterator,这也将在本书的后面章节中介绍。

使用 std::priority_queue 实现个人待办事项列表

std::priority_queue是另一个容器适配器类,例如std::stack。它是另一个数据结构(默认情况下为std::vector)的包装器,并为其提供了类似队列的接口。这意味着可以逐步将项目推入其中,然后逐步将其弹出。被推入其中的东西被弹出。这通常也被缩写为先进先出FIFO)队列。这与堆栈相反,堆栈中最后推入的项目会弹出。

虽然我们刚刚描述了std::queue的行为,但本节展示了std::priority_queue的工作原理。该适配器很特别,因为它不仅考虑 FIFO 特性,还将其与优先级混合在一起。这意味着 FIFO 原则被分解为具有优先级的子 FIFO 队列。

如何做...

在本节中,我们将建立一个便宜的待办事项列表组织结构。我们不解析用户输入,以便使程序简短并集中在std::priority_queue上。因此,我们只是将待办事项的无序列表与优先级和描述一起填充到优先级队列中,然后像从 FIFO 队列数据结构中读取一样,但是根据各个项目的优先级进行分组。

  1. 我们首先需要包含一些头文件。std::priority_queue在头文件<queue>中。
      #include <iostream>
      #include <queue>
      #include <tuple>
      #include <string>
  1. 我们如何将待办事项存储在优先级队列中?问题是,我们不能添加项目并额外附加优先级。优先级队列将尝试使用队列中所有项目的自然顺序。我们现在可以实现自己的struct todo_item,并给它一个优先级数字和一个待办描述字符串,然后实现比较运算符<以使它们可排序。或者,我们可以使用std::pair,它使我们能够将两个东西聚合在一个类型中,并为我们自动实现比较。
      int main()
      {
          using item_type = std::pair<int, std::string>;
  1. 我们现在有了一个新类型item_type,它由整数优先级和字符串描述组成。因此,让我们实例化一个优先级队列,其中包含这样的项目。
          std::priority_queue<item_type> q;
  1. 我们现在将用不同优先级的不同项目填充优先级队列。目标是提供一个无结构的列表,然后优先级队列告诉我们以哪种顺序什么。如果有漫画要读,还有作业要做,当然,作业必须先做。不幸的是,std::priority_queue没有接受初始化列表的构造函数,我们可以用它来从一开始就填充队列。(使用向量或普通列表,它会按照这种方式工作。)所以我们首先定义列表,然后在下一步中插入它。
          std::initializer_list<item_type> il {
              {1, "dishes"},
              {0, "watch tv"},
              {2, "do homework"},
              {0, "read comics"},
          };
  1. 我们现在可以舒适地遍历待办事项的无序列表,并使用push函数逐步插入它们。
          for (const auto &p : il) {
              q.push(p);
          }
  1. 所有项目都被隐式排序,因此我们有一个队列,它给我们最高优先级的项目。
          while(!q.empty()) {
              std::cout << q.top().first << ": " << q.top().second << 'n';
              q.pop();
          }
          std::cout << 'n';
      }
  1. 让我们编译并运行我们的程序。确实,它告诉我们,首先做家庭作业,洗完碗后,我们最终可以看电视和看漫画。
      $ ./main
      2: do homework
      1: dishes
      0: watch tv
      0: read comics

它是如何工作的...

std::priority列表非常容易使用。我们只使用了三个函数:

  1. q.push(item)将项目推入队列。

  2. q.top()返回队列中首先出队的项目的引用。

  3. q.pop()移除队列中最前面的项目。

但是项目的排序是如何工作的?我们将优先级整数和待办事项描述字符串分组到一个std::pair中,并获得自动排序。如果我们有一个std::pair<int, std::string>实例p,我们可以写p.first来访问整数部分,p.second来访问字符串部分。我们在循环中做到了这一点,打印出所有的待办事项。

但是,优先队列是如何推断出{2, "做家庭作业"}{0, "看电视"}更重要的,而不是我们告诉它比较数字部分?

比较运算符<处理不同的情况。假设我们比较left < rightleftright是一对。

  1. left.first != right.first,然后返回left.first < right.first

  2. left.first == right.first,然后返回left.second < right.second

这样,我们可以按需订购物品。唯一重要的是,优先级是对的成员,描述是对的第二成员。否则,std::priority_queue会以一种看起来字母顺序比优先级更重要的方式对项目进行排序。(在这种情况下,看电视会被建议作为第一件事情做,做家庭作业稍后一些时间。这对于我们这些懒惰的人来说至少是很好的!)

第二十三章:迭代器

本章中涵盖以下内容:

  • 构建自己的可迭代范围

  • 使自己的迭代器与 STL 迭代器类别兼容

  • 使用迭代器包装器填充通用数据结构

  • 按迭代器实现算法

  • 使用反向迭代器适配器进行反向迭代

  • 使用迭代器哨兵终止范围上的迭代

  • 使用检查迭代器自动检查迭代器代码

  • 构建自己的 zip 迭代器适配器

介绍

迭代器在 C++ 中是一个非常重要的概念。STL 的目标是尽可能灵活和通用,而迭代器在这方面非常有帮助。不幸的是,它们有时候使用起来有点乏味,这就是为什么许多新手会避免使用它们并退回到 C-Style C++ 的原因。一个避免使用迭代器的程序员基本上放弃了 STL 的一半潜力。本章涉及迭代器并快速地介绍了它们的工作原理。这种非常快速的介绍可能不够,但 配方 真的是为了让人对迭代器内部有一个良好的感觉。

大多数容器类,但也包括老式的 C 风格数组,以某种方式都包含一组数据项的 范围。许多日常任务处理大量数据项时并不关心如何获取这些数据。然而,如果我们考虑,例如,一个整数数组和一个整数 链表 并想要计算这两种结构中所有项目的 总和,我们最终会得到两种不同的算法,可能看起来像下面这样:

  • 一个处理数组并检查其大小并将其求和的算法如下:
      int sum {0};
      for (size_t i {0}; i < array_size; ++i) { sum += array[i]; }
  • 另一个算法,通过迭代链表直到达到其末尾:
      int sum {0};
      while (list_node != nullptr) { 
          sum += list_node->value; list_node = list_node->next; 
      }

它们都是关于 整数求和,但我们输入的字符中有多少是直接与 实际 求和任务相关的?其中一个是否适用于第三种数据结构,比如 std::map,还是我们必须实现另一个版本?没有迭代器,这将使我们陷入荒谬的方向。

只有通过迭代器的帮助,才能以通用形式实现这一点:

int sum {0};
for (int i : array_or_vector_or_map_or_list) { sum += i; }

这种漂亮而简短的所谓基于范围的 for 循环自 C++11 以来就存在了。它只是一种语法糖,类似于以下代码:

{ 
    auto && __range = array_or_vector_or_map_or_list ; 
    auto __begin = std::begin(__range);
    auto __end   = std::end(__range);
    for ( ; __begin != __end; ++__begin) { 
        int i = *__begin; 
        sum += i;
    } 
}

对于那些已经使用过迭代器的人来说,这已经是老生常谈了,但对于那些没有使用过的人来说,这看起来完全像是魔术。想象一下我们的整数向量看起来像下面这样:

std::begin(vector) 命令与 vector.begin() 相同,返回一个指向第一个项目(1)的迭代器。std::end(vector)vector.end() 相同,返回一个指向最后一个项目之后一个项目的迭代器(5之后)。

在每次迭代中,循环都会检查起始迭代器是否不等于结束迭代器。如果是这样,它将 解引用 起始迭代器,从而访问它指向的整数值。然后,它 递增 迭代器,重复与结束迭代器的比较,依此类推。在这一刻,帮助阅读循环代码时想象迭代器就是普通的 C 风格指针。事实上,普通的 C 风格指针也是一种有效的迭代器。

迭代器类别

迭代器有多个类别,它们有不同的限制。它们并不难记忆,只需记住一个类别所需的功能是从下一个更强大的类别继承的。迭代器类别的整个重点在于,如果算法知道它正在处理哪种类型的迭代器,它可以以一种优化的方式实现。这样,程序员可以放松并表达自己的意图,而编译器可以选择给定任务的 最佳实现

让我们按正确的顺序来看一下:

输入迭代器

输入迭代器只能被解引用来读取它们指向的值。一旦它们被增加,它们指向的最后一个值在增加过程中被失效。这意味着不可能多次迭代这样的范围。std::istream_iterator就是这一类的例子。

前向迭代器

前向迭代器与输入迭代器相同,但它们的区别在于它们表示的范围可以被多次迭代。std::forward_list的迭代器就是一个例子。这样的列表只能向前迭代,不能向后,但可以随意多次迭代。

双向迭代器

双向迭代器,顾名思义,可以被增加和减少,以便向前或向后迭代。例如,std::liststd::setstd::map的迭代器支持这一点。

随机访问迭代器

随机访问迭代器允许一次跳过多个值,而不是逐个步进。这适用于std::vectorstd::deque的迭代器。

连续迭代器

这个类别指定了前面提到的所有要求,还要求被迭代的数据位于连续的内存中,就像在数组或std::vector中一样。

输出迭代器

输出迭代器与其他类别无关。这是因为迭代器可以是纯输出迭代器,只能被增加并用于写入它指向的数据。如果它们被读取,值将是未定义的。

可变迭代器

如果一个迭代器既是输出迭代器又是其他类别之一,它就是可变迭代器。它可以被读取和写入。如果我们从一个非 const 容器实例中获取迭代器,它通常会是这种类型。

构建自己的可迭代范围

我们已经意识到迭代器在各种容器上进行迭代时,有点像标准接口。我们只需要实现前缀增量运算符++、解引用运算符*和对象比较运算符==,然后我们就已经有了一个原始迭代器,可以适应时髦的 C++11 基于范围的for循环。

为了更好地适应这一点,这个示例展示了如何实现一个迭代器,当通过迭代时只发出一系列数字。它不依赖于任何容器结构或类似的东西。这些数字是在迭代时临时生成的。

如何做...

在这个示例中,我们将实现自己的迭代器类,然后通过它进行迭代:

  1. 首先,我们包含头文件,这样我们就可以打印到终端:
      #include <iostream>
  1. 我们的迭代器类将被称为num_iterator
      class num_iterator {
  1. 它唯一的数据成员是一个整数。该整数用于计数。构造函数用于初始化它。通常最好将构造函数设为显式,这样可以避免意外的隐式转换。请注意,我们还为position提供了默认值。这使得num_iterator类的实例可以默认构造。尽管在整个示例中我们不会使用默认构造函数,但这真的很重要,因为一些 STL 算法依赖于迭代器是默认可构造的:
          int i;
      public:

          explicit num_iterator(int position = 0) : i{position} {}
  1. 当解引用我们的迭代器(*it)时,它将发出一个整数:
          int operator*() const { return i; }
  1. 增加迭代器(++it)只会增加它的内部计数器i
          num_iterator& operator++() {
              ++i;
              return *this;
          }
  1. for循环将迭代器与结束迭代器进行比较。如果它们不相等,它将继续迭代:
          bool operator!=(const num_iterator &other) const {
              return i != other.i;
          }
      };
  1. 这就是迭代器类。我们仍然需要一个中间对象来编写for (int i : intermediate(a, b)) {...},然后包含开始和结束迭代器,它被预设为从ab进行迭代。我们称之为num_range
      class num_range {
  1. 它包含两个整数成员,表示迭代应该从哪个数字开始,以及第一个数字过去的数字是多少。这意味着如果我们想要从09进行迭代,a设置为0b设置为10
          int a;
          int b;

      public:
          num_range(int from, int to)
              : a{from}, b{to}
          {}
  1. 我们只需要实现两个成员函数:beginend函数。两者都返回指向数字范围开始和结束的迭代器:
          num_iterator begin() const { return num_iterator{a}; }
          num_iterator end()   const { return num_iterator{b}; }
      };
  1. 就是这样。我们可以使用它。让我们编写一个主函数,它只是迭代从100109的范围,并打印出所有的值:
      int main()
      {
          for (int i : num_range{100, 110}) {
              std::cout << i << ", ";
          }
          std::cout << 'n';
      }
  1. 编译和运行程序产生以下终端输出:
      100, 101, 102, 103, 104, 105, 106, 107, 108, 109,

工作原理...

考虑我们编写以下代码:

for (auto x : range) { code_block; }

编译器将对其求值为以下内容:

{ 
    auto __begin = std::begin(range);
    auto __end   = std::end(range);
    for ( ; __begin != __end; ++__begin) { 
        auto x = *__begin; 
        code_block
    } 
}

在查看这段代码时,很明显迭代器的唯一要求是以下三个运算符:

  • operator!=:不相等比较

  • operator++:前缀递增

  • operator*:解引用

范围的要求是它有一个begin和一个end方法,返回两个迭代器,表示范围的开始和结束。

在本书中,我们大多数时候使用std::begin(x)而不是x.begin()。这通常是一个很好的风格,因为std::begin(x)会自动调用x.begin(),如果该成员方法可用。如果x是一个没有begin()方法的数组,std::begin(x)会自动找出如何处理它。对std::end(x)也是一样。不提供begin()/end()成员的用户定义类型无法使用std::begin/std::end

在这个食谱中,我们所做的只是将一个简单的数字计数算法适应到前向迭代器接口中。实现迭代器和范围总是涉及到这最少量的样板代码,这在一方面可能有点烦人。另一方面,查看使用num_range的循环是非常有益的,因为它看起来如此完美简单

回头仔细看看迭代器和范围类的方法中哪些是const。忘记使这些函数const可能会使编译器在许多情况下拒绝您的代码,因为迭代const对象是一件很常见的事情。

使自己的迭代器与 STL 迭代器类别兼容

无论我们想出什么自己的容器数据结构,为了有效地混合它与所有 STL 的优点,我们需要使它们提供迭代器接口。在上一节中,我们学会了如何做到这一点,但我们很快意识到一些STL 算法无法与我们的自定义迭代器很好地编译。为什么?

问题在于很多 STL 算法试图找出更多关于它们被我们要求处理的迭代器的信息。不同的迭代器类别具有不同的功能,因此,可能有不同的可能性来实现相同的算法。例如,如果我们从一个std::vector复制普通数字到另一个,这可能是通过快速的memcpy调用来实现的。如果我们从std::list复制数据,这就不再可能了,项目必须一个接一个地逐个复制。STL 算法的实现者对这种自动优化进行了大量思考。为了帮助他们,我们可以为我们的迭代器提供一些关于它们的信息。本节展示了如何实现相同的功能。

如何做...

在本节中,我们将实现一个原始迭代器,计数数字并将其与最初无法与之一起编译的 STL 算法一起使用。然后我们做必要的工作使其与 STL 兼容。

  1. 首先,我们需要像往常一样包含一些头文件:
      #include <iostream>
      #include <algorithm>
  1. 然后我们实现一个原始的数字计数迭代器,就像前一节一样。在对其进行迭代时,它将发出普通递增的整数。num_range充当一个方便的beginend迭代器捐赠者:
      class num_iterator 
      {
          int i;
      public:

          explicit num_iterator(int position = 0) : i{position} {}

          int operator*() const { return i; }

          num_iterator& operator++() {
              ++i;
              return *this;
          }

          bool operator!=(const num_iterator &other) const {
              return i != other.i;
          }

          bool operator==(const num_iterator &other) const {
              return !(*this != other); 
          }
      };

      class num_range {
          int a;
          int b;

      public:
          num_range(int from, int to)
              : a{from}, b{to}
          {}

          num_iterator begin() const { return num_iterator{a}; }
          num_iterator end()   const { return num_iterator{b}; }
      };
  1. 为了使std::命名空间前缀保持在外部并保持代码可读性,我们声明使用std命名空间:
      using namespace std;
  1. 现在让我们实例化一个范围,从100109。请注意,值110是结束迭代器的位置。这意味着110是范围之外的第一个数字(这就是为什么它从100109):
      int main()
      {
          num_range r {100, 110};
  1. 现在,我们使用std::minmax_element。这个算法返回一个std::pair,其中包含两个成员:指向范围中最低值的迭代器和指向最高值的迭代器。当然,这些值是100109,因为这是我们构造范围的方式:
          auto [min_it, max_it] (minmax_element(begin(r), end(r)));
          cout << *min_it << " - " << *max_it << 'n';
      }
  1. 编译代码会导致以下错误消息。这是与std::iterator_traits相关的错误。稍后会详细介绍。可能会发生其他编译器和/或 STL 库实现的错误,或者根本没有错误。这个错误消息出现在 clang 版本 5.0.0(trunk 299766)中:

  1. 为了解决这个问题,我们需要为我们的迭代器类激活迭代器特性功能。在num_iterator的定义之后,我们编写了std::iterator_traits类型的以下模板结构专门化。它告诉 STL 我们的num_iterator是前向迭代器类别,并且它迭代int值:
      namespace std {
        struct iterator_traits<num_iterator> {

          using iterator_category = std::forward_iterator_tag;

          using value_type = int;

          using difference_type = void;

          using pointer = int*;

          using reference = int&;

        };
      }
  1. 让我们再次编译它;我们可以看到它工作了!min/max 函数的输出如下,这正是我们期望的:
      100 - 109

它是如何工作的...

一些 STL 算法需要了解它们所使用的迭代器类型的特性。其他一些需要知道迭代器迭代的项目类型。这有不同的实现原因。

然而,所有 STL 算法将通过std::iterator_traits<my_iterator>访问此类型信息,假设迭代器类型为my_iterator。这个特性类包含多达五种不同的类型成员定义:

  • difference_type:写it1 - it2的结果是什么类型?

  • value_type:我们使用*it访问的项目是什么类型(请注意,对于纯输出迭代器,这是void)?

  • pointer:为了指向一个项目,指针必须是什么类型?

  • reference:为了引用一个项目,引用必须是什么类型?

  • iterator_category:迭代器属于哪个类别?

pointerreferencedifference_type类型定义对于我们的num_iterator来说是没有意义的,因为它不迭代真正的内存值(我们只是返回int值,但它们不像数组中那样持久可用)。因此最好不定义它们,因为如果算法依赖于这些项目在内存中可引用,当与我们的迭代器结合时可能会出现错误

还有更多...

直到 C17,鼓励让迭代器类型直接继承自std::iterator<...>,这会自动填充我们的类所有类型定义。这仍然有效,但自 C17 以来已不再鼓励。

使用迭代器适配器填充通用数据结构

在许多情况下,我们希望用大量数据填充任何容器,但数据源和容器没有共同的接口。在这种情况下,我们需要编写自己的手工制作的算法,只是处理如何将数据从源推送到接收端的问题。通常,这会让我们分心,无法专注于解决特定问题的实际工作。

我们可以用一行代码实现在概念上不同的数据结构之间传输数据的任务,这要归功于 STL 提供的另一个抽象:迭代器适配器。本节演示了如何使用其中一些迭代器适配器,以便让人感受到它们有多么有用。

如何做...

在本节中,我们使用一些迭代器包装器,只是为了展示它们的存在以及它们如何帮助我们在日常编程任务中。

  1. 我们需要首先包含一些头文件:
      #include <iostream>
      #include <string>
      #include <iterator>
      #include <sstream>
      #include <deque>
  1. 声明我们使用命名空间std可以减少我们以后的输入:
      using namespace std;
  1. 我们从std::istream_iterator开始。我们将其专门化为int。这样,它将尝试将标准输入解析为整数。例如,如果我们对其进行迭代,它看起来就像是std::vector<int>。结束迭代器也是用相同类型实例化的,但没有任何构造参数:
      int main()
      {
          istream_iterator<int> it_cin {cin};
          istream_iterator<int> end_cin;
  1. 接下来,我们实例化std::deque<int>,并将所有整数从标准输入复制到 deque 中。deque 本身不是一个迭代器,所以我们使用std::back_inserter辅助函数将其包装成std::back_insert_iterator。这个特殊的迭代器包装器将对我们从标准输入获取的每个项目执行v.push_back(item)。这样,deque 会自动增长!
          deque<int> v;

          copy(it_cin, end_cin, back_inserter(v));
  1. 在下一个练习中,我们使用std::istringstream将项目复制到 deque 的中间。因此,让我们首先定义一些示例数字,以字符串的形式实例化流对象:
          istringstream sstr {"123 456 789"};
  1. 然后,我们需要一个提示,告诉我们在 deque 中插入的位置。这将是中间,所以我们使用 deque 的 begin 指针并将其传递给std::next函数。这个函数的第二个参数表示它将返回一个迭代器,向前移动了v.size() / 2步,也就是 deque 的一半。(我们将v.size()强制转换为int,因为std::next的第二个参数是作为第一个参数使用的迭代器的difference_type。在这种情况下,这是一个有符号整数类型。根据编译器标志,如果我们没有显式转换,编译器可能会在这一点警告。)
          auto deque_middle (next(begin(v), 
                                  static_cast<int>(v.size()) / 2));
  1. 现在,我们可以逐步将解析的整数从输入字符串流复制到 deque 中。再次强调,流迭代器包装器的结束迭代器只是一个没有构造参数的空的std::istream_iterator<int>(即代码行中的空{}括号)。deque 被包装成插入器包装器,它是一个std::insert_iterator,使用deque_middle迭代器指向 deque 的中间:
          copy(istream_iterator<int>{sstr}, {}, inserter(v, deque_middle));
  1. 现在,让我们使用std::front_insert_iterator在 deque 的前面插入一些项目:
          initializer_list<int> il2 {-1, -2, -3};
          copy(begin(il2), end(il2), front_inserter(v));
  1. 在最后一步,我们将整个 deque 的内容打印到用户 shell 上。std::ostream_iterator的工作原理类似于输出迭代器,在我们的例子中,它只是将所有从中复制的整数转发到std::cout,然后在每个项目后附加", "
          copy(begin(v), end(v), ostream_iterator<int>{cout, ", "});
          cout << 'n';
      }
  1. 编译并运行程序会产生以下输出。你能辨别出哪个数字是由哪行代码插入的吗?
      $ echo "1 2 3 4 5" | ./main
      -3, -2, -1, 1, 2, 123, 456, 789, 3, 4, 5,

它的工作原理...

在本节中,我们使用了许多不同的迭代器适配器。它们都有一个共同点,就是它们将一个对象包装成一个不是迭代器本身的迭代器。

std::back_insert_iterator

back_insert_iterator可以包装std::vectorstd::dequestd::list等。它将调用容器的push_back方法,将新项目插入到现有项目之后。如果容器实例不够大,它将自动增长。

std::front_insert_iterator

front_insert_iteratorback_insert_iterator完全做相同的事情,但它调用容器的push_front方法,这会在所有现有项目之前插入新项目。请注意,对于像std::vector这样的容器,这意味着所有现有项目都需要向前移动一个位置,以便为前面的新项目留出空间。

std::insert_iterator

这个迭代器适配器类似于其他插入器,但能够在现有项目之间插入新项目。构造这样一个包装器的std::inserter辅助函数需要两个参数。第一个参数是容器,第二个参数是指向新项目应该插入的位置的迭代器。

std::istream_iterator

istream_iterator是另一个非常方便的适配器。它可以与任何std::istream对象一起使用(例如标准输入或文件),并将尝试根据实例化时的模板参数从该流对象中解析输入。在本节中,我们使用了std::istream_iterator<int>(std::cin),它从标准输入中提取整数。

流的特殊之处在于我们通常无法预先知道流的长度。这就引出了一个问题,如果我们不知道流的结束在哪里,结束迭代器将指向哪里?它的工作方式是,迭代器知道当它到达流的末尾时。当它与结束迭代器进行比较时,它实际上不会真正与结束迭代器进行比较,而是返回流是否还有标记剩余。这就是为什么结束迭代器的构造函数不接受任何参数。

std::ostream_iterator

ostream_iteratoristream_iterator相同,但工作方式相反:它不从输入流中获取标记,而是将标记推送到输出流中。与istream_iterator的另一个不同之处在于,它的构造函数接受第二个参数,该参数是一个字符串,应在每个项目后推送到输出流中。这很有用,因为这样我们可以在每个项目后打印一个分隔符", "或一个新行。

以迭代器实现算法

迭代器通常通过移动它们的位置从容器的一个项目迭代到另一个项目。但它们不一定需要在数据结构上进行迭代。迭代器也可以用于实现算法,在这种情况下,它们在递增(++it)时计算下一个值,并在解引用(*it)时返回该值。

在本节中,我们通过实现迭代器形式的斐波那契函数来演示这一点。斐波那契函数的递归定义如下:F(n) = F(n - 1) + F(n - 2)。它从F(0) = 0F(1) = 1的初始值开始。这导致以下数字序列:

  • F(0) = 0

  • F(1) = 1

  • F(2) = F(1) + F(0) = 1

  • F(3) = F(2) + F(1) = 2

  • F(4) = F(3) + F(2) = 3

  • F(5) = F(4) + F(3) = 5

  • F(6) = F(5) + F(4) = 8

  • ...等等

如果我们以可调用函数的形式实现这一点,该函数将返回任何数字n的斐波那契值,我们最终将得到一个递归自调用函数,或者一个循环实现。这没问题,但是如果我们编写一些程序,需要按某种模式消耗斐波那契数,一个接一个地,我们将有两种可能性——要么我们为每个新的斐波那契数重新计算所有递归调用,这是一种浪费计算时间的做法,要么我们保存最后两个斐波那契数作为临时变量,并使用它们来计算下一个。在后一种情况下,我们重新实现了斐波那契算法的循环实现。看起来我们最终会混合斐波那契代码和解决不同问题的实际代码:

size_t a {0};
size_t b {1};

for (size_t i {0}; i < N; ++i) {
    const size_t old_b {b};
    b += a;
    a  = old_b;

    // do something with b, which is the current fibonacci number
}

迭代器是解决这个问题的一个有趣方法。我们可以将基于循环的迭代式斐波那契实现中的步骤包装在斐波那契值迭代器的前缀递增++运算符实现中。正如本节所示,这是非常容易的。

如何做...

在本节中,我们专注于实现一个在迭代过程中生成斐波那契数列数字的迭代器。

  1. 为了能够将斐波那契数打印到终端,我们首先需要包含一个头文件:
      #include <iostream>
  1. 我们称斐波那契迭代器为fibit。它将携带一个成员i,用于保存斐波那契序列中的索引位置,ab将是保存最后两个斐波那契值的变量。如果使用默认构造函数实例化,斐波那契迭代器将初始化为值F(0)
      class fibit
      {
          size_t i {0};
          size_t a {0};
          size_t b {1};
  1. 接下来,我们定义标准构造函数和另一个构造函数,它允许我们在任何斐波那契数步骤上初始化迭代器:
      public:
          fibit() = default;

          explicit fibit(size_t i_)
              : i{i_}
          {}
  1. 当解引用我们的迭代器(*it)时,它将只输出当前步骤的斐波那契数:
          size_t operator*() const { return b; }
  1. 在增加迭代器(++it)时,它将将其状态移动到下一个斐波那契数。这个函数包含与基于循环的斐波那契实现相同的代码:
          fibit& operator++() {
              const size_t old_b {b};
              b += a;
              a = old_b;
              ++i;
              return *this;
          }
  1. 在循环中使用时,增加的迭代器与结束迭代器进行比较,因此我们需要定义!=运算符。我们只比较斐波那契迭代器当前所在的步骤,这样可以更容易地为步骤1000000定义结束迭代器,例如,因为我们不需要提前昂贵地计算这么高的斐波那契数:
          bool operator!=(const fibit &o) const { return i != o.i; }
      };
  1. 为了能够在基于范围的for循环中使用斐波那契迭代器,我们必须事先实现一个范围类。我们称之为fib_range,它的构造函数将接受一个参数,告诉我们要在斐波那契范围内迭代多远:
      class fib_range
      {
          size_t end_n;

      public:
          fib_range(size_t end_n_)
              : end_n{end_n_}
          {}
  1. 它的beginend函数返回指向位置F(0)F(end_n)的迭代器:
          fibit begin() const { return fibit{}; }
          fibit end()   const { return fibit{end_n}; }
      };
  1. 好了,现在让我们忘记所有与迭代器相关的样板代码。因为我们现在有了一个辅助类,可以很好地隐藏所有的实现细节!让我们打印前 10 个斐波那契数:
      int main()
      {
          for (size_t i : fib_range(10)) {
              std::cout << i << ", ";
          }
          std::cout << 'n';
      }
  1. 编译和运行程序会产生以下 shell 输出:
      1, 1, 2, 3, 5, 8, 13, 21, 34, 55,

还有更多...

为了能够在 STL 中使用这个迭代器,它必须支持std::iterator_traits类。要了解如何做到这一点,请看其他的食谱,它处理了这个问题:使您自己的迭代器与 STL 迭代器类别兼容

试着以迭代器的方式思考。这在许多情况下会导致非常优雅的代码。不要担心性能:编译器发现优化掉与迭代器相关的样板代码是微不足道的!

为了保持示例简单,我们没有做任何处理,但如果我们将斐波那契迭代器发布为库,就会发现它存在一个可用性缺陷--使用构造函数参数创建的fibit实例只能用作结束迭代器,因为它不包含有效的斐波那契值。我们的小型库不强制这种用法。有不同的可能性来解决这个问题:

  • fibit(size_t i_)构造函数设置为私有,并将fib_range类声明为fibit类的友元。这样,用户只能以正确的方式使用它。

  • 使用迭代器哨兵来防止用户解引用结束迭代器。看看我们介绍的那个食谱:使用迭代器哨兵终止范围上的迭代

使用反向迭代器适配器进行反向迭代

有时,逆向迭代一个范围是有价值的,不是向前,而是向后。基于范围的for循环,以及所有 STL 算法通常通过递增迭代器来迭代给定的范围,尽管向后迭代需要递减它们。当然,可以将迭代器包装成一个层,将递增调用有效地转换为递减调用。这听起来像是为我们想要支持的每种类型编写大量样板代码。

STL 提供了一个有用的反向迭代器适配器,可以帮助我们设置这样的迭代器。

如何做...

在这一部分,我们将以不同的方式使用反向迭代器,只是为了展示它们的用法:

  1. 我们首先需要包含一些头文件,就像往常一样:
      #include <iostream>
      #include <list>
      #include <iterator>
  1. 接下来,我们声明我们使用std命名空间,以节省一些输入。
      using namespace std;
  1. 为了有一些可以迭代的东西,让我们实例化一个整数列表:
      int main()
      {
          list<int> l {1, 2, 3, 4, 5};
  1. 现在让我们以相反的形式打印这些整数。为了做到这一点,我们使用std::listrbeginrend函数来遍历列表,并通过标准输出使用方便的ostream_iterator适配器将这些值输出:
          copy(l.rbegin(), l.rend(), ostream_iterator<int>{cout, ", "});
          cout << 'n';
  1. 如果一个容器没有提供方便的rbeginrend函数,但至少提供了双向迭代器,std::make_reverse_iterator函数会提供帮助。它接受普通迭代器并将它们转换为反向迭代器:
          copy(make_reverse_iterator(end(l)),
               make_reverse_iterator(begin(l)),
               ostream_iterator<int>{cout, ", "});
          cout << 'n';
      }
  1. 编译和运行我们的程序会产生以下输出:
      5, 4, 3, 2, 1, 
      5, 4, 3, 2, 1,

它是如何工作的...

为了能够将普通迭代器转换为反向迭代器,它必须至少支持双向迭代。这个要求由双向类别或更高级别的任何迭代器都可以满足。

反向迭代器有点像包含一个普通迭代器并完全模拟其接口,但它将增量操作重定向为减量操作。

下一个细节是关于开始和结束迭代器位置。让我们看一下下面的图表,它显示了一个可迭代范围中保留的标准数字序列。如果序列从15,那么开始迭代器必须指向元素1,结束迭代器必须指向5之后的一个元素:

在定义反向迭代器时,rbegin迭代器必须指向5rend迭代器必须指向1之前的元素。把书倒过来看,就会完全合理。

如果我们希望我们自己的自定义容器类支持反向迭代,我们不需要自己实现所有这些细节;我们可以使用std::make_reverse_iterator辅助函数将普通迭代器包装成反向迭代器,它会为我们执行所有的操作重定向和偏移校正。

使用迭代器标记终止范围的迭代

STL 算法和基于范围的for循环都假设迭代的开始和结束位置是预先已知的。然而,在某些情况下,很难在迭代到达之前知道结束位置。

一个非常简单的例子是迭代普通的 C 风格字符串,其长度在运行时之前是未知的。通常迭代这样的字符串的代码看起来像这样:

for (const char *c_ponter = some_c_string; *c_pointer != ''; ++c_pointer) {
    const char c = *c_pointer;
    // do something with c
}

将其放入基于范围的for循环的唯一方法似乎是将其包装成一个std::string,它有begin()end()函数:

for (char c : std::string(some_c_string)) { /* do something with c */ }

然而,std::string的构造函数将在我们的for循环可以迭代它之前迭代整个字符串。自 C++17 以来,我们也有std::string_view,但它的构造函数也会遍历字符串一次。对于字符串来说,这不值得真正的麻烦,但这也只是一个在其他情况中可能值得麻烦的问题的例子。当std::istream_iteratorstd::cin中捕获输入时,它也必须处理这个问题,因为它的结束迭代器在用户仍在输入键时实际上不能指向用户输入的结尾。

C++17 带来了一个伟大的消息,即它不限制开始和结束迭代器必须是相同类型。本节演示了如何将这个小规则变更发挥到极大作用

如何做...

在本节中,我们将一起构建一个迭代器和一个范围类,它使我们能够在不提前找到结束位置的情况下迭代一个未知长度的字符串。

  1. 首先,像往常一样,我们需要包含头文件:
      #include <iostream>
  1. 迭代器标记是本节的一个非常核心的元素。令人惊讶的是,它的类定义可以完全为空:
      class cstring_iterator_sentinel {};
  1. 现在我们实现迭代器。它将包含一个字符串指针,这是我们要迭代的容器
      class cstring_iterator {
          const char *s {nullptr};
  1. 构造函数只是将内部字符串指针初始化为用户提供的任何字符串。让我们将构造函数声明为显式的,以防止从字符串到字符串迭代器的意外隐式转换:
      public:
          explicit cstring_iterator(const char *str)
              : s{str}
          {}
  1. 在某个位置对迭代器进行解引用时,它只会返回该位置的字符值:
          char operator*() const { return *s; }
  1. 递增迭代器只是递增字符串中的位置:
          cstring_iterator& operator++() {
              ++s;
              return *this;
          }
  1. 这是有趣的部分。我们为比较实现了!=运算符,因为它被 STL 算法和基于范围的for循环使用。然而,这一次,我们不是为迭代器与其他迭代器的比较实现它,而是为迭代器与哨兵的比较实现它。当我们将一个迭代器与另一个迭代器进行比较时,我们只能检查它们的内部字符串指针是否都指向相同的地址,这有些限制。通过与空的哨兵对象进行比较,我们可以执行完全不同的语义——我们检查迭代器指向的字符是否是终止''字符,因为这代表了字符串的结束
          bool operator!=(const cstring_iterator_sentinel) const {
              return s != nullptr && *s != '';
          }
      };
  1. 为了在基于范围的for循环中使用它,我们需要一个围绕它的范围类,它会发出开始和结束的迭代器:
      class cstring_range {
          const char *s {nullptr};
  1. 用户在实例化期间唯一需要提供的是要迭代的字符串:
      public:
          cstring_range(const char *str)
              : s{str}
          {}
  1. 我们从begin()函数返回一个普通的cstring_iterator,它指向字符串的开头。从end()函数返回的只是哨兵类型。请注意,如果没有哨兵类型,我们也会返回一个迭代器,但是我们怎么知道字符串的末尾呢,如果我们没有提前搜索呢?
          cstring_iterator begin() const { 
              return cstring_iterator{s}; 
          }
          cstring_iterator_sentinel end() const { 
              return {}; 
          }
      };
  1. 就是这样。我们可以立即使用它。来自用户的字符串是我们无法提前知道长度的输入的一个例子。为了强制用户在启动程序时至少提供一个参数,如果用户没有在 shell 中启动程序时提供至少一个参数,我们将中止程序:
      int main(int argc, char *argv[])
      {
          if (argc < 2) {
              std::cout << "Please provide one parameter.n";
              return 1;
          }
  1. 如果程序到这一点仍在执行,那么我们知道argv[1]包含一些用户字符串:
          for (char c : cstring_range(argv[1])) {
              std::cout << c;
          }
          std::cout << 'n';
      }
  1. 编译和运行程序会产生以下输出:
      $ ./main "abcdef"
      abcdef

循环打印我们刚刚输入的内容并不奇怪,因为这只是一个关于基于哨兵的迭代器范围实现的微型示例。这种迭代终止方法将帮助您在遇到与结束位置比较方法无法帮助的情况下实现自己的迭代器时。

使用检查迭代器自动检查迭代器代码

无论迭代器有多有用,以及它们代表的通用接口是什么,迭代器都很容易被误用,就像指针一样。在处理指针时,代码必须以一种方式编写,即当指向无效内存位置时永远不会取消引用它们。迭代器也是一样,但是有很多规则规定了迭代器何时有效以及何时失效。通过稍微研究 STL 文档,这些规则可以很容易地学习到,但仍然有可能编写错误的代码。

在最好的情况下,这种错误的代码会在测试时在开发人员面前爆炸,而不是在客户的机器上。然而,在许多情况下,代码似乎只是默默地工作,尽管它会取消引用悬空指针、迭代器等。在这种情况下,如果我们生成显示未定义行为的代码,我们希望能够及早警告

幸运的是,有帮助!GNU STL 实现有一个调试模式,GNU C编译器以及 LLVM clang C编译器都支持用于为我们生成额外敏感冗长的二进制文件的额外库,这些二进制文件可以立即在各种各样的错误上爆炸。这是易于使用非常有用的,我们将在本节中进行演示。Microsoft Visual C++标准库也提供了激活额外检查的可能性。

如何做...

在本节中,我们将编写一个故意访问无效迭代器的程序:

  1. 首先,我们包括头文件。
      #include <iostream>
      #include <vector>
  1. 现在,让我们实例化一个整数向量,并获得指向第一个项目的迭代器,值为1。我们对向量应用shrink_to_fit(),以确保其容量确实3,因为它的实现可能分配了比必要更多的内存作为未来项目插入的小保留:
      int main()
      {
          std::vector<int> v {1, 2, 3};
          v.shrink_to_fit();

          const auto it (std::begin(v));
  1. 然后我们打印取消引用的迭代器,这是完全正常的:
          std::cout << *it << 'n';
  1. 接下来,让我们向向量追加一个新的数字。由于向量的大小不足以容纳另一个数字,它将自动增加其大小。它通过分配一个新的更大的内存块,将所有现有的项目移动到新的内存块,然后删除内存来实现这一点。
          v.push_back(123);
  1. 现在,让我们再次通过这个迭代器从向量中打印1。这很糟糕。为什么?嗯,当向量将所有的值移动到新的内存块并丢弃旧的内存块时,它没有告诉迭代器这个变化。这意味着迭代器仍然指向旧的位置,我们无法知道自那时起它到底发生了什么:
          std::cout << *it << 'n'; // bad bad bad!
      }
  1. 编译和运行这个程序会导致无缺陷的执行。应用程序不会崩溃,但当取消引用无效的指针时打印的内容几乎是随机的。把它留在这种状态下是非常危险的,但在这一点上,如果我们自己没有看到这个 bug,就没有人告诉我们!

  2. 调试标志来拯救!GNU STL 实现支持一个名为_GLIBCXX_DEBUG的预处理宏,它在 STL 中激活了大量的健全性检查代码。这会使程序变慢,但它找到了 bug。我们可以通过在编译器命令行中添加-D_GLIBCXX_DEBUG标志,或者在include行之前的代码文件头部定义它来激活它。正如你所看到的,它会在激活不同的 sanitizers 时杀死应用程序。让我们用 clang 有用(用于 Microsoft Visual C++编译器的已检查迭代器的激活标志是/D_ITERATOR_DEBUG_LEVEL=1)编译代码!

  3. LLVM/clang 实现的 STL 也有调试标志,但它们用于调试STL本身,而不是用户代码。对于用户代码,你可以激活不同的 sanitizers。让我们使用 clang 编译代码,使用-fsanitize=address -fsanitize=undefined标志,看看会发生什么!

哇,这是一个非常精确的描述出了什么问题。如果没有被截断,这个屏幕截图可能会跨越这本书的多个页面。请注意,这不是 clang 的专属功能,它也适用于 GCC。

如果由于某个库丢失而出现运行时错误,那么你的编译器没有自动提供libasanlibubsan。尝试通过软件包管理器或类似的方式安装它们。

它是如何工作的...

正如我们所看到的,我们不需要改变代码就能获得这种对于有 bug 的代码的触发器功能。它基本上是免费的,只需在编译程序时在命令行中添加一些编译器标志即可。

这个功能是由sanitizers实现的。通常,sanitizer 由一个额外的编译器模块和一个运行时库组成。当 sanitizers 被激活时,编译器会向二进制文件中添加额外的 信息代码,这些信息是来自我们的程序。在运行时,链接到程序二进制文件中的 sanitizer 库可以,例如,替换mallocfree函数,以分析程序如何处理它获取的内存。

Sanitizers 可以检测不同类型的 bug。举几个有价值的例子:

  • 越界:每当我们访问数组、向量或类似的东西超出其合法内存范围时,就会触发这个功能。

  • 释放后使用:如果我们在释放堆内存后引用它,sanitizers 就会触发这个功能(我们在本节中就是这样做的)。

  • 整数溢出:如果整数变量通过计算与不适合该变量的值而溢出,就会触发这个功能。对于有符号整数,算术环绕是未定义行为。

  • 指针对齐:一些架构如果在内存中有奇怪的对齐方式就无法访问内存。

有许多这样的 bug 可以被 sanitizers 检测到。

不可行总是激活所有可用的消毒剂,因为它们会使程序变得更慢。然而,在你的单元测试集成测试中总是激活消毒剂是一个很好的风格。

还有更多...

有很多不同的消毒剂用于不同的 bug 类别,它们仍在不断发展。我们可以和应该在互联网上了解如何改进我们的测试二进制文件。GCC 和 LLVM 项目主页在它们的在线文档页面中列出了它们的消毒能力:

彻底测试消毒剂是每个程序员都应该意识到并且总是应该做的事情。不幸的是,在许多公司中这并不是这样,尽管有 bug 的代码是所有恶意软件计算机病毒的最重要入口点。

当你作为软件开发人员得到一份新工作时,检查你的团队是否真的使用了所有可能的消毒方法。如果没有,你有机会在工作的第一天修复重要且隐蔽的错误!

构建你自己的拉链迭代器适配器

不同的编程语言导致不同的编程风格。这是因为表达事物的方式不同,它们在每种用例的优雅程度上也不同。这并不奇怪,因为每种语言都是根据特定的目标设计的。

一种非常特殊的编程风格是 函数式编程。它与 C 或 C++程序员所习惯的命令式编程有着神奇的不同。虽然这种风格非常不同,但它在许多情况下能够产生极其优雅的代码。

这种优雅的实现之一是公式的实现,比如数学点积。给定两个数学向量,对它们应用点积意味着对向量中相同位置的数字进行成对乘法,然后将所有这些乘积值相加。两个向量(a, b, c) * (d, e, f)的点积是(a * e + b * e + c * f)。当然,我们也可以用 C 和 C++来做。它可能看起来像下面这样:

std::vector<double> a {1.0, 2.0, 3.0};
std::vector<double> b {4.0, 5.0, 6.0};

double sum {0};
for (size_t i {0}; i < a.size(); ++i) {
    sum += a[i] * b[i];
}
// sum = 32.0

那些被认为更加优雅的语言是什么样子的?

Haskell 是一种纯函数式语言,这是你可以用一个神奇的一行代码计算两个向量的点积的方法:

Python 不是一种纯函数式语言,但它在某种程度上支持类似的模式,就像在下一个例子中所看到的那样:

STL 提供了一个特定的算法叫做std::inner_product,它也可以用一行代码解决这个特定的问题。但关键是,在许多其他语言中,这样的代码可以即时用一行代码写出,不需要支持这个确切目的的特定库函数。

不用深入解释这种外来语法,两个例子中的一个重要共同点是神奇的zip函数。它是做什么的?它接受两个向量ab,并将它们转换为一个混合向量。例如:[a1, a2, a3][b1, b2, b3]在被合并在一起时会得到[ (a1, b1), (a2, b2), (a3, b3) ]。仔细看一下;它真的很像拉链拉链一样工作!

相关的一点是现在可以在一个组合范围上进行迭代,可以进行成对的乘法,然后将它们相加到一个累加变量中。在 Haskell 和 Python 的例子中,没有添加任何循环或索引变量的噪音。

不可能使 C++代码与 Haskell 或 Python 一样优雅和通用,但本节解释了如何使用迭代器实现类似魔术,通过实现zip 迭代器。通过特定库更优雅地解决两个向量的点积的示例问题,这超出了本书的范围。但是,本节试图展示迭代器库可以通过提供极其通用的构建块来帮助编写表达力强的代码。

如何做...

在本节中,我们将重新创建来自 Haskell 或 Python 的zip函数。它将被硬编码为double变量的向量,以免分散迭代器机制的注意力。

  1. 首先,我们需要包含一些头文件:
      #include <iostream>
      #include <vector>
      #include <numeric>
  1. 接下来,我们定义zip_iterator类。在遍历zip_iterator范围时,我们将在每次迭代步骤中从两个容器中获得一对值。这意味着我们同时遍历两个容器:
      class zip_iterator {
  1. zip 迭代器需要保存两个迭代器,每个容器一个:
          using it_type = std::vector<double>::iterator;

          it_type it1;
          it_type it2;
  1. 构造函数只是保存我们想要迭代的两个容器的迭代器:
      public:
          zip_iterator(it_type iterator1, it_type iterator2)
              : it1{iterator1}, it2{iterator2}
          {}
  1. 增加 zip 迭代器意味着增加成员迭代器:
          zip_iterator& operator++() {
              ++it1;
              ++it2;
              return *this;
          }
  1. 如果两个 zip 迭代器的成员迭代器都与另一个 zip 迭代器中的对应迭代器不相等,则两个 zip 迭代器是不相等的。通常,人们会使用逻辑或(||)而不是和(&&),但想象一下,范围的长度不相等。在这种情况下,将不可能同时匹配两个结束迭代器。这样,当我们到达任一范围的第一个结束迭代器时,我们可以中止循环:
          bool operator!=(const zip_iterator& o) const {
              return it1 != o.it1 && it2 != o.it2;
          }
  1. 相等比较运算符只是使用其他运算符实现,但否定结果:
          bool operator==(const zip_iterator& o) const {
              return !operator!=(o);
          }
  1. 解引用 zip 迭代器可以访问两个容器在相同位置的元素:
          std::pair<double, double> operator*() const {
              return {*it1, *it2};
          }
      };
  1. 这就是迭代器代码。我们需要使迭代器与 STL 算法兼容,因此我们为此定义了所需的类型特征样板代码。它基本上表示这个迭代器只是一个前向迭代器,在解引用时返回一对双值。虽然在这个示例中我们没有使用difference_type,但 STL 的不同实现可能需要它才能编译:
      namespace std {

      template <>
      struct iterator_traits<zip_iterator> {
         using iterator_category = std::forward_iterator_tag;
         using value_type = std::pair<double, double>;
         using difference_type = long int;
      };

      }
  1. 下一步是定义一个范围类,从其beginend函数返回 zip 迭代器:
      class zipper {
          using vec_type = std::vector<double>;
          vec_type &vec1;
          vec_type &vec2;
  1. 它需要引用两个现有容器,以便从中形成 zip 迭代器:
      public:
          zipper(vec_type &va, vec_type &vb)
              : vec1{va}, vec2{vb}
          {}
  1. beginend函数只是提供开始和结束指针对,以便从中构造 zip 迭代器实例:
          zip_iterator begin() const { 
              return {std::begin(vec1), std::begin(vec2)}; 
          }
          zip_iterator end() const { 
              return {std::end(vec1), std::end(vec2)}; 
          }
      };
  1. 就像 Haskell 和 Python 示例中一样,我们定义了两个double值的向量。我们还在主函数中默认使用std命名空间:
      int main()
      {
          using namespace std;
          vector<double> a {1.0, 2.0, 3.0};
          vector<double> b {4.0, 5.0, 6.0};
  1. zipper 对象将它们组合成一个类似向量的范围,我们可以看到ab值的对:
          zipper zipped {a, b};
  1. 我们将使用std::accumulate来将范围中的所有项相加。我们不能直接这样做,因为这意味着我们要对std::pair<double, double>的实例求和,而这种情况下求和的概念是不被定义的。因此,我们将定义一个辅助 lambda,它接受一对值,将其成员相乘,并将其添加到累加器中。std::accumulate可以很好地处理具有这种签名的 lambda:
          const auto add_product ([](double sum, const auto &p) {
             return sum + p.first * p.second;
          });
  1. 现在,我们将它传递给std::accumulate,以及 zipped 范围的开始和结束迭代器对,以及累加器变量的起始值0.0,最终包含产品的总和:
          const auto dot_product (accumulate(
                  begin(zipped), end(zipped), 0.0, add_product));
  1. 让我们打印点积结果:
          cout << dot_product << 'n';
      }
  1. 编译并运行程序会产生正确的结果:
      32

还有更多...

好吧,这需要很多工作来获得一点点语法糖,而且仍然不像 Haskell 代码那样优雅,而且不费吹灰之力。一个很大的缺陷是我们的小 zip 迭代器的硬编码特性--它只适用于std::vector范围内的双变量。通过一些模板代码和一些类型特征,可以使拉链器更通用。这样,它可以组合列表和向量,或者双端队列和映射,即使它们是专门针对完全不同的容器项类型的。

为了真正正确地使这样的类通用化,需要付出大量的工作和思考。幸运的是,这样的库已经存在。一个流行的非 STL 库是Boost zip_iterator。它非常通用且易于使用。

顺便说一句,如果你来这里是为了看到在 C++中执行点积最优雅的方法,并且并不真的关心 zip-iterators 的概念,你应该看看std::valarray。自己看看:

#include <iostream>
#include <valarray>

int main()
{
    std::valarray<double> a {1.0, 2.0, 3.0};
    std::valarray<double> b {4.0, 5.0, 6.0};

    std::cout << (a * b).sum() << 'n';
}

Ranges 库

有一个非常非常有趣的 C库,支持拉链器和所有其他类型的魔术迭代器适配器、过滤器等等:ranges库。它受到 Boost ranges 库的启发,有一段时间看起来它会进入 C17,但不幸的是,我们将不得不等待下一个标准。这样做的不幸之处在于,它将大大改进用 C++编写富有表现力快速代码的可能性,通过从通用简单的代码块组合复杂功能。

它的文档中有一些非常简单的例子:

  1. 计算从110的所有数字的平方和:
      const int sum = accumulate(view::ints(1)
                               | view::transform([](int i){return i*i;})
                               | view::take(10), 0);
  1. 从数字向量中过滤出所有奇数,并将其余部分转换为字符串:
      std::vector<int> v {1,2,3,4,5,6,7,8,9,10};

      auto rng = v | view::remove_if([](int i){return i % 2 == 1;})
                   | view::transform([](int i){return std::to_string(i);});

      // rng == {"2"s,"4"s,"6"s,"8"s,"10"s};

如果你感兴趣并且等不及下一个 C++标准,可以查看ericniebler.github.io/range-v3/上的 ranges 文档。

第二十四章:Lambda 表达式

本章中我们将涵盖以下内容:

  • 使用 lambda 表达式在运行时定义函数

  • 通过将 lambda 包装到std::function中添加多态性

  • 通过连接组合函数

  • 使用逻辑连接创建复杂的谓词

  • 使用相同的输入调用多个函数

  • 使用std::accumulate和 lambda 实现transform_if

  • 在编译时生成任意输入的笛卡尔积对

介绍

C11 的一个重要新特性是lambda 表达式。在 C14 和 C++17 中,lambda 表达式得到了一些新的添加,使它们变得更加强大。但首先,什么是 lambda 表达式?

Lambda 表达式或 lambda 函数构造闭包。闭包是一个非常通用的术语,用来描述可以像函数一样调用无名对象。为了在 C++ 中提供这样的能力,这样的对象必须实现()函数调用运算符,可以带参数也可以不带参数。在 C++11 之前,构造这样的对象而不使用 lambda 表达式可能看起来像下面这样:

#include <iostream>
#include <string>

int main() {
    struct name_greeter {
        std::string name;

        void operator()() {
            std::cout << "Hello, " << name << 'n'; 
        }
    };

    name_greeter greet_john_doe {"John Doe"};
    greet_john_doe();
}

name_greeter 结构的实例显然携带一个字符串。请注意,这种结构类型和实例都不是无名的,但是 lambda 表达式可以是无名的,我们将会看到。就闭包而言,我们会说它们捕获了一个字符串。当像没有参数的函数一样调用示例实例时,它会打印出"Hello, John Doe",因为我们用这个名字构造了它。

自从 C++11 以来,创建这样的闭包变得更加容易:

#include <iostream>

int main() {
    auto greet_john_doe ([] {
        std::cout << "Hello, John Doen"; 
    });

    greet_john_doe();
}

就是这样。整个name_greeter结构都被一个小小的[] { /* do something */ }构造替代了,这一开始可能看起来有点像魔术,但本章的第一部分将会详细解释它的所有可能变体。

Lambda 表达式对于使代码通用整洁非常有帮助。它们可以作为参数用于非常通用的算法,以便在处理特定用户定义类型时专门化它们的操作。它们还可以用于将工作包装在一起,包括数据,以便在线程中运行,或者只是保存工作并推迟实际执行。自从 C11 推出以来,越来越多的库使用 lambda 表达式,因为它们在 C 中变得非常自然。另一个用例是元编程,因为 lambda 表达式也可以在编译时进行评估。然而,我们不会深入那个方向,因为这会很快超出本书的范围。

本章在很大程度上依赖一些函数式编程模式,这可能对新手或已经有经验但不熟悉这些模式的程序员看起来很奇怪。如果在接下来的示例中看到返回 lambda 表达式的 lambda 表达式,再返回 lambda 表达式,请不要感到沮丧或迷惑得太快。我们正在推动边界,以便为现代 C++ 做准备,在那里函数式编程模式越来越频繁地出现。如果在接下来的示例中看到一些代码看起来有点太复杂,请花点时间去理解它。一旦你通过了这一点,在野外的真实项目中复杂的 lambda 表达式将不再让你困惑。

使用 lambda 表达式在运行时定义函数

使用 lambda 表达式,我们可以封装代码以便以后调用,而且也可能在其他地方调用,因为我们可以复制它们。我们也可以封装代码以便以稍微不同的参数多次调用它,而不必为此实现一个全新的函数类。

Lambda 表达式的语法在 C11 中是全新的,它在接下来的两个标准版本中略有变化,直到 C17。在本节中,我们将看到 lambda 表达式的样子和含义。

如何做...

我们将编写一个小程序,其中我们将使用 lambda 表达式来熟悉它们:

  1. Lambda 表达式不需要任何库支持,但我们将向终端写入消息并使用字符串,因此我们需要这些头文件:
      #include <iostream>
      #include <string>
  1. 这次所有的事情都发生在主函数中。我们定义了两个不带参数并返回整数常量值12的函数对象。请注意,返回语句被大括号{}包围,就像在普通函数中一样,()括号表示无参数函数,是可选的,我们在第二个 lambda 表达式中没有提供它们。但[]括号必须在那里:
      int main()
      {
          auto just_one ( [](){ return 1; } );
          auto just_two ( []  { return 2; } );
  1. 现在,我们可以通过只写它们保存的变量的名称并附加括号来调用这两个函数对象。在这一行中,它们对于读者来说与普通函数是无法区分的:
          std::cout << just_one() << ", " << just_two() << 'n';
  1. 现在让我们忘记这些,定义另一个函数对象,称为plus,因为它接受两个参数并返回它们的和:
          auto plus ( [](auto l, auto r) { return l + r; } );
  1. 这也很容易使用,就像任何其他二进制函数一样。由于我们将其参数定义为auto类型,它将与定义了加法运算符+的任何东西一起工作,就像字符串一样:
          std::cout << plus(1, 2) << 'n';
          std::cout << plus(std::string{"a"}, "b") << 'n';
  1. 我们不需要将 lambda 表达式存储在变量中才能使用它。我们也可以就地定义它,然后在其后面的括号中写入参数(1, 2)
          std::cout 
            << [](auto l, auto r){ return l + r; }(1, 2) 
            << 'n';
  1. 接下来,我们将定义一个闭包,它携带一个整数计数器值。每当我们调用它时,它会增加其计数器值并返回新值。为了告诉它它有一个内部计数器变量,我们在括号内写入count = 0,告诉它有一个初始化为整数值0的变量count。为了允许它修改自己捕获的变量,我们使用mutable关键字,因为否则编译器不会允许它:
          auto counter (
              [count = 0] () mutable { return ++count; }
          );
  1. 现在,让我们调用函数对象五次并打印它返回的值,这样我们以后可以看到递增的数字值:
          for (size_t i {0}; i < 5; ++i) {
              std::cout << counter() << ", ";
          }
          std::cout << 'n';
  1. 我们还可以获取现有变量并通过引用捕获它们,而不是给闭包自己的值副本。这样,捕获的变量可以被闭包递增,但在外部仍然可以访问。为了这样做,我们在括号之间写入&a,其中&表示我们只存储对变量的引用,而不是副本
          int a {0};
          auto incrementer ( [&a] { ++a; } );
  1. 如果这样做有效,那么我们应该能够多次调用这个函数对象,然后观察它是否真的改变了变量a的值:
          incrementer();
          incrementer();
          incrementer();

          std::cout 
            << "Value of 'a' after 3 incrementer() calls: " 
            << a << 'n';
  1. 最后一个例子是柯里化。柯里化意味着我们接受一些参数的函数并将其存储在另一个函数对象中,该函数对象接受更少的参数。在这种情况下,我们存储plus函数并只接受一个参数,然后将其转发给plus函数。另一个参数是值10,我们将其保存在函数对象中。这样,我们得到一个函数,我们称之为plus_ten,因为它可以将该值添加到它接受的单个参数中:
          auto plus_ten ( [=] (int x) { return plus(10, x); } );
          std::cout << plus_ten(5) << 'n';
      }
  1. 在编译和运行程序之前,再次检查代码并尝试预测它将打印到终端的内容。然后运行它并检查实际输出:
      1, 2
      3
      ab
      3
      1, 2, 3, 4, 5, 
      Value of a after 3 incrementer() calls: 3
      15

它是如何工作的...

我们刚刚做的并不是过于复杂--我们添加了数字,并递增和打印它们。我们甚至用一个函数对象连接了字符串,该函数对象被实现为将数字相加。但是对于那些尚不了解 lambda 表达式语法的人来说,这可能看起来很困惑。

所以,让我们首先看一下所有 lambda 表达式的特点:

通常情况下,我们可以省略大部分内容,这样可以节省一些输入,平均情况下,最短的 lambda 表达式可能是[]{}。它不接受任何参数,不捕获任何内容,本质上什么也不做

那么剩下的是什么意思?

捕获列表

指定我们是否以及捕获了什么。有几种形式可以这样做。还有两种懒惰的变体:

  • 如果我们写[=] () {...},我们通过值捕获闭包从外部引用的每个变量,这意味着值会被复制

  • [&] () {...}意味着闭包引用外部的一切都只通过引用捕获,不会导致复制。

当然,我们可以为每个变量单独设置捕获设置。写[a, &b] () {...}意味着我们通过捕获变量a,通过引用捕获b。这是更多的打字工作,但通常更安全,因为我们不能意外地从外部捕获我们不想捕获的东西。

在这个示例中,我们将 lambda 表达式定义为[count=0] () {...}。在这种特殊情况下,我们没有从外部捕获任何变量,而是定义了一个名为count的新变量。它的类型是从我们初始化它的值中推断出来的,即0,所以它是一个int

也可以通过值和引用来捕获一些变量,如:

  • [a, &b] () {...}:这通过复制捕获a,通过引用捕获b

  • [&, a] () {...}:这通过复制捕获a,并通过引用捕获任何其他使用的变量。

  • [=, &b, i{22}, this] () {...}:这通过引用捕获b,通过复制捕获this,用值22初始化一个新变量i,并通过复制捕获任何其他使用的变量。

如果尝试捕获对象的成员变量,不能直接使用[member_a] () {...}。相反,必须捕获this*this

mutable(可选)

如果函数对象应该能够修改它通过复制[=])捕获的变量,必须定义为mutable。这包括调用捕获对象的非 const 方法。

constexpr(可选)

如果将 lambda 表达式明确标记为constexpr,如果不满足constexpr函数的条件,编译器将报错constexpr函数和 lambda 表达式的优势在于,如果它们使用编译时常量参数调用,编译器可以在编译时评估它们的结果。这会导致后期二进制代码量减少。

如果我们没有明确声明 lambda 表达式为constexpr,但它符合要求,它将隐式地成为constexpr。如果我们想要一个 lambda 表达式是constexpr,最好是明确声明,因为编译器会在我们错误时帮助我们报错。

异常属性(可选)

这是指定函数对象在调用时是否能抛出异常并遇到错误情况的地方。

返回类型(可选)

如果我们想要对返回类型有终极控制,可能不希望编译器自动推断它。在这种情况下,我们可以写[] () -> Foo {},告诉编译器我们确实总是返回Foo类型。

通过将 lambda 包装到 std::function 中添加多态性

假设我们想为某种可能会偶尔改变的值编写一个观察者函数,然后通知其他对象;比如气压指示器,或者股票价格,或者类似的东西。每当值发生变化时,应该调用一个观察者对象列表,然后它们做出反应。

为了实现这一点,我们可以在向量中存储一系列观察者函数对象,它们都接受一个int变量作为参数,表示观察到的值。我们不知道这些函数对象在调用新值时具体做什么,但我们也不在乎。

那个函数对象的向量将是什么类型?如果我们捕获具有void f(int);这样签名的函数的指针,那么std::vector<void (*)(int)>类型将是正确的。这实际上也适用于不捕获任何变量的任何 lambda 表达式,例如[](int x) {...}。但是,捕获某些东西的 lambda 表达式实际上是完全不同的类型,因为它不仅仅是一个函数指针。它是一个对象,它将一定数量的数据与一个函数耦合在一起!想想 C++11 之前的时代,当时没有 lambda。类和结构是将数据与函数耦合在一起的自然方式,如果更改类的数据成员类型,您将得到完全不同的类类型。一个向量不能使用相同的类型名称存储完全不同的类型,这是自然的。

告诉用户只能保存不捕获任何东西的观察者函数对象是不好的,因为它非常限制了使用情况。我们如何允许用户存储任何类型的函数对象,只限制调用接口,该接口接受表示将被观察的值的特定参数集?

这一部分展示了如何使用std::function解决这个问题,它可以作为任何 lambda 表达式的多态包装,无论它捕获了什么。

如何做...

在这一部分,我们将创建几个完全不同的 lambda 表达式,它们在捕获的变量类型方面完全不同,但在共同的函数调用签名方面相同。这些将被保存在一个使用std::function的向量中:

  1. 让我们首先做一些必要的包含:
      #include <iostream>
      #include <deque>
      #include <list>
      #include <vector>
      #include <functional>
  1. 我们实现了一个小函数,它返回一个 lambda 表达式。它接受一个容器并返回一个捕获该容器的函数对象。函数对象本身接受一个整数参数。每当该函数对象被提供一个整数时,它将追加该整数到它捕获的容器中:
      static auto consumer (auto &container){
          return [&] (auto value) {
              container.push_back(value);
          };
      }
  1. 另一个小的辅助函数将打印我们提供的任何容器实例:
      static void print (const auto &c)
      {
          for (auto i : c) {
              std::cout << i << ", ";
          }
          std::cout << 'n';
      }
  1. 在主函数中,我们首先实例化了一个deque,一个list和一个vector,它们都存储整数:
      int main()
      {
          std::deque<int>  d;
          std::list<int>   l;
          std::vector<int> v;
  1. 现在我们使用consumer函数与我们的容器实例dlv:我们为这些产生消费者函数对象,并将它们全部存储在一个vector实例中。然后我们有一个存储三个函数对象的向量。这些函数对象每个都捕获一个对容器对象的引用。这些容器对象是完全不同的类型,所以函数对象也是完全不同的类型。尽管如此,向量持有std::function<void(int)>的实例。所有函数对象都被隐式地包装成这样的std::function对象,然后存储在向量中:
          const std::vector<std::function<void(int)>> consumers 
              {consumer(d), consumer(l), consumer(v)};
  1. 现在,我们通过循环遍历值并循环遍历消费者函数对象,将 10 个整数值输入所有数据结构,然后调用这些值:
          for (size_t i {0}; i < 10; ++i) {
              for (auto &&consume : consumers) {
                  consume(i);
              }
          }
  1. 现在所有三个容器应该包含相同的 10 个数字值。让我们打印它们的内容:
          print(d);
          print(l);
          print(v);
      }
  1. 编译和运行程序产生了以下输出,这正是我们所期望的:
      $ ./std_function
      0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 
      0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 
      0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 

它是如何工作的...

这个食谱的复杂部分是以下行:

const std::vector<std::function<void(int)>> consumers 
        {consumer(d), consumer(l), consumer(v)};

对象dlv都被包装到一个consumer(...)调用中。这个调用返回函数对象,然后每个函数对象都捕获了dlv中的一个引用。尽管这些函数对象都接受int值作为参数,但它们捕获完全不同的变量的事实也使它们完全不同的类型。这就像试图将类型为ABC的变量塞进一个向量中,尽管这些类型没有任何共同之处。

为了修复这个问题,我们需要找到一个可以存储非常不同的函数对象的通用类型,也就是std::function。一个std::function<void(int)>对象可以存储任何接受整数参数并返回空的函数对象或传统函数。它使用多态性将其类型与底层函数对象类型分离。考虑我们写这样的东西:

std::function<void(int)> f (
    &vector { vector.push_back(x); });

这里,从 lambda 表达式构造的函数对象被包装到了一个std::function对象中,每当我们调用f(123)时,这将导致一个虚函数调用,它被重定向到其中的实际函数对象。

在存储函数对象时,std::function实例应用了一些智能。如果我们在 lambda 表达式中捕获了越来越多的变量,它必须变得更大。如果它的大小不是太大,std::function可以将其存储在自身内部。如果存储的函数对象的大小太大,std::function将在堆上分配一块内存,然后将大的函数对象存储在那里。这不会影响我们代码的功能,但我们应该知道这一点,因为这可能会影响我们代码的性能

很多新手程序员认为或希望std::function<...>实际上表达了 lambda 表达式的类型。不,它不是。它是一个多态库助手,用于包装 lambda 表达式并擦除它们的类型差异。

通过连接组合函数

很多任务实际上并不值得完全自定义代码来实现。例如,让我们看看程序员如何使用 Haskell 编程语言解决查找文本包含多少个唯一单词的任务。第一行定义了一个名为unique_words的函数,第二行演示了它在一个示例字符串中的使用:

哇,这太简短了!不多解释 Haskell 语法,让我们看看代码做了什么。它定义了一个名为unique_words的函数,它将一系列函数应用于其输入。它首先使用map toLower将输入的所有字符映射为小写。这样,像FOOfoo这样的单词可以被视为相同的单词。然后,words函数将一个句子拆分为单独的单词,例如从"foo bar baz"["foo", "bar", "baz"]。下一步是对新的单词列表进行排序。这样,一个单词序列,比如["a", "b", "a"]就变成了["a", "a", "b"]。现在,group函数接管了。它将连续相同的单词分组成分组列表,所以["a", "a", "b"]变成了[ ["a", "a"], ["b"] ]。工作现在几乎完成了,因为我们现在只需要计算有多少相同的单词,这正是length函数所做的。

这是一种奇妙的编程风格,因为我们可以从右到左读取发生的事情,因为我们只是在描述一个转换管道。我们不需要关心个别部分是如何实现的(除非它们是慢的或有 bug)。

然而,我们在这里不是为了赞扬 Haskell,而是为了提高我们的 C技能。在 C中也可以像这样工作。我们可能无法完全达到 Haskell 示例的优雅,但我们仍然拥有最快的编程语言。这个示例解释了如何使用 lambda 表达式在 C++中模拟函数连接

如何做到...

在这一部分,我们定义了一些简单的玩具函数对象并连接它们,这样我们就得到了一个单一的函数,它将简单的玩具函数依次应用于我们给它的输入。为了做到这一点,我们编写了自己的连接辅助函数:

  1. 首先,我们需要一些包含:
      #include <iostream>
      #include <functional>
  1. 然后,我们实现了辅助函数concat,它任意地接受许多参数。这些参数将是函数,比如fgh,结果将是另一个函数对象,它对任何输入应用f(g(h(...)))
      template <typename T, typename ...Ts>
      auto concat(T t, Ts ...ts)
      {
  1. 现在,它变得有点复杂。当用户提供函数fgh时,我们将将其评估为f( concat(g, h) ),这再次扩展为f( g( concat(h) ) ),递归中止,因此我们得到f( g( h(...) ) )。这些用户函数的连接链被 lambda 表达式捕获,稍后可以接受一些参数p,然后将它们转发到f(g(h(p)))。这个 lambda 表达式就是我们返回的内容。if constexpr构造检查我们是否处于递归步骤中,剩下的要连接的函数多于一个:
          if constexpr (sizeof...(ts) > 0) {
              return = { 
                  return t(concat(ts...)(parameters...)); 
              };
          }
  1. if constexpr构造的另一个分支是在递归的末尾时由编译器选择的。在这种情况下,我们只返回函数t,因为它是唯一剩下的参数:
          else {
              return t;
          }
      }
  1. 现在,让我们使用我们很酷的新函数连接助手与一些我们想要看到连接的函数。让我们从main函数开始,我们在其中定义两个简单的函数对象:
      int main()
      {
          auto twice  ([] (int i) { return i * 2; });
          auto thrice ([] (int i) { return i * 3; });
  1. 现在让我们进行连接。我们将我们的两个乘法函数对象与 STL 函数std::plus<int>进行连接,该函数接受两个参数并简单地返回它们的和。这样,我们得到一个执行twice(thrice(plus(a, b)))的函数。
          auto combined (
              concat(twice, thrice, std::plus<int>{})
          );
  1. 现在让我们使用它。combined函数现在看起来像一个普通的单一函数,编译器也能够连接这些函数,而没有任何不必要的开销:
          std::cout << combined(2, 3) << 'n';
      }
  1. 编译和运行我们的程序产生了以下输出,这也是我们预期的,因为2 * 3 * (2 + 3)30
      $ ./concatenation
      30

工作原理...

这一部分的复杂之处在于concat函数。它看起来非常复杂,因为它将参数包ts解包到另一个 lambda 表达式中,该 lambda 表达式递归调用concat,并且参数更少:

template <typename T, typename ...Ts>
auto concat(T t, Ts ...ts)
{
    if constexpr (sizeof...(ts) > 0) { 
        return = { 
            return t(concat(ts...)(parameters...)); 
        }; 
    } else {
        return = { 
            return t(parameters...); 
        };
    }
}

让我们编写一个更简单的版本,它精确地连接三个函数:

template <typename F, typename G, typename H>
auto concat(F f, G g, H h)
{
    return = {
        return f( g( h( params... ) ) ); 
    };
}

这看起来已经很相似,但不那么复杂。我们返回一个 lambda 表达式,它捕获了fgh。这个 lambda 表达式任意接受许多参数,并将它们转发到fgh的调用链。当我们写auto combined (concat(f, g, h)),然后稍后用两个参数调用该函数对象,比如combined(2, 3),那么2, 3将由前面的concat函数的params包表示。

再次看看更复杂的通用concat函数;我们真正不同的唯一一件事是f ( g( h( params... ) ) )的连接。相反,我们写f( concat(g, h) )(params...),这在下一次递归调用中会评估为f( g( concat(h) ) )(params...),然后最终结果为f( g( h( params... ) ) )

使用逻辑连接创建复杂的谓词

在使用通用代码过滤数据时,我们最终会定义谓词,告诉我们想要什么数据,以及不想要什么数据。有时,谓词是不同谓词的组合

例如,在过滤字符串时,我们可以实现一个谓词,如果其输入字符串以"foo"开头,则返回true。另一个谓词可以在其输入字符串以"bar"结尾时返回true

我们可以通过组合来重用谓词,而不是一直编写自定义谓词。如果我们想要过滤以"foo"开头并以"bar"结尾的字符串,我们可以选择我们现有的谓词,并用逻辑将它们组合起来。在本节中,我们将使用 lambda 表达式来寻找一种舒适的方法来做到这一点。

如何做...

我们将实现非常简单的字符串过滤谓词,然后我们将用一个小助手函数将它们以通用方式组合起来。

  1. 像往常一样,我们首先包含一些头文件:
      #include <iostream>
      #include <functional>
      #include <string>
      #include <iterator>
      #include <algorithm>
  1. 因为我们以后会需要它们,我们实现了两个简单的谓词函数。第一个告诉我们一个字符串是否以字符'a'开头,第二个告诉我们一个字符串是否以字符'b'结尾:
      static bool begins_with_a (const std::string &s)
      {
          return s.find("a") == 0;
      }

      static bool ends_with_b (const std::string &s)
      {
          return s.rfind("b") == s.length() - 1;
      }
  1. 现在,让我们实现一个辅助函数,我们称之为combine。它以二进制函数作为第一个参数,这个函数可以是逻辑AND函数或逻辑OR函数,然后,它接受另外两个参数,这两个参数将被组合:
      template <typename A, typename B, typename F>
      auto combine(F binary_func, A a, B b)
      {
  1. 我们只需返回一个捕获新谓词combination的 lambda 表达式。它将一个参数转发到两个谓词,然后将两者的结果放入二进制函数中,并返回其结果:
          return = {
              return binary_func(a(param), b(param));
          };
      }
  1. 让我们声明我们在main函数中使用std命名空间来节省一些输入:
      using namespace std;
  1. 现在,让我们将两个谓词函数组合成另一个谓词函数,告诉我们给定的字符串是否以a开头并且b结尾,就像"ab""axxxb"一样。作为二进制函数,我们选择std::logical_and。它是一个需要实例化的模板类,因此我们使用大括号来实例化它。请注意,我们没有提供模板参数,因为对于这个类,默认为void。这个类的特化自动推断所有参数类型:
      int main()
      {
          auto a_xxx_b (combine(
              logical_and<>{}, 
              begins_with_a, ends_with_b));
  1. 我们遍历标准输入,并将所有满足我们谓词的单词打印回终端:
          copy_if(istream_iterator<string>{cin}, {},
                  ostream_iterator<string>{cout, ", "},
                  a_xxx_b);
          cout << 'n';
      }
  1. 编译和运行程序产生以下输出。我们用四个单词输入程序,但只有两个满足谓词条件:
      $ echo "ac cb ab axxxb" | ./combine
      ab, axxxb, 

还有更多...

STL 已经提供了一堆有用的函数对象,比如std::logical_andstd::logical_or,以及许多其他函数,因此我们不需要在每个项目中重新实现它们。查看 C++参考并探索已有的内容是一个好主意:

en.cppreference.com/w/cpp/utility/functional

使用相同的输入调用多个函数

有很多任务会导致重复的代码。使用 lambda 表达式和一个包装这种重复任务的 lambda 表达式辅助函数可以很容易地消除大量重复的代码。

在本节中,我们将使用 lambda 表达式来转发一个带有所有参数的单个调用到多个接收者。这将在没有任何数据结构的情况下发生,因此编译器可以简单地生成一个没有开销的二进制文件。

如何做...

我们将编写一个 lambda 表达式辅助函数,将单个调用转发给多个对象,以及另一个 lambda 表达式辅助函数,将单个调用转发给其他函数的多个调用。在我们的示例中,我们将使用这个来使用不同的打印函数打印单个消息:

  1. 首先让我们包含我们需要打印的 STL 头文件:
      #include <iostream>
  1. 首先,我们实现multicall函数,这是本教程的核心。它接受任意数量的函数作为参数,并返回一个接受一个参数的 lambda 表达式。它将此参数转发到之前提供的所有函数。这样,我们可以定义auto call_all (multicall(f, g, h)),然后call_all(123)导致一系列调用,f(123); g(123); h(123);。这个函数看起来非常复杂,因为我们需要一个语法技巧来展开参数包functions,通过使用std::initializer_list构造函数来进行一系列调用:
      static auto multicall (auto ...functions)
      {
          return = {
              (void)std::initializer_list<int>{
                  ((void)functions(x), 0)...
              };
          };
      }
  1. 下一个辅助函数接受一个函数f和一组参数xs。它的作用是对每个参数调用f。这样,for_each(f, 1, 2, 3)调用导致一系列调用:f(1); f(2); f(3);。这个函数本质上使用了与之前的其他函数相同的语法技巧,将参数包xs展开为一系列函数调用:
      static auto for_each (auto f, auto ...xs) {
          (void)std::initializer_list<int>{
              ((void)f(xs), 0)...
          };
      }
  1. brace_print函数接受两个字符并返回一个新的函数对象,它接受一个参数x。它会打印它,用我们刚刚捕获的两个字符包围起来:
      static auto brace_print (char a, char b) {
          return [=] (auto x) {
              std::cout << a << x << b << ", ";
          };
      }
  1. 现在,我们终于可以在main函数中把所有东西都用起来了。首先,我们定义了fgh函数。它们代表接受值并将其打印在不同的大括号/括号中的打印函数。nl函数接受任何参数,只是打印一个换行字符:
      int main()
      {
          auto f  (brace_print('(', ')'));
          auto g  (brace_print('[', ']'));
          auto h  (brace_print('{', '}'));
          auto nl ([](auto) { std::cout << 'n'; });
  1. 让我们使用我们的multicall助手将它们全部组合起来:
          auto call_fgh (multicall(f, g, h, nl));
  1. 对于我们提供的每个数字,我们希望看到它们被不同的大括号/括号包围打印三次。这样,我们可以进行一次函数调用,最终得到五次对我们的多功能函数的调用,它又会调用fghnl四次。
          for_each(call_fgh, 1, 2, 3, 4, 5);
      }
  1. 在编译和运行之前,想一想期望的输出:
      $ ./multicaller
      (1), [1], {1}, 
      (2), [2], {2}, 
      (3), [3], {3}, 
      (4), [4], {4}, 
      (5), [5], {5}, 

它是如何工作的...

我们刚刚实现的辅助函数看起来非常复杂。这是因为我们使用std::initializer_list来展开参数包。我们为什么要使用这种数据结构呢?让我们再看看for_each

auto for_each ([](auto f, auto ...xs) {
    (void)std::initializer_list<int>{
        ((void)f(xs), 0)...
    };
});

这个函数的核心是f(xs)表达式。xs是一个参数包,我们需要展开它,以便将其中的各个值取出并传递给各个f调用。不幸的是,我们不能只使用...符号写f(xs)...,这一点我们已经知道了。

我们可以使用std::initializer_list构造一个值列表,它具有可变参数的构造函数。诸如return std::initializer_list<int>{f(xs)...};这样的表达式可以胜任,但它有缺点。让我们看看for_each的一个实现,它只是这样做,所以它看起来比我们现在有的更简单:

auto for_each ([](auto f, auto ...xs) {
    return std::initializer_list<int>{f(xs)...};
});

这更容易理解,但它的缺点是:

  1. 它构造了一个实际的初始化器列表,其中包含所有f调用的返回值。在这一点上,我们不关心返回值。

  2. 返回了初始化列表,尽管我们想要一个*“发射并忘记”的函数,它不返回任何东西*。

  3. 可能f是一个函数,甚至不返回任何东西,如果是这样,那么这甚至不会编译。

更复杂的for_each函数解决了所有这些问题。它做了以下几件事来实现这一点:

  1. 它不是返回初始化列表,而是整个表达式转换为void,使用(void)std::initializer_list<int>{...}

  2. 在初始化表达式中,它将f(xs)...包装成(f(xs), 0)...表达式。这导致返回值被丢弃,而0被放入初始化列表中。

  3. (f(xs), 0)...表达式中的f(xs)再次被转换为void,因此如果有的话,返回值确实没有被处理到任何地方。

将所有这些组合在一起不幸地导致了一个丑陋的结构,但它确实能正常工作,并且能够编译各种函数对象,无论它们是否返回任何东西或者返回什么。

这种技术的一个好处是,函数调用的顺序是有严格顺序保证的。

使用旧的 C 风格表示法(void)expression来转换任何东西是不建议的,因为 C++有自己的转换操作符。我们应该使用reinterpret_cast<void>(expression),但这会进一步降低代码的可读性

使用 std::accumulate 和 lambda 实现 transform_if

大多数使用std::copy_ifstd::transform的开发人员可能已经问过自己,为什么没有std::transform_ifstd::copy_if函数从源范围复制项目到目标范围,但会跳过用户定义的谓词函数未选择的项目。std::transform无条件地从源范围复制所有项目到目标范围,但在中间进行转换。转换由用户定义的函数提供,可能做简单的事情,比如乘以数字或将项目转换为完全不同的类型。

这样的函数现在已经存在很长时间了,但仍然没有std::transform_if函数。在本节中,我们将实现这个函数。通过实现一个函数,它在复制被谓词函数选择的所有项目的同时迭代范围,进行中间转换,这样做很容易。然而,我们将利用这个机会更深入地研究 lambda 表达式。

如何做...

我们将构建自己的transform_if函数,通过提供std::accumulate正确的函数对象来工作:

  1. 我们需要像往常一样包含一些头文件:
      #include <iostream>
      #include <iterator>
      #include <numeric>
  1. 首先,我们将实现一个名为map的函数。它接受一个输入转换函数作为参数,并返回一个函数对象,它与std::accumulate很好地配合使用:
      template <typename T>
      auto map(T fn)
      {
  1. 我们返回的是一个接受reduce函数的函数对象。当这个对象被调用时,它会返回另一个函数对象,它接受一个accumulator和一个输入参数。它调用 reduce 函数对这个累加器和fn转换后的输入变量进行操作。如果这看起来很复杂,不要担心,我们稍后会把它整合在一起,看看它是如何真正工作的:
          return [=] (auto reduce_fn) {
              return [=] (auto accum, auto input) {
                  return reduce_fn(accum, fn(input));
              };
          };
      }
  1. 现在我们实现一个名为filter的函数。它的工作方式与map函数完全相同,但它保持输入不变,而map函数使用转换函数转换它。相反,我们接受一个谓词函数,并在不减少它们的情况下跳过输入变量,如果它们不被谓词函数接受:
      template <typename T>
      auto filter(T predicate)
      {
  1. 这两个 lambda 表达式与map函数中的表达式具有完全相同的函数签名。唯一的区别是input参数保持不变。谓词函数用于区分我们是在输入上调用reduce_fn函数,还是只是在不做任何更改的情况下将累加器向前推进:
          return [=] (auto reduce_fn) {
              return [=] (auto accum, auto input) {
                  if (predicate(input)) {
                      return reduce_fn(accum, input);
                  } else {
                      return accum;
                  }
              };
          };
      }
  1. 现在让我们最终使用这些辅助函数。我们实例化迭代器,让我们从标准输入中读取整数值:
      int main()
      {
          std::istream_iterator<int> it {std::cin};
          std::istream_iterator<int> end_it;
  1. 然后我们定义一个谓词函数even,如果我们有一个偶数,它就返回true。变换函数twice将它的整数参数乘以因子2
          auto even  ([](int i) { return i % 2 == 0; });
          auto twice ([](int i) { return i * 2; });
  1. std::accumulate函数接受一系列值并累加它们。累加意味着在默认情况下使用+运算符对值进行求和。我们想要提供我们自己的累加函数。这样,我们就不需要维护值的总和。我们做的是将范围的每个值赋给解引用的迭代器it,然后在推进它之后返回这个迭代器:
          auto copy_and_advance ([](auto it, auto input) {
              *it = input;
              return ++it;
          });
  1. 现在我们终于把这些部分整合在一起了。我们遍历标准输入并提供一个输出,ostream_iterator,它打印到终端。copy_and_advance函数对象通过将用户输入的整数赋值给它来处理输出迭代器。将值赋给输出迭代器有效地打印了被赋值的项目。但我们只想要用户输入中的偶数,并且我们想要乘以它们。为了实现这一点,我们将copy_and_advance函数包装到一个even filter中,然后再包装到一个twice mapper中:
          std::accumulate(it, end_it,
              std::ostream_iterator<int>{std::cout, ", "},
              filter(even)(
                  map(twice)(
                      copy_and_advance
                  )
              ));
          std::cout << 'n';
      }
  1. 编译和运行程序会产生以下输出。值135被丢弃,因为它们不是偶数,而值246在被加倍后被打印出来:
      $ echo "1 2 3 4 5 6" | ./transform_if
      4, 8, 12, 

工作原理...

这个食谱看起来非常复杂,因为我们嵌套了很多 lambda 表达式。为了理解这是如何工作的,让我们首先来看一下std::accumulate的内部工作。这是在典型的 STL 实现中的样子:

template <typename T, typename F>
T accumulate(InputIterator first, InputIterator last, T init, F f)
{
    for (; first != last; ++first) {
        init = f(init, *first);
    }
    return init;
}

这里,函数参数f承担了主要工作,而循环在用户提供的init变量中收集其结果。在通常的例子中,迭代器范围可能代表一个数字向量,比如0, 1, 2, 3, 4,而init值为0f函数只是一个二元函数,可能使用+运算符计算两个项目的总和

在这个例子中,循环只是将所有项目相加到init变量中,比如init = (((0 + 1) + 2) + 3) + 4。像这样写下来很明显,std::accumulate只是一个通用的折叠函数。折叠一个范围意味着对累加器变量应用二元操作,并逐步应用范围中包含的每个项目(每次操作的结果就是下一个累加器值)。由于这个函数是如此通用,我们可以做各种各样的事情,就像实现std::transform_if一样!f函数也被称为reduce函数。

transform_if的一个非常直接的实现如下所示:

template <typename InputIterator, typename OutputIterator, 
          typename P, typename Transform>
OutputIterator transform_if(InputIterator first, InputIterator last,
                            OutputIterator out,
                            P predicate, Transform trans)
{
    for (; first != last; ++first) {
        if (predicate(*first)) {
            *out = trans(*first);
            ++out;
        }
    }
    return out;
}

这看起来与std::accumulate非常相似,如果我们将参数out视为init变量,并且以某种方式让函数f替代 if 结构及其主体!

我们实际上做到了。我们使用我们提供的二元函数对象构造了 if 结构及其主体,并将其作为参数提供给std::accumulate

auto copy_and_advance ([](auto it, auto input) {
    *it = input;
    return ++it;
});

std::accumulate函数将init变量放入二元函数的it参数中。第二个参数是源范围中每次循环迭代步骤的当前值。我们提供了一个输出迭代器作为std::accumulateinit参数。这样,std::accumulate不计算总和,而是将其迭代的项目转发到另一个范围。这意味着我们只是重新实现了std::copy,没有任何谓词和转换。

我们通过将copy_and_advance函数对象包装成另一个函数对象来添加使用谓词进行过滤:

template <typename T>
auto filter(T predicate)
{
    return [=] (auto reduce_fn) {
        return [=] (auto accum, auto input) {
            if (predicate(input)) {
                return reduce_fn(accum, input);
            } else {
                return accum;
            }
        };
    };
}

这个构造一开始看起来并不简单,但是看看if结构。如果predicate函数返回true,它将参数转发给reduce_fn函数,这在我们的情况下是copy_and_advance。如果谓词返回false,则accum变量,即std::accumulateinit变量,将不经改变地返回。这实现了过滤操作的跳过部分。if结构位于内部 lambda 表达式中,其具有与copy_and_advance函数相同的二元函数签名,这使其成为一个合适的替代品。

现在我们能够过滤,但仍然没有转换。这是由map函数助手完成的:

template <typename T>
auto map(T fn)
{
    return [=] (auto reduce_fn) {
        return [=] (auto accum, auto input) {
            return reduce_fn(accum, fn(input));
        };
    };
}

这段代码看起来简单得多。它再次包含了一个内部 lambda 表达式,其签名与copy_and_advance相同,因此可以替代它。实现只是转发输入值,但是转换了二元函数调用的参数,使用fn函数。

稍后,当我们使用这些辅助函数时,我们写下了以下表达式:

filter(even)(
    map(twice)(
        copy_and_advance
    )
)

filter(even)调用捕获了even谓词,并给了我们一个函数,它接受一个二元函数,以便将其包装成另一个二元函数,进行额外的过滤map(twice)函数对twice转换函数做了同样的事情,但是将二元函数copy_and_advance包装成另一个二元函数,它总是转换右参数。

没有任何优化,我们将得到一个非常复杂的嵌套函数构造,调用函数并在其中间做很少的工作。然而,对于编译器来说,优化所有代码是一项非常简单的任务。生成的二进制代码就像是从transform_if的更直接的实现中得到的一样简单。这种方式在性能方面没有任何损失。但我们得到的是函数的非常好的可组合性,因为我们能够将even谓词与twice转换函数简单地组合在一起,几乎就像它们是乐高积木一样简单。

在编译时生成任何输入的笛卡尔积对

Lambda 表达式与参数包结合可以用于复杂的任务。在本节中,我们将实现一个函数对象,它接受任意数量的输入参数,并生成这组参数与自身笛卡尔积

笛卡尔积是一个数学运算。它表示为A x B,意思是集合A和集合B的笛卡尔积。结果是另一个单一集合,其中包含集合AB所有项目组合的对。该操作基本上意味着,将 A 中的每个项目与 B 中的每个项目组合。下图说明了该操作:

在前面的图中,如果A = (x, y, z)B = (1, 2, 3),那么笛卡尔积是(x, 1)(x, 2)(x, 3)(y, 1)(y, 2),等等。

如果我们决定AB相同的集合,比如(1, 2),那么它的笛卡尔积是(1, 1)(1, 2)(2, 1)(2, 2)。在某些情况下,这可能被声明为冗余,因为与自身的项目组合(如(1, 1))或(1, 2)(2, 1)的冗余组合可能是不需要的。在这种情况下,可以使用简单的规则过滤笛卡尔积。

在本节中,我们将实现笛卡尔积,但不使用任何循环,而是使用 lambda 表达式和参数包展开。

如何做...

我们实现了一个接受函数f和一组参数的函数对象。函数对象将创建参数集的笛卡尔积,过滤掉冗余部分,并调用f函数的每一个:

  1. 我们只需要包括用于打印的 STL 头文件:
      #include <iostream>
  1. 然后,我们定义一个简单的辅助函数,用于打印一对值,并开始实现main函数:
      static void print(int x, int y)
      {
          std::cout << "(" << x << ", " << y << ")n";
      }

      int main()
      {
  1. 现在开始困难的部分。我们首先实现了cartesian函数的辅助函数,我们将在下一步中实现它。这个函数接受一个参数f,当我们以后使用它时,它将是print函数。其他参数是x和参数包rest。这些包含我们想要得到笛卡尔积的实际项目。看一下f(x, rest)表达式:对于x=1rest=2, 3, 4,这将导致诸如f(1, 2); f(1, 3); f(1, 4);的调用。(x < rest)测试是为了消除生成的对中的冗余。我们稍后将更详细地看一下这一点:
          constexpr auto call_cart (
              = constexpr {
                  (void)std::initializer_list<int>{
                      (((x < rest)
                          ? (void)f(x, rest)
                          : (void)0)
                      ,0)...
                  };
              });
  1. cartesian函数是整个配方中最复杂的代码。它接受参数包xs并返回一个捕获它的函数对象。返回的函数对象接受一个函数对象f

对于参数包,xs=1, 2, 3,内部 lambda 表达式将生成以下调用:call_cart(f, **1**, 1, 2, 3); call_cart(f, **2**, 1, 2, 3); call_cart(f, **3**, 1, 2, 3);。从这一系列调用中,我们可以生成所有需要的笛卡尔积对。

请注意,我们使用...符号来两次展开xs参数包,一开始看起来很奇怪。第一次出现的...将整个xs参数包展开为call_cart调用。第二次出现会导致多个call_cart调用,第二个参数不同:

          constexpr auto cartesian (= constexpr {
              return [=] (auto f) constexpr {
                  (void)std::initializer_list<int>{
                      ((void)call_cart(f, xs, xs...), 0)...
                  };
              };
          });
  1. 现在,让我们生成数字集合1, 2, 3的笛卡尔积并打印这些配对。去除冗余配对后,这应该得到数字配对(1, 2)(2, 3)(1, 3)。如果我们忽略顺序并且不希望在一个配对中有相同的数字,那么就不可能有更多的组合。这意味着我们希望(1, 1),并且认为(1, 2)(2, 1)相同的配对。

首先,我们让cartesian生成一个函数对象,该对象已经包含了所有可能的配对,并接受我们的打印函数。然后,我们使用它来让我们的print函数被所有这些配对调用。

我们声明print_cart变量为constexpr,这样我们可以保证它所持有的函数对象(以及它生成的所有配对)在编译时创建:

          constexpr auto print_cart (cartesian(1, 2, 3));

          print_cart(print);
      }
  1. 编译和运行产生了以下输出,正如预期的那样。通过删除call_cart函数中的(x < xs)条件,可以尝试在代码中进行调整,看看我们是否会得到包含冗余配对和相同数字配对的完整笛卡尔积。
      $ ./cartesian_product
      (1, 2)
      (1, 3)
      (2, 3)

它是如何工作的...

这是另一个看起来非常复杂的 lambda 表达式构造。但一旦我们彻底理解了这一点,我们就不会被任何 lambda 表达式所困惑!

因此,让我们仔细看一下。我们应该对需要发生的事情有一个清晰的认识:

这些是三个步骤:

  1. 我们取我们的集合1, 2, 3,并从中组合三个新集合。每个集合的第一部分依次是集合中的一个单独项,第二部分是整个集合本身。

  2. 我们将第一个项与集合中的每个项组合,得到尽可能多的配对

  3. 从这些得到的配对中,我们只挑选那些不冗余的(例如(1, 2)(2, 1)是冗余的)和不相同编号的(例如(1, 1))。

现在,回到实现:

 constexpr auto cartesian (= constexpr {
     return = constexpr {
         (void)std::initializer_list<int>{
             ((void)call_cart(f, xs, xs...), 0)...
         };
     };
 });

内部表达式call_cart(xs, xs...)恰好表示将(1, 2, 3)分成这些新集合,比如1, [1, 2, 3]。完整表达式((void)call_cart(f, xs, xs...), 0)...与其他...在外面,对集合的每个值进行了这种分割,所以我们也得到了2, [1, 2, 3]3, [1, 2, 3]

第 2 步和第 3 步是由call_cart完成的:

auto call_cart ([](auto f, auto x, auto ...rest) constexpr {
    (void)std::initializer_list<int>{
        (((x < rest)
            ? (void)f(x, rest)
            : (void)0)
        ,0)...
    };
});

参数x始终包含从集合中选取的单个值,rest包含整个集合。首先忽略(x < rest)条件。在这里,表达式f(x, rest)...参数包展开一起生成函数调用f(1, 1)f(1, 2)等等,这导致配对被打印。这是第 2 步。

第 3 步是通过筛选出只有(x < rest)适用的配对来实现的。

我们将所有的 lambda 表达式和持有它们的变量都设为constexpr。通过这样做,我们现在可以保证编译器将在编译时评估它们的代码,并编译出一个已经包含所有数字配对的二进制文件,而不是在运行时计算它们。请注意,只有当我们提供给 constexpr 函数的所有函数参数在编译时已知时才会发生这种情况。

第二十五章:STL 算法基础

本章中我们将涵盖以下内容:

  • 将项目从一个容器复制到另一个容器

  • 对容器进行排序

  • 从容器中删除特定项目

  • 转换容器的内容

  • 在有序和无序向量中查找项目

  • 使用std::clamp将向量的值限制在特定的数值范围内

  • 使用std::search在字符串中查找模式并选择最佳实现

  • 对大型向量进行抽样

  • 生成输入序列的排列

  • 实现字典合并工具

介绍

STL 不仅包含数据结构,还包括算法。数据结构帮助以不同的方式和不同的动机和目标存储维护数据,而算法则对这些数据进行特定的转换

让我们来看一个标准任务,比如对向量中的项目求和。这可以通过循环遍历向量并将所有项目累加到一个名为sum的累加器变量中轻松完成:

 vector<int> v {100, 400, 200 /*, ... */ };

 int sum {0};
 for (int i : v) { sum += i; }

 cout << sum << 'n';

但是因为这是一个相当标准的任务,所以也有一个 STL 算法可以完成这个任务:

cout << accumulate(begin(v), end(v), 0) << 'n';

在这种情况下,手工制作的循环变体并不比一行代码长多少,而且也不比一个一行代码难以阅读:accumulate。然而,在很多情况下,阅读一个 10 行代码的循环是很尴尬的,"我刚刚是否不得不研究整个循环才能理解它执行了一个标准任务 X?",而不是看到一行代码,它使用了一个清楚说明它的名字的标准算法,比如accumulatecopymovetransformshuffle

基本思想是提供丰富多样的算法,供程序员在日常工作中使用,以减少重复实现它们的需要。这样,程序员可以直接使用现成的算法实现,并集中精力解决问题,而不是浪费时间在 STL 已经解决的问题上。另一个角度是正确性--如果程序员一遍又一遍地实现相同的东西,那么有可能在一次或另一次尝试中引入一点错误。这是完全不必要的,而且如果在代码审查期间被同事指出,这也是非常尴尬的,而与此同时,可以使用标准算法。

STL 算法的另一个重要点是效率。许多 STL 算法提供了相同算法的多个专门实现,这些实现根据它们所使用的迭代器类型的不同而执行不同的操作。例如,如果一个整数向量中的所有元素都应该被置零,可以使用 STL 算法std::fill来完成。因为向量的迭代器已经可以告诉编译器它是在连续内存上迭代,它可以选择使用使用 C 过程memsetstd::fill实现。如果程序员将容器类型从vector更改为list,那么 STL 算法就不能再使用memset,而必须逐个迭代列表以将项目置零。如果程序员自己使用memset,那么实现将不必要地硬编码为使用向量或数组,因为大多数其他数据结构不会将它们的数据保存在连续的内存块中。在大多数情况下,试图变得聪明几乎没有意义,因为 STL 的实现者可能已经实现了相同的想法,这些想法可以免费使用。

让我们总结前面的观点。使用 STL 算法有以下好处:

  • 可维护性:算法的名称已经清楚地说明了它们的功能。显式循环很少有比标准算法更易读且与数据结构无关的情况。

  • 正确性:STL 已经由专家编写和审查,并且被如此多的人使用和测试,以至于在重新实现其复杂部分时,你很难达到相同的正确性程度。

  • 效率:STL 算法默认至少与大多数手工编写的循环一样有效。

大多数算法都在迭代器上工作。关于迭代器如何工作的概念已经在第二十章中解释过了,迭代器。在本章中,我们将集中讨论使用 STL 算法解决不同问题,以便对它们如何有利地利用有所感触。展示所有STL 算法会使这本书变成一个非常无聊的 C参考资料,尽管已经有一个 C参考资料公开可用。

成为 STL 忍者的最佳方法是始终随身携带 C++参考资料,或者至少将其保存在浏览器书签中。在解决问题时,每个程序员都应该在脑海中回想一下这个问题,“我的问题是否有 STL 算法?”,然后再自己编写代码。

一个非常好而完整的 C++参考资料可以在线查看:

cppreference.com

它也可以下载以供离线查看。

在工作面试中,熟练掌握 STL 算法通常被视为对 C++知识的强大指标。

从容器复制项目到其他容器

最重要的 STL 数据结构都有迭代器支持。这意味着至少可以通过begin()end()函数获取迭代器,这些迭代器指向数据结构的基础有效负载数据,并允许对该数据进行迭代。迭代总是看起来一样,无论迭代的是什么类型的数据结构。

我们可以从向量、列表、双端队列、地图等获取迭代器。使用迭代器适配器,我们甚至可以将迭代器作为文件、标准输入和标准输出的接口。此外,正如我们在上一章中看到的,我们甚至可以将迭代器接口包装在算法周围。现在,在我们可以使用迭代器访问所有内容的地方,我们可以将它们与接受迭代器作为参数的 STL 算法结合使用。

展示迭代器如何将不同数据结构的本质抽象化的一个非常好的方法是std::copy算法,它只是将项目从一组迭代器复制到输出迭代器。在使用这样的算法时,底层数据结构的本质不再真正相关。为了证明这一点,我们将稍微使用一下std::copy

如何做...

在本节中,我们将使用std::copy的不同变体:

  1. 让我们首先包括我们使用的数据结构所需的所有头文件。此外,我们声明我们使用std命名空间:
       #include <iostream>
       #include <vector>
       #include <map>
       #include <string>
       #include <tuple>
       #include <iterator>
       #include <algorithm>

       using namespace std;
  1. 接下来我们将使用整数和字符串值的对。为了漂亮地打印它们,我们应该首先为它们重载<<流操作符:
       namespace std {
       ostream& operator<<(ostream &os, const pair<int, string> &p)
       {
           return os << "(" << p.first << ", " << p.second << ")";
       }
       }
  1. main函数中,我们用一些默认值填充了一个整数-字符串对的vector。然后我们声明了一个map变量,它将整数值与字符串值关联起来:
       int main()
       {
           vector<pair<int, string>> v {
               {1, "one"}, {2, "two"}, {3, "three"}, 
               {4, "four"}, {5, "five"}};

           map<int, string> m;
  1. 现在,我们使用std::copy_n从向量的前面精确地复制三个整数-字符串对到地图中。因为向量和地图是完全不同的数据结构,我们需要使用insert_iterator适配器来转换向量中的项目。std::inserter函数为我们生成这样的适配器。请始终注意,使用std::copy_n等算法与插入迭代器结合使用是将项目复制/插入到其他数据结构的最通用方法,但不是最快的方法。使用数据结构特定的成员函数来插入项目通常是最有效的方法:
           copy_n(begin(v), 3, inserter(m, begin(m)));
  1. 让我们打印一下映射之后的内容。在整本书中,我们经常使用std::copy函数打印容器的内容。std::ostream_iterator在这方面非常有帮助,因为它允许我们将用户 shell 的标准输出视为另一个容器,我们可以将数据复制到其中:
           auto shell_it (ostream_iterator<pair<int, string>>{cout, 
                                                              ", "});

           copy(begin(m), end(m), shell_it);
           cout << 'n';
  1. 让我们再次清空映射以进行下一个实验。这一次,我们将项目向量移动到映射中,而且这一次,是所有项目:
           m.clear();

           move(begin(v), end(v), inserter(m, begin(m)));
  1. 我们再次打印映射的新内容。此外,由于std::move是一个也会改变数据的算法,我们也将打印源向量。这样,我们就可以看到它在充当移动源时发生了什么:
           copy(begin(m), end(m), shell_it);
           cout << 'n';

           copy(begin(v), end(v), shell_it);
           cout << 'n';
       }
  1. 让我们编译并运行程序,看看它说了什么。前两行很简单。它们反映了应用copy_nmove算法后映射包含的内容。第三行很有趣,因为它显示了我们用作移动源的向量中的字符串现在为空。这是因为字符串的内容没有被复制,而是被有效地移动(这意味着映射使用了先前由向量中的字符串对象引用的堆内存中的字符串数据*)。我们通常不应该访问在重新分配之前作为移动源的项目,但为了这个实验,让我们忽略这一点:
      $ ./copying_items
      (1, one), (2, two), (3, three), 
      (1, one), (2, two), (3, three), (4, four), (5, five), 
      (1, ), (2, ), (3, ), (4, ), (5, ),

它是如何工作的...

由于std::copy是 STL 算法中最简单的之一,因此其实现非常简短。让我们看看它是如何实现的:

template <typename InputIterator, typename OutputIterator>
OutputIterator copy(InputIterator it, InputIterator end_it, 
                    OutputIterator out_it)
{
    for (; it != end_it; ++it, ++out_it) {
        *out_it = *it;
    }
    return out_it;
}

这看起来确切地像一个人会天真地手动实现从一个可迭代范围到另一个可迭代范围的项目复制。在这一点上,人们也可以问,“那么为什么不手动实现它,循环足够简单,我甚至不需要返回值?”,这当然是一个很好的问题。

虽然std::copy不是使代码显著缩短的最佳示例,但许多其他具有更复杂实现的算法是。不明显的是这些 STL 算法的隐藏自动优化。如果我们碰巧使用存储其项目在连续内存中的数据结构(如std::vectorstd::array项目本身是平凡复制可分配的,那么编译器将选择完全不同的实现(假设迭代器类型为指针):

template <typename InputIterator, typename OutputIterator>
OutputIterator copy(InputIterator it, InputIterator end_it, 
                    OutputIterator out_it)
{
    const size_t num_items (distance(it, end_it));
    memmove(out_it, it, num_items * sizeof(*it));
    return it + num_items;
}

这是std::copy算法的memmove变体在典型的 STL 实现中的简化版本。它比标准循环版本更快,而且这一次,它也不那么容易阅读。但是,如果参数类型符合此优化的要求,std::copy用户会自动从中受益。编译器为所选择的算法选择可能的最快实现,而用户代码则很好地表达了算法的做什么,而没有用太多的如何细节来污染代码。

STL 算法通常提供了可读性最佳实现之间的最佳权衡。

如果类型只包含一个或多个(由类/结构体包装)标量类型或类,通常可以将其视为平凡的可复制可分配类型,这些类型可以安全地使用memcopy/memmove进行移动,而无需调用用户定义的复制分配运算符。

我们还使用了std::move。它的工作原理与std::copy完全相同,但它在循环中将std::move(*it)应用于源迭代器,以将lvalues转换为rvalues。这使得编译器选择目标对象的移动赋值运算符,而不是复制赋值运算符。对于许多复杂对象,这样做性能更好,但破坏了源对象。

排序容器

对值进行排序是一个相当标准的任务,可以用多种方式完成。每个被迫学习大多数现有排序算法(以及它们的性能和稳定性权衡)的计算机科学学生都知道这一点。

因为这是一个解决的问题,程序员不应该浪费时间再次解决它,除非是为了学习目的。

如何做...

在本节中,我们将使用std::sortstd::partial_sort

  1. 首先,我们包括所有必要的内容,并声明我们使用std命名空间:
       #include <iostream>
       #include <algorithm>
       #include <vector>
       #include <iterator>
       #include <random>       

       using namespace std;
  1. 我们将多次打印整数向量的状态,因此让我们通过编写一个小程序来简化这个任务:
       static void print(const vector<int> &v)
       {
           copy(begin(v), end(v), ostream_iterator<int>{cout, ", "});
           cout << 'n';
       }
  1. 我们从一个包含一些示例数字的向量开始:
       int main()
       {
           vector<int> v {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
  1. 因为我们将多次对向量进行洗牌,以便使用不同的排序函数,所以我们需要一个随机数生成器:
           random_device rd;
           mt19937 g {rd()};
  1. std::is_sorted函数告诉我们容器的内容是否已排序。这行应该打印1
           cout << is_sorted(begin(v), end(v)) << 'n';
  1. 使用std::shuffle,我们摇动向量的内容,以便稍后再次对其进行排序。前两个参数表示将被洗牌的范围,第三个参数是随机数生成器:
           shuffle(begin(v), end(v), g);
  1. is_sorted函数现在应该返回false,以便打印0,向量中的值应该相同,但顺序不同。我们将在将它们再次打印到 shell 后看到:
           cout << is_sorted(begin(v), end(v)) << 'n';
           print(v);
  1. 现在,我们使用std::sort重新建立原始项目排序。现在,终端上的相同打印应该再次给我们从一开始的排序顺序:
           sort(begin(v), end(v));

           cout << is_sorted(begin(v), end(v)) << 'n';
           print(v);
  1. 另一个有趣的函数是std::partition。也许,我们不想完全对列表进行排序,因为只需将小于某个值的项目放在前面就足够了。因此,让我们分区向量,以便将所有小于5的项目移到前面并打印它:
           shuffle(begin(v), end(v), g);

           partition(begin(v), end(v), [] (int i) { return i < 5; });

           print(v);
  1. 下一个与排序相关的函数是std::partial_sort。我们可以使用它来对容器的内容进行排序,但只能在某种程度上。它将所有向量元素中的N个最小元素放在向量的前半部分,并按排序顺序排列。其余的将驻留在第二半部分,不会排序:
           shuffle(begin(v), end(v), g);
           auto middle (next(begin(v), int(v.size()) / 2));
           partial_sort(begin(v), middle, end(v));

           print(v);
  1. 如果我们想对没有比较运算符的数据结构进行排序怎么办?让我们定义一个并创建这样项目的向量:
           struct mystruct {
               int a;
               int b;
           };

           vector<mystruct> mv {{5, 100}, {1, 50}, {-123, 1000}, 
                                {3, 70}, {-10, 20}};
  1. std::sort函数可选地接受一个比较函数作为其第三个参数。让我们使用它,并提供一个这样的函数。只是为了显示这是可能的,我们通过它们的第二字段b进行比较。这样,它们将按照mystruct::b的顺序而不是mystruct::a的顺序出现:
           sort(begin(mv), end(mv),
                [] (const mystruct &lhs, const mystruct &rhs) {
                    return lhs.b < rhs.b;
                });
  1. 最后一步是打印排序后的mystruct项目向量:
           for (const auto &[a, b] : mv) {
               cout << "{" << a << ", " << b << "} ";
           }
           cout << 'n';
       }
  1. 让我们编译并运行我们的程序。

第一个1是在初始化排序向量后对std::is_sorted的调用的结果。然后,我们洗牌了向量,并从第二个is_sorted调用中得到了0。第三行显示了洗牌后的所有向量项目。下一个1是使用std::sort再次对其进行排序后的is_sorted调用的结果。

然后,我们再次洗牌整个向量,并使用std::partition进行分区。我们可以看到所有小于5的项目也在向量中的5的左边。所有大于5的项目在其右边。除此之外,它们似乎被洗牌了。

倒数第二行显示了std::partial_sort的结果。直到中间的所有项目都严格排序,但其余的没有。

在最后一行,我们可以看到我们的mystruct实例向量。它们严格按照它们的第二成员值排序:

      $ ./sorting_containers 
      1
      0
      7, 1, 4, 6, 8, 9, 5, 2, 3, 10, 
      1
      1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 
      1, 2, 4, 3, 5, 7, 8, 10, 9, 6, 
      1, 2, 3, 4, 5, 9, 8, 10, 7, 6,
      {-10, 20} {1, 50} {3, 70} {5, 100} {-123, 1000}

它是如何工作的...

我们使用了与排序有关的不同算法:

算法 目的
std::sort 接受一个范围作为参数并简单地对其进行排序。
std::is_sorted 接受一个范围作为参数,并告诉是否该范围已排序。
std::shuffle 这在某种程度上是与排序相反的操作;它接受一个范围作为参数并洗牌其项目。
std::partial_sort 接受一个范围作为参数和另一个迭代器,告诉输入范围应排序到哪里。在该迭代器后面,其余项目将未排序。
std::partition 接受一个范围和一个谓词函数。谓词函数返回true的所有项目都移动到范围的前面。其余的移动到后面。

对于没有比较运算符<实现的对象,可以提供自定义比较函数。这些函数应该始终具有bool function_name(const T &lhs, const T &rhs)这样的签名,并且在执行过程中不应该有任何副作用。

还有其他算法,比如std::stable_sort,它也可以排序,但保留具有相同排序键的项目的顺序,以及std::stable_partition

std::sort有不同的实现用于排序。根据迭代器参数的性质,它被实现为选择排序,插入排序,归并排序,或者完全针对较少数量的项目进行优化。在用户端,我们通常甚至不需要关心。

从容器中删除特定项目

复制,转换和过滤可能是数据范围上最常见的操作。在本节中,我们集中在过滤项目上。

从数据结构中过滤项目,或者简单地删除特定项目,对于不同的数据结构来说完全不同。例如,在链表中(如std::list),可以通过使其前驱指向其后继来删除节点。以这种方式从链接链中删除节点后,可以将其返回给分配器。在连续存储数据结构(std::vectorstd::array,以及在某种程度上std::deque)中,只能通过用其他项目覆盖它们来删除项目。如果标记要删除的项目槽,那么在它后面的所有项目必须向前移动一个槽,以填补空白。这听起来很麻烦,但是如果我们想要从字符串中简单地删除空格,这应该可以在不多的代码的情况下实现。

当手头有任何一种数据结构时,我们实际上并不想关心如何删除一个项目。它应该只是发生。这就是std::removestd::remove_if可以为我们做的事情。

如何做...

我们将通过不同的方式删除向量的内容:

  1. 让我们导入所有需要的头文件,并声明我们使用std命名空间:
       #include <iostream>
       #include <vector>
       #include <algorithm>
       #include <iterator>      

       using namespace std;
  1. 一个简短的打印辅助函数将打印我们的向量:
       void print(const vector<int> &v)
       {
           copy(begin(v), end(v), ostream_iterator<int>{cout, ", "});
           cout << 'n';
       }
  1. 我们将从一个包含一些简单整数值的示例向量开始。我们也会打印它,这样我们就可以看到它在稍后应用于它的函数中如何改变:
       int main()
       {
           vector<int> v {1, 2, 3, 4, 5, 6};
           print(v);
  1. 现在让我们从向量中删除值为2的所有项目。std::remove以一种使实际上在向量中消失的值2的方式移动其他项目。因为在删除项目后向量的实际内容变短了,std::remove会返回一个指向新结尾的迭代器。新结尾迭代器和旧结尾迭代器之间的项目应被视为垃圾,因此我们告诉向量擦除它们。我们将两行删除代码放在一个新的作用域中,因为new_end迭代器在之后无论如何都会失效,所以它可以立即超出作用域:
           {
               const auto new_end (remove(begin(v), end(v), 2));
               v.erase(new_end, end(v));
           }
           print(v);
  1. 现在让我们删除所有奇数。为了做到这一点,我们实现一个谓词,告诉我们一个数字是否是奇数,并将其输入到std::remove_if函数中,该函数接受这样的谓词:
           {
               auto odd_number ([](int i) { return i % 2 != 0; });
               const auto new_end (
                   remove_if(begin(v), end(v), odd_number));
               v.erase(new_end, end(v));
           }
           print(v);
  1. 我们尝试的下一个算法是std::replace。我们使用它来用值123覆盖所有值为4的值。std::replace函数也存在为std::replace_if,它也接受谓词函数:
           replace(begin(v), end(v), 4, 123);
           print(v);
  1. 让我们完全将新值注入向量,并创建两个新的空向量,以便对它们进行另一个实验:
           v = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};

           vector<int> v2;
           vector<int> v3;
  1. 然后,我们再次实现一个奇数的谓词和另一个谓词函数,告诉我们一个数字是否是偶数:
           auto odd_number  ([](int i) { return i % 2 != 0; });
           auto even_number ([](int i) { return i % 2 == 0; });
  1. 接下来的两行做了完全相同的事情。它们将偶数值复制到向量v2v3。第一行使用std::remove_copy_if算法,它将来自源容器的所有内容复制到另一个容器,该容器不满足谓词约束。另一行使用std::copy_if,它复制满足谓词约束的所有内容:
           remove_copy_if(begin(v), end(v), 
                          back_inserter(v2), odd_number);
           copy_if(begin(v), end(v), 
                   back_inserter(v3), even_number);
  1. 现在打印这两个向量应该得到相同的输出:
           print(v2);
           print(v3);
       }
  1. 让我们编译并运行程序。第一行输出显示了向量在初始化后的状态。第二行显示了删除所有值为2后的状态。下一行显示了删除所有奇数后的结果。在第四行之前,我们用123替换了所有值为4的值。

最后两行显示了向量v2v3

      $ ./removing_items_from_containers 
      1, 2, 3, 4, 5, 6, 
      1, 3, 4, 5, 6, 
      4, 6, 
      123, 6, 
      2, 4, 6, 8, 10, 
      2, 4, 6, 8, 10, 

它是如何工作的...

我们已经使用了不同的算法,这些算法与过滤有关:

算法 目的
std::remove 接受范围和值作为参数,并删除该值的任何出现。返回修改后范围的新结束迭代器。
std::replace 接受范围和两个值作为参数,并用第二个值替换所有第一个值的出现。
std::remove_copy 接受范围、输出迭代器和值作为参数,并将不等于给定值的所有值从范围复制到输出迭代器。
std::replace_copy工作原理类似于std::replace,但类似于std::remove_copy。源范围不会被改变。
std::copy_if 类似于std::copy,但还接受谓词函数作为参数,以便仅复制谓词接受的值,这使它成为一个过滤函数。

对于列出的每个算法,还存在一个*_if版本,它接受谓词函数而不是值,然后决定要删除或替换哪些值。

转换容器的内容

如果std::copy是应用于范围的最简单的 STL 算法,那么std::transform就是第二简单的 STL 算法。就像copy一样,它将项目从一个范围复制到另一个范围,但还接受一个转换函数。这个转换函数可以在分配给目标范围中的项目之前改变输入类型的值。此外,它甚至可以构造一个完全不同的类型,这在源范围和目标范围的有效负载项目类型不同的情况下非常有用。它很简单但仍然非常有用,这使得它成为可移植日常程序中使用的普通标准组件。

如何做到...

在本节中,我们将使用std::transform来修改向量的项目并将它们复制:

  1. 与往常一样,我们首先需要包含所有必要的头文件,并为了节省一些输入,声明我们使用std命名空间:
       #include <iostream>
       #include <vector>
       #include <string>
       #include <sstream>
       #include <algorithm>
       #include <iterator>       

       using namespace std;
  1. 一个包含一些简单整数的向量将作为示例源数据结构:
       int main()
       {
           vector<int> v {1, 2, 3, 4, 5};
  1. 现在,我们将所有项目复制到ostream_iterator适配器中以进行打印。transform函数接受一个函数对象,该函数对象在每次复制操作期间接受容器有效负载类型的项目并对其进行转换。在这种情况下,我们计算每个数字项目的平方,因此代码将打印向量中项目的平方,而无需将它们存储在任何地方:
           transform(begin(v), end(v), 
               ostream_iterator<int>{cout, ", "},
               [] (int i) { return i * i; });
           cout << 'n';
  1. 让我们进行另一个转换。例如,从数字3,我们可以生成一个易于阅读的字符串,如3² = 9。以下的int_to_string函数对象就是使用std::stringstream对象实现了这一点:
           auto int_to_string ([](int i) {
               stringstream ss;
               ss << i << "² = " << i * i;
               return ss.str();
           });
  1. 我们刚刚实现的函数从整数值返回字符串值。我们还可以说它从整数到字符串的映射。使用transform函数,我们可以将所有这样的映射从整数向量复制到字符串向量中:
           vector<string> vs;

           transform(begin(v), end(v), back_inserter(vs),
                     int_to_string);
  1. 打印完这些之后,我们就完成了:
           copy(begin(vs), end(vs), 
                ostream_iterator<string>{cout, "n"});
      }
  1. 让我们编译并运行程序:
      $ ./transforming_items_in_containers 
      1, 4, 9, 16, 25, 
      1² = 1
      2² = 4
      3² = 9
      4² = 16
      5² = 25

它是如何工作的...

std::transform函数的工作方式与std::copy完全相同,但在将源迭代器的值复制分配到目标迭代器时,它会在将结果分配给目标迭代器之前应用用户提供的转换函数到该值。

在有序和无序向量中查找项目

通常,我们需要告诉是否某种类型的项目存在于某个范围内。如果存在,我们通常还需要修改它或访问与之关联的其他数据。

有不同的策略来查找项目。如果项目按排序顺序出现,那么我们可以进行二进制搜索,这比逐个遍历项目要快。如果没有排序,我们又被困在线性遍历中。

典型的 STL 搜索算法都可以为我们做这两件事,因此了解它们及其特性是很好的。本节介绍了简单的线性搜索算法std::find,二进制搜索版本std::equal_range及其变体。

如何做...

在本节中,我们将在一个小例子数据集上使用线性和二进制搜索算法:

  1. 我们首先包括所有必要的头文件,并声明我们使用std命名空间:
      #include <iostream>
      #include <vector>
      #include <list>
      #include <algorithm>
      #include <string>

      using namespace std;
  1. 我们的数据集将由city结构组成,它只保存城市的名称和人口数量:
      struct city {
          string name;
          unsigned population;
      };
  1. 搜索算法需要能够将一个项目与另一个项目进行比较,因此我们为city结构实例重载了==运算符:
      bool operator==(const city &a, const city &b) {
          return a.name == b.name && a.population == b.population;
      }
  1. 我们还想打印city实例,因此我们重载了流运算符<<
      ostream& operator<<(ostream &os, const city &city) {
          return os << "{" << city.name << ", " 
                    << city.population << "}";
      }
  1. 搜索函数通常返回迭代器。这些迭代器指向找到的项目,否则指向底层容器的结束迭代器。在最后一种情况下,我们不允许访问这样的迭代器。因为我们将打印我们的搜索结果,我们实现了一个函数,它返回另一个函数对象,该函数对象封装了数据结构的结束迭代器。在用于打印时,它将比较其迭代器参数与结束迭代器,然后打印项目,否则只是<end>
      template <typename C>
      static auto opt_print (const C &container)
      {
          return [end_it (end(container))] (const auto &item) {
              if (item != end_it) {
                  cout << *item << 'n';
              } else {
                  cout << "<end>n";
              }
          };
      }
  1. 我们从一些德国城市的示例向量开始:
      int main()
      {
          const vector<city> c {
              {"Aachen",        246000},
              {"Berlin",       3502000},
              {"Braunschweig",  251000},
              {"Cologne",      1060000}
          };
  1. 使用这个帮助程序,我们构建了一个城市打印函数,它捕获了我们城市向量c的结束迭代器:
          auto print_city (opt_print(c));
  1. 我们使用std::find在向量中查找项目,该项目保存了科隆的城市项目。起初,这个搜索看起来毫无意义,因为我们确切地得到了我们搜索的项目。但是在此之前,我们不知道它在向量中的位置,find函数只返回了这一点。然而,我们可以,例如,使我们重载的city结构的==运算符只比较城市名称,然后我们可以只使用城市名称进行搜索,甚至不知道它的人口。但这不是一个好的设计。在下一步中,我们将以不同的方式进行:
          {
              auto found_cologne (find(begin(c), end(c), 
                  city{"Cologne", 1060000}));
              print_city(found_cologne);
          }
  1. 在不知道城市的人口数量,也不干扰其==运算符的情况下,我们只能通过比较其名称与向量的内容来搜索。std::find_if函数接受一个谓词函数对象,而不是特定的值。这样,我们可以在只知道其名称的情况下搜索科隆市的项目:
          {
              auto found_cologne (find_if(begin(c), end(c), 
                  [] (const auto &item) {
                      return item.name == "Cologne";
                  }));
              print_city(found_cologne);
          }
  1. 为了使搜索更加美观和表达力强,我们可以实现谓词构建器。population_higher_than函数对象接受一个人口规模,并返回一个告诉我们city实例是否比捕获的值具有更大人口的函数。让我们使用它来搜索我们小例子集中拥有两百万以上居民的德国城市。在给定的向量中,那个城市只有柏林:
          {
              auto population_more_than ([](unsigned i) {
                  return [=] (const city &item) { 
                      return item.population > i; 
                  };
              });
              auto found_large (find_if(begin(c), end(c), 
                  population_more_than(2000000)));
              print_city(found_large);
          }
  1. 我们刚刚使用的搜索函数遍历了我们的容器。因此它们的运行时复杂度为O(n)。STL 还有二进制搜索函数,它们在*O(log(n))*内工作。让我们生成一个新的例子数据集,它只包含一些整数值,并为此构建另一个print函数:
          const vector<int> v {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};

          auto print_int (opt_print(v));
  1. std::binary_search函数返回布尔值,只告诉我们是否找到了一个项目,但它返回项目本身。重要的是,我们正在搜索的容器是排序的,否则,二进制搜索就无法正确工作:
          bool contains_7 {binary_search(begin(v), end(v), 7)};
          cout << contains_7 << 'n';
  1. 为了得到我们正在搜索的项目,我们需要其他 STL 函数。其中之一是std::equal_range。它不返回我们找到的项目的迭代器,而是一对迭代器。第一个迭代器指向第一个不小于我们正在寻找的值的项目。第二个迭代器指向第一个大于它的项目。在我们的范围内,从110,第一个迭代器指向实际的7,因为它是第一个不小于7的项目。第二个迭代器指向8,因为它是第一个大于7的项目。如果我们有多个值为7,那么这两个迭代器实际上代表项目的子范围
          auto [lower_it, upper_it] (
              equal_range(begin(v), end(v), 7));
          print_int(lower_it);
          print_int(upper_it);
  1. 如果我们只需要一个迭代器;我们可以使用std::lower_boundstd::upper_boundlower_bound函数只返回一个迭代器,指向第一个不小于我们搜索的项目。upper_bound函数返回一个迭代器,指向第一个大于我们搜索的项目:
          print_int(lower_bound(begin(v), end(v), 7));
          print_int(upper_bound(begin(v), end(v), 7));
      }
  1. 让我们编译并运行程序,看看输出是否符合我们的假设:
      $ ./finding_items 
      {Cologne, 1060000}
      {Cologne, 1060000}
      {Berlin, 3502000}
      1
      7
      8
      7
      8

它是如何工作的...

这些是我们在这个配方中使用的搜索算法:

算法 目的
std::find 接受搜索范围和比较值作为参数。返回一个指向与比较值相等的第一个项目的迭代器。进行线性搜索。
std::find_if 类似于std::find,但使用谓词函数而不是比较值。
std::binary_search 接受搜索范围和比较值作为参数。执行二进制搜索,如果范围包含该值,则返回true
std::lower_bound 接受搜索范围和比较值,然后对第一个不小于比较值的项目执行二进制搜索。返回指向该项目的迭代器。
std::upper_bound 类似于std::lower_bound,但返回一个指向第一个大于比较值的项目的迭代器。
std::equal_range 接受搜索范围和比较值,然后返回一对迭代器。第一个迭代器是std::lower_bound的结果,第二个迭代器是std::upper_bound的结果。

所有这些函数都接受自定义比较函数作为可选的附加参数。这样,搜索可以被定制,就像我们在配方中所做的那样。

让我们更仔细地看看std::equal_range是如何工作的。假设我们有一个向量,v = {0, 1, 2, 3, 4, 5, 6, 7, 7, 7, 8},并调用equal_range(begin(v), end(v), 7);来对值7执行二进制搜索。由于equal_range给我们返回了一个下界和一个上界迭代器的一对,因此这些之后应该表示范围{7, 7, 7},因为在排序向量中有这么多值为7。查看以下图表以获得更清晰的解释:

首先,equal_range使用典型的二进制搜索方法,直到它遇到不小于搜索值的值范围。然后,它分成一个lower_bound调用和一个upper_bound调用,以将它们的返回值捆绑成一对作为返回值。

为了得到一个二进制搜索函数,它只返回符合要求的第一个项目,我们可以实现以下内容:

template <typename Iterator, typename T>
Iterator standard_binary_search(Iterator it, Iterator end_it, T value)
{
    const auto potential_match (lower_bound(it, end_it, value));
    if (potential_match != end_it && value == *potential_match) {
        return potential_match;
    }
    return end_it;
}

该函数使用std::lower_bound来找到第一个不小于value的项目。然后,得到的potential_match可以指向三种不同的情况:

  • 没有项目比value小。在这种情况下,它与end_it相同。

  • 第一个不小于value的项目也大于value。因此,我们必须通过返回end_it来表示我们没有找到它。

  • potential_match指向的项目等于value。因此,它不仅是一个potential匹配,而且是一个actual匹配。因此我们可以返回它。

如果我们的类型T不支持==运算符,那么它至少必须支持二分搜索的<运算符。然后,我们可以将比较重写为!(value < *potential_match) && !(*potential_match < value)。如果既不小也不大,那么它必须相等。

STL 没有提供这样一个函数的一个潜在原因是缺乏关于可能存在多个命中的可能性的知识,就像在我们有多个值为7的图表中一样。

请注意,诸如std::mapstd::set等的数据结构都有它们自己的find函数。当然,这些函数比更通用的算法更快,因为它们与数据结构的实现和数据表示紧密耦合。

使用 std::clamp 将向量的值限制在特定的数值范围内

在许多应用程序中,我们从某处获得数值数据。在我们可以绘制或以其他方式处理它之前,可能需要对其进行归一化,因为这些值之间的差异可能是随机的。

通常,这意味着对保存所有这些值的数据结构进行一次小的std::transform调用,结合一个简单的scaling函数。但是,如果我们不知道值有多大或多小,我们需要先通过数据找到合适的dimensions来进行缩放函数。

STL 包含了用于此目的的有用函数:std::minmax_elementstd::clamp。使用这些函数,并将它们与一些 lambda 表达式粘合在一起,我们可以轻松地执行这样的任务。

如何做...

在本节中,我们将以两种不同的方式将向量的值从示例数值范围归一化为归一化范围,其中一种使用std::minmax_element,另一种使用std::clamp

  1. 与往常一样,我们首先需要包括以下头文件并声明我们使用std命名空间:
       #include <iostream>
       #include <vector>
       #include <algorithm>
       #include <iterator>       

       using namespace std;
  1. 我们实现了一个供以后使用的函数,它接受范围的最小值和最大值,以及一个新的最大值,以便它可以将旧范围的值投影到我们想要的较小范围。函数对象接受这样的值,并返回另一个函数对象,该函数对象正是进行这种转换。为了简单起见,新的最小值是0,因此无论旧数据有什么偏移,其归一化值始终相对于0。为了可读性,我们忽略了maxmin可能具有相同值的可能性,这将导致除以零:
       static auto norm (int min, int max, int new_max)
       {
           const double diff (max - min);
           return [=] (int val) {
               return int((val - min) / diff * new_max);
           };
       }
  1. 另一个函数对象构建器称为clampval返回一个函数对象,该函数对象捕获minmax值,并在具有这些值的值上调用std::clamp,以限制它们的值在此范围内:
       static auto clampval (int min, int max)
       {
           return [=] (int val) -> int {
               return clamp(val, min, max);
           };
       }
  1. 我们要归一化的数据是一个包含不同值的向量。例如,这可能是某种热量数据,地形高度或随时间变化的股票价格:
       int main()
       {
           vector<int> v {0, 1000, 5, 250, 300, 800, 900, 321};
  1. 为了能够归一化数据,我们需要最高最低值。std::minmax_element函数在这里非常有帮助。它为我们返回了一个指向这两个值的迭代器对:
           const auto [min_it, max_it] (
               minmax_element(begin(v), end(v)));
  1. 我们将所有值从第一个向量复制到第二个向量。让我们实例化第二个向量,并准备接受与第一个向量中的新项目一样多的新项目:
           vector<int> v_norm;
           v_norm.reserve(v.size());
  1. 使用std::transform,我们将值从第一个向量复制到第二个向量。在复制项目的同时,它们将使用我们的归一化辅助函数进行转换。旧向量的最小值和最大值分别为01000。归一化后的最小值和最大值分别为0255
           transform(begin(v), end(v), back_inserter(v_norm),
                     norm(*min_it, *max_it, 255));
  1. 在我们实现另一种归一化策略之前,我们先打印一下我们现在有的东西:
           copy(begin(v_norm), end(v_norm), 
                ostream_iterator<int>{cout, ", "});
           cout << 'n';
  1. 我们使用另一个名为clampval的辅助函数重用相同的归一化向量,它旧范围限制为最小值为0和最大值为255的范围:
           transform(begin(v), end(v), begin(v_norm), 
                     clampval(0, 255));
  1. 打印这些值后,我们就完成了:
           copy(begin(v_norm), end(v_norm),
                ostream_iterator<int>{cout, ", "});
           cout << 'n';
       }
  1. 让我们编译并运行程序。将值减少到0255的值,我们可以将它们用作 RGB 颜色代码的亮度值,例如:
      $ ./reducing_range_in_vector 
      0, 255, 1, 63, 76, 204, 229, 81, 
      0, 255, 5, 250, 255, 255, 255, 255,
  1. 当我们绘制数据时,我们得到以下图表。正如我们所看到的,值除以最小值和最大值之间的差异的方法是原始数据的线性转换。夹紧图表丢失了一些信息。不同的情况下,这两种变化都可能有用:

它是如何工作的...

除了std::transform,我们使用了两种算法:

std::minmax_element只接受输入范围的开始和结束迭代器。它遍历范围并记录最大和最小的元素,然后返回这些值的一对,我们用于我们的缩放函数。

与之相反,std::clamp函数不适用于可迭代范围。它接受三个值:输入值、最小值和最大值。这个函数的输出是输入值被截断,以便它位于允许的最小值和最大值之间。我们也可以写max(min_val, min(max_val, x))而不是std::clamp(x, min_val, max_val)

使用 std::search 在字符串中定位模式并选择最佳实现

在字符串中搜索字符串与在范围中查找一个对象是一个略有不同的问题。一方面,字符串当然也是一个可迭代范围(字符);另一方面,在字符串中查找字符串意味着在另一个范围中查找一个范围。这伴随着每个潜在匹配位置的多次比较,因此我们需要一些其他的算法。

std::string已经包含一个find函数,它可以做我们正在谈论的事情;尽管如此,我们将在本节集中讨论std::search。尽管std::search可能主要用于字符串,但它适用于所有类型的容器。std::search更有趣的特性是,自 C++17 以来,它具有稍微不同的附加接口,并且允许简单地交换搜索算法本身。这些算法是经过优化的,可以根据使用情况自由选择。此外,如果我们能想出比已提供的更好的东西,我们还可以实现自己的搜索算法并将它们插入std::search

如何做...

我们将使用新的std::search函数与字符串,并尝试其不同的变体与搜索器对象:

  1. 首先,我们将包括所有必要的标头,并声明我们使用std命名空间:
       #include <iostream>
       #include <string>
       #include <algorithm>
       #include <iterator>
       #include <functional>       

       using namespace std;
  1. 我们将打印搜索算法返回给我们的位置的子字符串,因此让我们为此实现一个小助手:
       template <typename Itr>
       static void print(Itr it, size_t chars)
       {
           copy_n(it, chars, ostream_iterator<char>{cout});
           cout << 'n';
       }
  1. 一个lorem-ipsum 风格的字符串将作为我们的示例字符串,我们将在其中搜索一个子字符串。在这种情况下,这是"elitr"
       int main()
       {
           const string long_string {
               "Lorem ipsum dolor sit amet, consetetur"
               " sadipscing elitr, sed diam nonumy eirmod"};
           const string needle {"elitr"};
  1. 旧的std::search接口接受我们正在搜索特定子字符串的字符串的开始/结束迭代器以及子字符串的开始/结束迭代器。然后返回一个指向它能够找到的子字符串的迭代器。如果没有找到字符串,返回的迭代器将是结束迭代器:
           {
               auto match (search(begin(long_string), end(long_string),
                                  begin(needle), end(needle)));
               print(match, 5);
           }
  1. C++17 版本的std::search不接受两对迭代器,而是接受一对开始/结束迭代器和一个searcher对象。std::default_searcher接受我们在较大字符串中搜索的子字符串的开始/结束迭代器对:
           {
               auto match (search(begin(long_string), end(long_string),
                   default_searcher(begin(needle), end(needle))));
               print(match, 5);
           }
  1. 这种改变的重点是这样很容易切换搜索算法。std::boyer_moore_searcher使用Boyer-Moore 搜索算法进行更快的搜索:
           {
               auto match (search(begin(long_string), end(long_string),
                   boyer_moore_searcher(begin(needle), 
                                        end(needle))));
               print(match, 5);
           }
  1. C++17 STL 带有三种不同的搜索器对象实现。第三个是 Boyer-Moore-Horspool 搜索算法实现:
           {
               auto match (search(begin(long_string), end(long_string),
                   boyer_moore_horspool_searcher(begin(needle), 
                                                 end(needle))));
               print(match, 5);
           }
       }
  1. 让我们编译并运行我们的程序。如果运行正确,我们应该在任何地方看到相同的字符串:
      $ ./pattern_search_string 
      elitr
      elitr
      elitr
      elitr

它是如何工作的...

我们使用了四种不同的方法来使用std::search,以获得完全相同的结果。在什么情况下应该使用哪种?

假设我们在其中搜索模式的大字符串称为s,模式称为p。然后,std::search(begin(s), end(s), begin(p), end(p));std::search(begin(s), end(s), default_searcher(begin(p), end(p));做的事情完全一样。

其他搜索函数对象是使用更复杂的搜索算法实现的。

  • std::default_searcher:这将重定向到传统的std::search实现

  • std::boyer_moore_searcher:这使用Boyer-Moore搜索算法

  • std::boyer_moore_horspool_searcher:这类似地使用Boyer-Moore-Horspool算法

其他算法有什么特别之处?Boyer-Moore 算法是根据一个特定的想法开发的——搜索模式与字符串进行比较,从右到左从模式的末尾开始。如果搜索字符串中的字符与覆盖位置处模式中的字符不同,并且在模式中甚至不存在,那么很明显,模式可以通过其完整长度移动到搜索字符串上。看一下下面的图表,在步骤 1 中发生了这种情况。如果当前比较的字符与该位置处模式的字符不同,但包含在模式中,则算法知道模式需要向右移动多少个字符才能正确对齐至少该字符,然后,它重新开始右到左的比较。在图表中,这在步骤 2 中发生。这样,与朴素的搜索实现相比,Boyer-Moore 算法可以省略很多不必要的比较:

当然,如果它没有带来自己的权衡,这将成为新的默认搜索算法。它比默认算法更快,但它需要快速查找数据结构,以确定哪些字符包含在搜索模式中,以及它们位于哪个偏移量。编译器将根据模式由哪些基础类型组成(在复杂类型之间变化为哈希映射,对于char等类型的基本查找表)选择不同复杂的实现。最终,这意味着如果搜索字符串不太大,则默认搜索实现将更快。如果搜索本身需要一些显着的时间,那么 Boyer-Moore 算法可以在常数因子的维度上带来性能增益。

Boyer-Moore-Horspool算法是 Boyer-Moore 算法的简化。它放弃了坏字符规则,这导致整个模式宽度的移位,如果找到一个搜索字符串字符,它在模式字符串中不存在。这个决定的权衡是它比未修改的 Boyer-Moore 稍慢,但它也需要更少的数据结构来进行操作。

不要试图推断在特定情况下哪种算法应该更快。始终使用对您的用户典型的数据样本测量代码的性能,并根据结果做出决定。

抽样大向量

当需要处理非常大量的数字数据时,在某些情况下,可能无法在可行的时间内处理所有数据。在这种情况下,可以对数据进行抽样,以减少进一步处理的总数据量,从而加快整个程序。在其他情况下,这可能不是为了减少处理工作量,而是为了保存传输数据。

抽样的一个天真的想法可能是只选择每第N个数据点。在许多情况下这可能是可以的,但在信号处理中,例如,它可能会导致一种称为混叠的数学现象。如果每个样本之间的距离受到小的随机偏移的影响,混叠可以被减少。看一下下面的图表,它展示了一个极端情况,只是为了说明这一点--原始信号由正弦波组成,图表上的三角形点是在每个100个数据点处进行抽样的抽样点。不幸的是,这些点的信号在这些点上具有相同的 y 值!然而,方形点显示了当我们抽样每100 + random(-15, +15)个点时我们得到的结果。在这里,信号看起来仍然与原始信号非常不同,但至少不像固定步长抽样情况下完全消失

std::sample函数不会对固定偏移的样本点进行随机更改,而是选择完全随机的点;因此,它的工作方式与这个例子有些不同:

如何做...

我们将对一个非常大的随机数据向量进行抽样。这些随机数据显示正态分布。在对其进行抽样后,结果点应该仍然显示出正态分布,我们将进行检查:

  1. 首先,我们需要包括我们使用的所有内容,并声明我们使用std命名空间,以节省一些输入:
       #include <iostream>
       #include <vector>
       #include <random>
       #include <algorithm>
       #include <iterator>
       #include <map>
       #include <iomanip>       

       using namespace std;
  1. 如果我们在它们自己的常量变量中配置我们算法的特定特征,那么就更容易玩弄代码。这些是大型随机向量的大小和我们将从中获取的样本数量:
       int main()
       {
           const size_t data_points   {100000};
           const size_t sample_points {100};
  1. 大型的、随机填充的向量应该从随机数生成器中获得数字,该生成器从正态分布中输出数字。任何正态分布都可以由平均值和与平均值的标准偏差来描述:
           const int    mean {10};
           const size_t dev  {3};
  1. 现在,我们设置随机生成器。首先,我们实例化一个随机设备,并调用它一次以获得用于随机生成器构造函数的种子。然后,我们实例化一个应用正态分布于随机输出的分布对象:
           random_device rd;
           mt19937 gen {rd()};
           normal_distribution<> d {mean, dev};
  1. 现在,我们实例化一个整数向量,并用大量随机数填充它。这是通过使用std::generate_n算法实现的,该算法将调用一个生成器函数对象,将其返回值馈送到我们的向量中,使用back_inserter迭代器。生成器函数对象只是包装在d(gen)表达式周围,该表达式从随机设备获取随机数,并将其馈送到分布对象中:
           vector<int> v;
           v.reserve(data_points);

           generate_n(back_inserter(v), data_points, 
               [&] { return d(gen); });
  1. 现在,我们实例化另一个向量,它将包含较小的样本集:
           vector<int> samples;
           v.reserve(sample_points);
  1. std::sample算法类似于std::copy,但它需要两个额外的参数:样本数量,它应该从输入范围中获取的样本数量,以及一个随机数生成器对象,它将用于获取随机抽样位置:
           sample(begin(v), end(v), back_inserter(samples), 
                  sample_points, mt19937{random_device{}()});
  1. 我们已经完成了抽样。其余的代码是为了显示目的。输入数据具有正态分布,如果抽样算法运行良好,那么抽样向量应该也显示正态分布。为了查看剩下多少正态分布,我们将打印值的直方图
           map<int, size_t> hist;

           for (int i : samples) { ++hist[i]; }
  1. 最后,我们循环遍历所有项目以打印我们的直方图:
           for (const auto &[value, count] : hist) {
               cout << setw(2) << value << " "
                    << string(count, '*') << 'n';
           }    
       }
  1. 编译并运行程序后,我们看到抽样向量仍然大致显示出正态分布的特征:

它的工作原理是...

std::sample算法是一个新算法,它随 C++17 一起推出。它的签名如下:

template<class InIterator, class OutIterator,
         class Distance, class UniformRandomBitGenerator>
OutIterator sample(InIterator first, InIterator last,
                   SampleIterator out, Distance n, 
                   UniformRandomBitGenerator&& g);

输入范围由firstlast迭代器表示,而out是输出操作符。这些迭代器的功能与std::copy中的功能完全相同;项从一个范围复制到另一个范围。std::sample算法在这方面是特殊的,因为它只会复制输入范围的一部分,因为它只对n个项进行抽样。它在内部使用均匀分布,因此源范围中的每个数据点都以相同的概率被选择。

生成输入序列的排列

在测试必须处理输入序列的代码时,如果参数的顺序不重要,测试它是否对该输入的所有可能的排列产生相同的输出是有益的。例如,这样的测试可以检查自己实现的排序算法是否正确排序。

无论出于什么原因,我们需要某个值范围的所有排列,std::next_permutation可以方便地为我们做到这一点。我们可以在可修改的范围上调用它,它会改变其项的顺序到下一个字典序排列

如何做...

在本节中,我们将编写一个程序,从标准输入中读取多个单词字符串,然后我们将使用std::next_permutation来生成并打印这些字符串的所有排列:

  1. 首先还是先来一些基础工作;我们包含所有必要的头文件,并声明我们使用std命名空间:
      #include <iostream>
      #include <vector>
      #include <string>
      #include <iterator>
      #include <algorithm>      

      using namespace std;
  1. 我们从一个字符串向量开始,我们用整个标准输入来填充它。下一步是排序向量:
      int main()
      {
          vector<string> v {istream_iterator<string>{cin}, {}};
          sort(begin(v), end(v));
  1. 现在,我们在用户终端上打印向量的内容。之后,我们调用std::next_permutation。它会系统地洗牌向量以生成其项的排列,然后我们再次打印。当达到最后一个排列时,next_permutation会返回false
          do {
              copy(begin(v), end(v), 
                   ostream_iterator<string>{cout, ", "});
              cout << 'n';
          } while (next_permutation(begin(v), end(v)));
      }
  1. 让我们用一些示例输入编译并运行该函数:
      $ echo "a b c" | ./input_permutations 
      a, b, c, 
      a, c, b, 
      b, a, c, 
      b, c, a, 
      c, a, b, 
      c, b, a,

它是如何工作的...

std::next_permutation算法使用起来有点奇怪。这是因为它只接受一个迭代器的开始/结束对,然后如果能找到下一个排列就返回true。否则,返回false。但是下一个排列到底是什么意思呢?

std::next_permutation用于找到项的下一个字典序排列的算法工作如下:

  1. 找到最大的索引i,使得v[i - 1] < v[i]。如果没有,则返回false

  2. 现在,找到最大的索引j,使得j >= iv[j] > v[i - 1]

  3. 在位置j和位置i - 1交换项。

  4. 反转从位置i到范围末尾的项的顺序。

  5. 返回true

我们从中得到的各自排列的顺序总是相同的。为了看到所有可能的排列,我们首先对数组进行排序,因为如果我们输入了"c b a",例如,算法会立即终止,因为这已经是元素的最后字典序排列。

实现字典合并工具

想象我们有一个排好序的东西列表,然后另外有人提出了另一个排好序的东西列表,我们想要彼此分享这些列表。最好的主意是将这两个列表合并。这两个列表的组合也应该是排好序的,这样,查找特定项就很容易了。

这样的操作也被称为合并。为了合并两个排好序的项范围,我们直观地会创建一个新范围,并从两个列表中的项中获取它。对于每个项的转移,我们必须比较输入范围的最前面的项,以便始终选择剩下的输入中的最小项。否则,输出范围将不再是排好序的。下面的图示更好地说明了这一点:

std::merge算法可以为我们做到这一点,所以我们不需要太多地摆弄。在本节中,我们将看到如何使用这个算法。

如何做...

我们将建立一个廉价的字典,从英语单词到它们的德语翻译的一对一映射,并将它们存储在std::deque结构中。程序将从文件和标准输入中读取这样的字典,并再次在标准输出上打印一个大的合并字典。

  1. 这次需要包含很多头文件,并且我们声明使用std命名空间:
      #include <iostream>
      #include <algorithm>
      #include <iterator>
      #include <deque>
      #include <tuple>
      #include <string>
      #include <fstream>     

      using namespace std;
  1. 一个字典条目应该包括从一种语言的字符串到另一种语言的字符串的对称映射:
      using dict_entry = pair<string, string>;
  1. 我们将同时将这样的对打印到终端并从用户输入中读取,因此我们需要重载<<>>运算符:
      namespace std {
      ostream& operator<<(ostream &os, const dict_entry p)
      {
          return os << p.first << " " << p.second;
      }

      istream& operator>>(istream &is, dict_entry &p)
      {
          return is >> p.first >> p.second;
      }

      }
  1. 一个接受任何输入流对象的辅助函数将帮助我们构建一个字典。它构造了一个字典条目对的std::deque,并且它们都从输入流中读取,直到输入流为空。在返回之前,我们对它进行排序:
      template <typename IS>
      deque<dict_entry> from_instream(IS &&is)
      {
          deque<dict_entry> d {istream_iterator<dict_entry>{is}, {}};
          sort(begin(d), end(d));
          return d;
      }
  1. 我们从不同的输入流中创建了两个单独的字典数据结构。一个输入流是从dict.txt文件中打开的,我们假设它存在。它包含逐行的单词对。另一个流是标准输入:
      int main()
      {
          const auto dict1 (from_instream(ifstream{"dict.txt"}));
          const auto dict2 (from_instream(cin));
  1. 由于辅助函数from_instream已经为我们对这两个字典进行了排序,我们可以直接将它们输入std::merge算法。它通过它的开始/结束迭代器对接受两个输入范围,并且一个输出。输出将是用户的 shell:
          merge(begin(dict1), end(dict1),
                begin(dict2), end(dict2),
                ostream_iterator<dict_entry>{cout, "n"});
      }
  1. 现在我们可以编译程序了,但在运行之前,我们应该创建一个dict.txt文件,并填充一些示例内容。让我们用一些英语单词和它们的德语翻译来填充它:
      car       auto
      cellphone handy
      house     haus
  1. 现在,我们可以启动程序,同时将一些英语-德语翻译传递给它的标准输入。输出是一个合并且仍然排序的字典,其中包含了两个输入的翻译。我们可以从中创建一个新的字典文件:
      $ echo "table tisch fish fisch dog hund" | ./dictionary_merge
      car auto
      cellphone handy
      dog hund
      fish fisch
      house haus
      table tisch

它是如何工作的...

std::merge算法接受两对开始/结束迭代器,表示输入范围。这些范围必须是排序的。第五个参数是一个输出迭代器,接受合并过程中传入的项目。

还有一种叫做std::inplace_merge的变体。这个算法和其他算法一样,但它不需要输出迭代器,因为它是原地工作的,正如它的名字所暗示的那样。它接受三个参数:一个开始迭代器,一个中间迭代器和一个结束迭代器。这些迭代器必须都引用相同数据结构中的数据。中间迭代器同时也是第一个范围的结束迭代器,以及第二个范围的开始迭代器。这意味着这个算法处理一个单一范围,实际上包括两个连续的范围,比如,例如{A, C, B, D}。第一个子范围是{A, C},第二个子范围是{B, D}。然后std::inplace_merge算法可以在同一个数据结构中合并两者,结果是{A, B, C, D}

第二十六章:STL 算法的高级用法

本章将涵盖以下食谱:

  • 使用 STL 算法实现 trie 类

  • 使用 trie 实现搜索输入建议生成器

  • 使用 STL 数值算法实现傅里叶变换公式

  • 计算两个向量的误差和

  • 使用 STL 算法实现 ASCII Mandelbrot 渲染器

  • 构建我们自己的算法 - split

  • 从标准算法中组合有用的算法 - gather

  • 删除单词之间的连续空格

  • 压缩和解压字符串

介绍

在上一章中,我们访问了基本的 STL 算法,并使用它们执行了简单的任务,以便对典型的 STL 接口有所了解:大多数 STL 算法接受形式为迭代器对的一个或多个范围作为输入/输出参数。它们通常还接受谓词函数、自定义比较函数或转换函数。最后,它们大多再次返回迭代器,因为这些迭代器通常可以随后被输入到其他一些算法中。

虽然 STL 算法旨在尽可能简化,但它们的接口也尽可能通用。这样可以最大程度地实现代码重用,但并不总是看起来太美观。一个有经验的 C++程序员,如果尽可能使用 STL 算法来表达尽可能多的想法,阅读其他人的代码会更轻松。这导致了程序员和读者之间理解的最大化共同基础。程序员的大脑可以更快地解析一个众所周知的算法名称,而不是理解一个复杂的循环,它主要做着类似的事情,但在某些细节上略有不同。

到目前为止,我们使用 STL 数据结构如此直观,以至于我们可以很好地避免指针、原始数组和其他粗糙的遗留结构。下一步是将我们对 STL 算法的理解提升到可以避免使用手工编写的循环控制结构复杂性的水平,而是用众所周知的 STL 算法来表达它们。通常,这是一个真正的改进,因为代码变得更简洁、更易读,同时更通用和数据结构无关。实际上,几乎总是可以避免编写手工循环,并将算法从std命名空间中取出,但有时,这确实会导致笨拙的代码。我们不打算区分什么是笨拙的,什么不是;我们只会探索可能性。

在本章中,我们将以创造性的方式使用 STL 算法,以寻找新的视角,并看看如何用现代 C++实现事物。在这个过程中,我们将实现我们自己的类似 STL 的算法,这些算法可以轻松地与现有数据结构和以相同方式设计的其他算法结合使用。我们还将组合现有的 STL 算法,以获得的算法,这些算法以前不存在。这样组合的算法允许在现有算法的基础上实现更复杂的算法,同时它们本身以这种方式极其简短和易读。在这个小旅程中,我们还将看到 STL 算法在可重用性或美观性方面的具体问题。只有当我们充分了解所有的方式时,我们才能最好地决定哪种方式是正确的。

使用 STL 算法实现 trie 类

所谓的trie数据结构提出了一种将数据以易于搜索的方式存储的有趣方法。在将文本句子分割成单词列表时,通常可以将一些句子共有的前几个单词合并起来。

让我们看一下下面的图表,在这里,句子"hi how are you""hi how do you do"被保存在类似树状的数据结构中。它们共有的第一个单词是"hi how",然后它们不同并分裂成树状:

因为 trie 数据结构结合了常见的前缀,所以它也被称为前缀树。使用 STL 已经给我们的东西实现这样的数据结构非常容易。本节集中在实现我们自己的 trie 类。

如何做...

在本节中,我们将实现我们自己的前缀树,只使用 STL 数据结构和算法。

  1. 我们将包括我们使用的 STL 部分的所有头文件,并声明我们默认使用std命名空间:
      #include <iostream>
      #include <optional>
      #include <algorithm>
      #include <functional>
      #include <iterator>
      #include <map>
      #include <vector>
      #include <string>

      using namespace std;
  1. 整个程序围绕着一个 trie,我们首先必须实现一个类。在我们的实现中,trie 基本上是一个递归的映射。每个 trie 节点包含一个映射,它将有效载荷类型T的实例映射到下一个 trie 节点:
      template <typename T>
      class trie
      {
          map<T, trie> tries;
  1. 插入新项目序列的代码很简单。用户提供一个 begin/end 迭代器对,我们通过递归循环遍历它。如果用户输入序列是{1, 2, 3},那么我们在子 trie 中查找1,然后在下一个子 trie 中查找2,以获取3的子 trie。如果这些子 trie 中的任何一个以前不存在,它们将被std::map[]运算符隐式添加:
      public:
          template <typename It>
          void insert(It it, It end_it) {
              if (it == end_it) { return; }
              tries[*it].insert(next(it), end_it);
          }
  1. 我们还定义了方便的函数,使用户只需提供一个项目容器,然后自动查询迭代器:
          template <typename C>
          void insert(const C &container) {
              insert(begin(container), end(container));
          }
  1. 为了允许用户编写my_trie.insert({"a", "b", "c"});,我们必须帮助编译器正确推断出那一行的所有类型,所以我们添加了一个函数,它重载了带有initializer_list参数的插入接口:
          void insert(const initializer_list<T> &il) {
              insert(begin(il), end(il));
          }
  1. 我们还想看看 trie 中有什么,所以我们需要一个print函数。为了打印,我们可以通过 trie 进行深度优先搜索。从根节点到第一个叶子的路上,我们记录我们已经看到的所有有效载荷项目。这样,一旦到达叶子,我们就有了一个完整的序列,这是微不足道的可打印的。当tries.empty()true时,我们看到我们到达了一个叶子。递归print调用之后,我们再次弹出最后添加的有效载荷项目:
          void print(vector<T> &v) const {
              if (tries.empty()) {
                  copy(begin(v), end(v), 
                       ostream_iterator<T>{cout, " "});
                  cout << 'n';
              }
              for (const auto &p : tries) {
                  v.push_back(p.first);
                  p.second.print(v);
                  v.pop_back();
              }
          }
  1. 递归的print函数传递一个可打印的有效载荷项目列表的引用,但用户应该在没有任何参数的情况下调用它。因此,我们定义了一个无参数的print函数,它构造了辅助列表对象:
          void print() const {
              vector<T> v;
              print(v);
          }
  1. 现在我们可以构建和打印 tries,我们可能想要搜索子 tries。这个想法是,如果 trie 包含序列,比如{a, b, c}{a, b, d, e},并且我们给它一个序列{a, b}进行搜索,它会返回包含{c}{d, e}部分的子 trie。如果我们找到了子 trie,我们会返回一个const引用。有可能 trie 中没有这样的子 trie,如果 trie 不包含我们要搜索的序列。在这种情况下,我们仍然需要返回somethingstd::optional是一个很好的帮助类,因为如果没有匹配,我们可以返回一个empty optional 对象:
          template <typename It>
          optional<reference_wrapper<const trie>> 
          subtrie(It it, It end_it) const {
              if (it == end_it) { return ref(*this); }
              auto found (tries.find(*it));
              if (found == end(tries)) { return {}; }

              return found->second.subtrie(next(it), end_it);
          }
  1. insert方法类似,我们提供了subtrie方法的单参数版本,它会自动从输入容器中获取迭代器:
          template <typename C>
          auto subtrie(const C &c) { 
              return subtrie(begin(c), end(c));
          }
      };
  1. 就是这样。让我们在主函数中使用新的 trie 类,通过实例化一个专门用于std::string对象的 trie,并填充一些示例内容:
      int main()
      {
          trie<string> t;

          t.insert({"hi", "how", "are", "you"});
          t.insert({"hi", "i", "am", "great", "thanks"});
          t.insert({"what", "are", "you", "doing"});
          t.insert({"i", "am", "watching", "a", "movie"});
  1. 让我们先打印整个 trie:
          cout << "recorded sentences:n";
          t.print();
  1. 然后我们获得了所有以"hi"开头的输入句子的子 trie,并打印它:
          cout << "npossible suggestions after "hi":n";

          if (auto st (t.subtrie(initializer_list<string>{"hi"})); 
              st) {
              st->get().print();
          }
      }
  1. 编译和运行程序显示,当我们查询 trie 时,它确实只返回以"hi"开头的两个句子:
      $ ./trie 
      recorded sentences:
      hi how are you 
      hi i am great thanks 
      i am watching a movie 
      what are you doing 

      possible suggestions after "hi":
      how are you 
      i am great thanks 

它是如何工作的...

有趣的是,单词序列插入的代码比在子 trie 中查找给定单词序列的代码更短更简单。所以,让我们先看一下插入代码:

template <typename It>
void trie::insert(It it, It end_it) {
    if (it == end_it) { return; }
    tries[*it].insert(next(it), end_it);
}

迭代器对itend_it表示要插入的单词序列。tries[*it]元素在子 trie 中查找序列中的第一个单词,然后,.insert(next(it), end_it)重新启动相同的函数,该函数在较低的子 trie 上,迭代器向前移动一个单词。if (it == end_it) { return; }行只是中止递归。空的return语句什么都不做,这一点起初有点奇怪。所有的插入都发生在tries[*it]语句中。std::map的方括号操作符[]要么返回给定键的现有项,要么使用该键创建一个项。关联值(映射类型是本食谱中的 trie)是从其默认构造函数构造的。这样,每当我们查找未知单词时,我们都会隐式创建一个新的 trie 分支。

在子 trie 中查找看起来更复杂,因为我们无法在隐式代码中隐藏太多内容:

template <typename It>
optional<reference_wrapper<const trie>> 
subtrie(It it, It end_it) const {
    if (it == end_it) { return ref(*this); }
    auto found (tries.find(*it));
    if (found == end(tries)) { return {}; }

    return found->second.subtrie(next(it), end_it);
}

这段代码基本上围绕着auto found (tries.find(*it));语句。我们使用find来查找下一个更深的 trie 节点,而不是使用方括号操作符([])。如果我们在查找时使用[]操作符,trie 将为我们创建缺失的项,这是我们在查找项是否存在时想要的!(顺便说一句,试着这样做。类方法是const,所以这甚至不可能。这可以挽救生命,帮助我们防止错误。)

另一个看起来可怕的细节是返回类型,optional<reference_wrapper<const trie>>。我们选择std::optional作为包装器,因为可能没有这样的子 trie 与我们正在寻找的输入序列相匹配。如果我们只插入了"hello my friend",那么就不会有"goodbye my friend"序列可以查找。在这种情况下,我们只返回{},这会给调用者一个空的 optional 对象。这仍然不能解释为什么我们使用reference_wrapper而不只是写optional<const trie &>。这里的重点是,具有trie&类型成员变量的可选实例是不可重新分配的,因此不会编译。使用reference_wrapper实现引用会导致可重新分配的对象。

使用 trie 实现搜索输入建议生成器

在互联网搜索引擎中输入内容时,界面通常会尝试猜测完整的搜索查询是什么样子。这种猜测通常基于过去的热门搜索查询。有时,这样的搜索引擎猜测很有趣,因为人们似乎在搜索引擎中输入了奇怪的查询。

在本节中,我们将使用前面食谱中实现的 trie 类,并构建一个小型的搜索查询建议引擎。

如何做到...

在本节中,我们将实现一个终端应用程序,它接受一些输入,然后尝试根据一个简单的文本文件数据库猜测用户可能想要查找的内容:

  1. 和往常一样,首先是包含部分,我们定义使用std命名空间:
      #include <iostream>
      #include <optional>
      #include <algorithm>
      #include <functional>
      #include <iterator>
      #include <map>
      #include <list>
      #include <string>
      #include <sstream>
      #include <fstream>  

      using namespace std;
  1. 我们使用了 trie 食谱中的 trie 实现:
      template <typename T>
      class trie
      {
          map<T, trie> tries;

      public:
          template <typename It>
          void insert(It it, It end_it) {
              if (it == end_it) { return; }
              tries[*it].insert(next(it), end_it);
          }

          template <typename C>
          void insert(const C &container) {
              insert(begin(container), end(container));
          }

          void insert(const initializer_list<T> &il) {
              insert(begin(il), end(il));
          }

          void print(list<T> &l) const {
              if (tries.empty()) {
                  copy(begin(l), end(l), 
                       ostream_iterator<T>{cout, " "});
                  cout << 'n';
              }
              for (const auto &p : tries) {
                  l.push_back(p.first);
                  p.second.print(l);
                  l.pop_back();
              }
          }

          void print() const {
              list<T> l;
              print(l);
          }

          template <typename It>
          optional<reference_wrapper<const trie>>
          subtrie(It it, It end_it) const {
              if (it == end_it) { return ref(*this); }
              auto found (tries.find(*it));
              if (found == end(tries)) { return {}; }

      return found->second.subtrie(next(it), end_it);
          }

          template <typename C>
          auto subtrie(const C &c) const { 
              return subtrie(begin(c), end(c));
          }
      };
  1. 让我们添加一个小的辅助函数,打印一行提示用户输入一些文本:
      static void prompt()
      {
          cout << "Next input please:n > ";
      }
  1. 在主函数中,我们打开一个文本文件,它充当我们的句子数据库。我们逐行读取该文本文件,并将这些行输入到 trie 中:
      int main()
      {
          trie<string> t;

          fstream infile {"db.txt"};
          for (string line; getline(infile, line);) {
              istringstream iss {line};
              t.insert(istream_iterator<string>{iss}, {});
          }
  1. 现在我们已经从文本文件中的内容构建了 trie,我们需要为用户实现一个查询接口。我们提示用户输入一些文本,并等待输入一整行:
          prompt();
          for (string line; getline(cin, line);) {
              istringstream iss {line};
  1. 有了这个文本输入,我们查询 trie 以从中获取一个子 trie。如果我们在文本文件中已经有这样的输入序列,那么我们可以打印出输入如何继续,就像搜索引擎建议功能一样。如果我们找不到匹配的子 trie,我们只是告诉用户:
              if (auto st (t.subtrie(istream_iterator<string>{iss}, {})); 
                  st) {
                  cout << "Suggestions:n";
                  st->get().print();
              } else {
                  cout << "No suggestions found.n";
              }
  1. 之后,我们再次打印提示文本,并等待用户输入下一行。就是这样。
              cout << "----------------n";
              prompt();
          }
      }
  1. 在考虑启动程序之前,我们需要将一些内容填入db.txt。输入可以是任何内容,甚至不需要排序。每行文本将是一个 trie 序列:
      do ghosts exist
      do goldfish sleep
      do guinea pigs bite
      how wrong can you be
      how could trump become president
      how could this happen to me
      how did bruce lee die
      how did you learn c++
      what would aliens look like
      what would macgiver do
      what would bjarne stroustrup do
      ...
  1. 在运行程序之前,我们需要创建db.txt。它的内容可能是这样的:
      hi how are you 
      hi i am great thanks 
      do ghosts exist
      do goldfish sleep
      do guinea pigs bite
      how wrong can you be
      how could trump become president
      how could this happen to me
      how did bruce lee die
      how did you learn c++
      what would aliens look like
      what would macgiver do
      what would bjarne stroustrup do
      what would chuck norris do
      why do cats like boxes
      why does it rain
      why is the sky blue
      why do cats hate water
      why do cats hate dogs
      why is c++ so hard
  1. 编译和运行程序并输入一些内容看起来像这样:
      $ ./word_suggestion 
      Next input please:
       > what would
      Suggestions:
      aliens look like 
      bjarne stroustrup do 
      chuck norris do 
      macgiver do 
      ----------------
      Next input please:
       > why do
      Suggestions:
      cats hate dogs 
      cats hate water 
      cats like boxes 
      ----------------
      Next input please:
       > 

它是如何工作的...

trie 的工作原理在上一个示例中已经解释过了,但是我们如何填充它以及如何查询它在这里看起来有点奇怪。让我们仔细看一下填充空 trie 的代码片段:

fstream infile {"db.txt"};
for (string line; getline(infile, line);) {
    istringstream iss {line};
    t.insert(istream_iterator<string>{iss}, {});
}

循环将字符串line逐行填充文本文件的内容。然后,我们将字符串复制到一个istringstream对象中。从这样的输入流对象,我们可以创建一个istream_iterator,这很有用,因为我们的 trie 不仅接受一个容器实例来查找子 trie,而且主要是迭代器。这样,我们就不需要构造一个单词的向量或列表,可以直接使用字符串。通过移动line的内容到iss中,可以避免最后一块不必要的内存分配。不幸的是,std::istringstream没有提供接受std::string值来移动的构造函数。尽管如此,它仍然会复制它的输入字符串。

当读取用户的输入以在 trie 中查找时,我们使用完全相同的策略,但我们不使用输入文件流。相反,我们使用std::cin。对于我们的用例,这完全相同,因为trie::subtrietrie::insert一样使用迭代器。

还有更多...

可以在 trie 的每个节点上添加计数变量。这样,就可以计算某个输入中前缀出现的频率。从而,我们可以根据它们的出现频率对我们的建议进行排序,这实际上就是搜索引擎所做的。智能手机触摸屏文本输入的单词建议也可以用这种方式实现。

这个修改留给读者作为一个练习。

使用 STL 数值算法实现傅立叶变换公式

傅立叶变换是信号处理中非常重要和著名的公式。它是近 200 年前发明的,但随着计算机的出现,它的用例数量真的飙升了。它被用于音频/图像/视频压缩、音频滤波器、医学成像设备、手机应用程序在听音乐时实时识别音轨等等。

由于一般数值应用场景的广泛性(当然不仅仅是傅立叶变换),STL 也试图在数值计算的上下文中提供帮助。傅立叶变换只是其中的一个例子,但也是一个棘手的例子。公式本身看起来像这样:

它描述的转换基本上是一个总和。总和的每个元素都是输入信号向量的数据点与表达式exp(-2 * i * ...)的乘积。这背后的数学对于不了解复数(或者只是不喜欢数学)的每个人来说都有点可怕,但是要实现它也不是完全必要完全理解数学。仔细观察公式,它说总和符号循环遍历信号的每个数据点(长度为N)使用循环变量j。变量k是另一个循环变量,因为傅立叶变换不是用来计算单个值的,而是用来计算一系列值的。在这个向量中,每个数据点代表了某个重复波频率的强度和相位,这个频率是原始信号的一部分或者不是。当使用手动循环来实现这个时,我们最终会得到类似以下的代码:

csignal fourier_transform(const csignal &s) { 
    csignal t(s.size()); 
    const double pol {-2.0 * M_PI / s.size()};

    for (size_t k {0}; k < s.size(); ++k) { 
        for (size_t j {0}; j < s.size(); ++j) { 
            t[k] += s[j] * polar(1.0, pol * k * j); 
        }
    } 
    return t; 
}

csignal 类型可以是复数的 std::vector 向量。对于复数,有一个 std::complex STL 类,可以帮助表示它们。std::polar 函数基本上执行 exp(-i * 2 * ...) 部分。

这已经很好了,但我们将使用 STL 工具来实现它。

如何做...

在本节中,我们将实现傅立叶变换及其反向变换,然后玩弄一下,以转换一些信号:

  1. 首先,我们包括所有的头文件,并声明我们使用 std 命名空间:
      #include <iostream>
      #include <complex>
      #include <vector>
      #include <algorithm>
      #include <iterator>
      #include <numeric>
      #include <valarray>
      #include <cmath>      

      using namespace std;
  1. 信号的数据点是一个复数,应该由 std::complex 表示,专门针对 double 类型。这样,类型别名 cmplx 代表两个耦合的 double 值,它们代表复数的 实部虚部。整个信号是这些项目的向量,我们将其别名为 csignal 类型:
      using cmplx   = complex<double>;
      using csignal = vector<cmplx>;
  1. 为了迭代一个递增的数字序列,我们从数字迭代器配方中获取 numeric iterator。公式中的变量 kj 将迭代这样的序列:
      class num_iterator {
          size_t i;
      public:
          explicit num_iterator(size_t position) : i{position} {}

          size_t operator*() const { return i; }

          num_iterator& operator++() {
              ++i;
              return *this;
          }

          bool operator!=(const num_iterator &other) const {
              return i != other.i;
          }
      };
  1. 傅立叶变换函数应该只接受一个信号并返回一个新的信号。返回的信号表示输入信号的傅立叶变换。由于从傅立叶变换信号到原始信号的反变换非常相似,我们提供了一个可选的 bool 参数,用于选择变换方向。请注意,bool 参数通常是不好的做法,特别是如果我们在函数签名中使用多个 bool 参数。这里我们只是为了简洁起见使用了一个。

我们要做的第一件事是分配一个具有初始信号大小的新信号向量:

      csignal fourier_transform(const csignal &s, bool back = false)
      {
          csignal t (s.size());
  1. 公式中有两个因素,它们总是看起来一样。让我们把它们打包到它们自己的变量中:
          const double pol {2.0 * M_PI * (back ? -1.0 : 1.0)};
          const double div {back ? 1.0 : double(s.size())};
  1. std::accumulate 算法是执行求和公式的合适选择。我们将在一个递增的数字值范围上使用 accumulate。从这些值中,我们可以形成每一步的单独的加数。std::accumulate 算法在每一步调用一个二元函数。这个函数的第一个参数是在前面的步骤中已经计算出的 sum 的一部分的当前值,它的第二个参数是范围中的下一个值。我们查找信号 s 在当前位置的值,并将其与复数因子 pol 相乘。然后,我们返回新的部分和。二元函数被包装成 另一个 lambda 表达式,因为我们将为每个 accumulate 调用使用不同的 j 值。因为这是一个二维循环算法,内部 lambda 是内部循环,外部 lambda 是外部循环:
          auto sum_up ([=, &s] (size_t j) {
              return [=, &s] (cmplx c, size_t k) {
                  return c + s[k] * 
                      polar(1.0, pol * k * j / double(s.size()));
              };
          });
  1. 傅立叶变换的内部循环部分现在由 std::accumulate 执行。对于算法的每个 j 位置,我们计算 i = 0...N 的所有加数的和。这个想法被包装成一个 lambda 表达式,我们将为结果傅立叶变换向量中的每个数据点执行它:
          auto to_ft (=, &s{
              return accumulate(num_iterator{0}, 
                                num_iterator{s.size()}, 
                                cmplx{},
                                sum_up(j))
                  / div;
          });
  1. 到目前为止,傅立叶代码还没有执行。我们只是准备了很多功能代码,现在我们将把它们付诸实践。std::transform 调用将生成值 j = 0...N,这是我们的外部循环。转换后的值都进入向量 t,然后我们将其返回给调用者:
          transform(num_iterator{0}, num_iterator{s.size()}, 
                    begin(t), to_ft);

          return t;
      }
  1. 我们将实现一些函数,帮助我们设置用于信号生成的函数对象。第一个是余弦信号生成器。它返回一个可以生成给定周期长度的余弦信号的 lambda 表达式。信号本身可以是任意长度,但周期长度是固定的。周期长度为 N 意味着信号在 N 步之后会重复。lambda 表达式不接受任何参数。我们可以重复调用它,每次调用都会返回下一个时间点的信号数据点:
      static auto gen_cosine (size_t period_len){
          return [period_len, n{0}] () mutable { 
              return cos(double(n++) * 2.0 * M_PI / period_len); 
          };
      }
  1. 我们要生成的另一个信号是方波。它在值-1+1之间振荡,没有其他值。这个公式看起来很复杂,但它只是将线性递增的值n转换为+1-1,振荡周期长度为period_len

请注意,这次我们将n初始化为与0不同的值。这样,我们的方波从其输出值开始于+1的相位开始:

      static auto gen_square_wave (size_t period_len)
      {
          return [period_len, n{period_len*7/4}] () mutable {
              return ((n++ * 2 / period_len) % 2) * 2 - 1.0;
          };
      }
  1. 从这样的生成器生成实际信号可以通过分配一个新向量并用从重复信号生成器函数调用生成的值填充它来实现。std::generate完成了这项工作。它接受一个开始/结束迭代器对和一个生成器函数。对于每个有效的迭代器位置,它执行*it = gen()。通过将这段代码封装到一个函数中,我们可以轻松地生成信号向量:
      template <typename F>
      static csignal signal_from_generator(size_t len, F gen)
      {
          csignal r (len);
          generate(begin(r), end(r), gen);
          return r;
      }
  1. 最后,我们需要打印生成的信号。我们可以通过将其值复制到输出流迭代器中来简单地打印信号,但我们需要先转换数据,因为我们的信号数据点是复数值对。在这一点上,我们只对每个数据点的实值部分感兴趣;因此,我们通过std::transform调用将其抛出,仅提取这部分:
      static void print_signal (const csignal &s)
      {
          auto real_val ([](cmplx c) { return c.real(); });
          transform(begin(s), end(s), 
                    ostream_iterator<double>{cout, " "}, real_val);
          cout << 'n';
      }
  1. 傅立叶公式现在已经实现了,但我们还没有要转换的信号。这就是我们在主函数中要做的事情。让我们首先定义一个所有信号都符合的标准信号长度。
      int main()
      {
          const size_t sig_len {100};
  1. 现在让我们生成信号,对它们进行变换并打印它们,这发生在接下来的三个步骤中。第一步是生成余弦信号和方波信号。两者都具有相同的总信号长度和周期长度:
          auto cosine      (signal_from_generator(sig_len, 
                 gen_cosine(     sig_len / 2)));
          auto square_wave (signal_from_generator(sig_len,
                 gen_square_wave(sig_len / 2)));
  1. 现在我们有了余弦函数和方波信号。为了在它们之间生成第三个信号,我们取方波信号并计算其傅立叶变换(保存在trans_sqw向量中)。方波的傅立叶变换具有特定的形式,我们将对其进行一些操作。从索引10(signal_length - 10)的所有项都设置为0.0。其余部分保持不变。将这个改变后的傅立叶变换转换回信号时间表示将给我们一个不同的信号。最后我们将看到它是什么样子的:
          auto trans_sqw (fourier_transform(square_wave));

          fill (next(begin(trans_sqw), 10), prev(end(trans_sqw), 10), 0);
          auto mid (fourier_transform(trans_sqw, true));
  1. 现在我们有了三个信号:cosinemidsquare_wave。对于每个信号,我们打印信号本身及其傅立叶变换。整个程序的输出将包括六行非常长的打印的双值列表:
          print_signal(cosine);
          print_signal(fourier_transform(cosine));

          print_signal(mid);
          print_signal(trans_sqw);

          print_signal(square_wave);
          print_signal(fourier_transform(square_wave));
      }
  1. 编译和运行程序会导致终端被大量的数字值填满。如果我们绘制输出,我们会得到以下图像:

它是如何工作的...

这个程序包含两个复杂的部分。一个是傅立叶变换本身,另一个是使用可变 lambda 表达式生成信号。

让我们先专注于傅立叶变换。原始循环实现的核心(我们没有在我们的实现中使用,但在介绍中看过)如下所示:

for (size_t k {0}; k < s.size(); ++k) {
    for (size_t j {0}; j < s.size(); ++j) {
        t[k] += s[j] * polar(1.0, pol * k * j / double(s.size()));
    }
}

通过 STL 算法std::transformstd::accumulate,我们编写了代码,可以总结为以下伪代码:

transform(num_iterator{0}, num_iterator{s.size()}, ...
    accumulate((num_iterator0}, num_iterator{s.size()}, ...
        c + s[k] * polar(1.0, pol * k * j / double(s.size()));

结果与循环变体完全相同。这可以说是一个例子,严格使用 STL 算法并不会导致更好的代码。尽管如此,这种算法实现对数据结构的选择是不可知的。它也可以在列表上工作(尽管在我们的情况下这没有太多意义)。另一个好处是 C++17 的 STL 算法很容易并行化(我们将在本书的另一章中进行讨论),而原始循环必须重构以支持多处理(除非我们使用外部库,例如OpenMP,但这些实际上为我们重构了循环)。

另一个复杂的部分是信号生成。让我们再看一下gen_cosine

static auto gen_cosine (size_t period_len)
{
    return [period_len, n{0}] () mutable {
        return cos(double(n++) * 2.0 * M_PI / period_len);
    };
}

每个 lambda 表达式的实例都代表一个函数对象,它在每次调用时修改自己的状态。它的状态包括变量period_lennn变量是在每次调用时修改的变量。信号在每个时间点上都有不同的值,n++代表不断增加的时间点。为了从中获得实际的信号向量,我们创建了辅助signal_from_generator

template <typename F>
static auto signal_from_generator(size_t len, F gen)
{
    csignal r (len);
    generate(begin(r), end(r), gen);
    return r;
}

这个辅助函数分配一个指定长度的信号向量,并调用std::generate来填充它的数据点。对于向量r的每个项目,它调用函数对象gen一次,这正是我们可以使用gen_cosine创建的自修改函数对象的类型。

不幸的是,STL 的方式并没有使这段代码更加优雅。一旦 ranges 库加入 STL 俱乐部(希望在 C++20 中实现),这种情况很可能会改变。

计算两个向量的误差和

有不同的可能性来计算目标值和实际值之间的数值误差。测量由许多数据点组成的信号之间的差异通常涉及循环和相应数据点的减法等。

计算信号a和信号b之间的误差的一个简单公式如下:

对于每个i,它计算a[i] - b[i],对这个差值进行平方(这样,负数和正数的差异可以进行比较),最后将这些值相加。这又是一个可以使用循环的情况,但出于有趣的原因,我们将使用 STL 算法来完成。好处是以这种方式获得了数据结构的独立性。我们的算法将适用于向量和类似列表的数据结构,其中不可能进行直接索引。

如何做...

在这一部分,我们将创建两个信号并计算它们的误差和:

  1. 像往常一样,首先是包含语句。然后,我们声明我们使用std命名空间:
      #include <iostream>
      #include <cmath>
      #include <algorithm>
      #include <numeric>
      #include <vector>
      #include <iterator>      

      using namespace std;
  1. 我们将计算两个信号的误差和。两个信号将是正弦波和它的副本,但值类型不同--原始正弦波保存在double变量的向量中,其副本保存在int变量的向量中。因为将值从double变量复制到int变量会在小数点后截断其小数部分,我们会有一些损失。让我们将double值的向量命名为as,代表模拟信号,将int值的向量命名为ds,代表数字信号。然后,误差和将告诉我们实际损失有多大:
      int main()
      {
          const size_t sig_len {100};
          vector<double> as (sig_len); // a for analog
          vector<int>    ds (sig_len); // d for digital
  1. 为了生成正弦波信号,我们使用一个带有mutable计数器值n的小 lambda 表达式。我们可以随时调用它,每次调用它都会返回正弦波的下一个时间点的值。std::generate调用用生成的信号填充信号向量,std::copy调用随后将所有double变量的值复制到int变量的向量中:
          auto sin_gen ([n{0}] () mutable { 
              return 5.0 * sin(n++ * 2.0 * M_PI / 100); 
          });

          generate(begin(as), end(as), sin_gen);
          copy(begin(as), end(as), begin(ds));
  1. 首先打印信号,这样它们以后可以绘制:
          copy(begin(as), end(as), 
               ostream_iterator<double>{cout, " "});
          cout << 'n';
          copy(begin(ds), end(ds), 
               ostream_iterator<double>{cout, " "});
          cout << 'n';
  1. 现在来看实际的误差和,我们使用std::inner_product,因为它可以很容易地适应计算信号向量的每两个对应元素之间的差异。它将遍历两个范围,选择在范围中相应位置的项目,计算它们之间的差异,对其进行平方,并累积结果:
          cout << inner_product(begin(as), end(as), begin(ds), 
                                0.0, std::plus<double>{},
                                [](double a, double b) { 
                                    return pow(a - b, 2); 
                                }) 
               << 'n';
      }
  1. 编译和运行程序会给我们两行信号输出和第三行,其中包含一个单一的输出值,即两个信号之间的误差。误差是40.889。如果我们连续计算误差,首先是第一对项目,然后是前两对项目,然后是前三对项目,依此类推,那么我们得到的是累积误差曲线,它在绘制的图表上可见,如下所示:

它是如何工作的...

在这个示例中,我们将循环遍历两个向量的任务,获取它们对应值的差异,对它们进行平方,最后将它们加总到一个std::inner_product调用中。在这个过程中,我们自己编写的唯一代码是 lambda 表达式[](double a, double b) { return pow(a - b, 2); },它获取其参数的差值并对其进行平方。

std::inner_product可能的实现的一瞥告诉我们为什么以及如何工作:

template<class InIt1, class InIt2, class T, class F, class G>
T inner_product(InIt1 it1, InIt1 end1, InIt2 it2, T val,
                F bin_op1, G bin_op2)
{
    while (it1 != end1) {
        val = bin_op1(val, bin_op2(*it1, *it2));
        ++it1;
        ++it2;
    }
    return value;
}

该算法接受第一个范围的一对 begin/end 迭代器,以及第二个范围的另一个 begin 迭代器。在我们的情况下,它们是我们想要计算误差和的向量。下一个字符是初始值val。我们将其初始化为0.0。然后,该算法接受两个二元函数,即bin_op1bin_op2

此时,我们可能意识到这个算法与std::accumulate非常相似。唯一的区别是std::accumulate只适用于一个范围。如果我们用*it语句替换bin_op2(*it1, *it2),那么我们基本上恢复了accumulate算法。因此,我们可以将std::inner_product视为std::accumulate的一个版本,它zip了一对输入范围。

在我们的情况下,zipper函数是pow(a - b, 2),就是这样。对于另一个函数bin_op1,我们选择了std::plus<double>,因为我们希望所有的平方都被加在一起。

实现 ASCII Mandelbrot 渲染器

1975 年,数学家 Benoît Mandelbrot 创造了术语分形。分形是一个数学图形或集合,它具有某些有趣的数学特性,但最终看起来就像一件艺术品。当放大时,分形也看起来无限 重复。最流行的分形之一是Mandelbrot 集,可以在以下海报上看到:

Mandelbrot 集的图像可以通过迭代特定的公式生成:

变量zc复数。Mandelbrot 集由c的所有这样的值组成,如果应用足够多次公式,它就会收敛。这是海报的彩色部分。一些值会更早地收敛,一些会更晚地收敛,因此它们可以用不同的颜色来可视化。有些根本不会收敛--这些被涂成黑色。

STL 附带有有用的std::complex类,我们将尝试实现这个公式,而不使用显式循环,只是为了更好地了解 STL。

如何做...

在本节中,我们将在终端中打印与墙海报相同的图像的一小部分 ASCII 艺术:

  1. 首先,我们包括所有的头文件,并声明我们使用std命名空间:
      #include <iostream>
      #include <algorithm>
      #include <iterator>
      #include <complex>
      #include <numeric>
      #include <vector>      

      using namespace std;
  1. Mandelbrot 集和公式操作复数。因此,我们定义了一个类型别名cmplx,它是std::complex类,专门针对双精度值。
      using cmplx = complex<double>;
  1. 可以将所有 ASCII Mandelbrot 图像的代码拼凑在大约 20 行左右,但我们将分别实现每个逻辑步骤,然后在最后组装所有步骤。第一步是实现一个从整数坐标到浮点坐标的缩放函数。我们一开始在终端上有字符位置的列和行。我们想要的是 Mandelbrot 集合坐标系中的复数类型坐标。为此,我们实现一个接受描述用户终端坐标系几何形状和我们要转换到的坐标系的参数的函数。这些值用于构建一个 lambda 表达式,然后返回。lambda 表达式接受一个int坐标并返回一个double坐标:
      static auto scaler(int min_from, int max_from, 
                         double min_to, double max_to)
      {
          const int    w_from   {max_from - min_from};
          const double w_to     {max_to - min_to};
          const int    mid_from {(max_from - min_from) / 2 + min_from};
          const double mid_to   {(max_to - min_to) / 2.0 + min_to};

          return [=] (int from) {
              return double(from - mid_from) / w_from * w_to + mid_to;
          };
      }
  1. 现在我们可以在一维坐标上进行变换,但 Mandelbrot 集合存在于二维坐标系中。为了从一个(x, y)坐标系转换到另一个,我们结合了一个 x 缩放器和一个 y 缩放器,并从它们的输出构造了一个cmplx实例:
      template <typename A, typename B>
      static auto scaled_cmplx(A scaler_x, B scaler_y)
      {
          return = {
              return cmplx{scaler_x(x), scaler_y(y)};
          };
      }
  1. 在能够将坐标转换为正确尺寸之后,我们现在可以实现 Mandelbrot 公式。我们正在实现的函数绝对不知道终端窗口或线性平面变换的概念,因此我们可以专注于 Mandelbrot 数学。我们对z进行平方并在循环中添加c,直到其abs值小于2。对于某些坐标,这永远不会发生,因此如果迭代次数超过max_iterations,我们也会跳出循环。最后,我们返回我们必须进行的迭代次数,直到abs值收敛:
      static auto mandelbrot_iterations(cmplx c)
      {
          cmplx z {};
          size_t iterations {0};
          const size_t max_iterations {1000};
          while (abs(z) < 2 && iterations < max_iterations) {
              ++iterations;
              z = pow(z, 2) + c;
          }
          return iterations;
      }
  1. 现在我们可以开始主函数,其中我们定义终端尺寸并实例化一个函数对象scale,它为两个轴的坐标值进行缩放:
      int main()
      {
          const size_t w {100};
          const size_t h {40};

          auto scale (scaled_cmplx(
              scaler(0, w, -2.0, 1.0),
              scaler(0, h, -1.0, 1.0)
          ));
  1. 为了对整个图像进行一维迭代,我们编写另一个转换函数,它接受一个一维i坐标。它根据我们假设的字符宽度计算出(x, y)坐标。在将i分解为行和列号后,它使用我们的scale函数进行转换并返回复杂坐标。
          auto i_to_xy (= { return scale(i % w, i / w); });
  1. 现在我们可以从一维坐标(int类型)通过二维坐标((int, int)类型)转换到 Mandelbrot 集合坐标(cmplx类型),然后从那里计算迭代次数(再次是int类型)。让我们将所有这些组合在一个函数中,为我们设置这个调用链:
          auto to_iteration_count (= { 
              return mandelbrot_iterations(i_to_xy(i));
          });
  1. 现在我们可以设置所有数据。我们假设我们的 ASCII 图像的宽度为w个字符,高度为h个字符。这可以保存在一个具有w * h个元素的一维向量中。我们使用std::iota填充这个向量,其值范围为0 ... (wh - 1)*。这些数字可以作为我们构造的转换函数范围的输入源,我们刚刚封装在to_iteration_count中:
          vector<int> v (w * h);
          iota(begin(v), end(v), 0);
          transform(begin(v), end(v), begin(v), to_iteration_count);
  1. 基本上就是这样。我们现在有了v向量,我们用一维坐标初始化了它,但后来被 Mandelbrot 迭代计数器覆盖了。从这里,我们现在可以打印一个漂亮的图像。我们可以将终端窗口设置为w个字符宽,然后我们就不需要在中间打印换行符了。但我们也可以创造性地滥用std::accumulate来为我们做换行。std::accumulate使用二进制函数来减少一个范围。我们提供一个二进制函数,它接受一个输出迭代器(我们将在下一步中链接到终端),以及范围中的单个值。如果迭代次数大于 50,我们将打印这个值作为*字符。否则,我们只打印一个空格字符。如果我们在行末(因为计数变量n可以被w整除),我们打印一个换行符号:
          auto binfunc ([w, n{0}] (auto output_it, int x) mutable {
              *++output_it = (x > 50 ? '*' : ' ');
              if (++n % w == 0) { ++output_it = 'n'; }
              return output_it;
          });
  1. 通过在输入范围上调用std:accumulate,结合我们的二进制打印函数和ostream_iterator,我们可以将计算出的 Mandelbrot 集刷新到终端窗口:
          accumulate(begin(v), end(v), ostream_iterator<char>{cout}, 
                     binfunc);
      }
  1. 编译和运行程序会产生以下输出,看起来像最初详细的 Mandelbrot 图像,但是以简化的形式:

它是如何工作的...

整个计算是在一维数组上的std::transform调用中进行的:

vector<int> v (w * h);
iota(begin(v), end(v), 0);
transform(begin(v), end(v), begin(v), to_iteration_count);

那么,到底发生了什么,为什么会这样工作呢?to_iteration_count函数基本上是从i_to_xyscale再到mandelbrot_iterations的调用链。以下图表说明了转换步骤:

这样,我们可以使用一维数组的索引作为输入,并得到该数组点表示的二维平面上 Mandelbrot 公式迭代的次数。好处是这三个转换完全不知道彼此。具有这种关注点分离的代码可以非常好地进行测试,因为每个组件都可以单独测试,而不受其他组件的影响。这样,很容易找到和修复错误,或者只是推理其正确性。

构建我们自己的算法 - split

在某些情况下,现有的 STL 算法是不够的。但是没有什么能阻止我们实现自己的算法。在解决特定问题之前,我们应该坚决思考一下,以认识到许多问题可以以通用方式解决。如果我们在解决自己的问题时经常堆积一些新的库代码,那么当我们的同行遇到类似的问题时,我们也在帮助他们。关键是要知道何时足够通用,何时不要追求比所需更通用--否则我们最终会得到一个新的通用语言。

在这个示例中,我们正在实现一个算法,我们将其称为split。它可以在特定值的每次出现时拆分任何项目范围,并将由此产生的块复制到输出范围中。

如何做...

在本节中,我们将实现我们自己的类似 STL 的算法,称为split,然后我们通过拆分示例字符串来检查它:

  1. 首先,我们包含一些 STL 库部分,并声明我们使用std命名空间:
      #include <iostream>
      #include <string>
      #include <algorithm>
      #include <iterator>
      #include <list>      

      using namespace std;
  1. 这一节围绕的整个算法是split。它接受一对输入迭代器的开始/结束,并一个输出迭代器,这使它与std::copystd::transform类似。其他参数是split_valbin_funcsplit_val参数是我们在输入范围中搜索的值,它表示我们切割输入区间的分割点。bin_func参数是一个函数,它从一对标记分割块子范围的迭代器开始和结束进行转换。我们使用std::find遍历输入范围,因此我们从split_val值的出现跳到另一个出现。当将长字符串分割成其各个单词时,我们会从空格字符跳到空格字符。在每个分割值上,我们停下来形成一个块并将其馈送到输出范围中:
      template <typename InIt, typename OutIt, typename T, typename F>
      InIt split(InIt it, InIt end_it, OutIt out_it, T split_val, 
                 F bin_func)
      {
          while (it != end_it) {
              auto slice_end (find(it, end_it, split_val));
              *out_it++ = bin_func(it, slice_end);

              if (slice_end == end_it) { return end_it; }
              it = next(slice_end);
          }
          return it;
      }
  1. 让我们使用新的算法。我们构造一个我们想要拆分的字符串。标记最后一个块的结束和下一个块的开始的项目将是破折号字符'-'
      int main()
      {
          const string s {"a-b-c-d-e-f-g"};
  1. 每当算法在一对迭代器上调用其bin_func时,我们希望从中构造一个新的字符串:
          auto binfunc ([](auto it_a, auto it_b) {
              return string(it_a, it_b);
          });
  1. 输出范围将是字符串的std::list。现在我们可以调用split算法,它与所有其他 STL 算法相比具有类似的设计:
          list<string> l;
          split(begin(s), end(s), back_inserter(l), '-', binfunc);
  1. 为了看到我们得到了什么,让我们打印新的分块字符串列表:
          copy(begin(l), end(l), ostream_iterator<string>{cout, "n"});
      }
  1. 编译和运行程序产生以下输出。它不再包含破折号,并显示它已经隔离了单词(在我们的示例字符串中当然只有单个字符):
      $ ./split 
      a
      b
      c
      d
      e
      f
      g

它是如何工作的...

split算法的工作方式类似于std::transform,因为它接受一个输入范围的起始/结束迭代器对和一个输出迭代器。它对输入范围进行某些操作,最终将结果赋值给输出迭代器。除此之外,它还接受一个名为split_val的项目值和一个二元函数。让我们重新审视整个实现以充分理解它:

template <typename InIt, typename OutIt, typename T, typename F>
InIt split(InIt it, InIt end_it, OutIt out_it, T split_val, F bin_func)
{
    while (it != end_it) {
        auto slice_end (find(it, end_it, split_val));
        *out_it++ = bin_func(it, slice_end);

        if (slice_end == end_it) { return end_it; }
        it = next(slice_end);
    }
    return it;
}

循环要求迭代直到输入范围的末尾。在每次迭代期间,使用std::find调用来查找输入范围中下一个等于split_val的元素。在我们的情况下,该元素是破折号字符('-'),因为我们想要在所有破折号位置分割我们的输入字符串。下一个破折号位置现在保存在slice_end中。循环迭代后,it迭代器被放在该分割位置的下一个项目上。这样,循环直接从破折号跳到破折号,而不是每个单独的项目上。

在这种情况下,迭代器it指向最后一个切片的开头,而slice_end指向最后一个切片的结尾。这两个迭代器结合起来标记了表示两个破折号符号之间的子范围的开始和结束。在字符串"foo-bar-baz"中,这意味着我们有三次循环迭代,每次我们得到一对迭代器,围绕着一个单词。但实际上我们不想要迭代器,而是子字符串。二元函数bin_func正是为我们做这件事。当我们调用split时,我们给它了以下二元函数:

[](auto it_a, auto it_b) {
    return string(it_a, it_b);
}

split函数通过bin_func将每对迭代器传递,然后将其输入输出迭代器。实际上,我们从bin_func中得到了字符串实例,结果是"foo""bar""baz"

还有更多...

实现字符串分割的一个有趣的替代方法是实现一个迭代器来完成相同的功能。我们暂时不会实现这样的迭代器,但让我们简要地看一下这种情况。

迭代器需要在每次增量时在分隔符之间跳跃。每当它被解引用时,它需要从它当前指向的迭代器位置创建一个字符串对象,它可以使用之前使用的binfunc这样的二元函数来完成。

如果我们有一个名为split_iterator的迭代器类,而不是一个名为split的算法,用户代码将如下所示:

string s {"a-b-c-d-e-f-g"};
list<string> l;

auto binfunc ([](auto it_a, auto it_b) {
    return string(it_a, it_b);
});

copy(split_iterator{begin(s), end(s), ‘-‘, binfunc},{}, back_inserter(l));

这种方法的缺点是,实现迭代器通常比单个函数更加复杂。此外,迭代器代码中有许多微妙的边缘情况可能导致错误,因此迭代器解决方案需要更多的繁琐测试。另一方面,将这样的迭代器与其他 STL 算法结合起来非常简单。

从标准算法组合有用的算法 - gather

STL 算法的可组合性的一个很好的例子是gather。当时在 Adobe Systems 担任首席科学家的 Sean Parent 因为这个算法既有用又简短而使其广为流传。它的实现方式使它成为 STL 算法组合理念的理想典范。

gather算法操作任意项目类型的范围。它以特定的方式修改项目的顺序,使特定项目围绕着由调用者选择的特定位置聚集起来。

如何做...

在本节中,我们将实现gather算法以及它的一个额外变体。之后,我们将看看如何使用它:

  1. 首先,我们添加所有的 STL 包含语句。然后,我们声明我们使用std命名空间:
      #include <iostream>
      #include <algorithm>
      #include <string>
      #include <functional>      

      using namespace std;
  1. gather算法是标准算法组合的一个很好的例子。gather接受一个起始/结束迭代器对,以及另一个迭代器gather_pos,它指向中间某个位置。最后一个参数是一个谓词函数。使用这个谓词函数,算法将所有满足谓词的项目推到靠近gather_pos迭代器的位置。项目移动的实现由std::stable_partition完成。gather算法的返回值是一对迭代器。这些迭代器是从stable_partition调用返回的,这样,它们标记了现在聚集范围的开始和结束:
      template <typename It, typename F>
      pair<It, It> gather(It first, It last, It gather_pos, F predicate)
      {
          return {stable_partition(first, gather_pos, not_fn(predicate)),
                  stable_partition(gather_pos, last, predicate)};
      }
  1. gather的另一个变体是gather_sort。它基本上与gather的工作方式相同,但它不接受一元谓词函数;它接受一个二元比较函数。这样,就可以聚集出现在gather_pos附近的最小最大的值:
      template <typename It>

      void gather_sort(It first, It last, It gather_pos)

      {

        using T = typename std::iterator_traits<It>::value_type;

        stable_sort(first, gather_pos, greater<T>{});

        stable_sort(gather_pos, last, less<T>{});

      }
  1. 让我们把这些算法投入使用。我们首先使用一个谓词,告诉我们给定的字符参数是否是'a'字符。我们构造一个字符串,其中包含交错的'a''_'字符:
      int main()
      {
          auto is_a ([](char c) { return c == 'a'; });
          string a {"a_a_a_a_a_a_a_a_a_a_a"};
  1. 我们构造一个迭代器,它指向新字符串的中间。让我们在其上调用gather,看看会发生什么。之后,'a'字符应该被聚集在中间周围:
          auto middle (begin(a) + a.size() / 2);

          gather(begin(a), end(a), middle, is_a);
          cout << a << 'n';
  1. 让我们再次调用gather,但这次gather_pos迭代器不在中间而是在开头:
          gather(begin(a), end(a), begin(a), is_a);
          cout << a << 'n';
  1. 在第三次调用中,我们聚集了结束迭代器周围的项目:
          gather(begin(a), end(a), end(a), is_a);
          cout << a << 'n';
  1. 最后一次调用gather,我们尝试再次聚集所有'a'字符周围的中间位置。这将不会按预期工作,稍后我们将看到原因:
          // This will NOT work as naively expected
          gather(begin(a), end(a), middle, is_a);
          cout << a << 'n';
  1. 我们用下划线字符和一些数字值构造另一个字符串。在该输入序列上,我们应用gather_sortgather_pos迭代器是字符串的中间,二元比较函数是std::less<char>
          string b {"_9_2_4_7_3_8_1_6_5_0_"};
          gather_sort(begin(b), end(b), begin(b) + b.size() / 2, 
                      less<char>{});
          cout << b << 'n';
      }
  1. 编译和运行程序产生以下有趣的输出。前三行看起来像预期的样子,但第四行看起来像gather对字符串没有做任何操作。

在最后一行,我们可以看到gather_short函数的结果。数字朝着任一方向排序:

      $ ./gather 
      _____aaaaaaaaaaa_____
      aaaaaaaaaaa__________
      __________aaaaaaaaaaa
      __________aaaaaaaaaaa
      _____9743201568______

工作原理...

最初,gather算法很难理解,因为它非常简短,但任务似乎很复杂。因此,让我们逐步进行:

  1. 初始状态是一系列项目,我们为其提供一个谓词函数。在图表中,我们的谓词函数返回true的所有项目都以灰色绘制。迭代器ac标记整个范围,迭代器b指向枢轴元素。枢轴元素是我们想要聚集所有灰色项目的元素周围。

  2. gather算法在范围a,b)上调用std::stable_partition,在执行此操作时,它使用谓词的否定版本。它否定谓词,因为std::stable_partition将谓词返回true的所有项目移动到前面。我们希望发生相反的情况。

  3. 另一个std::stable_partition调用被执行,但这次是在范围[b,c)上,并且否定谓词。灰色项目被移动到输入范围的前面,这意味着它们都朝着被b指向的枢轴元素移动。

  4. 现在项目围绕b聚集,算法返回迭代器,指向现在连续的灰色项目的开始和结束。

我们在相同的范围上多次调用gather。起初,我们聚集了范围中间的所有项目。然后我们聚集了范围的begin()周围,然后是范围的end()周围。这些情况很有趣,因为它们总是导致一个std::stable_partition调用在范围上操作,这导致没有动作

我们再次使用范围的参数(begin, end, middle)gather进行最后一次调用,但没有起作用。为什么?乍一看,这似乎是一个错误,但实际上并不是。

想象一下字符范围"aabb",以及一个谓词函数is_character_a,它只对'a'项为真--如果我们用一个指向字符范围中间的第三个迭代器调用它,我们会观察到相同的错误。原因是第一个stable_partition调用将在子范围"aa"上操作,而另一个stable_partition调用将在范围"bb"上操作。这一系列的调用不能导致最初我们天真地希望的"baab"

为了在最后一种情况下得到我们想要的结果,我们可以使用std::rotate(begin, begin + 1, end);

gather_sort修改基本上与gather相同。唯一的区别是它不接受一个一元的谓词函数,而是一个二元的比较函数,就像std::sort一样。而且它不是调用std::stable_partition两次,而是调用std::stable_sort两次。

比较函数的否定不能使用not_fn来完成,就像我们在gather算法中所做的那样,因为not_fn不能用于二元函数。

删除单词之间的连续空格

因为字符串通常是从用户输入中读取的,它们可能包含狂野的格式,通常需要被清理。其中一个例子是包含太多空格的字符串。

在本节中,我们将实现一个巧妙的空格过滤算法,它从字符串中删除多余的空格,但保留单个空格字符。我们称这个算法为remove_multi_whitespace,它的接口看起来非常类似 STL。

如何做...

在本节中,我们将实现remove_multi_whitespace算法并查看它是如何工作的:

  1. 像往常一样,我们首先进行一些包含,然后声明我们默认使用std命名空间:
      #include <iostream>
      #include <string>
      #include <algorithm>      

      using namespace std;
  1. 我们实现了一个新的 STL 风格的算法,称为remove_multi_whitespace。这个算法删除了空格的聚集出现,但不删除单个空格。这意味着字符串"a b"保持不变,但像"a b"这样的字符串

"a b"被缩减为"a b"。为了实现这一点,我们使用了一个自定义的二元谓词函数来调用std::uniquestd::unique遍历一个可迭代范围,并总是查看连续的负载项对。然后它询问谓词函数两个项是否相等。如果是,那么std::unique会删除其中一个。之后,范围不再包含相邻的相等项的子范围。通常在这种情况下应用的谓词函数告诉两个项是否相等。我们所做的是给std::unique一个谓词,它告诉是否有两个连续的空格,以便将它们删除。就像std::unique一样,我们接受一对 begin/end 迭代器,然后返回一个指向范围新结尾的迭代器:

      template <typename It>
      It remove_multi_whitespace(It it, It end_it)
      {
          return unique(it, end_it, [ {
              return isspace(a) && isspace(b);
          });
      }
  1. 就是这样了。让我们构造一个包含一些不必要空格的字符串:
      int main()
      {
          string s {"fooo     bar    t   baz"};

          cout << s << 'n';
  1. 现在,我们使用erase-remove idiom来处理字符串,以摆脱多余的空格字符:
          s.erase(remove_multi_whitespace(begin(s), end(s)), end(s));

          cout << s << 'n';
      }
  1. 编译和运行程序产生以下输出:
      $ ./remove_consecutive_whitespace 
      fooo     bar        baz
      fooo bar baz

它是如何工作的...

我们解决了问题的整个复杂性,没有任何循环或手动比较项目。我们只提供了一个谓词函数,告诉我们给定的两个字符是否是空格字符。然后我们将该谓词输入到std::unique中,,所有多余的空格都消失了。虽然本章还包含一些我们必须更努力地使用 STL 算法来表达我们的程序的示例,但这个算法是一个真正好的、简短的例子。

这个有趣的组合是如何详细工作的呢?让我们首先看一下std::unique的可能实现:

template<typename It, typename P>
It unique(It it, It end, P p)
{
    if (it == end) { return end; }

    It result {it};
    while (++it != end) {
        if (!p(*result, *it) && ++result != it) {
            *result = std::move(*it);
        }
    }
    return ++result;
}

循环遍历范围项,直到它们不满足谓词条件。在满足谓词的位置,它将这样的项移动到上一次触发谓词的旧位置之后的一个项。不接受额外谓词函数的std::unique版本检查两个相邻项是否相等。这样,它可以,例如,将"abbbbbbc"转换为"abc",从而消除重复字符。

我们想要的不是清除所有重复的字符,而是重复的空格。因此,我们的谓词不是说“两个参数字符相等”,而是“两个参数字符都是空格字符”。

最后要注意的一点是,std::uniqueremove_multi_whitespace都不会真正从基础字符串中删除字符项。它们只是根据语义在字符串中移动字符,并告诉它的新结尾在哪里。必须仍然删除从新结尾到旧结尾的所有现在过时的字符。这就是为什么我们写了以下内容:

s.erase(remove_multi_whitespace(begin(s), end(s)), end(s));

这遵循擦除-移除惯用法,我们已经从向量和列表中了解到。

压缩和解压缩字符串

这一部分涉及编码面试中相对流行的任务。基本思想是一个函数,它接受一个字符串,比如"aaaaabbbbbbbccc",并将其转换为一个更短的字符串"a5b7c3"。它是"a5",因为有五个'a'字符。然后是"b7",因为有七个'b'字符。这是一个非常简单的压缩算法。对于普通文本来说,它的效用减少了,因为正常语言通常不会重复到使其文本表示使用这种压缩方案变得更短。然而,即使我们不得不在白板上手动实现,它相对容易实现。棘手的部分是,如果程序一开始的结构不是很好,很容易编写有 bug 的代码。处理字符串通常不是一件难事,但是如果使用了传统的 C 风格格式化函数,那么在这里实现缓冲区溢出错误的机会就会很多

让我们尝试使用这种简单方案来实现字符串压缩和解压缩的 STL 方法。

如何做...

在这一部分,我们将为字符串实现简单的compressdecompress函数:

  1. 首先,我们包括一些 STL 库,然后声明我们使用std命名空间:
      #include <iostream>
      #include <string>
      #include <algorithm>
      #include <sstream>
      #include <tuple>      

      using namespace std;
  1. 对于我们的廉价压缩算法,我们试图找到包含相同字符范围的文本块,并单独压缩它们。每当我们从一个字符串位置开始时,我们希望找到包含不同字符的第一个位置。我们使用std::find来找到范围中第一个与当前位置的字符不同的字符。之后,我们返回一个元组,其中包含指向第一个不同项的迭代器,填充当前范围的字符变量c,以及此子范围包含的出现次数:
      template <typename It>
      tuple<It, char, size_t> occurrences(It it, It end_it)
      {
          if (it == end_it) { return {it, '?', 0}; }

          const char c {*it};
          const auto diff (find_if(it, end_it, 
                           c { return c != x; }));

          return {diff, c, distance(it, diff)};
      }
  1. compress算法不断调用occurrences函数。这样,我们从一个相同的字符组跳到另一个字符组。r << c << n行将字符推入输出流,然后是它在输入字符串的这一部分中的出现次数。输出是一个自动随着输出增长的字符串流。最后,我们从中返回一个字符串对象,其中包含压缩后的字符串:
      string compress(const string &s)
      {
          const auto end_it (end(s));
          stringstream r;

          for (auto it (begin(s)); it != end_it;) {
              const auto [next_diff, c, n] (occurrences(it, end_it));
              r << c << n;
              it = next_diff;
          }

          return r.str();
      }
  1. decompress方法的工作方式类似,但更简单。它不断尝试从输入流中获取字符值,然后获取接下来的数字。根据这两个值,它可以构造一个包含字符的字符串,次数由数字表示。最后,我们再次从输出流返回一个字符串。顺便说一下,这个decompress函数是不安全的。它很容易被利用。你能猜到怎么做吗?我们稍后会看一下这个问题:
      string decompress(const string &s)
      {
          stringstream ss{s};
          stringstream r;

          char c;
          size_t n;
          while (ss >> c >> n) { r << string(n, c); }

          return r.str();
      }
  1. 在我们的主函数中,我们构造了一个有很多重复的简单字符串,算法在这个字符串上运行得非常好。让我们打印压缩版本,然后是压缩和再次解压缩版本。最后,我们应该得到与最初构造的相同的字符串:
      int main()
      { 
          string s {"aaaaaaaaabbbbbbbbbccccccccccc"};
          cout << compress(s) << 'n';
          cout << decompress(compress(s)) << 'n';
      }
  1. 编译和运行程序产生以下输出:
      $ ./compress
      a9b9c11
      aaaaaaaaabbbbbbbbbccccccccccc

它的工作原理...

这个程序基本上围绕着两个函数:compressdecompress

解压函数非常简单,因为它只包括变量声明、一行实际执行操作的代码和接下来的返回语句。实际执行操作的代码行是以下代码:

while (ss >> c >> n) { r << string(n, c); }

它不断地从字符串流ss中读取字符c和计数器变量n。此时,stringstream类为我们隐藏了很多字符串解析的魔法。当这成功时,它将构造一个解压后的字符串块到字符串流中,从中可以将最终结果字符串返回给decompress的调用者。如果c = 'a'n = 5,表达式string(n, c)将得到一个内容为"aaaaa"的字符串。

压缩函数更复杂。我们还为它编写了一个小的辅助函数。我们称这个辅助函数为occurences。所以,让我们先看一下occurrences。以下图表显示了它的工作原理:

occurences函数接受两个参数:指向范围内字符序列开头的迭代器和该范围的结束迭代器。使用find_if,它找到第一个与最初指向的字符不同的字符。在图表中,这是迭代器diff。新位置和旧迭代器位置之间的差异就是相等项的数量(在图表中,diff - it等于6)。计算出这些信息后,diff迭代器可以被重用以执行下一次搜索。因此,我们将diff、子范围的字符和子范围的长度打包到一个元组中并返回它。

将信息排列如此,我们可以从子范围跳到子范围,并将中间结果推入压缩目标字符串中:

for (auto it (begin(s)); it != end_it;) { 
    const auto [next_diff, c, n] (occurrences(it, end_it)); 
    r << c << n; 
    it = next_diff; 
}

还有更多...

在第 4 步中,我们提到decompress函数是不安全的。的确,它很容易被利用

想象一下以下输入字符串:"a00000"。压缩它将导致子字符串"a1",因为只有一个字符'a'。接下来是五次'0',这将导致"05"。这样,压缩后的字符串就是"a105"。不幸的是,这个压缩后的字符串表示"字符'a'出现 105 次"。这与我们最初的输入字符串无关。更糟糕的是,如果我们解压它,从一个六个字符的字符串变成一个 105 个字符的字符串。想象一下数字更大的情况——用户很容易就能够使我们的堆使用量爆炸,因为我们的算法没有准备好处理这样的输入。

为了防止这种情况,compress函数可以,例如,拒绝带有数字的输入,或者可以以特殊方式掩盖它们。decompress算法可以采取另一个条件,对结果字符串大小设置一个上限。我把这留给你作为练习。

第二十七章:字符串、流类和正则表达式

我们将在本章中涵盖以下内容:

  • 创建、连接和转换字符串

  • 从字符串的开头和结尾修剪空白

  • 在不构造std::string对象的情况下获得std::string的舒适性

  • 从用户输入中读取值

  • 计算文件中的所有单词

  • 使用 I/O 流操纵器格式化输出

  • 从文件输入初始化复杂对象

  • std::istream迭代器填充容器

  • 使用std::ostream迭代器进行通用打印

  • 将输出重定向到特定代码段的文件

  • 通过继承std::char_traits创建自定义字符串类

  • 使用正则表达式库对输入进行标记化

  • 在不同上下文中舒适地漂亮地打印数字

  • std::iostream错误中捕获可读的异常

介绍

本章专门讨论任意数据的字符串处理、解析和打印。对于这样的工作,STL 提供了其I/O 流库。该库基本上由以下类组成,每个类都用灰色框表示:

箭头显示了类的继承结构。这一开始可能看起来很压抑,但在本章中我们将使用大多数这些类,并逐个熟悉它们。当查看 C++ STL 文档中的这些类时,我们将无法直接找到它们的确切名称。这是因为图表中的名称是我们作为应用程序员看到的,但它们实际上大多只是带有basic_类名前缀的类的 typedef(例如,我们将更容易地在 STL 文档中搜索basic_istream而不是istream)。basic_* I/O 流类是可以为不同字符类型进行特化的模板。图表中的类是针对char值进行特化的。我们将在整本书中使用这些特化。如果我们在这些类名前加上w字符,我们会得到wistreamwostream等等--这些是wchar_t的特化 typedef,而不是char,例如。

在图表的顶部,我们看到std::ios_base。我们基本上永远不会直接使用它,但它被列出是为了完整性,因为所有其他类都继承自它。下一个特化是std::ios,它体现了维护数据流的对象的概念,可以处于良好状态、运行数据状态(EOF)或某种失败状态。

我们将实际使用的第一个特化是std::istreamstd::ostream"i""o"前缀代表输入和输出。我们在 C++编程的最早期就已经见过它们,以最简单的形式出现在std::coutstd::cin(但也有std::cerr)的对象中。这些是这些类的实例,它们始终全局可用。我们通过ostream进行数据输出,通过istream进行输入。

同时继承自istreamostream的类是iostream。它结合了输入和输出功能。当我们了解到来自istreamostreamiostream三者组成的所有类可以如何使用时,我们基本上已经准备好立即使用所有接下来的类了:

ifstreamofstreamfstream分别继承自istreamostreamiostream,但它们提升了它们的能力,以重定向 I/O 从计算机的文件系统到文件。

istringstreamostringstreamiostringstream的工作方式非常类似。它们帮助在内存中构建字符串,并/或从中消耗数据。

创建、连接和转换字符串

即使是非常古老的 C++程序员也会知道std::string。在 C 中,特别是在解析、连接、复制字符串等方面,字符串处理是繁琐且痛苦的,而std::string在简单性和安全性方面确实是一大进步。

由于 C++11,当我们想要将所有权转移到其他函数或数据结构时,我们甚至不需要再复制字符串,因为我们可以移动它们。这样,在大多数情况下,几乎没有太多的开销。

std::string在过去几个标准增量中有一些新功能。C17 中完全新的是std::string_view。我们将稍微玩弄一下两者(但还有另一个配方,更集中于std::string_view的特性),以便对它们有所了解,并了解它们在 C17 时代的工作方式。

如何做到...

在本节中,我们将创建字符串和字符串视图,并对它们进行基本的连接和转换:

  1. 像往常一样,我们首先包括头文件并声明我们使用std命名空间:
      #include <iostream>
      #include <string>
      #include <string_view>
      #include <sstream>
      #include <algorithm>      

      using namespace std;
  1. 首先让我们创建字符串对象。最明显的方法是实例化一个string类的对象a。我们通过给构造函数传递一个 C 风格的字符串来控制它的内容(在编译后作为包含字符的静态数组嵌入到二进制文件中)。构造函数将复制它并将其作为字符串对象a的内容。或者,我们可以使用字符串字面量操作符""s来初始化它,而不是从 C 风格字符串初始化它。它可以即时创建一个字符串对象。使用它来构造对象b,我们甚至可以使用自动类型推断:
      int main()
      {
          string a { "a"  };
          auto   b ( "b"s );
  1. 我们刚刚创建的字符串是将它们的输入从构造函数参数复制到它们自己的缓冲区中。为了不复制,而是引用底层字符串,我们可以使用string_view实例。这个类也有一个字面操作符,称为""sv
          string_view c { "c"   };
          auto        d ( "d"sv );
  1. 好的,现在让我们玩一下我们的字符串和字符串视图。对于这两种类型,std::ostream类都有operator<<的重载,因此它们可以轻松地打印出来:
          cout << a << ", " << b << 'n';
          cout << c << ", " << d << 'n';
  1. 字符串类重载了operator+,所以我们可以添加两个字符串并得到它们的连接作为结果。这样,"a" + "b"的结果是"ab"。以这种方式连接ab很容易。对于ac,情况就不那么容易了,因为 c 不是一个string,而是一个string_view。我们首先必须从c中获取字符串,然后将其添加到a中。此时,有人可能会问,“等等,为什么你要将c复制到一个中间字符串对象中,然后再将其添加到a中?你不能通过使用c.data()来避免那个复制吗?”这是一个好主意,但它有一个缺陷--string_view实例不一定要携带零终止的字符串。这是一个可能导致缓冲区溢出的问题:
          cout << a + b << 'n';
          cout << a + string{c} << 'n';
  1. 让我们创建一个新的字符串,其中包含我们刚刚创建的所有字符串和字符串视图。通过使用std::ostringstream,我们可以将任何变量打印到一个行为完全像std::cout的流对象中,但它不会打印到 shell。相反,它会打印到字符串缓冲区中。在我们使用operator<<将所有变量流到一起并在它们之间使用一些分隔空间后,我们可以从中构造并打印一个新的字符串对象o.str()
          ostringstream o;

          o << a << " " << b << " " << c << " " << d;
          auto concatenated (o.str());
          cout << concatenated << 'n';
  1. 现在我们还可以通过将所有字母转换为大写来转换这个新字符串,例如。C 库函数toupper,它将小写字符映射为大写字符并保持其他字符不变,已经可用,并且可以与std::transform结合使用,因为字符串基本上也是一个具有char项的可迭代容器对象:
          transform(begin(concatenated), end(concatenated), 
                    begin(concatenated), ::toupper);
          cout << concatenated << 'n';
      }
  1. 编译和运行程序会产生以下输出,这正是我们所期望的:
      $ ./creating_strings 
      a, b
      c, d
      ab
      ac
      a b c d
      A B C D

它是如何工作的...

显然,字符串可以像数字一样使用+运算符进行相加,但这与数学无关,而是产生连接的字符串。为了将其与string_view混合使用,我们需要首先转换为std::string

然而,非常重要的一点是,当在代码中混合字符串和字符串视图时,我们绝不能假设string_view背后的基础字符串是零终止的!这就是为什么我们宁愿写"abc"s + string{some_string_view}而不是"abc"s + some_string_view.data()。除此之外,std::string提供了一个成员函数append,可以处理string_view实例,但它会改变字符串,而不是返回一个新的带有字符串视图内容的字符串。

std::string_view很有用,但在与字符串和字符串函数混合使用时要小心。我们不能假设它们是以零结尾的,这在标准字符串环境中会很快出问题。幸运的是,通常有适当的函数重载,可以正确处理它们。

然而,如果我们想要进行复杂的字符串连接和格式化等操作,我们不应该逐个在字符串实例上执行。std::stringstreamstd::ostringstreamstd::istringstream类更适合这样做,因为它们在附加时增强了内存管理,并提供了我们从一般流中了解的所有格式化功能。在本节中,我们选择了std::ostringstream类,因为我们要创建一个字符串而不是解析它。std::istringstream实例可以从现有字符串实例中实例化,然后我们可以轻松地将其解析为其他类型的变量。如果我们想要结合两者,std::stringstream是完美的全能选手。

修剪字符串开头和结尾的空格。

特别是在从用户输入中获取字符串时,它们经常被不需要的空格污染。在另一个示例中,我们去除了单词之间出现的多余空格。

现在让我们看看被空格包围的字符串并去除它。std::string有一些很好的辅助函数可以完成这项工作。

阅读了这个使用普通字符串对象执行此操作的示例后,确保还阅读以下示例。在那里,我们将看到如何避免不必要的副本或数据修改,使用新的std::string_view类。

如何做...

在本节中,我们将编写一个辅助函数,用于识别字符串中的周围空格并返回一个不包含它的副本,然后我们将对其进行简要测试。

  1. 和往常一样,首先是头文件包含和使用指令:
      #include <iostream>
      #include <string>
      #include <algorithm>
      #include <cctype>

      using namespace std;
  1. 我们的修剪字符串周围空格的函数接受一个现有字符串的常量引用。它将返回一个没有任何周围空格的新字符串:
      string trim_whitespace_surrounding(const string &s)
      {
  1. std::string提供了两个很有用的函数,这些函数对我们非常有帮助。第一个是string::find_first_not_of,它接受一个包含我们要跳过的所有字符的字符串。这当然是空格,意味着空格字符 ' ', 制表符 't' 和换行符 'n'。它会返回第一个非空格字符的位置。如果字符串中只有空格,它会返回string::npos。这意味着如果我们从中修剪空格,只剩下一个空字符串。因此,在这种情况下,让我们返回一个空字符串:
          const char whitespace[] {" tn"};
          const size_t first (s.find_first_not_of(whitespace));
          if (string::npos == first) { return {}; }
  1. 我们现在知道新字符串应该从哪里开始,但我们还不知道它应该在哪里结束。因此,我们使用另一个方便的字符串函数string::find_last_not_of。它将返回字符串中最后一个非空白字符的位置:
          const size_t last (s.find_last_not_of(whitespace));
  1. 使用string::substr,我们现在可以返回由空格包围但不包含空格的字符串部分。这个函数接受两个参数--一个位置,表示从字符串的哪个位置开始,以及在这个位置之后的字符数
          return s.substr(first, (last - first + 1));
      }
  1. 就是这样。让我们编写一个主函数,在其中创建一个字符串,用各种空格包围文本句子,以便对其进行修剪:
      int main()
      {
          string s {" tn string surrounded by ugly"
                    " whitespace tn "};
  1. 我们打印字符串的未修剪和修剪版本。通过用括号括起字符串,更容易看出修剪前它包含的空格:
          cout << "{" << s << "}n";
          cout << "{" 
               << trim_whitespace_surrounding(s) 
               << "}n";
      }
  1. 编译和运行程序会产生我们预期的输出:
      $ ./trim_whitespace 
      {  
        string surrounded by ugly whitespace    
         }
      {string surrounded by ugly whitespace}

它是如何工作的...

在这一部分,我们使用了string::find_first_not_ofstring::find_last_not_of。这两个函数都接受一个 C 风格的字符串,它作为一个应该在搜索不同字符时跳过的字符列表。如果我们有一个携带字符串"foo bar"的字符串实例,并且在它上调用find_first_not_of("bfo "),它将返回值5,因为'a'字符是第一个不在"bfo "字符串中的字符。参数字符串中字符的顺序并不重要。

相同的函数也存在相反的逻辑,尽管我们在这个示例中没有使用它们:string::find_first_ofstring::find_last_of

与基于迭代器的函数类似,我们需要检查这些函数是否返回字符串中的实际位置,还是表示它们没有找到满足约束条件的字符位置的值。如果它们没有找到,它们会返回string::npos

从我们的辅助函数中检索到的字符位置,我们建立了一个不包含周围空白的子字符串,使用string::substring。这个函数接受一个相对偏移和一个字符串长度,然后返回一个新的字符串实例,其中包含了那个子字符串。例如,string{"abcdef"}.substr(2, 2)将返回一个新的字符串"cd"

获得 stdstring 的便利性,而不需要构造 stdstring 对象的成本

std::string类是一个非常有用的类,因为它极大地简化了处理字符串的过程。一个缺点是,如果我们想传递它的子字符串,我们需要传递一个指针和一个长度变量,两个迭代器,或者子字符串的副本。我们在上一个示例中做到了这一点,我们通过获取不包含周围空白的子字符串范围的副本来实现了这一点。

如果我们想要将字符串或子字符串传递给甚至不支持std::string的库,我们只能提供一个原始字符串指针,这有点令人失望,因为它让我们回到了旧的 C 语言时代。就像子字符串问题一样,原始指针并不携带有关字符串长度的信息。这样,一个人将不得不实现一个指针和字符串长度的捆绑。

以简化的方式来说,这正是std::string_view。它自 C++17 起可用,并提供了一种将指向某个字符串的指针与该字符串的大小配对的方法。它体现了为数据数组提供引用类型的想法。

如果我们设计的函数以前接受std::string实例作为参数,但没有改变它们以需要字符串实例重新分配保存实际字符串负载的内存的方式,我们现在可以使用std::string_view,并且更兼容于 STL-agnostic 的库。我们可以让其他库提供对其复杂字符串实现背后的负载字符串的string_view视图,然后在我们的 STL 代码中使用它。这样,string_view类就充当了一个最小且有用的接口,可以在不同的库之间共享。

另一个很酷的事情是,string_view可以被用作对更大的字符串对象的子字符串的非复制引用。有很多可以利用它的可能性。在这一部分,我们将使用string_view来玩耍,以便对其优势和劣势有所了解。我们还将看到如何通过调整字符串视图而不是修改或复制实际字符串来隐藏字符串的周围空白。这种方法避免了不必要的复制或数据修改。

如何做...

我们将实现一个依赖于一些string_view特性的函数,然后,我们将看到我们可以将多少不同类型的数据输入到其中:

  1. 首先是头文件包含和使用指令:
      #include <iostream>
      #include <string_view>

      using namespace std;
  1. 我们实现了一个函数,它只接受一个string_view作为参数:
      void print(string_view v)
      {
  1. 在对输入字符串进行任何操作之前,我们去除任何前导和尾随空白。我们不会改变字符串,但是通过将其缩小到实际的非空白部分,视图会改变。find_first_not_of函数将找到字符串中第一个不是空格(' ')、制表符('t')和换行符('n')的字符。通过remove_prefix,我们将内部的string_view指针移动到第一个非空白字符。如果字符串只包含空白,find_first_not_of函数将返回值npos,即size_type(-1)。由于size_type是无符号变量,这将变成一个非常大的数字。因此,我们取两者中较小的一个:words_begin或字符串视图的大小:
          const auto words_begin (v.find_first_not_of(" tn"));
          v.remove_prefix(min(words_begin, v.size()));
  1. 我们对尾随空白做同样的处理。remove_suffix会缩小视图的大小变量:
          const auto words_end (v.find_last_not_of(" tn"));
          if (words_end != string_view::npos) {
              v.remove_suffix(v.size() - words_end - 1);
          }
  1. 现在我们可以打印字符串视图及其长度:
          cout << "length: " << v.length()
               << " [" << v << "]n";
      }
  1. 在我们的主函数中,我们通过使用完全不同的参数类型来玩弄新的print函数。首先,我们给它一个运行时的char*字符串,来自argv指针。在运行时,它包含了我们可执行文件的文件名。然后,我们给它一个空的string_view实例。然后,我们用 C 风格的静态字符字符串和""sv字面量来给它提供参数,这会在我们的程序中构造一个string_view。最后,我们给它一个std::string。好处是,为了调用print函数,这些参数都没有被修改或复制。没有堆分配发生。对于许多和/或大字符串,这是非常高效的。
      int main(int argc, char *argv[])
      {
          print(argv[0]);
          print({});
          print("a const char * array");
          print("an std::string_view literal"sv);
          print("an std::string instance"s);
  1. 我们没有测试去除空白的功能。所以,让我们给它一个有很多前导和尾随空白的字符串:
          print(" tn foobar n t ");
  1. 另一个很酷的功能是,string_view给我们访问的字符串不必是零终止的。如果我们构造一个字符串,比如"abc",没有尾随零,print函数仍然可以安全地处理它,因为string_view也携带了它指向的字符串的大小:
          char cstr[] {'a', 'b', 'c'};
          print(string_view(cstr, sizeof(cstr)));
      }
  1. 编译和运行程序会产生以下输出。所有字符串都被正确处理。我们填充了大量前导和尾随空白的字符串被正确过滤,没有零终止的abc字符串也被正确打印,没有任何缓冲区溢出:
      $ ./string_view 
      length: 17 [./string_view]
      length: 0 []
      length: 20 [a const char * array]
      length: 27 [an std::string_view literal]
      length: 23 [an std::string instance]
      length: 6 [foobar]
      length: 3 [abc]

它是如何工作的...

我们刚刚看到,我们可以调用接受string_view参数的函数,基本上可以使用任何类似字符串的东西,它以连续方式存储字符。在我们的print调用中,没有对基础字符串进行任何复制

有趣的是,在我们的print(argv[0])调用中,字符串视图自动确定了字符串长度,因为这是一个约定的零结尾字符串。反过来,不能假设可以通过计算直到达到零终止符为止的项目数来确定string_view实例的数据长度。因此,我们必须始终小心地处理string_view::data()指向的字符串视图数据的指针。通常的字符串函数大多假定零终止,因此,使用指向字符串视图有效载荷的原始指针可能会非常严重地缓冲区溢出。最好使用已经期望字符串视图的接口。

除此之外,我们已经从std::string中知道了很多豪华的接口。

使用std::string_view来传递字符串或子字符串,可以避免复制或堆分配,同时不失去字符串类的便利性。但要注意std::string_view放弃了字符串以零结尾的假设。

从用户输入读取值

这本书中的许多食谱都从输入源(如标准输入或文件)中读取数值,并对其进行处理。这次我们只关注读取,并学习更多关于错误处理的知识,如果从流中读取的内容出现问题,我们需要处理它,而不是终止整个程序。

在这个食谱中,我们只会从用户输入中读取,但一旦我们知道如何做到这一点,我们也知道如何从任何其他流中读取。用户输入是通过std::cin读取的,这本质上是一个输入流对象,就像ifstreamistringstream的实例一样。

如何做...

在本节中,我们将读取用户输入到不同的变量中,并看看如何处理错误,以及如何将输入复杂化为有用的块:

  1. 这次我们只需要iostream。因此,让我们包含这个单一的头文件,并声明我们默认使用std命名空间:
      #include <iostream>

      using namespace std;
  1. 首先提示用户输入两个数字。我们将把它们解析成一个int和一个double变量。用户可以用空格分隔它们。例如,1 2.3是一个有效的输入:
      int main()
      {
          cout << "Please Enter two numbers:n> ";
          int x;
          double y;
  1. 解析和错误检查同时在if分支的条件部分完成。只有在两个数字都能解析出来时,它们对我们才有意义,我们才会打印它们:
          if (cin >> x >> y) {
              cout << "You entered: " << x 
                   << " and " << y << 'n';
  1. 如果由于任何原因解析失败,我们会告诉用户解析没有成功。cin流对象现在处于失败状态,直到我们再次清除失败状态之前,它不会给我们其他输入。为了能够解析新的输入,我们调用cin.clear()并丢弃到目前为止收到的所有输入。丢弃是用cin.ignore完成的,我们指定丢弃最大数量的字符,直到最终看到换行符,然后将其丢弃。之后的所有内容又变得有趣起来:
          } else {
              cout << "Oh no, that did not go well!n";
              cin.clear();
              cin.ignore(
                  std::numeric_limits<std::streamsize>::max(),
                  'n');
          }
  1. 现在让我们要求输入其他内容。我们让用户输入名字。由于名字可以由多个用空格分隔的单词组成,空格字符不再是一个好的分隔符。因此,我们使用std::getline,它接受一个流对象,比如cin,一个字符串引用,它将把输入复制到其中,以及一个分隔字符。让我们选择逗号(,)作为分隔字符。通过不仅仅使用cin,而是使用cin >> ws作为getline的流参数,我们可以使cin在任何名字之前丢弃任何前导空格。在每个循环步骤中,我们打印当前的名字,但如果一个名字是空的,我们就退出循环:
          cout << "now please enter some "
                  "comma-separated names:n> ";

          for (string s; getline(cin >> ws, s, ',');) {
              if (s.empty()) { break; }
              cout << "name: "" << s << ""n";
          }
      }
  1. 编译和运行程序会产生以下输出,假设我们只输入了有效的输入。数字是"1 2",被正确解析,然后我们输入一些名字,它们也被正确列出。以两个连续逗号的形式输入空名字会退出循环:
      $ ./strings_from_user_input 
      Please Enter two numbers:
      > 1 2
      You entered: 1 and 2
      now please enter some comma-separated names:
      > john doe,  ellen ripley,       alice,    chuck norris,,
      name: "john doe"
      name: "ellen ripley"
      name: "alice"
      name: "chuck norris"
  1. 当再次运行程序时,在开始输入错误的数字时,我们看到程序正确地选择了另一个分支,丢弃了错误的输入,并正确地继续了名字的输入。尝试使用cin.clear()cin.ignore(...)行,看看它们如何影响名字读取代码:
      $ ./strings_from_user_input
      Please Enter two numbers:
      > a b
      Oh no, that did not go well!
      now please enter some comma-separated names:
      > bud spencer, terence hill,,
      name: "bud spencer"
      name: "terence hill"

工作原理...

在本节中,我们进行了一些复杂的输入检索。首先要注意的是,我们总是同时进行检索和错误检查。

表达式cin >> x的结果再次是对cin的引用。这样,我们可以写cin >> x >> y >> z >> ...。同时,它也可以在布尔上下文中转换为布尔值,比如if条件中。布尔值告诉我们最后一次读取是否成功。这就是为什么我们能够写if (cin >> x >> y) {...}

例如,如果我们尝试读取一个整数,但输入包含"foobar"作为下一个标记,那么将其解析为整数是不可能的,流对象进入失败状态。这只对解析尝试很重要,但对整个程序并不重要。重置它然后尝试其他任何事情都是可以的。在我们的配方程序中,我们尝试在尝试读取两个数字失败后读取一系列名称。在尝试读取这些数字失败的情况下,我们使用cin.clear()cin恢复到工作状态。但是,它的内部光标仍然停留在我们键入的内容而不是数字上。为了丢弃这个旧输入并清除名称输入的管道,我们使用了非常长的表达式cin.ignore(std::numeric_limits<std::streamsize>::max(), 'n');。这是必要的,因为我们想要从一个真正新鲜的缓冲区开始,当我们要求用户提供一系列名称时。

下面的循环一开始可能看起来很奇怪:

for (string s; getline(cin >> ws, s, ',');) { ... }

for循环的条件部分中,我们使用getlinegetline函数接受一个输入流对象,一个字符串引用作为输出参数,以及一个分隔符字符。默认情况下,分隔符字符是换行符。在这里,我们将其定义为逗号(,)字符,因此列表中的所有名称,例如"john, carl, frank",都将被单独读取。

到目前为止,一切都很好。但是将cin >> ws函数作为流对象提供是什么意思呢?这使得cin首先刷新所有空白字符,这些空白字符位于下一个非空白字符之前和最后一个逗号之后。回顾一下"john, carl, frank"的例子,我们将得到子字符串"john"" carl"" frank",而不使用ws。注意carlfrank的不必要的前导空格字符?由于我们对输入流的ws预处理,这些实际上消失了。

在文件中计算所有单词

假设我们读取一个文本文件,并且想要计算文本中的单词数。我们定义一个单词是两个空格字符之间的字符范围。我们该如何做呢?

我们可以计算空格的数量,例如,因为单词之间必须有空格。在句子"John has a funny little dog."中,我们有五个空格字符,所以我们可以说有六个单词。

如果我们有一个带有空格噪音的句子,例如" John has t anfunny little dog ."?这个字符串中有太多不必要的空格,甚至不仅仅是空格。从本书的其他配方中,我们已经学会了如何去除这种多余的空格。因此,我们可以首先将字符串预处理为正常的句子形式,然后应用计算空格字符的策略。是的,这是可行的,但有一个简单的方法。为什么我们不使用 STL 已经提供给我们的东西呢?

除了为这个问题找到一个优雅的解决方案之外,我们还将让用户选择是否从标准输入或文本文件中计算单词。

如何做...

在本节中,我们将编写一个一行函数,用于计算输入缓冲区中的单词,并让用户选择输入缓冲区的读取位置:

  1. 首先让我们包括所有必要的头文件,并声明我们使用std命名空间:
      #include <iostream>
      #include <fstream>
      #include <string>
      #include <algorithm>
      #include <iterator>      

      using namespace std;
  1. 我们的wordcount函数接受一个输入流,例如cin。它创建一个std::input_iterator迭代器,该迭代器从流中标记字符串,然后将它们传递给std::distancedistance参数接受两个迭代器作为参数,并尝试确定从一个迭代器位置到另一个迭代器位置需要多少递增步骤。对于随机访问迭代器来说,这很简单,因为它们实现了数学差异操作(operator-)。这样的迭代器可以像指针一样相互减去。然而,istream_iterator前向迭代器,必须一直前进直到等于结束迭代器。最终,所需的步骤数就是单词数:
      template <typename T>
      size_t wordcount(T &is)
      {
          return distance(istream_iterator<string>{is}, {});
      }
  1. 在我们的主函数中,我们让用户选择输入流是std::cin还是输入文件:
      int main(int argc, char **argv)
      {
          size_t wc;
  1. 如果用户在 shell 中与文件名一起启动程序(例如$ ./count_all_words some_textfile.txt),那么我们将从argv命令行参数数组中获取该文件名,并打开它,以便将新的输入文件流输入到wordcount中:
          if (argc == 2) {
              ifstream ifs {argv[1]};
              wc = wordcount(ifs);
  1. 如果用户在没有任何参数的情况下启动程序,我们假设输入来自标准输入:
          } else {
              wc = wordcount(cin);
          }
  1. 就是这样,所以我们只需打印我们保存在变量wc中的单词数:
          cout << "There are " << wc << " wordsn";
      };
  1. 让我们编译并运行程序。首先,我们从标准输入中输入程序,没有任何文件参数。我们可以通过管道将 echo 调用与一些单词一起输入,或者启动程序并从键盘输入一些单词。在后一种情况下,我们可以通过按Ctrl+D来停止输入。这是将一些单词回显到程序中的方式:
      $ echo "foo bar baz" | ./count_all_words 
      There are 3 words
  1. 当以源代码文件作为输入启动程序时,它将计算它由多少个单词组成:
      $ ./count_all_words count_all_words.cpp
      There are 61 words

它是如何工作的...

没有太多要说的了;大部分内容在实现时已经解释过了,因为这个程序非常简短。我们可以详细介绍一点的是,我们完全可以以相互替换的方式使用std::cinstd::ifstream实例。cinstd::istream类型,而std::ifstream继承自std::istream。看一下本章开头的类继承图表。这样,它们在运行时是完全可以互换的。

通过使用流抽象来保持代码模块化。这有助于解耦源代码部分,并使您的代码易于测试,因为您可以注入任何其他匹配类型的流。

使用 I/O 流操纵器格式化输出

在许多情况下,仅仅打印字符串和数字是不够的。有时,数字需要以十进制数打印,有时以十六进制数打印,有时甚至以八进制数打印。有时我们希望在十六进制数前面看到"0x"前缀,有时不希望。

在打印浮点数时,我们可能也有很多事情想要影响。小数值是否总是以相同的精度打印?它们是否应该被打印?或者,也许我们想要科学计数法?

除了科学表示法和十六进制、八进制等,我们还希望以整洁的形式呈现用户输出。有些输出可以以表格的形式排列,以使其尽可能可读。

当然,所有这些都可以通过输出流实现。当从输入流中解析值时,其中一些设置也很重要。在本教程中,我们将通过玩弄这些所谓的I/O 操纵器来感受一下。有时,它们看起来很棘手,所以我们也会深入一些细节。

如何做...

在本节中,我们将使用各种格式设置打印数字,以便熟悉 I/O 操纵器:

  1. 首先,我们包括所有必要的标头,并声明我们默认使用std命名空间:
      #include <iostream>
      #include <iomanip>
      #include <locale>      

      using namespace std;
  1. 接下来,我们定义一个辅助函数,它以不同的样式打印单个整数值。它接受填充宽度和填充字符,默认为空格' '
      void print_aligned_demo(int val, 
                              size_t width, 
                              char fill_char = ' ')
      {
  1. 使用setw,我们可以设置打印数字时的最小字符数。例如,如果我们以宽度为6打印123,我们会得到" 123""123 "。我们可以使用std::leftstd::rightstd::internal控制填充发生在哪一侧。在以十进制形式打印数字时,internal看起来与right相同。但是,例如,如果我们以宽度为6internal打印值0x1,我们会得到"0x 6"setfill操纵器定义了用于填充的字符。我们将尝试不同的样式:
          cout << "================n";
          cout << setfill(fill_char);
          cout << left << setw(width) << val << 'n';
          cout << right << setw(width) << val << 'n';
          cout << internal << setw(width) << val << 'n';
      }
  1. 在主函数中,我们开始使用刚刚实现的函数。首先,我们打印值12345,宽度为15。我们这样做两次,但第二次,我们使用'_'字符进行填充:
      int main()
      {
          print_aligned_demo(123456, 15);
          print_aligned_demo(123456, 15, '_');
  1. 之后,我们以与之前相同的宽度打印值0x123abc。但在这之前,我们应用了std::hexstd::showbase,告诉输出流对象cout应该以十六进制格式打印数字,并且应该在它们前面添加"0x",以便明确表示它们应该被解释为十六进制:
          cout << hex << showbase;
          print_aligned_demo(0x123abc, 15);
  1. 我们也可以使用oct做同样的事情,告诉cout使用八进制系统来打印数字。showbase仍然有效,因此0将被添加到每个打印的数字前面:
          cout << oct;
          print_aligned_demo(0123456, 15);
  1. 使用hexuppercase,我们得到了"0x"中的'x'大写打印。'0x123abc'中的'abc'也是大写的:
          cout << "A hex number with upper case letters: "
               << hex << uppercase << 0x123abc << 'n';
  1. 如果我们想再次以十进制格式打印100,我们必须记住之前已经将流切换为hex。通过使用dec,我们可以将其恢复为正常状态:
          cout << "A number: " << 100 << 'n';
          cout << dec;

          cout << "Oops. now in decimal again: " << 100 << 'n';
  1. 我们还可以配置布尔值的打印方式。默认情况下,true打印为1false打印为0。使用boolalpha,我们可以将其设置为文本表示:
          cout << "true/false values: " 
               << true << ", " << false << 'n';
          cout << boolalpha
               << "true/false values: "
               << true << ", " << false << 'n';
  1. 让我们来看看floatdouble类型的浮点变量。如果我们打印一个数字,比如12.3,它当然会打印为12.3。如果我们有一个数字,比如12.0,输出流将会去掉小数点,我们可以使用showpoint来改变这一点。使用这个,小数点总是会显示:
          cout << "doubles: "
               << 12.3 << ", "
               << 12.0 << ", "
               << showpoint << 12.0 << 'n';
  1. 浮点数的表示可以是科学或固定的。scientific表示数字被标准化为这样一种形式,即小数点前只有一个数字,然后打印出指数,这是将数字乘回其实际大小所需的。例如,值300.0将被打印为"3.0E2",因为300等于3.0 * 10²fixed则恢复为正常的十进制表示法:
          cout << "scientific double: " << scientific 
               << 123000000000.123 << 'n';
          cout << "fixed      double: " << fixed 
               << 123000000000.123 << 'n';
  1. 除了表示法,我们还可以决定浮点数打印的精度。让我们创建一个非常小的值,并以小数点后 10 位的精度打印它,然后再以小数点后只有一位的精度打印它:
          cout << "Very precise double: " 
               << setprecision(10) << 0.0000000001 << 'n';
          cout << "Less precise double: " 
               << setprecision(1)  << 0.0000000001 << 'n';
      }
  1. 编译并运行程序会产生以下冗长的输出。前四个输出块是打印助手函数的输出,该函数对setwleft/right/internal修饰符进行了调整。之后,我们对基本表示、布尔表示和浮点数格式进行了调整。熟悉每种格式是个好主意:
      $ ./formatting 
      ================
      123456         
               123456
               123456
      ================
      123456_________
      _________123456
      _________123456
      ================
      0x123abc       
             0x123abc
      0x       123abc
      ================
      0123456        
              0123456
              0123456
      A hex number with upper case letters: 0X123ABC
      A number: 0X64
      Oops. now in decimal again: 100
      true/false values: 1, 0
      true/false values: true, false
      doubles: 12.3, 12, 12.0000
      scientific double: 1.230000E+11
      fixed      double: 123000000000.123001
      Very precise double: 0.0000000001
      Less precise double: 0.0

它是如何工作的...

所有这些有时相当长的<< foo << bar流表达式如果读者不清楚每个表达式的含义,会让人感到困惑。因此,让我们来看一下现有格式修饰符的表格。它们都应该放在input_stream >> modifieroutput_stream << modifier表达式中,然后影响接下来的输入或输出:

符号 含义
setprecision(int n) 设置打印或解析浮点值时的精度参数。
showpoint / noshowpoint 启用或禁用打印浮点数的小数点,即使它们没有任何小数位
fixed / scientific / hexfloat / defaultfloat 数字可以以固定样式(这是最直观的样式)或科学样式打印。fixedscientific代表这些模式。hexfloat激活这两种模式,它以十六进制浮点表示法格式化浮点数。defaultfloat取消这两种模式。
showpos / noshowpos 启用或禁用打印正浮点值的'+'前缀
setw(int n) 读取或写入确切的n个字符。在读取时,这会截断输入。在打印时,如果输出长度小于n个字符,则会应用填充。
setfill(char c) 在应用填充(参见setw)时,用字符值c填充输出。默认值是空格(' ')。
internal / left / right leftright控制固定宽度打印(参见setw)的填充位置。internal将填充字符放在整数及其负号、十六进制前缀和十六进制打印值,或货币单位和值之间的中间位置。
dec / hex / oct 可以在十进制、十六进制和八进制基数系统中打印和解析整数值
setbase(int n) 这是dec/hex/oct的数值同义函数,如果与值10/16/8一起使用,则它们是等效的。其他值会将基础选择重置为0,这将再次导致十进制打印,或者根据输入的前缀进行解析。
quoted(string) 以引号打印字符串或从带引号的输入中解析字符串,然后删除引号。string可以是 String 类实例或 C 风格的字符数组。
boolalpha / noboolalpha 以字母表示形式而不是1/0字符串打印或解析布尔值
showbase / noshowbase 在打印或解析数字时启用或禁用基数前缀。对于hex,这是0x;对于octal,这是0
uppercase / nouppercase 在打印浮点和十六进制值时启用或禁用大写或字母字符

熟悉它们的最佳方法是稍微研究它们的多样性并与它们玩耍。

然而,在与它们玩耍时,我们可能已经注意到这些修改器中的大多数似乎是粘性的,而其中一些则不是。粘性意味着一旦应用,它们似乎会永久地影响输入/输出,直到它们再次被重置。此表中唯一不粘性的是setwquoted。它们只影响输入/输出中的下一项。这是很重要的,因为如果我们以某种格式打印一些输出,我们应该在之后整理我们的流对象格式设置,因为来自不相关代码的下一个输出可能看起来很疯狂。同样适用于输入解析,其中错误的 I/O 操作器选项可能会导致问题。

我们并没有真正使用它们中的任何一个,因为它们与格式无关,但出于完整性的原因,我们也应该看一下其他一些流状态操作器:

符号 含义
skipws / noskipws 启用或禁用输入流跳过空白的功能
unitbuf / nounitbuf 启用或禁用任何输出操作后立即刷新输出缓冲区
ws 可以在输入流上使用,以跳过流头部的任何空白
ends 在流中写入一个字符串终止''字符
flush 立即刷新输出缓冲区中的内容
endl 在输出流中插入一个 'n' 字符并刷新输出

从中,只有skipws/noskipwsunitbuf/nounitbuf是粘性的。

从文件输入初始化复杂对象

读取单独的整数、浮点数和单词字符串非常容易,因为输入流对象的 >> 操作符已经为所有这些类型重载了,并且输入流方便地为我们删除了所有中间的空白。

但是,如果我们有一个更复杂的结构,我们想要从输入流中读取,如果我们需要读取包含多个单词的字符串(因为它们通常会被分成单个单词,因为空白会被跳过),那该怎么办呢?

对于任何类型,都可以提供另一个输入流 operator>> 重载,我们将看到如何做到这一点。

如何做...

在本节中,我们将定义一个自定义数据结构,并提供从标准输入流中读取这些项目的功能:

  1. 首先,我们需要包含一些头文件,并且为了方便起见,我们声明默认使用 std 命名空间:
      #include <iostream>
      #include <iomanip>
      #include <string>
      #include <algorithm>
      #include <iterator>
      #include <vector>      

      using namespace std;
  1. 作为一个复杂对象的例子,我们定义了一个 city 结构。一个城市应该有一个名称、一个人口数量和地理坐标:
      struct city {
          string name;
          size_t population;
          double latitude;
          double longitude;
      };
  1. 为了能够从串行输入流中读取这样一个城市,我们需要重载流函数 operator>>。在这个操作符中,我们首先使用 ws 跳过所有前导空白,因为我们不希望空白污染城市名称。然后,我们读取一整行文本输入。这意味着在输入文件中,只有一整行文本只携带城市对象的名称。然后,在换行符之后,跟着一个以空格分隔的数字列表,表示人口数量、地理纬度和经度:
      istream& operator>>(istream &is, city &c)
      {
          is >> ws;
          getline(is, c.name);
          is >> c.population 
             >> c.latitude 
             >> c.longitude;
          return is;
      }
  1. 在我们的主函数中,我们创建了一个可以容纳一系列城市项目的向量。我们使用 std::copy 来填充它。复制调用的输入是一个 istream_iterator 范围。通过将 city 结构类型作为模板参数传递给它,它将使用我们刚刚实现的 operator>> 函数重载:
      int main()
      {
          vector<city> l;

          copy(istream_iterator<city>{cin}, {}, 
               back_inserter(l));
  1. 为了查看我们的城市解析是否正确,我们打印了列表中的内容。I/O 格式化,left << setw(15) <<,导致城市名称被填充了空白,所以我们得到了一个很好的可读形式的输出:
          for (const auto &[name, pop, lat, lon] : l) {
              cout << left << setw(15) << name
                   << " population=" << pop
                   << " lat=" << lat
                   << " lon=" << lon << 'n';
          }
      }
  1. 我们将喂给我们的程序的文本文件看起来像这样。有四个示例城市及其人口数量和地理坐标:
      Braunschweig
      250000 52.268874 10.526770
      Berlin
      4000000 52.520007 13.404954
      New York City
      8406000 40.712784 -74.005941
      Mexico City
      8851000 19.432608 -99.133208
  1. 编译和运行程序产生了以下输出,这正是我们所期望的。尝试通过在城市名称之前添加一些不必要的空白来篡改输入文件,以查看它是如何被过滤掉的:
      $ cat cities.txt  | ./initialize_complex_objects
      Braunschweig    population=250000 lat=52.2689 lon=10.5268
      Berlin          population=4000000 lat=52.52 lon=13.405
      New York City   population=8406000 lat=40.7128 lon=-74.0059
      Mexico City     population=8851000 lat=19.4326 lon=-99.1332

它是如何工作的...

这又是一个简短的示例。我们所做的唯一的事情就是创建一个新的结构 city,然后为这种类型重载 std::istream 迭代器的 operator>>,就是这样。这已经使我们能够从标准输入中反序列化城市项目使用 istream_iterator<city>

关于错误检查可能还有一个未解决的问题。让我们再次看看 operator>> 的实现:

      istream& operator>>(istream &is, city &c)
      {
          is >> ws;
          getline(is, c.name);
          is >> c.population >> c.latitude >> c.longitude;
          return is;
      }

我们正在读取很多不同的东西。如果其中一个失败了,下一个又怎么样?这是否意味着我们可能会用错误的“偏移量”读取所有后续的项目?不,这是不可能的。一旦这些项目中的一个无法从输入流中解析出来,输入流对象就会进入错误状态,并拒绝进一步解析任何内容。这意味着,例如 c.populationc.latitude 无法解析,剩余的 >> 操作数就会“跳过”,我们将以一个半反序列化的城市对象离开这个操作符函数范围。

在调用方面,当我们写 if (input_stream >> city_object) 时,我们会得到通知。当作为条件表达式使用时,这样的流表达式会被隐式转换为一个布尔值。如果输入流对象处于错误状态,则返回 false。知道这一点后,我们可以重置流并执行适当的操作。

在这个示例中,我们没有自己编写这样的if条件,因为我们让std::istream_iterator<city>进行反序列化。这个迭代器类的operator++实现在解析时也会检查错误。如果发生任何错误,它将拒绝进一步迭代。在这种状态下,当它与结束迭代器进行比较时,它将返回true,这使得copy算法终止。这样,我们就安全了。

从 std::istream 迭代器填充容器

在上一个示例中,我们学会了如何从输入流中组装复合数据结构,然后用它们填充列表或向量。

这一次,我们通过标准输入填充一个std::map,使问题变得有点困难。这里的问题是,我们不能只是用值填充单个结构,然后将其推回线性容器,比如列表或向量,因为map将其有效负载分为键和值部分。然而,它并不完全不同,正如我们将看到的那样。

学习了这个示例之后,我们将会对从字符流中序列化和反序列化复杂的数据结构感到满意。

如何做...

我们将定义另一个类似上一个示例的结构,但这次我们将把它填充到一个地图中,这使得它变得更加复杂,因为这个容器从键到值的映射,而不仅仅是在列表中保存所有值:

  1. 首先,我们包括所有需要的头文件,并声明我们默认使用std命名空间:
      #include <iostream>
      #include <iomanip>
      #include <map>
      #include <iterator>
      #include <algorithm>
      #include <numeric>      

      using namespace std;
  1. 我们想要维护一个小的互联网迷因数据库。假设一个迷因有一个名称、一个描述以及它诞生或发明的年份。我们将把它们保存在一个std::map中,其中名称是键,而其他信息则作为与键关联的值打包在一个结构中:
      struct meme {
          string description;
          size_t year;
      };
  1. 让我们首先忽略键,只为struct meme实现一个流operator>>函数重载。我们假设描述被引号包围,后面跟着年份。这在文本文件中看起来像"一些描述" 2017。由于描述被引号包围,它可以包含空格,因为我们知道引号之间的所有内容都属于它。通过使用is >> quoted(m.description)读取,引号会自动用作分隔符,并在之后被丢弃。这非常方便。就在那之后,我们读取年份数字:
      istream& operator>>(istream &is, meme &m) {
          return is >> quoted(m.description) >> m.year;
      }
  1. 好的,现在我们考虑将迷因的名称作为地图的键。为了将迷因插入地图,我们需要一个std::pair<key_type, value_type>实例。key_type当然是string,而value_typememe。名称也允许包含空格,所以我们使用与描述相同的quoted包装。p.first是名称,p.second是与之关联的整个meme结构。它将被馈送到我们刚刚实现的另一个operator>>实现中:
      istream& operator >>(istream &is, 
                           pair<string, meme> &p) {
          return is >> quoted(p.first) >> p.second;
      }
  1. 好的,就是这样。让我们编写一个主函数,实例化一个地图,并填充该地图。因为我们重载了流函数operator>>istream_iterator可以直接处理这种类型。我们让它从标准输入反序列化我们的迷因项目,并使用inserter迭代器将它们泵入地图中:
      int main()
      {
          map<string, meme> m;

          copy(istream_iterator<pair<string, meme>>{cin},
               {},
               inserter(m, end(m)));
  1. 在打印我们拥有的内容之前,让我们首先找出地图中最长的迷因名称是什么。我们使用std::accumulate来实现这一点。它得到一个初始值0uu表示无符号),并将按元素访问地图,以便将它们合并在一起。在accumulate中,合并通常意味着添加。在我们的情况下,我们不想得到任何数值的总和,而是最大的字符串长度。为了实现这一点,我们提供了一个辅助函数max_funcaccumulate,它接受当前最大尺寸变量(必须是unsigned,因为字符串长度是无符号的)并将其与当前项目的迷因名称字符串长度进行比较,以便取两个值中的最大值。这将对每个元素发生。accumulate函数的最终返回值是最大的迷因名称长度:
          auto max_func ([](size_t old_max, 
                            const auto &b) {
              return max(old_max, b.first.length());
          });
          size_t width {accumulate(begin(m), end(m), 
                                   0u, max_func)};
  1. 现在,让我们快速地循环遍历 map 并打印每个项。我们使用<< left << setw(width)来获得一个漂亮的类似表格的打印:
          for (const auto &[meme_name, meme_desc] : m) {
              const auto &[desc, year] = meme_desc;

              cout << left << setw(width) << meme_name
                   << " : " << desc
                   << ", " << year << 'n';
          }
      }
  1. 就是这样。我们需要一个小的互联网迷因数据库文件,所以让我们用一些示例填充一个文本文件:
      "Doge" "Very Shiba Inu. so dog. much funny. wow." 2013
      "Pepe" "Anthropomorphic frog" 2016
      "Gabe" "Musical dog on maximum borkdrive" 2016
      "Honey Badger" "Crazy nastyass honey badger" 2011
      "Dramatic Chipmunk" "Chipmunk with a very dramatic look" 2007
  1. 使用示例 meme 数据库编译和运行程序产生以下输出:
      $ cat memes.txt | ./filling_containers 
      Doge              : Very Shiba Inu. so dog. much funny. wow., 2013
      Dramatic Chipmunk : Chipmunk with a very dramatic look, 2007
      Gabe              : Musical dog on maximum borkdrive, 2016
      Honey Badger      : Crazy nastyass honey badger, 2011
      Pepe              : Anthropomorphic frog, 2016

它是如何工作的...

在这个示例中有三个特殊之处。一个是我们没有从串行字符流中填充普通向量或列表,而是从std::map这样的更复杂的容器中填充。另一个是我们使用了那些神奇的quoted流操作器。最后一个是accumulate调用,它找出了最大的键字符串大小。

让我们从map部分开始。我们的struct meme只包含一个description字段和year。互联网迷因的名称不是这个结构的一部分,因为它被用作 map 的键。当我们向 map 中插入东西时,我们可以提供一个具有键类型和值类型的std::pair。这就是我们所做的。我们首先为struct meme实现了流operator>>,然后我们为pair<string, meme>做了同样的事情。然后我们使用istream_iterator<**pair<string, meme>**>{cin}从标准输入中获取这些项,并使用inserter(m, end(m))将它们插入 map 中。

当我们从流中反序列化 meme 项时,我们允许名称和描述包含空格。这是很容易实现的,尽管我们每个 meme 只使用一行,因为我们对这些字段进行了引用。一行格式的示例如下:"Name with spaces" "Description with spaces" 123

处理输入和输出中的带引号字符串时,std::quoted是一个很好的帮助。如果我们有一个字符串s,使用cout << quoted(s)来打印它会加上引号。如果我们通过流反序列化一个字符串,例如,通过cin >> quoted(s),它将读取下一个引号,用后面的内容填充字符串,并继续直到看到下一个引号,无论涉及多少空格。

在我们的累积调用中,最后一个看起来奇怪的是max_func

auto max_func ([](size_t old_max, const auto &b) {
    return max(old_max, b.first.length());
});

size_t width {accumulate(begin(m), end(m), 0u, max_func)};

显然,max_func接受一个size_t参数和另一个auto-类型的参数,结果是来自 map 的pair项。这一开始看起来很奇怪,因为大多数二进制缩减函数接受相同类型的参数,然后使用某种操作将它们合并在一起,就像std::plus一样。在这种情况下,情况确实很不同,因为我们不是合并实际的pair项。我们只从每对中选择键字符串长度,丢弃其余部分,然后使用max函数减少结果的size_t值。

在累积调用中,max_func的第一个调用得到我们最初提供的0u值作为左参数,并得到右侧的第一个 pair 项的引用。这导致max(0u, string_length)的返回值,这是下一个调用的左参数,下一个 pair 项作为右参数,依此类推。

使用 std::ostream 迭代器进行通用打印

使用输出流打印任何东西都很容易,因为 STL 已经为大多数基本类型提供了许多有用的operator<<重载。这样,包含这些类型项的数据结构可以很容易地使用std::ostream_iterator类进行打印,这在本书中我们已经经常做过。

在这个示例中,我们将集中讨论如何使用自定义类型以及在调用方面不需要太多代码的情况下,我们可以通过模板类型选择来操纵打印。

如何做...

我们将通过启用与新自定义类的组合来玩std::ostream_iterator,并查看其隐式转换能力,这可以帮助我们进行打印:

  1. 首先是包含文件,然后我们声明默认使用std命名空间:
      #include <iostream>
      #include <vector>
      #include <iterator>
      #include <unordered_map>
      #include <algorithm>      

      using namespace std;
  1. 让我们实现一个转换函数,将数字映射到字符串。它应该为值1返回"one",为值2返回"two",依此类推:
      string word_num(int i) {
  1. 我们用我们需要的映射填充哈希映射,以便以后访问它们:
          unordered_map<int, string> m {
              {1, "one"}, {2, "two"}, {3, "three"},
              {4, "four"}, {5, "five"}, //...
          };
  1. 现在,我们可以使用哈希映射的 find 函数来查找参数 i,并返回它找到的内容。如果找不到任何内容,因为给定数字没有翻译,我们将返回字符串 "unknown"
          const auto match (m.find(i));
          if (match == end(m)) { return "unknown"; }
          return match->second;
      };
  1. 我们稍后将使用的另一件事是 struct bork。它只包含一个整数,并且也可以从整数隐式构造出来。它有一个 print 函数,接受一个输出流引用,并根据其成员整数 borks 的值重复打印 "bork" 字符串:
      struct bork {
          int borks;

          bork(int i) : borks{i} {}

          void print(ostream& os) const {
              fill_n(ostream_iterator<string>{os, " "}, 
                     borks, "bork!"s);
          }
      };
  1. 为了方便使用 bork::print,我们为流对象重载了 operator<<,因此每当 bork 对象被流到输出流中时,它们会自动调用 bork::print
      ostream& operator<<(ostream &os, const bork &b) {
          b.print(os);
          return os;
      }
  1. 现在我们终于可以开始实现实际的主函数了。我们最初只是创建了一个带有一些示例值的向量:
      int main()
      {
          const vector<int> v {1, 2, 3, 4, 5};
  1. ostream_iterator 类型的对象需要一个模板参数,该参数表示它们可以打印哪种类型的变量。如果我们写 ostream_iterator<**T**>,它将在打印时使用 ostream& operator(ostream&, const **T**&)。这正是我们之前为 bork 类型实现的。这一次,我们只是打印整数,所以是 ostream_iterator<**int**>。它将使用 cout 进行打印,因此我们将其作为构造函数参数提供。我们在循环中遍历我们的向量,并将每个项目 i 分配给解引用的输出迭代器。这也是 STL 算法使用流迭代器的方式:
          ostream_iterator<int> oit {cout};

          for (int i : v) { *oit = i; }
          cout << 'n';
  1. 我们刚刚生成的迭代器的输出是正常的,但它打印数字时没有任何分隔符。如果我们希望在所有打印的项目之间有一些分隔空格,我们可以将自定义的间隔字符串作为输出流迭代器构造函数的第二个参数提供。这样,它将打印 "1, 2, 3, 4, 5, " 而不是 "12345"。不幸的是,我们无法轻松地告诉它在最后一个数字之后删除逗号空格字符串,因为迭代器在到达最后一个数字之前不知道它的结束:
          ostream_iterator<int> oit_comma {cout, ", "};

          for (int i : v) { *oit_comma = i; }
          cout << 'n';
  1. 将项目分配给输出流迭代器以便打印它们并不是使用它的错误方式,但这不是它们被发明的目的。想法是将它们与算法结合使用。最简单的算法是 std::copy。我们可以将向量的开始和结束迭代器作为输入范围,将输出流迭代器作为输出迭代器。它将打印向量的所有数字。让我们用输出迭代器和之前编写的循环来比较一下:
          copy(begin(v), end(v), oit);
          cout << 'n';

          copy(begin(v), end(v), oit_comma);
          cout << 'n';
  1. 还记得函数 word_num 吗,它将数字映射到字符串,比如 1 对应 "one"2 对应 "two",依此类推?是的,我们也可以用它们来打印。我们只需要使用一个输出流操作符,它是针对 string 进行模板专门化的,因为我们不再打印整数。而且我们使用 std::transform 而不是 std::copy,因为它允许我们在将每个项目复制到输出范围之前对输入范围中的每个项目应用转换函数:
          transform(begin(v), end(v), 
                    ostream_iterator<string>{cout, " "}, 
                    word_num);
          cout << 'n';
  1. 程序中的最后一行最终使用了 struct bork。我们可以为 std::transform 提供一个转换函数,但我们没有这样做。相反,我们可以在 std::copy 调用中创建一个专门针对 bork 类型的输出流迭代器。这将导致从输入范围整数隐式创建 bork 实例。这将给我们一些有趣的输出:
          copy(begin(v), end(v), 
               ostream_iterator<bork>{cout, "n"});
      }
  1. 编译和运行程序会产生以下输出。前两行与接下来的两行完全相同,这是我们预料到的。然后,我们得到了漂亮的、写出来的数字字符串,然后是大量的 bork! 字符串。这些出现在多行中,因为我们使用了 "n" 分隔字符串而不是空格:
      $ ./ostream_printing 
      12345
      1, 2, 3, 4, 5, 
      12345
      1, 2, 3, 4, 5, 
      one two three four five 
      bork! 
      bork! bork! 
      bork! bork! bork! 
      bork! bork! bork! bork! 
      bork! bork! bork! bork! bork! 

它是如何工作的...

我们已经看到std::ostream_iterator实际上只是一个语法技巧,它将打印的行为压缩成迭代器的形式和语法。递增这样的迭代器没有任何作用。对其进行解引用只会返回一个代理对象,其赋值运算符将其参数转发到输出流。

对于类型T(如ostream_iterator<T>)进行特化的输出流迭代器可以使用提供了ostream& operator<<(ostream&, const T&)实现的所有类型。

ostream_iterator总是尝试调用其模板参数指定的类型的operator<<,它将尝试隐式转换类型(如果允许)。当我们迭代A类型的项目范围,但将这些项目复制到output_iterator<B>实例时,如果A可以隐式转换为B,这将起作用。我们对struct bork也是完全相同的操作:bork实例可以从整数值隐式转换。这就是为什么很容易将大量"bork!"字符串抛到用户 shell 上。

如果隐式转换不可能,我们可以自己做,使用std::transform,这就是我们与word_num函数结合使用的方法。

请注意,通常允许自定义类型进行隐式转换不好的风格,因为这是一个常见的bug 来源,后期很难找到。在我们的示例用例中,隐式构造函数比危险更有用,因为该类除了打印之外没有其他用途。

将输出重定向到特定代码段的文件

std::cout提供了一个非常好的方法,可以在任何时候打印我们想要的内容,因为它简单易用,易于扩展,并且全局可访问。即使我们想要打印特殊消息,比如错误消息,我们想要将其与普通消息隔离开来,我们可以使用std::cerr,它与cout相同,但是将内容打印到标准错误通道而不是标准输出通道。

有时我们可能对日志记录有更复杂的需求。例如,我们想要将函数的输出重定向到文件,或者我们想要静音函数的输出,而不改变函数本身。也许它是一个我们无法访问源代码的库函数。也许它从未被设计为写入文件,但我们希望将其输出到文件中。

确实可以重定向流对象的输出。在本教程中,我们将看到如何以非常简单和优雅的方式做到这一点。

如何做到...

我们将实现一个辅助类,解决重定向流和再次恢复重定向的问题,使用构造函数/析构函数的魔法。然后我们看看如何使用它:

  1. 这次我们只需要输入、输出和文件流的头文件。并将std命名空间声明为查找的默认命名空间:
      #include <iostream>
      #include <fstream>     

      using namespace std;
  1. 我们实现了一个类,它包含一个文件流对象和一个指向流缓冲区的指针。作为流对象的cout有一个内部流缓冲区,我们可以简单地交换。在交换的同时,我们可以保存之前的内容,以便稍后可以撤消任何更改。我们可以在 C++参考中查找其类型,但我们也可以使用decltype来找出cout.rdbuf()返回的类型。这通常不是所有情况下的良好做法,但在这种情况下,它只是一个指针类型:
      class redirect_cout_region
      {
          using buftype = decltype(cout.rdbuf());

          ofstream ofs;
          buftype  buf_backup;
  1. 我们的类的构造函数接受一个文件名字符串作为其唯一参数。文件名用于初始化文件流成员ofs。初始化后,我们可以将其输入cout作为新的流缓冲区。接受新缓冲区的相同函数也返回旧缓冲区的指针,因此我们可以保存它以便稍后恢复它:
      public:
          explicit 
          redirect_cout_region (const string &filename)
              : ofs{filename}, 
                buf_backup{cout.rdbuf(ofs.rdbuf())}
          {}
  1. 默认构造函数与其他构造函数的作用相同。不同之处在于它不会打开任何文件。将默认构造的文件流缓冲区输入到cout流缓冲区会导致cout停用。它只会丢弃我们给它的输入进行打印。在某些情况下,这也是有用的:
          redirect_cout_region()
              : ofs{}, 
                buf_backup{cout.rdbuf(ofs.rdbuf())}
          {}
  1. 析构函数只是恢复了我们的更改。当这个类的对象超出范围时,cout的流缓冲区再次变为旧的:
          ~redirect_cout_region() { 
              cout.rdbuf(buf_backup); 
          }
      };
  1. 让我们模拟一个输出密集的函数,这样我们以后可以玩耍:
      void my_output_heavy_function()
      {
          cout << "some outputn";
          cout << "this function does really heavy workn";
          cout << "... and lots of it...n";
          // ...
      }
  1. 在主函数中,我们首先产生一些完全正常的输出:
      int main()
      {
          cout << "Readable from normal stdoutn";
  1. 现在我们正在打开另一个作用域,这个作用域中的第一件事就是用文本文件参数实例化我们的新类。文件流默认以读写模式打开文件,因此它为我们创建了这个文件。任何后续的输出现在都将重定向到这个文件,尽管我们使用cout进行打印:
          {
              redirect_cout_region _ {"output.txt"};
              cout << "Only visible in output.txtn";
              my_output_heavy_function();
          }
  1. 离开作用域后,文件被关闭,输出重新重定向到正常的标准输出。现在让我们在另一个作用域中实例化相同的类,但是通过它的默认构造函数。这样,下面打印的文本行将不会在任何地方可见。它只会被丢弃:
          {
              redirect_cout_region _;
              cout << "This output will "
                      "completely vanishn";
          }
  1. 离开那个作用域后,我们的标准输出被恢复,最后一行文本输出将再次在 shell 中可读:
          cout << "Readable from normal stdout againn";
      }
  1. 编译和运行程序产生了我们预期的输出。在 shell 中只有第一行和最后一行输出可见:
      $ ./log_regions 
      Readable from normal stdout
      Readable from normal stdout again
  1. 我们可以看到,创建了一个名为output.txt的新文件,并包含了第一个作用域的输出。第二个作用域的输出完全消失了。
      $ cat output.txt 
      Only visible in output.txt
      some output
      this function does really heavy work
      ... and lots of it...

工作原理...

每个流对象都有一个内部缓冲区,它充当前端。这些缓冲区是可交换的。如果我们有一个流对象s,想要将其缓冲区保存到变量a中,并安装一个新的缓冲区b,则如下所示:a = s.rdbuf(b)。恢复它可以简单地使用s.rdbuf(a)来完成。

这正是我们在这个示例中所做的。另一个很酷的事情是我们可以堆叠这些redirect_cout_region助手:

{
    cout << "print to standard outputn";

    redirect_cout_region la {"a.txt"};
    cout << "print to a.txtn";

    redirect_cout_region lb {"b.txt"};
    cout << "print to b.txtn";
}
cout << "print to standard output againn";

这是因为对象的销毁顺序与它们的构造顺序相反。使用对象的构造和销毁之间的紧密耦合的模式的概念被称为资源获取即初始化RAII)。

有一件非常重要的事情应该提到--redirect_cout_region类的成员变量的初始化顺序

class redirect_cout_region {
    using buftype = decltype(cout.rdbuf());

    ofstream ofs;
    buftype  buf_backup;

public:
    explicit 
    redirect_cout_region(const string &filename)
        : ofs{filename}, 
          buf_backup{cout.rdbuf(ofs.rdbuf())}
    {}

...

正如我们所看到的,成员buf_backup是从取决于ofs的表达式构造的。这显然意味着ofs需要在buf_backup之前初始化。有趣的是,这些成员初始化的顺序并不取决于初始化列表项的顺序。初始化顺序只取决于成员声明的顺序!

如果一个类成员变量需要在另一个成员变量之后初始化,它们在类成员声明中也必须按照这个顺序出现。它们在构造函数的初始化列表中出现的顺序并不重要。

通过继承自 std::char_traits 创建自定义字符串类

std::string非常有用。然而,一旦人们需要一个具有略有不同语义的字符串处理的字符串类,一些人就倾向于编写自己的字符串类。

编写自己的字符串类很少是一个好主意,因为安全的字符串处理很困难。幸运的是,std::string只是模板类std::basic_string的专门类型定义。这个类包含了所有复杂的内存处理内容,但它不会对字符串的复制、比较等施加任何策略。这是通过接受一个包含特性类的模板参数导入到basic_string中的。

在本教程中,我们将看到如何构建我们自己的特性类,以此方式创建自定义字符串而无需重新实现任何内容。

如何做...

我们将实现两种不同的自定义字符串类:lc_stringci_string。第一个类从任何字符串输入构造小写字符串。另一个类不转换任何字符串,但可以进行不区分大小写的字符串比较:

  1. 让我们首先包含一些必要的头文件,然后声明我们默认使用std命名空间:
      #include <iostream>
      #include <algorithm>
      #include <string>      

      using namespace std;
  1. 然后我们重新实现了std::tolower函数,它已经在<cctype>中定义。已经存在的函数很好,但它不是constexpr。自 C++17 以来,一些string函数是constexpr,我们希望能够利用我们自己的自定义字符串特性类。该函数将大写字符映射到小写字符,并保持其他字符不变:
      static constexpr char tolow(char c) {
          switch (c) {
          case 'A'...'Z': return c - 'A' + 'a';
          default:        return c;
          }
      }
  1. std::basic_string类接受三个模板参数:基础字符类型、字符特性类和分配器类型。在本节中,我们只更改字符特性类,因为它定义了字符串的行为。为了仅重新实现与普通字符串不同的部分,我们公开继承标准特性类:
      class lc_traits : public char_traits<char> {
      public:
  1. 我们的类接受输入字符串但将它们转换为小写。有一个函数,它逐个字符地执行此操作,因此我们可以在这里放置我们自己的tolow函数。这个函数是constexpr的,这就是为什么我们重新实现了一个constexprtolow函数:
          static constexpr 
          void assign(char_type& r, const char_type& a ) {
              r = tolow(a);
          }
  1. 另一个函数处理整个字符串复制到自己的内存中。我们使用std::transform调用将所有字符从源字符串复制到内部目标字符串,并同时将每个字符映射到其小写版本:
          static char_type* copy(char_type* dest, 
                                 const char_type* src, 
                                 size_t count) {
              transform(src, src + count, dest, tolow);
              return dest;
          }
      };
  1. 另一个特性有助于构建一个有效地将字符串转换为小写的字符串类。我们将编写另一个特性,它保持实际的字符串有效负载不变,但在比较字符串时不区分大小写。我们再次从现有的标准字符特性类继承,并且这次,我们重新定义了一些其他成员函数:
      class ci_traits : public char_traits<char> {
      public:
  1. eq函数告诉我们两个字符是否相等。我们也这样做,但是我们比较它们的小写版本。这样'A'等于'a'
          static constexpr bool eq(char_type a, char_type b) {
              return tolow(a) == tolow(b);
          }
  1. lt函数告诉我们a的值是否小于b的值。我们在将两个字符再次转换为小写后,应用正确的逻辑运算符:
          static constexpr bool lt(char_type a, char_type b) {
              return tolow(a) < tolow(b);
          }
  1. 最后两个函数处理逐个字符的输入,接下来的两个函数处理逐个字符串的输入。compare函数类似于老式的strncmp函数。如果两个字符串在count定义的长度内相等,则返回0。如果它们不同,则返回一个负数或正数,告诉哪个输入字符串在词典顺序上更小。当然,必须在它们的小写版本上计算每个位置的字符之间的差异。好处是自 C++14 以来,这整个循环代码一直是constexpr函数的一部分:
          static constexpr int compare(const char_type* s1,
                                       const char_type* s2,
                                       size_t count) {
              for (; count; ++s1, ++s2, --count) {
                  const char_type diff (tolow(*s1) - tolow(*s2));
                  if      (diff < 0) { return -1; }
                  else if (diff > 0) { return +1; }
              }
              return 0;
          }
  1. 我们需要为我们的不区分大小写的字符串类实现的最后一个函数是find。对于给定的输入字符串p和长度count,它找到字符ch的位置。然后,它返回指向该字符的第一个出现的指针,如果没有,则返回nullptr。该函数中的比较必须使用tolow“眼镜”来进行,以使搜索不区分大小写。不幸的是,我们不能使用std::find_if,因为它不是constexpr,必须自己编写一个循环:
          static constexpr 
          const char_type* find(const char_type* p,
                                size_t count,
                                const char_type& ch) {
              const char_type find_c {tolow(ch)};

              for (; count != 0; --count, ++p) {
                  if (find_c == tolow(*p)) { return p; }
              }

              return nullptr;
          }
      };
  1. 好的,特性就是这些。既然我们现在已经有了它们,我们可以定义两种新的字符串类类型。lc_string表示小写字符串ci_string表示不区分大小写的字符串。这两个类与std::string唯一的区别在于它们的字符特性类:
      using lc_string = basic_string<char, lc_traits>;
      using ci_string = basic_string<char, ci_traits>;
  1. 为了使输出流接受这些新的类进行打印,我们需要快速重载流operator<<
      ostream& operator<<(ostream& os, const lc_string& str) {
          return os.write(str.data(), str.size());
      }

      ostream& operator<<(ostream& os, const ci_string& str) {
          return os.write(str.data(), str.size());
      }
  1. 现在我们终于可以开始实现实际的程序了。让我们实例化一个普通字符串、一个小写字符串和一个不区分大小写的字符串,并立即打印它们。它们在终端上应该都看起来正常,但小写字符串应该都是小写的:
      int main()
      {
          cout << "   string: " 
               << string{"Foo Bar Baz"} << 'n'
               << "lc_string: " 
               << lc_string{"Foo Bar Baz"} << 'n'
               << "ci_string: "
               << ci_string{"Foo Bar Baz"} << 'n';
  1. 为了测试不区分大小写的字符串,我们可以实例化两个基本相等但在某些字符的大小写上有所不同的字符串。当进行真正的不区分大小写比较时,它们应该看起来是相等的:
          ci_string user_input {"MaGiC PaSsWoRd!"};
          ci_string password   {"magic password!"};
  1. 因此,让我们比较它们,并打印出它们是否匹配:
          if (user_input == password) {
              cout << "Passwords match: "" << user_input
                   << "" == "" << password << ""n";
          }
      }
  1. 编译和运行程序会产生我们预期的结果。当我们首先以不同类型三次打印相同的字符串时,我们得到了不变的结果,但lc_string实例全部是小写的。只有在字符大小写不同的两个字符串的比较确实成功,并产生了正确的输出:
      $ ./custom_string 
         string: Foo Bar Baz
      lc_string: foo bar baz
      ci_string: Foo Bar Baz
      Passwords match: "MaGiC PaSsWoRd!" == "magic password!"

它是如何工作的...

我们所做的所有子类化和函数重新实现对于初学者来说肯定看起来有点疯狂。我们从哪里得到所有这些函数签名,我们神奇地知道我们需要重新实现?

让我们首先看看std::string真正来自哪里:

template <
    class CharT, 
    class Traits    = std::char_traits<CharT>, 
    class Allocator = std::allocator<CharT>
    > 
class basic_string;

std::string实际上是一个std::basic_string<char>,它扩展为std::basic_string<char, std::char_traits<char>, std::allocator<char>>。好吧,这是一个很长的类型描述,但是它是什么意思呢?所有这一切的重点是,可以基于单字节char项以及其他更大的类型来构建字符串。这使得可以处理更多的字符集,而不仅仅是典型的美国 ASCII 字符集。这不是我们现在要研究的东西。

然而,char_traits<char>类包含了basic_string在其操作中需要的算法。它知道如何比较、查找和复制字符和字符串。

allocator<char>类也是一个特性类,但它的特殊工作是处理字符串的分配和释放。这对我们来说现在并不重要,因为默认行为满足我们的需求。

如果我们希望字符串类的行为有所不同,我们可以尝试尽可能多地重用basic_stringchar_traits已经提供的内容。这就是我们所做的。我们实现了两个char_traits子类,分别称为case_insentitivelower_caser,并通过将它们用作标准char_traits类型的替代品,配置了两种全新的字符串类型。

为了探索其他可能性,以适应basic_string到您自己的需求,查阅 C++ STL 文档中的std::char_traits,看看它还有哪些其他函数可以重新实现。

使用正则表达式库对输入进行标记化

在复杂的字符串解析或转换以及将其分成块时,正则表达式是一个很好的帮助。在许多编程语言中,它们已经内置,因为它们非常有用和方便。

如果您还不了解正则表达式,请查看关于它们的维基百科文章,例如。当解析任何类型的文本时,它们肯定会扩展您的视野,因为很容易看到它们的有用性。例如,正则表达式可以测试电子邮件地址字符串或 IP 地址字符串是否有效,找到并提取符合复杂模式的大字符串中的子字符串等等。

在这个示例中,我们将从 HTML 文件中提取所有链接并列出给用户。代码将非常简短,因为自 C11 以来,我们在 C STL 中内置了正则表达式支持。

如何做...

我们将定义一个检测链接的正则表达式,并将其应用于 HTML 文件,以便漂亮地打印出该文件中出现的所有链接:

  1. 让我们首先包括所有必要的头文件,并声明我们默认使用std命名空间:
      #include <iostream>
      #include <iterator>
      #include <regex>
      #include <algorithm>
      #include <iomanip>      

      using namespace std;
  1. 稍后我们将生成一个可迭代范围,其中包含字符串。这些字符串总是成对出现,一个是链接,一个是链接描述。因此,让我们编写一个小帮助函数,漂亮地打印这些:
      template <typename InputIt>
      void print(InputIt it, InputIt end_it)
      {
          while (it != end_it) {
  1. 在每个循环步骤中,我们将迭代器递增两次,并复制链接和链接描述。在两个迭代器解引用之间,我们添加了另一个保护if分支,检查我们是否过早地到达了可迭代范围的末尾,只是为了安全起见:
              const string link {*it++};
              if (it == end_it) { break; }
              const string desc {*it++};
  1. 现在,让我们以一个漂亮的格式打印链接及其描述,就这样:
              cout << left << setw(28) << desc 
                   << " : " << link << 'n';
          }
      }
  1. 在主函数中,我们正在读取来自标准输入的所有内容。为了做到这一点,我们通过输入流迭代器从整个标准输入构造一个字符串。为了防止标记化,因为我们希望整个用户输入保持原样,我们使用noskipws。这个修饰符取消了空格跳过和标记化:
      int main()
      {
          cin >> noskipws;
          const std::string in {istream_iterator<char>{cin}, {}};
  1. 现在我们需要定义一个正则表达式,描述我们如何假设 HTML 链接的外观。正则表达式中的括号()定义了组。这些是我们想要访问的链接的部分--它链接到的 URL 及其描述:
          const regex link_re {
              "<a href="([^"]*)"[^<]*>([^<]*)</a>"};
  1. sregex_token_iterator类与istream_iterator具有相同的外观和感觉。我们将整个字符串作为可迭代输入范围,并使用刚刚定义的正则表达式。还有第三个参数{1, 2},它是一个整数值的初始化列表。它定义了我们要迭代表达式捕获的组 1 和 2:
          sregex_token_iterator it {
              begin(in), end(in), link_re, {1, 2}};
  1. 现在我们有一个迭代器,如果找到任何内容,它将发出链接和链接描述。我们将它与相同类型的默认构造的迭代器一起提供给我们之前实现的print函数:
          print(it, {});
      }
  1. 编译和运行程序后,我们得到以下输出。我在 ISO C++主页上运行了curl程序,它只是从互联网上下载了一个 HTML 页面。当然,也可以写cat some_html_file.html | ./link_extraction。我们使用的正则表达式基本上是固定的,假设了 HTML 文档中链接的外观。你可以尝试使它更通用:
      $ curl -s "https://isocpp.org/blog" | ./link_extraction 
      Sign In / Suggest an Article : https://isocpp.org/member/login
      Register                     : https://isocpp.org/member/register
      Get Started!                 : https://isocpp.org/get-started
      Tour                         : https://isocpp.org/tour
      C++ Super-FAQ                : https://isocpp.org/faq
      Blog                         : https://isocpp.org/blog
      Forums                       : https://isocpp.org/forums
      Standardization              : https://isocpp.org/std
      About                        : https://isocpp.org/about
      Current ISO C++ status       : https://isocpp.org/std/status
      (...and many more...)

它的工作原理...

正则表达式(或简称regex)非常有用。它们可能看起来很神秘,但值得学习它们的工作原理。如果我们手动进行匹配,一个简短的正则表达式就可以节省我们编写许多行代码。

在这个示例中,我们首先实例化了一个 regex 类型的对象。我们将其构造函数与描述正则表达式的字符串一起使用。一个非常简单的正则表达式是".",它匹配每个字符,因为点是正则表达式通配符。如果我们写"a",那么这只匹配'a'字符。如果我们写"ab*",那么这意味着"一个a,以及零个或任意多个b字符"。等等。正则表达式是另一个大的主题,在维基百科和其他网站或文献上有很好的解释。

让我们再看看我们假设是 HTML 链接的正则表达式。一个简单的 HTML 链接可能看起来像<a href="some_url.com/foo">A great link</a>。我们想要some_url.com/foo部分,以及A great link。因此,我们想出了以下正则表达式,其中包含用于匹配子字符串的

整个匹配本身始终是Group 0。在这种情况下,这是完整的<a href ..... </a>字符串。包含链接到的 URL 的引用href部分是Group 1。正则表达式中的( )括号定义了这样一个。另一个是在<a ...></a>之间的部分,其中包含链接描述。

有各种 STL 函数接受正则表达式对象,但我们直接使用了正则表达式令牌迭代器适配器,这是一个高级抽象,它在底层使用std::regex_search来自动化重复匹配工作。我们像这样实例化它:

sregex_token_iterator it {begin(in), end(in), link_re, {1, 2}};

开始和结束部分表示我们的输入字符串,正则表达式令牌迭代器将在其上迭代并匹配所有链接。当然,这是我们实现的复杂正则表达式,用于匹配链接。{1, 2}部分是下一个看起来复杂的东西。它指示令牌迭代器在每次完全匹配时停止,并首先产生第 1 组,然后在递增迭代器后产生第 2 组,再次递增后,最终在字符串中搜索下一个匹配项。这种智能行为确实为我们节省了一些代码行。

让我们看另一个例子,确保我们理解了这个概念。假设正则表达式是"a(b*)(c*)"。它将匹配包含a字符的字符串,然后是零个或任意多个b字符,然后是零个或任意多个c字符:

const string s {" abc abbccc "};
const regex re {"a(b*)(c*)"};

sregex_token_iterator it {begin(s), end(s), re, {1, 2}};

print( *it ); // prints b
++it;
print( *it ); // prints c
++it;
print( *it ); // prints bb
++it;
print( *it ); // prints ccc

还有std::regex_iterator类,它发出正则表达式匹配之间的子字符串。

舒适地根据上下文动态地以不同方式打印数字

在上一个示例中,我们学会了如何使用输出流格式化输出。在做同样的事情时,我们意识到了两个事实:

  • 大多数 I/O 操纵器是粘性的,因此我们必须在使用后恢复它们的效果,以免干扰其他不相关的代码,也会打印

  • 如果我们不得不设置长链的 I/O 操纵器才能以特定格式打印出少量变量,这将非常乏味,看起来也不太可读。

很多人不喜欢 I/O 流,甚至在 C++中,他们仍然使用printf来格式化他们的字符串。

在这个示例中,我们将看到如何在代码中减少 I/O 操纵器的噪音,动态地格式化类型。

如何做...

我们将实现一个名为format_guard的类,它可以自动恢复任何格式设置。此外,我们添加了一个包装类型,它可以包含任何值,但在打印时,它以特殊格式显示,而不会给我们带来 I/O 操纵器的噪音:

  1. 首先,我们包含一些头文件,并声明我们使用std命名空间:
      #include <iostream>
      #include <iomanip>      

      using namespace std;
  1. 帮助类format_guard会为我们整理流格式设置。它的构造函数保存了std::cout在那一刻设置的格式标志。它的析构函数将它们恢复到构造函数调用时的状态。这实际上撤销了在之间应用的任何格式设置:
      class format_guard {
          decltype(cout.flags()) f {cout.flags()};

      public:
          ~format_guard() { cout.flags(f); }
      };
  1. 另一个小帮助类是scientific_type。因为它是一个类模板,它可以将任何有效载荷类型包装为成员变量。它基本上什么也不做:
      template <typename T>
      struct scientific_type {
          T value;

          explicit scientific_type(T val) : value{val} {}
      };
  1. 我们可以为任何类型定义完全自定义的格式设置,这些类型在之前被包装成scientific_type,因为如果我们为其重载流operator>>,那么当打印这些类型时,流库会执行完全不同的代码。这样,我们可以以科学浮点表示法打印科学值,使用大写格式和显式的+前缀(如果它们具有正值)。我们还使用我们的format_guard类来在离开此函数时整理所有设置:
      template <typename T>
      ostream& operator<<(ostream &os, const scientific_type<T> &w) {
          format_guard _;
          os << scientific << uppercase << showpos;
          return os << w.value;
      }
  1. 在主函数中,我们首先尝试使用format_guard类。我们打开一个新的作用域,首先获得该类的一个实例,然后我们对std::cout应用一些疯狂的格式标志:
      int main()
      {
          {
              format_guard _;
              cout << hex << scientific << showbase << uppercase;

              cout << "Numbers with special formatting:n";
              cout << 0x123abc << 'n';
              cout << 0.123456789 << 'n';
          }
  1. 在启用了许多格式标志的情况下打印了一些数字后,我们再次离开了作用域。在此期间,format_guard的析构函数整理了格式。为了测试这一点,我们再次打印完全相同的数字。它们应该看起来不同:
          cout << "Same numbers, but normal formatting again:n";
          cout << 0x123abc << 'n';
          cout << 0.123456789 << 'n';
  1. 现在我们要使用scientific_type。让我们依次打印三个浮点数。我们将第二个数字包装在scientific_type中。这样,它将以我们特殊的科学样式打印,但它之前和之后的数字将采用默认格式。同时,我们避免了丑陋的格式化行噪音
          cout << "Mixed formatting: "
               << 123.0 << " "
               << scientific_type{123.0} << " "
               << 123.456 << 'n';
      }
  1. 编译和运行程序会产生以下结果。前两个数字以特定格式打印。接下来的两个数字以默认格式显示,这表明我们的format_guard工作得很好。最后一行的三个数字看起来也正如预期的那样。只有中间的数字具有scientific_type的格式,其余的都是默认格式:
      $ ./pretty_print_on_the_fly 
      Numbers with special formatting:
      0X123ABC
      1.234568E-01
      Same numbers, but normal formatting again:
      1194684
      0.123457
      Mixed formatting: 123 +1.230000E+02 123.456

捕获 std::iostream 错误的可读异常

在本章的任何食谱中,我们都没有使用异常来捕获错误。虽然这是可能的,但在没有异常的情况下使用流对象已经非常方便。如果我们尝试解析 10 个值,但在中间某个地方失败了,整个流对象就会将自己设置为失败状态并停止进一步解析。这样,我们就不会遇到从流中错误的偏移解析变量的危险。我们可以在条件语句中进行解析,比如if (cin >> foo >> bar >> ...)。如果失败了,我们就处理它。在try { ... } catch ...块中进行解析似乎并不是很有利。

事实上,在 C中引入异常之前,C I/O 流库已经存在。异常支持是后来添加的,这可能解释了为什么它们不是流库中的一流支持特性。

为了在流库中使用异常,我们必须单独配置每个流对象,以便在将自身设置为失败状态时抛出异常。不幸的是,异常对象中的错误解释并没有得到彻底的标准化。正如我们将在本节中看到的那样,这导致了不太有用的错误消息。如果我们真的想要在流对象中使用异常,我们可以另外轮询 C 库以获取文件系统错误状态以获得一些额外信息。

在本节中,我们将编写一个可以以不同方式失败的程序,使用异常处理这些失败,并看看如何在之后从中挤取更多信息。

如何做...

我们将实现一个程序,打开一个文件(可能失败),然后我们将从中读取一个整数(这也可能失败)。我们在激活异常的情况下进行这些操作,然后看看我们如何处理这些异常:

  1. 首先,我们包含一些头文件,并声明我们使用std命名空间:
      #include <iostream>
      #include <fstream>
      #include <system_error>
      #include <cstring>      

      using namespace std;
  1. 如果我们想要在流对象中使用异常,我们必须首先启用它们。为了使文件流对象在访问的文件不存在或存在解析错误时抛出异常,我们需要在异常掩码中设置一些失败位。如果我们之后做了一些失败的事情,它将触发异常。通过激活failbitbadbit,我们为文件系统错误和解析错误启用了异常:
      int main()
      {
          ifstream f;
          f.exceptions(f.failbit | f.badbit);
  1. 现在我们可以打开一个try块并访问一个文件。如果打开文件成功,我们尝试从中读取一个整数。只有在两个步骤都成功的情况下,我们才打印整数:
          try {
              f.open("non_existant.txt");

              int i;
              f >> i;

              cout << "integer has value: " << i << 'n';
          }
  1. 在两种预期的错误可能性中,都会抛出std::ios_base::failure的实例。这个对象有一个what()成员函数,应该解释触发异常的原因。不幸的是,这条消息的标准化被省略了,它并没有提供太多信息。然而,我们至少可以区分是否存在文件系统问题(例如文件不存在)或格式解析问题。全局变量errno甚至在 C++发明之前就存在了,并且被设置为一个错误值,我们现在可以检查。strerror函数将错误号转换为可读的字符串。如果errno0,那么至少没有文件系统错误:
          catch (ios_base::failure& e) {
              cerr << "Caught error: ";
              if (errno) {
                  cerr << strerror(errno) << 'n';
              } else {
                  cerr << e.what() << 'n';
              }
          }
      }
  1. 编译程序并在两种不同的情况下运行它会产生以下输出。如果要打开的文件存在,但无法从中解析出整数,则会得到一个iostream_category错误消息:
      $ ./readable_error_msg 
      Caught error: ios_base::clear: unspecified iostream_category error
  1. 如果文件不存在,我们将收到一个不同于strerror(errno)的消息通知我们:
      $ ./readable_error_msg
      Caught error: No such file or directory

它是如何工作的...

我们已经看到,我们可以通过s.exceptions(s.failbit | s.badbit)为流对象s启用异常。这意味着,例如,如果我们想在打开文件时无法打开文件时得到异常,就无法使用std::ifstream实例的构造函数:

ifstream f {"non_existant.txt"};
f.exceptions(...); // too late for an exception

这是一个遗憾,因为异常实际上承诺它们使错误处理变得不那么笨拙,与老式的 C 风格代码相比,后者充斥着大量的if分支,处理每一步之后的错误。

如果我们尝试引发流失败的各种原因,我们会意识到没有抛出不同的异常。这样,我们只能找出何时发生错误,而不是什么具体的错误(当然,这对于一般的异常处理来说是正确的,但对于 STL 流库来说是正确的)。这就是为什么我们另外查看了errno的值。这个全局变量是一个古老的构造,在旧日当没有 C++或一般的异常时就已经被使用。

如果任何与系统相关的函数遇到错误条件,它可以将errno变量设置为非0的值(0表示没有错误),然后调用者可以读取该错误号并查找其值的含义。唯一的问题是,当我们有一个多线程应用程序,并且所有线程都使用可以设置此错误变量的函数时,它是谁的错误值?如果我们即使没有错误也读取它,它可能携带一个错误值,因为在不同线程中运行的其他系统函数可能已经遇到了错误。幸运的是,自 C++11 以来,这个缺陷已经消失,进程中的每个线程都可以看到自己的errno变量。

不详细阐述古老的错误指示方法的利弊,当异常在基于系统的事物上触发时,它可以给我们提供有用的额外信息。异常告诉我们何时发生了,而errno可以告诉我们发生了什么,如果它是在系统级别发生的。

第二十八章:实用类

在本章中,我们将涵盖以下配方:

  • 使用std::ratio在不同时间单位之间转换

  • 使用std::chrono在绝对时间和相对时间之间进行转换

  • 使用std::optional安全地标记失败

  • 在元组上应用函数

  • 使用std::tuple快速组合数据结构

  • 使用std::any替换void*以获得更多类型安全性

  • 使用std::variant存储不同类型

  • 使用std::unique_ptr自动处理资源

  • 使用std::shared_ptr自动处理共享堆内存

  • 处理指向共享对象的弱指针

  • 简化智能指针处理遗留 API 的资源处理

  • 共享同一对象的不同成员值

  • 生成随机数并选择正确的随机数引擎

  • 生成随机数并让 STL 形成特定分布

介绍

本章专门介绍了对解决特定任务非常有用的实用类。其中一些确实非常有用,以至于我们很可能在将来的任何 C++程序片段中经常看到它们,或者至少已经在本书的所有其他章节中看到它们。

前两个配方是关于测量和获取时间的。我们还将看到如何在不同时间单位之间转换以及如何在时间点之间跳转。

然后,我们将研究optionalvariantany类型(这些类型都是 C14 和 C17 中引入的),以及另外五个配方中的一些tuple技巧。

自 C++11 以来,我们还获得了复杂的智能指针类型,即unique_ptrshared_ptrweak_ptr,它们在内存管理方面提供了极大的帮助,这就是为什么我们将专门介绍它们的五个配方。

最后,我们将全面了解 STL 库中与生成随机数有关的部分。除了学习 STL 随机引擎的最重要特性外,我们还将学习如何对随机数应用形状,以获得符合我们实际需求的分布。

使用 std::ratio 在不同时间单位之间转换

自 C++11 以来,STL 包含了一些新类型和函数,用于获取、测量和显示时间。这部分库存在于std::chrono命名空间中,并具有一些复杂的细节。

在这个配方中,我们将集中在测量时间跨度以及如何在单位之间转换测量结果,比如秒、毫秒和微秒。STL 提供了设施,使我们能够定义自己的时间单位并在它们之间无缝转换。

如何做到...

在本节中,我们将编写一个小游戏,提示用户输入特定的单词。用户需要在键盘上输入这个单词所需的时间将被测量并以多种时间单位显示出来:

  1. 首先,我们需要包含所有必要的头文件。出于舒适的原因,我们声明默认使用std命名空间:
      #include <iostream>
      #include <chrono>
      #include <ratio>
      #include <cmath>
      #include <iomanip>
      #include <optional>      

      using namespace std;
  1. chrono::duration作为时间持续的类型通常指的是秒的倍数或分数。所有 STL 时间持续单位都是整数类型的持续特化。在这个配方中,我们将专门研究double。在这个配方之后,我们将更多地集中在 STL 中已经内置的时间单位定义上:
      using seconds = chrono::duration<double>;
  1. 一毫秒是秒的一部分,因此我们通过参考秒来定义这个单位。ratio_multiply模板参数将 STL 预定义的milli因子应用于seconds::period,从而给我们提供了所需的分数。ratio_multiply模板基本上是一个用于乘法比例的元编程函数:
      using milliseconds = chrono::duration<
          double, ratio_multiply<seconds::period, milli>>;
  1. 微秒也是一样的。虽然毫秒是秒的“毫”分之一,但微秒是秒的“微”分之一:
      using microseconds = chrono::duration<
          double, ratio_multiply<seconds::period, micro>>;
  1. 现在我们将实现一个函数,该函数从用户输入中读取一个字符串,并测量用户输入该字符串所需的时间。它不带参数,并返回用户输入字符串以及经过的时间,捆绑在一对中:
      static pair<string, seconds> get_input()
      {
          string s;
  1. 我们需要在用户输入发生的期间开始和结束之后获取时间。获取时间快照看起来像这样:
          const auto tic (chrono::steady_clock::now());
  1. 现在进行实际的用户输入捕获。如果我们不成功,我们只返回一个默认初始化的元组。调用者将看到他得到了一个空的输入字符串:
          if (!(cin >> s)) {
              return {{}, {}};
          }
  1. 在成功的情况下,我们继续获取另一个时间快照。然后我们返回输入字符串和两个时间点之间的差异。请注意,这两个时间点都是绝对时间点,但通过计算差异,我们得到一个持续时间:
          const auto toc (chrono::steady_clock::now());

          return {s, toc - tic};
      }
  1. 现在让我们实现实际的程序。我们循环直到用户正确输入输入字符串。在每个循环步骤中,我们要求用户输入字符串"C++17",然后调用我们的get_input函数:
      int main()
      {
          while (true) {
              cout << "Please type the word "C++17" as"
                      " fast as you can.n> ";

              const auto [user_input, diff] = get_input();
  1. 然后我们检查输入。如果输入为空,我们将其解释为请求退出整个程序:
              if (user_input == "") { break; }
  1. 如果用户正确输入了"C++17",我们表示祝贺,然后打印用户正确输入该单词所需的时间。diff.count()方法返回浮点数秒数。如果我们使用原始的 STLseconds持续时间类型,那么我们将得到一个四舍五入的整数值,而不是一个分数。通过在调用count()之前使用我们的diff变量来喂入毫秒或微秒constructor,我们可以得到相同的值转换为不同的单位:
              if (user_input == "C++17") {
                  cout << "Bravo. You did it in:n" 
                       << fixed << setprecision(2)
                       << setw(12) << diff.count() 
                       << " seconds.n"
                       << setw(12) << milliseconds(diff).count()
                       << " milliseconds.n"
                       << setw(12) << microseconds(diff).count()
                       << " microseconds.n";
                  break;
  1. 如果用户在输入中出现拼写错误,我们让他再试一次:
              } else {
                  cout << "Sorry, your input does not match."
                          " You may try again.n";
              }
          }
      }
  1. 编译和运行程序会产生以下输出。首先,有拼写错误,程序会反复要求正确输入单词。在正确输入单词后,它会显示我们输入该单词所用的三种不同时间单位的时间:
      $ ./ratio_conversion 
      Please type the word "C++17" as fast as you can.
      > c+17
      Sorry, your input does not match. You may try again.
      Please type the word "C++17" as fast as you can.
      > C++17
      Bravo. You did it in:
              1.48 seconds.
           1480.10 milliseconds.
        1480099.00 microseconds.

它是如何工作的...

虽然本节主要是关于不同时间单位之间的转换,但我们首先必须选择三个可用时钟对象中的一个。通常在std::chrono命名空间中可以选择system_clocksteady_clockhigh_resolution_clock之间。它们之间有什么区别?让我们仔细看一下:

时钟 特征
system_clock代表系统范围内的实时“墙”时钟。如果我们想要获取本地时间,这是正确的选择。
steady_clock这个时钟被承诺是单调的。这意味着它永远不会被任何时间量倒退。当其他时钟的时间被最小量校正时,或者当时间在冬夏时间之间切换时,其他时钟可能会发生这种情况。
high_resolution_clock这是 STL 实现可以提供的最精细粒度时钟滴答周期的时钟。

由于我们测量了从一个绝对时间点到另一个绝对时间点的时间距离或持续时间(我们在变量tictoc中捕获了这些时间点),我们不关心这些时间点是否在全球范围内偏移。即使时钟晚了 112 年、5 小时、10 分钟和 1 秒(或其他任何时间),这对它们之间的差异没有影响。唯一重要的是,在我们保存时间点tic之后并在保存时间点toc之前,时钟不能进行微调(这在许多系统中不时发生),因为这会扭曲我们的测量。对于这些要求,steady_clock是最佳选择。它的实现可以基于处理器的时间戳计数器,该计数器自系统启动以来一直单调递增。

好了,现在通过正确的时间对象选择,我们能够通过chrono::steady_clock::now()保存时间点。now函数会返回一个chrono::time_point<chrono::steady_clock>类型的值。两个这样的值之间的差异(如toc - tic)是一个时间跨度,或者是chrono::duration类型的持续时间。由于这是本节的核心类型,现在变得有点复杂。让我们更仔细地看看duration的模板类型接口:

template<
    class Rep, 
    class Period = std::ratio<1> 
> class duration;

我们可以更改的参数称为RepPeriodRep很容易解释:这只是用于保存时间值的数值变量类型。对于现有的 STL 时间单位,这通常是long long int。在这个示例中,我们选择了double。由于我们的选择,我们可以默认保存秒为单位的时间值,然后将其转换为毫秒或微秒。如果我们将1.2345秒的时间持续保存在chrono::seconds类型中,那么它将四舍五入为一秒。这样,我们将必须将tiktoc之间的时间差保存在chrono::microseconds中,然后可以转换为较不精细的单位。由于我们选择了double作为Rep,我们可以向上和向下转换,只会丢失一点点精度,这在这个例子中并不会有影响。

我们对所有时间单位使用了Rep = double,因此它们只在我们选择的Period参数上有所不同:

using seconds      = chrono::duration<double>;
using milliseconds = chrono::duration<double, 
 ratio_multiply<seconds::period, milli>>;
using microseconds = chrono::duration<double, 
 ratio_multiply<seconds::period, micro>>;

虽然seconds是最简单的单位,因为它使用Period = ratio<1>,但其他单位必须进行调整。由于一毫秒是一秒的千分之一,我们将seconds::period(这只是一个获取函数,用于Period参数)与milli相乘,millistd::ratio<1, 1000>的类型别名(std::ratio<a, b>表示分数值a/b)。ratio_multiply类型基本上是一个编译时函数,它表示从一个比率类型乘以另一个比率类型得到的类型。

也许这听起来太复杂了,所以让我们看一个例子:ratio_multiply<ratio<2, 3>, ratio<4, 5>>的结果是ratio<8, 15>,因为(2/3) * (4/5) = 8/15

我们的结果类型定义等同于以下定义:

using seconds      = chrono::duration<double, ratio<1, 1>>;
using milliseconds = chrono::duration<double, ratio<1, 1000>>;
using microseconds = chrono::duration<double, ratio<1, 1000000>>;

有了这些类型的对齐,它们之间的转换就变得很容易。如果我们有一个类型为seconds的时间持续时间d,我们可以通过将其传递到另一种类型的构造函数中,即milliseconds(d),将其转换为milliseconds

还有更多...

在其他教程或书籍中,当时间持续时间被转换时,你可能会遇到duration_cast。例如,如果我们有一个类型为chrono::milliseconds的持续时间值,并且想要将其转换为chrono::hours,我们确实需要写duration_cast<chrono::hours>(milliseconds_value),因为这些单位依赖于整数类型。从细粒度单位转换为较不精细的单位会导致精度损失,这就是为什么我们需要一个duration_cast。对于基于doublefloat的持续时间单位,这是不需要的。

使用 std::chrono 在绝对时间和相对时间之间进行转换

直到 C11,获取墙上的时钟时间并仅仅打印它是相当麻烦的,因为 C没有自己的时间库。总是需要调用 C 库的函数,这看起来非常古老,考虑到这些调用可以很好地封装到它们自己的类中。

自 C++11 以来,STL 提供了chrono库,使得与时间相关的任务更容易实现。

在这个示例中,我们将获取本地时间,打印它,并通过添加不同的时间偏移量来玩耍,这是使用std::chrono非常方便的事情。

如何做...

我们将保存当前时间并打印它。此外,我们的程序将向保存的时间点添加不同的偏移量,并打印出结果时间点:

  1. 典型的包含行首先出现;然后,我们声明默认使用std命名空间:
      #include <iostream>
      #include <iomanip>
      #include <chrono>      

      using namespace std;
  1. 我们将打印绝对时间点。这些将以chrono::time_point类型模板的形式出现,所以我们只需为其重载输出流运算符。有不同的方法可以打印时间点的日期和/或时间部分。我们将只使用%c标准格式。当然,我们也可以只打印时间、只打印日期、只打印年份,或者任何我们想到的东西。在我们最终应用put_time之前,所有不同类型之间的转换看起来有点笨拙,但我们只需要做一次:
      ostream& operator<<(ostream &os, 
                    const chrono::time_point<chrono::system_clock> &t)
      {
          const auto tt   (chrono::system_clock::to_time_t(t));
          const auto loct (std::localtime(&tt));
          return os << put_time(loct, "%c");
      }
  1. STL 已经为secondsminuteshours等定义了类型。现在我们将添加days类型。这很容易;我们只需通过引用hours来专门化chrono::duration模板,并乘以24,因为一整天有 24 小时:
      using days = chrono::duration<
          chrono::hours::rep,
          ratio_multiply<chrono::hours::period, ratio<24>>>;
  1. 为了能够以最优雅的方式表示多天的持续时间,我们可以定义自己的days字面量运算符。现在,我们可以写3_days来构造一个代表三天的值:
      constexpr days operator ""_days(unsigned long long h)
      {
          return days{h};
      }
  1. 在实际程序中,我们将拍摄一个时间快照,然后简单地打印出来。这非常容易和舒适,因为我们已经为此实现了正确的运算符重载:
      int main()
      {
          auto now (chrono::system_clock::now());

          cout << "The current date and time is " << now << 'n';
  1. 将当前时间保存在now变量中后,我们可以向其中添加任意持续时间并打印出来。让我们在当前时间上加 12 小时,并打印出 12 小时后的时间:
          chrono::hours chrono_12h {12};

          cout << "In 12 hours, it will be "
               << (now + chrono_12h)<< 'n';
  1. 通过默认声明我们使用chrono_literals命名空间,我们解锁了所有现有的持续时间字面量,如小时、秒等。这样,我们可以优雅地打印 12 小时 15 分钟前的时间,或者 7 天前的时间:
          using namespace chrono_literals;

          cout << "12 hours and 15 minutes ago, it was "
               << (now - 12h - 15min) << 'n'
               << "1 week ago, it was "
               << (now - 7_days) << 'n';
      }
  1. 编译并运行程序后,会得到以下输出。因为我们在时间格式化的格式字符串中使用了%c,所以我们得到了一个相当完整的描述,以特定格式呈现。通过尝试不同的格式字符串,我们可以得到任何我们喜欢的格式。请注意,时间格式不是 12 小时制的 AM/PM,而是 24 小时制,因为该应用在欧洲系统上运行:
 $ ./relative_absolute_times 
      The current date and time is Fri May  5 13:20:38 2017
      In 12 hours, it will be Sat May  6 01:20:38 2017
      12 hours and 15 minutes ago, it was Fri May  5 01:05:38 2017
      1 week ago, it was Fri Apr 28 13:20:38 2017

工作原理...

我们从std::chrono::system_clock获得了当前时间点。这个 STL 时钟类是唯一一个可以将其时间点值转换为可以显示为人类可读时间描述字符串的时间结构的类。

为了打印这样的时间点,我们实现了输出流的operator<<

ostream& operator<<(ostream &os, 
                    const chrono::time_point<chrono::system_clock> &t)
{
    const auto tt   (chrono::system_clock::to_time_t(t));
    const auto loct (std::localtime(&tt));
    return os << put_time(loct, "%c");
}

这里首先发生的是,我们从chrono::time_point<chrono::system_clock>转换为std::time_t。这种类型的值可以转换为本地墙钟相关的时间值,我们使用std::localtime进行转换。这个函数返回一个指向转换值的指针(不用担心指针后面的内存维护;它是一个静态对象,不是在堆上分配的),现在我们可以最终打印出来了。

std::put_time函数接受这样一个对象和一个时间格式字符串。"%c"显示标准的日期时间字符串,如Sun Mar 12 11:33:40 2017。我们也可以写"%m/%d/%y";那么程序将以03/12/17的格式打印时间。现有的时间格式字符串修饰符列表非常长,但在在线 C++参考文档中有完整的文档。

除了打印外,我们还向时间点添加了时间偏移。这很容易,因为我们可以将时间持续时间表示为“12 小时 15 分钟”这样的表达式,如12h + 15minchrono_literals命名空间已经为小时(h)、分钟(min)、秒(s)、毫秒(ms)、微秒(us)和纳秒(ns)提供了方便的类型字面量。

将这样的持续时间值添加到时间点值会创建一个新的时间点值,因为这些类型具有正确的operator+operator-重载,这就是为什么在时间中添加和显示偏移如此简单的原因。

使用 std::optional 安全地标记失败

当程序与外部世界通信并依赖于从那里得到的值时,各种故障都可能发生。

这意味着每当我们编写一个应该返回一个值的函数,但也可能失败时,这必须在函数接口的某些改变中得到体现。我们有几种可能性。让我们看看如何设计一个将返回一个字符串但也可能失败的函数的接口:

  • 使用表示成功的返回值和输出参数:bool get_string(string&);

  • 返回一个指针(或智能指针),如果失败则可以设置为nullptrstring* get_string();

  • 在失败的情况下抛出异常,并保持函数签名非常简单:string get_string();

所有这些方法都有不同的优点和缺点。自 C++17 以来,有一种新类型可以用来以不同的方式解决这样的问题:std::optional。可选值的概念来自纯函数式编程语言(有时被称为Maybe类型),可以导致非常优雅的代码。

我们可以在我们自己的类型周围包装optional,以便表示错误的值。在这个示例中,我们将学习如何做到这一点。

如何做到...

在本节中,我们将实现一个程序,从用户那里读取整数并将它们求和。因为用户总是可以输入随机的东西而不是数字,我们将看到optional如何改进我们的错误处理:

  1. 首先,我们包括所有需要的头文件,并声明我们使用std命名空间:
      #include <iostream>
      #include <optional>     

      using namespace std;
  1. 让我们定义一个整数类型,可能包含一个值。std::optional类型正是这样做的。通过将任何类型包装成optional,我们为其赋予了一个额外的可能状态,这反映了它当前没有值:
      using oint = optional<int>;
  1. 通过定义了一个可选整数类型,我们可以表达通常返回整数的函数也可能失败。如果我们从用户输入中获取一个整数,这可能会失败,因为用户可能并不总是输入一个整数,即使我们要求他这样做。在这种情况下,返回一个可选整数是完美的。如果读取整数成功,我们将其传递给optional<int>构造函数。否则,我们返回一个默认构造的可选值,这表示失败或空:
      oint read_int()
      {
          int i;
          if (cin >> i) { return {i}; }
          return {};
      }
  1. 我们可以做的不仅仅是从可能失败的函数中返回整数。如果我们计算两个可选整数的和会怎样?只有当操作数都包含实际值时,这才可能导致真正的数值和。在任何其他情况下,我们返回一个空的可选变量。这个函数需要更多的解释:通过隐式转换optional<int>变量ab为布尔表达式(通过写!a!b),我们可以知道它们是否包含实际值。如果它们包含实际值,我们可以通过简单地用*a*b对它们进行解引用来访问它们,就像指针或迭代器一样:
      oint operator+(oint a, oint b)
      {
          if (!a || !b) { return {}; }

          return {*a + *b};
      }
  1. 将一个普通整数添加到一个可选整数遵循相同的逻辑:
      oint operator+(oint a, int b)
      {
          if (!a) { return {}; }

          return {*a + b};
      }
  1. 现在让我们编写一个程序,对可选整数进行操作。我们让用户输入两个数字:
      int main()
      {
          cout << "Please enter 2 integers.n> ";

          auto a {read_int()};
          auto b {read_int()};
  1. 然后我们添加这些输入数字,并额外添加值 10 到它们的和。由于ab是可选整数,sum也将是一个可选整数类型的变量:
          auto sum (a + b + 10);
  1. 如果a和/或b不包含值,那么sum也不可能包含值。现在我们的可选整数的好处是,我们不需要显式检查ab。当我们对空的可选值求和时会发生什么是完全合理和定义良好的行为,因为我们已经为这些类型安全地定义了operator+。这样,我们可以任意地添加许多可能为空的可选整数,我们只需要检查结果的可选值。如果它包含一个值,那么我们可以安全地访问并打印它:
          if (sum) {
             cout << *a << " + " << *b << " + 10 = "
                  << *sum << 'n';
  1. 如果用户输入非数字内容,我们会报错:
          } else {
             cout << "sorry, the input was "
                     "something else than 2 numbers.n";
          }
      }
  1. 就是这样。当我们编译并运行程序时,我们会得到以下输出:
      $ ./optional 
      Please enter 2 integers.
      > 1 2
      1 + 2 + 10 = 13
  1. 再次运行程序并输入非数字内容会产生我们为这种情况准备的错误消息:
      $ ./optional 
      Please enter 2 integers.
      > 2 z
      sorry, the input was something else than 2 numbers.

它是如何工作的...

使用optional通常非常简单和方便。如果我们想要将可能失败或可选性的概念附加到任何类型T,我们只需将其包装到std::optional<T>中,就可以了。

每当我们从某个地方得到这样一个值时,我们必须检查它是否处于空状态或者是否包含了一个真实的值。bool optional::has_value()函数为我们做到了这一点。如果它返回true,我们可以访问该值。访问可选值的值可以使用T& optional::value()来完成。

我们可以使用if (x) {...}*x来代替总是写if (x.has_value()) {...}x.value()std::optional类型以这样一种方式定义了对booloperator*的显式转换,以便处理可选类型类似于处理指针。

另一个方便的操作符助手是optionaloperator->重载。如果我们有一个struct Foo { int a; string b; }类型,并且想通过一个optional<Foo>变量x访问它的成员之一,那么我们可以写x->ax->b。当然,我们应该首先检查x是否真的有一个值。

如果我们尝试访问一个可选值,即使它没有值,那么它将抛出std::logic_error。这样,我们可以在不总是检查它们的情况下处理大量可选值。使用try-catch子句,我们可以编写以下形式的代码:

cout << "Please enter 3 numbers:n";

try {
    cout << "Sum: " 
         << (*read_int() + *read_int() + *read_int()) 
         << 'n';
} catch (const std::bad_optional_access &) {
    cout << "Unfortunately you did not enter 3 numbersn";
}

std::optional的另一个妙招是optional::value_or。如果我们想取一个可选的值,并在它处于空状态时返回一个默认值,那么这就有帮助了。x = optional_var.value_or(123)在一行简洁的代码中完成了这项工作,其中123是备用默认值。

应用函数到元组

自 C++11 以来,STL 提供了std::tuple。这种类型允许我们将多个值偶尔捆绑到单个变量中并在周围到达它们。元组的概念在许多编程语言中已经存在很长时间了,本书中的一些示例已经致力于这种类型,因为它非常适用。

然而,有时我们最终会得到一个捆绑在元组中的值,然后需要使用它们的各个成员调用函数。为每个函数参数单独解包成员非常乏味(如果我们在某个地方引入了拼写错误,那么容易出错)。繁琐的形式看起来像这样:func(get<0>(tup), get<1>(tup), get<2>(tup), ...);

在这个示例中,您将学习如何以一种优雅的方式将值打包到元组中并从元组中解包,以便调用一些不知道元组的函数。

如何做...

我们将实现一个程序,将值打包到元组中并从元组中解包。然后,我们将看到如何使用元组中的值调用不知道元组的函数:

  1. 首先,我们包括了许多头文件,并声明我们使用std命名空间:
      #include <iostream>
      #include <iomanip>
      #include <tuple>
      #include <functional>
      #include <string>
      #include <list>      

      using namespace std;
  1. 让我们首先定义一个函数,它接受描述学生的多个参数并打印它们。许多传统或 C 函数接口看起来很相似。
      static void print_student(size_t id, const string &name, double gpa)
      {
          cout << "Student " << quoted(name) 
               << ", ID: "   << id 
               << ", GPA: "  << gpa << 'n';
      }
  1. 在实际程序中,我们动态定义了一个元组类型,并用有意义的学生数据填充它:
      int main()
      {
          using student = tuple<size_t, string, double>;
          student john {123, "John Doe"s, 3.7};
  1. 为了打印这样的对象,我们可以将其分解为其各个成员,并使用这些单独的变量调用print_student
          {
              const auto &[id, name, gpa] = john;
              print_student(id, name, gpa);
          }
          cout << "-----n";
  1. 让我们创建一个以学生元组的初始化列表形式的整套学生:
          auto arguments_for_later = {
              make_tuple(234, "John Doe"s,  3.7),
              make_tuple(345, "Billy Foo"s, 4.0),
              make_tuple(456, "Cathy Bar"s, 3.5),
          };
  1. 我们仍然可以相对舒适地打印它们所有,但是为了分解元组,我们需要关心这样的元组有多少个元素。如果我们不得不编写这样的代码,那么我们也将不得不在函数调用接口发生变化的情况下对其进行重构:
          for (const auto &[id, name, gpa] : arguments_for_later) {
              print_student(id, name, gpa);
          }
          cout << "-----n";
  1. 我们可以做得更好。即使不知道print_student的参数类型或学生元组中的成员数量,我们也可以直接使用std::apply将元组的内容传递给函数。这个函数接受一个函数指针或函数对象和一个元组,然后解包元组以便使用元组成员作为参数调用函数:
          apply(print_student, john);
          cout << "-----n";
  1. 这在循环中也可以很好地工作:
          for (const auto &args : arguments_for_later) {
              apply(print_student, args);
          }
          cout << "-----n";
      }
  1. 编译和运行程序显示,两种方式都可以正常工作,正如我们所假设的那样:
      $ ./apply_functions_on_tuples 
      Student "John Doe", ID: 123, GPA: 3.7
      -----
      Student "John Doe", ID: 234, GPA: 3.7
      Student "Billy Foo", ID: 345, GPA: 4
      Student "Cathy Bar", ID: 456, GPA: 3.5
      -----
      Student "John Doe", ID: 123, GPA: 3.7
      -----
      Student "John Doe", ID: 234, GPA: 3.7
      Student "Billy Foo", ID: 345, GPA: 4
      Student "Cathy Bar", ID: 456, GPA: 3.5
      -----

工作原理...

std::apply是一个在编译时帮助我们更不受我们代码中处理的类型的影响的辅助程序。

假设我们有一个包含值(123, "abc"s, 456.0)的元组t。这个元组的类型是tuple<int, string, double>。另外,假设我们有一个签名为int f(int, string, double)的函数f(类型也可以是引用)。

然后,我们可以写x = apply(f, t),这将导致一个函数调用,x = f(123, "abc"s, 456.0)apply方法甚至会返回f的返回值。

使用 std::tuple 快速组合数据结构

让我们来看一个我们很可能已经知道的元组的基本用例。我们可以定义一个结构如下,以便只是捆绑一些变量:

struct Foo {
    int a;
    string b;
    float c;
};

我们可以定义一个元组,而不是像前面的例子中那样定义一个结构:

using Foo = tuple<int, string, float>;

我们可以使用类型列表中的类型的索引号来访问元组的项。为了访问元组的第一个成员,我们可以使用std::get<0>(t),要访问第二个成员,我们写std::get<1>,依此类推。如果索引号太大,编译器甚至会安全地报错。

在整本书中,我们已经使用了 C++17 的元组分解功能。它们允许我们通过只需编写auto [a, b, c] = some_tuple来快速分解元组,以便访问其各个项。

组合和分解单个数据结构并不是我们可以使用元组做的唯一事情。我们还可以连接或拆分元组,或者进行各种魔术。在这个示例中,我们将玩弄这些功能,以便学习如何做到这一点。

如何做...

在本节中,我们将编写一个可以即时打印任何元组的程序。除此之外,我们还将编写一个可以zip元组的函数:

  1. 首先,我们需要包含一些头文件,然后我们声明默认使用std命名空间:
      #include <iostream>
      #include <tuple>
      #include <list>
      #include <utility>
      #include <string>
      #include <iterator>
      #include <numeric>
      #include <algorithm>      

      using namespace std;
  1. 由于我们将处理元组,因此展示它们的内容将是有趣的。因此,我们现在将实现一个非常通用的函数,可以打印任何由可打印类型组成的元组。该函数接受一个输出流引用os,用于实际打印,以及一个可变参数列表,其中包含所有元组成员。我们将所有参数分解为第一个元素并将其放入参数v中,其余部分存储在参数包vs...中:
      template <typename T, typename ... Ts>
      void print_args(ostream &os, const T &v, const Ts &...vs)
      {
          os << v;
  1. 如果参数包vs中还有参数,这些参数将使用initializer_list扩展技巧交错打印", "。您在第二十一章中学习了这个技巧,Lambda 表达式
          (void)initializer_list<int>{((os << ", " << vs), 0)...};
      }
  1. 现在,我们可以通过编写print_args(cout, 1, 2, "foo", 3, "bar")来打印任意一组参数,例如。但这与元组无关。为了打印元组,我们通过实现一个模板函数重载流输出运算符<<来匹配任何元组特化的情况:
      template <typename ... Ts>
      ostream& operator<<(ostream &os, const tuple<Ts...> &t)
      {
  1. 现在变得有点复杂了。我们首先使用一个 lambda 表达式,任意接受许多参数。每当它被调用时,它将os参数放在这些参数之前,然后调用print_args,并使用结果新的参数列表。这意味着对capt_tup(...一些参数...)的调用会导致对print_args(os, ...一些参数...)的调用:
          auto print_to_os (&os {
              print_args(os, xs...);
          });
  1. 现在我们可以进行实际的元组解包魔术。我们使用std::apply来解包元组。所有的值都将从元组中取出,然后作为函数参数排列给我们提供的函数。这意味着如果我们有一个元组t = (1, 2, 3),并调用apply(capt_tup, t),那么这将导致一个函数调用capt_tup(1, 2, 3),这又将导致函数调用print_args(os, 1, 2, 3)。这正是我们需要的。作为一个很好的额外,我们用括号括起来打印:
          os << "(";
          apply(print_to_os, t);
          return os << ")";
      }
  1. 好的,现在我们写了一些复杂的代码,当我们想要打印一个元组时,这将使我们的生活变得更容易。但是我们可以用元组做更多的事情。例如,让我们编写一个函数,接受一个可迭代的范围,比如一个向量或一组数字的列表,作为参数。这个函数将遍历该范围,然后返回范围中所有数字的总和,并将其与所有值的最小值最大值平均数捆绑在一起。通过将这四个值打包成一个元组,我们可以将它们作为单个对象返回,而无需定义额外的结构类型:
      template <typename T>
      tuple<double, double, double, double>
      sum_min_max_avg(const T &range)
      {
  1. std::minmax_element函数返回一对迭代器,分别指向输入范围的最小值和最大值。std::accumulate方法对其输入范围中的所有值进行求和。这就是我们需要返回适合我们元组的四个值的全部内容!
          auto min_max (minmax_element(begin(range), end(range)));
          auto sum     (accumulate(begin(range), end(range), 0.0));
          return {sum, *min_max.first, *min_max.second, 
                  sum / range.size()};
      }
  1. 在实现主程序之前,我们将实现一个最后的魔术辅助函数。我称它为魔术,因为一开始看起来确实很复杂,但在理解它的工作原理之后,它将变得非常流畅和有用。它将两个元组进行压缩。这意味着如果我们给它一个元组(1, 2, 3),和另一个元组('a', 'b', 'c'),它将返回一个元组(1, 'a', 2, 'b', 3, 'c')
      template <typename T1, typename T2>
      static auto zip(const T1 &a, const T2 &b)
      {
  1. 现在我们来到了这个食谱中最复杂的代码行。我们创建了一个函数对象z,它接受任意数量的参数。然后它返回另一个函数对象,它捕获所有这些参数在一个参数包xs中,但也接受另一个任意数量的参数。让我们沉浸在其中片刻。在这个内部函数对象中,我们可以以参数包xsys的形式访问两个参数列表。现在让我们看看我们实际上如何处理这些参数包。表达式make_tuple(xs, ys)...将参数包逐项分组。这意味着如果我们有xs = 1, 2, 3ys = 'a', 'b', 'c',这将导致一个新的参数包(1, 'a'), (2, 'b'), (3, 'c')。这是一个逗号分隔的三个元组的列表。为了将它们全部分组在一个元组中,我们使用std::tuple_cat,它接受任意数量的元组并将它们重新打包成一个元组。这样我们就得到了一个漂亮的(1, 'a', 2, 'b', 3, 'c')元组:
          auto z ([](auto ...xs) {
              return xs... {
                  return tuple_cat(make_tuple(xs, ys) ...);
              };
          });
  1. 最后一步是从输入元组ab中解包所有值,并将它们推入z。表达式apply(z, a)a中的所有值放入参数包xs中,apply(..., b)b中的所有值放入参数包ys中。结果的元组是大的压缩元组,我们将其返回给调用者:
          return apply(apply(z, a), b);
      }
  1. 我们在辅助/库代码中投入了相当多的行。现在让我们最终将它们投入使用。首先,我们构造一些任意的元组。student包含学生的 ID、姓名和 GPA 分数。student_desc包含描述这些字段在人类可读形式中意味着什么的字符串。std::make_tuple是一个非常好的辅助函数,因为它自动推断所有参数的类型并创建一个合适的元组类型:
      int main()
      {
          auto student_desc (make_tuple("ID", "Name", "GPA"));
          auto student      (make_tuple(123456, "John Doe", 3.7));
  1. 让我们打印一下我们所拥有的。这很简单,因为我们刚刚为它实现了正确的operator<<重载:
          cout << student_desc << 'n'
               << student      << 'n';
  1. 我们还可以使用std::tuple_cat在飞行中对元组进行分组并像这样打印它们:
          cout << tuple_cat(student_desc, student) << 'n';
  1. 我们还可以使用我们的zip函数创建一个新的zipped元组,并打印它:
          auto zipped (zip(student_desc, student));
          cout << zipped << 'n';
  1. 不要忘记我们的sum_min_max_avg函数。我们创建了一个包含一些数字的初始化列表,并将其传递给这个函数。为了使它变得更加复杂,我们创建了另一个相同大小的元组,其中包含一些描述字符串。通过压缩这些元组,我们得到了一个漂亮的、交错的输出,当我们运行程序时会看到:
          auto numbers = {0.0, 1.0, 2.0, 3.0, 4.0};
          cout << zip(
                  make_tuple("Sum", "Minimum", "Maximum", "Average"),
                  sum_min_max_avg(numbers))
               << 'n';
      }
  1. 编译和运行程序产生以下输出。前两行只是单独的studentstudent_desc元组。第 3 行是我们通过使用tuple_cat得到的元组组合。第 4 行包含了压缩的学生元组。在最后一行,我们看到了我们上次创建的数字列表的总和、最小值、最大值和平均值。由于压缩,很容易看出每个值的含义:
      $ ./tuple
      (ID, Name, GPA)
      (123456, John Doe, 3.7)
      (ID, Name, GPA, 123456, John Doe, 3.7)
      (ID, 123456, Name, John Doe, GPA, 3.7)
      (Sum, 10, Minimum, 0, Maximum, 4, Average, 2)

它是如何工作的...

这一部分的一些代码确实很复杂。我们为元组编写了一个operator<<实现,看起来非常复杂,但支持所有由可打印类型组成的元组。然后我们实现了sum_min_max_avg函数,它只返回一个元组。我们头脑中非常复杂的另一件事是zip函数。

最容易的部分是sum_min_max_avg。关于它的要点是,当我们定义一个返回实例tuple<FooBarBaz> f()的函数时,我们可以在该函数中写return {foo_instance, bar_instance, baz_instance};来构造这样一个元组。如果您对我们在sum_min_max_avg函数中使用的 STL 算法有困难,那么您可能想看看本书的第二十二章 STL 算法基础,在那里我们已经仔细研究了它们。

其他代码太复杂了,我们将专门的辅助程序分配给它们自己的子部分:

元组的 operator<<

在我们甚至触及输出流的operator<<之前,我们实现了print_args函数。由于它的可变参数性质,它接受任意数量和类型的参数,只要第一个参数是ostream实例:

template <typename T, typename ... Ts>
void print_args(ostream &os, const T &v, const Ts &...vs)
{
    os << v;

    (void)initializer_list<int>{((os << ", " << vs), 0)...};
}

这个函数打印第一个项目v,然后打印参数包vs中的所有其他项目。我们单独打印第一个项目,因为我们希望所有项目都与", "交错,但我们不希望这个字符串领先或尾随整个列表(就像"1, 2, 3, "", 1, 2, 3")。我们在第二十一章 Lambda 表达式使用相同输入调用多个函数中学习了initializer_list扩展技巧。

有了这个函数,我们就可以打印元组所需的一切。我们的operator<<实现如下:

template <typename ... Ts>
ostream& operator<<(ostream &os, const tuple<Ts...> &t)
{
    auto capt_tup (&os {
        print_args(os, xs...);
    });

    os << "(";
    apply(capt_tup, t);
    return os << ")";
}

我们要做的第一件事是定义函数对象capt_tup。当我们调用capt_tup(foo, bar, whatever)时,这会导致调用print_args(**os,** foo, bar, whatever)。这个函数对象唯一要做的就是将输出流对象os放在它的可变参数列表之前。

之后,我们使用std::apply来解包元组t中的所有项目。如果这一步看起来太复杂,请看看这之前的一篇文章,专门介绍了std::apply的工作原理。

元组的 zip 函数

zip函数接受两个元组,但看起来非常复杂,尽管它有一个非常清晰的实现:

template <typename T1, typename T2>
auto zip(const T1 &a, const T2 &b)
{
    auto z ([](auto ...xs) {
        return xs... {
            return tuple_cat(make_tuple(xs, ys) ...);
        };
    });
    return apply(apply(z, a), b);
}

为了更好地理解这段代码,想象一下元组a携带值1, 2, 3,元组b携带值'a', 'b', 'c'

在这种情况下,调用apply(z, a)会导致调用z(1, 2, 3)的函数调用,它返回另一个捕获这些值1, 2, 3的函数对象,放入参数包xs中。然后,当这个函数对象被apply(z(1, 2, 3), b)调用时,它会将值'a', 'b', 'c'填入参数包ys中。这基本上与直接调用z(1, 2, 3)('a', 'b', 'c')是一样的。

好了,现在我们有了xs = (1, 2, 3)ys = ('a', 'b', 'c'),然后会发生什么?表达式tuple_cat(make_tuple(xs, ys) ...)进行了以下魔术;看一下图表:

首先,xsys中的项目通过成对交错地进行了配对。这种“成对交错”发生在make_tuple(xs, ys) ...表达式中。这最初只导致一个包含两个项目的元组的可变列表。为了获得一个大元组,我们对它们应用tuple_cat,然后最终得到一个包含初始元组的所有成员的大的串联元组。

用 std::any 替换 void*以获得更多的类型安全

有时我们希望在变量中存储任何类型的项目。对于这样的变量,我们需要能够检查它是否包含任何东西,如果包含,我们需要能够区分它包含什么。所有这些都需要以类型安全的方式发生。

在过去,我们基本上能够在void*指针中存储指向各种对象的指针。void类型的指针本身无法告诉我们它指向什么类型的对象,因此我们需要手工制作一种额外的机制来告诉我们应该期望什么。这样的代码很快就会导致看起来古怪和不安全的代码。

C++17 对 STL 的另一个补充是std::any类型。它旨在保存任何类型的变量,并提供了使其能够进行类型安全检查和访问的功能。

在这个示例中,我们将使用这种实用类型来感受一下它。

如何做...

我们将实现一个函数,试图能够打印一切。它使用std::any作为参数类型:

  1. 首先,我们包含一些必要的头文件,并声明我们使用std命名空间:
      #include <iostream>
      #include <iomanip>
      #include <list>
      #include <any>
      #include <iterator>     

      using namespace std;
  1. 为了减少以下程序中尖括号语法的数量,我们为list<int>定义了一个别名,稍后我们将使用它:
      using int_list = list<int>;
  1. 让我们实现一个声称能够打印任何东西的函数。承诺是以std::any变量的形式打印任何提供的参数:
      void print_anything(const std::any &a)
      {
  1. 我们需要检查的第一件事是参数是否包含任何东西,或者它只是一个空的any实例。如果是空的,那么试图弄清楚如何打印它就没有意义:
          if (!a.has_value()) {
              cout << "Nothing.n";
  1. 如果不为空,我们可以尝试将其与不同的类型进行比较,直到找到匹配项。首先要尝试的类型是string。如果是string,我们可以使用std::any_casta转换为string类型的引用,并直接打印它。我们将字符串放在引号中是为了美观的原因:
          } else if (a.type() == typeid(string)) {
              cout << "It's a string: "
                   << quoted(any_cast<const string&>(a)) << 'n';
  1. 如果不是string,可能是int。如果这种类型匹配,我们可以使用any_cast<int>来获取实际的int值:
          } else if (a.type() == typeid(int)) {
              cout << "It's an integer: "
                   << any_cast<int>(a) << 'n';
  1. std::any不仅适用于stringint等简单类型。我们还可以将整个映射或列表或任何组成的复杂数据结构放入any变量中。让我们看看输入是否是整数列表,如果是,我们可以像打印列表一样打印它:
          } else if (a.type() == typeid(int_list)) {
              const auto &l (any_cast<const int_list&>(a));

              cout << "It's a list: ";
              copy(begin(l), end(l), 
                   ostream_iterator<int>{cout, ", "});
              cout << 'n';
  1. 如果这些类型都不匹配,我们就无法猜测类型了。在这种情况下,让我们放弃,并告诉用户我们不知道如何打印这个:
          } else {
              cout << "Can't handle this item.n";
          }
      }
  1. 在主函数中,我们现在可以使用任意类型调用这个函数。我们可以使用空的any变量{}调用它,或者用字符串"abc"或整数来调用它。因为std::any可以从这些类型隐式构造,所以没有语法开销。我们甚至可以构造一个完整的列表并将其传递给这个函数:
      int main()
      {
          print_anything({});
          print_anything("abc"s);
          print_anything(123);
          print_anything(int_list{1, 2, 3});
  1. 如果我们要将真正昂贵的对象放入any变量中,我们也可以执行就地构造。让我们尝试一下我们的列表类型。in_place_type_t<int_list>{}表达式是一个空对象,它给any的构造函数提供了足够的信息,以知道我们将要构造什么。第二个参数{1, 2, 3}只是一个初始化列表,将被馈送到嵌入在any变量中用于构造的int_list中。这样,我们避免了不必要的复制或移动:
          print_anything(any(in_place_type_t<int_list>{}, {1, 2, 3}));
      }
  1. 编译和运行程序产生了以下输出,这正是我们所期望的:
      $ ./any 
      Nothing.
      It's a string: "abc"
      It's an integer: 123
      It's a list: 1, 2, 3, 
      It's a list: 1, 2, 3, 

它是如何工作的...

std::any类型在一个方面类似于std::optional--它有一个has_value()方法,告诉实例是否携带值。但除此之外,它可以包含任何东西,因此与optional相比,处理起来更加复杂。

在访问any变量的内容之前,我们需要找出它携带的是什么类型,然后将其转换为该类型。

找出any实例是否持有类型T值可以通过比较来完成:x.type() == typeid(T)。如果这个比较结果为true,那么我们可以使用any_cast来获取内容。

请注意,any_cast<T>(x)返回x中内部T值的副本。如果我们想要一个引用,以避免复制复杂对象,我们需要使用any_cast<T&>(x)。这就是我们在本节代码中访问内部stringlist<int>对象时所做的。

如果我们将any的实例转换为错误的类型,它将抛出一个std::bad_any_cast异常。

使用 std::variant 存储不同类型

在 C++中不仅有structclass原语可以让我们组合类型。如果我们想表达某个变量可以容纳类型A或类型B(或C,或其他任何类型),我们可以使用union。联合的问题在于它们无法告诉我们它们实际上是初始化为可以容纳的类型中的哪一个。

考虑以下代码:

union U { 
    int    a;
    char  *b; 
    float  c;
};

void func(U u) { std::cout << u.b << 'n'; }

如果我们使用一个初始化为通过成员a持有整数的联合来调用func函数,没有任何阻止我们访问它的东西,就好像它是通过成员b持有指向字符串的指针初始化的一样。这样的代码可能传播各种错误。在我们开始用一个辅助变量来打包我们的联合,告诉我们它是为了获得一些安全性而初始化的之前,我们可以直接使用 C++17 中提供的std::variant

variant有点像新式、类型安全和高效的联合类型。它不使用堆,因此它与基于联合的手工制作的解决方案一样空间和时间高效,因此我们不必自己实现它。它可以存储除了引用、数组或void类型之外的任何东西。

在这个示例中,我们将构建一个利用variant来获得如何使用 STL 这个新功能的示例。

如何做...

让我们实现一个程序,它知道类型catdog,并且存储了一个混合的猫和狗列表,而不使用任何运行时多态:

  1. 首先,我们包括所有需要的头文件,并定义我们使用std命名空间:
      #include <iostream>
      #include <variant>
      #include <list>
      #include <string>
      #include <algorithm>      

      using namespace std;
  1. 接下来,我们实现两个具有类似功能的类,但彼此之间没有任何其他关联,与那些例如继承自相同接口或类似接口的类相反。第一个类是cat。一个cat对象有一个名字,可以说meow
      class cat {
          string name;

      public:
          cat(string n) : name{n} {}

          void meow() const {
              cout << name << " says Meow!n";
          }
      };
  1. 另一个类是dog。一个dog对象不会说meow,而是woof,当然:
      class dog {
          string name;

      public:
          dog(string n) : name{n} {}

          void woof() const {
              cout << name << " says Woof!n";
          }
      };
  1. 现在我们可以定义一个animal类型,它只是一个到std::variant<dog, cat>的类型别名。这基本上与老式联合相同,但具有variant提供的所有额外功能:
      using animal = variant<dog, cat>;
  1. 在编写主程序之前,我们首先实现了两个帮助器。一个帮助器是一个动物谓词。通过调用is_type<cat>(...)is_type<dog>(...),我们可以找出动物变体实例是否持有catdog。实现只是调用holds_alternative,这是一个用于变体类型的通用谓词函数:
      template <typename T>
      bool is_type(const animal &a) {
          return holds_alternative<T>(a);
      }
  1. 第二个帮助器是一个充当函数对象的结构。它是一个双重的函数对象,因为它实现了两次operator()。一个实现是一个重载,接受狗,另一个接受猫。对于这些类型,它只是调用woofmeow函数:
      struct animal_voice
      {
          void operator()(const dog &d) const { d.woof(); }
          void operator()(const cat &c) const { c.meow(); }
      };
  1. 让我们把这些类型和帮助器用起来。首先,我们定义了一个animal变体实例列表,并用猫和狗填充它:
      int main()
      {
          list<animal> l {cat{"Tuba"}, dog{"Balou"}, cat{"Bobby"}};
  1. 现在,我们将三次打印列表的内容,每次以不同的方式。一种方法是使用variant::index()。因为animalvariant<dog, cat>的别名,返回值为0意味着变体持有dog实例。索引1表示它是cat。这里关键是变体专门化中类型的顺序。在 switch case 块中,我们使用get<T>访问变体,以获取内部的实际catdog实例:
          for (const animal &a : l) {
              switch (a.index()) {
              case 0: 
                  get<dog>(a).woof();
                  break;
              case 1:
                  get<cat>(a).meow();
                  break;
              }
          }
          cout << "-----n";
  1. 我们可以明确要求每种类型,而不是使用类型的数字索引。get_if<dog>返回一个指向内部dog实例的dog类型指针。如果内部没有dog实例,则指针为null。这样,我们可以尝试获取不同类型,直到最终成功:
          for (const animal &a : l) {
              if (const auto d (get_if<dog>(&a)); d) {
                  d->woof();
              } else if (const auto c (get_if<cat>(&a)); c) {
                  c->meow();
              }
          }
          cout << "-----n";
  1. 最后,最优雅的方法是variant::visit。此函数接受一个函数对象和一个变体实例。函数对象必须为变体可以容纳的所有可能类型实现不同的重载。我们之前实现了一个具有正确operator()重载的结构,因此可以在这里使用它:
          for (const animal &a : l) {
              visit(animal_voice{}, a);
          }
          cout << "-----n";
  1. 最后,我们将计算变体列表中猫和狗的数量。is_type<T>谓词可以专门用于catdog,然后可以与std::count_if结合使用,以返回此类型的实例数:
          cout << "There are "
               << count_if(begin(l), end(l), is_type<cat>)
               << " cats and "
               << count_if(begin(l), end(l), is_type<dog>)
               << " dogs in the list.n";
      }
  1. 首先编译和运行程序会打印相同的列表三次。之后,我们看到is_type谓词与count_if结合使用效果很好:
      $ ./variant 
      Tuba says Meow!
      Balou says Woof!
      Bobby says Meow!
      -----
      Tuba says Meow!
      Balou says Woof!
      Bobby says Meow!
      -----
      Tuba says Meow!
      Balou says Woof!
      Bobby says Meow!
      -----
      There are 2 cats and 1 dogs in the list.

它是如何工作的...

std::variant类型有点类似于std::any,因为两者都可以持有不同类型的对象,并且我们需要在运行时区分它们确切地持有什么,然后再尝试访问它们的内容。

另一方面,std::variantstd::any不同之处在于,我们必须声明它应该能够以模板类型列表的形式存储什么。std::variant<A, B, C>的实例必须持有ABC类型的一个实例。没有可能持有它们中的任何一个,这意味着std::variant没有可选性的概念。

类型为variant<A, B, C>的变体模拟了一个联合类型,可能如下所示:

union U {
    A a;
    B b;
    C c;
};

联合的问题在于我们需要构建自己的机制来区分它是用AB还是C变量初始化的。std::variant类型可以在不费吹灰之力的情况下为我们做到这一点。

在本节的代码中,我们使用了三种不同的方法来处理变体变量的内容。

第一种方法是variantindex()函数。对于变体类型variant<A, B, C>,如果它被初始化为持有A类型,则可以返回索引0,对于B,则为1,对于C,则为2,对于更复杂的变体,依此类推。

接下来的方法是get_if<T>函数。它接受一个变体对象的地址,并返回一个T类型的指针指向其内容。如果T类型错误,那么这个指针将是一个null指针。还可以在变体变量上调用get<T>(x),以便获得对其内容的引用,但如果失败,此函数会抛出异常(在进行这种get-casts 之前,可以使用布尔谓词holds_alternative<T>(x)来检查正确的类型)。

访问 variant 的最后一种方式是 std::visit 函数。它接受一个函数对象和一个 variant 实例。visit 函数然后检查 variant 的内容是哪种类型,然后调用函数对象的正确的 operator() 重载。

正是为了这个目的,我们实现了 animal_voice 类型,因为它可以与 visitvariant<dog, cat> 结合使用:

struct animal_voice
{
    void operator()(const dog &d) const { d.woof(); }
    void operator()(const cat &c) const { c.meow(); }
};

访问 variant 的 visit 方式可以被认为是最优雅的,因为实际访问 variant 的代码部分不需要硬编码到 variant 可以保存的类型。这使得我们的代码更容易扩展。

variant 类型不能保存 没有 值的说法并不完全正确。通过将 std::monostate 类型添加到其类型列表中,它确实可以被初始化为 没有 值。

使用 std::unique_ptr 自动处理资源

自 C11 以来,STL 提供了智能指针,可以真正帮助跟踪动态内存及其处理。即使在 C11 之前,也有一个称为 auto_ptr 的类,它已经能够进行自动内存处理,但很容易以错误的方式使用。

然而,使用 C++11 生成的智能指针,我们很少需要自己编写 newdelete,这是一件非常好的事情。智能指针是自动内存管理的一个光辉例子。如果我们使用 unique_ptr 维护动态分配的对象,我们基本上不会有内存泄漏,因为在其销毁时,该类会自动调用 delete 来释放它维护的对象。

唯一指针表示对其指向的对象的所有权,并在不再使用时遵循释放其内存的责任。这个类有潜力永远解决我们的内存泄漏问题(至少与其伴侣 shared_ptrweak_ptr 一起,但在这个示例中,我们只集中在 unique_ptr 上)。最好的是,与使用原始指针和手动内存管理的代码相比,它对空间和运行时性能没有额外的开销。(好吧,它在销毁指向的对象后在内部将其内部原始指针设置为 nullptr,这不能总是被优化掉。大多数手动编写管理动态内存的代码也是这样。)

在这个示例中,我们将看看 unique_ptr 以及如何使用它。

如何做...

我们将编写一个程序,通过创建一个自定义类型,该类型在其构造和销毁时添加一些调试消息,以显示我们如何使用 unique_ptr 处理内存。然后,我们将使用唯一指针来维护动态分配的实例:

  1. 首先,我们包含必要的头文件,并声明我们使用 std 命名空间:
      #include <iostream>
      #include <memory>  

      using namespace std;
  1. 我们将为我们将使用 unique_ptr 管理的对象实现一个小类。它的构造函数和析构函数会打印到终端,这样我们以后就可以看到它何时被自动删除。
      class Foo
      {
      public:
          string name;

          Foo(string n)
              : name{move(n)}
          { cout << "CTOR " << name << 'n'; }

          ~Foo() { cout << "DTOR " << name << 'n'; }
      };
  1. 为了查看接受唯一指针作为参数的函数有什么限制,我们只需实现一个函数。它通过打印其名称来 处理 一个 Foo 项。请注意,虽然唯一指针很聪明,没有额外开销,并且非常安全,但它们仍然可能是 null。这意味着我们在解引用它们之前仍然需要检查它们:
      void process_item(unique_ptr<Foo> p)
      {
          if (!p) { return; }

          cout << "Processing " << p->name << 'n';
      }
  1. 在主函数中,我们将打开另一个作用域,在堆上创建两个 Foo 对象,并使用唯一指针管理两个对象。我们使用 new 运算符显式在堆上创建第一个对象,然后将其放入 unique_ptr<Foo> 变量 p1 的构造函数中。我们通过调用 make_unique<Foo> 创建唯一指针 p2,并使用我们否则直接提供给 Foo 构造函数的参数。这是更加优雅的方式,因为我们可以使用自动类型推断,而且第一次访问对象时,它已经由 unique_ptr 管理:
      int main()
      {
          {
              unique_ptr<Foo> p1 {new Foo{"foo"}};
              auto            p2 (make_unique<Foo>("bar"));
          }
  1. 我们离开作用域后,两个对象立即被销毁,它们的内存被释放到堆中。现在让我们来看一下process_item函数以及如何在unique_ptr中使用它。如果我们在函数调用中构造一个由unique_ptr管理的新的Foo实例,那么它的生命周期将缩短到函数的作用域。当process_item返回时,对象被销毁:
          process_item(make_unique<Foo>("foo1"));
  1. 如果我们想要使用已经存在的对象调用process_item,那么我们需要转移所有权,因为该函数通过值传递了一个unique_ptr,这意味着调用它会导致复制。但unique_ptr不能被复制,它只能被移动。让我们创建两个新的Foo对象,并将其中一个移动到process_item中。通过稍后查看终端输出,我们将看到foo2process_item返回时被销毁,因为我们将所有权转移到了它。foo3将继续存在,直到主函数返回:
          auto p1 (make_unique<Foo>("foo2"));
          auto p2 (make_unique<Foo>("foo3"));

          process_item(move(p1));

          cout << "End of main()n";
      }
  1. 让我们编译并运行程序。首先,我们看到了foobar的构造函数和析构函数调用。它们确实在程序离开额外的作用域后立即被销毁。请注意,对象的销毁顺序与它们的创建顺序相反。下一个构造函数行来自foo1,这是我们在process_item调用期间创建的项目。它确实在函数调用后立即被销毁。然后我们创建了foo2foo3foo2在我们转移所有权的process_item调用后立即被销毁。而另一个项目foo3则是在主函数的最后一行代码后被销毁:
      $ ./unique_ptr 
      CTOR foo
      CTOR bar
      DTOR bar
      DTOR foo
      CTOR foo1
      Processing foo1
      DTOR foo1
      CTOR foo2
      CTOR foo3
      Processing foo2
      DTOR foo2
      End of main()
      DTOR foo3

它的工作原理...

使用std::unique_ptr处理堆对象非常简单。在初始化唯一指针以持有指向某个对象的指针后,我们无法在某些代码路径上意外忘记删除它。

如果我们将某个新指针分配给唯一指针,那么它将始终首先删除它指向的旧对象,然后存储新指针。在唯一指针变量x上,我们还可以调用x.reset()来立即删除它指向的对象,而不分配新指针。通过x = new_pointer重新分配的另一个等效替代方法是x.reset(new_pointer)

确实有一种方法可以释放unique_ptr管理的对象,而不删除它。release函数可以做到这一点,但在大多数情况下不建议使用这个函数。

由于指针在实际解引用之前需要进行检查,它们以一种使它们能够模拟原始指针的方式重载了正确的运算符。条件语句如if (p) {...}if (p != nullptr) {...}的执行方式与我们检查原始指针的方式相同。

通过get()函数可以对唯一指针进行解引用,该函数返回一个可以进行解引用的对象的原始指针,或者直接通过operator*进行解引用,这再次模拟了原始指针。

unique_ptr的一个重要特性是,它的实例不能被复制,但可以从一个unique_ptr变量移动到另一个。这就是为什么我们必须将现有的唯一指针移动到process_item函数中的原因。如果我们能够复制一个唯一指针,那么这意味着被指向的对象由两个唯一指针拥有,尽管这与唯一指针的设计相矛盾,它是底层对象的唯一 所有者(后来是“删除器”)。

由于存在unique_ptrshared_ptr等数据结构,因此很少有理由直接使用new创建堆对象并手动delete它们。无论何时都要使用这些类!特别是unique_ptr在运行时没有开销。

使用 std::shared_ptr 自动处理共享堆内存

在上一个示例中,我们学习了如何使用unique_ptr。这是一个非常有用和重要的类,因为它帮助我们管理动态分配的对象。但它只能处理单一所有权。不可能让多个对象拥有相同的动态分配对象,因为这样,谁后来删除它将是不清楚的。

指针类型shared_ptr专门为这种情况而设计。共享指针可以任意复制。内部引用计数机制跟踪有多少对象仍然维护对载荷对象的指针。只有最后一个共享指针离开范围时,才会调用载荷对象的delete。这样,我们可以确保我们不会因为对象在使用后自动删除而导致内存泄漏。同时,我们可以确保它们不会过早或过频繁地被删除(每个创建的对象只能被删除一次)。

在这个示例中,您将学习如何使用shared_ptr来自动管理在多个所有者之间共享的动态对象,并了解与unique_ptr相比有何不同的地方:

如何做...

我们将编写一个类似于我们在unique_ptr示例中编写的程序,以便深入了解shared_ptr的用法和原则:

  1. 首先,我们只包括必要的头文件,并声明我们默认使用std命名空间:
      #include <iostream>
      #include <memory>      

      using namespace std;
  1. 然后我们定义一个小的辅助类,它可以帮助我们看到它的实例何时被创建和销毁。我们将使用shared_ptr来管理它的实例:
      class Foo
      {
      public:
          string name;

          Foo(string n)
              : name{move(n)}
          { cout << "CTOR " << name << 'n'; }

          ~Foo() { cout << "DTOR " << name << 'n'; }
      };
  1. 接下来,我们实现一个函数,该函数通过值接受一个指向Foo实例的共享指针。通过值接受共享指针作为参数比通过引用接受更有趣,因为在这种情况下,它们需要被复制,这会改变它们的内部引用计数,我们将会看到:
      void f(shared_ptr<Foo> sp)
      {
          cout << "f: use counter at " 
               << sp.use_count() << 'n';
      }
  1. 在主函数中,我们声明一个空的共享指针。通过默认构造它,它实际上是一个null指针:
      int main()
      {
          shared_ptr<Foo> fa;
  1. 接下来,我们打开另一个范围并实例化两个Foo对象。我们使用new运算符创建第一个对象,然后将其传递给一个新的shared_ptr的构造函数。然后我们使用make_shared<Foo>创建第二个实例,它从我们提供的参数创建一个Foo实例。这是更优雅的方法,因为我们可以使用自动类型推断,并且在我们有机会第一次访问它时,对象已经被管理。在这一点上,这与unique_ptr示例非常相似:
          {
              cout << "Inner scope beginn";

              shared_ptr<Foo> f1 {new Foo{"foo"}};
              auto            f2 (make_shared<Foo>("bar"));
  1. 由于共享指针可以被共享,它们需要跟踪有多少方共享它们。这是通过内部引用计数或use计数来完成的。我们可以使用use_count打印它的值。此时的值正好是1,因为我们还没有复制它。我们可以将f1复制到fa,这会将使用计数增加到2
              cout << "f1's use counter at " << f1.use_count() << 'n';
              fa = f1;
              cout << "f1's use counter at " << f1.use_count() << 'n';
  1. 在我们离开范围时,共享指针f1f2被销毁。f1变量的引用计数再次减少到1,使fa成为Foo实例的唯一所有者。当f2被销毁时,它的引用计数减少到0。在这种情况下,shared_ptr指针的析构函数将调用delete来处理它:
          }
          cout << "Back to outer scopen";

          cout << fa.use_count() << 'n';
  1. 现在,让我们以两种不同的方式使用我们的共享指针调用f函数。首先,我们通过复制fa来天真地调用它。f函数将打印出引用计数为2的值。在对f的第二次调用中,我们将指针移动到函数中。这使得f成为对象的唯一所有者:
          cout << "first f() calln";
          f(fa);
          cout << "second f() calln";
          f(move(fa));
  1. f被返回后,Foo实例立即被销毁,因为我们不再拥有它。因此,当主函数返回时,所有对象都已经被销毁:
          cout << "end of main()n";
      }
  1. 编译和运行程序产生以下输出。一开始,我们看到"foo""bar"被创建。在我们复制f1(指向"foo")时,它的引用计数增加到2。在离开作用域时,"bar"被销毁,因为指向它的共享指针是唯一的所有者。输出中的单个1fa的引用计数,它现在是"foo"的唯一所有者。之后,我们调用函数f两次。在第一次调用时,我们将fa复制到其中,这再次给它一个引用计数为2。在第二次调用时,我们将其移动到f中,这不会改变它的引用计数。此外,因为此时f"foo"的唯一所有者,对象在f离开作用域后立即被销毁。这样,在main中的最后一行打印后,没有其他堆对象被销毁:
      $ ./shared_ptr
      Inner scope begin
      CTOR foo
      CTOR bar
      f1's use counter at 1
      f1's use counter at 2
      DTOR bar
      Back to outer scope
      1
      first f() call
      f: use counter at 2
      second f() call
      f: use counter at 1
      DTOR foo
      end of main()

它是如何工作的...

在构造和删除对象时,shared_ptr的工作原理基本上与unique_ptr相似。构造共享指针的方式与创建唯一指针类似(尽管有一个函数make_shared,它创建共享对象作为unique_ptr指针的make_unique函数的对应物)。

unique_ptr的主要区别在于我们可以复制shared_ptr实例,因为共享指针与它们管理的对象一起维护一个所谓的控制块。控制块包含指向有效负载对象的指针和引用计数或使用计数器。如果有Nshared_ptr实例指向对象,则使用计数器的值也为N。每当shared_ptr实例被销毁时,它的析构函数会递减这个内部使用计数器。对于这样一个对象的最后一个共享指针将满足条件,在其销毁期间将使用计数器递减到0。这是,然后,共享指针实例,它在有效负载对象上调用delete运算符!这样,我们不可能遭受内存泄漏,因为对象的使用计数会自动跟踪。

为了更好地说明这一点,让我们来看一下下面的图表:

在第 1 步中,我们有两个管理类型为Foo的对象的shared_ptr实例。使用计数器的值为2。然后,shared_ptr2被销毁,这将使用计数器减少到1Foo实例尚未被销毁,因为还有另一个共享指针。在第 3 步中,最后一个共享指针也被销毁。这导致使用计数器减少到0。第 4 步发生在第 3 步之后立即。控制块和Foo实例都被销毁,它们的内存被释放到堆上。

有了shared_ptrunique_ptr,我们可以自动处理大多数动态分配的对象,而不必再担心内存泄漏。然而,有一个重要的警告需要考虑——想象一下,我们在堆上有两个包含彼此的共享指针的对象,还有其他共享指针从其他地方指向其中一个。如果外部共享指针超出范围,那么两个对象仍然具有非零值的使用计数,因为它们相互引用。这会导致内存泄漏。在这种情况下不应该使用共享指针,因为这样的循环引用链会阻止这些对象的使用计数永远达到0

还有更多...

看看下面的代码。如果告诉你它包含潜在的内存泄漏,会怎么样?

void function(shared_ptr<A>, shared_ptr<B>, int);
// "function" is defined somewhere else

// ...somewhere later in the code:
function(new A{}, new B{}, other_function());

"内存泄漏在哪里?",有人可能会问,因为新分配的对象AB立即被输入到shared_ptr类型中,然后我们就不再担心内存泄漏了。

是的,事实上,一旦指针被捕获在shared_ptr实例中,我们就不再担心内存泄漏了。问题有点棘手,需要理解。

当我们调用一个函数,f(x(), y(), z()),编译器需要组装代码,先调用x()y()z(),这样它才能将它们的返回值转发给f。与之前的例子结合起来,这样做会让我们非常糟糕,因为编译器可以以任何顺序执行这些函数调用到xyz

回顾一下这个例子,如果编译器决定以一种方式构造代码,首先调用new A{},然后调用other_function(),最后调用new B{},然后再将这些函数的结果最终传递给function,如果other_function()抛出异常,我们会得到一个内存泄漏,因为我们仍然在堆上有一个未管理的对象A,因为我们刚刚分配了它,但没有机会将其交给shared_ptr的管理。无论我们如何捕获异常,对象的句柄都已经消失,我们无法删除它!

有两种简单的方法可以避免这个问题:

// 1.)
function(make_shared<A>(), make_shared<B>(), other_function());

// 2.)
shared_ptr<A> ap {new A{}};
shared_ptr<B> bp {new B{}};
function(ap, bp, other_function());

这样,对象已经由shared_ptr管理,无论之后谁抛出了什么异常。

处理指向共享对象的弱指针

在关于shared_ptr的配方中,我们学会了共享指针是多么有用和易于使用。与unique_ptr一起,它们为需要管理动态分配的对象的代码提供了无价的改进。

每当我们复制shared_ptr时,我们都会增加它的内部引用计数。只要我们持有共享指针的副本,被指向的对象就不会被删除。但是如果我们想要一种指针,它使我们能够在对象存在的情况下访问它,但不会阻止它的销毁呢?我们如何确定对象是否仍然存在呢?

在这种情况下,weak_ptr是我们的伙伴。它比unique_ptrshared_ptr更复杂一些,但在遵循这个配方之后,我们将准备好使用它。

如何做...

我们将实现一个程序,用shared_ptr实例维护对象,然后,我们混入weak_ptr,看看这如何改变智能指针内存处理的行为:

  1. 首先,我们包括必要的头文件,并声明我们默认使用std命名空间:
      #include <iostream>
      #include <iomanip>
      #include <memory>      

      using namespace std;
  1. 接下来,我们实现一个类,在其析构函数实现中打印一条消息。这样,我们可以简单地检查稍后在程序输出中何时实际销毁一个项目:
      struct Foo {
          int value;

          Foo(int i) : value{i} {}
          ~Foo() { cout << "DTOR Foo " << value << 'n'; }
      };
  1. 让我们还实现一个函数,打印关于弱指针的信息,这样我们就可以在程序的不同点打印弱指针的状态。weak_ptrexpired函数告诉我们它指向的对象是否仍然存在,因为持有一个对象的弱指针不会延长它的生命周期!use_count计数器告诉我们当前有多少shared_ptr实例指向所讨论的对象:
      void weak_ptr_info(const weak_ptr<Foo> &p)
      {
          cout << "---------" << boolalpha
               << "nexpired:   " << p.expired()
               << "nuse_count: " << p.use_count()
               << "ncontent:   ";
  1. 如果我们想要访问实际对象,我们需要调用lock函数。它会返回一个指向对象的共享指针。如果对象不再存在,我们从中得到的共享指针实际上是一个null指针。我们需要检查一下,然后我们就可以访问它了:
          if (const auto sp (p.lock()); sp) {
              cout << sp->value << 'n';
          } else {
              cout << "<null>n";
          }
      }
  1. 让我们在主函数中实例化一个空的弱指针,并打印它的内容,当然,一开始是空的:
      int main()
      {
          weak_ptr<Foo> weak_foo;

          weak_ptr_info(weak_foo);
  1. 在一个新的作用域中,我们用Foo类的一个新实例实例化一个新的共享指针,然后将其复制到弱指针中。请注意,这不会增加共享指针的引用计数。引用计数器为1,因为只有一个共享指针拥有它:
          {
              auto shared_foo (make_shared<Foo>(1337));
              weak_foo = shared_foo;
  1. 在我们离开作用域之前,让我们调用弱指针函数,然后在离开作用域再次调用。Foo实例应该立即被销毁,尽管有一个弱指针指向它:
              weak_ptr_info(weak_foo);
          }

          weak_ptr_info(weak_foo);
      }
  1. 编译和运行程序会使我们得到weak_ptr_info函数的输出三次。在第一次调用中,弱指针为空。在第二次调用中,它已经指向我们创建的Foo实例,并且在锁定之后能够解引用它。在第三次调用之前,我们离开了内部范围,这触发了Foo实例的析构函数,正如我们所预期的那样。之后,不再可能通过弱指针访问已删除的Foo项目的内容,弱指针正确地识别出它已经过期:
      $ ./weak_ptr 
      ---------
      expired:   true
      use_count: 0
      content:   <null>
      ---------
      expired:   false
      use_count: 1
      content:   1337
      DTOR Foo 1337
      ---------
      expired:   true
      use_count: 0
      content:   <null>

工作原理...

弱指针为我们提供了一种指向由共享指针维护的对象的方式,而不增加其使用计数器。好吧,原始指针也可以做同样的事情,但原始指针无法告诉我们它是否悬空。而弱指针可以!

为了理解弱指针作为共享指针的补充是如何工作的,让我们直接跳到一个说明性的图表:

流程与关于共享指针的配方中的图表类似。在步骤 1 中,我们有两个共享指针和一个指向类型为Foo的对象的弱指针。尽管有三个对象指向它,但只有共享指针操作其使用计数器,这就是为什么它的值为2。弱指针只操作控制块的弱计数器。在步骤 2 和 3 中,共享指针实例被销毁,逐步导致使用计数器为0。在步骤 4 中,这导致Foo对象被删除,但控制块仍然存在。弱指针仍然需要控制块来区分它是否悬空。只有当最后一个仍然指向控制块的指针也超出范围时,控制块才会被删除。

我们还可以说悬空的弱指针已经过期。为了检查这个属性,我们可以询问weak_ptr指针的expired方法,它返回一个布尔值。如果为true,那么我们不能解引用弱指针,因为没有对象可以再解引用了。

为了解引用弱指针,我们需要调用lock()。这是安全和方便的,因为这个函数返回给我们一个共享指针。只要我们持有这个共享指针,它后面的对象就不会消失,因为我们通过锁定它来增加了使用计数器。如果对象在lock()调用之前被删除,那么它返回的共享指针实际上是一个null指针。

使用智能指针简化遗留 API 的资源处理

智能指针(unique_ptrshared_ptrweak_ptr)非常有用,通常可以安全地说,程序员应该始终使用这些指针,而不是手动分配和释放内存。

但是,如果对象不能使用new运算符进行分配和/或不能使用delete再次释放呢?许多遗留库都带有自己的分配/销毁函数。看起来这可能是一个问题,因为我们学到智能指针依赖于newdelete。如果特定类型的对象的创建和/或销毁依赖于特定工厂函数的删除器接口,这是否会阻止我们获得智能指针的巨大好处呢?

一点也不。在这个配方中,我们将看到我们只需要对智能指针进行非常少量的定制,以便让它们遵循特定对象的分配和销毁的特定程序。

如何做...

在本节中,我们将定义一种类型,不能直接使用new进行分配,也不能使用delete进行释放。由于这阻止了它直接与智能指针一起使用,我们对unique_ptrsmart_ptr的实例进行了必要的小调整:

  1. 和往常一样,我们首先包含必要的头文件,并声明我们默认使用std命名空间:
      #include <iostream>
      #include <memory>
      #include <string>      

      using namespace std;
  1. 接下来,我们声明一个类,其构造函数和析构函数声明为private。这样,我们模拟了我们需要访问特定函数来创建和销毁它的实例的问题:
      class Foo
      {
          string name;

          Foo(string n)
              : name{n}
          { cout << "CTOR " << name << 'n'; }

          ~Foo() { cout << "DTOR " << name << 'n';}
  1. 静态方法create_foodestroy_foo然后创建和销毁Foo实例。它们使用原始指针。这模拟了一个遗留的 C API 的情况,它阻止我们直接使用普通的shared_ptr指针:
      public:
          static Foo* create_foo(string s) { 
             return new Foo{move(s)};
          }

          static void destroy_foo(Foo *p) { delete p; }
      };
  1. 现在,让我们通过shared_ptr来管理这样的对象。当然,我们可以将从create_foo得到的指针放入 shared 指针的构造函数中。只有销毁是棘手的,因为shared_ptr的默认删除器会做错。诀窍在于我们可以给shared_ptr一个自定义删除器。删除器函数或可调用对象需要具有的函数签名已经与destroy_foo函数的相同。如果我们需要调用更复杂的函数来销毁对象,我们可以简单地将其包装成 lambda 表达式:
      static shared_ptr<Foo> make_shared_foo(string s)
      {
          return {Foo::create_foo(move(s)), Foo::destroy_foo};
      }
  1. 请注意,make_shared_foo返回一个通常的shared_ptr<Foo>实例,因为给它一个自定义的删除器并没有改变它的类型。这是因为shared_ptr使用虚函数调用来隐藏这些细节。唯一指针不会施加任何开销,这使得对它们来说同样的技巧不可行。在这里,我们需要改变unique_ptr的类型。作为第二个模板参数,我们给它void (*)(Foo*),这正是指向函数destroy_foo的指针的类型:
      static unique_ptr<Foo, void (*)(Foo*)> make_unique_foo(string s)
      {
          return {Foo::create_foo(move(s)), Foo::destroy_foo};
      }
  1. 在主函数中,我们只是实例化了一个 shared 指针和一个 unique 指针实例。在程序输出中,我们将看到它们是否真的、正确地自动销毁了:
      int main()
      {
          auto ps (make_shared_foo("shared Foo instance"));
          auto pu (make_unique_foo("unique Foo instance"));
      }
  1. 编译和运行程序产生了以下输出,幸运的是正是我们所期望的:
      $ ./legacy_shared_ptr 
      CTOR shared Foo instance
      CTOR unique Foo instance
      DTOR unique Foo instance
      DTOR shared Foo instance

它是如何工作的...

通常,unique_ptrshared_ptr只是在它们应该销毁维护的对象时在内部指针上调用delete。在本节中,我们构造了一个类,它既不能使用x = new Foo{123}的 C++方式分配,也不能直接使用delete x来销毁。

Foo::create_foo函数只是返回一个新构造的Foo实例的普通原始指针,因此这不会引起进一步的问题,因为智能指针无论如何都可以使用原始指针。

我们需要解决的问题是,如果默认方式不正确,我们需要教unique_ptrshared_ptr如何销毁一个对象。

在这方面,智能指针类型有一点不同。为了为unique_ptr定义自定义删除器,我们必须改变它的类型。因为Foo删除器的类型签名是void Foo::destroy_foo(Foo*);,维护Foo实例的unique_ptr的类型必须是unique_ptr<Foo, void (*)(Foo*)>。现在,它可以持有一个指向destroy_foo的函数指针,我们在make_unique_foo函数中将其作为第二个构造参数提供给它。

如果给unique_ptr一个自定义的删除器函数强迫我们改变它的类型,那么为什么我们能够在shared_ptr上做同样的事情而改变它的类型呢?我们在那里唯一需要做的事情就是给shared_ptr一个第二个构造参数,就是这样。为什么对于unique_ptr来说不能像对shared_ptr那样容易呢?

之所以可以很简单地为shared_ptr提供某种可调用的删除对象,而不改变共享指针的类型,是因为共享指针的本质在于维护一个控制块。共享指针的控制块是一个具有虚函数的对象。这意味着标准共享指针的控制块与具有自定义删除器的共享指针的控制块的类型是不同的!当我们想让唯一指针使用自定义删除器时,这会改变唯一指针的类型。当我们想让共享指针使用自定义删除器时,这会改变内部控制块的类型,这对我们来说是不可见的,因为这种差异被隐藏在虚函数接口的背后。

可能使用唯一指针做同样的技巧,但这将意味着在它们上面有一定的运行时开销。这不是我们想要的,因为唯一指针承诺在运行时完全没有开销。

共享同一对象的不同成员值

让我们想象一下,我们正在维护一个指向某个复杂、组合和动态分配的对象的共享指针。然后,我们想要启动一个新的线程,对这个复杂对象的成员进行一些耗时的工作。如果我们现在想释放这个共享指针,那么在其他线程仍在访问它时,对象将被删除。如果我们不想给线程对象整个复杂对象的指针,因为那会破坏我们的良好接口,或者出于其他原因,这是否意味着我们现在必须进行手动内存管理?

不。可以使用共享指针,一方面指向一个大型共享对象的成员,另一方面对整个初始对象执行自动内存管理。

在这个例子中,我们将创建这样的一个场景(为了简单起见,不使用线程),以便对shared_ptr的这一便利功能有所感受。

如何做...

我们将定义一个由多个成员组成的结构。然后,我们在堆上分配这个结构的一个实例,并由共享指针维护。从这个共享指针,我们获得更多的共享指针,它们不指向实际对象,而是指向它的成员:

  1. 首先包括必要的头文件,然后声明我们默认使用std命名空间:
      #include <iostream>
      #include <memory>
      #include <string>      

      using namespace std;
  1. 然后我们定义一个具有不同成员的类。我们将让共享指针指向各个成员。为了能够看到类何时被创建和销毁,我们让它的构造函数和析构函数打印消息:
      struct person {
          string name;
          size_t age;

          person(string n, size_t a)
              : name{move(n)}, age{a}
          { cout << "CTOR " << name << 'n'; }

          ~person() { cout << "DTOR " << name << 'n'; }
      };
  1. 让我们定义共享指针,使其具有正确的类型,可以指向person类实例的nameage成员变量:
      int main()
      {
          shared_ptr<string> shared_name;
          shared_ptr<size_t> shared_age;
  1. 接下来,我们进入一个新的作用域,创建这样一个人物对象,并让一个共享指针管理它:
          {
              auto sperson (make_shared<person>("John Doe", 30));
  1. 然后,我们让前两个共享指针指向它的名称和年龄成员。诀窍在于我们使用了shared_ptr的特定构造函数,该构造函数接受一个共享指针和一个指向共享对象成员的指针。这样,我们可以管理对象,而不是直接指向对象本身!
              shared_name = shared_ptr<string>(sperson, &sperson->name);
              shared_age  = shared_ptr<size_t>(sperson, &sperson->age);
          }
  1. 离开作用域后,我们打印人的姓名和年龄值。只有在对象仍然分配时才合法:
          cout << "name: "  << *shared_name
               << "nage: " << *shared_age << 'n';
      }
  1. 编译和运行程序产生以下输出。从析构函数的消息中,我们看到当我们通过成员指针访问人的姓名和年龄值时,对象确实仍然存活和分配!
      $ ./shared_members 
      CTOR John Doe
      name: John Doe
      age:  30
      DTOR John Doe

它是如何工作的...

在这一部分,我们首先创建了一个管理动态分配的person对象的共享指针。然后,我们让另外两个智能指针指向该人物对象,但它们都没有直接指向该人物对象本身,而是指向它的成员,nameage

总结一下我们刚刚创建的场景,让我们看一下下面的图表:

请注意,shared_ptr1直接指向person对象,而shared_nameshared_age指向同一对象的nameage成员。显然,它们仍然管理对象的整个生命周期。这是可能的,因为内部控制块指针仍然指向相同的控制块,无论个别共享指针指向哪个子对象。

在这种情况下,控制块的使用计数为3。这样,当shared_ptr1被销毁时,person对象不会被销毁,因为其他共享指针仍然拥有该对象。

当创建指向共享对象成员的这种共享指针实例时,语法看起来有点奇怪。为了获得指向共享人员的名称成员的shared_ptr<string>,我们需要写如下内容:

auto sperson (make_shared<person>("John Doe", 30));
auto sname   (shared_ptr<string>(sperson, &sperson->name));

为了获得共享对象成员的特定指针,我们使用共享指针实例化一个类型特化的成员。这就是为什么我们写shared_ptr<**string**>。然后,在构造函数中,我们首先提供维护person对象的原始共享指针,作为第二个参数,我们提供新共享指针在解引用时将使用的对象的地址。

生成随机数和选择正确的随机数引擎

为了获得任何目的的随机数,C程序员通常在 C11 之前基本上使用 C 库的rand()函数。自 C++11 以来,已经有了一整套不同目的和不同特性的随机数生成器。

这些生成器并不完全自解释,所以我们将在本教程中查看它们。最后,我们将看到它们之间的区别,如何选择正确的生成器,以及我们很可能永远不会使用它们全部。

如何做...

我们将实现一个过程,打印一个漂亮的直方图,显示随机生成器生成的数字。然后,我们将运行所有 STL 随机数生成器引擎通过这个过程,并从结果中学习。这个程序包含许多重复的行,所以最好直接从附带本书互联网代码库中复制源代码,而不是手动输入所有重复的代码。

  1. 首先,我们包含所有必要的头文件,然后声明我们默认使用std命名空间:
      #include <iostream>
      #include <string>
      #include <vector>
      #include <random>
      #include <iomanip>
      #include <limits>
      #include <cstdlib>
      #include <algorithm>      

      using namespace std;
  1. 然后,我们实现一个辅助函数,它帮助我们维护和打印每种随机数引擎的一些统计信息。它接受两个参数:分区的数量和样本的数量。我们将立即看到这些是什么。随机生成器的类型是通过模板参数RD定义的。在这个函数中,我们做的第一件事是为生成器返回的数字的结果数值类型定义一个别名类型。我们还确保至少有 10 个分区:
      template <typename RD>
      void histogram(size_t partitions, size_t samples)
      {
          using rand_t = typename RD::result_type;
          partitions = max<size_t>(partitions, 10);
  1. 接下来,我们实例化一个类型为RD的实际生成器实例。然后,我们定义一个称为div的除数变量。所有随机数引擎发出的随机数范围为0RD::max()。函数参数partitions允许调用者选择我们将每个随机数范围划分为多少个分区。通过将最大可能值除以分区数,我们知道每个分区有多大:
          RD rd;
          rand_t div ((double(RD::max()) + 1) / partitions);
  1. 接下来,我们实例化一个计数器变量的向量。它的大小正好等于我们拥有的分区数。然后,我们从随机引擎中获取与变量samples相同数量的随机值。表达式rd()从生成器中获取一个随机数,并将其内部状态移位,以准备返回下一个随机数。通过将每个随机数除以div,我们得到它所在的分区号,并可以增加计数器向量中的正确计数器:
          vector<size_t> v (partitions);
          for (size_t i {0}; i < samples; ++i) { 
              ++v[rd() / div];
          }
  1. 现在我们有了一个样本值的粗略直方图。为了打印它,我们需要了解更多关于其实际计数器值的信息。让我们使用max_element算法提取其最大值。然后我们将这个最大计数器值除以100。这样,我们可以将所有计数器值除以max_div并在终端上打印大量星号,而不会超过100的宽度。如果最大计数器包含的数字小于100,因为我们没有使用太多样本,我们使用max来获得1的最小除数:
          rand_t max_elm (*max_element(begin(v), end(v)));
          rand_t max_div (max(max_elm / 100, rand_t(1)));
  1. 现在让我们将直方图打印到终端上。每个分区在终端上都有自己的一行。通过将其计数器值除以max_div并打印相应数量的星号'*',我们可以得到适合终端的直方图行:
          for (size_t i {0}; i < partitions; ++i) {
              cout << setw(2) << i << ": "
                   << string(v[i] / max_div, '*') << 'n';
          }
      }
  1. 好的,就是这样。现在到主程序。我们让用户定义应该使用多少个分区和样本:
      int main(int argc, char **argv)
      {
          if (argc != 3) {
              cout << "Usage: " << argv[0] 
                   << " <partitions> <samples>n";
              return 1;
          }
  1. 然后我们从命令行读取这些变量。当然,命令行由字符串组成,我们可以使用std::stoullstoullstring to unsigned long long 的缩写)将其转换为数字:
          size_t partitions {stoull(argv[1])};
          size_t samples    {stoull(argv[2])};
  1. 现在我们对 STL 提供的每个随机数引擎调用我们的直方图辅助函数。这使得这个示例非常冗长和重复。最好从互联网上复制示例。这个程序的输出真的很有趣。我们从random_device开始。这个设备试图将随机性均匀分布在所有可能的值上:
          cout << "random_device" << 'n';
          histogram<random_device>(partitions, samples);
  1. 我们尝试的下一个随机引擎是default_random_engine。这种类型引用的引擎是特定于实现的。它可以是以下任何一种随机引擎:
          cout << "ndefault_random_engine" << 'n';
          histogram<default_random_engine>(partitions, samples);
  1. 然后我们在所有其他引擎上尝试一下:
          cout << "nminstd_rand0" << 'n';
          histogram<minstd_rand0>(partitions, samples);
          cout << "nminstd_rand" << 'n';
          histogram<minstd_rand>(partitions, samples);

          cout << "nmt19937" << 'n';
          histogram<mt19937>(partitions, samples);
          cout << "nmt19937_64" << 'n';
          histogram<mt19937_64>(partitions, samples);

          cout << "nranlux24_base" << 'n';
          histogram<ranlux24_base>(partitions, samples);
          cout << "nranlux48_base" << 'n';
          histogram<ranlux48_base>(partitions, samples);

          cout << "nranlux24" << 'n';
          histogram<ranlux24>(partitions, samples);
          cout << "nranlux48" << 'n';
          histogram<ranlux48>(partitions, samples);

          cout << "nknuth_b" << 'n';
          histogram<knuth_b>(partitions, samples);
      }
  1. 编译和运行程序会产生有趣的结果。我们将看到一个很长的输出列表,并且我们会看到所有随机引擎具有不同的特征。让我们首先使用10个分区和只有1000个样本运行程序:

  1. 然后,我们再次运行相同的程序。这次仍然是10个分区,但是1,000,000个样本。很明显,当我们从中取更多的样本时,直方图看起来会更清晰。这是一个重要的观察:

它是如何工作的...

一般来说,任何随机数生成器在使用之前都需要实例化为对象。生成的对象可以像没有参数的函数一样调用,因为它重载了operator()。每次调用都会产生一个新的随机数。就是这么简单。

在本节中,我们编写了一个比以往更复杂的程序,以便更多地了解随机数生成器。请通过使用不同的命令行参数启动生成的程序来玩耍,并意识到以下事实:

  • 我们取样的样本越多,我们的分区计数器看起来就越均匀。

  • 分区计数器的不平等在各个单独的引擎之间差异很大。

  • 对于大量样本,个别随机引擎的性能差异变得明显。

  • 多次以低数量的样本运行程序。分布模式始终看起来相同--随机引擎重复产生相同的随机数序列,这意味着它们根本不是随机。这样的引擎被称为确定性,因为它们的随机数可以被预测。唯一的例外是std::random_device

正如我们所看到的,有一些特征需要考虑。对于大多数标准应用程序,std::default_random_engine将完全足够。密码学专家或类似安全敏感主题的专家将明智地在使用的引擎之间进行选择,但对于我们这些普通程序员来说,在编写带有一些随机性的应用程序时,这并不太重要。

我们应该从这个示例中得出以下三个事实:

  1. 通常,std::default_random_engine 对于一般的应用来说是一个很好的默认选择。

  2. 如果我们真的需要非确定性的随机数,std::random_device可以提供给我们这样的随机数。

  3. 我们可以用std::random_device的一个真正的随机数(或者可能是系统时钟的时间戳)来给任何随机引擎的构造函数提供种子,以便使其每次产生不同的随机数。这就是所谓的种子

请注意,std::random_device可能会退回到其中一个确定性引擎,如果库不支持非确定性随机引擎。

生成随机数并让 STL 塑造特定分布

在上一个示例中,我们学习了一些关于 STL 随机数引擎的知识。生成随机数这样或那样往往只是工作的一半。

另一个问题是,我们需要这些数字做什么?我们是在程序上“抛硬币”吗?人们过去常常使用rand() % 2来做这个,这会得到01的值,然后可以映射到正面反面。好吧,我们不需要为此使用库(尽管随机性专家知道,仅使用随机数的最低几位并不总是会得到高质量的随机数)。

如果我们想要建模一个骰子呢?那么,我们肯定可以写(rand() % 6) + 1,以表示掷骰子后的结果。对于这样简单的任务,还不需要使用库。

如果我们想要建模一个发生的概率恰好为 66%的事件怎么办?好吧,那么我们可以想出一个公式,比如bool yesno = (rand() % 100 > 66)。(哦等等,应该是>=,还是>正确?)

除此之外,我们如何建模一个不公平的骰子,其各面的概率并不相同?或者如何建模更复杂的分布?这些问题很快就会演变成科学任务。为了集中精力解决我们的主要问题,让我们先看看 STL 已经提供了什么来帮助我们。

STL 包含了十几种分布算法,可以为特定的需求塑造随机数。在这个示例中,我们将简要地查看所有这些算法,并更仔细地研究其中最常用的几种。

如何做到...

我们将生成随机数,塑造它们,并将它们的分布模式打印到终端。这样,我们可以了解它们,并理解最重要的那些,这对于我们如果需要以随机性为基础来建模某些特定的事物是很有用的。

  1. 首先,我们包含所有需要的头文件,并声明我们使用std命名空间:
      #include <iostream>
      #include <iomanip>
      #include <random>
      #include <map>
      #include <string>
      #include <algorithm>     

      using namespace std;
  1. 对于 STL 提供的每个分布,我们将打印一个直方图,以便看到它的特征,因为每个分布看起来都很特别。它接受一个分布作为参数,以及应该从中取样的样本数。然后,我们实例化默认的随机引擎和一个地图。地图将从我们从分布中获得的值映射到计数器,计算每个值出现的次数。之所以总是实例化一个随机引擎,是因为所有分布只是用作随机数的塑造函数,而随机数仍然需要由随机引擎生成:
      template <typename T>
      void print_distro(T distro, size_t samples)
      {
          default_random_engine e;
          map<int, size_t> m;
  1. 我们取样本数与samples变量相同,并用它们来填充地图计数器。这样,我们就得到了一个漂亮的直方图。调用e()会得到一个原始的随机数,而distro(e)则通过分布对象塑造了随机数。
          for (size_t i {0}; i < samples; ++i) {
              m[distro(e)] += 1;
          }
  1. 为了得到一个适合终端窗口的终端输出,我们需要知道最大计数器值。max_element函数帮助我们找到最大值,通过比较地图中所有相关的计数器,并返回一个指向最大计数器节点的迭代器。知道了这个值,我们就可以确定需要将所有计数器值除以多少,以便将输出适应终端窗口:
          size_t max_elm (max_element(begin(m), end(m),
              [](const auto &a, const auto &b) { 
                   return a.second < b.second; 
              })->second);
          size_t max_div (max(max_elm / 100, size_t(1)));
  1. 现在,我们遍历映射并为所有具有显着大小的计数器打印一个星号符号'*'的条形。我们放弃其他计数器,因为一些分布引擎将数字分布在如此大的域上,以至于它会完全淹没我们的终端窗口:
          for (const auto [randval, count] : m) {
              if (count < max_elm / 200) { continue; }

              cout << setw(3) << randval << " : "
                   << string(count / max_div, '*') << 'n';
          }
      }
  1. 在主函数中,我们检查用户是否向我们提供了一个参数,该参数告诉我们从每个分布中取多少个样本。如果用户没有提供或提供了多个参数,我们会报错。
      int main(int argc, char **argv)
      {
          if (argc != 2) {
              cout << "Usage: " << argv[0] 
                   << " <samples>n";
              return 1;
          }
  1. 我们使用std::stoull将命令行参数字符串转换为数字:
          size_t samples {stoull(argv[1])};
  1. 首先,我们尝试uniform_int_distributionnormal_distribution。这些是在需要随机数时使用的最典型的分布。在学校学习随机过程的人很可能已经听说过这些了。均匀分布接受两个值,表示它们将在其上分布随机值的范围的下限和上限。通过选择09,我们将得到在(包括)09之间出现的值。正态分布接受均值标准偏差作为参数:
          cout << "uniform_int_distributionn";
          print_distro(uniform_int_distribution<int>{0, 9}, samples);

          cout << "normal_distributionn";
          print_distro(normal_distribution<double>{0.0, 2.0}, samples);
  1. 另一个非常有趣的分布是piecewise_constant_distribution。它接受两个输入范围作为参数。第一个范围包含数字,表示区间的限制。通过将其定义为0, 5, 10, 30,我们得到一个从04的区间,然后是一个从59的区间,最后一个从1029的区间。另一个输入范围定义了输入范围的权重。通过将这些权重设置为0.2, 0.3, 0.5,区间被随机数命中的概率分别为 20%,30%和 50%。在每个区间内,所有值都具有相等的概率被命中:
          initializer_list<double> intervals {0, 5, 10, 30};
          initializer_list<double> weights {0.2, 0.3, 0.5};
          cout << "piecewise_constant_distributionn";
          print_distro(
              piecewise_constant_distribution<double>{
                  begin(intervals), end(intervals), 
                  begin(weights)}, 
             samples);
  1. piecewise_linear_distribution的构造方式类似,但其权重特性完全不同。对于每个区间边界点,都有一个权重值。在从一个边界过渡到另一个边界时,概率是线性插值的。我们使用相同的区间列表,但是不同的权重值列表。
          cout << "piecewise_linear_distributionn";
          initializer_list<double> weights2 {0, 1, 1, 0};
          print_distro(
              piecewise_linear_distribution<double>{
                  begin(intervals), end(intervals), begin(weights2)}, 
              samples);
  1. 伯努利分布是另一个重要的分布,因为它只分布具有特定概率的是/否命中/未命中正面/反面值。其输出值只有01。另一个有趣的分布,在许多情况下都很有用,是discrete_distribution。在我们的情况下,我们将其初始化为离散值1, 2, 4, 8。这些值被解释为可能的输出值03的权重:
          cout << "bernoulli_distributionn";
          print_distro(std::bernoulli_distribution{0.75}, samples);

          cout << "discrete_distributionn";
          print_distro(discrete_distribution<int>{{1, 2, 4, 8}}, samples);
  1. 还有很多其他不同的分布引擎。它们非常特殊,在非常特定的情况下非常有用。如果你从未听说过它们,它们可能不适合你。然而,由于我们的程序将产生漂亮的分布直方图,出于好奇的原因,我们将打印它们全部:
          cout << "binomial_distributionn";
          print_distro(binomial_distribution<int>{10, 0.3}, samples);
          cout << "negative_binomial_distributionn";
          print_distro(
              negative_binomial_distribution<int>{10, 0.8}, 
              samples);
          cout << "geometric_distributionn";
          print_distro(geometric_distribution<int>{0.4}, samples);
          cout << "exponential_distributionn";
          print_distro(exponential_distribution<double>{0.4}, samples);
          cout << "gamma_distributionn";
          print_distro(gamma_distribution<double>{1.5, 1.0}, samples);
          cout << "weibull_distributionn";
          print_distro(weibull_distribution<double>{1.5, 1.0}, samples);
          cout << "extreme_value_distributionn";
          print_distro(
              extreme_value_distribution<double>{0.0, 1.0}, 
              samples);
          cout << "lognormal_distributionn";
          print_distro(lognormal_distribution<double>{0.5, 0.5}, samples);
          cout << "chi_squared_distributionn";
          print_distro(chi_squared_distribution<double>{1.0}, samples);
          cout << "cauchy_distributionn";
          print_distro(cauchy_distribution<double>{0.0, 0.1}, samples);
          cout << "fisher_f_distributionn";
          print_distro(fisher_f_distribution<double>{1.0, 1.0}, samples);
          cout << "student_t_distributionn";
          print_distro(student_t_distribution<double>{1.0}, samples);
      }
  1. 编译和运行程序产生以下输出。让我们首先以每个分布1000个样本运行程序:

  1. 另一个以每个分布1,000,000个样本运行的结果显示,直方图看起来更加干净,更加典型。但我们也可以看到哪些是慢的,哪些是快的,当它们被生成时:

它的工作原理...

通常情况下,我们不太关心随机数引擎,只要它快速并且产生尽可能随机的数字,分布是我们应该根据我们想要解决(或创建)的问题明智选择的东西。

为了使用任何分布,我们首先需要从中实例化一个分布对象。我们已经看到不同的分布需要不同的构造参数。在食谱描述中,我们对一些分布引擎描述得有点太简要了,因为它们中的大多数都太特殊和/或太复杂,无法在这里涵盖。但不要担心,它们在 C++ STL 文档中都有详细的文档。

然而,一旦我们实例化了一个分布,我们就可以像调用函数一样调用它,它只接受一个随机引擎对象作为其唯一参数。然后发生的是,分布引擎从随机引擎中取一个随机值,应用一些魔术形状(当然完全取决于分布引擎的选择),然后返回给我们一个形状的随机值。这导致了完全不同的直方图,就像我们在执行程序后看到的那样。

了解不同分布的最全面的方法是玩弄我们刚刚编写的程序。除此之外,让我们总结一下最重要的分布。对于我们程序中出现但下表中没有的所有分布,请参阅 C++ STL 文档(如果您感兴趣):

分布 描述
uniform_int_distribution 这个分布接受下限和上限值作为构造参数。然后,它给我们的随机数总是落在(包括)这些边界之间的区间内。这个区间内每个值的概率是相同的,这给我们一个平坦形状的直方图。这个分布代表了掷骰子,因为骰子的每一面出现的概率都是相同的。
normal_distribution 正态分布或高斯分布在自然界几乎无处不在。它的 STL 版本接受平均值和标准偏差值作为构造函数参数,并在直方图中形成一个屋顶形状。如果我们比较人类或其他动物的身体大小或智商,或者学生的成绩,我们会意识到这些数字也是正态分布的。
bernoulli_distribution 伯努利分布非常适合我们想要抛硬币或得到是/否答案的情况。它只发出值01,其唯一的构造函数参数是值1的概率。
discrete_distribution 离散分布在我们只想要一个非常有限的、离散的值集合,并且想要为每个单独的值定义概率时是很有趣的。它的构造函数接受一个权重列表,并将根据它们的权重发出具有不同概率的随机数。如果我们想要模拟随机分布的血型,其中只有四种不同的血型具有特定的概率,那么这个引擎就是一个完美的选择。

第二十九章:并行性和并发性

在本章中,我们将涵盖以下内容:

  • 自动并行化使用标准算法的代码

  • 让程序在特定时间内休眠

  • 启动和停止线程

  • 使用std::unique_lockstd::shared_lock执行异常安全的共享锁定

  • 使用std::scoped_lock避免死锁

  • 同步并发的std::cout使用

  • 使用std::call_once安全地延迟初始化

  • 使用std::async将任务执行推入后台

  • 使用std::condition_variable实现生产者/消费者模式

  • 使用std::condition_variable实现多个生产者/消费者模式

  • 使用std::async并行化 ASCII Mandelbrot 渲染器

  • 使用std::future实现一个小型自动并行化库

介绍

在 C11 之前,C对并行化的支持并不多。这并不意味着启动、控制、停止和同步线程是不可能的,但是必须使用特定于操作系统的库,因为线程本质上与操作系统相关。

使用 C11,我们得到了std::thread,它可以在所有操作系统上进行基本的可移植线程控制。为了同步线程,C11 还引入了互斥类和舒适的 RAII 风格的锁包装器。除此之外,std::condition_variable允许线程之间灵活地进行事件通知。

一些其他非常有趣的添加是std::asyncstd::future--我们现在可以将任意普通函数包装成std::async调用,以便在后台异步执行它们。这样包装的函数返回std::future对象,承诺稍后包含函数结果,因此我们可以在等待其到达之前做其他事情。

STL 的另一个实际上巨大的改进是执行策略,可以添加到 69 个已经存在的算法中。这个添加意味着我们可以在旧程序中的现有标准算法调用中添加一个单一的执行策略参数,从而实现并行化,而无需进行复杂的重写。

在本章中,我们将逐个介绍所有这些添加内容,以便了解其中最重要的内容。之后,我们将对 C17 STL 中的并行化支持有足够的概览。我们不涵盖所有细节,但是最重要的部分。从本书中获得的概览有助于快速理解 C 17 STL 在线文档中的其余并行编程机制。

最后,本章包含两个额外的示例。在一个示例中,我们将使用最小的更改来并行化第二十三章中的 Mandelbrot ASCII 渲染器,STL 算法的高级使用。在最后一个示例中,我们将实现一个小型库,以隐式和自动地帮助并行化复杂任务。

自动并行化使用标准算法的代码

C++17 带来了一个非常重要的并行扩展:标准算法的执行策略。六十九个算法被扩展以接受执行策略,以便在多个核心上并行运行,甚至启用矢量化。

对于用户来说,这意味着如果我们已经在所有地方使用 STL 算法,我们可以免费获得一个不错的并行化奖励。我们可以通过简单地向现有的 STL 算法调用中添加一个单一的执行策略参数,轻松地为我们的应用程序提供后续的并行化。

在这个示例中,我们将实现一个简单的程序(具有不太严肃的用例场景),排列多个 STL 算法调用。在使用这些算法时,我们将看到使用 C++17 执行策略是多么容易,以便让它们多线程运行。在本节的最后几个小节中,我们将更仔细地研究不同的执行策略。

如何做...

在这一节中,我们将编写一个使用一些标准算法的程序。程序本身更多地是一个示例,展示了现实生活中的情景可能是什么样子,而不是真正的实际工作情况。在使用这些标准算法时,我们嵌入了执行策略以加快代码速度:

  1. 首先,我们需要包含一些头文件,并声明我们使用std命名空间。execution头文件是一个新的头文件;它是 C++17 中新增的:
      #include <iostream>
      #include <vector>
      #include <random>
      #include <algorithm>
      #include <execution>      

      using namespace std;
  1. 仅仅为了示例,我们将声明一个谓词函数,告诉一个数字是否是奇数。我们稍后会用到它:
      static bool odd(int n) { return n % 2; }
  1. 让我们首先在主函数中定义一个大向量。我们将用大量数据填充它,以便对其进行计算需要一些时间。这段代码的执行速度将会很大地变化,取决于执行这段代码的计算机。在不同的计算机上,较小/较大的向量大小可能更好:
      int main()
      {
          vector<int> d (50000000);
  1. 为了获得大量的随机数据用于向量,让我们实例化一个随机数生成器和一个分布,并将它们打包在一个可调用对象中。如果这对你来说看起来很奇怪,请先看一下处理随机数生成器和分布的示例,第二十五章,实用类
          mt19937 gen;
          uniform_int_distribution<int> dis(0, 100000);

          auto rand_num ([=] () mutable { return dis(gen); });
  1. 现在,让我们使用std::generate算法来填充向量的随机数据。这个算法有一个新的 C++17 版本,可以接受一种新类型的参数:执行策略。我们在这里放入了std::par,它允许自动并行化这段代码。通过这样做,我们允许多个线程同时开始填充向量,这样可以减少执行时间,如果计算机有多个 CPU 的话,这通常是现代计算机的情况:
          generate(execution::par, begin(d), end(d), rand_num);
  1. std::sort 方法也应该已经很熟悉了。C++17 版本也支持一个额外的参数来定义执行策略:
          sort(execution::par, begin(d), end(d));
  1. std::reverse也是一样的:
          reverse(execution::par, begin(d), end(d));
  1. 然后我们使用std::count_if来计算向量中所有奇数的个数。我们甚至可以通过只添加一个执行策略来并行化它!
          auto odds (count_if(execution::par, begin(d), end(d), odd));
  1. 整个程序并没有做任何真正的科学工作,因为我们只是要看一下如何并行化标准算法,但最后让我们打印一些东西:
          cout << (100.0 * odds / d.size()) 
               << "% of the numbers are odd.n";
      }
  1. 编译和运行程序会给我们以下输出。在这一点上,有趣的是看到在使用算法时,不带执行策略与所有其他执行策略相比,执行速度有何不同。这留给读者作为一个练习。试一试;可用的执行策略有seqparpar_vec。我们应该得到每个执行策略的不同执行时间:
      $ ./auto_parallel
      50.4% of the numbers are odd.

它是如何工作的...

特别是因为这个示例没有让我们分心于任何复杂的现实问题解决方案,我们能够完全集中精力在标准库函数调用上。很明显,它们的并行化版本与经典的顺序版本几乎没有区别。它们只是多了一个参数,即执行策略

让我们看一下调用并回答三个核心问题:

generate(execution::par, begin(d), end(d), rand_num);
sort(    execution::par, begin(d), end(d));
reverse( execution::par, begin(d), end(d));

auto odds (count_if(execution::par, begin(d), end(d), odd));

我们可以用这种方式并行化哪些 STL 算法?

在 C++17 标准中,现有的 69 个 STL 算法升级为支持并行处理,还有七个新算法也支持并行处理。虽然这样的升级对于实现来说可能相当具有侵入性,但在接口方面并没有太多改变--它们都增加了一个额外的ExecutionPolicy&& policy参数,就是这样。这意味着我们总是必须提供执行策略参数。只是它们另外支持接受执行策略作为它们的第一个参数。

这些是升级的 69 个标准算法。还有七个新的算法从一开始就支持执行策略(用粗体标出):

| std::adjacent_difference std::adjacent_find

std::all_of

std::any_of

std::copy

std::copy_if

std::copy_n

std::count

std::count_if

std::equal **std::exclusive_scan**

std::fill

std::fill_n

std::find

std::find_end

std::find_first_of

std::find_if

std::find_if_not **std::for_each**

**std::for_each_n**

std::generate

std::generate_n

std::includes **std::inclusive_scan**

std::inner_product | std::inplace_merge std::is_heap std::is_heap_until std::is_partitioned

std::is_sorted

std::is_sorted_until

std::lexicographical_compare

std::max_element

std::merge

std::min_element

std::minmax_element

std::mismatch

std::move

std::none_of

std::nth_element

std::partial_sort

std::partial_sort_copy

std::partition

std::partition_copy

std::remove

std::remove_copy

std::remove_copy_if

std::remove_if

std::replace

std::replace_copy

std::replace_copy_if | std::replace_if std::reverse

std::reverse_copy

std::rotate

std::rotate_copy

std::search

std::search_n

std::set_difference

std::set_intersection

std::set_symmetric_difference

std::set_union

std::sort

std::stable_partition

std::stable_sort

std::swap_ranges

std::transform

**std::transform_exclusive_scan** **std::transform_inclusive_scan** **std::transform_reduce**

std::uninitialized_copy

std::uninitialized_copy_n

std::uninitialized_fill

std::uninitialized_fill_n

std::unique

std::unique_copy |

这些算法的升级是个好消息!我们的旧程序越多地利用 STL 算法,我们就越容易事后为它们添加并行性。请注意,这并意味着这些更改会使每个程序自动* N *倍加速,因为多程序设计要复杂得多。

然而,我们现在可以以非常优雅、独立于操作系统的方式并行化标准任务,而不是设计自己复杂的并行算法,使用std::threadstd::async或包含外部库。

这些执行策略是如何工作的?

执行策略告诉我们允许使用哪种策略来自动并行化我们的标准算法调用。

std::execution命名空间中存在以下三种策略类型:

策略 含义
sequenced_policy 该算法必须以类似于原始算法的顺序形式执行,而不使用执行策略。全局可用的实例名为std::execution::seq
parallel_policy 该算法可以以多线程方式执行,共享工作以并行方式进行。全局可用的实例名为std::execution::par
parallel_unsequenced_policy 该算法可以以多线程共享工作的方式执行。除此之外,允许对代码进行向量化。在这种情况下,容器访问可以在线程之间交错,也可以在同一线程内由于向量化而交错。全局可用的实例名为std::execution::par_unseq

执行策略对我们有特定的约束。约束越严格,我们可以允许的并行化策略措施就越多:

  • 并行化算法使用的所有元素访问函数不能引起死锁数据竞争

  • 在并行和向量化的情况下,所有访问函数必须不使用任何形式的阻塞同步

只要我们遵守这些规则,我们就应该免受使用 STL 算法的并行版本引入的错误的影响。

请注意,正确使用并行 STL 算法并不总是会导致保证的加速。根据我们尝试解决的问题、问题规模以及我们的数据结构和其他访问方法的效率,可测量的加速将会变化很大,或者根本不会发生。多程序设计仍然很困难。

向量化是什么意思?

向量化是 CPU 和编译器都需要支持的功能。让我们简要了解一下向量化是什么以及它是如何工作的。想象一下,我们想要对一个非常大的向量中的数字进行求和。这个任务的简单实现可能如下所示:

std::vector<int> v {1, 2, 3, 4, 5, 6, 7 /*...*/};

int sum {std::accumulate(v.begin(), v.end(), 0)};

编译器最终将从accumulate调用生成一个循环,可能如下所示:

int sum {0};
for (size_t i {0}; i < v.size(); ++i) {
    sum += v[i];
}

从这一点出发,允许并启用向量化,编译器可以生成以下代码。循环在一个循环步骤中执行四个累加步骤,也减少了四倍的迭代次数。为简单起见,示例未处理向量不包含N * 4元素的余数:

int sum {0};
for (size_t i {0}; i < v.size() / 4; i += 4) {
    sum += v[i] + v[i+1] + v[i + 2] + v[i + 3];
}
// if v.size() / 4 has a remainder, 
// real code has to deal with that also.

为什么要这样做?许多 CPU 提供的指令可以在一步中执行数学运算,例如sum += v[i] + v[i+1] + v[i + 2] + v[i + 3];。尽可能多地将许多数学运算压缩到尽可能少的指令中是目标,因为这会加快程序速度。

自动向量化很难,因为编译器需要在一定程度上理解我们的程序,以使我们的程序更快,但又不影响其正确性。至少,我们可以通过尽可能经常使用标准算法来帮助编译器,因为这些对编译器来说比复杂的手工循环和复杂的数据流依赖更容易理解。

将程序休眠特定时间

C++11 引入了一种简单的控制线程的可能性。它引入了this_thread命名空间,其中包括只影响调用线程的函数。它包含两个不同的函数,允许将线程休眠一段时间,因此我们不再需要为此类任务使用任何外部或操作系统相关的库。

在这个示例中,我们专注于如何暂停线程一段时间,或者如何将它们置于休眠状态。

如何做...

我们将编写一个简短的程序,只是将主线程休眠一段时间:

  1. 让我们首先包含所有需要的头文件,并声明我们将使用stdchrono_literals命名空间。chrono_literals命名空间包含用于创建时间跨度值的方便缩写:
      #include <iostream>
      #include <chrono>
      #include <thread>      

      using namespace std;
      using namespace chrono_literals;
  1. 让我们立即将主线程休眠 5 秒和 300 毫秒。由于chrono_literals,我们可以以非常易读的格式表达这一点:
      int main()
      {
          cout << "Going to sleep for 5 seconds"
                  " and 300 milli seconds.n";

          this_thread::sleep_for(5s + 300ms);
  1. 最后一个休眠语句是relative。我们也可以表达absolute的休眠请求。让我们休眠到现在加上3秒的时间点:
          cout << "Going to sleep for another 3 seconds.n";

          this_thread::sleep_until(
              chrono::high_resolution_clock::now() + 3s);
  1. 在退出程序之前,让我们打印一些其他内容,以示第二个休眠期结束:
          cout << "That's it.n";
      }
  1. 编译和运行程序产生以下结果。Linux、Mac 和其他类 UNIX 操作系统提供time命令,该命令接受另一个命令以执行它并停止所需的时间。使用time运行我们的程序显示它运行了8.32秒,大约是我们让程序休眠的5.3秒和3秒。运行程序时,可以计算在终端上打印行到达之间的时间。
      $ time ./sleep 
      Going to sleep for 5 seconds and 300 milli seconds.
      Going to sleep for another 3 seconds.
      That's it.

      real 0m8.320s
      user 0m0.005s
      sys  0m0.003s

它是如何工作的...

sleep_forsleep_until函数已添加到 C++11 中,并驻留在std::this_thread命名空间中。它们阻塞当前线程(而不是整个进程或程序)一段特定的时间。线程在被阻塞时不会消耗 CPU 时间。它只是被操作系统置于非活动状态。当然,操作系统会提醒自己再次唤醒线程。最好的是,我们不需要关心我们的程序运行在哪个操作系统上,因为 STL 将这个细节抽象化了。

this_thread::sleep_for函数接受chrono::duration值。在最简单的情况下,这只是1s5s + 300ms,就像我们的示例代码中一样。为了获得这样漂亮的时间跨度文字,我们需要声明using namespace std::chrono_literals;

this_thread::sleep_until函数接受chrono::time_point而不是时间跨度。如果我们希望将线程休眠直到特定的挂钟时间,这是很方便的。

唤醒的时间只有操作系统允许的那么准确。这将在大多数操作系统中通常足够准确,但如果某些应用程序需要纳秒级精度,可能会变得困难。

将线程休眠一小段时间的另一种可能性是this_thread::yield。它不接受任何参数,这意味着我们无法知道线程的执行被放置回去多长时间。原因是这个函数并没有真正实现睡眠或停放线程的概念。它只是以一种合作的方式告诉操作系统可以重新安排任何其他进程的任何其他线程。如果没有,那么线程将立即再次执行。因此,yield通常比仅仅睡眠一段最小但指定的时间不太有用。

启动和停止线程

C++11 带来的另一个新增功能是std::thread类。它提供了一种干净简单的方法来启动和停止线程,而无需外部库或了解操作系统如何实现这一点。这一切都包含在 STL 中。

在这个示例中,我们将实现一个启动和停止线程的程序。一旦线程启动,就需要了解如何处理线程的一些细节,所以我们也会详细介绍这些内容。

如何做...

我们将启动多个线程,并查看当我们释放多个处理器核心同时执行其代码的部分时,我们的程序的行为如何:

  1. 首先,我们需要包括只有两个标题,然后我们声明使用stdchrono_literals命名空间:
      #include <iostream>
      #include <thread>      

      using namespace std;
      using namespace chrono_literals;
  1. 为了启动一个线程,我们需要能够告诉它应该执行什么代码。因此,让我们定义一个可以执行的函数。函数是线程的自然潜在入口点。示例函数接受一个参数i,它充当线程 ID。这样我们可以稍后知道哪个打印行来自哪个线程。此外,我们使用线程 ID 让所有线程等待不同的时间,这样我们可以确保它们不会在完全相同的时间使用cout。如果是这样,输出将会混乱。本章的另一个示例专门处理了这个问题:
      static void thread_with_param(int i)
      {
          this_thread::sleep_for(1ms * i);

          cout << "Hello from thread " << i << 'n';

          this_thread::sleep_for(1s * i);

          cout << "Bye from thread " << i << 'n';
      }
  1. 在主函数中,我们可以出于好奇,使用std::thread::hardware_concurrency打印可以同时运行多少个线程。这取决于机器实际上有多少个核心,以及 STL 实现支持多少个核心。这意味着在每台计算机上这个数字可能是不同的:
      int main()
      {
          cout << thread::hardware_concurrency()
               << " concurrent threads are supported.n";
  1. 现在让我们最终启动线程。对于每个线程,我们使用不同的 ID 启动三个线程。当使用thread t {f, x}这样的表达式实例化线程时,这将导致新线程调用f(x)。这样我们可以为每个线程的thread_with_param函数提供不同的参数:
          thread t1 {thread_with_param, 1};
          thread t2 {thread_with_param, 2};
          thread t3 {thread_with_param, 3};
  1. 由于这些线程是自由运行的,当它们完成工作时,我们需要再次停止它们。我们使用join函数来做到这一点。它将阻塞调用线程,直到我们尝试加入的线程返回:
          t1.join();
          t2.join();
  1. 与加入相对应的是分离。如果我们不调用join或分离,整个应用程序将在thread对象的析构函数执行时立即终止。通过调用detach,我们告诉thread,我们真的希望让线程 3 继续运行,即使它的thread实例被销毁:
          t3.detach();
  1. 在退出主函数和整个程序之前,我们打印另一条消息:
          cout << "Threads joined.n";
      }
  1. 编译和运行代码显示了以下输出。我们可以看到我的机器有八个 CPU 核心。然后,我们看到了所有线程的hello消息,但只有两个我们实际加入的线程的bye消息。线程 3 仍然处于等待 3 秒的期间,但整个程序在第二个线程等待 2 秒后就已经终止了。这样,我们无法看到线程 3 的 bye 消息,因为它被简单地杀死了,没有任何完成的机会(也没有噪音):
      $ ./threads 
      8 concurrent threads are supported.
      Hello from thread 1
      Hello from thread 2
      Hello from thread 3
      Bye from thread 1
      Bye from thread 2
      Threads joined.

它是如何工作的...

启动和停止线程是一件非常简单的事情。当线程需要共同工作(共享资源,等待彼此等)时,多道程序设计开始变得复杂。

为了启动一个线程,我们首先需要一个将由它执行的函数。这个函数不需要特殊,因为线程可以执行几乎每个函数。让我们确定一个最小的示例程序,启动一个线程并等待它的完成:

void f(int i) { cout << i << 'n'; }

int main()
{
    thread t {f, 123};
    t.join();
}

std::thread的构造函数调用接受一个函数指针或可调用对象,后面跟着应该与函数调用一起使用的参数。当然,也可以启动一个不接受任何参数的函数的线程。

如果系统有多个 CPU 核心,那么线程可以并行和同时运行。并行和同时运行有什么区别?如果计算机只有一个 CPU 核心,那么可以有很多线程并行运行,但从来不会同时运行,因为一个 CPU 核心一次只能运行一个线程。然后线程以交错的方式运行,每个线程执行一部分时间,然后暂停,然后下一个线程获得时间片(对于人类用户来说,这看起来像它们同时运行)。如果它们不需要共享 CPU 核心,那么它们可以同时运行,就像真正同时一样。

在这一点上,我们绝对无法控制以下细节:

  • 当共享一个 CPU 核心时,线程交错的顺序。

  • 线程的优先级,或者哪一个比其他更重要。

  • 线程真正分布在所有 CPU 核心之间,或者操作系统只是将它们固定在同一个核心上。事实上,我们的所有线程可能只在一个核心上运行,尽管机器有 100 多个核心。

大多数操作系统提供了控制多道程序设计这些方面的可能性,但这些功能在 STL 中包括在内。

然而,我们可以启动和停止线程,并告诉它们在什么时候工作,什么时候暂停。这对于大多数应用程序来说应该足够了。在本节中,我们启动了三个额外的线程。之后,我们加入了大部分线程,并分离了最后一个线程。让我们用一个简单的图表总结一下发生了什么:

从上到下阅读图表,它显示了程序工作流程在某一时刻分成了总共四个线程。我们启动了三个额外的线程,它们做了一些事情(即等待和打印),但在启动线程之后,执行主函数的主线程仍然没有工作。

每当一个线程执行完它启动的函数时,它将从这个函数返回。标准库然后做一些整理工作,导致线程从操作系统的调度中移除,也许会被销毁,但我们不需要担心这些。

我们唯一需要担心的是加入。当一个线程在另一个thread对象上调用函数x.join()时,它会被放到睡眠状态,直到线程x返回。请注意,如果线程被困在无限循环中,我们就没那么幸运了!如果我们希望线程继续存在,直到它决定终止自己,我们可以调用x.detach()。这样做后,我们就不再对线程有外部控制。无论我们做出什么决定,我们必须始终加入分离线程。如果我们不做这两者之一,thread对象的析构函数将调用std::terminate(),这将导致应用程序突然关闭。

当我们的主函数返回时,整个应用程序当然被终止。但与此同时,我们分离的线程t3仍在睡眠,然后将其再见消息打印到终端。操作系统并不在乎,它只是在不等待该线程完成的情况下终止了整个程序。这是我们需要考虑的事情。如果该附加线程必须完成一些重要工作,我们必须让主函数等待它。

使用 stdunique_lock 和 stdshared_lock 执行异常安全的共享锁定

由于线程的操作是与操作系统支持密切相关的事情,STL 为此提供了良好的操作系统无关接口,因此为线程之间的同步提供 STL 支持也是明智的。这样,我们不仅可以在没有外部库的情况下启动和停止线程,还可以使用来自单一统一库的抽象来同步它们:STL。

在这个示例中,我们将看看 STL 互斥锁类和 RAII 锁抽象。虽然我们在具体的示例实现中玩弄了其中一些,但我们也将概述 STL 提供的更多同步助手。

如何做...

我们将编写一个程序,该程序在其独占共享模式下使用std::shared_mutex实例,并查看这意味着什么。此外,我们不调用锁定和解锁函数,而是使用 RAII 助手进行自动解锁:

  1. 首先,我们需要包括所有必要的头文件。因为我们一直与时间文字一起使用 STL 函数和数据结构,所以我们声明我们使用stdchrono_literal命名空间:
      #include <iostream>
      #include <shared_mutex>
      #include <thread>
      #include <vector>      

      using namespace std;
      using namespace chrono_literals;
  1. 整个程序围绕一个共享互斥锁展开,因此为了简单起见,让我们定义一个全局实例:
      shared_mutex shared_mut;
  1. 我们将使用std::shared_lockstd::unique_lock的 RAII 助手。为了使它们的名称看起来不那么笨拙,我们为它们定义了短类型别名:
      using shrd_lck = shared_lock<shared_mutex>;
      using uniq_lck = unique_lock<shared_mutex>;
  1. 在开始主函数之前,我们定义了两个辅助函数,它们都尝试以独占模式锁定互斥锁。这个函数在共享互斥锁上实例化一个unique_lock实例。第二个构造函数参数defer_lock告诉对象保持锁定。否则,它的构造函数将尝试锁定互斥锁,然后阻塞直到成功。然后我们在exclusive_lock对象上调用try_lock。这个调用将立即返回,其布尔返回值告诉我们它是否获得了锁,还是互斥锁已经在其他地方被锁定:
      static void print_exclusive()
      {
          uniq_lck l {shared_mut, defer_lock};

          if (l.try_lock()) {
              cout << "Got exclusive lock.n";
          } else {
              cout << "Unable to lock exclusively.n";
          }
      }
  1. 另一个辅助函数也尝试以独占模式锁定互斥锁。它会阻塞直到获得锁。然后我们通过抛出异常来模拟一些错误情况(它只携带一个普通整数而不是更复杂的异常对象)。尽管这会导致我们持有锁定互斥锁的上下文立即退出,但互斥锁将会被设计上的unique_lock析构函数在任何情况下都会释放锁定:
      static void exclusive_throw()
      {
          uniq_lck l {shared_mut};
          throw 123;
      }
  1. 现在到主要功能。首先,我们打开另一个范围并实例化一个shared_lock实例。它的构造函数立即以shared模式锁定互斥锁。我们将在接下来的步骤中看到这意味着什么:
      int main()
      {
          {
              shrd_lck sl1 {shared_mut};

              cout << "shared lock once.n";
  1. 现在我们打开另一个作用域,并在同一个互斥体上实例化第二个shared_lock实例。现在我们有两个shared_lock实例,它们都持有互斥体的共享锁。实际上,我们可以在同一个互斥体上实例化任意多个shared_lock实例。然后我们调用print_exclusive,它试图以独占模式锁定互斥体。这不会成功,因为它已经以共享模式锁定了:
              {
                  shrd_lck sl2 {shared_mut};

                  cout << "shared lock twice.n";

                  print_exclusive();
              }
  1. 在离开最新的作用域后,shared_lock sl2的析构函数释放了它对互斥体的共享锁。print_exclusive函数将再次失败,因为互斥体仍处于共享锁定模式:
              cout << "shared lock once again.n";

              print_exclusive();

          }
          cout << "lock is free.n";
  1. 在离开另一个作用域后,所有shared_lock对象都被销毁,互斥体再次处于未锁定状态。现在我们终于可以以独占模式锁定互斥体。让我们通过调用exclusive_throw然后print_exclusive来做到这一点。请记住,我们在exclusive_throw中抛出异常。但是因为unique_lock是一个 RAII 对象,它给我们提供了异常安全性,无论我们如何从exclusive_throw返回,互斥体都将再次被解锁。这样print_exclusive将不会在错误地仍然锁定的互斥体上阻塞:
          try {
              exclusive_throw();
          } catch (int e) {
              cout << "Got exception " << e << 'n';
          }

          print_exclusive();
      }
  1. 编译和运行代码产生以下输出。前两行显示我们得到了两个共享锁实例。然后print_exclusive函数无法以独占模式锁定互斥体。在离开内部作用域并解锁第二个共享锁后,print_exclusive函数仍然失败。在离开另一个作用域后,最终释放了互斥体,exclusive_throwprint_exclusive最终能够锁定互斥体:
      $ ./shared_lock 
      shared lock once.
      shared lock twice.
      Unable to lock exclusively.
      shared lock once again.
      Unable to lock exclusively.
      lock is free.
      Got exception 123
      Got exclusive lock.

工作原理...

当查看 C++文档时,首先会让人感到困惑的是有不同的互斥类和 RAII 锁辅助工具。在查看我们的具体代码示例之前,让我们总结一下 STL 为我们提供了什么。

互斥类

术语互斥体代表mutual exclusion。为了防止并发运行的线程以非协调的方式更改相同的对象,可能导致数据损坏,我们可以使用互斥对象。STL 提供了不同的互斥类,具有不同的特性。它们都有一个lock和一个unlock方法。

每当一个线程是第一个在之前未锁定的互斥体上调用lock()的线程时,它就拥有了互斥体。在这一点上,其他线程将在它们的lock调用上阻塞,直到第一个线程再次调用unlockstd::mutex正好可以做到这一点。

STL 中有许多不同的互斥类:

类型名称 描述
mutex 具有lockunlock方法的标准互斥体。提供额外的非阻塞try_lock方法。
timed_mutex 与互斥体相同,但提供了额外的try_lock_fortry_lock_until方法,允许超时而不是永远阻塞。
recursive_mutex mutex相同,但如果一个线程已经锁定了它的实例,它可以在同一个互斥对象上多次调用lock而不会阻塞。在拥有线程调用unlock与调用lock的次数一样多后,它将被释放。
recursive_timed_mutex 提供了timed_mutexrecursive_mutex的特性。
shared_mutex 这个互斥体在这方面很特别,它可以以独占模式和共享模式锁定。在独占模式下,它显示与标准互斥体类相同的行为。如果一个线程以共享模式锁定它,其他线程也可以以共享模式锁定它。只要最后一个共享模式锁定所有者释放它,它就会被解锁。在共享模式锁定时,不可能获得独占所有权。这与shared_ptr的行为非常相似,只是它不管理内存,而是锁的所有权。
shared_timed_mutex 结合了shared_mutextimed_mutex的特性,既可以进行独占模式也可以进行共享模式。

锁类

只要线程只锁定互斥锁,访问一些并发保护对象,然后再次解锁互斥锁,一切都很顺利。一旦一个健忘的程序员在某处忘记解锁互斥锁,或者在互斥锁仍然被锁定时抛出异常,事情就会很快变得混乱。在最好的情况下,程序会立即挂起,并且很快就会发现缺少的解锁调用。然而,这些错误与内存泄漏非常相似,当缺少显式的delete调用时也会发生内存泄漏。

在考虑内存管理时,我们有unique_ptrshared_ptrweak_ptr。这些辅助程序提供了非常方便的方法来避免内存泄漏。互斥锁也有这样的辅助程序。最简单的是std::lock_guard。可以按照以下方式使用:

void critical_function()
{
    lock_guard<mutex> l {some_mutex};

    // critical section
}

lock_guard元素的构造函数接受一个互斥锁,在该互斥锁上立即调用lock。整个构造函数调用将阻塞,直到它获得互斥锁上的锁。在销毁时,它再次解锁互斥锁。这样做是为了防止lock/unlock循环出错,因为它会自动发生。

C17 STL 提供了以下不同的 RAII 锁辅助程序。它们都接受一个模板参数,该参数应与互斥锁的类型相同(尽管自 C17 以来,编译器可以自行推断该类型):

名称 描述
lock_guard 该类除了构造函数和析构函数外,没有提供其他内容,它们lockunlock一个互斥锁。
scoped_lock 类似于lock_guard,但在其构造函数中支持任意数量的互斥锁。在其析构函数中以相反的顺序释放它们。
unique_lock 以独占模式锁定互斥锁。构造函数还接受参数,指示它在锁定调用时超时而不是永远阻塞。还可以选择根本不锁定互斥锁,或者假定它已经被锁定,或者仅尝试锁定互斥锁。额外的方法允许在unique_lock锁的生命周期内锁定和解锁互斥锁。
shared_lock unique_lock相同,但所有操作都以共享模式应用于互斥锁。

虽然lock_guardscoped_lock具有非常简单的接口,只包括构造函数和析构函数,但unique_lockshared_lock更复杂,但也更灵活。在本章的后续配方中,我们将看到它们可以如何被用于不仅仅是简单的锁定区域。

现在让我们回到配方代码。虽然我们只在单线程上运行了代码,但我们已经看到了如何使用锁辅助程序。shrd_lck类型别名代表shared_lock<shared_mutex>,允许我们以共享模式多次锁定实例。只要sl1sl2存在,就无法通过print_exclusive调用以独占模式锁定互斥锁。这仍然很简单。

现在让我们来看一下稍后在主函数中出现的独占锁定函数:

int main()
{
    {
        shrd_lck sl1 {shared_mut};
        {
            shrd_lck sl2 {shared_mut};

            print_exclusive();
        }
        print_exclusive();
    }

    try {
        exclusive_throw();
    } catch (int e) {
        cout << "Got exception " << e << 'n';
    }
    print_exclusive();
}

一个重要的细节是,在从exclusive_throw返回后,print_exclusive函数能够再次锁定互斥锁,尽管exclusive_throw由于抛出的异常而没有干净地退出。

让我们再看一下print_exclusive,因为它使用了一个奇怪的构造函数调用:

void print_exclusive()
{
    uniq_lck l {shared_mut, defer_lock};

    if (l.try_lock()) {
        // ...
    }
}

在这个过程中,我们不仅提供了shared_mut,还提供了defer_lock作为unique_lock的构造函数参数。defer_lock是一个空的全局对象,可以用来选择unique_lock的不锁定互斥锁的不同构造函数。通过这样做,我们能够稍后调用l.try_lock(),它不会阻塞。如果互斥锁已经被锁定,我们可以做其他事情。如果确实可能获得锁,我们仍然有析构函数在我们之后整理。

使用 std::scoped_lock 避免死锁

如果死锁发生在道路交通中,它们看起来会像以下情况:

为了让交通流量再次畅通,我们要么需要一台大型起重机,随机从街道交叉口中心挑选一辆汽车并将其移走。如果这不可能,那么我们需要足够多的司机合作。死锁可以通过一个方向的所有司机向后倒车几米,为其他司机继续行驶腾出空间来解决。

在多线程程序中,当然需要程序员严格避免这种情况。然而,当程序真正复杂时,很容易在这方面失败。

在这个示例中,我们将编写故意引发死锁情况的代码。然后我们将看到如何编写代码,以获取导致其他代码陷入死锁的相同资源,但使用新的 C++17 中引入的 STL 锁类std::scoped_lock来避免这个错误。

如何做...

本节的代码包含两对应该由并发线程执行的函数,它们以互斥量的形式获取两个资源。一对引发死锁,另一对避免了死锁。在主函数中,我们将尝试它们:

  1. 让我们首先包含所有需要的头文件,并声明我们使用stdchrono_literals命名空间:
      #include <iostream>
      #include <thread>
      #include <mutex>      

      using namespace std;
      using namespace chrono_literals;
  1. 然后我们实例化两个互斥对象,这是为了陷入死锁所必需的:
      mutex mut_a;
      mutex mut_b;
  1. 为了通过两个资源引发死锁,我们需要两个函数。一个函数尝试锁定互斥量 A,然后锁定互斥量 B,而另一个函数将以相反的顺序执行。通过让两个函数在锁定之间稍微休眠一会儿,我们可以确保这段代码永远在死锁上阻塞。(这是为了演示目的。如果我们重复启动程序,没有一些休眠行可能会成功运行而不会发生死锁。)

请注意,我们不使用'n'字符来打印换行,而是使用endlendl不仅执行换行,还刷新了cout的流缓冲区,因此我们可以确保打印不会被堆积和延迟:

      static void deadlock_func_1()
      {
          cout << "bad f1 acquiring mutex A..." << endl;
          lock_guard<mutex> la {mut_a};

          this_thread::sleep_for(100ms);

          cout << "bad f1 acquiring mutex B..." << endl;
          lock_guard<mutex> lb {mut_b};

          cout << "bad f1 got both mutexes." << endl;
      }
  1. 如上一步所承诺的,deadlock_func_2看起来与deadlock_func_1完全相同,但是以相反的顺序锁定了互斥量 A 和 B:
      static void deadlock_func_2()
      {
          cout << "bad f2 acquiring mutex B..." << endl;
          lock_guard<mutex> lb {mut_b};

          this_thread::sleep_for(100ms);

          cout << "bad f2 acquiring mutex A..." << endl;
          lock_guard<mutex> la {mut_a};

          cout << "bad f2 got both mutexes." << endl;
      }
  1. 现在我们编写这两个函数的无死锁变体。它们使用scoped_lock类,该类锁定我们提供为构造函数参数的所有互斥量。它的析构函数再次解锁它们。在锁定互斥量时,它内部为我们应用了死锁避免策略。请注意,这两个函数仍然以相反的顺序使用互斥量 A 和 B:
      static void sane_func_1()
      {
          scoped_lock l {mut_a, mut_b};

          cout << "sane f1 got both mutexes." << endl;
      }

      static void sane_func_2()
      {
          scoped_lock l {mut_b, mut_a};

          cout << "sane f2 got both mutexes." << endl;
      }
  1. 在主函数中,我们将通过两种情况。首先,我们在多线程环境中使用正常函数:
      int main()
      {
          {
              thread t1 {sane_func_1};
              thread t2 {sane_func_2};

              t1.join();
              t2.join();
          }
  1. 然后我们使用不使用任何死锁避免策略的引发死锁的函数:
          {
              thread t1 {deadlock_func_1};
              thread t2 {deadlock_func_2};

              t1.join();
              t2.join();
          }
      }
  1. 编译和运行程序产生以下输出。前两行显示正常锁定函数场景有效,并且两个函数都能够返回而不会永远阻塞。另外两个函数陷入了死锁。我们可以看到这是一个死锁,因为我们看到打印行告诉我们各个线程尝试锁定互斥量 A 和 B,然后永远等待。两者都没有达到成功锁定两个互斥量的地方。我们可以让这个程序运行数小时、数天和数年,什么都不会发生。

这个应用程序需要从外部终止,例如通过按下Ctrl + C键:

      $ ./avoid_deadlock 
      sane f1 got both mutexes
      sane f2 got both mutexes
      bad f2 acquiring mutex B...
      bad f1 acquiring mutex A...
      bad f1 acquiring mutex B...
      bad f2 acquiring mutex A...

它是如何工作的...

通过实现故意引发死锁的代码,我们看到了这种不希望的情况会发生得多么迅速。在一个大型项目中,多个程序员编写需要共享一组互斥保护资源的代码时,所有程序员都需要遵守相同的顺序来锁定和解锁互斥量。虽然这些策略或规则确实很容易遵循,但也很容易忘记。这个问题的另一个术语是锁定顺序倒置

scoped_lock在这种情况下真的很有帮助。它是 C++17 中的新功能,工作方式与lock_guardunique_lock相同:它的构造函数执行锁定,其析构函数执行互斥量的解锁。scoped_lock的特点是它可以使用多个互斥量来执行这个操作。

scoped_lock使用std::lock函数,该函数应用一种特殊的算法,对提供的所有互斥量执行一系列try_lock调用,以防止死锁。因此,可以完全安全地使用scoped_lock或在不同顺序下调用std::lock相同的一组锁。

同步并发使用 std::cout

在多线程程序中的一个不便之处是,我们必须实际上保护每一个它们修改的数据结构,使用互斥量或其他措施来防止不受控制的并发修改。

通常用于打印的一个数据结构是std::cout。如果多个线程同时访问cout,那么输出将以有趣的混合模式出现在终端上。为了防止这种情况,我们需要编写自己的函数,以并发安全的方式进行打印。

我们将学习如何提供一个由最少代码组成且与cout一样方便使用的cout包装器。

如何做...

在本节中,我们将实现一个程序,它可以从许多线程并发地打印到终端。为了防止消息由于并发而混乱,我们实现了一个小的辅助类,它在线程之间同步打印:

  1. 和往常一样,首先是包含:
      #include <iostream>
      #include <thread>
      #include <mutex>
      #include <sstream>
      #include <vector>      

      using namespace std;
  1. 然后我们实现我们的辅助类,我们称之为pcoutp代表parallel,因为它可以在并行上下文中同步工作。pcout公开继承自stringstream。这样我们就可以在它的实例上使用operator<<。一旦pcout实例被销毁,它的析构函数会锁定一个互斥量,然后打印stringstream缓冲区的内容。我们将在下一步中看到如何使用它:
      struct pcout : public stringstream {
          static inline mutex cout_mutex;

          ~pcout() {
              lock_guard<mutex> l {cout_mutex};
              cout << rdbuf();
              cout.flush();
          }
      };
  1. 现在让我们编写两个可以由额外线程执行的函数。两者都接受线程 ID 作为参数。它们唯一的区别是第一个简单地使用cout进行打印。另一个看起来几乎相同,但是它不直接使用cout,而是实例化pcout。这个实例是一个临时对象,只在这行代码中存在。在所有operator<<调用执行完毕后,内部字符串流被填充了我们想要打印的内容。然后调用pcout实例的析构函数。我们已经看到析构函数的作用:它锁定所有pcout实例共享的特定互斥量并进行打印:
      static void print_cout(int id)
      {
          cout << "cout hello from " << id << 'n';
      }

      static void print_pcout(int id)
      {
           pcout{} << "pcout hello from " << id << 'n';
      }
  1. 让我们试一下。首先,我们将使用print_cout,它只使用cout进行打印。我们启动 10 个线程,它们同时打印它们的字符串并等待直到它们完成:
      int main()
      {
          vector<thread> v;

          for (size_t i {0}; i < 10; ++i) {
              v.emplace_back(print_cout, i);
          }

          for (auto &t : v) { t.join(); }
  1. 然后我们用print_pcout函数做同样的事情:
          cout << "=====================n";

          v.clear();
          for (size_t i {0}; i < 10; ++i) {
              v.emplace_back(print_pcout, i);
          }

          for (auto &t : v) { t.join(); }
      }
  1. 编译和运行程序产生以下结果。正如我们所看到的,前 10 个打印完全是乱码。这就是在没有锁定的情况下并发使用cout时的情况。程序的最后 10 行是print_pcout行,没有显示任何乱码的迹象。我们可以看到它们是从不同的线程打印出来的,因为它们的顺序在每次运行程序时都是随机的:

它的工作原理...

好的,我们已经构建了这个*"cout 包装器"*,它可以自动序列化并发打印尝试。它是如何工作的呢?

让我们以手动方式执行我们的pcout辅助程序所做的相同步骤,而不使用任何魔法。首先,它实例化一个字符串流并接受我们输入的内容:

stringstream ss;
ss << "This is some printed line " << 123 << 'n';

然后它锁定一个全局可用的互斥量:

{
    lock_guard<mutex> l {cout_mutex};

在这个锁定的范围内,它访问字符串流ss的内容,打印它,然后通过离开范围释放互斥锁。cout.flush()行告诉流对象立即打印到终端。没有这一行,程序可能会运行得更快,因为多个打印行可以被捆绑在一起,并在稍后一次运行中打印。在我们的示例中,我们希望立即看到所有输出行,所以我们使用flush方法:

    cout << ss.rdbuf();
    cout.flush();
}

好的,这很简单,但如果我们不得不一遍又一遍地做同样的事情,那就太繁琐了。我们可以将stringstream的实例化缩短如下:

stringstream{} << "This is some printed line " << 123 << 'n';

这实例化了一个字符串流对象,将我们想要打印的所有内容输入其中,然后再次销毁它。字符串流的生命周期仅缩短到这一行。之后,我们无法再打印它,因为我们无法访问它。哪段代码是最后能够访问流内容的?它是stringstream的析构函数。

我们不能修改stringstream实例的成员方法,但是我们可以通过继承将自己的类型包装在它周围来扩展它们:

struct pcout : public stringstream {
    ~pcout() {
        lock_guard<mutex> l {cout_mutex};
        cout << rdbuf();
        cout.flush();
    }
};

这个类仍然是一个字符串流,我们可以像任何其他字符串流一样使用它。唯一的区别是它将锁定一个互斥锁,并使用cout打印自己的缓冲区。

我们还将cout_mutex对象移入结构pcout中作为静态实例,这样我们就可以在一个地方将它们捆绑在一起。

使用 std::call_once 安全地延迟初始化

有时我们有特定的代码部分,可以由多个线程在并行上下文中运行,但必须在执行实际函数之前执行一些设置代码。一个简单的解决方案是在程序进入可以不时执行并行代码的状态之前,只需执行现有的设置函数。

这种方法的缺点如下:

  • 如果并行函数来自库,用户不能忘记调用设置函数。这并不会使库更容易使用。

  • 如果设置函数在某种方式上是昂贵的,并且甚至可能不需要在并行函数不总是被使用的情况下执行,那么我们需要的是决定何时/是否运行它的代码。

在这个示例中,我们将看看std::call_once,它是一个帮助函数,以一种简单易用和优雅的隐式方式解决了这个问题。

如何做...

我们将编写一个程序,使用完全相同的代码启动多个线程。尽管它们被编程为执行完全相同的代码,但我们的示例设置函数只会被调用一次:

  1. 首先,我们需要包括所有必要的头文件:
      #include <iostream>
      #include <thread>
      #include <mutex>
      #include <vector>     

      using namespace std;
  1. 我们稍后将使用std::call_once。为了使用它,我们需要在某个地方有一个once_flag的实例。它用于同步所有使用call_once的线程在特定函数上:
      once_flag callflag;
  1. 必须只执行一次的函数如下。它只打印一个感叹号:
      static void once_print()
      {
          cout << '!';
      }
  1. 所有线程将执行打印函数。我们首先通过std::call_once函数调用函数once_printcall_once需要我们之前定义的变量callflag。它将用它来编排线程:
      static void print(size_t x)
      {
          std::call_once(callflag, once_print);
          cout << x;
      }
  1. 好的,现在让我们启动 10 个使用print函数的线程:
      int main()
      {
          vector<thread> v;

          for (size_t i {0}; i < 10; ++i) {
              v.emplace_back(print, i);
          }

          for (auto &t : v) { t.join(); }
          cout << 'n';
      }
  1. 编译和运行产生以下输出。首先,我们看到once_print函数的感叹号。然后我们看到所有线程 ID。call_once不仅确保once_print只被调用一次。此外,它还同步了所有线程,以便在执行once_print之前不会打印任何 ID:
      $ ./call_once
      !1239406758

工作原理...

std:call_once的工作原理类似于屏障。它维护对函数(或可调用对象)的访问。第一个到达它的线程将执行该函数。直到它完成,任何到达call_once行的其他线程都将被阻塞。在第一个线程从函数返回后,所有其他线程也将被释放。

为了组织这个小舞蹈,需要一个变量,其他线程可以从中确定它们是否必须等待,以及它们何时被释放。这就是我们的变量once_flag callflag;的作用。每个call_once行也需要一个once_flag实例作为参数,该参数在调用一次的函数之前。

另一个好处是:如果发生了这种情况,即所选用来执行call_once函数的线程失败,因为抛出了一些异常,那么下一个线程就可以再次执行该函数。希望下一次不会再抛出异常。

使用 std::async 将任务推送到后台执行

每当我们希望某些代码在后台执行时,我们可以简单地启动一个新线程来执行这些代码。在此期间,我们可以做其他事情,然后等待结果。这很简单:

std::thread t {my_function, arg1, arg2, ...};
// do something else
t.join(); // wait for thread to finish

但接下来就会出现不便:t.join()不会给我们my_function的返回值。为了得到它,我们需要编写一个调用my_function并将其返回值存储在某个变量中的函数,该变量也可以被我们启动新线程的第一个线程访问。如果这种情况反复发生,那么我们就需要一遍又一遍地编写大量样板代码。

自 C++11 以来,我们有std::async可以为我们做这个工作,不仅如此。在这个示例中,我们将编写一个简单的程序,使用异步函数调用同时执行多个任务。由于std::async比单独使用更强大,我们将更仔细地研究它的不同方面。

如何做...

我们将实现一个程序,它可以同时执行多个不同的任务,但我们不是显式地启动线程,而是使用std::asyncstd::future

  1. 首先,我们包括所有必要的头文件,并声明我们使用std命名空间:
      #include <iostream>
      #include <iomanip>
      #include <map>
      #include <string>
      #include <algorithm>
      #include <iterator>
      #include <future>      

      using namespace std;
  1. 我们实现了三个函数,它们与并行无关,但执行有趣的任务。第一个函数接受一个字符串,并创建该字符串中出现的所有字符的直方图:
      static map<char, size_t> histogram(const string &s)
      {
          map<char, size_t> m;

          for (char c : s) { m[c] += 1; }

          return m;
      }
  1. 第二个函数也接受一个字符串,并返回它的排序副本:
      static string sorted(string s)
      {
          sort(begin(s), end(s));
          return s;
      }
  1. 第三个函数计算它接受的字符串中存在多少元音字母:
      static bool is_vowel(char c)
      {
          char vowels[] {"aeiou"};
          return end(vowels) != 
                 find(begin(vowels), end(vowels), c);
      }

      static size_t vowels(const string &s)
      {
          return count_if(begin(s), end(s), is_vowel);
      }
  1. 在主函数中,我们将整个标准输入读入一个字符串。为了不将输入分割成单词,我们取消了ios::skipws。这样我们就得到了一个大字符串,无论输入包含多少空白。之后我们对结果字符串使用pop_back,因为这样我们得到了一个多余的终止''字符:
      int main()
      {
          cin.unsetf(ios::skipws);
          string input {istream_iterator<char>{cin}, {}};
          input.pop_back();
  1. 现在让我们从之前实现的所有函数中获取返回值。为了加快非常长输入的执行速度,我们异步启动它们。std::async函数接受一个策略、一个函数和该函数的参数。我们使用launch::async作为策略调用histogramsortedvowels(稍后我们将看到这意味着什么)。所有函数都以相同的输入字符串作为参数:
          auto hist        (async(launch::async, 
                                  histogram, input));
          auto sorted_str  (async(launch::async, 
                                  sorted,    input));
          auto vowel_count (async(launch::async, 
                                  vowels,    input));
  1. async调用会立即返回,因为它们实际上并不执行我们的函数。相反,它们设置了同步结构,稍后将获取函数调用的结果。结果现在正在由额外的线程并发计算。与此同时,我们可以自由地做任何我们想做的事情,因为我们可以稍后获取这些值。返回值histsorted_strvowel_count是函数histogramsortedvowels的返回类型,但它们被std::async包装在future类型中。这种类型的对象表明它们将在某个时间点包含它们的值。通过对它们所有使用.get(),我们可以使主函数阻塞,直到值到达,然后用它们进行打印:
          for (const auto &[c, count] : hist.get()) {
              cout << c << ": " << count << 'n';
          }

          cout << "Sorted string: " 
               << quoted(sorted_str.get()) << 'n'
               << "Total vowels: "  
               << vowel_count.get()        << 'n';
      }
  1. 编译和运行代码如下。我们使用一个简短的示例字符串,这并不值得并行化,但为了这个例子,代码仍然是并发执行的。此外,程序的整体结构与其天真的顺序版本相比并没有太大变化:
      $ echo "foo bar baz foobazinga" | ./async 
       : 3
      a: 4
      b: 3
      f: 2
      g: 1
      i: 1
      n: 1
      o: 4
      r: 1
      z: 2
      Sorted string: "   aaaabbbffginoooorzz"
      Total vowels: 9

它是如何工作的...

如果我们没有使用std::async,串行未并行化的代码可能看起来就像这样简单:

auto hist        (histogram(input));
auto sorted_str  (sorted(   input));
auto vowel_count (vowels(   input));

for (const auto &[c, count] : hist) {
    cout << c << ": " << count << 'n';
}
cout << "Sorted string: " << quoted(sorted_str) << 'n';
cout << "Total vowels: "  << vowel_count        << 'n';

我们为了并行化代码所做的唯一事情是将这三个函数调用包装在async(launch::async, ...)调用中。这样这三个函数就不会由我们当前运行的主线程执行。相反,async启动新线程并让它们并发执行函数。这样我们只执行启动另一个线程的开销,并且可以继续下一行代码,而所有的工作都在后台进行:

auto hist        (async(launch::async, histogram, input));
auto sorted_str  (async(launch::async, sorted,    input));
auto vowel_count (async(launch::async, vowels,    input));

for (const auto &[c, count] : hist.get()) {
    cout << c << ": " << count << 'n';
}
cout << "Sorted string: " 
     << quoted(sorted_str.get()) << 'n'
     << "Total vowels: "  
     << vowel_count.get()        << 'n';

例如,histogram返回给我们一个 map 实例,而async(..., histogram, ...)返回给我们一个被包装在future对象中的 map。这个future对象在执行histogram函数返回之前是一种空的占位符。然后将生成的 map 放入future对象中,这样我们最终可以访问它。然后get函数给我们访问封装结果的权限。

让我们看另一个最小的例子。考虑以下代码片段:

auto x (f(1, 2, 3));
cout << x;

与编写前面的代码相比,我们也可以这样做:

auto x (async(launch::async, f, 1, 2, 3));
cout << x.get();

基本上就是这样。在标准 C++中执行后台任务可能从未如此简单。还有一件事需要解决:launch::async是什么意思?launch::async是一个标志,定义了启动策略。有两个策略标志,允许三种情况:

策略选择 含义
launch::async 保证函数由另一个线程执行。
launch::deferred 函数由同一个线程执行,但稍后执行(延迟评估)。当在 future 上调用getwait时才会执行。如果两者都没有发生,函数根本不会被调用。
launch::async &#124; launch::deferred 两个标志都设置时,STL 的async实现可以自由选择要遵循哪种策略。如果没有提供策略,则这是默认选择。

通过只调用async(f, 1, 2, 3)而不带有策略参数,我们自动选择两种策略。然后,async的实现可以自由选择要使用哪种策略。这意味着我们无法确定是否启动了另一个线程,或者执行是否只是在当前线程中延迟进行。

还有更多...

确实还有最后一件事我们应该知道。假设我们编写如下代码:

async(launch::async, f);
async(launch::async, g);

这可能是为了在并发线程中执行函数fg(在这个例子中我们不关心它们的返回值),然后同时做不同的事情。在运行这样的代码时,我们会注意到代码在这些调用上阻塞,这很可能不是我们想要的。

那么为什么会阻塞呢?async不是关于非阻塞异步调用的吗?是的,但有一个特殊的特点:如果从带有launch::async策略的 async 调用中获得了一个 future,那么它的析构函数会执行阻塞等待

这意味着这个简短示例中的两个 async 调用都是阻塞的,因为它们返回的 futures 的生命周期在同一行结束!我们可以通过将它们的返回值捕获到具有更长生命周期的变量中来解决这个问题。

使用 std::condition_variable 实现生产者/消费者习语

在这个配方中,我们将实现一个具有多个线程的典型生产者/消费者程序。总体思路是有一个线程生产项目并将它们放入队列。然后有另一个线程消费这些项目。如果没有东西可以生产,生产者线程就会休眠。如果队列中没有要消费的项目,消费者就会休眠。

由于两个线程都可以访问的队列在每次生产或消费项目时都会被两者修改,因此需要通过互斥锁来保护。

另一个需要考虑的事情是:如果队列中没有项目,消费者该怎么办?它是否每秒轮询队列,直到看到新项目?这是不必要的,因为我们可以让消费者等待由生产者触发的唤醒事件,每当有新项目时。

C++11 提供了一种称为std::condition_variable的很好的数据结构,用于这种类型的事件。在这个配方中,我们将实现一个简单的生产者/消费者应用程序,利用这一点。

如何做...

我们将实现一个简单的生产者/消费者程序,其中一个线程中有一个单一的值生产者,另一个线程中有一个单一的消费者线程:

  1. 首先,我们需要执行所有需要的包含:
      #include <iostream>
      #include <queue>
      #include <tuple>
      #include <condition_variable>
      #include <thread>      

      using namespace std;
      using namespace chrono_literals;
  1. 我们实例化一个简单数值值队列,并称其为q。生产者将向其中推送值,消费者将从中取出值。为了同步两者,我们需要一个互斥锁。除此之外,我们实例化一个condition_variable cv。变量finished将是生产者告诉消费者不会再有更多值的方式:
      queue<size_t>      q;
      mutex              mut;
      condition_variable cv;
      bool               finished {false};
  1. 让我们首先实现生产者函数。它接受一个名为items的参数,限制了生产的最大项目数。在一个简单的循环中,它将为每个项目休眠 100 毫秒,这模拟了一些计算复杂性。然后我们锁定同步访问队列的互斥锁。在成功生产并插入队列后,我们调用cv.notify_all()。这个函数唤醒消费者。我们稍后会在消费者端看到这是如何工作的:
      static void producer(size_t items) {
          for (size_t i {0}; i < items; ++i) {
              this_thread::sleep_for(100ms);
              {
                  lock_guard<mutex> lk {mut};
                  q.push(i);
              }
              cv.notify_all();
          }
  1. 在生产完所有项目后,我们再次锁定互斥锁,因为我们将要更改设置finished位。然后我们再次调用cv.notify_all()
          {
              lock_guard<mutex> lk {mut};
              finished = true;
          }
          cv.notify_all();
      }
  1. 现在我们可以实现消费者函数。它不带参数,因为它会盲目地消费,直到队列为空为止。在一个循环中,只要finished没有设置,它首先会锁定保护队列和finished标志的互斥锁。一旦获得锁,它就会调用cv.wait,并将锁和 lambda 表达式作为参数。lambda 表达式是一个断言,告诉生产者线程是否仍然存活,以及队列中是否有任何东西可以消费。
      static void consumer() {
          while (!finished) {
              unique_lock<mutex> l {mut};

              cv.wait(l, [] { return !q.empty() || finished; });
  1. cv.wait调用解锁锁,并等待由断言函数描述的条件成立。然后,它再次锁定互斥锁,并从队列中消费所有东西,直到它为空。如果生产者仍然存活,它将再次迭代循环。否则,它将终止,因为finished被设置,这是生产者告知不再生产更多项目的方式:
              while (!q.empty()) {
                  cout << "Got " << q.front() 
                       << " from queue.n";
                  q.pop();
              }
          }
      }
  1. 在主函数中,我们启动一个生产者线程,它会生产 10 个项目,以及一个消费者线程。然后我们等待它们完成并终止程序:
      int main() {
          thread t1 {producer, 10};
          thread t2 {consumer};
          t1.join();
          t2.join();
          cout << "finished!n";
      }
  1. 编译和运行程序产生以下输出。当程序执行时,我们可以看到每行之间有一些时间(100 毫秒),因为生产项目需要一些时间:
      $ ./producer_consumer
      Got 0 from queue.
      Got 1 from queue.
      Got 2 from queue.
      Got 3 from queue.
      Got 4 from queue.
      Got 5 from queue.
      Got 6 from queue.
      Got 7 from queue.
      Got 8 from queue.
      Got 9 from queue.
      finished!

它是如何工作的...

在这个配方中,我们简单地启动了两个线程。第一个线程生产项目并将它们放入队列。另一个从队列中取出项目。每当这些线程中的一个以任何方式触及队列时,它都会锁定共同的互斥锁mut,这对两者都是可访问的。通过这种方式,我们确保不会发生两个线程同时操纵队列状态的情况。

除了队列和互斥锁,我们通常声明了四个与生产者-消费者相关的变量:

queue<size_t>      q;
mutex              mut;
condition_variable cv;
bool               finished {false};

变量finished很容易解释。当生产者完成生产固定数量的物品时,它被设置为true。当消费者看到这个变量为true时,它会消耗队列中的最后物品并停止消耗。但condition_variable cv是用来做什么的?我们在两个不同的上下文中使用了cv。一个上下文是等待特定条件,另一个是发出该条件的信号

等待特定条件的消费者端看起来像这样。消费者线程循环执行一个块,首先在unique_lock中锁定互斥锁mut。然后调用cv.wait

while (!finished) {
    unique_lock<mutex> l {mut};

 cv.wait(l, [] { return !q.empty() || finished; });

    while (!q.empty()) {
        // consume
    }
}

这段代码在某种程度上等同于以下替代代码。我们很快会详细说明为什么它实际上并不相同:

while (!finished) {
    unique_lock<mutex> l {mut};

 while (q.empty() && !finished) {
 l.unlock();
 l.lock();
 }

    while (!q.empty()) {
        // consume
    }
}

这意味着通常我们首先获取锁,然后检查我们的情况是什么:

  1. 有可消耗的物品吗?那么保持锁定,消耗,释放锁定,然后重新开始。

  2. 否则,如果没有可消耗的物品,但生产者仍然活着,则释放互斥锁以使生产者有机会向队列中添加物品。然后,尝试再次锁定它,希望情况会改变,我们会看到情况 1。

cv.wait行不等同于while (q.empty() && ... )构造的真正原因是,我们不能简单地循环执行l.unlock(); l.lock();。如果生产者线程在某段时间内处于非活动状态,那么这将导致互斥锁的持续锁定和解锁,这是没有意义的,因为这会不必要地消耗 CPU 周期。

cv.wait(lock, predicate)这样的表达式将等待,直到predicate()返回true。但它并不是通过不断解锁和锁定lock来做到这一点的。为了唤醒一个在condition_variable对象的wait调用上阻塞的线程,另一个线程必须在同一对象上调用notify_one()notify_all()方法。只有这样,等待的线程才会被唤醒,以检查predicate()是否成立。

wait调用检查谓词的好处是,如果有虚假唤醒调用,线程将立即再次进入睡眠状态。这意味着如果我们有太多的通知调用,它并不会真正影响程序流程(但可能会影响性能)。

在生产者端,我们在生产者将物品插入队列后和生产者生产最后一个物品并将finished标志设置为true后,只需调用cv.notify_all()。这足以引导消费者。

使用 std::condition_variable 实现多个生产者/消费者习语

让我们从上一个示例中解决生产者/消费者问题,并使其变得更加复杂:我们让多个生产者生产物品,多个消费者消耗它们。除此之外,我们定义队列不得超过最大大小。

这种方式不仅消费者必须不时休眠,如果队列中没有物品,生产者也必须不时休眠,当队列中有足够的物品时。

我们将看到如何使用多个std::condition_variable对象解决此问题,并且还将以与上一个示例略有不同的方式使用它们。

如何做...

在本节中,我们将实现一个程序,就像在上一个示例中一样,但这次有多个生产者和多个消费者:

  1. 首先,我们需要包含所有需要的标头,并声明我们使用stdchrono_literals命名空间:
      #include <iostream>
      #include <iomanip>
      #include <sstream>
      #include <vector>
      #include <queue>
      #include <thread>
      #include <mutex>
      #include <condition_variable>
      #include <chrono>     

      using namespace std;
      using namespace chrono_literals;
  1. 然后我们在本章的另一个示例中实现了同步打印助手,因为我们将进行大量并发打印:
      struct pcout : public stringstream {
          static inline mutex cout_mutex;
          ~pcout() {
              lock_guard<mutex> l {cout_mutex};
              cout << rdbuf();
          }
      };
  1. 所有生产者将值写入同一个队列,所有消费者也将从该队列中取出值。除了该队列,我们还需要一个保护队列和一个标志的互斥锁,该标志可以告诉我们生产是否在某个时刻停止:
      queue<size_t> q;
      mutex         q_mutex; 
      bool          production_stopped {false};
  1. 在这个程序中,我们将使用两个不同的condition_variables。在单个生产者/消费者配方中,我们有一个condition_variable告诉队列中有新物品。在这种情况下,我们要复杂一点。我们希望生产者生产,直到队列包含一定数量的库存物品。如果达到了库存量,它们将休眠。这样go_consume变量可以用来唤醒消费者,然后消费者可以再次用go_produce变量唤醒生产者:
      condition_variable go_produce;
      condition_variable go_consume;
  1. 生产者函数接受生产者 ID 号、要生产的物品总数和库存限制作为参数。然后它进入自己的生产循环。在那里,它首先锁定队列的互斥量,并在go_produce.wait调用中再次解锁。它等待队列大小低于stock阈值的条件:
      static void producer(size_t id, size_t items, size_t stock)
      {
          for (size_t i = 0; i < items; ++i) {
              unique_lock<mutex> lock(q_mutex);
              go_produce.wait(lock, 
                  [&] { return q.size() < stock; });
  1. 生产者被唤醒后,它生产一个物品并将其推入队列。队列值是从表达式id * 100 + i计算出来的。这样我们以后可以看到哪个生产者生产了它,因为数字中的百位数是生产者 ID。我们还将生产事件打印到终端。打印的格式可能看起来奇怪,但它将与终端中的消费者输出很好地对齐:
              q.push(id * 100 + i);

              pcout{} << "   Producer " << id << " --> item "
                      << setw(3) << q.back() << 'n';
  1. 生产后,我们可以唤醒正在睡眠的消费者。90 毫秒的睡眠时间模拟了生产物品需要一些时间:
              go_consume.notify_all();
              this_thread::sleep_for(90ms);
           }

           pcout{} << "EXIT: Producer " << id << 'n';
      }
  1. 现在是消费者函数,它只接受一个消费者 ID 作为参数。如果生产还没有停止,或者队列不为空,它将继续等待物品。如果队列为空,但生产还没有停止,那么可能很快会有新的物品:
      static void consumer(size_t id)
      {
           while (!production_stopped || !q.empty()) {
               unique_lock<mutex> lock(q_mutex);
  1. 在锁定队列互斥量后,我们再次解锁它,以便在go_consume事件变量上等待。lambda 表达式参数描述了我们希望在队列包含物品时从等待调用中返回。第二个参数1s表示我们不想永远等待。如果超过 1 秒,我们希望退出等待函数。我们可以区分wait_for函数返回的原因,因为谓词条件成立,或者因为超时而退出,因为在超时的情况下它将返回false。如果队列中有新物品,我们会消耗它们并将此事件打印到终端:
               if (go_consume.wait_for(lock, 1s, 
                       [] { return !q.empty(); })) {
                   pcout{} << "                  item "
                           << setw(3) << q.front() 
                           << " --> Consumer "
                           << id << 'n';
                   q.pop();
  1. 在物品消耗后,我们通知生产者并睡眠 130 毫秒,以模拟消耗物品也需要时间:
                  go_produce.notify_all();
                  this_thread::sleep_for(130ms);
              }
          }

          pcout{} << "EXIT: Producer " << id << 'n';
      }
  1. 在主函数中,我们为工作线程实例化一个向量,另一个为消费者线程:
      int main()
      {
          vector<thread> workers;
          vector<thread> consumers;
  1. 然后我们生成三个生产者线程和五个消费者线程:
          for (size_t i = 0; i < 3; ++i) {
              workers.emplace_back(producer, i, 15, 5);
          }

          for (size_t i = 0; i < 5; ++i) {
              consumers.emplace_back(consumer, i);
          }
  1. 首先让生产者线程完成。一旦它们全部返回,我们设置production_stopped标志,这将导致消费者也完成。我们需要收集它们,然后我们可以退出程序:
          for (auto &t : workers)   { t.join(); }
          production_stopped = true;
          for (auto &t : consumers) { t.join(); }
      }
  1. 编译和运行程序会产生以下输出。输出非常长,这就是为什么在这里进行截断。我们可以看到生产者不时进入睡眠状态,让消费者吃掉一些物品,直到它们最终再次生产。改变生产者/消费者的等待时间以及操纵生产者/消费者和库存物品的数量是很有趣的,因为这完全改变了输出模式:
      $ ./multi_producer_consumer
         Producer 0 --> item   0
         Producer 1 --> item 100
                        item   0 --> Consumer 0
         Producer 2 --> item 200
                        item 100 --> Consumer 1
                        item 200 --> Consumer 2
         Producer 0 --> item   1
         Producer 1 --> item 101
                        item   1 --> Consumer 0
      ...
         Producer 0 --> item  14
      EXIT: Producer 0
         Producer 1 --> item 114
      EXIT: Producer 1
                        item  14 --> Consumer 0
         Producer 2 --> item 214
      EXIT: Producer 2
                        item 114 --> Consumer 1
                        item 214 --> Consumer 2
      EXIT: Consumer 2
      EXIT: Consumer 3
      EXIT: Consumer 4
      EXIT: Consumer 0
      EXIT: Consumer 1

工作原理...

这个配方是前一个配方的扩展。我们不仅同步一个生产者和一个消费者,而是实现了一个同步M个生产者和N个消费者的程序。除此之外,如果没有物品留给消费者,不仅消费者会进入睡眠状态,一旦物品队列变得太长,生产者也会进入睡眠状态。

当多个消费者等待相同的队列填满时,这通常也适用于一个生产者/一个消费者场景中的消费者代码。只要只有一个线程锁定保护队列的互斥锁,然后从中取出项目,代码就是安全的。无论有多少线程同时等待锁定,都无关紧要。生产者也是如此,因为在这两种情况下,唯一重要的是队列永远不会被多个线程同时访问。

这个程序之所以比只有一个生产者/一个消费者的例子更复杂,是因为我们让生产者线程在项目队列长度达到一定阈值时停止。为了满足这一要求,我们实现了两个不同的信号,它们各自拥有自己的condition_variable

  1. go_produce信号着事件,队列没有完全填满到最大,生产者可以再次填满它。

  2. go_consume信号着事件,队列达到最大长度,消费者可以再次消费项目。

这样,生产者将项目填入队列,并向消费者线程发出go_consume事件的信号,消费者线程在以下行上等待:

if (go_consume.wait_for(lock, 1s, [] { return !q.empty(); })) {
    // got the event without timeout
}

另一方面,生产者会在以下行上等待,直到被允许再次生产:

go_produce.wait(lock, [&] { return q.size() < stock; });

一个有趣的细节是,我们不让消费者永远等待。在go_consume.wait_for调用中,我们额外添加了一个 1 秒的超时参数。这是消费者的退出机制:如果队列空闲超过一秒,可能没有活跃的生产者了。

为了简化起见,代码试图始终将队列长度保持在最大值。更复杂的程序可以让消费者线程在队列只有其最大长度的一半时推送唤醒通知,只有在队列中的项目数量仍然足够时,生产者才会被提前唤醒。这样,生产者就不会在队列中仍有足够的项目时被不必要地提前唤醒。

condition_variable为我们优雅地解决了以下情况:如果消费者触发了go_produce通知,可能会有一大群生产者竞相生产下一个项目。如果只缺一个项目,那么只会有一个生产者生产它。如果所有生产者在go_produce事件触发后总是立即生产一个项目,我们经常会看到队列填满到允许的最大值以上的情况。

假设我们的队列中有(max - 1)个项目,并且希望生产一个新项目,以便再次填满队列。无论消费者线程调用go_produce.notify_one()(只唤醒一个等待线程)还是go_produce.notify_all()(唤醒所有等待线程),我们保证只有一个生产者线程会退出go_produce.wait调用,因为对于所有其他生产者线程来说,一旦它们在被唤醒后获得互斥锁,q.size() < stock等待条件就不再成立。

使用 std::async 并行化 ASCII Mandelbrot 渲染器

还记得第二十三章中的ASCII Mandelbrot 渲染器吗,STL 算法的高级用法?在这个配方中,我们将使用线程来加速其计算时间。

首先,我们将修改原始程序中限制每个选定坐标迭代次数的行。这将使程序变慢,其结果比我们实际上可以在终端上显示的更准确,但这样我们就有了一个很好的并行化目标。

然后,我们将对程序进行轻微修改,看整个程序如何运行得更快。在这些修改之后,程序将使用std::asyncstd::future运行。为了充分理解这个配方,理解原始程序是至关重要的。

如何做...

在这一部分,我们将采用我们在第二十三章中实现的 ASCII Mandelbrot 分形渲染器,STL 算法的高级用法。首先,我们将通过增加计算限制来使计算时间更长。然后,我们通过对程序进行四处小改动来实现加速,以便并行化:

  1. 为了跟随这些步骤,最好是直接从其他的配方中复制整个程序。然后按照以下步骤的说明进行所有必要的调整。所有与原始程序的不同之处都用加粗标出。

第一个变化是一个额外的头文件,<future>:

      #include <iostream>
      #include <algorithm>
      #include <iterator>
      #include <complex>
      #include <numeric>
      #include <vector>
 #include <future>      

      using namespace std;
  1. scalerscaled_cmplx函数不需要任何更改:
      using cmplx = complex<double>;

      static auto scaler(int min_from, int max_from,
                         double min_to, double max_to)
      {
          const int w_from {max_from - min_from};
          const double w_to {max_to - min_to};
          const int mid_from {(max_from - min_from) / 2 + min_from};
          const double mid_to {(max_to - min_to) / 2.0 + min_to};

          return [=] (int from) {
              return double(from - mid_from) / w_from * w_to + mid_to;
          };
      }

      template <typename A, typename B>
      static auto scaled_cmplx(A scaler_x, B scaler_y)
      {
          return = {
              return cmplx{scaler_x(x), scaler_y(y)};
          };
      }
  1. 在函数mandelbrot_iterations中,我们只是增加迭代次数,以使程序更加计算密集:
      static auto mandelbrot_iterations(cmplx c)
      {
          cmplx z {};
          size_t iterations {0};
          const size_t max_iterations {100000};
          while (abs(z) < 2 && iterations < max_iterations) {
              ++iterations;
              z = pow(z, 2) + c;
          }
          return iterations;
      }
  1. 然后我们有一个主函数的一部分,它再次不需要任何更改:
      int main()
      {
          const size_t w {100};
          const size_t h {40};

          auto scale (scaled_cmplx(
              scaler(0, w, -2.0, 1.0),
              scaler(0, h, -1.0, 1.0)
          ));

          auto i_to_xy (= { 
              return scale(x % w, x / w); 
          });
  1. to_iteration_count函数中,我们不再直接调用mandelbrot_iterations(x_to_xy(x)),而是使用std::async异步调用:
          auto to_iteration_count (= {
              return async(launch::async,
 mandelbrot_iterations, i_to_xy(x));
          });
  1. 在最后一个变化之前,函数to_iteration_count返回了特定坐标需要 Mandelbrot 算法收敛的迭代次数。现在它返回一个将来会包含相同值的future变量,因为它是异步计算的。因此,我们需要一个保存所有未来值的向量,所以让我们添加一个。我们提供给transform的输出迭代器作为新输出向量r的起始迭代器:
          vector<int> v (w * h);
 vector<future<size_t>> r (w * h);
          iota(begin(v), end(v), 0);
          transform(begin(v), end(v), begin(r), 
                    to_iteration_count);
  1. accumulate调用不再接受size_t值作为第二个参数,而是future<size_t>值。我们需要调整为这种类型(如果一开始就使用auto&作为它的类型,那么这甚至是不必要的),然后我们需要调用x.get()来等待值的到来,而不是之前直接访问x
          auto binfunc ([w, n{0}] (auto output_it, future<size_t> &x) 
                  mutable {
              *++output_it = (x.get() > 50 ? '*' : ' ');
              if (++n % w == 0) { ++output_it = 'n'; }
              return output_it;
          });

          accumulate(begin(r), end(r), 
                     ostream_iterator<char>{cout}, binfunc);
      }
  1. 编译和运行给我们与以前相同的输出。唯一有趣的区别是执行速度。如果我们也增加原始版本程序的迭代次数,那么并行化版本应该计算得更快。在我的计算机上,有四个 CPU 核心和超线程(导致 8 个虚拟核心),我用 GCC 和 clang 得到了不同的结果。最佳加速比为5.3,最差为3.8。当然,结果在不同的机器上也会有所不同。

工作原理...

首先要理解整个程序,因为这样就清楚了所有的 CPU 密集型工作都发生在主函数的一行代码中:

transform(begin(v), end(v), begin(r), to_iteration_count);

向量v包含了所有映射到复坐标的索引,然后用 Mandelbrot 算法迭代。每次迭代的结果都保存在向量r中。

在原始程序中,这是消耗所有处理时间来计算分形图像的单行代码。它之前的所有代码都是设置工作,之后的所有代码都是用于打印。这意味着并行化这一行是提高性能的关键。

并行化这个问题的一种可能方法是将从begin(v)end(v)的整个线性范围分成相同大小的块,并均匀分配到所有核心上。这样所有核心将共享工作量。如果我们使用带有并行执行策略的std::transform的并行版本,这将正好是这种情况。不幸的是,这不是这个问题的正确策略,因为 Mandelbrot 集中的每个点都显示出非常独特的迭代次数。

我们的方法是使每个单独的向量项(代表终端上的单个打印字符单元)成为一个异步计算的future值。由于源向量和目标向量都有w * h个项,也就是在我们的情况下是100 * 40,我们有一个包含 4000 个异步计算值的向量。如果我们的系统有 4000 个 CPU 核心,那么这意味着我们启动了 4000 个线程,这些线程真正同时进行 Mandelbrot 迭代。在具有较少核心的普通系统上,CPU 将依次处理每个核心的一个异步项。

transform调用与to_iteration_count的异步版本本身不进行计算,而是设置线程和 future 对象,它几乎立即返回。程序的原始版本在这一点上被阻塞,因为迭代时间太长。

程序的并行化版本当然也会在某个地方阻塞。在终端上打印所有值的函数必须访问 future 中的结果。为了做到这一点,它对所有值调用x.get()。这就是诀窍:当它等待第一个值被打印时,很多其他值同时被计算。因此,如果第一个 future 的get()调用返回,下一个 future 可能已经准备好打印了!

如果w * h的结果是非常大的数字,那么创建和同步所有这些 future 将会有一些可测量的开销。在这种情况下,开销并不太显著。在我的笔记本电脑上,配备了一颗 Intel i7 处理器,有 4 个超线程核心(结果是八个虚拟核心),与原始程序相比,这个程序的并行版本运行速度快了 3-5 倍。理想的并行化会使其快 8 倍。当然,这种加速会因不同的计算机而异,因为它取决于很多因素。

使用 std::future 实现一个微小的自动并行化库

大多数复杂的任务可以分解成子任务。从所有子任务中,我们可以绘制一个描述哪个子任务依赖于其他子任务以完成更高级任务的有向无环图DAG)。例如,假设我们想要生成字符串"foo bar foo bar this that ",我们只能通过创建单词并将其与其他单词或自身连接来实现。假设这个功能由三个基本函数createconcattwice提供。

考虑到这一点,我们可以绘制以下 DAG,以可视化它们之间的依赖关系,以便获得最终结果:

在代码中实现这一点时,很明显一切都可以在一个 CPU 核心上以串行方式实现。或者,所有不依赖其他子任务或其他已经完成的子任务的子任务可以在多个 CPU 核心上并发执行。

编写这样的代码可能看起来有点乏味,即使使用std::async,因为子任务之间的依赖关系需要被建模。在本配方中,我们将实现两个小型库辅助函数,帮助将普通函数createconcattwice转换为异步工作的函数。有了这些,我们将找到一种真正优雅的方式来设置依赖图。在执行过程中,图将以一种看似智能的方式并行化,以尽可能快地计算结果。

如何做...

在本节中,我们将实现一些函数,模拟相互依赖的计算密集型任务,并让它们尽可能并行运行:

  1. 让我们首先包含所有必要的头文件:
      #include <iostream>
      #include <iomanip>
      #include <thread>
      #include <string>
      #include <sstream>
      #include <future>      

      using namespace std;
      using namespace chrono_literals;
  1. 我们需要同步对cout的并发访问,因此让我们使用本章其他配方中的同步助手:
      struct pcout : public stringstream {
          static inline mutex cout_mutex;

          ~pcout() {
              lock_guard<mutex> l {cout_mutex};
              cout << rdbuf();
              cout.flush();
          }
      };
  1. 现在让我们实现三个转换字符串的函数。第一个函数将从 C 字符串创建一个std::string对象。我们让它休眠 3 秒来模拟字符串创建是计算密集型的:
      static string create(const char *s)
      {
          pcout{} << "3s CREATE " << quoted(s) << 'n';
          this_thread::sleep_for(3s);
          return {s};
      }
  1. 下一个函数接受两个字符串对象作为参数并返回它们的连接。我们给它 5 秒的等待时间来模拟这是一个耗时的任务:
      static string concat(const string &a, const string &b)
      {
          pcout{} << "5s CONCAT "
                  << quoted(a) << " "
                  << quoted(b) << 'n';
          this_thread::sleep_for(5s);
          return a + b;
      }
  1. 最后一个计算密集型的函数接受一个字符串并将其与自身连接。这将花费 3 秒的时间:
      static string twice(const string &s)
      {
          pcout{} << "3s TWICE " << quoted(s) << 'n';
          this_thread::sleep_for(3s);
          return s + s;
      }
  1. 现在我们已经可以在串行程序中使用这些函数了,但我们想要实现一些优雅的自动并行化。所以让我们为此实现一些辅助函数。请注意,接下来的三个函数看起来非常复杂。asynchronize接受一个函数f并返回一个可调用对象来捕获它。我们可以用任意数量的参数调用这个可调用对象,然后它将这些参数与f一起捕获在另一个可调用对象中返回给我们。然后可以无需参数调用这个最后的可调用对象。它会异步地使用捕获的所有参数调用f
      template <typename F>
      static auto asynchronize(F f)
      {
          return f {
              return [=] () {
                  return async(launch::async, f, xs...);
              };
          };
      }
  1. 下一个函数将被我们在下一步中声明的函数使用。它接受一个函数f,并将其捕获在一个可调用对象中返回。该对象可以用一些 future 对象调用。然后它将在所有的 future 上调用.get(),对它们应用f,并返回其结果:
      template <typename F>
      static auto fut_unwrap(F f)
      {
          return f {
              return f(xs.get()...);
          };
      }
  1. 最后一个辅助函数也接受一个函数f。它返回一个可调用对象来捕获f。这个可调用对象可以用任意数量的可调用对象作为参数调用,然后它将这些与f一起捕获在另一个可调用对象中返回。然后可以无需参数调用这个最后的可调用对象。它会异步地调用捕获在xs...包中的所有可调用对象。这些将返回 future,需要使用fut_unwrap来解开。未来的解开和对来自未来的实际值应用真实函数f的实际应用再次使用std::async异步进行:
      template <typename F>
      static auto async_adapter(F f)
      {
          return f {
              return [=] () {
                  return async(launch::async, 
                               fut_unwrap(f), xs()...);
              };
          };
      }
  1. 好的,这可能有点疯狂,有点像电影《盗梦空间》,因为它使用了返回 lambda 表达式的 lambda 表达式。我们稍后会对这段代码进行非常详细的解释。现在让我们将函数createconcattwice改为异步的。函数async_adapter使一个完全正常的函数等待未来的参数并返回未来的结果。它是一种从同步到异步世界的翻译包装。我们将其应用于concattwice。我们必须在create上使用asynchronize,因为它应该返回一个 future,但我们将用实际值而不是 future 来提供它。任务依赖链必须从create调用开始:
      int main()
      {
          auto pcreate (asynchronize(create));
          auto pconcat (async_adapter(concat));
          auto ptwice  (async_adapter(twice));
  1. 现在我们有了自动并行化的函数,它们的名称与原来的同步函数相同,但带有p前缀。现在让我们建立一个复杂的依赖树示例。首先,我们创建字符串"foo ""bar ",然后立即将它们连接成"foo bar "。然后使用twice将这个字符串与自身连接。然后我们创建字符串"this ""that ",将它们连接成"this that "。最后,我们将结果连接成"foo bar foo bar this that "。结果将保存在变量callable中。然后最后调用callable().get()来开始计算并等待其返回值,以便打印出来。在调用callable()之前不会进行任何计算,而在此调用之后,所有的魔法就开始了:
          auto result (
              pconcat(
                  ptwice(
                      pconcat(
                          pcreate("foo "),
                          pcreate("bar "))),
                  pconcat(
                      pcreate("this "),
                      pcreate("that "))));

          cout << "Setup done. Nothing executed yet.n";

          cout << result().get() << 'n';
      }
  1. 编译和运行程序显示所有create调用同时执行,然后执行其他调用。看起来它们被智能地调度了。整个程序运行了 16 秒。如果步骤不是并行执行的,完成需要 30 秒。请注意,我们需要至少四个 CPU 核心的系统才能同时执行所有create调用。如果系统的 CPU 核心较少,那么一些调用将不得不共享 CPU,这当然会消耗更多时间。
      $ ./chains 
      Setup done. Nothing executed yet.
      3s CREATE "foo "
      3s CREATE "bar "
      3s CREATE "this "
      3s CREATE "that "
      5s CONCAT "this " "that "
      5s CONCAT "foo " "bar "
      3s TWICE  "foo bar "
      5s CONCAT "foo bar foo bar " "this that "
      foo bar foo bar this that

它是如何工作的...

这个程序的普通串行版本,没有任何asyncfuture魔法,看起来像这样:

int main()
{
    string result {
        concat(
            twice(
                concat(
                    create("foo "),
                    create("bar "))),
            concat(
                create("this "),
                create("that "))) };

    cout << result << 'n';
}

在这个示例中,我们编写了辅助函数async_adapterasynchronize,帮助我们从createconcattwice创建新函数。我们称这些新的异步函数为pcreatepconcatptwice

让我们首先忽略async_adapterasynchronize的实现复杂性,先看看这给我们带来了什么。

串行版本看起来类似于这段代码:

string result {concat( ... )};
cout << result << 'n';

并行化版本看起来类似于以下内容:

auto result (pconcat( ... ));
cout << result().get() << 'n';

好的,现在我们来到了复杂的部分。并行化结果的类型不是string,而是一个返回future<string>的可调用对象,我们可以在其上调用get()。这一开始可能看起来很疯狂。

那么,我们到底是如何以及为什么最终得到了返回 future 的可调用对象?我们的createconcattwice方法的问题在于它们很。(好吧,我们人为地让它们变慢,因为我们试图模拟消耗大量 CPU 时间的真实任务)。但我们发现描述数据流的依赖树有独立的部分可以并行执行。让我们看两个示例调度:

在左侧,我们看到一个单核调度。所有函数调用必须一个接一个地执行,因为我们只有一个 CPU。这意味着,当create花费 3 秒,concat花费 5 秒,twice花费 3 秒时,获取最终结果需要 30 秒。

在右侧,我们看到一个并行调度,在这个调度中尽可能多地并行执行函数调用之间的依赖关系。在一个理想的拥有四个核心的世界中,我们可以同时创建所有子字符串,然后连接它们等等。以最佳并行调度获得结果的最短时间是 16 秒。如果函数调用本身不能更快,我们就无法更快地进行。只有四个 CPU 核心,我们就可以实现这个执行时间。我们可以明显地实现了最佳调度。这是如何实现的?

我们可以天真地写下以下代码:

auto a (async(launch::async, create, "foo "));
auto b (async(launch::async, create, "bar "));
auto c (async(launch::async, create, "this "));
auto d (async(launch::async, create, "that "));
auto e (async(launch::async, concat, a.get(), b.get()));
auto f (async(launch::async, concat, c.get(), d.get()));
auto g (async(launch::async, twice, e.get()));
auto h (async(launch::async, concat, g.get(), f.get()));

这是abcd的良好起点,它们代表了最初的四个子字符串。这些都是在后台异步创建的。不幸的是,这段代码在初始化e的地方阻塞了。为了连接ab,我们需要在它们两个上调用get(),这会阻塞直到这些值准备好。显然,这不是一个好主意,因为并行化在第一次get()调用时就停止了。我们需要一个更好的策略。

好的,让我们先展开我们编写的复杂辅助函数。第一个是asynchronize

template <typename F>
static auto asynchronize(F f)
{
    return f {
        return [=] () {
            return async(launch::async, f, xs...);
        };
    };
}

当我们有一个函数int f(int, int)时,我们可以这样做:

auto f2 ( asynchronize(f) );
auto f3 ( f2(1, 2) );
auto f4 ( f3() );
int result { f4.get() };

f2是我们的f的异步版本。它可以用与f相同的参数调用,因为它模仿f。然后它返回一个可调用对象,我们将其保存在f3中。f3现在捕获了f和参数1, 2,但它还没有调用任何东西。这只是关于捕获的。

当我们现在调用f3()时,最终我们得到一个 future,因为f3()执行了async(launch::async, **f, 1, 2**);调用!从这个意义上说,f3的语义意思是“取得捕获的函数和参数,然后将它们一起抛到std::async”。

不接受任何参数的内部 lambda 表达式给了我们一个间接引用。有了它,我们可以为并行调度设置工作,但不必调用任何阻塞的东西,至少目前还不用。我们在更复杂的函数async_adapter中也遵循相同的原则:

template <typename F>
static auto async_adapter(F f)
{
    return f {
        return [=] () {
            return async(launch::async, fut_unwrap(f), xs()...);
        };
    };
}

这个函数首先返回一个模仿f的函数,因为它接受相同的参数。然后该函数返回一个可调用对象,再次不接受任何参数。然后这个可调用对象最终与其他辅助函数不同。

async(launch::async, fut_unwrap(f), xs()...);这一行是什么意思?xs()...部分意味着,保存在包xs中的所有参数都被假定为可调用对象(就像我们一直在创建的那些对象!),因此它们都被调用而不带参数。我们一直在产生的这些可调用对象本身产生未来值,我们可以在其上调用get()。这就是fut_unwrap发挥作用的地方:

template <typename F>
static auto fut_unwrap(F f)
{
    return f {
        return f(xs.get()...);
    };
}

fut_unwrap只是将函数f转换为一个接受一系列参数的函数对象。然后这个函数对象调用.get(),最后将它们转发给f

慢慢消化这一切。当我们在主函数中使用时,auto result (pconcat(...));调用链只是构造了一个包含所有函数和所有参数的大型可调用对象。此时还没有进行任何async调用。然后,当我们调用result()时,我们释放了一小波async.get()调用,它们恰好按照正确的顺序来避免相互阻塞。事实上,在所有async调用都已经分派之前,没有get()调用会发生。

最后,我们终于可以在result()返回的未来值上调用.get(),然后我们就得到了我们的最终字符串。

第三十章:文件系统

在本章中,我们将涵盖以下内容:

  • 实施路径标准化

  • 从相对路径获取规范文件路径

  • 列出目录中的所有文件

  • 实施类似 grep 的文本搜索工具

  • 实施自动文件重命名工具

  • 实施磁盘使用量计数器

  • 计算文件类型的统计信息

  • 实施通过用符号链接替换重复项来减小文件夹大小的工具

介绍

如果没有一个帮助我们的库,处理文件系统路径总是很繁琐,因为有许多条件需要我们处理。

有些路径是绝对的,有些是相对的,也许它们甚至不是直接的,因为它们还包含.(当前目录)和..(父目录)的间接。同时,不同的操作系统使用斜杠/来分隔目录(Linux、MacOS 和不同的 UNIX 衍生版本),或者反斜杠(Windows)。当然还有不同类型的文件。

由于处理与文件系统相关的其他程序都需要这样的功能,因此在 C++17 STL 中拥有新的文件系统库是非常好的。最好的一点是,它对不同的操作系统都是以相同的方式工作,因此我们不必为支持不同操作系统的程序版本编写不同的代码。

在本章中,我们将首先看到path类的工作原理,因为它对于这个库中的任何其他内容都是最核心的。然后,我们将看到directory_iteratorrecursive_directory_iterator类是多么强大但又简单易用,同时我们会对文件进行一些有用的操作。最后,我们将使用一些小而简单的示例工具,执行一些与文件系统相关的真实任务。从这一点开始,构建更复杂的工具将变得容易。

实施路径标准化

我们将本章以围绕std::filesystem::path类和一个智能规范化文件系统路径的辅助函数的非常简单的示例开始。

这个示例的结果是一个小应用程序,它接受任何文件系统路径,并以规范化形式返回相同的路径。规范化意味着我们得到一个不包含...路径间接的绝对路径。

在实施这一点的同时,我们还将看到在处理文件系统库的这个基本部分时需要注意哪些细节。

如何做...

在本节中,我们将实现一个程序,它只接受文件系统路径作为命令行参数,然后以规范化形式打印出来。

  1. 首先是包含,然后我们声明使用stdfilesystem命名空间。
      #include <iostream>
      #include <filesystem>      

      using namespace std;
      using namespace filesystem;
  1. 在主函数中,我们检查用户是否提供了命令行参数。如果没有,我们就会报错并打印如何使用程序。如果提供了路径,我们就会从中实例化一个filesystem::path对象。
      int main(int argc, char *argv[])
      {
          if (argc != 2) {
              cout << "Usage: " << argv[0] << " <path>n";
              return 1;
          }

          const path dir {argv[1]};
  1. 由于我们可以从任何字符串实例化path对象,我们不能确定路径是否真的存在于计算机的文件系统中。为了做到这一点,我们可以使用filesystem::exists函数。如果不存在,我们就会再次报错。
          if (!exists(dir)) {
              cout << "Path " << dir << " does not exist.n";
              return 1;
          }
  1. 好的,在这一点上,我们非常确定用户提供了一条现有路径,知道我们可以要求其规范化版本,然后我们打印出来。filesystem::canonical会返回另一个path对象。我们可以直接打印它,但path类型重载的<<运算符会用引号括起路径。为了避免这种情况,我们可以通过其.c_str().string()方法打印路径。
          cout << canonical(dir).c_str() << 'n';
      }
  1. 让我们编译程序并与之交互。当我们在我的家目录中执行它,使用相对路径"src",它将打印出完整的绝对路径。
      $ ./normalizer src
      /Users/tfc/src
  1. 当我们再次在我的家目录中运行程序,但给它一个古怪的相对路径描述,首先进入我的Desktop文件夹,然后再次使用..退出它,然后进入Documents文件夹并再次退出,最后进入src目录,程序打印出相同的路径!
      $ ./normalizer Desktop/../Documents/../src
      /Users/tfc/src

它是如何工作的...

作为std::filesystem的入门,这个示例仍然相当简短和直接。我们从包含文件系统路径描述的字符串初始化了一个path对象。std::filesystem::path类在我们使用文件系统库时扮演着非常重要的角色,因为大多数函数和类都与它相关。

使用filesystem::exists函数,我们能够检查路径是否真的存在。在那之前,我们不能确定,因为确实可能创建与现有文件系统对象无关的path对象。exists只接受一个path实例,并在它真的存在时返回true。该函数已经能够自行确定我们给它一个绝对路径还是相对路径,这使得它非常方便使用。

最后,我们使用filesystem::canonical在目录上,以便以规范化的形式打印它。

path canonical(const path& p, const path& base = current_path());

canonical接受一个路径,并作为可选的第二个参数,它接受另一个路径。如果p是一个相对路径,第二个路径base将被添加到路径p之前。在这样做之后,canonical会尝试移除任何...路径指示。

在打印时,我们在规范化的路径上使用了.c_str()方法。这样做的原因是,对于输出流的operator<<的重载会用引号括起路径,而我们并不总是想要这样。

还有更多...

如果我们要规范化的路径不存在,canonical会抛出一个filesystem_error类型的异常。为了防止这种情况,我们用exists检查了我们的文件系统路径。但是那个检查真的足以避免出现未处理的异常吗?不是。

existscanonical都可以抛出bad_alloc异常。如果遇到这些异常,有人可能会认为程序无论如何都要失败。一个更为严重,也更为可能的问题是,当我们检查文件是否存在并对其进行规范化之间,其他人重命名或删除了底层文件!在这种情况下,canonical会抛出一个filesystem_error,尽管我们之前检查了文件的存在。

大多数文件系统函数都有一个额外的重载,它接受相同的参数,但还有一个std::error_code引用。

path canonical(const path& p, const path& base = current_path());
path canonical(const path& p, error_code& ec);
path canonical(const std::filesystem::path& p,
               const std::filesystem::path& base,
               std::error_code& ec );

这样我们可以选择是否用try-catch结构包围我们的文件系统函数调用,或者手动检查错误。请注意,这只会改变与文件系统相关的错误的行为!有了ec参数和没有ec参数,更基本的异常,例如bad_alloc,如果系统内存不足,仍然可能被抛出。

从相对路径获取规范化的文件路径

在上一个示例中,我们已经规范化/标准化了路径。filesystem::path类当然能够做更多的事情,而不仅仅是保存和检查路径。它还帮助我们轻松地从字符串中组合路径,并再次分解它们。

在这一点上,path已经将操作系统的细节抽象化了,但也有一些情况下我们仍然需要记住这些细节。

我们将通过玩弄绝对路径和相对路径来看如何处理路径及其组合/分解。

如何做...

在这一部分,我们将尝试使用绝对路径和相对路径,以便看到path类及其周围的辅助函数的优势。

  1. 首先,我们包含了所有必要的头文件,并声明我们使用stdsfilesystem命名空间。
      #include <iostream>
      #include <filesystem>     

      using namespace std;
      using namespace filesystem;
  1. 然后,我们声明一个示例路径。在这一点上,它指的文本文件是否真的存在并不重要。然而,如果底层文件不存在,有一些函数会抛出异常。
      int main()
      {
          path p {"testdir/foobar.txt"};
  1. 现在我们将看看四个不同的文件系统库函数。current_path返回程序当前执行的路径,即工作目录absolute接受一个相对路径,比如我们的路径p,并返回整个文件系统中的绝对、非歧义路径。system_complete在 Linux、MacOS 或类 UNIX 操作系统上实际上与absolute做的事情几乎一样。在 Windows 上,我们会得到绝对路径,另外还会加上磁盘卷标(例如"C:")。canonical再次做的事情与absolute一样,但然后又移除了任何"."(代表当前目录)或".."(代表上一级目录)的间接。我们将在以下步骤中玩弄这样的间接:
          cout << "current_path      : " << current_path()
               << "nabsolute_path   : " << absolute(p)
               << "nsystem_complete : " << system_complete(p)
               << "ncanonical(p)    : " << canonical(p)
               << 'n';
  1. path类的另一个好处是它重载了/运算符。这样我们就可以使用/连接文件夹名称和文件名,并从中组合路径。让我们试一试,并打印一个组合的路径。
          cout << path{"testdir"} / "foobar.txt" << 'n';
  1. 让我们来玩玩canonical和组合路径。通过给canonical一个相对路径,比如"foobar.txt",和一个组合的绝对路径current_path() / "testdir",它应该返回我们现有的绝对路径。在另一个调用中,我们给它我们的路径p(即"testdir/foobar.txt"),并提供一个绝对路径current_path(),这将引导我们进入"testdir",然后再次返回。这应该与current_path()相同,因为有间接。在这两个调用中,canonical应该返回相同的绝对路径。
          cout << "canonical testdir     : "
               << canonical("foobar.txt", 
                            current_path() / "testdir")
               << "ncanonical testdir 2 : "
               << canonical(p, current_path() / "testdir/..") 
               << 'n';
  1. 我们还可以测试两个非规范路径的等价性。equivalence将接受的路径规范化,并在最终描述相同路径时返回true。对于这个测试,路径必须真的存在,否则会抛出异常。
          cout << "equivalence: "
               << equivalent("testdir/foobar.txt",
                            "testdir/../testdir/foobar.txt") 
               << 'n';
      }
  1. 编译和运行程序会产生以下输出。current_path()返回我笔记本电脑上的主文件夹,因为我是从那里执行应用程序的。我们的相对路径p已经被absolute_pathsystem_completecanonical添加了这个目录。我们看到absolute_pathsystem_complete在我的系统上返回完全相同的路径,因为我用的是 Mac(在 Linux 上也是一样的)。在 Windows 机器上,system_complete会添加"C:",或者工作目录所在的任何驱动器。
      $ ./canonical_filepath
      current_path    : "/Users/tfc"
      absolute_path   : "/Users/tfc/testdir/foobar.txt"
      system_complete : "/Users/tfc/testdir/foobar.txt"
      canonical(p)    : "/Users/tfc/testdir/foobar.txt"
      "testdir/foobar.txt"
      canonical testdir   : "/Users/tfc/testdir/foobar.txt"
      canonical testdir 2 : "/Users/tfc/testdir/foobar.txt"
      equivalence: 1
  1. 我们的简短程序中没有处理任何异常。如果我们删除testdir目录中的foobar.txt文件,那么程序会因为异常而中止执行。canonical函数要求路径存在。还有一个weakly_canonical函数,它不具备这个要求。
      $ ./canonial_filepath 
      current_path    : "/Users/tfc"
      absolute_path   : "/Users/tfc/testdir/foobar.txt"
      system_complete : "/Users/tfc/testdir/foobar.txt"
 terminate called after throwing an instance of 
      'std::filesystem::v1::__cxx11::filesystem_error'
        what():  filesystem error: cannot canonicalize: 
        No such file or directory [testdir/foobar.txt] [/Users/tfc]

工作原理...

这个食谱的目标是看看动态组合新路径有多容易。这主要是因为path类对/运算符有一个方便的重载。除此之外,文件系统函数可以很好地处理相对路径、绝对路径,以及包含...间接的路径。

path实例的函数有很多,有些带有转换,有些没有。我们不会在这里列出所有的函数,因为简单地查看 C++参考文献是获得概述的最佳方式。

path类的成员函数可能值得更仔细地研究。让我们看看path的成员函数返回路径的哪一部分。下面的图表还显示了 Windows 路径与 UNIX/Linux 路径稍有不同。

你可以看到图表显示了path的成员函数对绝对路径返回的内容。对于相对路径,root_pathroot_nameroot_directory是空的。然后,如果路径已经是相对的,relative_path就只返回路径。

列出目录中的所有文件

当然,每个提供文件系统支持的操作系统也都配备了某种在文件系统中仅列出目录中所有文件的实用程序。最简单的例子是 Linux、MacOS 和其他 UNIX 相关操作系统上的ls命令。在 DOS 和 Windows 中,有dir命令。两者都列出目录中的所有文件,并提供文件大小、权限等补充信息。

重新实现这样的工具也是一个很好的标准任务,可以开始进行目录和文件遍历。所以,让我们来做吧!

我们自己的ls/dir实用程序将能够按名称列出目录中的所有项目,指示有哪些项目,列出它们的访问权限标志,并显示它们在文件系统上占用的字节数。

如何做...

在本节中,我们将实现一个小工具,列出用户提供的任何目录中的所有文件。它不仅会列出文件名,还会列出它们的类型、大小和访问权限。

  1. 首先,我们需要包含一些头文件,并声明我们默认使用stdfilesystem命名空间。
      #include <iostream>
      #include <sstream>
      #include <iomanip>
      #include <numeric>
      #include <algorithm>
      #include <vector>
      #include <filesystem>      

      using namespace std;
      using namespace filesystem;
  1. 我们将需要的另一个辅助函数是file_info。它接受一个directory_entry对象引用,并从中提取路径,以及一个file_status对象(使用status函数),其中包含文件类型和权限信息。最后,如果是常规文件,它还提取条目的大小。对于目录或其他特殊文件,我们简单地返回大小为0。所有这些信息都被捆绑成一个元组。
static tuple<path, file_status, size_t> 
      file_info(const directory_entry &entry)
      {
          const auto fs (status(entry));
          return {entry.path(),
                  fs,
                  is_regular_file(fs) ? file_size(entry.path()) : 0u};
      }
  1. 我们需要的另一个辅助函数是type_char。路径不仅可以表示目录和简单的文本/二进制文件。操作系统提供了许多其他类型,用于抽象其他内容,例如硬件设备接口,以所谓的字符/块文件的形式。STL 文件系统库为它们提供了许多谓词函数。这样,我们可以为目录返回字母'd',对于常规文件返回字母'f',依此类推。
      static char type_char(file_status fs)
      {
          if      (is_directory(fs))      { return 'd'; }
          else if (is_symlink(fs))        { return 'l'; }
          else if (is_character_file(fs)) { return 'c'; }
          else if (is_block_file(fs))     { return 'b'; }
          else if (is_fifo(fs))           { return 'p'; }
          else if (is_socket(fs))         { return 's'; }
          else if (is_other(fs))          { return 'o'; }
          else if (is_regular_file(fs))   { return 'f'; }

          return '?';
      }
  1. 我们还需要的另一个辅助函数是rwx函数。它接受一个perms变量(它只是文件系统库中的一个enum类类型)并返回一个字符串,例如"rwxrwxrwx",描述文件的权限设置。第一组"rwx"字符描述了文件所有者的读、写和执行权限。下一组描述了属于文件所属的用户组的所有用户的相同权限。最后一组字符描述了其他所有人对访问文件的权限。例如"rwxrwxrwx"表示每个人都可以以任何方式访问对象。"rw-r--r--"表示只有所有者可以读取和修改文件,而其他人只能读取。

我们只需从这些读/写/执行字符值中组合一个字符串,逐个权限位检查perms变量p是否包含特定的所有者位,然后返回'-'或正确的字符。

      static string rwx(perms p)
      {
          auto check (p {
              return (p & bit) == perms::none ? '-' : c; 
          });

          return {check(perms::owner_read,   'r'),
                  check(perms::owner_write,  'w'),
                  check(perms::owner_exec,   'x'),
                  check(perms::group_read,   'r'),
                  check(perms::group_write,  'w'),
                  check(perms::group_exec,   'x'),
                  check(perms::others_read,  'r'),
                  check(perms::others_write, 'w'),
                  check(perms::others_exec,  'x')};
      }
  1. 最后,最后一个辅助函数接受一个整数文件大小,并将其转换为更易读的形式。我们在除法时忽略小数点,并将其向下取整到最近的千、兆或吉边界。
      static string size_string(size_t size)
      {
          stringstream ss;
          if        (size >= 1000000000) { 
              ss << (size / 1000000000) << 'G'; 
          } else if (size >= 1000000)    { 
              ss << (size / 1000000) << 'M';
          } else if (size >= 1000)       { 
              ss << (size / 1000) << 'K'; 
          } else { ss << size << 'B'; }

          return ss.str();
      }
  1. 现在我们终于可以实现主函数了。我们首先检查用户是否在命令行中提供了路径。如果没有,我们就使用当前目录"。"。然后,我们检查目录是否存在。如果不存在,我们就无法列出任何文件。
      int main(int argc, char *argv[])
      {
          path dir {argc > 1 ? argv[1] : "."};

          if (!exists(dir)) {
              cout << "Path " << dir << " does not exist.n";
              return 1;
          }
  1. 现在,我们将用文件信息元组填充一个vector,就像我们的第一个辅助函数file_infodirectory_entry对象返回的那样。我们实例化一个directory_iterator,并将其构造函数给予我们在上一步中创建的path对象。在使用目录迭代器进行迭代时,我们将directory_entry对象转换为文件信息元组,并将其插入向量中。
          vector<tuple<path, file_status, size_t>> items;

          transform(directory_iterator{dir}, {},
              back_inserter(items), file_info);
  1. 现在我们已经将所有信息保存在向量项中,可以使用我们编写的所有辅助函数简单地打印它。
          for (const auto &[path, status, size] : items) {
              cout << type_char(status) 
                   << rwx(status.permissions()) << " "
                   << setw(4) << right << size_string(size) 
                   << " " << path.filename().c_str() 
                   << 'n';
          }
      }
  1. 在离线版本的 C文档中使用文件路径编译和运行项目会产生以下输出。我们看到该文件夹只包含目录和普通文件,因为所有输出行的第一个字符只有'd'和'f'。这些文件具有不同的访问权限,当然也有不同的大小。请注意,文件按其名称的字母顺序出现,但我们不能真正依赖它,因为字母顺序不是 C17 标准要求的。
      $ ./list ~/Documents/cpp_reference/en/cpp
      drwxrwxr-x    0B  algorithm
      frw-r--r--   88K  algorithm.html
      drwxrwxr-x    0B  atomic
      frw-r--r--   35K  atomic.html
      drwxrwxr-x    0B  chrono
      frw-r--r--   34K  chrono.html
      frw-r--r--   21K  comment.html
      frw-r--r--   21K  comments.html
      frw-r--r--  220K  compiler_support.html
      drwxrwxr-x    0B  concept
      frw-r--r--   67K  concept.html
      drwxr-xr-x    0B  container
      frw-r--r--  285K  container.html
      drwxrwxr-x    0B  error
      frw-r--r--   52K  error.html

它是如何工作的...

在这个示例中,我们遍历了文件,并对每个文件检查了其状态和大小。虽然我们的每个文件操作都相当简单直接,但我们的实际目录遍历看起来有点神奇。

为了遍历我们的目录,我们只需实例化一个directory_iterator,然后对其进行迭代。使用文件系统库遍历目录非常简单。

for (const directory_entry &e : directory_iterator{dir}) {
    // do something
}

关于这个类,除了以下几点外,没有更多要说的:

  • 它访问目录的每个元素一次

  • 目录元素的迭代顺序是未指定的

  • 目录元素...已经被过滤掉

然而,值得注意的是,directory_iterator似乎既是迭代器,又是可迭代范围。为什么?在我们刚刚看到的最小for循环示例中,它被用作可迭代范围。在实际的代码中,我们将它用作迭代器:

transform(directory_iterator{dir}, {},
          back_inserter(items), file_info);

事实上,它只是一个迭代器类类型,但std::beginstd::end函数为这种类型提供了重载。这样我们就可以在这种迭代器上调用beginend函数,它们会再次返回给我们迭代器。乍一看可能会觉得奇怪,但这样可以使这个类更有用。

实现类似 grep 的文本搜索工具

大多数操作系统都配备了某种本地搜索引擎。用户可以通过一些键盘快捷键启动它,然后输入他们要查找的本地文件。

在这些功能出现之前,命令行用户已经使用诸如grepawk之类的工具搜索文件。用户可以简单地输入"grep -r foobar .",该工具将在当前目录中递归搜索,并找到包含"foobar"字符串的任何文件。

在这个示例中,我们将实现一个这样的应用程序。我们的小型 grep 克隆将从命令行接受一个模式,然后递归地搜索我们在应用程序启动时所在的目录。然后,它将打印出每个与我们的模式匹配的文件的名称。模式匹配将逐行应用,因此我们还可以打印出文件匹配模式的确切行号。

如何做...

我们将实现一个小工具,用于在文件中搜索用户提供的文本模式。该工具类似于 UNIX 工具grep,但为了简单起见,它不会像grep那样成熟和强大。

  1. 首先,我们需要包括所有必要的头文件,并声明我们使用stdfilesystem命名空间。
      #include <iostream>
      #include <fstream>
      #include <regex>
      #include <vector>
      #include <string>
      #include <filesystem>      

      using namespace std;
      using namespace filesystem;
  1. 我们实现了一个辅助函数。它接受一个文件路径和一个描述我们正在寻找的模式的正则表达式对象。然后,我们实例化一个vector,其中包含匹配行号和它们的内容。我们还实例化了一个输入文件流对象,从中我们将逐行读取和匹配内容。
      static vector<pair<size_t, string>> 
      matches(const path &p, const regex &re)
      {
          vector<pair<size_t, string>> d;
          ifstream is {p.c_str()};
  1. 我们使用getline函数逐行遍历文件。如果regex_search返回true,则表示字符串包含我们的模式。如果是这种情况,我们将行号和字符串放入向量中。最后,我们返回所有收集到的匹配项。
          string s;
          for (size_t line {1}; getline(is, s); ++line) {
              if (regex_search(begin(s), end(s), re)) {
                  d.emplace_back(line, move(s));
              }
          }

          return d;
      }
  1. 在主函数中,我们首先检查用户是否提供了可以用作模式的命令行参数。如果没有,我们会报错。
      int main(int argc, char *argv[])
      {
          if (argc != 2) {
              cout << "Usage: " << argv[0] << " <pattern>n";
              return 1;
          }
  1. 接下来,我们从输入模式构造一个正则表达式对象。如果模式不是有效的正则表达式,这将导致异常。如果发生这样的异常,我们会捕获它并报错。
          regex pattern;

          try { pattern = regex{argv[1]}; }
          catch (const regex_error &e) {
              cout << "Invalid regular expression provided.n";
              return 1;
          }
  1. 现在,我们终于可以遍历文件系统并寻找模式匹配了。我们使用recursive_directory_iterator来遍历工作目录中的所有文件。它的工作方式与上一个教程中的directory_iterator完全相同,但它还会进入子目录。这样我们就不必管理递归。在每个条目上,我们调用我们的辅助函数matches
          for (const auto &entry :
                recursive_directory_iterator{current_path()}) {
              auto ms (matches(entry.path(), pattern));
  1. 对于每个匹配(如果有的话),我们打印文件路径、行号和匹配行的完整内容。
              for (const auto &[number, content] : ms) {
                  cout << entry.path().c_str() << ":" << number
                       << " - " << content << 'n';
              }
          }
      }
  1. 让我们准备一个名为"foobar.txt"的文件,其中包含一些我们可以搜索的测试行。
      foo
      bar
      baz
  1. 编译和运行产生以下输出。我在我的笔记本电脑的/Users/tfc/testdir文件夹中启动了应用程序,首先使用模式"bar"。在该目录中,它找到了我们的foobar.txt文件的第二行和另一个文件"text1.txt",它位于testdir/dir1中。
      $ ./grepper bar
      /Users/tfc/testdir/dir1/text1.txt:1 - foo bar bla blubb
      /Users/tfc/testdir/foobar.txt:2 - bar

  1. 再次启动应用程序,但这次使用模式"baz",它找到了我们示例文本文件的第三行。
      $ ./grepper baz
      /Users/tfc/testdir/foobar.txt:3 - baz

它的工作原理...

设置和使用正则表达式来过滤文件内容肯定是这个教程的主要任务。然而,让我们集中在recursive_directory_iterator上,因为递归地过滤迭代的文件只是我们在这个教程中使用这个特殊迭代器类的动机。

就像directory_iterator一样,recursive_directory_iterator遍历目录的元素。它的特点是递归地执行这个操作,正如它的名字所示。每当它遇到一个文件系统元素是目录时,它将向这个路径产生一个directory_entry实例,然后还会进入其中以遍历它的子元素。

recursive_directory_iterator有一些有趣的成员函数:

  • depth():这告诉我们迭代器当前已经进入子目录的级数。

  • recursion_pending():这告诉我们迭代器当前指向的元素之后是否会进入递归。

  • 禁用递归挂起():如果当前指向的是一个目录,可以调用此方法来阻止迭代器进入下一个子目录,如果它当前指向的是一个目录,那么调用此方法将不起作用,因为我们调用它太早

  • pop():中止当前递归级别,并在目录层次结构中向上移动一级以从那里继续。

还有更多...

还要了解的一件事是directory_options枚举类。recursive_directory_iterator的构造函数确实接受这种类型的值作为第二个参数。我们一直在隐式使用的默认值是directory_options::none。其他值包括:

  • follow_directory_symlink:这允许递归迭代器跟随符号链接到目录

  • skip_permission_denied:这告诉迭代器跳过否则会因为文件系统拒绝访问权限而导致错误的目录

这些选项可以与|运算符结合使用。

实现自动文件重命名器

这个教程的动机是我经常发现自己处于这样的情况。例如,从不同的朋友和不同的照片设备收集假期的图片文件放在一个文件夹中,文件扩展名经常看起来不同。一些 JPEG 文件有.jpg扩展名,一些有.jpeg,还有一些甚至有.JPEG

有些人可能更喜欢使所有扩展名统一。使用单个命令重命名所有文件将是有用的。同时,我们可以删除空格' '并用下划线'_'替换它们,例如。

在这个教程中,我们将实现这样一个工具,并将其称为renamer。它将接受一系列输入模式及其替代品,如下所示:

$ renamer jpeg jpg JPEG jpg

在这种情况下,重命名器将递归地遍历当前目录,并在所有文件名中搜索模式jpegJPEG。它将用jpg替换两者。

如何做...

我们将实现一个工具,递归扫描目录中的所有文件,并将它们的文件名与模式进行匹配。所有匹配项都将替换为用户提供的标记,并相应地重命名受影响的文件。

  1. 首先,我们需要包括一些头文件,并声明我们使用命名空间stdfilesystem
      #include <iostream>
      #include <regex>
      #include <vector>
      #include <filesystem>      

      using namespace std;
      using namespace filesystem;
  1. 我们实现了一个简短的辅助函数,它接受一个字符串形式的输入文件路径和一系列替换对。每个替换对包括一个模式和其替换。在循环遍历替换范围时,我们使用regex_replace将其提供给输入字符串,并让其返回转换后的字符串。然后,我们返回结果字符串。
      template <typename T>
      static string replace(string s, const T &replacements)
      {
          for (const auto &[pattern, repl] : replacements) {
              s = regex_replace(s, pattern, repl);
          }

          return s;
      }
  1. 在主函数中,我们首先验证命令行。我们接受成对的命令行参数,因为我们希望模式与它们的替换一起。argv的第一个元素始终是可执行文件名。这意味着如果用户提供了至少一对或更多对,那么argc必须是奇数,且不小于3
      int main(int argc, char *argv[])
      {
          if (argc < 3 || argc % 2 != 1) {
              cout << "Usage: " << argv[0] 
                   << " <pattern> <replacement> ...n";
              return 1;
          }
  1. 一旦我们检查到有输入对,我们将用这些对填充一个向量。
          vector<pair<regex, string>> patterns;

          for (int i {1}; i < argc; i += 2) {
              patterns.emplace_back(argv[i], argv[i + 1]);
          }
  1. 现在我们可以遍历文件系统。为了简单起见,我们只需将应用程序的当前路径定义为要遍历的目录。

对于每个目录条目,我们提取其原始路径到opath变量中。然后,我们只取文件名而不是其余路径,并根据之前收集的模式和替换列表进行转换。我们复制opath,称其为rpath,并用新文件名替换其文件名部分。

          for (const auto &entry :
                recursive_directory_iterator{current_path()}) {
              path opath {entry.path()};
              string rname {replace(opath.filename().string(),
                                    patterns)};

              path rpath {opath};
              rpath.replace_filename(rname);
  1. 对于受我们模式影响的所有文件,我们打印出我们重命名它们。如果替换模式后的文件名已经存在,我们无法继续。让我们跳过这样的文件。当然,我们当然也可以只向路径追加一些数字或其他内容来解决名称冲突。
              if (opath != rpath) {
                  cout << opath.c_str() << " --> " 
                       << rpath.filename().c_str() << 'n';
                  if (exists(rpath)) {
                      cout << "Error: Can't rename."
                              " Destination file exists.n";
                  } else {
                      rename(opath, rpath);
                  }
              }
          }
      }
  1. 在示例目录中编译和运行程序会产生以下输出。我把一些 JPEG 图片放入了目录中,但给它们不同的名称结尾jpgjpegJPEG。然后,我用模式jpegJPEG执行了程序,并选择了jpg作为两者的替换。结果是一个具有同质文件扩展名的文件夹。
      $ ls
      birthday_party.jpeg   holiday_in_dubai.jpg  holiday_in_spain.jpg 
      trip_to_new_york.JPEG
      $ ../renamer jpeg jpg JPEG jpg
      /Users/tfc/pictures/birthday_party.jpeg --> birthday_party.jpg
      /Users/tfc/pictures/trip_to_new_york.JPEG --> trip_to_new_york.jpg
      $ ls
      birthday_party.jpg   holiday_in_dubai.jpg holiday_in_spain.jpg
      trip_to_new_york.jpg

实现磁盘使用计数器

我们已经实现了一个类似于 Linux/MacOS 上的ls或 Windows 上的dir的工具,但与这些工具一样,它不会打印目录的文件大小。

为了获得目录的大小等价值,我们需要进入其中并计算其中包含的所有文件的大小。

在这个示例中,我们将实现一个工具来做到这一点。该工具可以在任何文件夹上运行,并汇总所有目录条目的累积大小。

如何做...

在这一部分,我们将实现一个应用程序,它遍历目录并列出每个条目的文件大小。对于常规文件来说很简单,但如果我们看到的目录条目本身是一个目录,那么我们必须查看它并总结其包含的所有文件的大小。

  1. 首先,我们需要包括所有必要的头文件,并声明我们使用命名空间stdfilesystem
      #include <iostream>
      #include <sstream>
      #include <iomanip>
      #include <numeric>
      #include <filesystem>      

      using namespace std;
      using namespace filesystem;
  1. 然后我们实现一个辅助函数,它接受一个directory_entry作为参数,并返回其在文件系统中的大小。如果不是目录,我们只需返回由file_size计算的文件大小。
      static size_t entry_size(const directory_entry &entry)
      {
          if (!is_directory(entry)) { return file_size(entry); }
  1. 如果是目录,我们需要遍历其所有条目并计算它们的大小。如果我们再次遇到子目录,我们最终会递归调用我们自己的entry_size辅助函数。
          return accumulate(directory_iterator{entry}, {}, 0u,
              [](size_t accum, const directory_entry &e) {
                  return accum + entry_size(e);
              });
      }
  1. 为了更好地可读性,我们在本章的其他示例中使用相同的size_string函数。它只是将大文件大小分成更短、更美观的字符串,以便读取带有 kilo、mega 或 giga 后缀的字符串。
      static string size_string(size_t size)
      {
          stringstream ss;
          if        (size >= 1000000000) { 
              ss << (size / 1000000000) << 'G'; 
          } else if (size >= 1000000)    { 
              ss << (size / 1000000) << 'M'; 
          } else if (size >= 1000)       { 
              ss << (size / 1000) << 'K'; 
          } else { ss << size << 'B'; }

          return ss.str();
      }  
  1. 在主函数中,我们需要做的第一件事是检查用户是否在命令行上提供了文件系统路径。如果不是这种情况,我们就取当前文件夹。在继续之前,我们要检查它是否存在。
      int main(int argc, char *argv[])
      {
          path dir {argc > 1 ? argv[1] : "."};

          if (!exists(dir)) {
              cout << "Path " << dir << " does not exist.n";
              return 1;
          }
  1. 现在,我们可以遍历所有目录条目并打印它们的大小和名称。
          for (const auto &entry : directory_iterator{dir}) {
              cout << setw(5) << right 
                   << size_string(entry_size(entry))
                   << " " << entry.path().filename().c_str() 
                   << 'n';
          }
      }
  1. 编译和运行程序产生以下结果。我在 C++离线参考手册的一个文件夹中启动了它。由于它也包含子文件夹,我们的递归文件大小摘要助手立即就派上了用场。
      $ ./file_size ~/Documents/cpp_reference/en/
        19M c
        12K c.html
       147M cpp
        17K cpp.html
        22K index.html
        22K Main_Page.html

它的工作原理...

整个程序围绕着在常规文件上使用file_size。如果程序看到一个目录,它会递归进入其中,并对所有条目调用file_size

我们用来区分是否直接调用file_size还是需要递归策略的唯一方法是询问is_directory谓词。这对于只包含常规文件和目录的目录非常有效。

尽管我们的示例程序很简单,但在以下情况下会崩溃,因为没有处理异常:

  • file_size只对常规文件和符号链接有效。在其他情况下会抛出异常。

  • 尽管file_size对符号链接有效,但如果我们在损坏的符号链接上调用它,它仍然会抛出异常。

为了使这个示例程序更加成熟,我们需要更多的防御性编程来处理错误类型的文件和异常处理。

计算文件类型的统计信息

在上一个示例中,我们实现了一个工具,列出任何目录中所有成员的大小。

在这个示例中,我们也将递归计算大小,但这次我们将每个文件的大小累积到它们的文件名扩展名中。这样我们就可以向用户打印一个表,列出我们有多少个每种文件类型的文件,以及这些文件类型的平均大小。

如何做...

在本节中,我们将实现一个小工具,它会递归地遍历给定目录。在这样做的过程中,它会计算所有文件的数量和大小,按其扩展名分组。最后,它会打印出该目录中存在的文件名扩展名,每个扩展名的数量以及它们的平均文件大小。

  1. 我们需要包括必要的头文件,并声明我们使用stdfilesystem命名空间。
      #include <iostream>
      #include <sstream>
      #include <iomanip>
      #include <map>
      #include <filesystem>     

      using namespace std;
      using namespace filesystem;
  1. size_string函数在其他示例中已经很有用了。它将文件大小转换为人类可读的字符串。
      static string size_string(size_t size)
      {
          stringstream ss;
          if        (size >= 1000000000) { 
              ss << (size / 1000000000) << 'G'; 
          } else if (size >= 1000000)    { 
              ss << (size / 1000000) << 'M'; 
          } else if (size >= 1000)       { 
              ss << (size / 1000) << 'K';
          } else { ss << size << 'B'; }

          return ss.str();
      }
  1. 然后,我们实现一个辅助函数,它接受一个path对象作为参数,并遍历该路径下的所有文件。在此过程中,它将所有信息收集到一个映射中,该映射将文件名扩展名映射到包含具有相同扩展名的所有文件的总数和累积大小的对中。
      static map<string, pair<size_t, size_t>> ext_stats(const path &dir)
      {
          map<string, pair<size_t, size_t>> m;

          for (const auto &entry :
                recursive_directory_iterator{dir}) {
  1. 如果目录条目本身是一个目录,我们就跳过它。此时跳过它并不意味着我们不会递归进入其中。recursive_directory_iterator仍然会这样做,但我们不想查看目录条目本身。
              const path        p  {entry.path()};
              const file_status fs {status(p)};

              if (is_directory(fs)) { continue; }
  1. 接下来,我们提取目录条目字符串的扩展部分。如果没有扩展名,我们就简单地跳过它。
              const string ext {p.extension().string()};

              if (ext.length() == 0) { continue; }
  1. 接下来,我们计算我们正在查看的文件的大小。然后,我们在地图中查找这个扩展名的聚合对象。如果此时还没有,它会被隐式创建。我们只是增加文件计数并将文件大小添加到大小累加器中。
              const size_t size {file_size(p)};

              auto &[size_accum, count] = m[ext];

              size_accum += size;
              count      += 1;
          }
  1. 之后,我们返回地图。
          return m;
      }
  1. 在主函数中,我们从命令行中获取用户提供的路径或当前目录。当然,我们需要检查它是否存在,否则继续下去就没有意义。
      int main(int argc, char *argv[])
      {
          path dir {argc > 1 ? argv[1] : "."};

          if (!exists(dir)) {
              cout << "Path " << dir << " does not exist.n";
              return 1;
          }
  1. 我们可以立即遍历ext_stats给我们的映射。因为映射中的accum_size项包含相同扩展名的所有文件的总和,所以在打印之前,我们将这个总和除以这些文件的总数。
          for (const auto &[ext, stats] : ext_stats(dir)) {
              const auto &[accum_size, count] = stats;

              cout << setw(15) << left << ext << ": "
                   << setw(4) << right << count 
                   << " items, avg size "
                   << setw(4) << size_string(accum_size / count)
                   << 'n';
          }
      }
  1. 编译和运行程序产生以下输出。我将离线 C++参考手册中的一个文件夹作为命令行参数。
      $ ./file_type ~/Documents/cpp_reference/
      .css           :    2 items, avg size  41K
      .gif           :    7 items, avg size 902B
      .html          : 4355 items, avg size  38K
      .js            :    3 items, avg size   4K
      .php           :    1 items, avg size 739B
      .png           :   34 items, avg size   2K
      .svg           :   53 items, avg size   6K
      .ttf           :    2 items, avg size 421K

实现一个通过用符号链接替换重复项来减小文件夹大小的工具

有很多工具以各种方式压缩数据。文件打包算法/格式的最著名的例子是 ZIP 和 RAR。这些工具试图通过减少内部冗余来减小文件的大小。

在将文件压缩到存档文件之前,减少磁盘使用的一个非常简单的方法就是删除 重复文件。在这个示例中,我们将实现一个小工具,它会递归地遍历一个目录。在遍历过程中,它将寻找具有相同内容的文件。如果找到这样的文件,它将删除所有重复项,只保留一个。所有删除的文件将被替换为指向现在唯一文件的符号链接。这样可以节省空间,而不需要任何压缩,同时保留所有数据。

如何做...

在这一部分,我们将实现一个小工具,找出目录中彼此重复的文件。有了这个知识,它将删除所有重复的文件,只保留一个,并用符号链接替换它们,从而减小文件夹的大小。

确保系统数据有一个备份。我们将使用 STL 函数删除文件。在这样一个程序中,一个简单的拼写错误路径可能导致程序以不希望的方式贪婪地删除太多文件。

  1. 首先,我们需要包含必要的头文件,然后我们声明我们默认使用stdfilesystem命名空间。
      #include <iostream>
      #include <fstream>
      #include <unordered_map>
      #include <filesystem>      

      using namespace std;
      using namespace filesystem;
  1. 为了找出哪些文件是彼此的重复项,我们将构建一个哈希映射,将文件内容的哈希映射到生成该哈希的第一个文件的路径。对于文件,使用生产哈希算法如 MD5 或 SHA 变体会是一个更好的主意。为了保持清晰和简单,我们只是将整个文件读入一个字符串,然后使用unordered_map已经用于字符串的相同哈希函数对象来计算哈希。
      static size_t hash_from_path(const path &p)
      {
          ifstream is {p.c_str(), 
                       ios::in | ios::binary};
          if (!is) { throw errno; }

          string s;

          is.seekg(0, ios::end);
          s.reserve(is.tellg());
          is.seekg(0, ios::beg);

          s.assign(istreambuf_iterator<char>{is}, {});

          return hash<string>{}(s);
      }
  1. 然后我们实现构建这样一个哈希映射并删除重复项的函数。它通过目录及其子目录进行递归迭代。
      static size_t reduce_dupes(const path &dir)
      {
          unordered_map<size_t, path> m;
          size_t count {0};

          for (const auto &entry :
                recursive_directory_iterator{dir}) {
  1. 对于每个目录条目,它都会检查它是否是一个目录本身。所有目录项都将被跳过。对于每个文件,我们生成其哈希值并尝试将其插入哈希映射中。如果哈希映射已经包含相同的哈希,则这意味着我们已经插入了具有相同哈希的文件。这意味着我们刚刚找到了一个重复项!在插入过程中发生冲突时,try_emplace返回的对中的第二个值为false
              const path p {entry.path()};

              if (is_directory(p)) { continue; }

              const auto &[it, success] =
                  m.try_emplace(hash_from_path(p), p);
  1. 使用try_emplace的返回值,我们可以告诉用户我们刚刚插入了一个文件,因为我们第一次看到了它的哈希。如果我们找到了重复项,我们会告诉用户它是哪个其他文件的重复项,并将其删除。删除后,我们创建一个替换重复项的符号链接。
              if (!success) {
                  cout << "Removed " << p.c_str()
                       << " because it is a duplicate of "
                       << it->second.c_str() << 'n';

                  remove(p);
                  create_symlink(absolute(it->second), p);
                  ++count;
              }
  1. 在文件系统迭代之后,我们返回删除的文件数,并用符号链接替换。
          }

          return count;
      }
  1. 在主函数中,我们确保用户在命令行上提供了一个目录,并且该目录存在。
      int main(int argc, char *argv[])
      {
          if (argc != 2) {
              cout << "Usage: " << argv[0] << " <path>n";
              return 1;
          }

          path dir {argv[1]};

          if (!exists(dir)) {
              cout << "Path " << dir << " does not exist.n";
              return 1;
          }
  1. 现在我们唯一需要做的就是在这个目录上调用reduce_dupes,并打印它删除了多少文件。
          const size_t dupes {reduce_dupes(dir)};

          cout << "Removed " << dupes << " duplicates.n";
      }
  1. 在包含一些重复文件的示例目录上编译和运行程序如下。我使用du工具在启动我们的程序之前和之后检查文件夹大小,以演示这种方法的工作原理。
      $ du -sh dupe_dir
      1.1M dupe_dir

      $ ./dupe_compress dupe_dir
      Removed dupe_dir/dir2/bar.jpg because it is a duplicate of 
      dupe_dir/dir1/bar.jpg
      Removed dupe_dir/dir2/base10.png because it is a duplicate of 
      dupe_dir/dir1/base10.png
      Removed dupe_dir/dir2/baz.jpeg because it is a duplicate of 
      dupe_dir/dir1/baz.jpeg
      Removed dupe_dir/dir2/feed_fish.jpg because it is a duplicate of 
      dupe_dir/dir1/feed_fish.jpg
      Removed dupe_dir/dir2/foo.jpg because it is a duplicate of 
      dupe_dir/dir1/foo.jpg
      Removed dupe_dir/dir2/fox.jpg because it is a duplicate of 
      dupe_dir/dir1/fox.jpg
      Removed 6 duplicates.

      $ du -sh dupe_dir
      584K dupe_dir

它是如何工作的...

我们使用create_symlink函数来使文件系统中的一个文件指向文件系统中的另一个文件。这样我们就可以避免重复的文件。我们也可以使用create_hard_link来设置硬链接。从语义上讲,这是相似的,但是硬链接有其他技术含义。不同的文件系统格式可能根本不支持硬链接,或者只支持指向同一文件的一定数量的硬链接。另一个问题是硬链接不能从一个文件系统链接到另一个文件系统。

然而,除了实现细节之外,在使用create_symlinkcreate_hard_link时有一个明显的错误源。以下行包含一个错误。你能立刻发现它吗?

path a {"some_dir/some_file.txt"};
path b {"other_dir/other_file.txt"};
remove(b);
create_symlink(a, b);

执行此程序时不会发生任何不良情况,但符号链接将会损坏。符号链接指向"some_dir/some_file.txt",这是错误的。问题在于它实际上应该指向"/absolute/path/some_dir/some_file.txt",或者"../some_dir/some_file.txt"。如果我们将create_symlink调用写成以下形式,则使用了正确的绝对路径:

create_symlink(absolute(a), b);

create_symlink不检查我们要链接的路径是否正确

还有更多...

我们已经注意到我们的哈希函数太简单了。为了使这个方法简单并且没有外部依赖,我们选择了这种方式。

我们的哈希函数有什么问题?实际上有两个问题:

  • 我们将整个文件读入一个字符串。这对于大于我们系统内存的文件是灾难性的。

  • C++哈希函数特性hash<string>很可能不是为这样的哈希设计的。

如果我们正在寻找更好的哈希函数,我们应该选择一个快速、内存友好的函数,并确保没有两个真正大但不同的文件得到相同的哈希值。后一个要求可能是最重要的。如果我们决定一个文件是另一个文件的副本,尽管它们不包含相同的数据,那么在删除后我们肯定会有一些数据丢失

更好的哈希算法例如 MD5 或 SHA 变体之一。为了在我们的程序中访问这样的函数,我们可以使用 OpenSSL 密码 API。

posted @ 2024-05-04 22:50  绝不原创的飞龙  阅读(20)  评论(0编辑  收藏  举报