C++-高性能编程(全)

C++ 高性能编程(全)

原文:annas-archive.org/md5/753c0f2773b6b78b5104ecb1b57442d4

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

如今的 C++为程序员提供了编写富有表现力和健壮的代码的能力,同时仍然可以针对几乎任何硬件平台,并且同时满足性能关键的要求。这使得 C++成为一种独特的语言。在过去的几年里,C++已经变成了一种更有趣、具有更好默认值的现代语言。

本书旨在为你提供编写高效应用程序的坚实基础,以及现代 C++中实现库的策略的洞察。我试图以实用的方式来解释当今的 C++是如何工作的,其中 C++17 和 C++20 的特性是语言的自然部分,而不是从历史上看待 C++。

本书的第二版是为了涵盖 C++20 新增的功能而撰写的。我包括了我认为与本书其余内容和重点相契合的功能。自然地,讨论新功能的章节更多地作为介绍,并包含较少的最佳实践和经过验证的解决方案。

在出版本书时,一些 C++20 功能的编译器支持仍然是实验性的。如果你在出版日期附近阅读本书,很可能你将不得不等待一些功能被你的编译器完全支持。

许多章节涵盖了广泛的难度范围。它们从绝对基础开始,最后涉及高级主题,如自定义内存分配器。如果某个部分对你不相关,可以随意跳过,或者以后再回来看。除了前三章外,大多数章节都可以独立阅读。

我们的主要技术审阅者 Timur Doumler 对这个新版本产生了很大的影响。他的热情和出色的反馈导致第一版的一些章节被重新修改,以更彻底、更深入地解释主题。在自然地融入新的 C++20 功能的章节中,Timur 也是一个重要的贡献者。本书的部分内容也经过了 Arthur O'Dwyer、Marius Bancila 和 Lewis Baker 的审阅。能够有这样优秀的审阅者参与这个项目是一种真正的快乐。我希望你能像我写作时那样享受阅读这个新版本。

本书适合对象

本书希望你具备 C++和计算机体系结构的基本知识,并对提升自己的技能有真正的兴趣。希望在你完成本书时,你能够对如何改进 C++应用程序在性能和语法上有一些见解。此外,我也希望你能有一些"啊哈"时刻。

本书涵盖内容

第一章C++简介,介绍了 C++的一些重要特性,如零成本抽象、值语义、const 正确性、显式所有权和错误处理。它还讨论了 C++的缺点。

第二章基本 C++技术,概述了使用 auto 进行自动类型推导,lambda 函数,移动语义和错误处理。

第三章分析和测量性能,将教你如何使用大 O 符号分析算法复杂性。本章还讨论了如何对代码进行性能分析,找出热点,并使用 Google Benchmark 设置性能测试。

第四章数据结构,带你了解了数据结构的重要性,以便可以快速访问。介绍了标准库中的容器,如std::vectorstd::liststd::unordered_mapstd::priority_queue。最后,本章演示了如何使用并行数组。

第五章算法,介绍了标准库中最重要的算法。你还将学习如何使用迭代器和范围,以及如何实现自己的通用算法。

第六章,范围和视图,将教您如何使用 C++20 引入的范围库组合算法。您将了解范围库中视图的用途以及延迟评估的一些好处。

第七章,内存管理,侧重于安全高效的内存管理。这包括内存所有权、RAII、智能指针、栈内存、动态内存和自定义内存分配器。

第八章,编译时编程,解释了使用constexprconsteval和类型特征的元编程技术。您还将学习如何使用 C++20 概念和新的概念库。最后,它提供了元编程用例的实际示例,如反射。

第九章,基本实用程序,将指导您了解实用程序库以及如何利用std::optionalstd::anystd::variant等类型,使用编译时编程技术。

第十章,代理对象和延迟评估,探讨了如何利用代理对象进行底层优化,同时保持清晰的语法。此外,还演示了一些创造性的运算符重载用法。

第十一章,并发,涵盖了并发编程的基础知识,包括并行执行、共享内存、数据竞争和死锁。还介绍了 C++线程支持库、原子库和 C++内存模型。

第十二章,协程和延迟生成器,包含对协程抽象的一般介绍。您将了解普通函数和协程如何在 CPU 上使用堆栈和堆执行。引入了 C++20 无栈协程,并将发现如何使用生成器解决问题。

第十三章,使用协程进行异步编程,介绍了使用 C++20 的无栈协程进行并发编程,并涉及使用 Boost.Asio 进行异步网络编程的主题。

第十四章,并行算法,首先展示了编写并行算法的复杂性以及如何衡量它们的性能。然后演示了如何使用执行策略在并行上下文中利用标准库算法。

充分利用本书

要充分利用本书,您需要具备 C++的基本知识。最好您已经遇到与性能相关的问题,并且现在正在寻找新的工具和实践,以备下次需要处理性能和 C++时使用。

本书中有很多代码示例。其中一些来自现实世界,但大多数是人工的或大大简化的示例,用来证明一个概念,而不是提供生产就绪的代码。

我已将所有代码示例放在按章节划分的源文件中,这样您可以很容易找到想要尝试的示例。如果您打开源代码文件,您会注意到我已经用 Google 测试框架编写了大部分示例的main()函数的测试用例。我希望这会对您有所帮助,而不是让您感到困惑。这使我能够为每个示例编写有用的描述,并且使得一次运行一个章节中的所有示例变得更容易。

为了编译和运行示例,您需要以下内容:

  • 一台计算机

  • 操作系统(示例已在 Windows、Linux 和 macOS 上验证)

  • 一个编译器(我使用了 Clang、GCC 和 Microsoft Visual C++)

  • CMake

提供的示例代码中的 CMake 脚本将下载并安装进一步的依赖项,如 Boost、Google Benchmark 和 Google 测试。

在写作本书的过程中,我发现使用Compiler Explorer很有帮助,该工具可在godbolt.org/上使用。Compiler Explorer 是一个在线编译器服务,可以让您尝试各种编译器和版本。如果您还没有尝试过,请试一试!

下载示例代码文件

本书的代码包托管在 GitHub 上,网址为github.com/PacktPublishing/Cpp-High-Performance-Second-Edition。如果代码有更新,将在现有的 GitHub 存储库上进行更新。

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

下载彩色图片

Packt 还提供了一份 PDF 文件,其中包含本书中使用的屏幕截图/图表的彩色图片。您可以在这里下载:static.packt-cdn.com/downloads/9781839216541_ColorImages.pdf

使用的约定

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

CodeInText:表示文本中的代码词、文件夹名称、文件名、文件扩展名、虚拟 URL 和用户输入。例如:"关键字constexpr是在 C++11 中引入的。"

一段代码块设置如下:

#include <iostream>
int main() {
  std::cout << "High Performance C++\n"; 
} 

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

#include <iostream>
int main() {
std`::`cout `<<` "High Performance C++\n"`;`
} 

任何命令行输入或输出都以以下形式书写:

$ clang++ -std=c++20 high_performance.cpp
$ ./a.out
$ High Performance C++ 

粗体:表示一个新术语、一个重要词或者屏幕上看到的词。例如:"填写表格,然后点击保存按钮。"

警告或重要说明会出现在这样的形式中。

第一章:C++简介

这本书旨在为您提供编写高效应用程序的坚实基础,以及实现现代 C++库的策略。我试图以实用的方式来解释 C++如何运作,现代 C++11 到 C++20 的现代特性是语言的自然部分,而不是从历史上看 C++。

在本章中,我们将:

  • 介绍一些对编写健壮、高性能应用程序很重要的 C++特性

  • 讨论 C++相对于竞争语言的优势和劣势

  • 查看本书中使用的库和编译器

为什么选择 C++?

让我们开始探讨一些今天使用 C++的原因。简而言之,C++是一种高度可移植的语言,提供了零成本的抽象。此外,C++为程序员提供了编写和管理大型、富有表现力和健壮的代码库的能力。在本节中,我们将探讨零成本抽象的含义,将 C++的抽象与其他语言中的抽象进行比较,并讨论可移植性和健壮性,以及为什么这些特性很重要。

让我们开始进入零成本抽象。

零成本抽象

活跃的代码库会不断增长。有更多的开发人员在一个代码库上工作,代码库就会变得更大。为了管理代码库不断增长的复杂性,我们需要语言特性,比如变量、函数和类,能够使用自定义名称和接口创建我们自己的抽象,以抑制实现的细节。

C++允许我们定义自己的抽象,但它也带有内置的抽象。例如,C++函数的概念本身就是控制程序流的抽象。基于范围的for循环是另一个内置抽象的例子,它使得直接迭代一系列值成为可能。作为程序员,我们在开发程序时不断添加新的抽象。同样,C++的新版本引入了语言和标准库的新抽象。但是不断添加抽象和新的间接层是有代价的——效率。这就是零成本抽象发挥作用的地方。C++提供的许多抽象在空间和时间方面的运行成本非常低。

使用 C++,当需要时可以自由地谈论内存地址和其他与计算机相关的低级术语。然而,在大型软件项目中,希望用处理应用程序正在执行的任务的术语来表达代码,并让库处理与计算机相关的术语。图形应用程序的源代码可能涉及铅笔、颜色和滤镜,而游戏可能涉及吉祥物、城堡和蘑菇。低级的与计算机相关的术语,比如内存地址,可以留在 C++库代码中,其中性能至关重要。

编程语言和机器码抽象

为了让程序员摆脱处理与计算机相关的术语的需要,现代编程语言使用抽象,这样一个字符串列表,例如,可以被处理和看作是一个字符串列表,而不是一个我们可能会因为轻微的拼写错误而失去追踪的地址列表。这些抽象不仅让程序员摆脱了错误,还通过使用应用程序领域的概念使代码更具表现力。换句话说,代码用更接近口语的术语表达,而不是用抽象的编程关键字表达。

C++和 C 现在是两种完全不同的语言。不过,C++与 C 高度兼容,并且从 C 继承了很多语法和习惯用法。为了给你一些 C++抽象的例子,我将展示如何在 C 和 C++中解决一个问题。

看一下以下 C/C++代码片段,它们对应于问题:“这个书籍列表中有多少本《哈姆雷特》?”

我们将从 C 版本开始:

// C version
struct string_elem_t { const char* str_; string_elem_t* next_; };
int num_hamlet(string_elem_t* books) {
  const char* hamlet = "Hamlet";
  int n = 0;
  string_elem_t* b; 
  for (b = books; b != 0; b = b->next_)
    if (strcmp(b->str_, hamlet) == 0)
      ++n;
  return n;
} 

使用 C++的等效版本看起来会是这样的:

// C++ version
int num_hamlet(const std::forward_list<std::string>& books) {
  return std::count(books.begin(), books.end(), "Hamlet");
} 

尽管 C++版本仍然更像机器语言而不是人类语言,但由于更高级别的抽象,许多编程术语已经消失。以下是前两个代码片段之间的一些显著差异:

  • 原始内存地址的指针根本不可见

  • std::forward_list<std::string>容器替换了手工制作的使用string_elem_t的链表

  • std::count()函数替换了for循环和if语句

  • std::string类提供了对char*strcmp()的更高级别抽象

基本上,num_hamlet()的两个版本都会转换为大致相同的机器代码,但 C++的语言特性使得库可以隐藏计算机相关的术语,比如指针。许多现代 C++语言特性可以被视为对基本 C 功能的抽象。

其他语言中的抽象

大多数编程语言都是基于抽象构建的,这些抽象被转换为机器代码,由 CPU 执行。C++已经发展成为一种高度表达性的语言,就像今天许多其他流行的编程语言一样。C++与大多数其他语言的区别在于,其他语言实现这些抽象是以运行时性能为代价的,而 C++始终致力于以零成本实现其抽象。这并不意味着用 C++编写的应用程序默认比用其他语言(比如 C#)编写的应用程序更快。相反,这意味着通过使用 C++,您可以对生成的机器代码指令和内存占用进行精细控制(如果需要)。

公平地说,如今很少需要最佳性能,而为了更低的编译时间、垃圾回收或安全性而牺牲性能,就像其他语言所做的那样,在许多情况下更为合理。

零开销原则

“零成本抽象”是一个常用的术语,但它存在一个问题 - 大多数抽象通常都是有成本的。即使在程序运行时没有成本,也几乎总是在某个地方产生成本,比如长时间的编译时间,难以解释的编译错误消息等等。通常更有趣的是讨论零开销原则。C++的发明者 Bjarne Stroustrup 这样定义零开销原则:

  • 你不使用的东西,你就不需要付费

  • 你使用的东西,你无法手工编码得更好

这是 C++的一个核心原则,也是语言演变的一个非常重要的方面。为什么,你可能会问?基于这一原则构建的抽象将被性能意识强烈的程序员广泛接受和使用,并且在性能非常关键的环境中使用。找到许多人都同意并广泛使用的抽象,使我们的代码库更易于阅读和维护。

相反,C++语言中不完全遵循零开销原则的特性往往会被程序员、项目和公司所放弃。在这一类中最显著的两个特性是异常(不幸的是)和运行时类型信息(RTTI)。即使没有使用这些特性,它们都可能对性能产生影响。我强烈建议使用异常,除非你有非常充分的理由不这样做。与使用其他机制处理错误相比,性能开销在大多数情况下都是可以忽略的。

可移植性

C++长期以来一直是一种受欢迎且全面的语言。它与 C 高度兼容,语言中很少有被弃用的部分,无论是好是坏。C++的历史和设计使其成为一种高度可移植的语言,而现代 C++的发展确保了它将长期保持这种状态。C++是一种活跃的语言,编译器供应商目前正在非常出色地迅速实现新的语言特性。

健壮性

除了性能、表现力和可移植性之外,C++还提供了一系列语言特性,使程序员能够编写健壮的代码。

在作者的经验中,健壮性并不是指编程语言本身的强大性 - 在任何语言中都可以编写健壮的代码。相反,资源的严格所有权,const 正确性,值语义,类型安全以及对象的确定性销毁是 C++提供的一些功能,使得编写健壮的代码更容易。也就是说,能够编写易于使用且难以误用的函数、类和库。

今天的 C++

总之,今天的 C++为程序员提供了编写富有表现力和健壮的代码基础的能力,同时还可以选择针对几乎任何硬件平台或实时需求。在今天最常用的语言中,只有 C++具有所有这些特性。

我已经简要介绍了为什么 C++仍然是一种相关且广泛使用的编程语言。在接下来的部分,我们将看看 C++与其他现代编程语言的比较。

与其他语言相比的 C++

自 C++首次发布以来,出现了大量的应用类型、平台和编程语言。然而,C++仍然是一种广泛使用的语言,其编译器适用于大多数平台。截至今天,唯一的例外是 Web 平台,JavaScript 及其相关技术是其基础。然而,Web 平台正在发展,能够执行以前只在桌面应用程序中可能的功能,在这种情况下,C++已经通过使用诸如 Emscripten、asm.js 和 WebAssembly 等技术进入了 Web 应用程序。

在这一部分,我们将首先从性能的角度比较竞争性语言。接下来,我们将看看 C++如何处理对象所有权和垃圾回收,以及如何避免在 C++中出现空对象。最后,我们将介绍一些 C++的缺点,用户在考虑语言是否适合其需求时应该牢记。

竞争性语言和性能

为了了解 C++如何实现与其他编程语言相比的性能,让我们讨论一些 C++与大多数其他现代编程语言之间的基本区别。

为简单起见,本节将重点比较 C++和 Java,尽管大部分比较也适用于基于垃圾收集器的其他编程语言,如 C#和 JavaScript。

首先,Java 编译为字节码,然后在应用程序执行时将其编译为机器代码,而大多数 C++实现直接将源代码编译为机器代码。尽管字节码和即时编译器在理论上可能能够实现与预编译的机器代码相同(或者在理论上甚至更好)的性能,但截至今天,它们通常做不到。不过,公平地说,它们对大多数情况来说表现得足够好。

其次,Java 以完全不同的方式处理动态内存,与 C++不同。在 Java 中,内存由垃圾收集器自动释放,而 C++程序通过手动或引用计数机制处理内存释放。垃圾收集器确实可以防止内存泄漏,但以性能和可预测性为代价。

第三,Java 将所有对象放在单独的堆分配中,而 C++允许程序员将对象放在堆和栈上。在 C++中,还可以在一个单一的堆分配中创建多个对象。这可以有两个原因带来巨大的性能提升:对象可以在不总是分配动态内存的情况下创建,并且多个相关对象可以相邻地放置在内存中。

看看下面的例子中内存是如何分配的。C++函数在栈上同时使用对象和整数;Java 将对象放在堆上:

C++ Java

|

class Car {
public:
  Car(int doors)
      : doors_(doors) {}
private:
  int doors_{}; 
};
auto some_func() {
  auto num_doors = 2;
  auto car1 = Car{num_doors};
  auto car2 = Car{num_doors};
  // ...
} 

|

class Car {
  public Car(int doors) { 
    doors_ = doors;
  }
  private int doors_;
  static void some_func() {
    int numDoors = 2;
    Car car1 = new Car(numDoors);
    Car car2 = new Car(numDoors);
    // ...
  }
} 

|

C++将所有内容都放在堆栈上: Java 将Car对象放在堆上:

现在让我们看看下一个例子,看看在使用 C++和 Java 时,Car对象的数组是如何放置在内存中的:

C++ Java

|

auto n = 4;
auto cars = std::vector<Car>{};
cars.reserve(n);
for (auto i=0; i<n;++i) {
   cars.push_back(Car{2});
} 

|

int n = 4;
ArrayList<Car> cars = 
  new ArrayList<Car>();
for (int i=0; i<n; i++) {
  cars.addElement(new Car(2));
} 

|

以下图表显示了在 C++中Car对象在内存中的布局: 以下图表显示了在 Java 中Car对象在内存中的布局:

C++向量包含放置在一个连续内存块中的实际Car对象,而 Java 中的等价物是对Car对象的引用的连续内存块。在 Java 中,对象已经分别分配,这意味着它们可以位于堆的任何位置。

这会影响性能,因为在这个例子中,Java 实际上需要在 Java 堆空间中执行五次分配。这也意味着每当应用程序迭代列表时,C++都会获得性能优势,因为访问附近的内存位置比访问内存中的几个随机位置更快。

C++语言的非性能相关特性

很容易认为只有在性能是主要关注点时才应该使用 C++。难道不是这样吗?C++只是因为手动内存处理而增加了代码库的复杂性,这可能导致内存泄漏和难以跟踪的错误吗?

这可能在几个 C++版本前是真的,但现代 C++程序员依赖于标准库中提供的容器和智能指针类型。在过去的 10 年中,C++增加的大部分特性使得这门语言更加强大和更容易使用。

我想在这里强调一些 C++的旧但强大的特性,这些特性与健壮性有关,而不是性能,很容易被忽视:值语义、const正确性、所有权、确定性销毁和引用。

值语义

C++支持值语义和引用语义。值语义允许我们按值传递对象,而不仅仅是传递对象的引用。在 C++中,值语义是默认的,这意味着当你传递一个类或结构的实例时,它的行为与传递intfloat或任何其他基本类型的行为相同。要使用引用语义,我们需要明确使用引用或指针。

C++类型系统使我们能够明确陈述对象的所有权。比较 C++和 Java 中一个简单类的以下实现。我们将从 C++版本开始:

// C++
class Bagel {
public:
  Bagel(std::set<std::string> ts) : toppings_(std::move(ts)) {}
private:
  std::set<std::string> toppings_;
}; 

在 Java 中对应的实现可能如下所示:

// Java
class Bagel {
  public Bagel(ArrayList<String> ts) { toppings_ = ts; }
  private ArrayList<String> toppings_;
} 

在 C++版本中,程序员声明toppings完全被Bagel类封装。如果程序员打算让夹料列表在几个百吉饼之间共享,它将被声明为某种指针:如果所有权在几个百吉饼之间共享,则为std::shared_ptr,如果其他人拥有夹料列表并且应该在程序执行时修改它,则为std::weak_ptr

在 Java 中,对象之间共享所有权。因此,无法区分夹心面包的夹料列表是打算在几个百吉饼之间共享还是不共享,或者它是否在其他地方处理,或者如果是在大多数情况下,是否完全由Bagel类拥有。

比较以下函数;由于在 Java(和大多数其他语言)中默认情况下每个对象都是共享的,程序员必须对诸如此类的微妙错误采取预防措施:

C++ Java

|

// Note how the bagels do
// not share toppings:
auto t = std::set<std::string>{};
t.insert("salt");
auto a = Bagel{t};
// 'a' is not affected
// when adding pepper
t.insert("pepper");
// 'a' will have salt
// 'b' will have salt & pepper 
auto b = Bagel{t};
// No bagel is affected
t.insert("oregano"); 

|

// Note how both the bagels
// share toppings:
TreeSet<String> t = 
  new TreeSet<String>();
t.add("salt");
Bagel a = new Bagel(t);
// Now 'a' will subtly 
// also have pepper
t.add("pepper");
// 'a' and 'b' share the
// toppings in 't'
Bagel b = new Bagel(t);
// Both bagels are affected
toppings.add("oregano"); 

|

const 正确性

C++的另一个强大特性是能够编写完全const正确的代码,而 Java 和许多其他语言则缺乏这一能力。Const 正确性意味着类的每个成员函数签名都明确告诉调用者对象是否会被修改;如果调用者尝试修改声明为const的对象,则不会编译。在 Java 中,可以使用final关键字声明常量,但这缺乏将成员函数声明为const的能力。

以下是一个示例,说明如何使用const成员函数防止意外修改对象。在下面的Person类中,成员函数age()声明为const,因此不允许改变Person对象,而set_age()改变对象,不能声明为const

class Person {
public:
  auto age() const { return age_; }
  auto set_age(int age) { age_ = age; }
private:
  int age_{};
}; 

还可以区分返回可变和不可变引用的成员。在下面的Team类中,成员函数“leader() const”返回一个不可变的Person,而leader()返回一个可能被改变的Person对象:

class Team {
public:
  auto& leader() const { return leader_; }
  auto& leader() { return leader_; }
private:
  Person leader_{};
}; 

现在让我们看看编译器如何帮助我们找到在尝试改变不可变对象时的错误。在下面的示例中,函数参数teams声明为const,明确显示此函数不允许修改它们:

void nonmutating_func(const std::vector<Team>& teams) {
  auto tot_age = 0;

  // Compiles, both leader() and age() are declared const
  for (const auto& team : teams) 
    tot_age += team.leader().age();
  // Will not compile, set_age() requires a mutable object
  for (auto& team : teams) 
    team.leader().set_age(20);
} 

如果我们想编写一个可以改变teams对象的函数,我们只需删除const。这向调用者发出信号,表明此函数可能会改变teams

void mutating_func(std::vector<Team>& teams) {
  auto tot_age = 0;

  // Compiles, const functions can be called on mutable objects
  for (const auto& team : teams) 
    tot_age += team.leader().age();
  // Compiles, teams is a mutable variable
  for (auto& team : teams) 
    team.leader().set_age(20);
} 

对象所有权

除非在非常罕见的情况下,C++程序员应该将内存处理留给容器和智能指针,而不必依赖手动内存处理。

明确地说,通过使用std::shared_ptr可以在 C++中几乎模拟 Java 中的垃圾收集模型。请注意,垃圾收集语言不使用与std::shared_ptr相同的分配跟踪算法。std::shared_ptr是基于引用计数算法的智能指针,如果对象具有循环依赖关系,它将泄漏内存。垃圾收集语言具有更复杂的方法,可以处理和释放循环依赖对象。

然而,与依赖垃圾收集器不同,通过精心避免共享对象默认情况下的严格所有权,可以避免由此产生的微妙错误,就像 Java 中的情况一样。

如果程序员在 C++中最小化了共享所有权,生成的代码将更易于使用,更难被滥用,因为它可以强制类的用户按照预期使用它。

C++中的确定性销毁

在 C++中,对象的销毁是确定性的。这意味着我们(可以)确切地知道对象何时被销毁。而在 Java 等垃圾收集语言中,垃圾收集器决定未引用对象何时被终结,这种情况并非如此。

在 C++中,我们可以可靠地撤销对象生命周期中所做的操作。起初,这可能看起来微不足道。但事实证明,这对我们如何提供异常安全保证以及在 C++中处理资源(如内存、文件句柄、互斥锁等)有很大影响。

确定性销毁也是使 C++可预测的特性之一。这是程序员非常重视的东西,也是对性能关键应用的要求。

我们将在本书的后面花更多时间讨论对象所有权、生命周期和资源管理。因此,如果目前这些内容还不太清楚,不要太担心。

使用 C++引用避免空对象

除了严格的所有权外,C++还有引用的概念,这与 Java 中的引用不同。在内部,引用是一个不允许为空或重新指向的指针;因此,当将其传递给函数时不涉及复制。

因此,C++中的函数签名可以明确限制程序员传递 null 对象作为参数。在 Java 中,程序员必须使用文档或注释来指示非 null 参数。

看一下这两个用于计算球体体积的 Java 函数。第一个如果传递了 null 对象就会抛出运行时异常,而第二个则会悄悄地忽略 null 对象。

在 Java 中,第一个实现如果传递了 null 对象就会抛出运行时异常:

// Java
float getVolume1(Sphere s) {
  float cube = Math.pow(s.radius(), 3);
  return (Math.PI * 4 / 3) * cube; 
} 

在 Java 中,第二个实现会悄悄地处理 null 对象:

// Java
float getVolume2(Sphere s) { 
  float rad = s == null ? 0.0f : s.radius();
  float cube = Math.pow(rad, 3);
  return (Math.PI * 4 / 3) * cube;
} 

在 Java 中实现的这两个函数中,调用函数的人必须检查函数的实现,以确定是否允许 null 对象。

在 C++中,第一个函数签名明确只接受通过引用初始化的对象,引用不能为 null。使用指针作为参数的第二个版本明确显示了处理 null 对象。

C++中作为引用传递的参数表示不允许 null 值:

auto get_volume1(const Sphere& s) {   
  auto cube = std::pow(s.radius(), 3.f);
  auto pi = 3.14f;
  return (pi * 4.f / 3.f) * cube;
} 

C++中作为指针传递的参数表示正在处理 null 值:

auto get_volume2(const Sphere* s) {
  auto rad = s ? s->radius() : 0.f;
  auto cube = std::pow(rad, 3);
  auto pi = 3.14f;
  return (pi * 4.f / 3.f) * cube;
} 

能够在 C++中使用引用或值作为参数立即告知 C++程序员函数的预期使用方式。相反,在 Java 中,用户必须检查函数的实现,因为对象总是作为指针传递,并且存在它们可能为 null 的可能性。

C++的缺点

如果不提及一些缺点,将 C++与其他编程语言进行比较是不公平的。正如前面提到的,C++有更多的概念需要学习,因此更难正确使用和发挥其全部潜力。然而,如果程序员能够掌握 C++,更高的复杂性就会变成优势,代码库变得更加健壮并且性能更好。

然而,C++也有一些缺点,这些缺点只是缺点。其中最严重的是长时间的编译时间和导入库的复杂性。直到 C++20,C++一直依赖于一个过时的导入系统,其中导入的头文件只是简单地粘贴到需要它们的地方。C++20 中引入的模块将解决系统的一些问题,该系统基于包含头文件,并且还将对大型项目的编译时间产生积极影响。

C++的另一个明显缺点是缺乏提供的库。而其他语言通常提供大多数应用程序所需的所有库,例如图形、用户界面、网络、线程、资源处理等,C++提供的几乎只是最基本的算法、线程,以及从 C++17 开始的文件系统处理。对于其他一切,程序员必须依赖外部库。

总之,尽管 C++的学习曲线比大多数其他语言要陡峭,但如果使用正确,C++的健壮性与许多其他语言相比是一个优势。因此,尽管编译时间长且缺乏提供的库,我认为 C++是一个非常适合大型项目的语言,即使对于性能不是最高优先级的项目也是如此。

本书中使用的库和编译器

正如前面提到的,C++在库方面并没有提供更多的东西。因此,在本书中,我们必须在必要时依赖外部库。在 C++世界中最常用的库可能是 Boost 库(www.boost.org)。

本书的一些部分使用了 Boost 库,因为标准 C++库不够。我们只会使用 Boost 库的头文件部分,这意味着使用它们自己不需要任何特定的构建设置;而只需要包含指定的头文件即可。

此外,我们将使用 Google Benchmark,一个微基准支持库,来评估小代码片段的性能。Google Benchmark 将在第三章 分析和测量性能中介绍。

可在github.com/PacktPublishing/Cpp-High-Performance-Second-Edition找到本书的存储库,其中包含了书中的源代码,使用了 Google Test 框架,使您更容易构建、运行和测试代码。

还应该提到,本书使用了很多来自 C++20 的新功能。在撰写本文时,我们使用的编译器(Clang、GCC 和 Microsoft Visual C++)尚未完全实现其中一些功能。其中一些功能完全缺失或仅支持实验性功能。关于主要 C++编译器当前状态的最新摘要可以在en.cppreference.com/w/cpp/compiler_support找到。

总结

在本章中,我已经强调了 C++的一些特点和缺点,以及它是如何发展到今天的状态的。此外,我们讨论了 C++与其他语言相比的优缺点,从性能和健壮性的角度来看。

在下一章中,我们将探讨一些对 C++语言发展产生重大影响的现代和基本功能。

第二章:基本的 C++技术

在本章中,我们将深入研究一些基本的 C++技术,如移动语义、错误处理和 lambda 表达式,这些技术将贯穿本书使用。即使是经验丰富的 C++程序员,有些概念仍然会让人困惑,因此我们将探讨它们的用例和工作原理。

本章将涵盖以下主题:

  • 自动类型推导以及在声明函数和变量时如何使用auto关键字。

  • 移动语义和五法则零法则

  • 错误处理和契约。虽然这些主题并没有提供可以被视为现代 C++的任何内容,但异常和契约在当今的 C++中都是高度争议的领域。

  • 使用 lambda 表达式创建函数对象,这是 C++11 中最重要的功能之一。

让我们首先来看一下自动类型推导。

使用 auto 关键字进行自动类型推导

自从 C++11 引入了auto关键字以来,C++社区对如何使用不同类型的auto(如const auto&auto&auto&&decltype(auto))产生了很多困惑。

在函数签名中使用 auto

尽管一些 C++程序员不赞成,但在我的经验中,在函数签名中使用auto可以增加可读性,方便浏览和查看头文件。

以下是auto语法与显式类型的传统语法相比的样子:

显式类型的传统语法: 使用 auto 的新语法:

|

struct Foo {
  int val() const {    return m_;   }  const int& cref() const {    return m_;   }  int& mref() {    return m_;   }  int m_{};}; 

|

struct Foo {
  auto val() const {    return m_;   }  auto& cref() const {    return m_;   }  auto& mref() {    return m_;   }  int m_{};}; 

|

auto语法可以在有或没有尾随返回类型的情况下使用。在某些情境下,尾随返回类型是必要的。例如,如果我们正在编写虚函数,或者函数声明放在头文件中,函数定义在.cpp文件中。

请注意,auto语法也可以用于自由函数:

返回类型 语法变体(a、b 和 c 对应相同的结果):
auto val() const                // a) auto, deduced type
auto val() const -> int         // b) auto, trailing type
int val() const                 // c) explicit type 

|

常量引用
auto& cref() const              // a) auto, deduced type
auto cref() const -> const int& // b) auto, trailing type
const int& cref() const         // c) explicit type 

|

可变引用
auto& mref()                    // a) auto, deduced type
auto mref() -> int&             // b) auto, trailing type
int& mref()                     // c) explicit type 

|

使用 decltype(auto)进行返回类型转发

还有一种相对罕见的自动类型推导版本称为decltype(auto)。它最常见的用途是从函数中转发确切的类型。想象一下,我们正在为前面表格中声明的val()mref()编写包装函数,就像这样:

int val_wrapper() { return val(); }    // Returns int
int& mref_wrapper() { return mref(); } // Returns int& 

现在,如果我们希望对包装函数使用返回类型推导,auto关键字将在两种情况下推导返回类型为int

auto val_wrapper() { return val(); }   // Returns int
auto mref_wrapper() { return mref(); } // Also returns int 

如果我们希望mref_wrapper()返回int&,我们需要写auto&。在这个例子中,这是可以的,因为我们知道mref()的返回类型。然而,并非总是如此。因此,如果我们希望编译器选择与int&auto&相同的类型而不明确指定mref_wrapper()的返回类型,我们可以使用decltype(auto)

decltype(auto) val_wrapper() { return val(); }   // Returns int
decltype(auto) mref_wrapper() { return mref(); } // Returns int& 

通过这种方式,我们可以避免在不知道函数val()mref()返回的类型时明确选择写autoauto&。这通常发生在泛型代码中,其中被包装的函数的类型是模板参数。

使用 auto 声明变量

C++11 引入auto关键字引发了 C++程序员之间的激烈辩论。许多人认为它降低了可读性,甚至使 C++变得类似于动态类型语言。我倾向于不参与这些辩论,但我个人认为你应该(几乎)总是使用auto,因为在我的经验中,它使代码更安全,减少了混乱。

过度使用auto可能会使代码难以理解。在阅读代码时,我们通常想知道某个对象支持哪些操作。一个好的 IDE 可以为我们提供这些信息,但在源代码中并没有明确显示。C++20 概念通过关注对象的行为来解决这个问题。有关 C++概念的更多信息,请参阅第八章编译时编程

我喜欢使用auto来定义使用从左到右的初始化样式的局部变量。这意味着将变量保留在左侧,后跟一个等号,然后在右侧是类型,就像这样:

auto i = 0;
auto x = Foo{};
auto y = create_object();
auto z = std::mutex{};     // OK since C++17 

在 C++17 中引入了保证的拷贝省略,语句auto x = Foo{}Foo x{}是相同的;也就是说,语言保证在这种情况下没有需要移动或复制的临时对象。这意味着我们现在可以使用从左到右的初始化样式,而不用担心性能,我们还可以用于不可移动/不可复制的类型,如std::atomicstd::mutex

使用auto定义变量的一个很大的优势是,您永远不会留下未初始化的变量,因为auto x;不会编译。未初始化的变量是未定义行为的一个常见来源,您可以通过遵循这里建议的样式完全消除。

使用auto将帮助您使用正确的类型来定义变量。但您仍然需要通过指定需要引用还是副本,以及是否要修改变量或仅从中读取来表达您打算如何使用变量。

一个 const 引用

const引用,用const auto&表示,具有绑定到任何东西的能力。原始对象永远不会通过这样的引用发生变异。我认为const引用应该是潜在昂贵的对象的默认选择。

如果const引用绑定到临时对象,则临时对象的生命周期将延长到引用的生命周期。这在以下示例中得到了证明:

void some_func(const std::string& a, const std::string& b) {
  const auto& str = a + b;  // a + b returns a temporary
  // ...
} // str goes out of scope, temporary will be destroyed 

也可以通过使用auto&得到一个const引用。可以在以下示例中看到:

 auto foo = Foo{};
 auto& cref = foo.cref(); // cref is a const reference
 auto& mref = foo.mref(); // mref is a mutable reference 

尽管这是完全有效的,但最好始终明确表示我们正在处理const引用,使用const auto&,更重要的是,我们应该使用auto&仅表示可变引用。

一个可变引用

const引用相反,可变引用不能绑定到临时对象。如前所述,我们使用auto&来表示可变引用。只有在打算更改引用的对象时才使用可变引用。

转发引用

auto&&被称为转发引用(也称为通用引用)。它可以绑定到任何东西,这对某些情况很有用。转发引用将像const引用一样,延长临时对象的生命周期。但与const引用相反,auto&&允许我们改变它引用的对象,包括临时对象。

对于只转发到其他代码的变量,请使用auto&&。在这些转发情况下,您很少关心变量是const还是可变的;您只是想将其传递给实际要使用变量的一些代码。

重要的是要注意,只有在函数模板中使用T作为该函数模板的模板参数时,auto&&T&&才是转发引用。使用显式类型,例如std::string&&,带有&&语法表示右值引用,并且不具有转发引用的属性(右值和移动语义将在本章后面讨论)。

便于使用的实践

尽管这是我的个人意见,我建议对基本类型(intfloat等)和小的非基本类型(如std::pairstd::complex)使用const auto。对于潜在昂贵的大型类型,使用const auto&。这应该涵盖 C++代码库中大多数变量声明。

只有在需要可变引用或显式复制的行为时,才应使用auto&auto;这向代码的读者传达了这些变量的重要性,因为它们要么复制一个对象,要么改变一个引用的对象。最后,只在转发代码时使用auto&&

遵循这些规则可以使您的代码库更易于阅读、调试和理解。

也许会觉得奇怪,虽然我建议在大多数变量声明中使用const autoconst auto&,但在本书的某些地方我倾向于使用简单的auto。使用普通的auto的原因是书籍格式提供的有限空间。

在继续之前,我们将花一点时间讨论const以及在使用指针时如何传播const

指针的 const 传播

通过使用关键字const,我们可以告诉编译器哪些对象是不可变的。然后编译器可以检查我们是否尝试改变不打算改变的对象。换句话说,编译器检查我们的代码是否符合const-correctness。在 C++中编写const-correct 代码时的一个常见错误是,const初始化的对象仍然可以操作成员指针指向的值。以下示例说明了这个问题:

class Foo {
public:
  Foo(int* ptr) : ptr_{ptr} {} 
  auto set_ptr_val(int v) const { 
    *ptr_ = v; // Compiles despite function being declared const!
  }
private:
  int* ptr_{};
};
int main() {
  auto i = 0;
  const auto foo = Foo{&i};
  foo.set_ptr_val(42);
} 

虽然函数set_ptr_val()正在改变int值,但声明它为const是有效的,因为指针ptr_本身没有被改变,只有指针指向的int对象被改变。

为了以一种可读的方式防止这种情况,标准库扩展中添加了一个名为std::experimental::propagate_const的包装器(在撰写本文时,已包含在最新版本的 Clang 和 GCC 中)。使用propagate_const,函数set_ptr_val()将无法编译。请注意,propagate_const仅适用于指针和类似指针的类,如std::shared_ptrstd::unique_ptr,而不适用于std::function

以下示例演示了如何使用propagate_const在尝试在const函数内部改变对象时生成编译错误:

#include <experimental/propagate_const>
class Foo { 
public: 
  Foo(int* ptr) : ptr_{ptr} {}
  auto set_ptr(int* p) const { 
    ptr_ = p;  // Will not compile, as expected
  }
  auto set_val(int v) const { 
    val_ = v;  // Will not compile, as expected
  }
  auto set_ptr_val(int v) const { 
    *ptr_ = v; // Will not compile, const is propagated
  }
private:
  std::experimental::propagate_const<int*> ptr_ = nullptr; 
  int val_{}; 
}; 

在大型代码库中正确使用const的重要性不言而喻,而引入propagate_const使const-correctness 变得更加有效。

接下来,我们将看一下移动语义以及处理类内部资源的一些重要规则。

解释移动语义

移动语义是 C++11 中引入的一个概念,在我看来,即使是经验丰富的程序员也很难理解。因此,我将尝试为您深入解释它的工作原理,编译器如何利用它,以及为什么它是必要的。

基本上,C++之所以有移动语义的概念,而大多数其他语言没有,是因为它是一种基于值的语言,正如在《第一章 C++简介》中讨论的那样。如果 C++没有内置移动语义,那么基于值的语义的优势在许多情况下将会丢失,程序员将不得不进行以下折衷之一:

  • 执行性能成本高的冗余深克隆操作

  • 像 Java 一样使用对象指针,失去值语义的健壮性

  • 以牺牲可读性为代价进行容易出错的交换操作

我们不希望出现这些情况,所以让我们看看移动语义如何帮助我们。

复制构造、交换和移动

在深入了解移动的细节之前,我将首先解释并说明复制构造对象、交换两个对象和移动构造对象之间的区别。

复制构造对象

在复制处理资源的对象时,需要分配新资源,并且需要复制源对象的资源,以便使这两个对象完全分离。想象一下,我们有一个类Widget,它引用需要在构造时分配的某种资源。以下代码默认构造了一个Widget对象,然后复制构造了一个新实例:

auto a = Widget{}; 
auto b = a;        // Copy-construction 

所进行的资源分配如下图所示:

图 2.1:复制具有资源的对象

分配和复制是缓慢的过程,在许多情况下,源对象不再需要。使用移动语义,编译器会检测到这样的情况,其中旧对象不与变量绑定,而是执行移动操作。

交换两个对象

在 C++11 中添加移动语义之前,交换两个对象的内容是一种常见的在不分配和复制的情况下传输数据的方式。如下所示,对象只是互相交换它们的内容:

auto a = Widget{};
auto b = Widget{};
std::swap(a, b); 

以下图示说明了这个过程:

图 2.2:在两个对象之间交换资源

std::swap()函数是一个简单但有用的实用程序,在本章后面将介绍的复制和交换习语中使用。

移动构造对象

移动对象时,目标对象直接从源对象中夺取资源,而源对象被重置。

正如您所见,这与交换非常相似,只是移出的对象不必从移入对象那里接收资源:

auto a = Widget{}; 
auto b = std::move(a); // Tell the compiler to move the resource into b 

以下图示说明了这个过程:

图 2.3:将资源从一个对象移动到另一个对象

尽管源对象被重置,但它仍处于有效状态。源对象的重置不是编译器自动为我们执行的。相反,我们需要在移动构造函数中实现重置,以确保对象处于可以被销毁或赋值的有效状态。我们将在本章后面更多地讨论有效状态。

只有在对象类型拥有某种资源(最常见的情况是堆分配的内存)时,移动对象才有意义。如果所有数据都包含在对象内部,移动对象的最有效方式就是简单地复制它。

现在您已经基本掌握了移动语义,让我们深入了解一下细节。

资源获取和五法则

要完全理解移动语义,我们需要回到 C++中类和资源获取的基础概念。C++中的一个基本概念是,一个类应该完全处理其资源。这意味着当一个类被复制、移动、复制赋值、移动赋值或销毁时,类应该确保其资源得到适当处理。实现这五个函数的必要性通常被称为五法则

floats pointed at by the raw pointer ptr_:
class Buffer { 
public: 
  // Constructor 
  Buffer(const std::initializer_list<float>& values)       : size_{values.size()} { 
    ptr_ = new float[values.size()]; 
    std::copy(values.begin(), values.end(), ptr_); 
  }
  auto begin() const { return ptr_; } 
  auto end() const { return ptr_ + size_; } 
  /* The 5 special functions are defined below */
private: 
  size_t size_{0}; 
  float* ptr_{nullptr};
}; 

在这种情况下,处理的资源是在Buffer类的构造函数中分配的一块内存。内存可能是类处理的最常见资源,但资源可以是更多:互斥锁、图形卡上纹理的句柄、线程句柄等等。

在“五法则”中提到的五个函数已被省略,将在下文中介绍。我们将从复制构造函数、复制赋值运算符和析构函数开始,这些函数都需要参与资源处理:

// 1\. Copy constructor 
Buffer::Buffer(const Buffer& other) : size_{other.size_} { 
  ptr_ = new float[size_]; 
  std::copy(other.ptr_, other.ptr_ + size_, ptr_); 
} 
// 2\. Copy assignment 
auto& Buffer::operator=(const Buffer& other) {
  delete [] ptr_;
  ptr_ = new float[other.size_];
  size_ = other.size_;
  std::copy(other.ptr_, other.ptr_ + size_, ptr_);
  return *this;
} 
// 3\. Destructor 
Buffer::~Buffer() { 
  delete [] ptr_; // OK, it is valid to delete a nullptr
  ptr_ = nullptr;  
} 

在 C++11 中引入移动语义之前,这三个函数通常被称为三法则。复制构造函数、复制赋值运算符和析构函数在以下情况下被调用:

auto func() { 
  // Construct 
  auto b0 = Buffer({0.0f, 0.5f, 1.0f, 1.5f}); 
  // 1\. Copy-construct 
  auto b1 = b0; 
  // 2\. Copy-assignment as b0 is already initialized 
  b0 = b1; 
} // 3\. End of scope, the destructors are automatically invoked 

虽然正确实现这三个函数是类处理内部资源所需的全部内容,但会出现两个问题:

  • 无法复制的资源:在Buffer类示例中,我们的资源可以被复制,但还有其他类型的资源,复制是没有意义的。例如,类中包含的资源可能是std::thread、网络连接或其他无法复制的资源。在这些情况下,我们无法传递对象。

  • 不必要的复制:如果我们从函数中返回我们的Buffer类,整个数组都需要被复制。(编译器在某些情况下会优化掉复制,但现在让我们忽略这一点。)

解决这些问题的方法是移动语义。除了复制构造函数和复制赋值,我们还可以在我们的类中添加移动构造函数和移动赋值运算符。移动版本不是以const引用(const Buffer&)作为参数,而是接受Buffer&&对象。

&&修饰符表示参数是我们打算从中移动而不是复制的对象。用 C++术语来说,这被称为 rvalue,我们稍后会更详细地讨论这些。

copy()函数复制对象,移动等效函数旨在将资源从一个对象移动到另一个对象,释放被移动对象的资源。

这就是我们如何通过移动构造函数和移动赋值来扩展我们的Buffer类。如您所见,这些函数不会抛出任何异常,因此可以标记为noexcept。这是因为,与复制构造函数/复制赋值相反,它们不会分配内存或执行可能引发异常的操作:

// 4\. Move constructor
Buffer::Buffer(Buffer&& other) noexcept     : size_{other.size_}, ptr_{other.ptr_} {
  other.ptr_ = nullptr;
  other.size_ = 0;
}
// 5\. Move assignment
auto& Buffer::operator=(Buffer&& other) noexcept {
  ptr_ = other.ptr_;
  size_ = other.size_;
  other.ptr_ = nullptr;
  other.size_ = 0;
  return *this;
} 

现在,当编译器检测到我们执行了似乎是复制的操作,例如从函数返回一个Buffer,但复制的值不再被使用时,它将使用不抛出异常的移动构造函数/移动赋值代替复制。

这非常棒;接口保持与复制时一样清晰,但在底层,编译器执行了一个简单的移动。因此,程序员不需要使用任何奇怪的指针或输出参数来避免复制;因为类已经实现了移动语义,编译器会自动处理这个问题。

不要忘记将您的移动构造函数和移动赋值运算符标记为noexcept(除非它们可能抛出异常)。不标记它们为noexcept会阻止标准库容器和算法在某些条件下使用它们,而是转而使用常规的复制/赋值。

为了能够知道编译器何时允许移动对象而不是复制,需要了解 rvalue。

命名变量和 rvalue

那么,编译器何时允许移动对象而不是复制呢?简短的答案是,当对象可以被归类为 rvalue 时,编译器会移动对象。术语rvalue听起来可能很复杂,但本质上它只是一个不与命名变量绑定的对象,原因如下:

  • 它直接来自函数

  • 通过使用std::move(),我们可以将变量变成 rvalue

以下示例演示了这两种情况:

// The object returned by make_buffer is not tied to a variable
x = make_buffer();  // move-assigned
// The variable "x" is passed into std::move()
y = std::move(x);   // move-assigned 

在本书中,我还将交替使用术语lvalue命名变量。lvalue 对应于我们在代码中可以通过名称引用的对象。

现在,我们将通过在类中使用std::string类型的成员变量来使其更加高级。以下的Button类将作为一个例子:

class Button { 
public: 
  Button() {} 
  auto set_title(const std::string& s) { 
    title_ = s; 
  } 
  auto set_title(std::string&& s) { 
    title_ = std::move(s); 
  } 
  std::string title_; 
}; 

我们还需要一个返回标题和Button变量的自由函数:

auto get_ok() {
  return std::string("OK");
}
auto button = Button{}; 

在满足这些先决条件的情况下,让我们详细看一些复制和移动的案例:

  • Case 1Button::title_被移动赋值,因为string对象通过std::move()传递:
auto str = std::string{"OK"};
button.set_title(str);              // copy-assigned 
  • Case 2Button::title_被移动赋值,因为str通过std::move()传递:
auto str = std::string{"OK"};
button.set_title(std::move(str));   // move-assigned 
  • Case 3Button::title_被移动赋值,因为新的std::string对象直接来自函数:
button.set_title(get_ok());        // move-assigned 
  • Case 4Button::title_被复制赋值,因为string对象与s绑定(这与Case 1相同):
auto str = get_ok();
button.set_title(str);             // copy-assigned 
  • Case 5Button::title_被复制赋值,因为str被声明为const,因此不允许改变:
const auto str = get_ok();
button.set_title(std::move(str));  // copy-assigned 

如您所见,确定对象是移动还是复制非常简单。如果它有一个变量名,它就会被复制;否则,它就会被移动。如果您正在使用std::move()来移动一个命名对象,那么该对象就不能被声明为const

默认移动语义和零规则

本节讨论自动生成的复制赋值运算符。重要的是要知道生成的函数没有强异常保证。因此,如果在复制赋值期间抛出异常,对象可能最终处于部分复制的状态。

与复制构造函数和复制赋值一样,移动构造函数和移动赋值可以由编译器生成。尽管一些编译器允许在某些条件下自动生成这些函数(稍后会详细介绍),但我们可以通过使用default关键字简单地强制编译器生成它们。

对于不手动处理任何资源的Button类,我们可以简单地扩展它如下:

class Button {
public: 
  Button() {} // Same as before

  // Copy-constructor/copy-assignment 
  Button(const Button&) = default; 
  auto operator=(const Button&) -> Button& = default;
  // Move-constructor/move-assignment 
  Button(Button&&) noexcept = default; 
  auto operator=(Button&&) noexcept -> Button& = default; 
  // Destructor
  ~Button() = default; 
  // ...
}; 

更简单的是,如果我们不声明任何自定义复制构造函数/复制赋值或析构函数,移动构造函数/移动赋值将被隐式声明,这意味着第一个Button类实际上处理了一切:

class Button {
public: 
  Button() {} // Same as before

  // Nothing here, the compiler generates everything automatically! 
  // ...
}; 

很容易忘记只添加五个函数中的一个会阻止编译器生成其他函数。以下版本的Button类具有自定义析构函数。因此,移动运算符不会生成,并且该类将始终被复制:

class Button {
public: 
  Button() {} 
  ~Button() 
    std::cout << "destructed\n"
  }
  // ...
}; 

让我们看看在实现应用程序类时如何使用这些生成函数的见解。

实际代码库中的零规则

实际上,必须编写自己的复制/移动构造函数、复制/移动赋值和构造函数的情况应该非常少。编写类,使其不需要显式编写任何这些特殊成员函数(或声明为default)通常被称为零规则。这意味着如果应用程序代码库中的类需要显式编写任何这些函数,那么该代码片段可能更适合于代码库的一部分。

在本书的后面,我们将讨论std::optional,这是一个方便的实用类,用于处理可选成员,同时应用零规则。

关于空析构函数的说明

编写空析构函数可以防止编译器实现某些优化。如下片段所示,使用具有空析构函数的平凡类的数组复制产生与使用手工制作的for循环复制相同(非优化)的汇编代码。第一个版本使用具有std::copy()的空析构函数:

struct Point {
 int x_, y_;
 ~Point() {}     // Empty destructor, don't use!
};
auto copy(Point* src, Point* dst) {
  std::copy(src, src+64, dst);
} 

第二个版本使用了一个没有析构函数但有手工制作的for循环的Point类:

struct Point {
  int x_, y_;
};
auto copy(Point* src, Point* dst) {
  const auto end = src + 64;
  for (; src != end; ++src, ++dst) {
    *dst = *src;
  }
} 

两个版本生成以下 x86 汇编代码,对应一个简单的循环:

 xor eax, eax
.L2:
 mov rdx, QWORD PTR [rdi+rax]
 mov QWORD PTR [rsi+rax], rdx
 add rax, 8
 cmp rax, 512
 jne .L2
 rep ret 

但是,如果我们删除析构函数或声明析构函数为default,编译器将优化std::copy()以利用memmove()而不是循环:

struct Point { 
  int x_, y_; 
  ~Point() = default; // OK: Use default or no constructor at all
};
auto copy(Point* src, Point* dst) {
  std::copy(src, src+64, dst);
} 

前面的代码生成以下 x86 汇编代码,带有memmove()优化:

 mov rax, rdi
 mov edx, 512
 mov rdi, rsi
 mov rsi, rax
 jmp memmove 

汇编是使用Compiler Explorer中的 GCC 7.1 生成的,可在godbolt.org/上找到。

总之,使用default析构函数或根本不使用析构函数,以便在应用程序中挤出更多性能。

一个常见的陷阱-移动非资源

在使用默认创建的移动赋值时存在一个常见的陷阱:将基本类型与更高级的复合类型混合使用。与复合类型相反,基本类型(如intfloatbool)在移动时只是被复制,因为它们不处理任何资源。

当简单类型与拥有资源的类型混合在一起时,移动赋值成为移动和复制的混合。

这是一个将失败的类的示例:

class Menu {
public:
  Menu(const std::initializer_list<std::string>& items)       : items_{items} {}
  auto select(int i) {
    index_ = i;
  }
  auto selected_item() const {
     return index_ != -1 ? items_[index_] : "";
  }
  // ...
private:
  int index_{-1}; // Currently selected item
  std::vector<std::string> items_; 
}; 

如果像这样使用Menu类,它将具有未定义的行为:

auto a = Menu{"New", "Open", "Close", "Save"};
a.select(2);
auto b = std::move(a);
auto selected = a.selected_item(); // crash 

未定义的行为发生在items_向量被移动并且因此为空。另一方面,index_被复制,因此在移动的对象a中仍然具有值2。当调用selected_item()时,函数将尝试访问索引2处的items_,程序将崩溃。

在这些情况下,移动构造函数/赋值最好通过简单交换成员来实现,就像这样:

Menu(Menu&& other) noexcept { 
  std::swap(items_, other.items_); 
  std::swap(index_, other.index_); 
} 
auto& operator=(Menu&& other) noexcept { 
  std::swap(items_, other.items_); 
  std::swap(index_, other.index_); 
  return *this; 
} 

这种方式,Menu类可以安全地移动,同时保留无抛出保证。在第八章编译时编程中,您将学习如何利用 C++中的反射技术来自动创建交换元素的移动构造函数/赋值函数。

&&修饰符应用于类成员函数

除了应用于对象之外,您还可以向类的成员函数添加&&修饰符,就像您可以向成员函数应用const修饰符一样。与const修饰符一样,具有&&修饰符的成员函数只有在对象是右值时才会被重载解析考虑:

struct Foo { 
  auto func() && {} 
}; 
auto a = Foo{}; 
a.func();            // Doesn't compile, 'a' is not an rvalue 
std::move(a).func(); // Compiles 
Foo{}.func();        // Compiles 

也许有些奇怪,有人会想要这种行为,但确实有用例。我们将在第十章代理对象和延迟评估中调查其中之一。

当复制被省略时不要移动

当从函数返回值时,可能会诱人使用std::move(),就像这样:

auto func() {
  auto x = X{};
  // ...
  return std::move(x);  // Don't, RVO is prevented
} 

然而,除非x是一个仅移动类型,否则不应该这样做。使用std::move()会阻止编译器使用返回值优化RVO),从而完全省略了x的复制,这比移动更有效。因此,当通过值返回新创建的对象时,不要使用std::move();而是直接返回对象:

auto func() {
  auto x = X{};
  // ...
  return x;  // OK
} 

这种特定的例子,其中命名对象被省略,通常称为NRVONamed-RVO。 RVO 和 NRVO 由今天所有主要的 C++编译器实现。如果您想了解更多关于 RVO 和复制省略的信息,您可以在en.cppreference.com/w/cpp/language/copy_elision找到详细的摘要。

在适用时传递值

考虑一个将std::string转换为小写的函数。为了在适用时使用移动构造函数,在不适用时使用复制构造函数,似乎需要两个函数:

// Argument s is a const reference
auto str_to_lower(const std::string& s) -> std::string {
  auto clone = s;
  for (auto& c: clone) c = std::tolower(c);
  return clone;
}
// Argument s is an rvalue reference
auto str_to_lower(std ::string&& s) -> std::string {
  for (auto& c: s) c = std::tolower(c);
  return s;
} 

然而,通过按值传递std::string,我们可以编写一个函数来涵盖这两种情况:

auto str_to_lower(std::string s) -> std::string {
  for (auto& c: s) c = std::tolower(c);
  return s;
} 

让我们看看str_to_lower()的这种实现如何避免可能的不必要的复制。当传递一个常规变量时,如下所示,函数调用之前str的内容被复制构造到s中,然后在函数返回时移动分配回str

auto str = std::string{"ABC"};
str = str_to_lower(str); 

当传递一个右值时,如下所示,函数调用之前str的内容被移动构造到s中,然后在函数返回时移动分配回str。因此,没有通过函数调用进行复制:

auto str = std::string{"ABC"};
str = str_to_lower(std::move(str)); 

乍一看,这种技术似乎适用于所有参数。然而,这种模式并不总是最佳的,接下来您将看到。

不适用传值的情况

有时,接受按值然后移动的模式实际上是一种悲观化。例如,考虑以下类,其中函数set_data()将保留传递给它的参数的副本:

class Widget {
  std::vector<int> data_{};
  // ...
public:
  void set_data(std::vector<int> x) { 
    data_ = std::move(x);               
  }
}; 

假设我们调用set_data()并将一个左值传递给它,就像这样:

auto v = std::vector<int>{1, 2, 3, 4};
widget.set_data(v);                  // Pass an lvalue 

由于我们传递了一个命名对象v,代码将复制构造一个新的std::vector对象x,然后将该对象移动分配到data_成员中。除非我们将一个空的向量对象传递给set_data(),否则std::vector复制构造函数将为其内部缓冲区执行堆分配。

现在将其与set_data()的以下版本进行比较,该版本针对左值进行了优化:

void set_data(const std::vector<int>& x) { 
    data_ = x;  // Reuse internal buffer in data_ if possible
} 

在这里,如果当前向量data_的容量小于源对象x的大小,那么赋值运算符内部将只有一个堆分配。换句话说,在许多情况下,data_的内部预分配缓冲区可以在赋值运算符中被重用,从而避免额外的堆分配。

如果我们发现有必要优化set_data()以适应 lvalues 和 rvalues,最好在这种情况下提供两个重载:

void set_data(const std::vector<int>& x) {
  data_ = x;
}
void set_data(std::vector<int>&& x) noexcept { 
  data_ = std::move(x);
} 

第一个版本对于 lvalues 是最佳的,第二个版本对于 rvalues 是最佳的。

最后,我们现在将看一个场景,在这个场景中我们可以安全地传值,而不用担心刚刚演示的悲观情况。

移动构造函数参数

在构造函数中初始化类成员时,我们可以安全地使用传值然后移动的模式。在构造新对象时,没有机会利用预分配的缓冲区来避免堆分配。接下来是一个具有一个std::vector成员和一个构造函数的类的示例,用于演示这种模式:

class Widget {
  std::vector<int> data_;
public:
  Widget(std::vector<int> x)       // By value
      : data_{std::move(x)} {}     // Move-construct
  // ...
}; 

我们现在将把焦点转移到一个不能被视为现代 C++但即使在今天也经常被讨论的话题。

设计带有错误处理的接口

错误处理是函数和类接口中重要但经常被忽视的部分。错误处理是 C++中一个备受争议的话题,但讨论往往倾向于异常与其他错误机制之间的对比。虽然这是一个有趣的领域,但在关注错误处理的实际实现之前,还有其他更重要的错误处理方面需要理解。显然,异常和错误码在许多成功的软件项目中都被使用过,而且经常会遇到将两者结合在一起的项目。

无论编程语言如何,错误处理的一个基本方面是区分编程错误(也称为错误)和运行时错误。运行时错误可以进一步分为可恢复的运行时错误不可恢复的运行时错误。不可恢复的运行时错误的一个例子是堆栈溢出(见第七章内存管理)。当发生不可恢复的错误时,程序通常会立即终止,因此没有必要发出这些类型的错误。然而,一些错误在某种类型的应用程序中可能被认为是可恢复的,但在其他应用程序中是不可恢复的。

讨论可恢复和不可恢复错误时经常出现的一个边缘情况是 C++标准库在内存耗尽时的不太幸运的行为。当程序耗尽内存时,这通常是不可恢复的,但标准库在这种情况下会尝试抛出std::bad_alloc异常。我们不会在这里花时间讨论不可恢复的错误,但是 Herb Sutter 的演讲《De-fragmenting C++: Making Exceptions and RTTI More Affordable and Usable》(sched.co/SiVW)非常推荐,如果你想深入了解这个话题。

在设计和实现 API 时,您应该始终反思您正在处理的错误类型,因为不同类别的错误应该以完全不同的方式处理。决定错误是编程错误还是运行时错误可以通过使用一种称为设计契约的方法来完成;这是一个值得一本书的话题。然而,我在这里将介绍足够我们目的的基本原则。

有关在 C++中添加契约语言支持的提案,但目前契约尚未成为标准的一部分。然而,许多 C++ API 和指南都假定您了解契约的基础知识,因为契约使用的术语使得更容易讨论和记录类和函数的接口。

契约

合同是调用某个函数的调用者和函数本身(被调用者)之间的一组规则。C++允许我们使用 C++类型系统明确指定一些规则。例如,考虑以下函数签名:

int func(float x, float y) 

它指定func()返回一个整数(除非它抛出异常),并且调用者必须传递两个浮点值。但它并没有说明允许使用什么浮点值。例如,我们可以传递值 0.0 或负值吗?此外,xy之间可能存在一些必需的关系,这些关系不能很容易地使用 C++类型系统来表达。当我们谈论 C++中的合同时,通常指的是调用者和被调用者之间存在的一些规则,这些规则不能很容易地使用类型系统来表达。

在不太正式的情况下,这里将介绍与设计合同相关的一些概念,以便为您提供一些可以用来推理接口和错误处理的术语:

  • 前置条件指定了函数的调用者责任。对函数传递的参数可能有约束。或者,如果它是一个成员函数,在调用函数之前对象可能必须处于特定状态。例如,在std::vector上调用pop_back()时的前置条件是向量不为空。确保向量不为空是pop_back()调用者的责任。

  • 后置条件指定了函数返回时的职责。如果它是一个成员函数,函数在什么状态下离开对象?例如,std::list::sort()的后置条件是列表中的元素按升序排序。

  • 不变量是一个应该始终成立的条件。不变量可以在许多情境中使用。循环不变量是每次循环迭代开始时必须为真的条件。此外,类不变量定义了对象的有效状态。例如,std::vector的不变量是size() <= capacity()。明确陈述某些代码周围的不变量使我们更好地理解代码。不变量也是一种工具,可以用来证明某些算法是否按预期运行。

类不变量非常重要;因此,我们将花费更多时间讨论它们是什么以及它们如何影响类的设计。

类不变量

如前所述,类不变量定义了对象的有效状态。它指定了类内部数据成员之间的关系。在执行成员函数时,对象可能暂时处于无效状态。重要的是,当函数将控制权传递给可以观察对象状态的其他代码时,不变量得到维持。这可能发生在函数:

  • 返回

  • 抛出异常

  • 调用回调函数

  • 调用可能观察当前调用对象状态的其他函数;一个常见的情况是将this的引用传递给其他函数

重要的是要意识到类不变量是类的每个成员函数的前置条件和后置条件的隐含部分。如果成员函数使对象处于无效状态,则未满足后置条件。类似地,成员函数在调用函数时始终可以假定对象处于有效状态。这条规则的例外是类的构造函数和析构函数。如果我们想要插入代码来检查类不变量是否成立,我们可以在以下点进行:

struct Widget {
  Widget() {
    // Initialize object…
    // Check class invariant
  }
  ~Widget() {
    // Check class invariant
    // Destroy object…
   }
   auto some_func() {
     // Check precondition (including class invariant)
     // Do the actual work…
     // Check postcondition (including class invariant)
   }
}; 

复制/移动构造函数和复制/移动赋值运算符在这里没有提到,但它们遵循与构造函数和some_func()相同的模式。

当对象已被移动后,对象可能处于某种空或重置状态。这也是对象的有效状态之一,因此是类不变式的一部分。然而,通常只有少数成员函数可以在对象处于此状态时调用。例如,您不能在已移动的std::vector上调用push_back()empty()size(),但可以调用clear(),这将使向量处于准备再次使用的状态。

您应该知道,这种额外的重置状态使类不变式变得更弱,也更不实用。为了完全避免这种状态,您应该以这样的方式实现您的类,使得已移动的对象被重置为对象在默认构造后的状态。我的建议是总是这样做,除非在很少的情况下,将已移动的状态重置为默认状态会带来无法接受的性能损失。这样,您可以更好地推理有关已移动状态的情况,而且类的使用更安全,因为在该对象上调用成员函数是可以的。

如果您可以确保对象始终处于有效状态(类不变式成立),那么您可能会拥有一个难以被误用的类,如果实现中存在错误,通常很容易发现。您最不希望的是在代码库中找到一个类,并想知道该类的某些行为是一个错误还是一个特性。违反合同始终是一个严重的错误。

为了能够编写有意义的类不变式,我们需要编写具有高内聚性和少可能状态的类。如果您曾经为自己编写的类编写单元测试,您可能会注意到,在编写单元测试时,很明显可以从初始版本改进 API。单元测试迫使您使用和反思类的接口而不是实现细节。同样,类不变式使您考虑对象可能处于的所有有效状态。如果您发现很难定义类不变式,通常是因为您的类承担了太多的责任并处理了太多的状态。因此,定义类不变式通常意味着您最终会得到设计良好的类。

维护合同

合同是您设计和实现的 API 的一部分。但是,您如何维护和向使用您的 API 的客户端传达合同呢?C++尚没有内置支持合同的功能,但正在进行工作以将其添加到未来的 C++版本中。不过,也有一些选择:

  • 使用诸如 Boost.Contract 之类的库。

  • 记录合同。这样做的缺点是在运行程序时不会检查合同。此外,文档往往在代码更改时过时。

  • 使用static_assert()<cassert>中定义的assert()宏。断言是可移植的,标准的 C++。

  • 构建一个自定义库,其中包含类似断言的自定义宏,但对失败合同的行为具有更好的控制。

在本书中,我们将使用断言,这是检查合同违规的最原始的方式之一。然而,断言可以非常有效,并对代码质量产生巨大影响。

启用和禁用断言

从技术上讲,在 C++中有两种标准的断言方式:使用<cassert>头文件中的static_assert()assert()宏。static_assert()在代码编译期间进行验证,因此需要一个可以在编译时而不是运行时进行检查的表达式。失败的static_assert()会导致编译错误。

对于只能在运行时评估的断言,您需要使用assert()宏。assert()宏是一种运行时检查,通常在调试和测试期间处于活动状态,并在以发布模式构建程序时完全禁用。assert()宏通常定义如下:

#ifdef NDEBUG
#define assert(condition) ((void)0)
#else
#define assert(condition) /* implementation defined */
#endif 

这意味着您可以通过定义NDEBUG完全删除所有断言和用于检查条件的代码。

现在,有了一些设计合同的术语,让我们专注于合同违反(错误)以及如何在您的代码中处理它们。

错误处理

在设计具有适当错误处理的 API 时,首先要做的是区分编程错误和运行时错误。因此,在我们深入讨论错误处理策略之前,我们将使用设计合同来定义我们正在处理的错误类型。

编程错误还是运行时错误?

如果我们发现合同违反,我们也发现了我们程序中的错误。例如,如果我们可以检测到有人在空向量上调用pop_back(),我们知道我们的源代码中至少有一个错误需要修复。每当前提条件不满足时,我们知道我们正在处理一个编程错误

另一方面,如果我们有一个从磁盘加载某个记录的函数,并且由于磁盘上的读取错误而无法返回记录,那么我们已经检测到了一个运行时错误

auto load_record(std::uint32_t id) {
  assert(id != 0);           // Precondition
  auto record = read(id);    // Read from disk, may throw
  assert(record.is_valid()); // Postcondition
  return record;
} 

前提条件得到满足,但由于程序外部的某些原因,后置条件无法满足。源代码中没有错误,但由于某些与磁盘相关的错误,函数无法返回在磁盘上找到的记录。由于无法满足后置条件,必须将运行时错误报告给调用者,除非调用者可以自行通过重试等方式恢复。

编程错误(错误)

一般来说,编写代码来发出并处理代码中的错误没有意义。相反,使用断言(或先前提到的其他一些替代方案)来使开发人员意识到代码中的问题。您应该只对可恢复的运行时错误使用异常或错误代码。

通过假设缩小问题空间

断言指定了您作为某些代码的作者所做的假设。只有在您的代码中的所有断言都为真时,您才能保证代码按预期工作。这使编码变得更容易,因为您可以有效地限制需要处理的情况数量。断言在您的团队使用、阅读和修改您编写的代码时也是巨大的帮助。所有假设都以断言语句的形式清楚地记录下来。

使用断言查找错误

失败的断言总是严重的错误。当您在测试过程中发现一个失败的断言时,基本上有三种选择:

  • 断言是正确的,但代码是错误的(要么是因为函数实现中的错误,要么是因为调用站点上的错误)。根据我的经验,这是最常见的情况。通常情况下,使断言正确比使其周围的代码正确要容易得多。修复代码并重新测试。

  • 代码是正确的,但断言是错误的。有时会发生这种情况,如果您看的是旧代码,通常会感到非常不舒服。更改或删除失败的断言可能会耗费时间,因为您需要确保代码实际上是有效的,并理解为什么旧断言突然开始失败。通常,这是因为原始作者没有考虑到一个新的用例。

  • 断言和代码都是错误的。这通常需要重新设计类或函数。也许要求已经改变,程序员所做的假设不再成立。但不要绝望;相反,您应该高兴那些假设是明确地使用断言写出来的;现在您知道为什么代码不再起作用了。

运行时断言需要测试,否则断言将不会被执行。新编写的带有许多断言的代码通常在测试时会出现故障。这并不意味着您是一个糟糕的程序员;这意味着您添加了有意义的断言,可以捕获一些本来可能会进入生产的错误。此外,使测试版本的程序终止的错误也很可能会被修复。

性能影响

在代码中有许多运行时断言很可能会降低测试构建的性能。然而,断言从不应该在优化程序的最终版本中使用。如果您的断言使您的测试构建速度太慢而无法使用,通常很容易在分析器中跟踪到减慢代码速度的断言集(有关分析器的更多信息,请参见第三章分析和测量性能)。

通过使程序的发布构建完全忽略由错误引起的错误状态,程序将不会花时间检查由错误引起的错误状态。相反,您的代码将运行得更快,只花时间解决它本来要解决的实际问题。它只会检查需要恢复的运行时错误。

总结一下,编程错误应该在测试程序时被检测出来。没有必要使用异常或其他错误处理机制来处理编程错误。相反,编程错误应该记录一些有意义的东西,并终止程序,以通知程序员需要修复错误。遵循这一准则显著减少了我们需要在代码中处理异常的地方。我们在优化构建中会有更好的性能,希望由于断言失败而检测到的错误会更少。然而,有些情况下可能会发生运行时错误,这些错误需要被我们实现的代码处理和恢复。

可恢复的运行时错误

如果一个函数无法履行其合同的一部分(即后置条件),则发生了运行时错误,需要将其通知到可以处理并恢复有效状态的代码中。处理可恢复错误的目的是将错误从发生错误的地方传递到可以恢复有效状态的地方。有许多方法可以实现这一点。这是一个硬币的两面:

  • 对于信号部分,我们可以选择 C++异常、错误代码、返回std::optionalstd::pair,或使用boost::outcomestd::experimental::expected

  • 保持程序的有效状态而不泄漏任何资源。确定性析构函数和自动存储期是 C++中使这成为可能的工具。

实用类std::optionalstd::pair将在第九章基本实用程序中介绍。现在我们将专注于 C++异常以及如何在从错误中恢复时避免泄漏资源。

异常

异常是 C++提供的标准错误处理机制。该语言设计用于与异常一起使用。一个例子是构造函数失败;从构造函数中发出错误的唯一方法是使用异常。

根据我的经验,异常以许多不同的方式使用。造成这种情况的一个原因是不同的应用在处理运行时错误时可能有非常不同的要求。对于一些应用,比如起搏器或发电厂控制系统,如果它们崩溃可能会产生严重影响,我们可能必须处理每种可能的异常情况,比如内存耗尽,并保持应用程序处于运行状态。有些应用甚至完全不使用堆内存,要么是因为平台根本没有可用的堆,要么是因为堆引入了无法控制的不确定性,因为分配新内存的机制超出了应用程序的控制。

我假设您已经知道抛出和捕获异常的语法,并且不会在这里涵盖它。可以标记为noexcept的函数保证不会抛出异常。重要的是要理解编译器验证这一点;相反,这取决于代码的作者来弄清楚他们的函数是否可能抛出异常。

标记为noexcept的函数在某些情况下可以使编译器生成更快的代码。如果从标记为noexcept的函数中抛出异常,程序将调用std::terminate()而不是展开堆栈。以下代码演示了如何将函数标记为不抛出异常:

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

您可能会注意到,本书中的许多代码示例即使在生产代码中也适用noexcept(或const),也没有使用。这仅仅是因为书的格式;如果在我通常会添加noexceptconst的所有地方添加它们,会使代码难以阅读。

保持有效状态

异常处理要求我们程序员考虑异常安全性保证;也就是说,在异常发生之前和之后程序的状态是什么?强异常安全性可以被视为一个事务。一个函数要么提交所有状态更改,要么在发生异常时执行完全回滚。

为了使这更具体化,让我们来看一个简单的函数:

void func(std::string& str) {
  str += f1();  // Could throw
  str += f2();  // Could throw
} 

该函数将f1()f2()的结果附加到字符串str。现在考虑一下,如果调用函数f2()时抛出异常会发生什么;只有f1()的结果会附加到str。相反,我们希望在发生异常时str保持不变。这可以通过使用一种称为复制和交换的惯用法来解决。这意味着我们在让应用程序状态被非抛出swap()函数修改之前,在临时副本上执行可能引发异常的操作:

void func(std::string& str) {
  auto tmp = std::string{str};  // Copy
  tmp += f1();                  // Mutate copy, may throw
  tmp += f2();                  // Mutate copy, may throw
  std::swap(tmp, str);          // Swap, never throws
} 

相同的模式可以在成员函数中使用,以保持对象的有效状态。假设我们有一个类,其中包含两个数据成员和一个类不变式,该不变式规定数据成员不能相等,如下所示:

class Number { /* ... */ };
class Widget {
public:
  Widget(const Number& x, const Number& y) : x_{x}, y_{y} {
    assert(is_valid());           // Check class invariant
  }
private:
  Number x_{};
  Number y_{};
  bool is_valid() const {         // Class invariant
   return x_ != y_;               // x_ and y_ must not be equal
  }
}; 

接下来,假设我们正在添加一个成员函数,该函数更新两个数据成员,如下所示:

void Widget::update(const Number& x, const Number& y) {
  assert(x != y && is_valid());   // Precondition
  x_ = x;
  y_ = y;          
  assert(is_valid());             // Postcondition
} 

前提条件规定xy不能相等。如果x_y_的赋值可能会抛出异常,x_可能会被更新,但y_不会。这可能导致破坏类不变式;也就是说,对象处于无效状态。如果发生错误,我们希望函数保持对象在赋值操作之前的有效状态。再次,一个可能的解决方案是使用复制和交换惯用法:

void Widget::update(const Number& x, const Number& y) {
    assert(x != y && is_valid());     // Precondition
    auto x_tmp = x;  
    auto y_tmp = y;  
    std::swap(x_tmp, x_); 
    std::swap(y_tmp, y_); 
    assert(is_valid());               // Postcondition
  } 

首先,创建本地副本,而不修改对象的状态。然后,如果没有抛出异常,可以使用非抛出swap()来更改对象的状态。复制和交换惯用法也可以在实现赋值运算符时使用,以实现强异常安全性保证。

错误处理的另一个重要方面是避免在发生错误时泄漏资源。

资源获取

C++对象的销毁是可预测的,这意味着我们完全控制我们何时以及以何种顺序释放我们获取的资源。在下面的示例中进一步说明了这一点,当退出函数时,互斥变量m总是被解锁,因为作用域锁在我们退出作用域时释放它,无论我们如何以及在何处退出:

auto func(std::mutex& m, bool x, bool y) {
  auto guard = std::scoped_lock{m}; // Lock mutex 
  if (x) { 
    // The guard automatically releases the mutex at early exit
    return; 
  }
  if (y) {
    // The guard automatically releases if an exception is thrown
    throw std::exception{};
  }
  // The guard automatically releases the mutex at function exit
} 

所有权、对象的生命周期和资源获取是 C++中的基本概念,我们将在第七章 内存管理中进行讨论。

性能

不幸的是,异常在性能方面声誉不佳。一些担忧是合理的,而一些是基于历史观察的,当时编译器没有有效地实现异常。然而,今天人们放弃异常的两个主要原因是:

  • 即使不抛出异常,二进制程序的大小也会增加。尽管这通常不是问题,但它并不遵循零开销原则,因为我们为我们不使用的东西付费。

  • 抛出和捕获异常相对昂贵。抛出和捕获异常的运行时成本是不确定的。这使得异常在具有硬实时要求的情况下不适用。在这种情况下,其他替代方案,如返回带有返回值和错误代码的std::pair可能更好。

另一方面,当没有抛出异常时,异常的性能表现非常出色;也就是说,当程序遵循成功路径时。其他错误报告机制,如错误代码,即使在程序没有任何错误时也需要在if-else语句中检查返回代码。

异常情况应该很少发生,通常当异常发生时,异常处理所增加的额外性能损耗通常不是这些情况的问题。通常可以在一些性能关键代码运行之前或之后执行可能引发异常的计算。这样,我们可以避免在程序中不能容忍异常的地方抛出和捕获异常。

为了公平比较异常和其他错误报告机制,重要的是要指定要比较的内容。有时异常与根本没有错误处理的情况进行比较是不公平的;异常需要与提供相同功能的机制进行比较。在你测量它们可能产生的影响之前,不要因为性能原因而放弃异常。你可以在下一章中了解更多关于分析和测量性能的内容。

现在我们将远离错误处理,探讨如何使用 lambda 表达式创建函数对象。

函数对象和 lambda 表达式

Lambda 表达式,引入于 C++11,并在每个 C++版本中进一步增强,是现代 C++中最有用的功能之一。它们的多功能性不仅来自于轻松地将函数传递给算法,还来自于在许多需要传递代码的情况下的使用,特别是可以将 lambda 存储在std::function中。

尽管 lambda 使得这些编程技术变得更加简单易用,但本节提到的所有内容都可以在没有 lambda 的情况下执行。lambda,或者更正式地说,lambda 表达式是构造函数对象的一种便捷方式。但是,我们可以不使用 lambda 表达式,而是实现重载了operator()的类,然后实例化这些类来创建函数对象。

我们将在稍后探讨 lambda 与这些类的相似之处,但首先我将在一个简单的用例中介绍 lambda 表达式。

C++ lambda 的基本语法

简而言之,lambda 使程序员能够像传递变量一样轻松地将函数传递给其他函数。

让我们比较将 lambda 传递给算法和将变量传递给算法:

// Prerequisite 
auto v = std::vector{1, 3, 2, 5, 4}; 

// Look for number three 
auto three = 3; 
auto num_threes = std::count(v.begin(), v.end(), three); 
// num_threes is 1 

// Look for numbers which is larger than three 
auto is_above_3 = [](int v) { return v > 3; }; 
auto num_above_3 = std::count_if(v.begin(), v.end(), is_above_3);
// num_above_3 is 2 

在第一种情况下,我们将一个变量传递给std::count(),而在后一种情况下,我们将一个函数对象传递给std::count_if()。这是 lambda 的典型用例;我们传递一个函数,由另一个函数(在本例中是std::count_if())多次评估。

此外,lambda 不需要与变量绑定;就像我们可以将变量直接放入表达式中一样,我们也可以将 lambda 放入表达式中:

auto num_3 = std::count(v.begin(), v.end(), 3); 
auto num_above_3 = std::count_if(v.begin(), v.end(), [](int i) { 
  return i > 3; 
}); 

到目前为止,你看到的 lambda 被称为无状态 lambda;它们不复制或引用 lambda 外部的任何变量,因此不需要任何内部状态。让我们通过使用捕获块引入有状态 lambda来使其更加高级。

捕获子句

在前面的例子中,我们在 lambda 中硬编码了值3,以便我们始终计算大于三的数字。如果我们想在 lambda 中使用外部变量怎么办?我们通过将外部变量放入捕获子句(即 lambda 的[]部分)来捕获外部变量:

auto count_value_above(const std::vector<int>& v, int x) { 
  auto is_above = x { return i > x; }; 
  return std::count_if(v.begin(), v.end(), is_above); 
} 

在这个例子中,我们通过将变量x复制到 lambda 中来捕获它。如果我们想要将x声明为引用,我们在开头加上&,像这样:

auto is_above = &x { return i > x; }; 

该变量现在只是外部x变量的引用,就像 C++中的常规引用变量一样。当然,我们需要非常小心引用到 lambda 中的对象的生命周期,因为 lambda 可能在引用的对象已经不存在的情况下执行。因此,通过值捕获更安全。

通过引用捕获与通过值捕获

使用捕获子句引用和复制变量的工作方式与常规变量一样。看看这两个例子,看看你能否发现区别:

通过值捕获 通过引用捕获

|

auto func() {
  auto vals = {1,2,3,4,5,6};
  auto x = 3;
  auto is_above = x {
    return v > x;
  };
  x = 4;
  auto count_b = std::count_if(
    vals.begin(),
    vals.end(),
    is_above
   );  // count_b equals 3 } 

|

auto func() {
  auto vals = {1,2,3,4,5,6};
  auto x = 3;
  auto is_above = &x {
    return v > x;
  };
  x = 4;
  auto count_b = std::count_if(
    vals.begin(),
    vals.end(),
    is_above
   );  // count_b equals 2 } 

|

在第一个例子中,x复制到 lambda 中,因此当x被改变时不受影响;因此std::count_if()计算的是大于 3 的值的数量。

在第二个例子中,x引用捕获,因此std::count_if()实际上计算的是大于 4 的值的数量。

lambda 和类之间的相似之处

我之前提到过,lambda 表达式生成函数对象。函数对象是一个具有调用运算符operator()()定义的类的实例。

要理解 lambda 表达式的组成,你可以将其视为具有限制的常规类:

  • 该类只包含一个成员函数

  • 捕获子句是类的成员变量和其构造函数的组合

下表显示了 lambda 表达式和相应的类。左列使用通过值捕获,右列使用通过引用捕获

通过值捕获的 lambda... 通过引用捕获的 lambda...

|

auto x = 3;auto is_above = x { return y > x;};auto test = is_above(5); 

|

auto x = 3;auto is_above = &x { return y > x;};auto test = is_above(5); 

|

...对应于这个类: ...对应于这个类:

|

auto x = 3;class IsAbove {
public: IsAbove(int x) : x{x} {} auto operator()(int y) const {   return y > x; }private: int x{}; // Value };auto is_above = IsAbove{x};
auto test = is_above(5); 

|

auto x = 3;class IsAbove {
public: IsAbove(int& x) : x{x} {} auto operator()(int y) const {   return y > x; }private: int& x; // Reference };
auto is_above = IsAbove{x};
auto test = is_above(5); 

|

由于 lambda 表达式,我们不必手动实现这些函数对象类型作为类。

初始化捕获变量

如前面的例子所示,捕获子句初始化了相应类中的成员变量。这意味着我们也可以在 lambda 中初始化成员变量。这些变量只能在 lambda 内部可见。下面是一个初始化名为numbers的捕获变量的 lambda 的示例:

auto some_func = [numbers = std::list<int>{4,2}]() {
  for (auto i : numbers)
    std::cout << i;
};
some_func();  // Output: 42 

相应的类看起来像这样:

class SomeFunc {
public:
 SomeFunc() : numbers{4, 2} {}
 void operator()() const {
  for (auto i : numbers)
    std::cout << i;
 }
private:
 std::list<int> numbers;
};
auto some_func = SomeFunc{};
some_func(); // Output: 42 

在捕获中初始化变量时,你可以想象在变量名前面有一个隐藏的auto关键字。在这种情况下,你可以将numbers视为被定义为auto numbers = std::list<int>{4, 2}。如果你想初始化一个引用,你可以在名称前面使用一个&,这对应于auto&。下面是一个例子:

auto x = 1;
auto some_func = [&y = x]() {
  // y is a reference to x
}; 

同样,当引用(而不是复制)lambda 外部的对象时,你必须非常小心对象的生命周期。

在 lambda 中也可以移动对象,这在使用std::unique_ptr等移动类型时是必要的。以下是如何实现的:

auto x = std::make_unique<int>(); 
auto some_func = [x = std::move(x)]() {
  // Use x here..
}; 

这也表明在 lambda 中使用相同的名称(x)是可能的。这并非必须。相反,我们可以在 lambda 内部使用其他名称,例如[y = std::move(x)]

改变 lambda 成员变量

由于 lambda 的工作方式就像一个具有成员变量的类,它也可以改变它们。然而,lambda 的函数调用运算符默认为const,因此我们需要使用mutable关键字明确指定 lambda 可以改变其成员。在下面的示例中,lambda 在每次调用时改变counter变量:

auto counter_func = [counter = 1]() mutable {
  std::cout << counter++;
};
counter_func(); // Output: 1
counter_func(); // Output: 2
counter_func(); // Output: 3 

如果 lambda 只通过引用捕获变量,我们不必在声明中添加mutable修饰符,因为 lambda 本身不会改变。可变和不可变 lambda 之间的区别在下面的代码片段中进行了演示:

通过值捕获 通过引用捕获

|

auto some_func() {
  auto v = 7;
  auto lambda = [v]() mutable {
    std::cout << v << " ";
    ++v;
  };
  assert(v == 7);
  lambda();  lambda();
  assert(v == 7);
  std::cout << v;
} 

|

auto some_func() {
  auto v = 7;
  auto lambda = [&v]() {
    std::cout << v << " ";
    ++v;
  };
  assert(v == 7);
  lambda();
  lambda();
  assert(v == 9);
  std::cout << v;
} 

|

输出:7 8 7 输出:7 8 9

在右侧的示例中,v被引用捕获,lambda 将改变some_func()作用域拥有的变量v。左侧列中的可变 lambda 只会改变 lambda 本身拥有的v的副本。这就是为什么我们会得到两个版本中不同的输出的原因。

从编译器的角度改变成员变量

要理解前面示例中发生了什么,看一下编译器如何看待前面的 lambda 对象:

通过值捕获 通过引用捕获

|

class Lambda {
 public:
 Lambda(int m) : v{m} {}
 auto operator()() {
   std::cout<< v << " ";
   ++v;
 }
private:
  int v{};
}; 

|

class Lambda {
 public:
 Lambda(int& m) : v{m} {}
 auto operator()() const {
   std::cout<< v << " ";
   ++v;
 }
private:
 int& v;
}; 

|

正如你所看到的,第一种情况对应于具有常规成员的类,而通过引用捕获的情况只是对应于成员变量是引用的类。

你可能已经注意到我们在通过引用捕获类的operator()成员函数上添加了const修饰符,并且在相应的 lambda 上也没有指定mutable。这个类仍然被认为是const的原因是我们没有在实际的类/lambda 内部改变任何东西;实际的改变应用于引用的值,因此函数仍然被认为是const的。

捕获所有

除了逐个捕获变量,还可以通过简单地写[=][&]来捕获作用域中的所有变量。

使用[=]意味着每个变量都将被值捕获,而[&]则通过引用捕获所有变量。

如果我们在成员函数内部使用 lambda,也可以通过使用[this]来通过引用捕获整个对象,或者通过写[*this]来通过复制捕获整个对象:

class Foo { 
public: 
 auto member_function() { 
   auto a = 0; 
   auto b = 1.0f;
   // Capture all variables by copy 
   auto lambda_0 = [=]() { std::cout << a << b; }; 
   // Capture all variables by reference 
   auto lambda_1 = [&]() { std::cout << a << b; }; 
   // Capture object by reference 
   auto lambda_2 = [this]() { std::cout << m_; }; 
   // Capture object by copy 
   auto lambda_3 = [*this]() { std::cout << m_; }; 
 }
private: 
 int m_{}; 
}; 

请注意,使用[=]并不意味着作用域内的所有变量都会被复制到 lambda 中;只有实际在 lambda 内部使用的变量才会被复制。

当通过值捕获所有变量时,可以指定通过引用捕获变量(反之亦然)。以下表格显示了捕获块中不同组合的结果:

捕获块 结果捕获类型

|

int a, b, c;auto func = [=] { /*...*/ }; 
通过值捕获abc

|

int a, b, c;auto func = [&] { /*...*/ }; 
通过引用捕获abc

|

int a, b, c;auto func = [=, &c] { /*...*/ }; 
通过值捕获ab。通过引用捕获c

|

int a, b, c;auto func = [&, c] { /*...*/ }; 
通过引用捕获ab。通过值捕获c

虽然使用[&][=]捕获所有变量很方便,但我建议逐个捕获变量,因为这样可以通过明确指出 lambda 作用域内使用了哪些变量来提高代码的可读性。

将 C 函数指针分配给 lambda

没有捕获的 lambda 可以隐式转换为函数指针。假设你正在使用一个 C 库,或者一个旧的 C++库,它使用回调函数作为参数,就像这样:

extern void download_webpage(const char* url,
                              void (*callback)(int, const char*)); 

回调函数将以返回代码和一些下载内容的形式被调用。在调用download_webpage()时,可以将 lambda 作为参数传递。由于回调是常规函数指针,lambda 不能有任何捕获,必须在 lambda 前面加上加号(+):

auto lambda = +[](int result, const char* str) {
  // Process result and str
};
download_webpage("http://www.packt.com", lambda); 

这样,lambda 就转换为常规函数指针。请注意,lambda 不能有任何捕获,以便使用此功能。

Lambda 类型

自 C++20 以来,没有捕获的 lambda 是可默认构造和可赋值的。通过使用decltype,现在可以轻松构造具有相同类型的不同 lambda 对象:

auto x = [] {};   // A lambda without captures
auto y = x;       // Assignable
decltype(y) z;    // Default-constructible
static_assert(std::is_same_v<decltype(x), decltype(y)>); // passes
static_assert(std::is_same_v<decltype(x), decltype(z)>); // passes 

然而,这仅适用于没有捕获的 lambda。具有捕获的 lambda 有它们自己的唯一类型。即使两个具有捕获的 lambda 函数是彼此的克隆,它们仍然具有自己的唯一类型。因此,不可能将一个具有捕获的 lambda 分配给另一个 lambda。

Lambda 和 std::function

如前一节所述,具有捕获的 lambda(有状态的 lambda)不能相互赋值,因为它们具有唯一的类型,即使它们看起来完全相同。为了能够存储和传递具有捕获的 lambda,我们可以使用std::function来保存由 lambda 表达式构造的函数对象。

std::function的签名定义如下:

std::function< return_type ( parameter0, parameter1...) > 

因此,返回空并且没有参数的std::function定义如下:

auto func = std::function<void(void)>{}; 

返回bool类型,带有intstd::string作为参数的std::function定义如下:

auto func = std::function<bool(int, std::string)>{}; 

共享相同签名(相同参数和相同返回类型)的 lambda 函数可以由相同类型的std::function对象持有。std::function也可以在运行时重新分配。

重要的是,lambda 捕获的内容不会影响其签名,因此具有捕获和不捕获的 lambda 可以分配给相同的std::function变量。以下代码展示了如何将不同的 lambda 分配给同一个名为funcstd::function对象:

// Create an unassigned std::function object 
auto func = std::function<void(int)>{}; 
// Assign a lambda without capture to the std::function object 
func = [](int v) { std::cout << v; }; 
func(12); // Prints 12 
// Assign a lambda with capture to the same std::function object 
auto forty_two = 42; 
func = forty_two { std::cout << (v + forty_two); }; 
func(12); // Prints 54 

让我们在接下来的一个类似真实世界的例子中使用std::function

使用 std::function 实现一个简单的 Button 类

假设我们着手实现一个Button类。然后我们可以使用std::function来存储与点击按钮对应的动作,这样当我们调用on_click()成员函数时,相应的代码就会被执行。

我们可以这样声明Button类:

class Button {
public: 
  Button(std::function<void(void)> click) : handler_{click} {} 
  auto on_click() const { handler_(); } 
private: 
  std::function<void(void)> handler_{};
}; 

然后我们可以使用它来创建多种具有不同动作的按钮。这些按钮可以方便地存储在容器中,因为它们都具有相同的类型:

auto create_buttons () { 
  auto beep = Button([counter = 0]() mutable {  
    std::cout << "Beep:" << counter << "! "; 
    ++counter; 
  }); 
  auto bop = Button([] { std::cout << "Bop. "; }); 
  auto silent = Button([] {});
  return std::vector<Button>{beep, bop, silent}; 
} 

在列表上进行迭代,并对每个按钮调用on_click()将执行相应的函数:

const auto& buttons = create_buttons();
for (const auto& b: buttons) {
  b.on_click();
}
buttons.front().on_click(); // counter has been incremented
// Output: "Beep:0! Bop. Beep:1!" 

前面的按钮和点击处理程序示例展示了在 lambda 与std::function结合使用时的一些好处;即使每个有状态的 lambda 都有其自己独特的类型,一个std::function类型可以包装共享相同签名(返回类型和参数)的 lambda。

顺便说一句,你可能已经注意到on_click()成员函数被声明为const。然而,它通过增加一个点击处理程序中的counter变量来改变成员变量handler_。这可能看起来违反了 const 正确性规则,因为Button的 const 成员函数允许调用其类成员的变异函数。之所以允许这样做,是因为成员指针在 const 上下文中允许改变其指向的值。在本章的前面,我们讨论了如何传播指针数据成员的 const 性。

std::function 的性能考虑

与通过 lambda 表达式直接构造的函数对象相比,std::function有一些性能损失。本节将讨论在使用std::function时需要考虑的一些与性能相关的事项。

阻止内联优化

在谈到 lambda 时,编译器有能力内联函数调用;也就是说,函数调用的开销被消除了。std::function的灵活设计使得编译器几乎不可能内联包装在std::function中的函数。如果非常频繁地调用包装在std::function中的小函数,那么阻止内联优化可能会对性能产生负面影响。

捕获变量的动态分配内存

如果将std::function分配给带有捕获变量/引用的 lambda,那么std::function在大多数情况下将使用堆分配的内存来存储捕获的变量。如果捕获变量的大小低于某个阈值,一些std::function的实现将不分配额外的内存。

这意味着不仅由于额外的动态内存分配而产生性能损失,而且由于堆分配的内存可能增加缓存未命中的次数(在第四章数据结构中了解更多关于缓存未命中的信息)。

额外的运行时计算

调用std::function通常比执行 lambda 慢一点,因为涉及到更多的代码。对于小而频繁调用的std::function来说,这种开销可能变得很大。想象一下,我们定义了一个非常小的 lambda:

auto lambda = [](int v) { return v * 3; }; 

接下来的基准测试演示了对于一个std::vector的 1000 万次函数调用,使用显式 lambda 类型与相应的std::functionstd::vector之间的差异。我们将从使用显式 lambda 的版本开始:

auto use_lambda() { 
  using T = decltype(lambda);
  auto fs = std::vector<T>(10'000'000, lambda);
  auto res = 1;
  // Start clock
  for (const auto& f: fs)
    res = f(res);
  // Stop clock here
  return res;
} 

我们只测量执行函数内部循环所需的时间。下一个版本将我们的 lambda 包装在std::function中,如下所示:

auto use_std_function() { 
  using T = std::function<int(int)>;
  auto fs = std::vector<T>(10'000'000, T{lambda});
  auto res = 1;
  // Start clock
  for (const auto& f: fs)
    res = f(res);
  // Stop clock here
  return res;
} 

我正在使用 2018 年的 MacBook Pro 上使用 Clang 编译此代码,并打开了优化(-O3)。第一个版本use_lambda()在大约 2 毫秒内执行循环,而第二个版本use_std_function()则需要近 36 毫秒来执行循环。

通用 lambda

通用 lambda 是一个接受auto参数的 lambda,使得可以用任何类型调用它。它的工作原理与常规 lambda 一样,但operator()已被定义为成员函数模板。

只有参数是模板变量,而不是捕获的值。换句话说,以下示例中捕获的值v将始终是int类型,而不管v0v1的类型如何:

auto v = 3; // int
auto lambda = v {
  return v + v0*v1;
}; 

如果我们将上述 lambda 表达式转换为一个类,它将对应于以下内容:

class Lambda {
public:
  Lambda(int v) : v_{v} {}
  template <typename T0, typename T1>
  auto operator()(T0 v0, T1 v1) const { 
    return v_ + v0*v1; 
  }
private:
  int v_{};
};
auto v = 3;
auto lambda = Lambda{v}; 

就像模板化版本一样,直到调用 lambda 表达式,编译器才会生成实际的函数。因此,如果我们像这样调用之前的 lambda:

auto res_int = lambda(1, 2);
auto res_float = lambda(1.0f, 2.0f); 

编译器将生成类似于以下 lambda 表达式:

auto lambda_int = v { return v + v0*v1; };
auto lambda_float = v { return v + v0*v1; };
auto res_int = lambda_int(1, 2);
auto res_float = lambda_float(1.0f, 2.0f); 

正如您可能已经发现的那样,这些版本将进一步处理,就像常规 lambda 一样。

C++20 的一个新特性是,我们可以在通用 lambda 的参数类型中使用typename而不仅仅是auto。以下通用 lambda 是相同的:

// Using auto
auto x = [](auto v) { return v + 1; };
// Using typename
auto y = []<typename Val>(Val v) { return v + 1; }; 

这使得在 lambda 的主体内部命名类型或引用类型成为可能。

总结

在本章中,您已经学会了如何使用现代 C++特性,这些特性将在整本书中使用。自动类型推导、移动语义和 lambda 表达式是每个 C++程序员今天都需要熟悉的基本技术。

我们还花了一些时间来研究错误处理以及如何思考错误和有效状态,以及如何从运行时错误中恢复。错误处理是编程中极其重要的一部分,很容易被忽视。考虑调用方和被调用方之间的契约是使您的代码正确并避免在程序的发布版本中进行不必要的防御性检查的一种方法。

在下一章中,我们将探讨在 C++中分析和测量性能的策略。

第三章:分析和测量性能

由于这是一本关于编写高效运行的 C++代码的书,我们需要涵盖一些关于如何衡量软件性能和估算算法效率的基础知识。本章大部分主题并不特定于 C++,在面对性能问题时都可以使用。

您将学习如何使用大 O 符号估算算法效率。在选择 C++标准库中的算法和数据结构时,这是必不可少的知识。如果您对大 O 符号不熟悉,这部分可能需要一些时间来消化。但不要放弃!这是一个非常重要的主题,以便理解本书的其余部分,更重要的是,成为一个注重性能的程序员。如果您想要更正式或更实用的介绍这些概念,有很多专门讨论这个主题的书籍和在线资源。另一方面,如果您已经掌握了大 O 符号并知道摊销时间复杂度是什么,您可以略过下一节,转到本章的后面部分。

本章包括以下部分:

  • 使用大 O 符号估算算法效率

  • 优化代码的建议工作流程,这样您不会在没有充分理由的情况下花费时间微调代码

  • CPU 性能分析器——它们是什么以及为什么你应该使用它们

  • 微基准测试

让我们首先看一下如何使用大 O 符号来估算算法效率。

渐近复杂度和大 O 符号

通常解决问题的方法不止一种,如果效率是一个问题,您应该首先专注于通过选择正确的算法和数据结构进行高级优化。评估和比较算法的一个有用方法是分析它们的渐近计算复杂性——也就是分析输入大小增加时运行时间或内存消耗的增长情况。此外,C++标准库为所有容器和算法指定了渐近复杂度,这意味着如果您使用这个库,对这个主题的基本理解是必须的。如果您已经对算法复杂度和大 O 符号有很好的理解,可以安全地跳过本节。

让我们以一个例子开始。假设我们想编写一个算法,如果在数组中找到特定的键,则返回true,否则返回false。为了找出我们的算法在不同大小的数组上的行为,我们希望分析这个算法的运行时间作为其输入大小的函数:

bool linear_search(const std::vector<int>& vals, int key) noexcept { 
  for (const auto& v : vals) { 
    if (v == key) { 
      return true; 
    } 
  } 
  return false; 
} 

该算法很简单。它遍历数组中的元素,并将每个元素与键进行比较。如果我们幸运的话,在数组的开头找到键并立即返回,但我们也可能在整个数组中循环而根本找不到键。这将是算法的最坏情况,通常情况下,这是我们想要分析的情况。

但是当我们增加输入大小时,运行时间会发生什么变化?假设我们将数组的大小加倍。嗯,在最坏的情况下,我们需要比较数组中的所有元素,这将使运行时间加倍。输入大小和运行时间之间似乎存在线性关系。我们称这为线性增长率。

图 3.1:线性增长率

现在考虑以下算法:

struct Point { 
  int x_{}; 
  int y_{}; 
}; 

bool linear_search(const std::vector<Point>& a, const Point& key) { 
  for (size_t i = 0; i < a.size(); ++i) { 
    if (a[i].x_ == key.x_ && a[i].y_ == key.y_) { 
      return true; 
    } 
  } 
  return false; 
} 

我们比较的是点而不是整数,并且我们使用下标运算符的索引来访问每个元素。这些变化如何影响运行时间?绝对运行时间可能比第一个算法高,因为我们做了更多的工作——例如,比较点涉及两个整数,而不是数组中每个元素的一个整数。然而,在这个阶段,我们对算法表现的增长率感兴趣,如果我们将运行时间绘制成输入大小的函数,我们仍然会得到一条直线,如前图所示。

作为搜索整数的最后一个例子,让我们看看是否可以找到更好的算法,如果我们假设数组中的元素是排序的。我们的第一个算法将在元素的顺序无关紧要的情况下工作,但是如果我们知道它们是排序的,我们可以使用二分搜索。它通过查看中间的元素来确定它是否应该继续在数组的第一半或第二半中搜索。为简单起见,索引highlowmid的类型为int,需要static_cast。更好的选择是使用迭代器,这将在后续章节中介绍。以下是算法:

bool binary_search(const std::vector<int>& a, int key) {
  auto low = 0; 
  auto high = static_cast<int>(a.size()) - 1;
  while (low <= high) {
    const auto mid = std::midpoint(low, high); // C++20
    if (a[mid] < key) {
      low = mid + 1;
    } else if (a[mid] > key) {
      high = mid - 1;
    } else {
      return true;
    }
  }
  return false;
} 

正如您所看到的,这个算法比简单的线性扫描更难正确实现。它通过猜测数组中间的元素来寻找指定的键。如果不是,它将比较键和中间的元素,以决定应该在数组的哪一半中继续寻找键。因此,在每次迭代中,它将数组减半。

假设我们使用包含 64 个元素的数组调用binary_search()。在第一次迭代中,我们拒绝 32 个元素,在下一次迭代中,我们拒绝 16 个元素,在下一次迭代中,我们拒绝 8 个元素,依此类推,直到没有更多元素可以比较,或者直到我们找到键。对于输入大小为 64,最多将有 7 次循环迭代。如果我们将输入大小加倍到 128 呢?由于我们在每次迭代中将大小减半,这意味着我们只需要再进行一次循环迭代。显然,增长率不再是线性的——实际上是对数的。如果我们测量binary_search()的运行时间,我们将看到增长率看起来类似于以下内容:

图 3.2:对数增长率

在我的机器上,对三种算法进行快速计时,每次调用 10,000 次,不同输入大小(n)产生了以下表中显示的结果:

算法 n = 10 n = 1,000 n = 100,000
使用int的线性搜索 0.04 毫秒 4.7 毫秒 458 毫秒
使用Point的线性搜索 0.07 毫秒 6.7 毫秒 725 毫秒
使用int的二分搜索 0.03 毫秒 0.08 毫秒 0.16 毫秒

表 3.1:不同版本搜索算法的比较

比较算法 1 和 2,我们可以看到,比较点而不是整数需要更多时间,但即使输入大小增加,它们仍然处于相同数量级。然而,当输入大小增加时,比较所有三种算法时,真正重要的是算法表现出的增长率。通过利用数组已排序的事实,我们可以用很少的循环迭代来实现搜索功能。对于大数组,与线性扫描数组相比,二分搜索实际上是免费的。

在确定选择正确的算法和数据结构之前,花时间调整代码通常不是一个好主意。

如果我们能以一种有助于我们决定使用哪种算法的方式来表达算法的增长率,那不是很好吗?这就是大 O 符号表示法派上用场的地方。

以下是一个非正式的定义:

如果f(n)是一个指定算法在输入大小n的运行时间的函数,我们说f(n)O(g(n)),如果存在一个常数k,使得

这意味着我们可以说linear_search()的时间复杂度是O(n),对于两个版本(一个操作整数,一个操作点),而binary_search()的时间复杂度是O(log n)或者O(log n)的大 O。

实际上,当我们想要找到一个函数的大 O 时,我们可以通过消除除了具有最大增长率的项之外的所有项,然后去掉任何常数因子来做到这一点。例如,如果我们有一个时间复杂度由f(n) = 4n² + 30n + 100描述的算法,我们挑出具有最高增长率的项,4n²。接下来,我们去掉常数因子 4,最终得到n²,这意味着我们可以说我们的算法运行在O(n²)。找到算法的时间复杂度可能很难,但是当你在编写代码时开始思考它时,它会变得更容易。在大多数情况下,跟踪循环和递归函数就足够了。

让我们试着找出以下排序算法的时间复杂度:

void insertion_sort(std::vector<int>& a) { 
  for (size_t i = 1; i < a.size(); ++i) { 
    auto j = i; 
    while (j > 0 && a[j-1] > a[j]) {  
      std::swap(a[j], a[j-1]); 
      --j;  
    } 
  } 
} 

输入大小是数组的大小。通过查看迭代所有元素的循环,可以大致估计运行时间。首先,有一个迭代n - 1个元素的外部循环。内部循环不同:第一次到达while循环时,j为 1,循环只运行一次。在下一次迭代中,j从 2 开始减少到 0。对于外部for循环的每次迭代,内部循环需要做更多的工作。最后,jn - 1开始,这意味着在最坏的情况下,我们执行了swap()1 + 2 + 3 + ... + (n - 1)次。我们可以通过注意到这是一个等差数列来用n来表示这一点。数列的和是:

因此,如果我们设k = (n - 1),排序算法的时间复杂度是:

我们现在可以通过首先消除除了具有最大增长率的项之外的所有项来找到这个函数的大 O,这让我们得到了(1/2)n²。之后,我们去掉常数1/2,得出排序算法的运行时间是O(n²)

增长率

如前所述,找到复杂度函数的大 O 的第一步是消除除了具有最高增长率的项之外的所有项。为了能够做到这一点,我们必须知道一些常见函数的增长率。在下图中,我画出了一些最常见的函数:

图 3.3:增长率函数的比较

增长率与机器或编码风格等无关。当两个算法之间的增长率不同时,当输入大小足够大时,增长率最慢的算法将始终获胜。让我们看看不同增长率的运行时间会发生什么,假设执行 1 单位的工作需要 1 毫秒。下表列出了增长函数、其常见名称和不同的输入大小n

大 O 名称 n = 10 n = 50 n = 1000
O(1) 常数 0.001 秒 0.001 秒 0.001 秒
O(log n) 对数 0.003 秒 0.006 秒 0.01 秒
O(n) 线性 0.01 秒 0.05 秒 1 秒
O(n log n) 线性对数或n log n 0.03 秒 0.3 秒 10 秒
O(n²) 二次方 0.1 秒 2.5 秒 16.7 分钟
O(2^n) 指数 1 秒 35,700 年 3.4 * 10²⁹⁰年

表 3.2:不同增长率和各种输入大小的绝对运行时间

注意右下角的数字是一个 291 位数!将其与宇宙的年龄 13.7 * 10⁹年相比较,后者只是一个 11 位数。

接下来,我将介绍摊销时间复杂度,这在 C++标准库中经常使用。

摊销时间复杂度

通常,算法在不同的输入下表现不同。回到我们线性搜索数组中元素的算法,我们分析了一个关键字根本不在数组中的情况。对于该算法,这是最坏情况,即算法将需要最多的资源。最佳情况是指算法将需要最少的资源,而平均情况指定了算法在不同输入下平均使用的资源量。

标准库通常指的是对容器进行操作的函数的摊销运行时间。如果算法以恒定的摊销时间运行,这意味着它在几乎所有情况下都将以O(1)运行,只有极少数情况下会表现得更差。乍一看,摊销运行时间可能会与平均时间混淆,但正如您将看到的那样,它们并不相同。

为了理解摊销时间复杂度,我们将花一些时间思考std::vector::push_back()。假设向量在内部具有固定大小的数组来存储所有元素。当调用push_back()时,如果固定大小数组中还有空间可以存放更多元素,则该操作将在常数时间O(1)内运行,即不依赖于向量中已有多少元素,只要内部数组还有空间可以存放一个以上的元素:

if (internal_array.size() > size) { 
  internal_array[size] = new_element; 
  ++size; 
} 

但是当内部数组已满时会发生什么?处理增长向量的一种方法是创建一个新的空内部数组,大小更大,然后将所有元素从旧数组移动到新数组。这显然不再是常数时间,因为我们需要对数组中的每个元素进行一次移动,即O(n)。如果我们认为这是最坏情况,那么这意味着push_back()O(n)。然而,如果我们多次调用push_back(),我们知道昂贵的push_back()不会经常发生,因此如果我们知道push_back()连续调用多次,那么说push_back()O(n)是悲观且不太有用的。

摊销运行时间用于分析一系列操作,而不是单个操作。我们仍然在分析最坏情况,但是针对一系列操作。摊销运行时间可以通过首先分析整个序列的运行时间,然后将其除以序列的长度来计算。假设我们执行一系列m个操作,总运行时间为T(m)

其中t[0] = 1, t[1] = n, t[2] = 1, t[3] = n,依此类推。换句话说,一半的操作在常数时间内运行,另一半在线性时间内运行。所有m个操作的总时间T可以表示如下:

每个操作的摊销复杂度是总时间除以操作数,结果为O(n)

然而,如果我们可以保证昂贵操作的次数与常数时间操作的次数相比相差很大,我们将实现更低的摊销运行成本。例如,如果我们可以保证昂贵操作仅在序列T(n) + T(1) + T(1) + ...中发生一次,那么摊销运行时间为O(1)。因此,根据昂贵操作的频率,摊销运行时间会发生变化。

现在,回到std::vector。C++标准规定push_back()需要在摊销常数时间内运行,O(1)。库供应商是如何实现这一点的呢?如果每次向量变满时容量增加固定数量的元素,我们将会有一个类似于前面的情况,其中运行时间为O(n)。即使使用一个大常数,容量变化仍然会以固定间隔发生。关键的见解是向量需要呈指数增长,以便使昂贵的操作发生得足够少。在内部,向量使用增长因子,使得新数组的容量是当前大小乘以增长因子。

一个大的增长因子可能会浪费更多的内存,但会使昂贵的操作发生得更少。为了简化数学计算,让我们使用一个常见的策略,即每次向量需要增长时都加倍容量。现在我们可以估计昂贵调用发生的频率。对于大小为n的向量,我们需要增长内部数组log[2](n)次,因为我们一直在加倍大小。每次增长数组时,我们需要移动当前数组中的所有元素。当我们增长数组的第i次时,将有2^i 个元素需要移动。因此,如果我们执行mpush_back()操作,增长操作的总运行时间将是:

这是一个等比数列,也可以表示为:

将这个除以序列的长度m,我们最终得到摊销运行时间O(1)

正如我已经说过的,摊销时间复杂度在标准库中被广泛使用,因此了解这种分析是很有帮助的。思考push_back()如何在摊销常数时间内实现已经帮助我记住了摊销常数时间的简化版本:它几乎在所有情况下都是O(1),只有极少数情况下会表现得更差。

这就是我们将要涵盖的关于渐近复杂度的全部内容。现在我们将继续讨论如何解决性能问题,并通过优化代码来有效地工作。

要测量什么以及如何测量?

优化几乎总是会给你的代码增加复杂性。高级优化,比如选择算法和数据结构,可以使代码的意图更清晰,但在大多数情况下,优化会使代码更难阅读和维护。因此,我们要确信我们添加的优化对我们在性能方面试图实现的目标有实际影响。我们真的需要让代码更快吗?以何种方式?代码真的使用了太多内存吗?为了了解可能的优化,我们需要对要求有一个很好的理解,比如延迟、吞吐量和内存使用。

优化代码是有趣的,但也很容易在没有可衡量的收益的情况下迷失方向。我们将从建议的工作流程开始,以便在调整代码时进行优化:

  1. 定义一个目标:如果有一个明确定义的定量目标,那么知道如何优化以及何时停止优化会更容易。对于一些应用程序,从一开始就很明确要求是什么,但在许多情况下,要求往往更加模糊。即使代码运行太慢可能是显而易见的,但知道什么是足够好是很重要的。每个领域都有自己的限制,所以确保你了解与你的应用程序相关的限制。以下是一些例子,以使其更具体:
  1. 测量:一旦我们知道要测量什么和限制是什么,我们就可以通过测量应用程序当前的性能来继续。从步骤 1开始,如果我们对平均时间、峰值、负载等感兴趣,那么很明显。在这一步中,我们只关心测量我们设定的目标。根据应用程序的不同,测量可以是从使用秒表到使用高度复杂的性能分析工具的任何事情。

  2. 找出瓶颈:接下来,我们需要找出应用程序的瓶颈——那些太慢的部分,使应用程序变得无用。此时不要相信你的直觉!也许在步骤 2的不同点测量代码时你获得了一些见解——这很好,但通常你需要进一步对代码进行分析,以找到最重要的热点。

  3. 做出合理猜测:提出一个如何提高性能的假设。可以使用查找表吗?我们可以缓存数据以获得整体吞吐量吗?我们可以改变代码以便编译器可以对其进行矢量化吗?我们可以通过重用内存来减少关键部分的分配次数吗?如果你知道这些只是合理的猜测,提出想法通常并不那么困难。错了也没关系——你以后会发现它们是否产生了影响。

  4. 优化:让我们实现我们在步骤 4中勾画的假设。在知道它是否真的产生效果之前,不要在这一步上花费太多时间使其完美。准备拒绝这种优化。它可能没有预期的效果。

  5. 评估:再次测量。做与步骤 2中完全相同的测试,并比较结果。我们得到了什么?如果我们没有得到任何东西,拒绝这段代码并返回步骤 4。如果优化实际上产生了积极的效果,你需要问自己是否值得再花更多时间。这种优化有多复杂?是否值得努力?这是一般性能提升还是高度特定于某种情况/平台?它是否可维护?我们能封装它吗,还是它散布在整个代码库中?如果你无法证明这种优化,返回步骤 4,否则继续进行最后一步。

  6. 重构:如果你遵循了步骤 5中的指示,并且在一开始没有花太多时间编写完美的代码,那么现在是时候重构优化以使其更清晰了。优化几乎总是需要一些注释来解释为什么我们以一种不寻常的方式做事情。

遵循这个过程将确保你保持在正确的轨道上,不会最终得到没有动机的复杂优化。花时间定义具体目标和测量的重要性不可低估。为了在这个领域取得成功,你需要了解哪些性能特性对你的应用程序是相关的。

性能特性

在开始测量之前,你必须知道对你正在编写的应用程序来说哪些性能特性是重要的。在本节中,我将解释一些在测量性能时经常使用的术语。根据你正在编写的应用程序,有些特性比其他特性更相关。例如,如果你正在编写在线图像转换服务,吞吐量可能比延迟更重要,而在编写具有实时要求的交互式应用程序时,延迟就很关键。以下是一些在性能测量过程中值得熟悉的有价值的术语和概念:

  • 延迟/响应时间:根据领域的不同,延迟和响应时间可能有非常精确和不同的含义。然而,在本书中,我指的是请求和操作响应之间的时间——例如,图像转换服务处理一个图像所需的时间。

  • 吞吐量:这指的是每个时间单位处理的交易(操作,请求等)的数量,例如,图像转换服务每秒可以处理的图像数量。

  • I/O 绑定或 CPU 绑定:任务通常在 CPU 上计算大部分时间或等待 I/O(硬盘,网络等)。如果 CPU 速度更快,任务通常会更快,就称为 CPU 绑定。如果通过加快 I/O 速度,任务通常会更快,就称为 I/O 绑定。有时你也会听到内存绑定任务,这意味着主内存的数量或速度是当前的瓶颈。

  • 功耗:这对于在带电池的移动设备上执行的代码来说非常重要。为了减少功耗,应用程序需要更有效地使用硬件,就像我们在优化 CPU 使用率,网络效率等一样。除此之外,应该避免高频率轮询,因为它会阻止 CPU 进入睡眠状态。

  • 数据聚合:在进行性能测量时,当收集大量样本时通常需要对数据进行聚合。有时平均值足以成为程序性能的良好指标,但更常见的是中位数,因为它对异常值更具鲁棒性,可以更多地告诉你实际性能。如果你对异常值感兴趣,你可以测量最小最大值(或者例如第 10 百分位数)。

这个列表并不是详尽无遗的,但这是一个很好的开始。在这里要记住的重要事情是,在测量性能时,我们可以使用已经确立的术语和概念。花一些时间来定义我们所说的优化代码实际意味着帮助我们更快地达到我们的目标。

执行时间的加速

当我们比较程序或函数的两个版本之间的相对性能时,通常习惯谈论加速。在这里我将给出一个比较执行时间(或延迟)时的加速定义。假设我们已经测量了某段代码的两个版本的执行时间:一个旧的较慢版本和一个新的较快版本。执行时间的加速可以相应地计算如下:

其中T[old]是代码初始版本的执行时间,T[new]是优化版本的执行时间。这个加速的定义意味着加速比为 1 表示根本没有加速。

让我们通过一个例子来确保你知道如何测量相对执行时间。假设我们有一个函数,执行时间为 10 毫秒(T[old] = 10 毫秒),经过一些优化后我们设法让它在 4 毫秒内运行(T[new] = 4 毫秒)。然后我们可以计算加速比如下:

换句话说,我们的新优化版本提供了 2.5 倍的加速。如果我们想将这种改进表示为百分比,我们可以使用以下公式将加速转换为百分比改进:

然后我们可以说新版本的代码比旧版本快 60%,这对应着 2.5 倍的加速。在本书中,当比较执行时间时,我将一贯使用加速,而不是百分比改进。

最终,我们通常对执行时间感兴趣,但时间并不总是最好的衡量标准。通过检查硬件上的其他值,硬件可能会给我们一些其他有用的指导,帮助我们优化我们的代码。

性能计数器

除了显而易见的属性,比如执行时间和内存使用,有时候测量其他东西可能会更有益。要么是因为它们更可靠,要么是因为它们可以更好地帮助我们了解导致代码运行缓慢的原因。

许多 CPU 配备了硬件性能计数器,可以为我们提供诸如指令数、CPU 周期、分支错误预测和缓存未命中等指标。我在本书中尚未介绍这些硬件方面,我们也不会深入探讨性能计数器。但是,知道它们的存在以及所有主要操作系统都有现成的工具和库(通过 API 可访问)来收集运行程序时的性能监视计数器PMC)是很有好处的。

性能计数器的支持因 CPU 和操作系统而异。英特尔提供了一个强大的工具称为 VTune,可用于监视性能计数器。FreeBSD 提供了pmcstat。macOS 自带 DTrace 和 Xcode Instruments。微软 Visual Studio 在 Windows 上提供了收集 CPU 计数器的支持。

另一个流行的工具是perf,它在 GNU/Linux 系统上可用。运行命令:

perf stat ./your-program 

将显示许多有趣的事件,例如上下文切换的次数,页面错误,错误的预测分支等。以下是运行小程序时输出的示例:

Performance counter stats for './my-prog':
     1 129,86 msec task-clock               # 1,000 CPUs utilized          
            8      context-switches         # 0,007 K/sec                  
            0      cpu-migrations           # 0,000 K/sec                  
       97 810      page-faults              # 0,087 M/sec                  
3 968 043 041      cycles                   # 3,512 GHz                    
1 250 538 491      stalled-cycles-frontend  # 31,52% frontend cycles idle
  497 225 466      stalled-cycles-backend   # 12,53% backend cycles idle    
6 237 037 204      instructions             # 1,57  insn per cycle         
                                            # 0,20  stalled cycles per insn
1 853 556 742      branches                 # 1640,516 M/sec                  
    3 486 026      branch-misses            # 0,19% of all branches        
  1,130355771 sec  time elapsed
  1,026068000 sec  user
  0,104210000 sec  sys 

我们现在将重点介绍一些测试和评估性能的最佳实践。

性能测试-最佳实践

由于某种原因,更常见的是回归测试涵盖功能要求,而不是性能要求或其他非功能要求在测试中得到覆盖。性能测试通常更加零星地进行,而且往往太晚了。我的建议是通过将性能测试添加到每晚的构建中,尽早测量并尽快检测到回归。

如果要处理大量输入,则明智地选择算法和数据结构,但不要没有充分理由就对代码进行微调。早期使用真实测试数据测试应用程序也很重要。在项目早期就询问数据大小的问题。应用程序应该处理多少表行并且仍然能够平稳滚动?不要只尝试 100 个元素并希望您的代码能够扩展-进行测试!

绘制数据是了解收集到的数据的一种非常有效的方式。今天有很多好用的绘图工具,所以没有理由不绘图。RStudio 和 Octave 都提供强大的绘图功能。其他例子包括 gnuplot 和 Matplotlib(Python),它们可以在各种平台上使用,并且在收集数据后需要最少的脚本编写来生成有用的图表。图表不一定要看起来漂亮才有用。一旦绘制了数据,您将能够看到通常在充满数字的表中很难找到的异常值和模式。

这结束了我们的要测量和如何测量?部分。接下来,我们将探索找到代码中浪费太多资源的关键部分的方法。

了解您的代码和热点

帕累托原则,或 80/20 法则,自 100 多年前意大利经济学家维尔弗雷多·帕累托首次观察到以来,已经在各个领域得到应用。他能够证明意大利人口的 20%拥有 80%的土地。在计算机科学中,它已被广泛使用,甚至可能被过度使用。在软件优化中,它表明代码的 20%负责程序使用的 80%资源。

当然,这只是一个经验法则,不应该被过于字面理解。尽管如此,对于尚未优化的代码,通常会发现一些相对较小的热点,它们消耗了绝大部分的资源。作为程序员,这实际上是个好消息,因为这意味着我们可以大部分时间编写代码而不需要为了性能而对其进行调整,而是专注于保持代码的清晰。这也意味着在进行优化时,我们需要知道在哪里进行优化;否则,我们很可能会优化对整体性能没有影响的代码。在本节中,我们将探讨寻找可能值得优化的代码中的 20%的方法和工具。

使用性能分析器通常是识别程序中热点的最有效方法。性能分析器分析程序的执行并输出函数或指令被调用的统计摘要,即性能分析结果。

此外,性能分析器通常还会输出一个调用图,显示函数调用之间的关系,即每个在分析期间被调用的函数的调用者和被调用者。在下图中,您可以看到sort()函数是从main()(调用者)调用的,而sort()又调用了swap()函数(被调用者):

图 3.4:调用图的示例。函数sort()被调用一次,并调用swap() 50 次。

性能分析器主要分为两类:采样性能分析器和插装性能分析器。这两种方法也可以混合使用,创建采样和插装的混合性能分析器。Unix 性能分析工具gprof就是一个例子。接下来的部分将重点介绍插装性能分析器和采样性能分析器。

插装性能分析器

通过插装,我指的是向程序中插入代码以便分析,以收集关于每个函数被执行频率的信息。通常,插入的插装代码记录每个入口和出口点。您可以通过手动插入代码来编写自己的原始插装性能分析器,或者您可以使用一个工具,在构建过程中自动插入必要的代码。

一个简单的实现可能对您的目的足够了,但要注意添加的代码对性能的影响,这可能会使性能分析结果产生误导。像这样的天真实现的另一个问题是,它可能会阻止编译器优化或者有被优化掉的风险。

仅仅举一个插装性能分析器的例子,这里是一个我在以前项目中使用过的计时器类的简化版本:

class ScopedTimer { 
public: 
  using ClockType = std::chrono::steady_clock;
  ScopedTimer(const char* func) 
      : function_name_{func}, start_{ClockType::now()} {}
  ScopedTimer(const ScopedTimer&) = delete; 
  ScopedTimer(ScopedTimer&&) = delete; 
  auto operator=(const ScopedTimer&) -> ScopedTimer& = delete; 
  auto operator=(ScopedTimer&&) -> ScopedTimer& = delete;
  ~ScopedTimer() {
    using namespace std::chrono;
    auto stop = ClockType::now(); 
    auto duration = (stop - start_); 
    auto ms = duration_cast<milliseconds>(duration).count(); 
    std::cout << ms << " ms " << function_name_ << '\n'; 
  } 

private: 
  const char* function_name_{}; 
  const ClockType::time_point start_{}; 
}; 

ScopedTimer类将测量从创建到超出作用域(即析构)的时间。我们使用自 C++11 以来可用的std::chrono::steady_clock类,它专门用于测量时间间隔。steady_clock是单调的,这意味着在两次连续调用clock_type::now()之间它永远不会减少。这对于系统时钟来说并非如此,例如,系统时钟可以随时调整。

我们现在可以通过在每个函数的开头创建一个ScopedTimer实例来使用我们的计时器类:

auto some_function() {
  ScopedTimer timer{"some_function"};
  // ...
} 

尽管我们通常不建议使用预处理宏,但这可能是使用预处理宏的一个案例:

#if USE_TIMER 
#define MEASURE_FUNCTION() ScopedTimer timer{__func__} 
#else 
#define MEASURE_FUNCTION() 
#endif 

我们使用自 C++11 以来可用的唯一预定义的函数局部__func__变量来获取函数的名称。C++20 还引入了方便的std::source_location类,它为我们提供了function_name()file_name()line()column()等函数。如果您的编译器尚不支持std::source_location,还有其他非标准的预定义宏被广泛支持,对于调试目的非常有用,例如__FUNCTION____FILE____LINE__

现在,我们的ScopedTimer类可以像这样使用:

auto some_function() { 
  MEASURE_FUNCTION(); 
  // ...
} 

假设我们在编译计时器时定义了USE_TIMER,那么每次some_function()返回时,它将产生以下输出:

2.3 ms some_function 

我已经演示了如何通过在代码中插入打印两个代码点之间经过的时间的代码来手动检测我们的代码。虽然这对于某些情况来说是一个方便的工具,请注意这样一个简单工具可能产生误导性的结果。在下一节中,我将介绍一种不需要对执行代码进行任何修改的性能分析方法。

采样分析器

采样分析器通过在均匀间隔(通常为每 10 毫秒)查看运行程序的状态来创建概要。采样分析器通常对程序的实际性能影响很小,并且还可以在启用所有优化的发布模式下构建程序。采样分析器的缺点是它们的不准确性和统计方法,通常只要你意识到这一点,这通常不是问题。

下图显示了一个运行程序的采样会话,其中包含五个函数:main()f1()f2()f3()f4()。标签t[1] - t[10]表示每个样本的取样时间。方框表示每个执行函数的入口和出口点:

图 3.5:采样分析器会话的示例

概要显示在下表中:

函数 总数 自身
main() 100% 10%
f1() 80% 10%
f2() 70% 30%
f3() 50% 50%

表 3.3:对于每个函数,概要显示了它出现在调用堆栈中的总百分比(Total)以及它出现在堆栈顶部的百分比(Self)。

前表中的Total列显示了包含某个函数的调用堆栈的百分比。在我们的示例中,主函数在 10 个调用堆栈中都出现(100%),而f2()函数只在 7 个调用堆栈中被检测到,占所有调用堆栈的 70%。

Self列显示了每个函数在调用堆栈顶部出现的次数。main()函数在第五个样本t[5]中被检测到在调用堆栈顶部出现一次,而f2()函数在样本t[6]、t[8]和t[9]中出现在调用堆栈顶部,对应 3/10 = 30%。

f3()函数具有最高的Self值(5/10),每当检测到它时,它都位于调用堆栈的顶部。

在概念上,采样分析器以均匀的时间间隔存储调用堆栈的样本。它检测当前在 CPU 上运行的内容。纯采样分析器通常只检测当前在运行状态的线程中执行的函数,因为休眠线程不会被调度到 CPU 上。这意味着如果一个函数正在等待导致线程休眠的锁,那么这段时间不会显示在时间概要中。这很重要,因为您的瓶颈可能是由线程同步引起的,这可能对采样分析器是不可见的。

f4()函数发生了什么?根据图表,它在样本二和三之间被f2()函数调用,但它从未出现在我们的统计概要中,因为它从未在任何调用堆栈中注册过。这是采样分析器的一个重要特性。如果每个样本之间的时间太长或总采样会话时间太短,那么短且不经常调用的函数将不会出现在概要中。这通常不是问题,因为这些函数很少是您需要调整的函数。您可能注意到f3()函数也在t[5]和t[6]之间被错过了,但由于f3()被频繁调用,它对概要产生了很大的影响。

确保您了解您的时间分析器实际上记录了什么。要充分利用它,要意识到它的局限性和优势。

微基准测试

分析可以帮助我们找到代码中的瓶颈。如果这些瓶颈是由低效的数据结构(见第四章数据结构)、算法选择错误(见第五章算法)或不必要的争用(见第十一章并发)引起的,那么应该首先解决这些更大的问题。但有时我们会发现需要优化的小函数或小代码块,在这种情况下,我们可以使用一种称为微基准测试的方法。通过这个过程,我们创建一个微基准测试——一个在程序的其余部分中孤立运行小代码片段的程序。微基准测试的过程包括以下步骤:

  1. 找到需要调整的热点,最好使用分析器。

  2. 将其与其余代码分离并创建一个孤立的微基准测试。

  3. 优化微基准测试。使用基准测试框架在优化过程中测试和评估代码。

  4. 将新优化的代码集成到程序中,然后重新测量,看看当代码在更大的上下文中运行时,优化是否相关。

该过程的四个步骤如下图所示:

图 3.6:微基准测试过程

微基准测试很有趣。然而,在着手尝试加快特定函数之前,我们应该首先确保:

  • 运行程序时在函数内部花费的时间显着影响我们想要加速的程序的整体性能。分析和阿姆达尔定律将帮助我们理解这一点。下面将解释阿姆达尔定律。

  • 我们无法轻易减少函数被调用的次数。消除对昂贵函数的调用通常是优化程序整体性能最有效的方法。

使用微基准测试来优化代码通常应该被视为最后的手段。预期的整体性能提升通常很小。然而,有时我们无法避免需要通过调整实现来加快相对较小的代码片段的运行速度,而在这些情况下,微基准测试可以非常有效。

接下来,您将了解微基准测试的加速比如何影响程序的整体加速比。

阿姆达尔定律

在使用微基准测试时,要牢记孤立代码的优化对整个程序的影响有多大(或多小)是至关重要的。我们的经验是,有时在改进微基准测试时很容易有点过于兴奋,只是意识到整体效果几乎可以忽略不计。使用健全的分析技术部分地解决了这种无法前进的风险,同时也要牢记优化的整体影响。

假设我们正在优化程序中的一个孤立部分的微基准测试。然后可以使用阿姆达尔定律计算整个程序的整体加速比的上限。为了计算整体加速比,我们需要知道两个值:

  • 首先,我们需要知道孤立部分的执行时间在整体执行时间中所占的比例。我们用字母p来表示这个比例执行时间的值。

  • 其次,我们需要知道我们正在优化的部分的加速比——即微基准测试的。我们用字母s来表示这个本地加速比的值。

使用ps,我们现在可以使用阿姆达尔定律来计算整体加速比:

希望这看起来不会太复杂,因为当投入使用时,这是非常直观的。为了直观理解阿姆达尔定律,可以看看在使用各种极端ps值时整体加速比会变成什么样:

  • 设置p = 0s = 5x意味着我们优化的部分对整体执行时间没有影响。因此,无论s的值如何,整体加速比始终为 1x。

  • 设置p = 1s = 5x意味着我们优化了整个程序执行时间的一部分,在这种情况下,整体加速将始终等于我们在优化部分所实现的加速——在这种情况下是 5 倍。

  • 设置p = 0.5s = ∞意味着我们完全删除了程序执行时间的一半。整体加速将是 2 倍。

结果总结在下表中:

p s 整体加速
0 5x 1x
1 5x 5x
0.5 2x

表 3.4:p 和 s 的极端值及实现的整体加速

一个完整的例子将演示我们如何在实践中使用阿姆达尔定律。假设你正在优化一个函数,使得优化版本比原始版本快 2 倍,即2x (s = 2)的加速。此外,让我们假设这个函数只占程序整体执行时间的 1%(p = 0.01),那么整个程序的整体加速可以计算如下:

因此,即使我们设法使我们的孤立代码快 2 倍,整体加速只有 1.005 倍的因素——并不是说这种加速必然是可以忽略的,但我们不断需要回过头来看我们的收益与整体情况的比例。

微基准测试的陷阱

在一般情况下测量软件性能和特别是微基准测试时,有很多隐藏的困难。在这里,我将列出在处理微基准测试时需要注意的事项:

  • 有时结果被过度概括,并被视为普遍真理。

  • 编译器可能会以不同于在完整程序中优化的方式来优化孤立的代码。例如,在微基准测试中可能会内联一个函数,但在完整程序中编译时可能不会内联。或者,编译器可能能够预先计算微基准测试的部分。

  • 在基准测试中未使用的返回值可能会使编译器删除我们试图测量的函数。

  • 在微基准测试中提供的静态测试数据可能会使编译器在优化代码时获得不切实际的优势。例如,如果我们硬编码循环将执行的次数,并且编译器知道这个硬编码的值恰好是 8 的倍数,它可以以不同的方式对循环进行矢量化,跳过可能与 SIMD 寄存器大小不对齐的部分的序言和尾声。然后在真实代码中,这个硬编码的编译时常量被替换为运行时值,这种优化就不会发生。

  • 不切实际的测试数据可能会影响运行基准测试时的分支预测。

  • 多次测量之间的结果可能会有所不同,因为频率缩放、缓存污染和其他进程的调度等因素。

  • 代码性能的限制因素可能是缓存未命中,而不是实际执行指令所需的时间。因此,在许多情况下,微基准测试的一个重要规则是,在测量之前必须清除缓存,否则你实际上并没有在测量任何东西。

我希望有一个简单的公式来避免上面列出的所有陷阱,但不幸的是,我没有。然而,在下一节中,我们将通过使用微基准测试支持库来看一个具体的例子,看看如何通过使用微基准测试支持库来解决其中一些陷阱。

一个微基准测试的例子

我们将通过回到本章的线性搜索和二分搜索的初始例子,并演示如何使用基准测试框架对它们进行基准测试来结束这一章。

我们开始这一章节,比较了在std::vector中搜索整数的两种方法。如果我们知道向量已经排序,我们可以使用二分搜索,这比简单的线性搜索算法效果更好。我不会在这里重复函数的定义,但声明看起来是这样的:

bool linear_search(const std::vector<int>& v, int key);
bool binary_search(const std::vector<int>& v, int key); 

一旦输入足够大,这些函数的执行时间差异是非常明显的,但它将作为我们目的的一个足够好的例子。我们将首先只测量linear_search()。然后,当我们有一个可用的基准测试时,我们将添加binary_search()并比较这两个版本。

为了制作一个测试程序,我们首先需要一种方法来生成一个排序的整数向量。以下是一个简单的实现,对我们的需求来说足够了:

auto gen_vec(int n) {
  std::vector<int> v;
  for (int i = 0; i < n; ++i) { 
    v.push_back(i); 
  }
  return v;
} 

返回的向量将包含 0 到n-1之间的所有整数。一旦我们有了这个,我们就可以创建一个像这样的简单测试程序:

int main() { // Don't do performance tests like this!
  ScopedTimer timer("linear_search");
  int n = 1024;
  auto v = gen_vec(n);
  linear_search(v, n);
} 

我们正在搜索值n,我们知道它不在向量中,所以算法将展示其在这个测试数据中的最坏情况性能。这是这个测试的好部分。除此之外,它还有许多缺陷,这将使得这个基准测试无用:

  • 使用优化编译这段代码很可能会完全删除代码,因为编译器可以看到函数的结果没有被使用。

  • 我们不想测量创建和填充std::vector所需的时间。

  • 只运行一次linear_search()函数,我们将无法获得统计上稳定的结果。

  • 测试不同的输入大小是很麻烦的。

让我们看看如何通过使用微基准支持库来解决这些问题。有各种各样的用于基准测试的工具/库,但我们将使用Google Benchmarkgithub.com/google/benchmark,因为它被广泛使用,而且作为一个奖励,它也可以在quick-bench.com页面上轻松在线测试,而无需任何安装。

这是使用 Google Benchmark 时linear_search()的一个简单微基准测试的样子:

#include <benchmark/benchmark.h> // Non-standard header
#include <vector>
bool linear_search(const std::vector<int>& v, int key) { /* ... */ }
auto gen_vec(int n) { /* ... */ }
static void bm_linear_search(benchmark::State& state) {
  auto n = 1024;
  auto v = gen_vec(n);
  for (auto _ : state) {
    benchmark::DoNotOptimize(linear_search(v, n));
  }
}
BENCHMARK(bm_linear_search); // Register benchmarking function
BENCHMARK_MAIN(); 

就是这样!我们还没有解决的唯一问题是输入大小被硬编码为 1024。我们稍后会解决这个问题。编译和运行这个程序将生成类似这样的东西:

-------------------------------------------------------------------
Benchmark                Time   CPU           Iterations
-------------------------------------------------------------------
bm_linear_search         361 ns 361 ns        1945664 

右侧列中报告的迭代次数报告了循环需要执行多少次才能获得统计上稳定的结果。传递给我们基准测试函数的state对象确定了何时停止。每次迭代的平均时间在两列中报告:时间是挂钟时间,CPU是主线程在 CPU 上花费的时间。在这种情况下,它们是相同的,但如果linear_search()被阻塞等待 I/O(例如),CPU 时间将低于挂钟时间。

另一个重要的事情要注意的是生成向量的代码不包括在报告的时间内。唯一被测量的代码是这个循环内的代码:

for (auto _ : state) {   // Only this loop is measured
  benchmark::DoNotOptimize(binary_search(v, n));
} 

从我们的搜索函数返回的布尔值被包裹在benchmark::DoNotOptimize()中。这是用来确保返回值不被优化掉的机制,这可能会使对linear_search()的整个调用消失。

现在让我们通过改变输入大小使这个基准测试更有趣。我们可以通过使用state对象向我们的基准测试函数传递参数来做到这一点。以下是如何做到的:

static void bm_linear_search(benchmark::State& state) {
  auto n = state.range(0);
  auto v = gen_vec(n);
  for (auto _ : state) {
    benchmark::DoNotOptimize(linear_search(v, n));
  }
}
BENCHMARK(bm_linear_search)->RangeMultiplier(2)->Range(64, 256); 

这将从输入大小为 64 开始,每次加倍大小,直到达到 256。在我的机器上,测试生成了以下输出:

-------------------------------------------------------------------
Benchmark                Time    CPU          Iterations
-------------------------------------------------------------------
bm_linear_search/64      17.9 ns 17.9 ns      38143169
bm_linear_search/128     44.3 ns 44.2 ns      15521161
bm_linear_search/256     74.8 ns 74.7 ns      8836955 

最后,我们将使用可变输入大小对linear_search()binary_search()函数进行基准测试,并尝试让框架估计我们函数的时间复杂度。这可以通过使用SetComplexityN()函数向state对象提供输入大小来实现。完整的微基准测试示例如下:

#include <benchmark/benchmark.h>
#include <vector>
bool linear_search(const std::vector<int>& v, int key) { /* ... */ }
bool binary_search(const std::vector<int>& v, int key) { /* ... */ }
auto gen_vec(int n) { /* ... */ }
static void bm_linear_search(benchmark::State& state) {
  auto n = state.range(0); 
  auto v = gen_vec(n);
  for (auto _ : state) { 
    benchmark::DoNotOptimize(linear_search(v, n)); 
  }
  state.SetComplexityN(n);
}
static void bm_binary_search(benchmark::State& state) {
  auto n = state.range(0); 
  auto v = gen_vec(n);
  for (auto _ : state) { 
    benchmark::DoNotOptimize(binary_search(v, n)); 
  }
  state.SetComplexityN(n);
}
BENCHMARK(bm_linear_search)->RangeMultiplier(2)->
  Range(64, 4096)->Complexity();
BENCHMARK(bm_binary_search)->RangeMultiplier(2)->
  Range(64, 4096)->Complexity();
BENCHMARK_MAIN(); 

运行基准测试时,将在控制台上打印以下结果:

-------------------------------------------------------------------
Benchmark                Time     CPU         Iterations
-------------------------------------------------------------------
bm_linear_search/64      18.0 ns  18.0 ns     38984922
bm_linear_search/128     45.8 ns  45.8 ns     15383123
...
bm_linear_search/8192    1988 ns  1982 ns     331870
bm_linear_search_BigO    0.24 N   0.24 N
bm_linear_search_RMS        4 %   4 %
bm_binary_search/64      4.16 ns  4.15 ns     169294398
bm_binary_search/128     4.52 ns  4.52 ns     152284319
...
bm_binary_search/4096    8.27 ns  8.26 ns     80634189
bm_binary_search/8192    8.90 ns  8.90 ns     77544824
bm_binary_search_BigO    0.67 lgN 0.67 lgN
bm_binary_search_RMS        3 %   3 % 

图 3.7:绘制不同输入大小的执行时间,显示了搜索函数的增长率

输出与本章初步结果一致,我们得出结论,这些算法分别表现出线性运行时间和对数运行时间。如果我们将数值绘制在表中,我们可以清楚地看到函数的线性和对数增长率。

输出结果如下:

总结

以下图是使用 Python 和 Matplotlib 生成的:

“测量让您领先于不需要测量的专家。”

您现在拥有了许多工具和见解,可以找到并改进代码的性能。在处理性能时,我再次强调测量和设定目标的重要性。Andrei Alexandrescu 的一句话将结束本节:

在本章中,您学会了如何使用大 O 符号比较算法的效率。您现在知道 C++标准库为算法和数据结构提供了复杂性保证。所有标准库算法都指定它们的最坏情况或平均情况性能保证,而容器和迭代器指定摊销或精确复杂度。

-Andrei Alexandrescu,2015 年,编写快速代码 I,code::dive conference 2015,https://codedive.pl/2015/writing-fast-code-part-1。

您还了解了如何通过测量延迟和吞吐量来量化软件性能。

最后,您学会了如何使用 CPU 分析器检测代码中的热点,并如何执行微基准测试来改进程序的孤立部分。

在下一章中,您将了解如何有效使用 C++标准库提供的数据结构。

第四章:数据结构

在上一章中,我们讨论了如何分析时间和内存复杂性以及如何衡量性能。在本章中,我们将讨论如何从标准库中选择和使用数据结构。要理解为什么某些数据结构在今天的计算机上运行得非常好,我们首先需要了解一些关于计算机内存的基础知识。在本章中,您将了解以下内容:

  • 计算机内存的属性

  • 标准库容器:序列容器和关联容器

  • 标准库容器适配器

  • 并行数组

在我们开始遍历标准库提供的容器和一些其他有用的数据结构之前,我们将简要讨论一些计算机内存的属性。

计算机内存的属性

C++将内存视为一系列单元。每个单元的大小为 1 字节,并且每个单元都有一个地址。通过其地址访问内存中的一个字节是一个常量时间操作,O(1),换句话说,它与内存单元的总数无关。在 32 位机器上,您可以理论上寻址 2³²字节,即大约 4GB,这限制了进程一次允许使用的内存量。在 64 位机器上,您可以理论上寻址 2⁶⁴字节,这是如此之大,以至于几乎没有任何地址用完的风险。

以下图显示了内存中排列的一系列内存单元。每个单元包含 8 位。十六进制数字是内存单元的地址:

图 4.1:一系列内存单元

由于通过地址访问一个字节是一个O(1)操作,从程序员的角度来看,很容易相信每个内存单元都可以快速访问。这种对内存的处理方式在许多情况下都是简单且有用的,但是在选择数据结构以实现高效使用时,您需要考虑现代计算机中存在的内存层次结构。随着从主存储器读取和写入所需的时间与今天处理器的速度相比变得更加昂贵,内存层次结构的重要性已经增加。以下图显示了具有一个 CPU 和四个核心的机器的架构:

图 4.2:具有四个核心的处理器的示例;标有 L1i、L1d、L2 和 L3 的框是内存缓存

我目前正在使用 2018 年的 MacBook Pro 进行撰写本章,它配备了 Intel Quad-Core i7 CPU。在这个处理器上,每个核心都有自己的 L1 和 L2 缓存,而 L3 缓存是所有四个核心共享的。从终端运行以下命令:

sysctl -a hw 

给我提供了以下信息,除其他外:

hw.memsize: 17179869184
hw.cachelinesize: 64
hw.l1icachesize: 32768
hw.l1dcachesize: 32768
hw.l2cachesize: 262144
hw.l3cachesize: 8388608 

报告的hw.memsize是主存储器的总量,本例中为 16GB。

hw.cachelinesize报告的是 64 字节,这是缓存行的大小,也称为块。当访问内存中的一个字节时,机器不仅会获取所请求的字节;相反,机器总是获取一个缓存行,在这种情况下是 64 字节。 CPU 和主存储器之间的各种高速缓存跟踪 64 字节的块,而不是单个字节。

hw.l1icachesize是 L1 指令缓存的大小。这是一个 32KB 的缓存,专门用于存储 CPU 最近使用的指令。 hw.l1dcachesize也是 32KB,专门用于数据,而不是指令。

最后,我们可以读取 L2 缓存和 L3 缓存的大小,分别为 256KB 和 8MB。一个重要的观察是,与可用的主存储器量相比,缓存非常小。

没有提供关于从缓存层中的每一层访问数据所需的实际周期数的详细事实,一个非常粗略的指导原则是,相邻层之间的延迟存在数量级的差异(例如,L1 和 L2)。下表显示了 Peter Norvig 在一篇名为《在十年内自学编程》(2001)的文章中提出的延迟数字的摘录(norvig.com/21-days.html)。完整的表通常被称为《每个程序员都应该知道的延迟数字》,并且由 Jeff Dean 创作:

L1 缓存引用 0.5 ns
L2 缓存引用 7 ns
主存储器引用 100 ns

以这样的方式结构化数据,使得缓存可以被充分利用,对性能有着显著的影响。访问最近使用过的数据,因此可能已经存在于缓存中,将使你的程序更快。这被称为时间局部性

此外,访问位于你正在使用的其他数据附近的数据,将增加你需要的数据已经在先前从主存储器中获取的缓存行中的可能性。这被称为空间局部性

在内部循环中不断清除缓存行可能导致非常糟糕的性能。这有时被称为缓存抖动。让我们看一个例子:

constexpr auto kL1CacheCapacity = 32768; // The L1 Data cache size 
constexpr auto kSize = kL1CacheCapacity / sizeof(int); 
using MatrixType = std::array<std::array<int, kSize>, kSize>; 
auto cache_thrashing(MatrixType& matrix) { 
  auto counter = 0;
  for (auto i = 0; i < kSize; ++i) {
    for (auto j = 0; j < kSize; ++j) {
      matrix[i][j] = counter++;
    }
  }
} 

这个版本在我的电脑上运行大约需要 40 毫秒。然而,只需将内部循环中的一行更改为以下内容,完成函数所需的时间就会从 40 毫秒增加到 800 毫秒以上:

matrix[j][i] = counter++; 

在第一个例子中,使用matrix[i][j]时,大多数情况下我们将访问已经在 L1 缓存中的内存,而在使用matrix[j][i]的修改版本中,每次访问都会生成一个 L1 缓存未命中。一些图像可能会帮助你理解发生了什么。与其绘制完整的 32768 x 32768 矩阵,不如用这里显示的一个小 3 x 3 矩阵作为例子:

图 4.3:一个 3x3 矩阵

即使这可能是我们对矩阵在内存中的想象,实际上并不存在二维内存。相反,当这个矩阵在一维内存空间中排列时,它看起来是这样的:

图 4.4:一个二维矩阵在一维内存空间中

也就是说,它是一个按行排列的连续元素数组。在我们算法的快速版本中,数字按照它们在内存中连续排列的顺序顺序访问,就像这样:

图 4.5:快速顺序步幅-1 访问

而在算法的慢速版本中,元素以完全不同的模式访问。使用慢速版本访问前四个元素现在看起来是这样的:

图 4.6:使用较大步幅的慢速访问

以这种方式访问数据由于空间局部性差而明显较慢。现代处理器通常也配备有预取器,它可以自动识别内存访问模式,并尝试从内存中预取可能在不久的将来被访问的缓存。预取器对于较小的步幅表现最佳。你可以在 Randal E. Bryant 和 David R. O'Hallaron 的优秀著作《计算机系统,程序员的视角》中阅读更多相关内容。

总结本节,即使内存访问是恒定时间操作,缓存对实际访问内存所需时间的影响可能会很大。在使用或实现新数据结构时,这是一件需要时刻牢记的事情。

接下来,我将介绍 C++标准库中的一组数据结构,称为容器。

标准库容器

C++标准库提供了一组非常有用的容器类型。容器是包含一系列元素的数据结构。容器管理它所持有的元素的内存。这意味着我们不必显式地创建和删除放入容器中的对象。我们可以将在堆栈上创建的对象传递给容器,容器将会复制并存储它们在自由存储器上。

迭代器用于访问容器中的元素,因此对于理解标准库中的算法和数据结构来说,它们是一个基本概念。迭代器概念在第五章算法中有介绍。对于本章来说,知道迭代器可以被视为指向元素的指针,并且迭代器根据它们所属的容器定义了不同的操作符就足够了。例如,类似数组的数据结构提供对其元素的随机访问迭代器。这些迭代器支持使用+-的算术表达式,而例如链表的迭代器只支持++--操作符。

容器分为三类:序列容器、关联容器和容器适配器。本节将简要介绍这三类容器中的容器,并讨论在性能成为问题时需要考虑的最重要的事情。

序列容器

序列容器会按照我们添加元素到容器时指定的顺序来保留元素。标准库中的序列容器包括std::arraystd::vectorstd::dequestd::liststd::forward_list。我也会在本节中介绍std::basic_string,尽管它不是正式的通用序列容器,因为它只处理字符类型的元素。

在选择序列容器之前,我们应该知道以下问题的答案:

  1. 元素数量是多少(数量级)?

  2. 使用模式是什么?您将多频繁地添加数据?读取/遍历数据?删除数据?重新排列数据?

  3. 您最常在序列中添加数据的位置是哪里?在末尾、开头还是中间?

  4. 您需要对元素进行排序吗?或者您是否甚至关心顺序?

根据这些问题的答案,我们可以确定哪种序列容器更适合我们的需求。但是,为了做到这一点,我们需要对每种类型的序列容器的接口和性能特征有基本的了解。

接下来的部分将简要介绍不同的序列容器,首先介绍最常用的容器之一。

向量和数组

std::vector可能是最常用的容器类型,原因很充分。向量是一个在需要时动态增长的数组。添加到向量中的元素保证在内存中是连续排列的,这意味着您可以通过索引以常数时间访问数组中的任何元素。这也意味着在按照它们排列的顺序遍历元素时,由于前面提到的空间局部性,它提供了出色的性能。

向量有一个大小和一个容量。大小是当前容器中保存的元素数量,容量是向量需要分配更多空间之前可以容纳的元素数量:

图 4.7:std::vector 的大小和容量

使用push_back()函数向向量末尾添加元素是快速的,只要大小小于容量。当添加一个元素并且没有更多空间时,向量将会分配一个新的内部缓冲区,然后将所有元素移动到新空间。容量会以一种很少发生调整缓冲区大小的方式增长,因此使push_back()成为摊销的常数时间操作,正如我们在第三章分析和测量性能中讨论的那样。

类型为std::vector<Person>的向量模板实例将按值存储Person对象。当向量需要重新排列Person对象(例如,作为插入的结果),值将被复制构造或移动。如果对象具有nothrow移动构造函数,则对象将被移动。否则,为了保证强异常安全性,对象将被复制构造:

Person(Person&& other) {         // Will be copied 
   // ...
} 
Person(Person&& other) noexcept { // Will be moved 
   // ...
} 

在内部,std::vector使用std::move_if_noexcept来确定对象是应该被复制还是移动。<type_traits>头文件可以帮助您在编译时验证您的类在移动时是否保证不会抛出异常:

static_assert(std::is_nothrow_move_constructible<Person>::value); 

如果您要将新创建的对象添加到向量中,您可以利用emplace_back()函数,它将为您创建对象,而不是使用push_back()函数创建对象,然后将其复制/移动到向量中:

persons.emplace_back("John", 65); 

向量的容量可以通过以下方式改变:

  • 通过在capacity == size时向向量添加元素

  • 通过调用reserve()

  • 通过调用shrink_to_fit()

除此之外,向量不会改变容量,因此也不会分配或释放动态内存。例如,成员函数clear()会清空向量,但不会改变其容量。这些内存保证使得向量即使在实时环境中也可以使用。

自 C++20 以来,还有两个免费函数可以从std::vector中删除元素。在 C++20 之前,我们必须使用擦除-移除惯用法,我们将在第五章 算法中讨论。然而,现在从std::vector中删除元素的推荐方法是使用std::erase()std::erase_if()。以下是如何使用这些函数的简短示例:

auto v = std::vector{-1, 5, 2, -3, 4, -5, 5};
std::erase(v, 5);                               // v: [-1,2,-3,4,-5]
std::erase_if(v, [](auto x) { return x < 0; }); // v: [2, 4] 

作为动态大小向量的替代,标准库还提供了一个名为std::array的固定大小版本,它通过使用堆栈而不是自由存储来管理其元素。数组的大小是在编译时指定的模板参数,这意味着大小和类型元素成为具体类型的一部分:

auto a = std::array<int, 16>{};
auto b = std::array<int, 1024>{}; 

在这个例子中,ab不是相同的类型,这意味着在使用类型作为函数参数时,你必须指定大小:

auto f(const std::array<int, 1024>& input) { 
  // ... 
} 

f(a);  // Does not compile, f requires an int array of size 1024 

这一开始可能看起来有点麻烦,但事实上,这是与内置数组类型(C 数组)相比的一个很大的优势,因为当传递给函数时,它会自动将指针转换为数组的第一个元素,从而丢失大小信息:

// input looks like an array, but is in fact a pointer 
auto f(const int input[]) {  
  // ... 
} 

int a[16]; 
int b[1024]; 
f(a); // Compiles, but unsafe 

数组失去其大小信息通常被称为数组衰变。在本章后面,您将看到如何通过在将连续数据传递给函数时使用std::span来避免数组衰变。

双端队列

有时,您会发现自己处于需要频繁向序列的开头和结尾添加元素的情况。如果您使用的是std::vector并且需要加快在前面插入的速度,您可以使用std::deque,它是双端队列的缩写。std::deque通常实现为一组固定大小的数组,这使得可以在常数时间内通过它们的索引访问元素。然而,正如您在下图中所看到的,所有元素并不是存储在内存中的连续位置,这与std::vectorstd::array的情况不同。

图 4.8:std::deque 的可能布局

列表和前向列表

std::list是一个双向链表,意味着每个元素都有一个指向下一个元素和一个指向前一个元素的链接。这使得可以向前和向后遍历列表。还有一个名为std::forward_list单向链表。之所以不总是选择双向链表而不是std::forward_list,是因为双向链表中的后向指针占用了过多的内存。因此,如果不需要向后遍历列表,就使用std::forward_list。单向链表的另一个有趣特性是它针对非常短的列表进行了优化。当列表为空时,它只占用一个字,这使得它成为稀疏数据的一种可行数据结构。

请注意,即使元素在一个序列中是有序的,它们在内存中并不像向量和数组那样连续布局,这意味着迭代链表很可能会产生比向量更多的缓存未命中。

总之,std::list是一个具有指向下一个和上一个元素的双向链表:

图 4.9:std::list 是一个双向链表

std::forward_list是一个具有指向下一个元素的单向链表:

图 4.10:std::forward_list 是一个单向链表

std::forward_list更加内存高效,因为它只有一个指向下一个元素的指针。

列表也是唯一支持splicing的容器,这是一种在不复制或移动元素的情况下在列表之间传输元素的方法。这意味着,例如,可以在常数时间O(1)内将两个列表连接成一个。其他容器对于这样的操作至少需要线性时间。

基本字符串

我们将在本节中介绍的最后一个模板类是std::basic_stringstd::stringstd::basic_string<char>的一个typedef。从历史上看,std::basic_string并不保证在内存中连续布局。这在 C++17 中发生了改变,这使得可以将字符串传递给需要字符数组的 API。例如,以下代码将整个文件读入字符串中:

auto in = std::ifstream{"file.txt", std::ios::binary | std::ios::ate}; 
if (in.is_open()) { 
  auto size = in.tellg(); 
  auto content = std::string(size, '\0'); 
  in.seekg(0); 
  in.read(&content[0], size); 
  // "content" now contains the entire file 
} 

通过使用std::ios::ate打开文件,位置指示器被设置到流的末尾,这样我们就可以使用tellg()来检索文件的大小。之后,我们将输入位置设置为流的开头并开始读取。

大多数std::basic_string的实现都利用了称为小对象优化的东西,这意味着如果字符串的大小很小,它们不会分配任何动态内存。我们将在本书的后面讨论小对象优化。现在,让我们继续讨论关联容器。

关联容器

关联容器根据元素本身的特性放置它们的元素。例如,在关联容器中不可能像使用std::vector::push_back()std::list::push_front()那样在后面或前面添加元素。相反,元素是以一种使得可以在不需要扫描整个容器的情况下找到元素的方式添加的。因此,关联容器对我们想要存储在容器中的对象有一些要求。我们将在后面讨论这些要求。

关联容器有两个主要类别:

  • 有序关联容器:这些容器基于树;容器使用树来存储它们的元素。它们要求元素按照小于运算符(<)进行排序。基于树的容器中添加、删除和查找元素的函数都是 O(log n)。这些容器被命名为std::setstd::mapstd::multisetstd::multimap

  • 无序关联容器:这些容器基于哈希表;容器使用哈希表来存储它们的元素。它们要求元素使用相等运算符(==)进行比较,并且有一种方法可以根据元素计算哈希值。稍后会详细介绍。基于哈希表的容器中添加、删除和查找元素的函数都是O(1)。这些容器的名称是std::unordered_setstd::unordered_mapstd::unordered_multisetstd::unordered_multimap

自 C++20 以来,所有关联容器都配备了一个名为contains()的函数,当您想知道容器是否包含某些特定元素时应该使用它。在较早版本的 C++中,需要使用count()find()来确定容器是否包含元素。

始终使用专门的函数,如contains()empty(),而不是使用count() > 0size() == 0。专门的函数保证是最有效的。

有序集合和映射

有序关联容器保证插入、删除和搜索可以在对数时间O(log n)内完成。如何实现这一点取决于标准库的实现。然而,我们所知道的实现确实使用了某种自平衡二叉搜索树。树保持大致平衡是控制树的高度以及访问元素的最坏情况运行时间的必要条件。树不需要预先分配内存,因此通常情况下,每次插入元素时树都会在自由存储器上分配内存,并在擦除元素时释放内存。请看下面的图表,显示平衡树的高度为O(log n)

图 4.11:如果树是平衡的,则树的高度为 O(log n)

无序集合和映射

无序集合和映射的版本提供了基于哈希的替代方案,而不是基于树的版本。这种数据结构通常被称为哈希表。理论上,哈希表提供了摊销的常数时间插入、添加和删除操作,可以与操作在O(log n)的基于树的版本进行比较。然而,在实践中,差异可能并不那么明显,特别是如果您的容器中没有存储非常大数量的元素。

让我们看看哈希表如何提供O(1)的操作。哈希表将其元素保存在一些桶的数组中。当向哈希表添加元素时,使用哈希函数计算元素的整数。这个整数通常被称为元素的哈希。然后,哈希值被限制在数组的大小范围内(例如通过使用取模运算),以便新的限制值可以用作数组中的索引。一旦计算出索引,哈希表就可以将元素存储在数组的该索引处。查找元素的操作方式类似,首先计算要查找的元素的哈希值,然后访问数组。

除了计算哈希值,这种技术似乎很简单。然而,这只是故事的一半。如果两个不同的元素生成相同的索引,要么是因为它们产生了相同的哈希值,要么是因为两个不同的哈希值被限制到相同的索引,会发生什么?当两个不相等的元素最终位于同一个索引时,我们称之为哈希冲突。这不仅仅是一个边缘情况:即使我们使用一个很好的哈希函数,尤其是当数组的大小与我们添加的元素数量相比较小时,这种情况会经常发生。有各种方法来处理哈希冲突。在这里,我们将专注于标准库中使用的一种方法,称为分离链接

分离链接解决了两个不相等的元素最终在相同索引处的问题。数组不仅仅是直接存储元素,而是一个序列的。每个桶可以包含多个元素,也就是所有散列到相同索引的元素。因此,每个桶也是某种类型的容器。用于桶的确切数据结构未定义,对于不同的实现可能会有所不同。但是,我们可以将其视为链表,并假设在特定桶中查找元素是缓慢的,因为它需要线性扫描桶中的元素。

下图显示了一个具有八个桶的哈希表。元素分布在三个单独的桶中。索引为2的桶包含四个元素,索引为4的桶包含两个元素,索引为5的桶只包含一个元素。其他桶为空:

图 4.12:每个桶包含 0 个或多个元素

哈希和相等

哈希值可以在与容器大小相关的常量时间内计算,它决定了元素将被放置在哪个桶中。由于可能会有多个对象生成相同的哈希值,因此最终进入同一个桶,每个键还需要提供一个相等函数,用于将要查找的键与桶中的所有键进行比较。

如果两个键相等,则它们需要生成相同的哈希值。但是,两个对象返回相同的哈希值而彼此不相等是完全合法的。

一个好的哈希函数计算快速,并且还会在桶之间均匀分布键,以最小化每个桶中的元素数量。

以下是一个非常糟糕但有效的哈希函数的示例:

auto my_hash = [](const Person& person) {
  return 47; // Bad, don't do this!
}; 

它是有效的,因为它将为两个相等的对象返回相同的哈希值。哈希函数也非常快。然而,由于所有元素将产生相同的哈希值,所有键最终将进入同一个桶,这意味着查找一个元素将是O(n)而不是我们所追求的O(1)

另一方面,一个好的哈希函数可以确保元素在桶之间均匀分布,以最小化哈希冲突。C++标准实际上对此有一个注释,指出哈希函数很少会为两个不同的对象产生相同的哈希值。幸运的是,标准库已经为基本类型提供了良好的哈希函数。在许多情况下,我们可以在为用户定义的类型编写自己的哈希函数时重用这些函数。

假设我们想要将Person类作为unorordered_set中的键。Person类有两个数据成员:age是一个intname是一个std::string。我们首先编写相等谓词:

auto person_eq = [](const Person& lhs, const Person& rhs) {
  return lhs.name() == rhs.name() && lhs.age() == rhs.age();
}; 

为了使两个Person对象相等,它们需要有相同的名称和相同的年龄。现在我们可以通过组合包含在相等谓词中的所有数据成员的哈希值来定义哈希谓词。不幸的是,C++标准中还没有函数来组合哈希值,但 Boost 中有一个很好的函数可用,我们将在这里使用:

#include <boost/functional/hash.hpp>
auto person_hash = [](const Person& person) { 
  auto seed = size_t{0};
  boost::hash_combine(seed, person.name()); 
  boost::hash_combine(seed, person.age()); 
  return seed;
}; 

如果由于某种原因,您无法使用 Boost,boost::hash_combine()实际上只是一个可以从www.boost.org/doc/libs/1_55_0/doc/html/hash/reference.html#boost.hash_combine的文档中复制的一行代码。

有了相等和哈希函数的定义,我们最终可以创建我们的unordered_set

using Set = std::unordered_set<Person, decltype(person_hash),                                decltype(person_eq)>; 
auto persons = Set{100, person_hash, person_eq}; 

一个很好的经验法则是在生成哈希值时始终使用等函数中使用的所有数据成员。这样,我们遵守了等号和哈希之间的约定,同时这使我们能够提供一个有效的哈希值。例如,仅在计算哈希值时使用名称是正确但低效的,因为这意味着所有具有相同名称的Person对象最终都会进入同一个桶中。更糟糕的是,在哈希函数中包括未在等函数中使用的数据成员。这很可能会导致灾难,使您无法在unordered_set中找到相等的对象。

哈希策略

除了创建均匀分布在桶中的键的哈希值之外,我们还可以通过拥有许多桶来减少碰撞的数量。每个桶的平均元素数称为负载因子。在前面的示例中,我们创建了一个具有 100 个桶的unordered_set。如果我们向集合中添加 50 个Person对象,load_factor()将返回 0.5。max_load_factor是负载因子的上限,当达到该值时,集合将需要增加桶的数量,并且因此还需要重新散列当前集合中的所有元素。还可以使用rehash()reserve()成员函数手动触发重新散列。

让我们继续看看第三类:容器适配器。

容器适配器

标准库中有三种容器适配器:std::stackstd::queuestd::priority_queue。容器适配器与序列容器和关联容器非常不同,因为它们代表可以由底层序列容器实现的抽象数据类型。例如,堆栈是一个后进先出LIFO)数据结构,支持在堆栈顶部进行推送和弹出,可以使用vectorlistdeque或任何其他支持back()push_back()pop_back()的自定义序列容器来实现。队列也是如此,它是一个先进先出FIFO)数据结构,以及priority_queue

在本节中,我们将重点关注std::priority_queue,这是一个非常有用的数据结构,很容易被忘记。

优先队列

优先队列提供了具有最高优先级的元素的常数时间查找。使用元素的小于运算符定义优先级。插入和删除都在对数时间内运行。优先队列是一个部分有序的数据结构,可能不明显何时使用它而不是完全排序的数据结构,例如树或排序向量。但是,在某些情况下,优先队列可以为您提供所需的功能,并且成本比完全排序的容器低。

标准库已经提供了一个部分排序算法,所以我们不需要自己写。但让我们看看如何使用优先队列来实现一个部分排序算法。假设我们正在编写一个程序,用于根据查询搜索文档。匹配的文档(搜索命中)应按排名排序,我们只对找到的前 10 个排名最高的搜索命中感兴趣。

文档由以下类表示:

class Document { 
public:  
  Document(std::string title) : title_{std::move(title)} {}
private:  
  std::string title_; 
  // ... 
}; 

在搜索时,算法选择与查询匹配的文档并计算搜索命中的排名。每个匹配的文档由Hit表示:

struct Hit { 
  float rank_{}; 
  std::shared_ptr<Document> document_; 
}; 

最后,我们需要对命中进行排序并返回前m个文档。对于排序命中有哪些选项?如果命中包含在提供随机访问迭代器的容器中,我们可以使用std::sort()并且只返回前m个元素。或者,如果命中的总数远远大于我们要返回的m个文档,我们可以使用std::partial_sort(),这比std::sort()更有效。

但是如果我们没有随机访问迭代器怎么办?也许匹配算法只提供了对命中的前向迭代器。在这种情况下,我们可以使用优先队列,仍然得到一个高效的解决方案。我们的排序接口将如下所示:

template<typename It>
auto sort_hits(It begin, It end, size_t m) -> std::vector<Hit> { 

我们可以使用定义了递增运算符的任何迭代器调用此函数。接下来,我们创建一个由std::vector支持的std::priority_queue,使用自定义比较函数来保持队列顶部的最低排名命中:

 auto cmp = [](const Hit& a, const Hit& b) { 
    return a.rank_ > b.rank_; // Note, we are using greater than 
  };
  auto queue = std::priority_queue<Hit, std::vector<Hit>,                                    decltype(cmp)>{cmp}; 

我们将在优先队列中最多插入 m 个元素。优先队列将包含到目前为止看到的排名最高的命中。在当前在优先队列中的元素中,排名最低的命中将成为最顶部的元素:

 for (auto it = begin; it != end; ++it) { 
    if (queue.size() < m) { 
      queue.push(*it); 
    } 
    else if (it->rank_ > queue.top().rank_) { 
      queue.pop(); 
      queue.push(*it); 
    } 
  } 

现在,我们已经在优先队列中收集了排名最高的命中,所以唯一剩下的事情就是将它们以相反的顺序放入向量中,并返回排序后的命中:

 auto result = std::vector<Hit>{}; 
  while (!queue.empty()) { 
    result.push_back(queue.top()); 
    queue.pop(); 
  } 
  std::reverse(result.begin(), result.end()); 
  return result; 
} // end of sort_hits() 

这个算法的复杂度是多少?如果我们用 n 表示命中次数,用 m 表示返回的命中次数,我们可以看到内存消耗是 O(m),而时间复杂度是 O(n * log m),因为我们正在迭代 n 个元素。此外,在每次迭代中,我们可能需要进行推送和/或弹出,这两者都在 O(log m)时间内运行。

现在我们将离开标准库容器,专注于一些与标准容器密切相关的新的有用的类模板。

使用视图

在本节中,我们将讨论 C++标准库中一些相对较新的类模板:C++17 中的std::string_view和 C++20 中引入的std::span

这些类模板不是容器,而是一系列连续元素的轻量级视图(或切片)。视图是小对象,可以按值复制。它们不分配内存,也不提供有关它们指向的内存的生存期的任何保证。换句话说,它们是非拥有引用类型,与本章前面描述的容器有很大不同。与此同时,它们与std::stringstd::arraystd::vector密切相关,我们将很快看到。我将从描述std::string_view开始。

使用 string_view 避免复制

std::string_view包含一个指向不可变字符串缓冲区开头的指针和一个大小。由于字符串是一系列连续的字符,指针和大小完全定义了一个有效的子字符串范围。通常,std::string_view指向由std::string拥有的一些内存。但它也可以指向具有静态存储期的字符串字面量或类似内存映射文件的东西。以下图表显示了std::string_view指向由std::string拥有的内存:

图 4.13:一个指向由 std::string 实例拥有的内存的 std::string_view 对象。

std::string_view定义的字符序列不需要以空字符结尾,但包含空字符的字符序列是完全有效的。另一方面,std::string需要能够从c_str()返回以空字符结尾的字符串,这意味着它总是在序列的末尾存储额外的空字符。

string_view不需要空终止符的事实意味着它可以比 C 风格字符串或std::string更有效地处理子字符串,因为它不必创建新的字符串来添加空终止符。使用std::string_viewsubstr()的复杂度是常数,这应该与std::stringsubstr()版本进行比较,后者的复杂度是线性时间。

将字符串传递给函数时也会有性能提升。考虑以下代码:

auto some_func(const std::string& s) {
  // process s ...
}
some_func("A string literal"); // Creates a std::string 

当将字符串字面量传递给some_func()时,编译器需要构造一个新的std::string对象以匹配参数的类型。然而,如果我们让some_func()接受一个std::string_view,就不再需要构造一个std::string了:

auto some_func(std::string_view s) { // Pass by value
  // process s ... 
}
some_func("A string literal"); 

std::string_view实例可以有效地从std::string和字符串字面量构造,并且因此是函数参数的合适类型。

使用 std::span 消除数组衰减

在本章前面讨论std::vectorstd::array时,我提到了数组衰减(失去数组的大小信息)在将内置数组传递给函数时会发生:

// buffer looks like an array, but is in fact a pointer 
auto f1(float buffer[]) {
  const auto n = std::size(buffer);   // Does not compile!
  for (auto i = 0u; i < n; ++i) {     // Size is lost!
    // ...
  }
} 

我们可以通过添加大小参数来解决这个问题:

auto f2(float buffer[], size_t n) {
  for (auto i = 0u; i < n; ++i) {
    // ...
  }
} 

尽管这在技术上是有效的,但向该函数传递正确的数据既容易出错又繁琐,如果f2()将缓冲区传递给其他函数,它需要记住传递正确大小的变量n。这是f2()的调用点可能会看起来像的:

float a[256]; 
f2(a, 256);     
f2(a, sizeof(a)/sizeof(a[0])); // A common tedious pattern
f2(a, std::size(a)); 

数组衰减是许多与边界相关的错误的根源,在使用内置数组的情况下(出于某种原因),std::span提供了一种更安全的方法将数组传递给函数。由于 span 在一个对象中同时保存了指向内存的指针和大小,因此我们可以将其用作将元素序列传递给函数时的单一类型:

auto f3(std::span<float> buffer) {  // Pass by value
  for (auto&& b : buffer) {         // Range-based for-loop
    // ...
  }
}
float a[256]; 
f3(a);          // OK! Array is passed as a span with size
auto v = std::vector{1.f, 2.f, 3.f, 4.f};
f3(v);          // OK! 

与内置数组相比,span 更方便使用,因为它更像一个具有迭代器支持的常规容器。

在数据成员(指针和大小)和成员函数方面,std::string_viewstd::span之间有许多相似之处。但也有一些显着的区别:std::span指向的内存是可变的,而std::string_view总是指向常量内存。std::string_view还包含特定于字符串的函数,如hash()substr(),这自然不是std::span的一部分。最后,在std::span中没有compare()函数,因此不可能直接在std::span对象上使用比较运算符。

现在是时候强调一些与使用标准库数据结构相关的一般性能要点了。

一些性能考虑

我们现在已经涵盖了三个主要的容器类别:序列容器、关联容器和容器适配器。本节将为您提供一些在使用容器时考虑的一般性能建议。

在复杂性保证和开销之间取得平衡。

在选择容器时,了解数据结构的时间和内存复杂性是重要的。但同样重要的是要记住,每个容器都带有开销成本,这对于较小的数据集的性能影响更大。复杂性保证只有在足够大的数据集时才变得有趣。在您的用例中,您需要决定足够大的含义。在这里,您需要再次在执行程序时测量以获得见解。

此外,计算机配备了内存缓存的事实使得对缓存友好的数据结构更有可能表现更好。这通常有利于std::vector,它的内存开销低,并且将其元素连续存储在内存中,使得访问和遍历更快。

下图显示了两种算法的实际运行时间。一个以线性时间O(n)运行,另一个以对数时间O(log n)运行,但开销更大。当输入大小低于标记的阈值时,对数算法比线性时间算法慢:

图 4.14:对于较小的 n,线性算法 O(n)比运行在 O(log n)的算法更快

我们要记住的下一个要点更加具体,突出了使用最合适的 API 函数的重要性。

了解并使用适当的 API 函数

在 C++中,通常有多种方法可以做某事。语言和库继续发展,但很少有功能被弃用。当新函数添加到标准库中时,我们应该学会何时使用它们,并反思我们可能已经使用的模式,以弥补以前缺失的功能。

在这里,我们将专注于标准库中的两个小但重要的函数:contains()empty()。在检查关联容器中的元素是否存在时使用contains()。如果要知道容器是否有任何元素或为空,请使用empty()。除了更清晰地表达意图外,它还具有性能优势。检查链表的大小是一个O(n)操作,而在列表上调用empty()则在常数时间O(1)内运行。

在 C++20 之前和contains()函数的引入之前,每当我们想要检查关联容器中某个值的存在时,我们都不得不绕个弯。您很可能会遇到使用各种方法来查找元素存在性的代码。假设我们使用std::multiset实现了一个单词袋:

auto bag = std::multiset<std::string>{}; // Our bag-of-words
// Fill bag with words ... 

如果我们想知道我们的单词袋中是否有某个特定单词,有许多方法可以继续。一个选择是使用count(),就像这样:

auto word = std::string{"bayes"}; // Our word we want to find
if (bag.count(word) > 0) {
   // ...
} 

这似乎是合理的,但它可能有一些额外开销,因为它计算与我们的单词匹配的所有元素。另一种选择是使用find(),但它有相同的开销,因为它返回所有匹配的单词,而不仅仅是第一次出现的:

if (bag.find(word) != bag.end()) {
  // ...
} 

在 C++20 之前,推荐的方法是使用lower_bound(),因为它只返回第一个匹配的元素,就像这样:

if (bag.lower_bound(word) != bag.end()) { 
  // ...
} 

现在,随着 C++20 和contains()的引入,我们可以更清楚地表达我们的意图,并确保当我们只想检查元素是否存在时,库会为我们提供最有效的实现:

if (bag.contains(word)) { // Efficient and with clear intent 
  // ...
} 

一般规则是,如果有一个特定的成员函数或为特定容器设计的自由函数,那么如果符合您的需求,请使用它。它将是高效的,并且会更清晰地表达意图。不要像之前展示的那样绕道而行,只是因为您还没有学会完整的 API,或者因为您有以某种方式做事的旧习惯。

还应该说的是,零开销原则特别适用于这样的函数,因此不要浪费时间试图通过手工制作自己的函数来智胜库实现者。

我们现在将继续看一个更长的示例,展示我们如何以不同的方式重新排列数据,以优化特定用例的运行时性能。

并行数组

我们将通过讨论迭代元素和探索在迭代类似数组的数据结构时改善性能的方法来结束本章。我已经提到了访问数据时性能的两个重要因素:空间局部性和时间局部性。当在内存中连续存储的元素上进行迭代时,如果我们设法保持对象小,那么我们将增加所需数据已经被缓存的概率,这要归功于空间局部性。显然,这将对性能产生巨大影响。

回想一下在本章开头展示的缓存抖动示例,我们在矩阵上进行了迭代。它表明有时我们需要考虑访问数据的方式,即使我们对数据有一个相当紧凑的表示。

接下来,我们将比较迭代不同大小对象需要多长时间。我们将首先定义两个结构体,SmallObjectBigObject

struct SmallObject { 
  std::array<char, 4> data_{}; 
  int score_{std::rand()}; 
};

struct BigObject { 
 std::array<char, 256> data_{}; 
 int score_{std::rand()}; 
}; 

SmallObjectBigObject是相同的,只是初始数据数组的大小不同。这两个结构都包含一个名为score_int,我们为测试目的初始化为一个随机值。我们可以使用sizeof运算符让编译器告诉我们对象的大小:

std::cout << sizeof(SmallObject); // Possible output is 8 
std::cout << sizeof(BigObject);   // Possible output is 260 

我们需要大量对象来评估性能。创建每种对象一百万个:

auto small_objects = std::vector<SmallObject>(1'000'000); 
auto big_objects = std::vector<BigObject>(1'000'000); 

现在进行迭代。假设我们想要对所有对象的分数进行求和。我们更倾向于使用std::accumulate(),这是我们稍后会在书中介绍的,但是,现在,一个简单的for循环就可以了。我们将这个函数写成一个模板,这样我们就不必为每种类型的对象手动编写一个版本。该函数迭代对象并对所有分数求和:

template <class T> 
auto sum_scores(const std::vector<T>& objects) {  
  ScopedTimer t{"sum_scores"};    // See chapter 3 

  auto sum = 0; 
  for (const auto& obj : objects) { 
    sum += obj.score_; 
  } 
  return sum; 
} 

现在,我们准备看看在小对象中求和分数需要多长时间,与大对象相比:

auto sum = 0; 
sum += sum_scores(small_objects); 
sum += sum_scores(big_objects); 

为了获得可靠的结果,我们需要多次重复测试。在我的电脑上,计算小对象的总和大约需要 1 毫秒,计算大对象的总和需要 10 毫秒。这个例子类似于本章开头的缓存抖动示例,而造成巨大差异的一个原因是,再次是因为计算机使用缓存层次结构从主内存中获取数据的方式。

在处理比前面的例子更现实的场景时,我们如何利用迭代小对象集合比大对象集合更快的事实?

显然,我们可以尽力保持类的大小较小,但这通常说起来容易做起来难。此外,如果我们正在处理一个已经增长了一段时间的旧代码库,很有可能会遇到一些非常大的类,其中包含太多的数据成员和太多的职责。

现在,我们将看一个代表在线游戏系统中用户的类,并看看我们如何将其分成更小的部分。该类具有以下数据成员:

struct User { 
  std::string name_; 
  std::string username_; 
  std::string password_; 
  std::string security_question_; 
  std::string security_answer_; 
  short level_{}; 
  bool is_playing_{}; 
}; 

用户有一个经常使用的名称和一些很少使用的身份验证信息。该类还跟踪玩家当前所玩的级别。最后,User结构还通过存储is_playing_布尔值来知道用户当前是否在玩。

sizeof运算符在 64 位架构编译时报告User类为 128 字节。数据成员的近似布局如下图所示:

图 4.15:User 类的内存布局

所有用户都保存在std::vector中,并且有两个经常调用并且需要快速运行的全局函数:num_users_at_level()num_playing_users()。这两个函数都迭代所有用户,因此我们需要快速迭代用户向量。

第一个函数返回达到特定级别的用户数量:

auto num_users_at_level(const std::vector<User>& users, short level) { 
  ScopedTimer t{"num_users_at_level (using 128 bytes User)"}; 

  auto num_users = 0; 
  for (const auto& user : users)
    if (user.level_ == level)
      ++num_users; 
  return num_users; 
} 

第二个函数计算当前有多少用户在玩:

auto num_playing_users(const std::vector<User>& users) { 
  ScopedTimer t{"num_playing_users (using 128 bytes User)"}; 

  return std::count_if(users.begin(), users.end(), 
    [](const auto& user) { 
      return user.is_playing_; 
    }); 
} 

在这里,我们使用算法std::count_if()而不是手写循环,就像我们在num_users_at_level()中所做的那样。std::count_if()将为用户向量中的每个用户调用我们提供的谓词,并返回谓词返回true的次数。这基本上也是我们在第一个函数中所做的,所以我们也可以在第一个情况下使用std::count_if()。这两个函数都在线性时间内运行。

使用一个包含一百万个用户的向量调用这两个函数会得到以下输出:

11 ms num_users_at_level (using 128 bytes User)
10 ms num_playing_users (using 128 bytes User) 

我们假设通过使User类更小,迭代向量将更快。如前所述,密码和安全数据字段很少使用,可以分组在一个单独的结构中。这将给我们以下类:

struct AuthInfo { 
  std::string username_; 
  std::string password_; 
  std::string security_question_; 
  std::string security_answer_; 
}; 

struct User { 
  std::string name_; 
  std::unique_ptr<AuthInfo> auth_info_; 
  short level_{}; 
  bool is_playing_{}; 
}; 

这个改变将User类的大小从 128 字节减小到 40 字节。在User类中不再存储四个字符串,而是使用指针来引用新的AuthInfo对象。下图显示了我们如何将User类分成两个较小的类:

图 4.16:当认证信息保存在单独的类中时的内存布局

从设计的角度来看,这个改变也是有意义的。将认证数据保存在单独的类中增加了User类的内聚性。User类包含一个指向认证信息的指针。当然,用户数据占用的总内存量并没有减少,但现在重要的是缩小User类以加快迭代所有用户的函数。

从优化的角度来看,我们必须再次测量以验证我们关于较小数据的假设是否有效。结果表明,使用较小的User类时,两个函数的运行速度都提高了两倍以上。修改版本运行时的输出如下:

4 ms num_users_at_level with User
3 ms num_playing_users with User 

接下来,我们将尝试一种更激进的方式来缩小我们需要迭代的数据量,即使用并行数组。首先,警告:在许多情况下,这是一种优化,具有太多的缺点,无法成为可行的替代方案。不要将其视为一般技术,并且不加思考地应用它。在看完几个例子之后,我们将回顾并行数组的优缺点。

通过使用并行数组,我们简单地将大型结构拆分为较小的类型,类似于我们为User类的认证信息所做的操作。但是,我们不是使用指针来关联对象,而是将较小的结构存储在相等大小的单独数组中。不同数组中的较小对象,它们共享相同的索引,形成完整的原始对象。

一个例子将阐明这种技术。我们所使用的User类由 40 个字节组成。现在它只包含一个用户名字符串,一个指向认证信息的指针,一个表示当前级别的整数,以及is_playing_布尔值。通过缩小用户对象,我们发现在迭代对象时性能有所提高。用户对象数组的内存布局看起来像下图所示。我们暂时忽略内存对齐和填充,但在第七章 内存管理中会回到这些主题:

图 4.17:用户对象在向量中连续存储

我们可以将所有short级别和is_playing_标志存储在单独的向量中,而不是一个包含用户对象的向量。用户数组中索引为 0 的用户的当前级别也存储在级别数组的索引 0 处。这样,我们可以避免使用级别的指针,而是只使用索引来连接数据字段。我们也可以对布尔is_playing_字段做同样的操作,最终得到三个并行数组,而不是一个。这三个向量的内存布局看起来像这样:

图 4.18:使用三个并行数组时的内存布局

我们使用三个并行数组来快速迭代一个特定字段。num_users_at_level()函数现在可以通过仅使用级别数组来计算特定级别的用户数量。现在的实现只是std::count()的一个包装器:

auto num_users_at_level(const std::vector<int>& users, short level) { 
  ScopedTimer t{"num_users_at_level using int vector"}; 
  return std::count(users.begin(), users.end(), level); 
} 

同样,num_playing_users()函数只需要迭代布尔向量来确定正在玩游戏的用户数量。同样,我们使用std::count()

auto num_playing_users(const std::vector<bool>& users) { 
  ScopedTimer t{"num_playing_users using vector<bool>"}; 
  return std::count(users.begin(), users.end(), true); 
} 

使用并行数组,我们根本不需要使用用户数组。提取数组所占用的内存量远远小于用户数组,因此让我们再次检查在一百万用户上运行这些函数时是否提高了性能:

auto users = std::vector<User>(1'000'000); 
auto levels = std::vector<short>(1'000'000); 
auto playing_users = std::vector<bool>(1'000'000); 

// Initialize data 
// ... 

auto num_at_level_5 = num_users_at_level(levels, 5);
auto num_playing = num_playing_users(playing_users); 

使用整数数组计算特定级别的用户数量只需要大约 0.7 毫秒。回顾一下,初始版本使用 128 字节大小的User类大约需要 11 毫秒。较小的User类执行时间为 4 毫秒,现在,只使用levels数组,我们的执行时间降至 0.7 毫秒。这是一个相当大的变化。

对于第二个函数num_playing_users()来说,改变更大——只需要大约 0.03 毫秒就能计算出当前正在玩游戏的用户数量。之所以能够如此快速,是因为有一种叫做位数组的数据结构。原来std::vector<bool>并不是标准的 C++ bool对象的向量。在内部,它实际上是一个位数组。在位数组中,诸如count()find()等操作可以被高效地优化,因为它可以一次处理 64 位(在 64 位机器上),甚至可能通过使用 SIMD 寄存器处理更多位。std::vector<bool>的未来尚不明朗,很可能会很快被固定大小的std::bitset和新的动态大小的 bitset 所取代。Boost 中已经有了一个名为boost::dynamic_bitset的版本。

这一切都很棒,但我警告过您会有一些缺点。首先,从类中提取字段实际上会对代码结构产生重大影响。在某些情况下,将大类拆分为较小的部分是完全合理的,但在其他情况下,它完全破坏了封装性,并暴露了本应该隐藏在更高抽象接口后面的数据。

确保数组同步也很麻烦,因此我们总是需要确保组成一个对象的字段在所有数组中的相同索引处存储。这样的隐式关系很难维护,也容易出错。

最后一个缺点实际上与性能有关。在前面的例子中,您看到对于逐个字段迭代的算法,性能有了很大的提升。然而,如果我们有一个需要访问已提取到不同数组中的多个字段的算法,它将比在一个包含更大对象的数组上迭代要慢得多。

因此,就像在处理性能时一样,没有什么是不需要付出代价的,暴露数据并将一个简单的数组拆分为多个数组的代价可能太高,也可能不太高。这一切取决于您所面临的情况,以及在测量后您所遇到的性能收益。在真正面临性能问题之前,不要考虑并行数组。始终优先考虑良好的设计原则,并倾向于显式地表达对象之间的关系,而不是隐式的。

总结

在本章中,介绍了标准库中的容器类型。您了解到我们如何组织数据对于我们能够高效执行集合对象上的某些操作有着重大影响。标准库容器的渐近复杂度规范是在选择不同数据结构时需要考虑的关键因素。

此外,您了解到现代处理器中的缓存层次结构如何影响我们需要如何组织数据以实现对内存的高效访问。高效利用缓存层次结构的重要性不言而喻。这也是为什么保持元素在内存中连续的容器,如std::vectorstd::string,已经成为最常用的容器之一的原因。

在下一章中,我们将看看如何使用迭代器和算法来高效地操作容器。

第五章:算法

标准库中容器的使用在 C++程序员中被广泛采用。很少能找到没有引用std::vectorstd::string等的 C++代码库。然而,在我的经验中,标准库算法的使用频率要低得多,尽管它们提供了与容器相同类型的好处:

  • 在解决复杂问题时可以用作构建块

  • 它们有很好的文档(包括参考资料、书籍和视频)

  • 许多 C++程序员已经熟悉它们

  • 它们的空间和运行时成本是已知的(复杂度保证)

  • 它们的实现非常精心和高效

如果这还不够,C++的特性,比如 lambda、执行策略、概念和范围,都使标准算法更加强大,同时也更加友好。

在本章中,我们将看看如何使用算法库在 C++中编写高效的算法。您将学习在应用程序中使用标准库算法作为构建块的好处,无论是性能还是可读性方面。

在本章中,您将学习:

  • C++标准库中的算法

  • 迭代器和范围-容器和算法之间的粘合剂

  • 如何实现一个可以操作标准容器的通用算法

  • 使用 C++标准算法的最佳实践

让我们首先看一下标准库算法,以及它们如何成为今天的样子。

介绍标准库算法

将标准库算法集成到您的 C++词汇表中是很重要的。在本介绍中,我将介绍一组可以通过使用标准库算法有效解决的常见问题。

C++20 通过引入 Ranges 库和C++概念的语言特性对算法库进行了重大改变。因此,在我们开始之前,我们需要简要了解 C++标准库的历史背景。

标准库算法的演变

您可能已经听说过 STL 算法或 STL 容器。希望您也已经听说了 C++20 引入的新的 Ranges 库。在 C++20 中,标准库有很多新增内容。在继续之前,我需要澄清一些术语。我们将从 STL 开始。

STL,或者标准模板库,最初是在上世纪 90 年代添加到 C++标准库中的一个库的名称。它包含算法、容器、迭代器和函数对象。这个名字一直很粘人,我们已经习惯了听到和谈论 STL 算法和容器。然而,C++标准并没有提到 STL;相反,它谈到了标准库及其各个组件,比如迭代器库和算法库。在本书中,我会尽量避免使用 STL 这个名字,而是在需要时谈论标准库或单独的库。

现在让我们来看看 Ranges 库以及我将称之为受限算法。Ranges 库是 C++20 中添加到标准库的一个库,引入了一个全新的头文件<ranges>,我们将在下一章中更多地谈论它。但是,Ranges 库的添加也对<algorithm>头文件产生了很大影响,通过引入所有先前存在的算法的重载版本。我将这些算法称为受限算法,因为它们使用了 C++概念进行限制。因此,<algorithm>头文件现在包括了旧的基于迭代器的算法和可以操作范围的使用 C++概念限制的新算法。这意味着我们将在本章讨论的算法有两种风味,如下例所示:

#include <algorithm>
#include <vector>
auto values = std::vector{9, 2, 5, 3, 4};
// Sort using the std algorithms
std::sort(values.begin(), values.end());
// Sort using the constrained algorithms under std::ranges
std::ranges::sort(values); 
std::ranges::sort(values.begin(), values.end()); 

请注意,sort()的两个版本都位于<algorithm>头文件中,但它们由不同的命名空间和签名区分。本章将使用这两种版本,但一般来说,我建议尽可能使用新的约束算法。在阅读本章后,这些好处将会变得明显。

现在你已经准备好开始学习如何使用现成的算法来解决常见问题了。

解决日常问题

我在这里列出了一些常见的场景和有用的算法,只是为了让你对标准库中可用的算法有所了解。标准库中有许多算法,在本节中我只会介绍其中的一些。对于标准库算法的快速但完整的概述,我推荐 Jonathan Boccara 在CppCon 2018上的演讲,题为Less Than an Hour,可在sched.co/FnJh上找到。

遍历序列

有一个有用的短小的辅助函数,可以打印序列的元素。下面的通用函数适用于任何容器,其中包含可以使用operator<<()打印到输出流的元素:

void print(auto&& r) {
  std::ranges::for_each(r, [](auto&& i) { std::cout << i << ' '; });
} 

print()函数使用了for_each(),这是从<algorithm>头文件导入的算法。for_each()为我们提供的函数为范围中的每个元素调用一次。我们提供的函数的返回值被忽略,并且对我们传递给for_each()的序列没有影响。我们可以使用for_each()来进行诸如打印到stdout之类的副作用(就像在这个例子中所做的那样)。

一个类似的非常通用的算法是transform()。它也为序列中的每个元素调用一个函数,但它不会忽略返回值,而是将函数的返回值存储在输出序列中,就像这样:

auto in = std::vector{1, 2, 3, 4};
auto out = std::vector<int>(in.size());
auto lambda = [](auto&& i) { return i * i; };
std::ranges::transform(in, out.begin(), lambda);
print(out); 
// Prints: "1 4 9 16" 
print() function defined earlier. The transform() algorithm will call our lambda once for each element in the input range. To specify where the output will be stored, we provide transform() with an output iterator, out.begin(). We will talk a lot more about iterators later on in this chapter.

有了我们的print()函数和一些最常见的算法演示,我们将继续看一些用于生成元素的算法。

生成元素

有时我们需要为一系列元素分配一些初始值或重置整个序列。下面的例子用值-1 填充了一个向量:

auto v = std::vector<int>(4);
std::ranges::fill(v, -1);
print(v); 
// Prints "-1 -1 -1 -1 " 

下一个算法generate()为每个元素调用一个函数,并将返回值存储在当前元素中:

auto v = std::vector<int>(4);
std::ranges::generate(v, std::rand);
print(v);
// Possible output: "1804289383 846930886 1681692777 1714636915 " 

在前面的例子中,std::rand()函数被每个元素调用了一次。

我要提到的最后一个生成算法是<numeric>头文件中的std::iota()。它按递增顺序生成值。起始值必须作为第二个参数指定。下面是一个生成 0 到 5 之间值的简短示例:

 auto v = std::vector<int>(6);
  std::iota(v.begin(), v.end(), 0);
  print(v); // Prints: "0 1 2 3 4 5 " 

这个序列已经排序好了,但更常见的情况是你有一个无序的元素集合需要排序,接下来我们会看一下。

元素排序

排序元素是一个非常常见的操作。有一些好的排序算法替代方案是值得了解的,但在这个介绍中,我只会展示最常规的版本,简单地命名为sort()

auto v = std::vector{4, 3, 2, 3, 6};
std::ranges::sort(v);
print(v);       // Prints: "2 3 3 4 6 " 

如前所述,这不是唯一的排序方式,有时我们可以使用部分排序算法来提高性能。我们将在本章后面更多地讨论排序。

查找元素

另一个非常常见的任务是找出特定值是否在集合中。也许我们想知道集合中有多少个特定值的实例。如果我们知道集合已经排序,那么搜索值的这些算法可以更有效地实现。你在第三章分析和测量性能中看到了这一点,我们比较了线性搜索和二分搜索。

我们从不需要排序的find()算法开始:

auto col = std::list{2, 4, 3, 2, 3, 1};
auto it = std::ranges::find(col, 2);
if (it != col.end()) {
  std::cout << *it << '\n';
} 

如果找不到我们要找的元素,find()会返回集合的end()迭代器。在最坏的情况下,find()需要检查序列中的所有元素,因此它的运行时间为O(n)

使用二分查找进行查找

如果我们知道集合已经排序,我们可以使用二分搜索算法之一:binary_search()equal_range()upper_bound()lower_bound()。如果我们将这些函数与提供对其元素进行随机访问的容器一起使用,它们都保证在O(log n)时间内运行。当我们在本章后面讨论迭代器和范围时(有一个名为Iterators and Ranges的部分即将到来),你将更好地理解算法如何提供复杂度保证,即使它们在不同的容器上操作。

在以下示例中,我们将使用一个排序的std::vector,其中包含以下元素:

图 5.1:一个包含七个元素的排序 std::vector

binary_search()函数根据我们搜索的值是否能找到返回truefalse

auto v = std::vector{2, 2, 3, 3, 3, 4, 5};    // Sorted!
bool found = std::ranges::binary_search(v, 3);
std::cout << std::boolalpha << found << '\n'; //   Output: true 

在调用binary_search()之前,你应该绝对确定集合是排序的。我们可以在代码中使用is_sorted()轻松断言这一点,如下所示:

assert(std::ranges::is_sorted(v)); 

这个检查将在O(n)时间内运行,但只有在激活断言时才会被调用,因此不会影响最终程序的性能。

我们正在处理的排序集合包含多个 3。如果我们想知道集合中第一个 3 或最后一个 3 的位置,我们可以使用lower_bound()来找到第一个 3,或者使用upper_bound()来找到最后一个 3 之后的元素:

auto v = std::vector{2, 2, 3, 3, 3, 4, 5};
auto it = std::ranges::lower_bound(v, 3);
if (it != v.end()) {
  auto index = std::distance(v.begin(), it);
  std::cout << index << '\n'; // Output: 2
} 

这段代码将输出2,因为这是第一个 3 的索引。要从迭代器获取元素的索引,我们使用<iterator>头文件中的std::distance()

同样地,我们可以使用upper_bound()来获取一个迭代器,指向最后一个 3 之后的元素:

const auto v = std::vector{2, 2, 3, 3, 3, 4, 5};
auto it = std::ranges::upper_bound(v, 3);
if (it != v.end()) {
  auto index = std::distance(v.begin(), it);
  std::cout << index << '\n'; // Output: 5
} 

如果你想要上下界,你可以使用equal_range(),它返回包含 3 的子范围:

const auto v = std::vector{2, 2, 3, 3, 3, 4, 5};
auto subrange = std::ranges::equal_range(v, 3);
if (subrange.begin() != subrange.end()) {
  auto pos1 = std::distance(v.begin(), subrange.begin());
  auto pos2 = std::distance(v.begin(), subrange.end());
  std::cout << pos1 << " " << pos2 << '\n';
} // Output: "2 5" 

现在让我们探索一些用于检查集合的其他有用算法。

测试特定条件

有三个非常方便的算法叫做all_of()any_of()none_of()。它们都接受一个范围、一个一元谓词(接受一个参数并返回truefalse的函数)和一个可选的投影函数。

假设我们有一个数字列表和一个小 lambda 函数,确定一个数字是否为负数:

const auto v = std::vector{3, 2, 2, 1, 0, 2, 1};
const auto is_negative = [](int i) { return i < 0; }; 

我们可以使用none_of()来检查是否没有任何数字是负数:

if (std::ranges::none_of(v, is_negative)) {
  std::cout << "Contains only natural numbers\n";
} 

此外,我们可以使用all_of()来询问列表中的所有元素是否都是负数:

if (std::ranges::all_of(v, is_negative)) {
  std::cout << "Contains only negative numbers\n";
} 

最后,我们可以使用any_of()来查看列表是否至少包含一个负数:

if (std::ranges::any_of(v, is_negative)) {
  std::cout << "Contains at least one negative number\n";
} 

很容易忘记标准库中存在的这些小而方便的构建块。但一旦你养成使用它们的习惯,你就再也不会回头手写这些了。

计算元素

计算等于某个值的元素数量最明显的方法是调用count()

const auto numbers = std::list{3, 3, 2, 1, 3, 1, 3};
int n = std::ranges::count(numbers, 3);
std::cout << n;                    // Prints: 4 

count()算法运行时间为线性。然而,如果我们知道序列是排序的,并且我们使用的是向量或其他随机访问数据结构,我们可以使用equal_range(),它将在O(log n)时间内运行。以下是一个例子:

const auto v = std::vector{0, 2, 2, 3, 3, 4, 5};
assert(std::ranges::is_sorted(v)); // O(n), but not called in release
auto r = std::ranges::equal_range(v, 3);
int n = std::ranges::size(r);
std::cout << n;                    // Prints: 2 

equal_range()函数找到包含我们要计数的所有元素的子范围。一旦找到子范围,我们可以使用<ranges>头文件中的size()来检索子范围的长度。

最小值、最大值和夹紧

我想提到一组小但非常有用的算法,这些算法对于经验丰富的 C++程序员来说是必不可少的知识。std::min()std::max()std::clamp()函数有时会被遗忘,而我们经常发现自己编写这样的代码:

const auto y_max = 100;
auto y = some_func();
if (y > y_max) {
  y = y_max;
} 

该代码确保y的值在某个限制范围内。这段代码可以工作,但我们可以避免使用可变变量和if语句,而是使用std::min(),如下所示:

const auto y = std::min(some_func(), y_max); 

通过使用std::min(),我们消除了代码中的可变变量和if语句。对于类似的情况,我们可以使用std::max()。如果我们想要将一个值限制在最小值和最大值之间,我们可以这样做:

const auto y = std::max(std::min(some_func(), y_max), y_min); 

但是,自 C++17 以来,我们现在有了std::clamp(),它可以在一个函数中为我们完成这个操作。因此,我们可以像下面这样使用clamp()

const auto y = std::clamp(some_func(), y_min, y_max); 

有时我们需要在未排序的元素集合中找到极值。为此,我们可以使用minmax(),它(不出所料地)返回序列的最小值和最大值。结合结构化绑定,我们可以按如下方式打印极值:

const auto v = std::vector{4, 2, 1, 7, 3, 1, 5};
const auto [min, max] = std::ranges::minmax(v);
std::cout << min << " " << max;      // Prints: "1 7" 

我们还可以使用min_element()max_element()找到最小或最大元素的位置。它不返回值,而是返回一个指向我们要查找的元素的迭代器。在下面的例子中,我们正在寻找最小元素:

const auto v = std::vector{4, 2, 7, 1, 1, 3};
const auto it = std::ranges::min_element(v);
std::cout << std::distance(v.begin(), it); // Output: 3 
3, which is the index of the first minimum value that was found.

这是对标准库中一些最常见算法的简要介绍。算法的运行时成本在 C++标准中有规定,所有库实现都需要遵守这些规定,尽管确切的实现可能在不同的平台之间有所不同。为了理解如何保持与许多不同类型的容器一起工作的通用算法的复杂性保证,我们需要更仔细地研究迭代器和范围。

迭代器和范围

正如前面的例子所示,标准库算法操作的是迭代器和范围,而不是容器类型。本节将重点介绍迭代器和 C++20 中引入的新概念范围。一旦掌握了迭代器和范围,正确使用容器和算法就变得容易了。

介绍迭代器

迭代器构成了标准库算法和范围的基础。迭代器是数据结构和算法之间的粘合剂。正如你已经看到的,C++容器以非常不同的方式存储它们的元素。迭代器提供了一种通用的方式来遍历序列中的元素。通过让算法操作迭代器而不是容器类型,算法变得更加通用和灵活,因为它们不依赖于容器的类型以及容器在内存中排列元素的方式。

在本质上,迭代器是表示序列中位置的对象。它有两个主要责任:

  • 在序列中导航

  • 在当前位置读取和写入值

迭代器抽象根本不是 C++独有的概念,而是存在于大多数编程语言中。C++实现迭代器概念的不同之处在于,C++模仿了原始内存指针的语法。

基本上,迭代器可以被认为是具有与原始指针相同属性的对象;它可以移动到下一个元素并解引用(如果指向有效地址)。算法只使用指针允许的一些操作,尽管迭代器可能在内部是一个遍历类似树状的std::map的重对象。

直接在std命名空间下找到的大多数算法只对迭代器进行操作,而不是容器(即std::vectorstd::map等)。许多算法返回的是迭代器而不是值。

为了能够在序列中导航而不越界,我们需要一种通用的方法来告诉迭代器何时到达序列的末尾。这就是我们有哨兵值的原因。

哨兵值和超出末尾的迭代器

哨兵值(或简称哨兵)是指示序列结束的特殊值。哨兵值使得可以在不知道序列大小的情况下迭代一系列值。哨兵值的一个示例用法是 C 风格的以 null 结尾的字符串(在这种情况下,哨兵是'\0'字符)。不需要跟踪以 null 结尾的字符串的长度,字符串开头的指针和末尾的哨兵就足以定义一系列字符。

约束算法使用迭代器来定义序列中的第一个元素,并使用哨兵来指示序列的结束。哨兵的唯一要求是它可以与迭代器进行比较,实际上意味着operator==()operator!=()应该被定义为接受哨兵和迭代器的组合:

bool operator=!(sentinel s, iterator i) {
  // ...
} 

现在你知道了哨兵是什么,我们如何创建一个哨兵来指示序列的结束呢?这里的诀窍是使用一个叫做past-the-end iterator的迭代器作为哨兵。它只是一个指向我们定义的序列中最后一个元素之后(或过去)的迭代器。看一下下面的代码片段和图表:

|

auto vec = std::vector {
  'a','b','c','d'
};
auto first = vec.begin();
auto last = vec.end(); 

如前图所示,last迭代器现在指向了一个想象中的'd'元素之后。这使得可以通过循环迭代序列中的所有元素:

for (; first != last; ++first) {
  char value = *first; // Dereference iterator
  // ... 

我们可以使用 past-the-end 哨兵与我们的迭代器it进行比较,但是我们不能对哨兵进行解引用,因为它不指向范围的元素。这种 past-the-end 迭代器的概念有着悠久的历史,甚至适用于内置的 C 数组:

char arr[] = {'a', 'b', 'c', 'd'};
char* end = arr + sizeof(arr);
for (char* it = arr; it != end; ++it) { // Stop at end
   std::cout << *it << ' ';} 
// Output: a b c d 

再次注意,end实际上指向了越界,因此我们不允许对其进行解引用,但是我们允许读取指针值并将其与我们的it变量进行比较。

范围

范围是指我们在引用一系列元素时使用的迭代器-哨兵对的替代品。<range>头文件包含了定义不同种类范围要求的多个概念,例如input_rangerandom_access_range等等。这些都是最基本概念range的细化,它的定义如下:

template<class T>
concept range = requires(T& t) {
  ranges::begin(t);
  ranges::end(t);
}; 

这意味着任何暴露begin()end()函数的类型都被认为是范围(假设这些函数返回迭代器)。

对于 C++标准容器,begin()end()函数将返回相同类型的迭代器,而对于 C++20 范围,这通常不成立。具有相同迭代器和哨兵类型的范围满足std::ranges::common_range的概念。新的 C++20 视图(在下一章中介绍)返回可以是不同类型的迭代器-哨兵对。但是,它们可以使用std::views::common转换为具有相同迭代器和哨兵类型的视图。

std::ranges命名空间中找到的约束算法可以操作范围而不是迭代器对。由于所有标准容器(vectormaplist等)都满足范围概念,因此我们可以直接将范围传递给约束算法,如下所示:

auto vec = std::vector{1, 1, 0, 1, 1, 0, 0, 1};
std::cout << std::ranges::count(vec, 0); // Prints 3 

范围是可迭代的东西的抽象(可以循环遍历的东西),在某种程度上,它们隐藏了对 C++迭代器的直接使用。然而,迭代器仍然是 C++标准库的一个重要部分,并且在 Ranges 库中也被广泛使用。

你需要理解的下一件事是存在的不同种类的迭代器。

迭代器类别

现在你对范围的定义以及如何知道何时到达序列的末尾有了更好的理解,是时候更仔细地看一下迭代器可以支持的操作,以便导航,读取和写入值。

在序列中进行迭代器导航可以使用以下操作:

  • 向前移动:std::next(it)++it

  • 向后移动:std::prev(it)--it

  • 跳转到任意位置:std::advance(it, n)it += n

通过解引用迭代器来读取和写入迭代器表示的位置的值。下面是它的样子:

  • 阅读:auto value = *it

  • 写入:*it = value

这些是容器公开的迭代器的最常见操作。但此外,迭代器可能在数据源上操作,其中写入或读取意味着向前移动。这些数据源的示例可能是用户输入,网络连接或文件。这些数据源需要以下操作:

  • 只读和向前移动:auto value = *it; ++it;

  • 只写和向前移动:*it = value; ++it;

这些操作只能用两个连续的表达式来表示。第一个表达式的后置条件是第二个表达式必须有效。这也意味着我们只能读取或写入一个值到一个位置一次。如果我们想要读取或写入一个新值,我们必须先将迭代器推进到下一个位置。

并非所有迭代器都支持前述列表中的所有操作。例如,一些迭代器只能读取值和向前移动,而其他一些既可以读取写入,又可以跳转到任意位置。

现在,如果我们考虑一些基本算法,就会显而易见地发现迭代器的要求在不同的算法之间有所不同:

  • 如果算法计算值的出现次数,则需要读取向前移动操作

  • 如果算法用一个值填充容器,则需要写入向前移动操作

  • 对于排序集合上的二分搜索算法需要读取跳转操作

一些算法可以根据迭代器支持的操作来更有效地实现。就像容器一样,标准库中的所有算法都有复杂度保证(使用大 O 表示法)。为了满足某个复杂度保证,算法对其操作的迭代器提出了要求。这些要求被归类为六种基本迭代器类别,它们之间的关系如下图所示:

图 5.2:六种迭代器类别及其相互关系

箭头表示迭代器类别还具有它所指向的类别的所有功能。例如,如果一个算法需要一个前向迭代器,我们同样可以传递一个双向迭代器,因为双向迭代器具有前向迭代器的所有功能。

这六个要求由以下概念正式指定:

  • std::input_iterator:支持只读和向前移动(一次)。一次性算法如std::count()可以使用输入迭代器。std::istream_iterator是输入迭代器的一个例子。

  • std::output_iterator:支持只写和向前移动(一次)。请注意,输出迭代器只能写入,不能读取。std::ostream_iterator是输出迭代器的一个例子。

  • std::forward_iterator:支持读取写入向前移动。当前位置的值可以多次读取或写入。例如std::forward_list公开前向迭代器。

  • std::bidirectional_iterator:支持读取写入向前移动向后移动。双向链表std::list公开双向迭代器。

  • std::random_access_iterator:支持读取写入向前移动向后移动和在常数时间内跳转到任意位置。std::deque中的元素可以使用随机访问迭代器访问。

  • std::contiguous_iterator:与随机访问迭代器相同,但也保证底层数据是连续的内存块,例如std::stringstd::vectorstd::arraystd::span和(很少使用的)std::valarray

迭代器类别对于理解算法的时间复杂度要求非常重要。对底层数据结构有很好的理解,可以很容易地知道哪些迭代器通常属于哪些容器。

现在我们准备深入了解大多数标准库算法使用的常见模式。

标准算法的特性

为了更好地理解标准算法,了解一些<algorithm>头文件中所有算法使用的特性和常见模式是很有帮助的。正如已经提到的,stdstd::ranges命名空间下的算法有很多共同之处。我们将从这里开始讨论适用于std算法和std::range下受限算法的通用原则。然后,在下一节中,我们将继续讨论std::ranges下特有的特性。

算法不会改变容器的大小

来自<algorithm>的函数只能修改指定范围内的元素;元素永远不会被添加或删除到底层容器中。因此,这些函数永远不会改变它们操作的容器的大小。

例如,std::remove()std::unique()实际上并不会从容器中删除元素(尽管它们的名字是这样)。相反,它们将应该保留的元素移动到容器的前面,然后返回一个标记,定义了元素的有效范围的新结尾:

代码示例 结果向量

|

// Example with std::remove()
auto v = std::vector{1,1,2,2,3,3};
auto new_end = std::remove(
  v.begin(), v.end(), 2);
v.erase(new_end, v.end()); 

|

// Example with std::unique()
auto v = std::vector{1,1,2,2,3,3};
auto new_end = std::unique(
  v.begin(), v.end());
v.erase(new_end, v.end()); 

C++20 在<vector>头文件中添加了std::erase()std::erase_if()函数的新版本,它们可以立即从向量中删除值,而无需先调用remove()再调用erase()

标准库算法永远不会改变容器的大小,这意味着在调用产生输出的算法时,我们需要自己分配数据。

带有输出的算法需要已分配的数据

向输出迭代器写入数据的算法,如std::copy()std::transform(),需要为输出预留已分配的数据。由于算法只使用迭代器作为参数,它们无法自行分配数据。为了扩大算法操作的容器,它们依赖于迭代器能够扩大它们迭代的容器。

如果将指向空容器的迭代器传递给输出算法,程序很可能会崩溃。下面的示例展示了这个问题,其中squared是空的:

const auto square_func = [](int x) { return x * x; };
const auto v = std::vector{1, 2, 3, 4};
auto squared = std::vector<int>{};
std::ranges::transform(v, squared.begin(), square_func); 

相反,你必须执行以下操作之一:

  • 为结果容器预先分配所需的大小,或者

  • 使用插入迭代器,它在迭代时向容器中插入元素

以下代码片段展示了如何使用预分配的空间:

const auto square_func = [](int x) { return x * x; };
const auto v = std::vector{1, 2, 3, 4};
auto squared = std::vector<int>{};
squared.resize(v.size());
std::ranges::transform(v, squared.begin(), square_func); 
std::back_inserter() and std::inserter() to insert values into a container that is not preallocated:
const auto square_func = [](int x) { return x * x; };
const auto v = std::vector{1, 2, 3, 4};
// Insert into back of vector using std::back_inserter
auto squared_vec = std::vector<int>{};
auto dst_vec = std::back_inserter(squared_vec);
std::ranges::transform(v, dst_vec, square_func);
// Insert into a std::set using std::inserter
auto squared_set = std::set<int>{};
auto dst_set = std::inserter(squared_set, squared_set.end());
std::ranges::transform(v, dst_set, square_func); 

如果你正在操作std::vector并且知道结果容器的预期大小,可以在执行算法之前使用reserve()成员函数来预留空间,以避免不必要的分配。否则,在算法执行期间,向量可能会多次重新分配新的内存块。

算法默认使用operator==()operator<()

作为比较,算法依赖于基本的==<运算符,就像整数的情况一样。为了能够在算法中使用自定义类,类必须提供operator==()operator<(),或者作为算法的参数提供。

通过使用三路比较运算符operator<=>(),我们可以让编译器生成必要的运算符。下面的示例展示了一个简单的Flower类,其中std::find()使用了operator==(),而std::max_element()使用了operator<()

struct Flower {
    auto operator<=>(const Flower& f) const = default; 
    bool operator==(const Flower&) const = default;
    int height_{};
};
auto garden = std::vector<Flower>{{67}, {28}, {14}};
// std::max_element() uses operator<()
auto tallest = std::max_element(garden.begin(), garden.end());
// std::find() uses operator==()
auto perfect = *std::find(garden.begin(), garden.end(), Flower{28}); 

除了使用当前类型的默认比较函数之外,还可以使用自定义比较函数,接下来我们将探讨这一点。

自定义比较函数

有时我们需要比较对象而不使用默认的比较运算符,例如在排序或按长度查找字符串时。在这些情况下,可以提供自定义函数作为额外参数。原始算法使用值(例如std::find()),具有特定运算符的版本在名称末尾附加了_ifstd::find_if()std::count_if()等):

auto names = std::vector<std::string> {
  "Ralph", "Lisa", "Homer", "Maggie", "Apu", "Bart"
};
std::sort(names.begin(), names.end(), 
          [](const std::string& a,const std::string& b) {
            return a.size() < b.size(); });
// names is now "Apu", "Lisa", "Bart", "Ralph", "Homer", "Maggie"
// Find names with length 3
auto x = std::find_if(names.begin(), names.end(), 
  [](const auto& v) { return v.size() == 3; });
// x points to "Apu" 

受限算法使用投影

std::ranges下的受限算法为我们提供了一个称为投影的方便功能,它减少了编写自定义比较函数的需求。前一节中的前面示例可以使用标准谓词std::less结合自定义投影进行重写:

auto names = std::vector<std::string>{
  "Ralph", "Lisa", "Homer", "Maggie", "Apu", "Bart"
};
std::ranges::sort(names, std::less<>{}, &std::string::size);
// names is now "Apu", "Lisa", "Bart", "Ralph", "Homer", "Maggie"

// Find names with length 3
auto x = std::ranges::find(names, 3, &std::string::size);
// x points to "Apu" 

还可以将 lambda 作为投影参数传递,这在想要在投影中组合多个属性时非常方便:

struct Player {
  std::string name_{};
  int level_{};
  float health_{};
  // ...
};
auto players = std::vector<Player>{
  {"Aki", 1, 9.f}, 
  {"Nao", 2, 7.f}, 
  {"Rei", 2, 3.f}};
auto level_and_health = [](const Player& p) {
  return std::tie(p.level_, p.health_);
}; 
// Order players by level, then health
std::ranges::sort(players, std::greater<>{}, level_and_health); 

向标准算法传递投影对象的可能性是一个非常受欢迎的功能,真正简化了自定义比较的使用。

算法要求移动操作不抛出异常

所有算法在移动元素时都使用std::swap()std::move(),但只有在移动构造函数和移动赋值标记为noexcept时才会使用。因此,在使用算法时,对于重型对象来说,实现这些是很重要的。如果它们不可用且无异常,则元素将被复制而不是移动。

请注意,如果您在类中实现了移动构造函数和移动赋值运算符,std::swap()将利用它们,因此不需要指定std::swap()重载。

算法具有复杂性保证

标准库中每个算法的复杂度都使用大 O 表示法进行了规定。算法是以性能为目标创建的。因此,它们既不分配内存,也不具有高于O(n log n)的时间复杂度。即使它们是相当常见的操作,也不包括不符合这些标准的算法。

请注意stable_sort()inplace_merge()stable_partition()的异常。许多实现在这些操作期间倾向于临时分配内存。

例如,让我们考虑一个测试非排序范围是否包含重复项的算法。一种选择是通过迭代范围并搜索范围的其余部分来实现它。这将导致一个O(n²)复杂度的算法:

template <typename Iterator>
auto contains_duplicates(Iterator first, Iterator last) {
  for (auto it = first; it != last; ++it)
    if (std::find(std::next(it), last, *it) != last)
      return true;
  return false;
} 

另一种选择是复制整个范围,对其进行排序,并查找相邻的相等元素。这将导致O(n log n)的时间复杂度,即std::sort()的复杂度。然而,由于它需要复制整个范围,因此仍然不符合构建块算法的条件。分配意味着我们不能相信它不会抛出异常:

template <typename Iterator>
auto contains_duplicates(Iterator first, Iterator last) {
  // As (*first) returns a reference, we have to get 
  // the base type using std::decay_t
  using ValueType = std::decay_t<decltype(*first)>;
  auto c = std::vector<ValueType>(first, last);
  std::sort(c.begin(), c.end());
  return std::adjacent_find(c.begin(),c.end()) != c.end();
} 

复杂性保证从 C++标准库的一开始就是其巨大成功的主要原因之一。C++标准库中的算法是以性能为目标设计和实现的。

算法的性能与 C 库函数等价物一样好

标准 C 库配备了许多低级算法,包括memcpy()memmove()memcmp()memset()。根据我的经验,有时人们使用这些函数而不是标准算法库中的等价物。原因是人们倾向于相信 C 库函数更快,因此接受类型安全的折衷。

这对于现代标准库实现来说是不正确的;等价算法std::copy()std::equal()std::fill()在可能的情况下会使用这些低级 C 函数;因此,它们既提供性能又提供类型安全。

当然,也许会有例外情况,C++编译器无法检测到可以安全地使用低级 C 函数的情况。例如,如果一个类型不是平凡可复制的,std::copy()就不能使用memcpy()。但这是有充分理由的;希望一个不是平凡可复制的类的作者有充分的理由以这种方式设计类,我们(或编译器)不应该忽视这一点,而不调用适当的构造函数。

有时,C++算法库中的函数甚至比它们的 C 库等效函数表现得更好。最突出的例子是std::sort()与 C 库中的qsort()std::sort()qsort()之间的一个重大区别是,qsort()是一个函数,而std::sort()是一个函数模板。当qsort()调用比较函数时,由于它是作为函数指针提供的,通常比使用std::sort()时调用的普通比较函数慢得多,后者可能会被编译器内联。

在本章的其余部分,我们将介绍在使用标准算法和实现自定义算法时的一些最佳实践。

编写和使用通用算法

算法库包含通用算法。为了尽可能具体,我将展示一个通用算法的实现示例。这将为您提供一些关于如何使用标准算法的见解,同时演示实现通用算法并不那么困难。我故意避免在这里解释示例代码的所有细节,因为我们将在本书的后面花费大量时间进行通用编程。

在接下来的示例中,我们将把一个简单的非通用算法转换为一个完整的通用算法。

非通用算法

通用算法是一种可以与各种元素范围一起使用的算法,而不仅仅是一种特定类型,比如std::vector。以下算法是一个非通用算法的例子,它只能与std::vector<int>一起使用:

auto contains(const std::vector<int>& arr, int v) {
  for (int i = 0; i < arr.size(); ++i) {	
    if (arr[i] == v) { return true; }
  }
  return false;
} 

为了找到我们要找的元素,我们依赖于std::vector的接口,它为我们提供了size()函数和下标运算符(operator[]())。然而,并非所有容器都提供这些函数,我也不建议您以这种方式编写原始循环。相反,我们需要创建一个在迭代器上操作的函数模板。

通用算法

通过用两个迭代器替换std::vector,用一个模板参数替换int,我们可以将我们的算法转换为通用版本。以下版本的contains()可以与任何容器一起使用:

template <typename Iterator, typename T>
auto contains(Iterator begin, Iterator end, const T& v) {
  for (auto it = begin; it != end; ++it) {
    if (*it == v) { return true; }
  }
  return false;
} 

例如,要将其与std::vector一起使用,您需要传递begin()end()迭代器:

auto v = std::vector{3, 4, 2, 4};
if (contains(v.begin(), v.end(), 3)) {
 // Found the value...
} 

我们可以通过提供一个接受范围而不是两个单独迭代器参数的版本来改进这个算法:

auto contains(const auto& r, const auto& x) {
  auto it = std::begin(r);
  auto sentinel = std::end(r);
  return contains(it, sentinel, x);
} 

这个算法不强制客户端提供begin()end()迭代器,因为我们已经将其移到函数内部。我们使用了 C++20 的缩写函数模板语法,以避免明确说明这是一个函数模板。最后一步,我们可以为我们的参数类型添加约束:

auto contains(const std::ranges::range auto& r, const auto& x) {
  auto it = std::begin(r);
  auto sentinel = std::end(r);
  return contains(it, sentinel, x);
} 

正如你所看到的,创建一个强大的通用算法实际上并不需要太多的代码。我们传递给算法的数据结构唯一的要求是它可以公开begin()end()迭代器。您将在第八章“编译时编程”中了解更多关于约束和概念的知识。

可以被通用算法使用的数据结构

这让我们意识到,只要我们的新自定义数据结构公开begin()end()迭代器或一个范围,它们就可以被标准通用算法使用。举个简单的例子,我们可以实现一个二维Grid结构,其中行被公开为一对迭代器,就像这样:

struct Grid {
  Grid(std::size_t w, std::size_t h) : w_{w}, h_{h} {    data_.resize(w * h); 
  }
  auto get_row(std::size_t y); // Returns iterators or a range

  std::vector<int> data_{};
  std::size_t w_{};
  std::size_t h_{};
}; 

下图说明了带有迭代器对的Grid结构的布局:

图 5.3:建立在一维向量上的二维网格

get_row()的可能实现将返回一个包含代表行的开始和结束的迭代器的std::pair

auto Grid::get_row(std::size_t y) {
  auto left = data_.begin() + w_ * y;
  auto right = left + w_;
  return std::make_pair(left, right);
} 

表示行的迭代器对然后可以被标准库算法使用。在下面的示例中,我们使用std::generate()std::count()

auto grid = Grid{10, 10};
auto y = 3;
auto row = grid.get_row(y);
std::generate(row.first, row.second, std::rand);
auto num_fives = std::count(row.first, row.second, 5); 

虽然这样可以工作,但使用std::pair有点笨拙,而且还要求客户端知道如何处理迭代器对。没有明确说明firstsecond成员实际上表示半开范围。如果它能暴露一个强类型的范围会不会很好呢?幸运的是,我们将在下一章中探讨的 Ranges 库为我们提供了一个名为std::ranges::subrange的视图类型。现在,get_row()函数可以这样实现:

auto Grid::get_row(std::size_t y) {
  auto first = data_.begin() + w_ * y;
  auto sentinel = first + w_;
  return std::ranges::subrange{first, sentinel};
} 

我们甚至可以更懒,使用为这种情况量身定制的方便视图,称为std::views::counted()

auto Grid::get_row(std::size_t y) {
  auto first = data_.begin() + w_ * y;
  return std::views::counted(first, w_);
} 

Grid类返回的行现在可以与接受范围而不是迭代器对的受限算法中的任何一个一起使用:

auto row = grid.get_row(y);
std::ranges::generate(row, std::rand);
auto num_fives = std::ranges::count(row, 5); 

这完成了我们编写和使用支持迭代器对和范围的通用算法的示例。希望这给您一些关于如何以通用方式编写数据结构和算法以避免组合爆炸的见解,如果我们不得不为所有类型的数据结构编写专门的算法,那么组合爆炸就会发生。

最佳实践

让我们考虑一些在使用我们讨论的算法时会对您有所帮助的实践。我将首先强调实际利用标准算法的重要性。

使用受限算法

在 C++20 中引入的std::ranges下的受限算法比std下的基于迭代器的算法提供了一些优势。受限算法执行以下操作:

  • 支持投影,简化元素的自定义比较。

  • 支持范围而不是迭代器对。无需将begin()end()迭代器作为单独的参数传递。

  • 易于正确使用,并且由于受 C++概念的限制,在编译期间提供描述性错误消息。

我建议开始使用受限算法而不是基于迭代器的算法。

您可能已经注意到,本书在许多地方使用了基于迭代器的算法。这样做的原因是,在撰写本书时,并非所有标准库实现都支持受限算法。

仅对需要检索的数据进行排序

算法库包含三种基本排序算法:sort()partial_sort()nth_element()。此外,它还包含其中的一些变体,包括stable_sort(),但我们将专注于这三种,因为根据我的经验,在许多情况下,可以通过使用nth_element()partial_sort()来避免完全排序。

虽然sort()对整个范围进行排序,但partial_sort()nth_element()可以被视为检查该排序范围的部分的算法。在许多情况下,您只对排序范围的某一部分感兴趣,例如:

  • 如果要计算范围的中位数,则需要排序范围中间的值。

  • 如果您想创建一个可以被人口平均身高的 80%使用的身体扫描仪,您需要在排序范围内找到两个值:距离最高者 10%的值和距离最矮者 10%的值。

下图说明了std::nth_elementstd::partial_sort如何处理范围,与完全排序的范围相比:

|

auto v = std::vector{6, 3, 2, 7,
                     4, 1, 5};
auto it = v.begin() + v.size()/2; 

|

std::ranges::sort(v); 

|

std::nth_element(v.begin(), it,
                 v.end()); 

|

std::partial_sort(v.begin(), it,
                  v.end()); 

图 5.1:使用不同算法对范围的排序和非排序元素

下表显示了它们的算法复杂度;请注意,m表示正在完全排序的子范围:

算法 复杂度
std::sort() O(n log n)
std::partial_sort() O(n log m)
std::nth_element() O(n)

表 5.2:算法复杂度

用例

现在您已经了解了std:nth_element()std::partial_sort(),让我们看看如何将它们结合起来检查范围的部分,就好像整个范围都已排序:

|

auto v = std::vector{6, 3, 2, 7,
                     4, 1, 5};
auto it = v.begin() + v.size()/2; 

|

auto left = it - 1;
auto right = it + 2;
std::nth_element(v.begin(),
                 left, v.end());
std::partial_sort(left, right,
                  v.end()); 

|

std::nth_element(v.begin(), it,
                 v.end());
std::sort(it, v.end()); 

|

auto left = it - 1;
auto right = it + 2;
std::nth_element(v.begin(),
                 right, v.end());
std::partial_sort(v.begin(),
                  left, right);
std::sort(right, v.end()); 

图 5.3:组合算法和相应的部分排序结果

正如您所看到的,通过使用std::sort()std::nth_element()std::partial_sort()的组合,有许多方法可以在绝对不需要对整个范围进行排序时避免这样做。这是提高性能的有效方法。

性能评估

让我们看看std::nth_element()std::partial_sort()std::sort()相比如何。我们使用了一个包含 1000 万个随机int元素的std::vector进行了测量:

操作 代码,其中r是操作的范围 时间(加速)
排序
std::sort(r.begin(), r.end()); 
760 毫秒(1.0x)
寻找中位数
auto it = r.begin() + r.size() / 2;
std::nth_element(r.begin(), it, r.end()); 
83 毫秒(9.2x)
对范围的前十分之一进行排序
auto it = r.begin() + r.size() / 10;
std::partial_sort(r.begin(), it, r.end()); 
378 毫秒(2.0x)

表 5.3:部分排序算法的基准结果

使用标准算法而不是原始的 for 循环

很容易忘记复杂的算法可以通过组合标准库中的算法来实现。也许是因为习惯于手工解决问题并立即开始手工制作for循环并使用命令式方法解决问题。如果这听起来对您来说很熟悉,我的建议是要充分了解标准算法,以至于您开始将它们作为首选。

我推荐使用标准库算法而不是原始的for循环,原因有很多:

  • 标准算法提供了性能。即使标准库中的一些算法看起来很琐碎,它们通常以不明显的方式进行了最优设计。

  • 标准算法提供了安全性。即使是更简单的算法也可能有一些特殊情况,很容易忽视。

  • 标准算法是未来的保障;如果您想利用 SIMD 扩展、并行性甚至是以后的 GPU,可以用更合适的算法替换给定的算法(参见第十四章并行算法)。

  • 标准算法有详细的文档。

此外,通过使用算法而不是for循环,每个操作的意图都可以通过算法的名称清楚地表示出来。如果您使用标准算法作为构建块,您的代码的读者不需要检查原始的for循环内部的细节来确定您的代码的作用。

一旦您养成了以算法思考的习惯,您会意识到许多for循环通常是一些简单算法的变体,例如std::transform()std::any_of()std::copy_if()std::find()

使用算法还将使代码更清晰。您通常可以实现函数而不需要嵌套代码块,并且同时避免可变变量。这将在下面的示例中进行演示。

示例 1:可读性问题和可变变量

我们的第一个示例来自一个真实的代码库,尽管变量名已经被伪装。由于这只是一个剪切,您不必理解代码的逻辑。这个例子只是为了向您展示与嵌套的for循环相比,使用算法时复杂度降低的情况。

原始版本如下:

// Original version using a for-loop
auto conflicting = false;
for (const auto& info : infos) {
  if (info.params() == output.params()) {
    if (varies(info.flags())) {
      conflicting = true;
      break;
    }
  }
  else {
    conflicting = true;
    break;
  }
} 

for-循环版本中,很难理解conflicting何时或为什么被设置为true,而在算法的后续版本中,你可以直观地看到,如果info满足谓词,它就会发生。此外,标准算法版本不使用可变变量,并且可以使用短 lambda 和any_of()的组合来编写。它看起来是这样的:

// Version using standard algorithms
const auto in_conflict = & {
  return info.params() != output.params() || varies(info.flags());
};
const auto conflicting = std::ranges::any_of(infos, in_conflict); 

虽然这可能有些言过其实,但想象一下,如果我们要追踪一个 bug 或者并行化它,使用 lambda 和any_of()的标准算法版本将更容易理解和推理。

示例 2:不幸的异常和性能问题

为了进一步说明使用算法而不是for-循环的重要性,我想展示一些不那么明显的问题,当使用手工制作的for-循环而不是标准算法时,你可能会遇到的问题。

假设我们需要一个函数,将容器前面的第 n 个元素移动到后面,就像这样:

图 5.4:将前三个元素移动到范围的后面

方法 1:使用传统的 for 循环

一个天真的方法是在迭代它们时将前 n 个元素复制到后面,然后删除前 n 个元素:

图 5.5:分配和释放以将元素移动到范围的后面

以下是相应的实现:

template <typename Container>
auto move_n_elements_to_back(Container& c, std::size_t n) {
  // Copy the first n elements to the end of the container
  for (auto it = c.begin(); it != std::next(c.begin(), n); ++it) {
    c.emplace_back(std::move(*it));
  }
  // Erase the copied elements from front of container
  c.erase(c.begin(), std::next(c.begin(), n));
} 

乍一看,这可能看起来是合理的,但仔细检查会发现一个严重的问题——如果容器在迭代过程中重新分配了内存,由于emplace_back(),迭代器it将不再有效。由于算法试图访问无效的迭代器,算法将进入未定义的行为,并且在最好的情况下会崩溃。

方法 2:安全的 for 循环(以性能为代价的安全)

由于未定义的行为是一个明显的问题,我们将不得不重写算法。我们仍然使用手工制作的for-循环,但我们将利用索引而不是迭代器:

template <typename Container>
auto move_n_elements_to_back(Container& c, std::size_t n) {
  for (size_t i = 0; i < n; ++i) {
    auto value = *std::next(c.begin(), i);
    c.emplace_back(std::move(value));
  }
  c.erase(c.begin(), std::next(c.begin(), n));
} 

解决方案有效;不再崩溃。但现在,它有一个微妙的性能问题。该算法在std::list上比在std::vector上慢得多。原因是std::next(it, n)std::list::iterator一起使用是O(n),而在std::vector::iterator上是O(1)。由于std::next(it, n)for-循环的每一步中都被调用,这个算法在诸如std::list的容器上将具有O(n²)的时间复杂度。除了这个性能限制,前面的代码还有以下限制:

  • 由于emplace_back(),它不适用于静态大小的容器,比如std::array

  • 它可能会抛出异常,因为emplace_back()可能会分配内存并失败(尽管这可能很少见)

方法 3:查找并使用合适的标准库算法

当我们达到这个阶段时,我们应该浏览标准库,看看它是否包含一个适合用作构建块的算法。方便的是,<algorithm>头文件提供了一个名为std::rotate()的算法,它正好可以解决我们正在寻找的问题,同时避免了前面提到的所有缺点。这是我们使用std::rotate()算法的最终版本:

template <typename Container>
auto move_n_elements_to_back(Container& c, std::size_t n) {
  auto new_begin = std::next(c.begin(), n);
  std::rotate(c.begin(), new_begin, c.end());
} 

让我们来看看使用std::rotate()的优势:

  • 该算法不会抛出异常,因为它不会分配内存(尽管包含的对象可能会抛出异常)

  • 它适用于大小无法更改的容器,比如std::array

  • 性能是O(n),无论它在哪个容器上操作

  • 实现很可能针对特定硬件进行优化

也许你会觉得这种for-循环和标准算法之间的比较是不公平的,因为这个问题还有其他解决方案,既优雅又高效。然而,在现实世界中,当标准库中有算法等待解决你的问题时,看到像你刚刚看到的这样的实现并不罕见。

例 3:利用标准库的优化

这个最后的例子突显了一个事实,即即使看起来非常简单的算法可能包含你不会考虑的优化。例如,让我们来看一下std::find()。乍一看,似乎明显的实现无法进一步优化。这是std::find()算法的可能实现:

template <typename It, typename Value>
auto find_slow(It first, It last, const Value& value) {
  for (auto it = first; it != last; ++it)
    if (*it == value)
      return it;
  return last;
} 

然而,通过查看 GNU libstdc++的实现,当与random_access_iterator一起使用时(换句话说,std::vectorstd::stringstd::dequestd::array),libc++实现者已经将主循环展开成一次四个循环的块,导致比较(it != last)执行的次数减少四分之一。

这是从 libstdc++库中取出的std::find()的优化版本:

template <typename It, typename Value>
auto find_fast(It first, It last, const Value& value) {
  // Main loop unrolled into chunks of four
  auto num_trips = (last - first) / 4;
  for (auto trip_count = num_trips; trip_count > 0; --trip_count) {
    if (*first == value) {return first;} ++first;
    if (*first == value) {return first;} ++first;
    if (*first == value) {return first;} ++first;
    if (*first == value) {return first;} ++first;
  }
  // Handle the remaining elements
  switch (last - first) {
    case 3: if (*first == value) {return first;} ++first;
    case 2: if (*first == value) {return first;} ++first;
    case 1: if (*first == value) {return first;} ++first;
    case 0:
    default: return last;
  }
} 

请注意,实际上使用的是std::find_if(),而不是std::find(),它利用了这种循环展开优化。但std::find()是使用std::find_if()实现的。

除了std::find(),libstdc++中还使用std::find_if()实现了大量算法,例如any_of()all_of()none_of()find_if_not()search()is_partitioned()remove_if()is_permutation(),这意味着所有这些都比手工制作的for-循环稍微快一点。

稍微地,我真的是指稍微;加速大约是 1.07 倍,如下表所示:

在包含 1000 万个元素的std::vector中查找整数
算法
find_slow()
find_fast()

表 5.5:find_fast()使用在 libstdc++中找到的优化。基准测试表明 find_fast()比 find_slow()稍微快一点。

然而,即使好处几乎可以忽略不计,使用标准算法,你可以免费获得它。

"与零比较"优化

除了循环展开之外,一个非常微妙的优化是trip_count是向后迭代以与零比较而不是一个值。在一些 CPU 上,与零比较比任何其他值稍微快一点,因为它使用另一个汇编指令(在 x86 平台上,它使用test而不是cmp)。

下表显示了使用 gcc 9.2 的汇编输出的差异:

动作 C++ 汇编 x86
与零比较
auto cmp_zero(size_t val) {
  return val > 0;
} 

|

test edi, edi
setne al
ret 

|

与另一个值比较
auto cmp_val(size_t val) {
  return val > 42;
} 

|

cmp edi, 42
setba al
ret 

|

表 5.6:汇编输出的差异

尽管标准库实现鼓励这种优化,但不要重新排列你手工制作的循环以从这种优化中受益,除非它是一个(非常)热点。这样做会严重降低你代码的可读性;让算法来处理这些优化。

这是关于使用算法而不是for-循环的建议的结束。如果你还没有使用标准算法,我希望我已经给了你一些理由来说服你尝试一下。现在我们将继续我的最后一个关于有效使用算法的建议。

避免容器拷贝

我们将通过突出一个常见问题来结束这一章,即尝试从算法库中组合多个算法时很难避免底层容器的不必要拷贝。

一个例子将澄清我的意思。假设我们有某种Student类来代表特定年份和特定考试分数的学生,就像这样:

struct Student {
  int year_{};
  int score_{};
  std::string name_{};
  // ...
}; 

如果我们想在一个庞大的学生集合中找到二年级成绩最高的学生,我们可能会在score_上使用max_element(),但由于我们只想考虑二年级的学生,这就变得棘手了。基本上,我们想要将copy_if()max_element()结合起来组成一个新的算法,但是在算法库中组合算法是不可能的。相反,我们需要将所有二年级学生复制到一个新的容器中,然后迭代新容器以找到最高分数:

auto get_max_score(const std::vector<Student>& students, int year) {
  auto by_year = = { return s.year_ == year; }; 
  // The student list needs to be copied in
  // order to filter on the year
  auto v = std::vector<Student>{};
  std::ranges::copy_if(students, std::back_inserter(v), by_year);
  auto it = std::ranges::max_element(v, std::less{}, &Student::score_);
  return it != v.end() ? it->score_ : 0; 
} 

这是一个诱人的地方,可以开始从头开始编写自定义算法,而不利用标准算法的优势。但正如您将在下一章中看到的,没有必要放弃标准库来执行这样的任务。组合算法的能力是使用 Ranges 库的主要动机之一,我们将在下一章中介绍。

总结

在本章中,您学习了如何使用算法库中的基本概念,以及使用它们作为构建模块而不是手写的for循环的优势,以及为什么在以后优化代码时使用标准算法库是有益的。我们还讨论了标准算法的保证和权衡,这意味着您从现在开始可以放心地使用它们。

通过使用算法的优势而不是手动的for循环,您的代码库已经为本书接下来的章节中将讨论的并行化技术做好了准备。标准算法缺少的一个关键特性是组合算法的可能性,这一点在我们试图避免不必要的容器复制时得到了强调。在下一章中,您将学习如何使用 C++ Ranges 库中的视图来克服标准算法的这一限制。

第六章:范围和视图

本章将继续上一章关于算法及其局限性的内容。Ranges 库中的视图是 Algorithm 库的强大补充,它允许我们将多个转换组合成一个惰性评估的视图,覆盖元素序列。阅读完本章后,您将了解什么是范围视图,以及如何将它们与标准库中的容器、迭代器和算法结合使用。

具体来说,我们将涵盖以下主要主题:

  • 算法的可组合性

  • 范围适配器

  • 将视图实例化为容器

  • 在范围内生成、转换和抽样元素

在我们深入讨论 Ranges 库本身之前,让我们讨论一下为什么它被添加到 C++20 中,以及为什么我们想要使用它。

Ranges 库的动机

随着 Ranges 库引入到 C++20 中,我们在实现算法时从标准库中获益的方式得到了一些重大改进。以下列表显示了新功能:

  • 定义迭代器和范围要求的概念现在可以由编译器更好地检查,并在开发过程中提供更多帮助

  • <algorithm>头文件中所有函数的新重载都受到了刚才提到的概念的约束,并接受范围作为参数,而不是迭代器对

  • 迭代器头文件中的约束迭代器

  • 范围视图,使得可以组合算法

本章将重点放在最后一项上:视图的概念,它允许我们组合算法以避免将数据不必要地复制到拥有的容器中。为了充分理解这一点,让我们从算法库中的可组合性不足开始。

算法库的局限性

标准库算法在一个基本方面存在局限性:可组合性。让我们通过查看第五章算法中的最后一个示例来了解这一点,我们在那里简要讨论了这个问题。如果您还记得,我们有一个类来表示特定年份和特定考试分数的Student

struct Student {
  int year_{};
  int score_{};
  std::string name_{};
  // ...
}; 

如果我们想要从一个大量学生的集合中找到他们第二年的最高分,我们可能会在score_上使用max_element(),但由于我们只想考虑特定年份的学生,这就变得棘手了。通过使用接受范围和投影的新算法(参见第五章算法),我们可能会得到类似这样的结果:

auto get_max_score(const std::vector<Student>& students, int year) {
  auto by_year = = { return s.year_ == year; }; 
  // The student list needs to be copied in
  // order to filter on the year
  auto v = std::vector<Student>{};
  std::ranges::copy_if(students, std::back_inserter(v), by_year);
  auto it = std::ranges::max_element(v, std::less{}, &Student::score_);
  return it != v.end() ? it->score_ : 0; 
} 

以下是一个示例,说明了它的使用方法:

auto students = std::vector<Student>{
  {3, 120, "Niki"},
  {2, 140, "Karo"},
  {3, 190, "Sirius"},
  {2, 110, "Rani"},
   // ...
};
auto score = get_max_score(students, 2);
std::cout << score << '\n'; 
// Prints 140 

这个get_max_score()的实现很容易理解,但在使用copy_if()std::back_inserter()时会创建不必要的Student对象的副本。

您现在可能会认为get_max_score()可以写成一个简单的for-循环,这样就可以避免由于copy_if()而产生额外的分配。

auto get_max_score(const std::vector<Student>& students, int year) {
  auto max_score = 0;
  for (const auto& student : students) {
    if (student.year_ == year) {
      max_score = std::max(max_score, student.score_);
    }
  }
  return max_score;
} 

虽然在这个小例子中很容易实现,但我们希望能够通过组合小的算法构建块来实现这个算法,而不是使用单个for-循环从头开始实现它。

我们希望有一种语法,它与使用算法一样易读,但又能够避免在算法的每一步中构造新的容器。这就是 Ranges 库中的视图发挥作用的地方。虽然 Ranges 库包含的不仅仅是视图,但与 Algorithm 库的主要区别在于能够将本质上不同类型的迭代器组合成惰性评估的范围。

如果使用 Ranges 库中的视图编写前面的示例,它将如下所示:

auto max_value(auto&& range) {
  const auto it = std::ranges::max_element(range);
  return it != range.end() ? *it : 0;
}
auto get_max_score(const std::vector<Student>& students, int year) {
  const auto by_year = = { return s.year_ == year; };
  return max_value(students 
    | std::views::filter(by_year)
    | std::views::transform(&Student::score_));
} 

现在我们又开始使用算法,因此可以避免可变变量、for循环和if语句。在我们的初始示例中,保存特定年份学生的额外向量现在已经被消除。相反,我们已经组成了一个范围视图,它代表了所有通过by_year谓词过滤的学生,然后转换为只暴露分数。然后将视图传递给一个小型实用程序函数max_value(),该函数使用max_element()算法来比较所选学生的分数,以找到最大值。

通过将算法链接在一起来组成算法,并同时避免不必要的复制,这就是我们开始使用 Ranges 库中的视图的动机。

从 Ranges 库中理解视图

Ranges 库中的视图是对范围的惰性评估迭代。从技术上讲,它们只是具有内置逻辑的迭代器,但从语法上讲,它们为许多常见操作提供了非常愉快的语法。

以下是如何使用视图来对向量中的每个数字进行平方的示例(通过迭代):

auto numbers = std::vector{1, 2, 3, 4};
auto square = [](auto v) {  return v * v; };
auto squared_view = std::views::transform(numbers, square);
for (auto s : squared_view) {  // The square lambda is invoked here
  std::cout << s << " ";
}
// Output: 1 4 9 16 

变量squared_view不是numbers向量的值平方的副本;它是一个代理对象,有一个细微的区别——每次访问一个元素时,都会调用std::transform()函数。这就是为什么我们说视图是惰性评估的。

从外部来看,你仍然可以像任何常规容器一样迭代squared_view,因此你可以执行常规算法,比如find()count(),但在内部,你没有创建另一个容器。

如果要存储范围,可以使用std::ranges::copy()将视图实现为容器。(这将在本章后面进行演示。)一旦视图被复制回容器,原始容器和转换后的容器之间就不再有任何依赖关系。

使用范围,还可以创建一个过滤视图,其中只有范围的一部分是可见的。在这种情况下,只有满足条件的元素在迭代视图时是可见的:

auto v = std::vector{4, 5, 6, 7, 6, 5, 4};
auto odd_view = 
  std::views::filter(v, [](auto i){ return (i % 2) == 1; });
for (auto odd_number : odd_view) {
  std::cout << odd_number << " ";
}
// Output: 5 7 5 

Ranges 库的多功能性的另一个例子是它提供了创建一个视图的可能性,该视图可以迭代多个容器,就好像它们是一个单一的列表一样:

auto list_of_lists = std::vector<std::vector<int>> {
  {1, 2},
  {3, 4, 5},
  {5},
  {4, 3, 2, 1}
};
auto flattened_view = std::views::join(list_of_lists);
for (auto v : flattened_view) 
  std::cout << v << " ";
// Output: 1 2 3 4 5 5 4 3 2 1

auto max_value = *std::ranges::max_element(flattened_view);
// max_value is 5 

现在我们已经简要地看了一些使用视图的例子,让我们来检查所有视图的共同要求和属性

视图是可组合的

视图的全部功能来自于能够将它们组合在一起。由于它们不复制实际数据,因此可以在数据集上表达多个操作,而在内部只迭代一次。为了理解视图是如何组成的,让我们看一下我们的初始示例,但是不使用管道运算符来组合视图;相反,让我们直接构造实际的视图类。这是它的样子:

auto get_max_score(const std::vector<Student>& s, int year) {
  auto by_year = = { return s.year_ == year; };

  auto v1 = std::ranges::ref_view{s}; // Wrap container in a view
  auto v2 = std::ranges::filter_view{v1, by_year};
  auto v3 = std::ranges::transform_view{v2, &Student::score_};
  auto it = std::ranges::max_element(v3);
  return it != v3.end() ? *it : 0;
} 

我们首先创建了一个std::ranges::ref_view,它是一个围绕容器的薄包装。在我们的情况下,它将向量s转换为一个便宜的视图。我们需要这个,因为我们的下一个视图std::ranges::filter_view需要一个视图作为它的第一个参数。正如你所看到的,我们通过引用链中的前一个视图来组成我们的下一个视图。

这种可组合视图的链当然可以任意延长。算法max_element()不需要知道完整链的任何信息;它只需要迭代范围v3,就像它是一个普通的容器一样。

以下图是max_element()算法、视图和输入容器之间关系的简化视图:

图 6.1:顶层算法 std::ranges::max_element()从视图中提取值,这些视图惰性地处理来自底层容器(std::vector)的元素

现在,这种组合视图的方式有点冗长,如果我们试图去除中间变量v1v2,我们最终会得到这样的东西:

using namespace std::ranges; // _view classes live in std::ranges
auto scores = 
  transform_view{filter_view{ref_view{s}, by_year},
    &Student::score_}; 

现在,这可能看起来不太语法优雅。通过摆脱中间变量,我们得到了一些即使对训练有素的人来说也很难阅读的东西。我们还被迫从内到外阅读代码以理解依赖关系。幸运的是,Ranges 库为我们提供了范围适配器,这是组合视图的首选方式。

范围视图配有范围适配器

正如你之前看到的,Ranges 库还允许我们使用范围适配器和管道运算符来组合视图,从而获得更加优雅的语法(你将在第十章代理对象和延迟评估中学习如何在自己的代码中使用管道运算符)。前面的代码示例可以通过使用范围适配器对象进行重写,我们会得到类似这样的东西:

using namespace std::views; // range adaptors live in std::views
auto scores = s | filter(by_year) | transform(&Student::score_); 

从左到右阅读语句的能力,而不是从内到外,使得代码更容易阅读。如果你使用过 Unix shell,你可能熟悉这种用于链接命令的表示法。

Ranges 库中的每个视图都有一个相应的范围适配器对象,可以与管道运算符一起使用。在使用范围适配器时,我们还可以跳过额外的std::ranges::ref_view,因为范围适配器直接与viewable_ranges一起工作,即可以安全转换为view的范围。

您可以将范围适配器视为一个全局无状态对象,它实现了两个函数:operator()()operator|()。这两个函数都构造并返回视图对象。管道运算符是在前面的示例中使用的。但也可以使用调用运算符使用嵌套语法来形成视图,如下所示:

using namespace std::views;
auto scores = transform(filter(s, by_year), &Student::score_); 

同样,在使用范围适配器时,无需将输入容器包装在ref_view中。

总之,Ranges 库中的每个视图包括:

  • 一个类模板(实际视图类型),它操作视图对象,例如std::ranges::transform_view。这些视图类型可以在命名空间std::ranges下找到。

  • 一个范围适配器对象,它从范围创建视图类的实例,例如std::views::transform。所有范围适配器都实现了operator()()operator|(),这使得可以使用管道运算符或嵌套来组合转换。范围适配器对象位于命名空间std::views下。

视图是具有复杂性保证的非拥有范围

在前一章中,介绍了范围的概念。任何提供begin()end()函数的类型,其中begin()返回一个迭代器,end()返回一个哨兵,都可以作为范围。我们得出结论,所有标准容器都是范围。容器拥有它们的元素,因此我们可以称它们为拥有范围。

视图也是一个范围,它提供begin()end()函数。然而,与容器不同,视图不拥有它们所覆盖的范围中的元素。

视图的构造必须是一个常量时间操作,O(1)。它不能执行任何依赖于底层容器大小的工作。对于视图的赋值、复制、移动和销毁也是如此。这使得在使用视图来组合多个算法时,很容易推断性能。它还使得视图无法拥有元素,因为这将需要在构造和销毁时具有线性时间复杂度。

视图不会改变底层容器

乍一看,视图可能看起来像是输入容器的变异版本。然而,容器根本没有发生变异:所有处理都是在迭代器中进行的。视图只是一个代理对象,当迭代时,看起来像是一个变异的容器。

int to std::string:
auto ints = std::list{2, 3, 4, 2, 1};
auto strings = ints 
  | std::views::transform([](auto i) { return std::to_string(i); }); 

也许我们有一个在容器上操作的函数,我们想要使用范围算法进行转换,然后我们想要返回并将其存储回容器。例如,在上面的例子中,我们可能确实想要将字符串存储在一个单独的容器中。您将在下一节中学习如何做到这一点。

视图可以实体化为容器

有时,我们想要将视图存储在容器中,即实体化视图。所有视图都可以实体化为容器,但这并不像您希望的那样容易。C++20 提出了一个名为std::ranges::to<T>()的函数模板,它可以将视图转换为任意容器类型T,但并没有完全实现。希望我们在将来的 C++版本中能够得到类似的东西。在那之前,我们需要做更多的工作来实体化视图。

在前面的例子中,我们将ints转换为std::strings,如下所示:

auto ints = std::list{2, 3, 4, 2, 1};
auto r = ints 
  | std::views::transform([](auto i) { return std::to_string(i); }); 

现在,如果我们想要将范围r实体化为一个向量,我们可以像这样使用std::ranges::copy()

auto vec = std::vector<std::string>{};
std::ranges::copy(r, std::back_inserter(vec)); 

实体化视图是一个常见的操作,所以如果我们有一个通用的实用程序来处理这种情况会很方便。假设我们想要将一些任意视图实体化为std::vector;我们可以使用一些通用编程来得到以下方便的实用函数:

auto to_vector(auto&& r) {
  std::vector<std::ranges::range_value_t<decltype(r)>> v;
  if constexpr(std::ranges::sized_range<decltype(r)>) {
    v.reserve(std::ranges::size(r));
  }
  std::ranges::copy(r, std::back_inserter(v));
  return v;
} 
https://timur.audio/how-to-make-a-container-from-a-c20-range, which is well worth a read. 

在本书中,我们还没有讨论过泛型编程,但接下来的几章将解释使用auto参数类型和if constexpr

我们正在使用reserve()来优化此函数的性能。它将为范围中的所有元素预先分配足够的空间,以避免进一步的分配。但是,我们只能在知道范围的大小时调用reserve(),因此我们必须使用if constexpr语句在编译时检查范围是否为size_range

有了这个实用程序,我们可以将某种类型的容器转换为持有另一种任意类型元素的向量。让我们看看如何使用to_vector()将整数列表转换为std::strings的向量。这是一个例子:

auto ints = std::list{2, 3, 4, 2, 1};
auto r = ints 
  | std::views::transform([](auto i) { return std::to_string(i); });
auto strings = to_vector(r); 
// strings is now a std::vector<std::string> 

请记住,一旦视图被复制回容器,原始容器和转换后的容器之间就不再有任何依赖关系。这也意味着实体化是一种急切的操作,而所有视图操作都是惰性的。

视图是惰性评估的

视图执行的所有工作都是惰性的。这与<algorithm>头文件中的函数相反,后者在调用时立即对所有元素执行其工作。

您已经看到std::views::filter视图可以替换算法std::copy_if(),而std::views::transform视图可以替换std::transform()算法。当我们将视图用作构建块并将它们链接在一起时,我们通过避免急切算法所需的容器元素的不必要复制而受益于惰性评估。

但是std::sort()呢?有对应的排序视图吗?答案是否定的,因为它需要视图首先急切地收集所有元素以找到要返回的第一个元素。相反,我们必须自己显式调用视图上的排序来做到这一点。在大多数情况下,我们还需要在排序之前实体化视图。我们可以通过一个例子来澄清这一点。假设我们有一个通过某个谓词过滤的数字向量,如下所示:

auto vec = std::vector{4, 2, 7, 1, 2, 6, 1, 5};
auto is_odd = [](auto i) { return i % 2 == 1; };
auto odd_numbers = vec | std::views::filter(is_odd); 

如果我们尝试使用std::ranges::sort()std::sort()对我们的视图odd_numbers进行排序,我们将收到编译错误:

std::ranges::sort(odd_numbers); // Doesn't compile 

编译器抱怨odd_numbers范围提供的迭代器类型。排序算法需要随机访问迭代器,但这不是我们的视图提供的迭代器类型,即使底层输入容器是std::vector。我们需要在排序之前实体化视图:

auto v = to_vector(odd_numbers);
std::ranges::sort(v);
// v is now 1, 1, 5, 7 

但为什么这是必要的呢?答案是这是惰性评估的结果。过滤视图(以及许多其他视图)在需要延迟读取一个元素时无法保留底层范围(在本例中为std::vector)的迭代器类型。

那么,有没有可以排序的视图?是的,一个例子是std::views::take,它返回范围中的前n个元素。以下示例在排序之前编译和运行良好,无需在排序之前实现视图:

auto vec = std::vector{4, 2, 7, 1, 2, 6, 1, 5};
auto first_half = vec | std::views::take(vec.size() / 2);
std::ranges::sort(first_half);
// vec is now 1, 2, 4, 7, 2, 6, 1, 5 

迭代器的质量已经得到保留,因此可以对first_half视图进行排序。最终结果是底层向量vec中前一半的元素已经被排序。

您现在对来自 Ranges 库的视图以及它们的工作原理有了很好的理解。在下一节中,我们将探讨如何使用标准库中包含的视图。

标准库中的视图

到目前为止,在本章中,我们一直在谈论来自 Ranges 库的视图。正如前面所述,这些视图类型需要在常数时间内构造,并且还具有常数时间的复制、移动和赋值运算符。然而,在 C++中,我们在 C++20 添加 Ranges 库之前就已经谈论过视图类。这些视图类是非拥有类型,就像std::ranges::view一样,但没有复杂性保证。

在本节中,我们将首先探索与std::ranges::view概念相关联的 Ranges 库中的视图,然后转到与std::ranges::view不相关联的std::string_viewstd::span

范围视图

Ranges 库中已经有许多视图,我认为我们将在未来的 C++版本中看到更多这样的视图。本节将快速概述一些可用视图,并根据其功能将它们放入不同的类别中。

生成视图

-2, -1, 0, and 1:
for (auto i : std::views::iota(-2, 2)) {
  std::cout << i << ' ';
}
// Prints -2 -1 0 1 

通过省略第二个参数,std::views::iota将在请求时产生无限数量的值。

转换视图

转换视图是转换范围的元素或范围结构的视图。一些示例包括:

  • std::views::transform:转换每个元素的值和/或类型

  • std::views::reverse:返回输入范围的反转版本

  • std::views::split:拆分每个元素并将每个元素拆分为子范围。结果范围是范围的范围

  • std::views::join:split 的相反操作;展平所有子范围

以下示例使用splitjoin从逗号分隔的值字符串中提取所有数字:

auto csv = std::string{"10,11,12"};
auto digits = csv 
  | std::views::split(',')      // [ [1, 0], [1, 1], [1, 2] ]
  | std::views::join;           // [ 1, 0, 1, 1, 1, 2 ]
for (auto i : digits) {   std::cout << i; }
// Prints 101112 

采样视图

采样视图是选择范围中的元素子集的视图,例如:

  • std::views::filter:仅返回满足提供的谓词的元素

  • std::views::take:返回范围中的n个第一个元素

  • std::views::drop:在丢弃前n个元素后返回范围中的所有剩余元素

在本章中,您已经看到了许多使用std::views::filter的示例;这是一个非常有用的视图。std::views::takestd::views::drop都有一个_while版本,它接受一个谓词而不是一个数字。以下是使用takedrop_while的示例:

auto vec = std::vector{1, 2, 3, 4, 5, 4, 3, 2, 1};
 auto v = vec
   | std::views::drop_while([](auto i) { return i < 5; })
   | std::views::take(3);
 for (auto i : v) { std::cout << i << " "; }
 // Prints 5 4 3 

此示例使用drop_while从前面丢弃小于 5 的值。剩下的元素传递给take,它返回前三个元素。现在到我们最后一类范围视图。

实用视图

在本章中,您已经看到了一些实用视图的用法。当您有想要转换或视为视图的东西时,它们非常方便。在这些视图类别中的一些示例是ref_viewall_viewsubrangecountedistream_view

以下示例向您展示了如何读取一个包含浮点数的文本文件,然后打印它们。

假设我们有一个名为numbers.txt的文本文件,其中包含重要的浮点数,如下所示:

1.4142 1.618 2.71828 3.14159 6.283 ... 

然后,我们可以通过使用std::ranges::istream_view来创建一个floats的视图:

auto ifs = std::ifstream("numbers.txt");
for (auto f : std::ranges::istream_view<float>(ifs)) {
  std::cout << f << '\n';
}
ifs.close(); 

通过创建一个std::ranges::istream_view并将其传递给一个istream对象,我们可以简洁地处理来自文件或任何其他输入流的数据。

Ranges 库中的视图已经经过精心选择和设计。在未来的标准版本中很可能会有更多的视图。了解不同类别的视图有助于我们将它们区分开,并在需要时更容易找到它们。

重新审视 std::string_view 和 std::span

值得注意的是,标准库在 Ranges 库之外还提供了其他视图。在第四章数据结构中引入的std::string_viewstd::span都是非拥有范围,非常适合与 Ranges 视图结合使用。

与 Ranges 库中的视图不同,不能保证这些视图可以在常数时间内构造。例如,从以 null 结尾的 C 风格字符串构造std::string_view可能会调用strlen(),这是一个O(n)操作。

假设出于某种原因,我们有一个重置范围中前n个值的函数:

auto reset(std::span<int> values, int n) {
  for (auto& i : std::ranges::take_view{values, n}) {
    i = int{};
  }
} 

在这种情况下,不需要使用范围适配器来处理values,因为values已经是一个视图。通过使用std::span,我们可以传递内置数组或容器,如std::vector

int a[]{33, 44, 55, 66, 77};
reset(a, 3); 
// a is now [0, 0, 0, 66, 77]
auto v = std::vector{33, 44, 55, 66, 77};
reset(v, 2); 
// v is now [0, 0, 55, 66, 77] 

类似地,我们可以将std::string_view与 Ranges 库一起使用。以下函数将std::string_view的内容拆分为std::vectorstd::string元素:

auto split(std::string_view s, char delim) {
  const auto to_string = [](auto&& r) -> std::string {
    const auto cv = std::ranges::common_view{r};
    return {cv.begin(), cv.end()};
  };
  return to_vector(std::ranges::split_view{s, delim} 
    | std::views::transform(to_string));
} 

lambda to_string将一系列char转换为std::stringstd::string构造函数需要相同的迭代器和 sentinel 类型,因此范围被包装在std::ranges::common_view中。实用程序to_vector()将视图实现并返回std::vector<std::string>to_vector()在本章前面已经定义过。

我们的split()函数现在可以用于const char*字符串和std::string对象,如下所示:

 const char* c_str = "ABC,DEF,GHI";  // C style string
  const auto v1 = split(c_str, ',');  // std::vector<std::string>
  const auto s = std::string{"ABC,DEF,GHI"};
  const auto v2 = split(s, ',');      // std::vector<std::string>
  assert(v1 == v2);                   // true 

我们现在将通过谈论我们期望在未来版本的 C++中看到的 Ranges 库来结束这一章。

Ranges 库的未来

在 C++20 中被接受的 Ranges 库是基于 Eric Niebler 编写的库,可以在github.com/ericniebler/range-v3上找到。目前,这个库中只有一小部分组件已经成为标准的一部分,但更多的东西可能很快就会被添加进来。

除了许多有用的视图尚未被接受,例如group_byzipsliceunique之外,还有actions的概念,可以像视图一样进行管道传递。但是,与视图一样,操作执行范围的急切变异,而不是像视图那样进行惰性求值。排序是典型操作的一个例子。

如果您等不及这些功能被添加到标准库中,我建议您看一下 range-v3 库。

总结

这一章介绍了使用范围视图构建算法背后的许多动机。通过使用视图,我们可以高效地组合算法,并使用管道操作符简洁的语法。您还学会了一个类成为视图意味着什么,以及如何使用将范围转换为视图的范围适配器。

视图不拥有其元素。构造范围视图需要是一个常数时间操作,所有视图都是惰性求值的。您已经看到了如何将容器转换为视图的示例,以及如何将视图实现为拥有容器。

最后,我们简要概述了标准库中提供的视图,以及 C++中范围的可能未来。

这一章是关于容器、迭代器、算法和范围的系列的最后一章。我们现在将转向 C++中的内存管理。

第七章:内存管理

在阅读了前面的章节之后,应该不会再感到惊讶,我们处理内存的方式对性能有很大影响。CPU 花费大量时间在 CPU 寄存器和主内存之间传输数据(加载和存储数据到主内存和从主内存中读取数据)。正如在第四章数据结构中所示,CPU 使用内存缓存来加速对内存的访问,程序需要对缓存友好才能运行得快。

本章将揭示更多关于计算机如何处理内存的方面,以便您知道在调整内存使用时必须考虑哪些事项。此外,本章还涵盖了:

  • 自动内存分配和动态内存管理。

  • C++对象的生命周期以及如何管理对象所有权。

  • 高效的内存管理。有时,存在严格的内存限制,迫使我们保持数据表示紧凑,有时我们有大量的可用内存,但需要通过使内存管理更高效来加快程序运行速度。

  • 如何最小化动态内存分配。分配和释放动态内存相对昂贵,有时我们需要避免不必要的分配以使程序运行更快。

我们将从解释一些概念开始这一章,这些概念在我们深入研究 C++内存管理之前需要理解。这个介绍将解释虚拟内存和虚拟地址空间,堆内存与栈内存,分页和交换空间。

计算机内存

计算机的物理内存是所有运行在系统上的进程共享的。如果一个进程使用了大量内存,其他进程很可能会受到影响。但从程序员的角度来看,我们通常不必担心其他进程正在使用的内存。这种内存的隔离是因为今天的大多数操作系统都是虚拟内存操作系统,它们提供了一个假象,即一个进程拥有了所有的内存。每个进程都有自己的虚拟地址空间

虚拟地址空间

程序员看到的虚拟地址空间中的地址由操作系统和处理器的内存管理单元MMU)映射到物理地址。每次访问内存地址时都会发生这种映射或转换。

这种额外的间接层使操作系统能够使用物理内存来存储进程当前正在使用的部分,并将其余的虚拟内存备份到磁盘上。在这个意义上,我们可以把物理主内存看作是虚拟内存空间的缓存,而虚拟内存空间位于辅助存储上。通常用于备份内存页面的辅助存储区域通常称为交换空间交换文件或简单地称为页面文件,具体取决于操作系统。

虚拟内存使进程能够拥有比物理地址空间更大的虚拟地址空间,因为未使用的虚拟内存不需要占用物理内存。

内存页面

实现虚拟内存的最常见方式是将地址空间划分为称为内存页面的固定大小块。当一个进程访问虚拟地址处的内存时,操作系统会检查内存页面是否由物理内存(页面帧)支持。如果内存页面没有映射到主内存中,将会发生硬件异常,并且页面将从磁盘加载到内存中。这种硬件异常称为页面错误。这不是错误,而是为了从磁盘加载数据到内存而必要的中断。不过,正如你可能已经猜到的那样,这与读取已经驻留在内存中的数据相比非常慢。

当主内存中没有更多可用的页面帧时,必须驱逐一个页面帧。如果要驱逐的页面是脏的,也就是说,自从上次从磁盘加载以来已经被修改,那么它需要被写入磁盘才能被替换。这种机制称为分页。如果内存页面没有被修改,那么内存页面就会被简单地驱逐。

并非所有支持虚拟内存的操作系统都支持分页。例如,iOS 具有虚拟内存,但脏页面永远不会存储在磁盘上;只有干净的页面才能从内存中驱逐。如果主内存已满,iOS 将开始终止进程,直到再次有足够的空闲内存。Android 使用类似的策略。不将内存页面写回移动设备的闪存存储的原因之一是它会消耗电池电量,还会缩短闪存存储本身的寿命。

下图显示了两个运行中的进程。它们都有自己的虚拟内存空间。一些页面映射到物理内存,而另一些则没有。如果进程 1 需要使用从地址 0x1000 开始的内存页面,就会发生页面错误。然后该内存页面将被映射到一个空闲的内存帧。还要注意虚拟内存地址与物理地址不同。进程 1 的第一个内存页面,从虚拟地址 0x0000 开始,映射到从物理地址 0x4000 开始的内存帧:

图 7.1:虚拟内存页面,映射到物理内存中的内存帧。未使用的虚拟内存页面不必占用物理内存。

抖动

抖动可能发生在系统的物理内存不足且不断分页的情况下。每当一个进程在 CPU 上被调度时,它试图访问已被分页出去的内存。加载新的内存页面意味着其他页面首先必须存储在磁盘上。在磁盘和内存之间来回移动数据通常非常缓慢;在某些情况下,这几乎会使计算机停滞,因为系统花费了所有的时间在分页上。查看系统的页面错误频率是确定程序是否开始抖动的好方法。

了解硬件和操作系统如何处理内存的基础知识对于优化性能很重要。接下来,我们将看到在执行 C++程序时内存是如何处理的。

进程内存

堆栈和堆是 C++程序中最重要的两个内存段。还有静态存储和线程本地存储,但我们稍后会更多地讨论这些。实际上,严格来说,C++并不谈论堆栈和堆;相反,它谈论自由存储、存储类和对象的存储持续时间。然而,由于堆栈和堆的概念在 C++社区中被广泛使用,并且我们所知道的所有 C++实现都使用堆栈来实现函数调用和管理局部变量的自动存储,因此了解堆栈和堆是很重要的。

在本书中,我还将使用术语堆栈而不是对象的存储持续时间。我将使用术语自由存储互换使用,并不会对它们进行区分。

堆栈和堆都驻留在进程的虚拟内存空间中。堆栈是所有局部变量驻留的地方;这也包括函数的参数。每次调用函数时,堆栈都会增长,并在函数返回时收缩。每个线程都有自己的堆栈,因此堆栈内存可以被视为线程安全。另一方面,堆是一个在运行进程中所有线程之间共享的全局内存区域。当我们使用new(或 C 库函数malloc()calloc())分配内存时,堆会增长,并在使用delete(或free())释放内存时收缩。通常,堆从低地址开始增长,向上增长,而堆栈从高地址开始增长,向下增长。图 7.2显示了堆栈和堆在虚拟地址空间中以相反方向增长:

图 7.2:进程的地址空间。堆栈和堆以相反方向增长。

接下来的部分将提供有关堆栈和堆的更多细节,并解释在我们编写的 C++程序中何时使用这些内存区域。

堆栈内存

堆栈在许多方面与堆不同。以下是堆栈的一些独特属性:

  • 堆栈是一个连续的内存块。

  • 它有一个固定的最大大小。如果程序超出最大堆栈大小,程序将崩溃。这种情况称为堆栈溢出。

  • 堆栈内存永远不会变得分散。

  • 从堆栈中分配内存(几乎)总是很快的。页面错误可能会发生,但很少见。

  • 程序中的每个线程都有自己的堆栈。

本节中接下来的代码示例将检查其中一些属性。让我们从分配和释放开始,以了解堆栈在程序中的使用方式。

通过检查堆栈分配的数据的地址,我们可以轻松找出堆栈增长的方向。以下示例代码演示了进入和离开函数时堆栈的增长和收缩:

void func1() {
  auto i = 0;
  std::cout << "func1(): " << std::addressof(i) << '\n';
}
void func2() {
  auto i = 0;
  std::cout << "func2(): " << std::addressof(i) << '\n';
  func1();
}

int main() { 
  auto i = 0; 
  std::cout << "main():  " << std::addressof(i) << '\n'; 
  func2();
  func1(); 
} 

运行程序时可能的输出如下:

main():  0x7ea075ac 
func2(): 0x7ea07594 
func1(): 0x7ea0757c 
func1(): 0x7ea07594 

通过打印堆栈分配的整数的地址,我们可以确定堆栈在我的平台上增长了多少,以及增长的方向。每次我们进入func1()func2()时,堆栈都会增加 24 个字节。整数i将分配在堆栈上,长度为 4 个字节。剩下的 20 个字节包含在函数结束时需要的数据,例如返回地址,可能还有一些用于对齐的填充。

以下图示说明了程序执行期间堆栈的增长和收缩。第一个框说明了程序刚进入main()函数时内存的样子。第二个框显示了当我们执行func1()时堆栈的增加,依此类推:

图 7.3:当进入函数时,堆栈增长和收缩

堆栈分配的总内存是在线程启动时创建的固定大小的连续内存块。那么,堆栈有多大,当我们达到堆栈的限制时会发生什么呢?

如前所述,每次程序进入函数时,堆栈都会增长,并在函数返回时收缩。每当我们在同一函数内创建新的堆栈变量时,堆栈也会增长,并在此类变量超出范围时收缩。堆栈溢出的最常见原因是深度递归调用和/或在堆栈上使用大型自动变量。堆栈的最大大小在不同平台之间有所不同,并且还可以为单个进程和线程进行配置。

让我们看看是否可以编写一个程序来查看默认情况下系统的堆栈有多大。我们将首先编写一个名为func()的函数,该函数将无限递归。在每个函数的开始,我们将分配一个 1 千字节的变量,每次进入func()时都会将其放入堆栈。每次执行func()时,我们打印堆栈的当前大小:

void func(std::byte* stack_bottom_addr) { 
  std::byte data[1024];     
  std::cout << stack_bottom_addr - data << '\n'; 
  func(stack_bottom_addr); 
} 

int main() { 
  std::byte b; 
  func(&b); 
} 

堆栈的大小只是一个估计值。我们通过从main()中定义的第一个局部变量的地址减去func()中定义的第一个局部变量的地址来计算它。

当我用 Clang 编译代码时,我收到一个警告,即func()永远不会返回。通常,这是一个我们不应该忽略的警告,但这次,这正是我们想要的结果,所以我们忽略了警告并运行了程序。程序在堆栈达到其限制后不久崩溃。在程序崩溃之前,它设法打印出数千行堆栈的当前大小。输出的最后几行看起来像这样:

... 
8378667 
8379755 
8380843 

由于我们在减去std::byte指针,所以大小以字节为单位,因此在我的系统上,堆栈的最大大小似乎约为 8 MB。在类 Unix 系统上,可以使用ulimit命令和选项-s来设置和获取进程的堆栈大小:

$ ulimit -s
$ 8192 

ulimit(用户限制的缩写)返回以千字节为单位的最大堆栈大小的当前设置。ulimit的输出证实了我们实验的结果:如果我没有显式配置,我的 Mac 上的堆栈大约为 8 MB。

在 Windows 上,默认的堆栈大小通常设置为 1 MB。如果堆栈大小没有正确配置,那么在 Windows 上运行良好的程序在 macOS 上可能会因堆栈溢出而崩溃。

通过这个例子,我们还可以得出结论,我们不希望用尽堆栈内存,因为当发生这种情况时,程序将崩溃。在本章的后面,我们将看到如何实现一个基本的内存分配器来处理固定大小的分配。然后我们将了解到,堆栈只是另一种类型的内存分配器,可以非常高效地实现,因为使用模式总是顺序的。我们总是在堆栈的顶部(连续内存的末尾)请求和释放内存。这确保了堆栈内存永远不会变得碎片化,并且我们可以通过仅移动堆栈指针来分配和释放内存。

堆内存

堆(或者更正确的术语是自由存储区,在 C++中)是动态存储数据的地方。如前所述,堆在多个线程之间共享,这意味着堆的内存管理需要考虑并发性。这使得堆中的内存分配比堆栈分配更复杂,因为堆中的内存分配是每个线程的本地分配。

堆栈内存的分配和释放模式是顺序的,即内存总是按照分配的相反顺序进行释放。另一方面,对于动态内存,分配和释放可以任意发生。对象的动态生命周期和内存分配的变量大小增加了内存碎片的风险。

理解内存碎片问题的简单方法是通过一个示例来说明内存如何发生碎片化。假设我们有一个小的连续内存块,大小为 16 KB,我们正在从中分配内存。我们正在分配两种类型的对象:类型A,大小为 1 KB,和类型B,大小为 2 KB。我们首先分配一个类型A的对象,然后是一个类型B的对象。这样重复,直到内存看起来像下面的图像:

图 7.4:分配类型 A 和 B 对象后的内存

接下来,所有类型A的对象都不再需要,因此它们可以被释放。内存现在看起来像这样:

图 7.5:释放类型 A 对象后的内存

现在有 10KB 的内存正在使用,还有 6KB 可用。现在,假设我们想要分配一个类型为B的新对象,它占用 2KB。尽管有 6KB 的空闲内存,但我们找不到 2KB 的内存块,因为内存已经变得碎片化。

现在您已经对计算机内存在运行过程中的结构和使用有了很好的理解,现在是时候探索 C++对象在内存中的生存方式了。

内存中的对象

我们在 C++程序中使用的所有对象都驻留在内存中。在这里,我们将探讨如何在内存中创建和删除对象,并描述对象在内存中的布局方式。

创建和删除对象

在本节中,我们将深入探讨使用newdelete的细节。考虑以下使用new在自由存储器上创建对象,然后使用delete删除它的方式:

auto* user = new User{"John"};  // allocate and construct 
user->print_name();             // use object 
delete user;                    // destruct and deallocate 

我不建议以这种方式显式调用newdelete,但现在让我们忽略这一点。让我们来重点讨论一下;正如注释所建议的那样,new实际上做了两件事,即:

  • 分配内存以容纳User类型的新对象

  • 通过调用User类的构造函数在分配的内存空间中构造一个新的User对象

同样的事情也适用于delete,它:

  • 通过调用其析构函数来销毁User对象

  • 释放User对象所在的内存

实际上,在 C++中可以将这两个操作(内存分配和对象构造)分开。这很少使用,但在编写库组件时有一些重要和合法的用例。

放置 new

C++允许我们将内存分配与对象构造分开。例如,我们可以使用malloc()分配一个字节数组,并在该内存区域中构造一个新的User对象。看一下以下代码片段:

auto* memory = std::malloc(sizeof(User));
auto* user = ::new (memory) User("john"); 

使用::new (memory)的可能不熟悉的语法称为放置 new。这是new的一种非分配形式,它只构造一个对象。::前面的双冒号确保了从全局命名空间进行解析,以避免选择operator new的重载版本。

在前面的示例中,放置 new 构造了User对象,并将其放置在指定的内存位置。由于我们使用std::malloc()为单个对象分配内存,所以它保证了正确的对齐(除非User类已声明为过对齐)。稍后,我们将探讨在使用放置 new 时必须考虑对齐的情况。

没有放置删除,因此为了销毁对象并释放内存,我们需要显式调用析构函数,然后释放内存:

user->~User();
std::free(memory); 

这是您应该显式调用析构函数的唯一时机。除非您使用放置 new 创建了一个对象,否则永远不要这样调用析构函数。

C++17 在<memory>中引入了一组实用函数,用于在不分配或释放内存的情况下构造和销毁对象。因此,现在可以使用一些以std::uninitialized_开头的函数来构造、复制和移动对象到未初始化的内存区域,而不是调用放置 new。而且,现在可以使用std::destroy_at()在特定内存地址上销毁对象,而无需释放内存。

前面的示例可以使用这些新函数重写。下面是它的样子:

auto* memory = std::malloc(sizeof(User));
auto* user_ptr = reinterpret_cast<User*>(memory);
std::uninitialized_fill_n(user_ptr, 1, User{"john"});
std::destroy_at(user_ptr);
std::free(memory); 

C++20 还引入了std::construct_at(),它使得可以用它来替换std::uninitialized_fill_n()的调用:

std::construct_at(user_ptr, User{"john"});        // C++20 

请记住,我们展示这些裸露的低级内存设施是为了更好地理解 C++中的内存管理。在 C++代码库中,使用reinterpret_cast和这里演示的内存实用程序应该保持绝对最低限度。

接下来,您将看到当我们使用newdelete表达式时调用了哪些操作符。

new 和 delete 操作符

函数 operator new 负责在调用 new 表达式时分配内存。new 运算符可以是全局定义的函数,也可以是类的静态成员函数。可以重载全局运算符 newdelete。在本章后面,我们将看到在分析内存使用情况时,这可能是有用的。

以下是如何做到这一点:

auto operator new(size_t size) -> void* { 
  void* p = std::malloc(size); 
  std::cout << "allocated " << size << " byte(s)\n"; 
  return p; 
} 

auto operator delete(void* p) noexcept -> void { 
  std::cout << "deleted memory\n"; 
  return std::free(p); 
} 

我们可以验证我们重载的运算符在创建和删除 char 对象时是否真的被使用:

auto* p = new char{'a'}; // Outputs "allocated 1 byte(s)"
delete p;                // Outputs "deleted memory" 

使用 new[]delete[] 表达式创建和删除对象数组时,还使用了另一对运算符,即 operator new[]operator delete[]。我们可以以相同的方式重载这些运算符:

auto operator new[](size_t size) -> void* {
  void* p = std::malloc(size); 
  std::cout << "allocated " << size << " byte(s) with new[]\n"; 
  return p; 
} 

auto operator delete[](void* p) noexcept -> void { 
  std::cout << "deleted memory with delete[]\n"; 
  return std::free(p); 
} 

请记住,如果重载了 operator new,还应该重载 operator delete。分配和释放内存的函数是成对出现的。内存应该由分配该内存的分配器释放。例如,使用 std::malloc() 分配的内存应始终使用 std::free() 释放,而使用 operator new[] 分配的内存应使用 operator delete[] 释放。

还可以覆盖特定于类的 operator newoperator delete。这可能比重载全局运算符更有用,因为更有可能需要为特定类使用自定义动态内存分配器。

在这里,我们正在为 Document 类重载 operator newoperator delete

class Document { 
// ...
public:  
  auto operator new(size_t size) -> void* {
    return ::operator new(size);
  } 
  auto operator delete(void* p) -> void {
    ::operator delete(p); 
  } 
}; 

当我们创建新的动态分配的 Document 对象时,将使用特定于类的 new 版本:

auto* p = new Document{}; // Uses class-specific operator new
delete p; 

如果我们希望使用全局 newdelete,仍然可以通过使用全局作用域 (::) 来实现:

auto* p = ::new Document{}; // Uses global operator new
::delete p; 

我们将在本章后面讨论内存分配器,然后我们将看到重载的 newdelete 运算符的使用。

迄今为止,总结一下,new表达式涉及两个方面:分配和构造。operator new分配内存,您可以全局或按类重载它以自定义动态内存管理。放置 new 可用于在已分配的内存区域中构造对象。

另一个重要但相当低级的主题是我们需要了解以有效使用内存的内存对齐

内存对齐

CPU 每次从内存中读取一个字时,将其读入寄存器。64 位架构上的字大小为 64 位,32 位架构上为 32 位,依此类推。为了使 CPU 在处理不同数据类型时能够高效工作,它对不同类型的对象所在的地址有限制。C++ 中的每种类型都有一个对齐要求,定义了内存中应该位于某种类型对象的地址。

如果类型的对齐方式为 1,则表示该类型的对象可以位于任何字节地址。如果类型的对齐方式为 2,则表示允许地址之间的字节数为 2。或者引用 C++ 标准的说法:

"对齐是一个实现定义的整数值,表示给定对象可以分配的连续地址之间的字节数。"

我们可以使用 alignof 来查找类型的对齐方式:

// Possible output is 4  
std::cout << alignof(int) << '\n'; 

当我运行此代码时,输出为 4,这意味着在我的平台上,类型 int 的对齐要求为 4 字节。

以下图示显示了来自具有 64 位字的系统的内存的两个示例。上排包含三个 4 字节整数,它们位于 4 字节对齐的地址上。CPU 可以以高效的方式将这些整数加载到寄存器中,并且在访问其中一个 int 成员时永远不需要读取多个字。将其与第二排进行比较,其中包含两个 int 成员,它们位于不对齐的地址上。第二个 int 甚至跨越了两个字的边界。在最好的情况下,这只是低效,但在某些平台上,程序将崩溃:

图 7.6:包含整数的内存的两个示例,分别位于对齐和不对齐的内存地址

假设我们有一个对齐要求为 2 的类型。C++标准没有规定有效地址是 1、3、5、7...还是 0、2、4、6...。我们所知道的所有平台都是从 0 开始计算地址,因此实际上我们可以通过使用取模运算符(%)来检查对象是否正确对齐。

但是,如果我们想编写完全可移植的 C++代码,我们需要使用std::align()而不是取模来检查对象的对齐。std::align()是来自<memory>的一个函数,它将根据我们传递的对齐方式调整指针。如果我们传递给它的内存地址已经对齐,指针将不会被调整。因此,我们可以使用std::align()来实现一个名为is_aligned()的小型实用程序函数,如下所示:

bool is_aligned(void* ptr, std::size_t alignment) {
  assert(ptr != nullptr);
  assert(std::has_single_bit(alignment)); // Power of 2
  auto s = std::numeric_limits<std::size_t>::max();
  auto aligned_ptr = ptr;
  std::align(alignment, 1, aligned_ptr, s);
  return ptr == aligned_ptr;
} 

首先,我们确保ptr参数不为空,并且alignment是 2 的幂,这是 C++标准中规定的要求。我们使用 C++20 <bit>头文件中的std::has_single_bit()来检查这一点。接下来,我们调用std::align()std::align()的典型用法是当我们有一定大小的内存缓冲区,我们想要在其中存储具有一定对齐要求的对象。在这种情况下,我们没有缓冲区,也不关心对象的大小,因此我们说对象的大小为 1,缓冲区是std::size_t的最大值。然后,我们可以比较原始的ptr和调整后的aligned_ptr,以查看原始指针是否已经对齐。我们将在接下来的示例中使用这个实用程序。

使用newstd::malloc()分配内存时,我们获得的内存应正确对齐为我们指定的类型。以下代码显示,为int分配的内存在我的平台上至少是 4 字节对齐的:

auto* p = new int{};
assert(is_aligned(p, 4ul)); // True 

实际上,newmalloc()保证始终返回适合任何标量类型的内存(如果它成功返回内存的话)。<cstddef>头文件为我们提供了一个名为std::max_align_t的类型,其对齐要求至少与所有标量类型一样严格。稍后,我们将看到在编写自定义内存分配器时,这种类型是有用的。因此,即使我们只请求自由存储器上的char内存,它也将适合于std::max_align_t

以下代码显示,从new返回的内存对于std::max_align_t和任何标量类型都是正确对齐的:

auto* p = new char{}; 
auto max_alignment = alignof(std::max_align_t);
assert(is_aligned(p, max_alignment)); // True 

让我们使用new连续两次分配char

auto* p1 = new char{'a'};
auto* p2 = new char{'b'}; 

然后,内存可能看起来像这样:

图 7.7:分配两个单独的 char 后的内存布局

p1p2之间的空间取决于std::max_align_t的对齐要求。在我的系统上,它是16字节,因此每个char实例之间有 15 个字节,即使char的对齐只有 1。

在使用alignas指定符声明变量时,可以指定比默认对齐更严格的自定义对齐要求。假设我们的缓存行大小为 64 字节,并且出于某种原因,我们希望确保两个变量位于不同的缓存行上。我们可以这样做:

alignas(64) int x{};
alignas(64) int y{};
// x and y will be placed on different cache lines 

在定义类型时,也可以指定自定义对齐。以下是一个在使用时将占用一整个缓存行的结构体:

struct alignas(64) CacheLine {
    std::byte data[64];
}; 

现在,如果我们创建一个类型为CacheLine的栈变量,它将根据 64 字节的自定义对齐进行对齐:

int main() {
  auto x = CacheLine{};
  auto y = CacheLine{};
  assert(is_aligned(&x, 64));
  assert(is_aligned(&y, 64));
  // ...
} 

在堆上分配对象时,也满足了更严格的对齐要求。为了支持具有非默认对齐要求的类型的动态分配,C++17 引入了operator new()operator delete()的新重载,它们接受std::align_val_t类型的对齐参数。在<cstdlib>中还定义了一个aligned_alloc()函数,可以用于手动分配对齐的堆内存。

以下是一个示例,我们在其中分配一个应该占用一个内存页面的堆内存块。在这种情况下,使用newdelete时将调用对齐感知版本的operator new()operator delete()

constexpr auto ps = std::size_t{4096};      // Page size
struct alignas(ps) Page {
    std::byte data_[ps];
};
auto* page = new Page{};                    // Memory page
assert(is_aligned(page, ps));               // True
// Use page ...
delete page; 

内存页面不是 C++抽象机器的一部分,因此没有可移植的方法来以编程方式获取当前运行系统的页面大小。但是,您可以在 Unix 系统上使用boost::mapped_region::get_page_size()或特定于平台的系统调用,如getpagesize()

要注意的最后一个警告是,支持的对齐集由您使用的标准库的实现定义,而不是 C++标准。

填充

编译器有时需要为我们定义的用户定义类型添加额外的字节,填充。当我们在类或结构中定义数据成员时,编译器被迫按照我们定义它们的顺序放置成员。

然而,编译器还必须确保类内的数据成员具有正确的对齐方式;因此,如果需要,它需要在数据成员之间添加填充。例如,假设我们有一个如下所示的类:

class Document { 
  bool is_cached_{}; 
  double rank_{}; 
  int id_{}; 
};
std::cout << sizeof(Document) << '\n'; // Possible output is 24 

可能输出为 24 的原因是,编译器在boolint之后插入填充,以满足各个数据成员和整个类的对齐要求。编译器将Document类转换为类似于这样的形式:

class Document {
  bool is_cached_{};
  std::byte padding1[7]; // Invisible padding inserted by compiler
  double rank_{};
  int id_{};
  std::byte padding2[4]; // Invisible padding inserted by compiler
}; 

booldouble之间的第一个填充为 7 字节,因为double类型的rank_数据成员具有 8 字节的对齐。在int之后添加的第二个填充为 4 字节。这是为了满足Document类本身的对齐要求。具有最大对齐要求的成员也决定了整个数据结构的对齐要求。在我们的示例中,这意味着Document类的总大小必须是 8 的倍数,因为它包含一个 8 字节对齐的double值。

我们现在意识到,我们可以重新排列Document类中数据成员的顺序,以最小化编译器插入的填充,方法是从具有最大对齐要求的类型开始。让我们创建Document类的新版本:

// Version 2 of Document class
class Document {
  double rank_{}; // Rearranged data members
  int id_{};
  bool is_cached_{};
}; 

通过重新排列成员,编译器现在只需要在is_cached_数据成员之后填充,以调整Document的对齐方式。这是填充后类的样子:

// Version 2 of Document class after padding
class Document { 
  double rank_{}; 
  int id_{}; 
  bool is_cached_{}; 
  std::byte padding[3]; // Invisible padding inserted by compiler 
}; 

新的Document类的大小现在只有 16 字节,而第一个版本为 24 字节。这里的见解应该是,对象的大小可以通过更改成员声明的顺序而改变。我们还可以通过在我们更新的Document版本上再次使用sizeof运算符来验证这一点:

std::cout << sizeof(Document) << '\n'; // Possible output is 16 

以下图片显示了Document类版本 1 和版本 2 的内存布局:

图 7.8:Document类的两个版本的内存布局。对象的大小可以通过更改成员声明的顺序而改变。

一般规则是,将最大的数据成员放在开头,最小的成员放在末尾。这样,您可以最小化填充引起的内存开销。稍后,我们将看到,在将对象放置在我们已分配的内存区域时,我们需要考虑对齐,然后才能知道我们正在创建的对象的对齐方式。

从性能的角度来看,也可能存在一些情况,你希望将对象对齐到缓存行,以最小化对象跨越的缓存行数量。在谈论缓存友好性时,还应该提到,将频繁一起使用的多个数据成员放在一起可能是有益的。

保持数据结构紧凑对性能很重要。许多应用程序受到内存访问时间的限制。内存管理的另一个重要方面是永远不要泄漏或浪费不再需要的对象的内存。通过清晰和明确地表达资源的所有权,我们可以有效地避免各种资源泄漏。这是接下来章节的主题。

内存所有权

资源的所有权是编程时需要考虑的基本方面。资源的所有者负责在不再需要资源时释放资源。资源通常是一块内存,但也可能是数据库连接、文件句柄等。无论使用哪种编程语言,所有权都很重要。然而,在诸如 C 和 C++之类的语言中更为明显,因为动态内存不会默认进行垃圾回收。每当我们在 C++中分配动态内存时,都必须考虑该内存的所有权。幸运的是,语言中现在有非常好的支持,可以通过使用智能指针来表达各种所有权类型,我们将在本节后面介绍。

标准库中的智能指针帮助我们指定动态变量的所有权。其他类型的变量已经有了定义的所有权。例如,局部变量由当前作用域拥有。当作用域结束时,在作用域内创建的对象将被自动销毁:

{
  auto user = User{};
} // user automatically destroys when it goes out of scope 

静态和全局变量由程序拥有,并将在程序终止时被销毁:

static auto user = User{}; 

数据成员由它们所属的类的实例拥有:

class Game {
  User user; // A Game object owns the User object
  // ...
}; 

只有动态变量没有默认所有者,程序员需要确保所有动态分配的变量都有一个所有者来控制变量的生命周期:

auto* user = new User{}; // Who owns user now? 

在现代 C++中,我们可以在大部分代码中不显式调用newdelete,这是一件好事。手动跟踪newdelete的调用很容易成为内存泄漏、双重删除和其他令人讨厌的错误的问题。原始指针不表达任何所有权,如果我们只使用原始指针引用动态内存,所有权很难跟踪。

我建议你清晰和明确地表达所有权,但努力最小化手动内存管理。通过遵循一些相当简单的规则来处理内存的所有权,你将增加代码干净和正确的可能性,而不会泄漏资源。接下来的章节将指导你通过一些最佳实践来实现这一目的。

隐式处理资源

首先,使你的对象隐式处理动态内存的分配/释放:

auto func() {
  auto v = std::vector<int>{1, 2, 3, 4, 5};
} 

在前面的例子中,我们同时使用了栈和动态内存,但我们不必显式调用newdelete。我们创建的std::vector对象是一个自动对象,将存储在栈上。由于它由作用域拥有,当函数返回时将自动销毁。std::vector对象本身使用动态内存来存储整数元素。当v超出作用域时,它的析构函数可以安全地释放动态内存。让析构函数释放动态内存的这种模式使得避免内存泄漏相当容易。

当我们谈论释放资源时,我认为提到 RAII 是有意义的。RAII是一个众所周知的 C++技术,缩写为Resource Acquisition Is Initialization,其中资源的生命周期由对象的生命周期控制。这种模式简单但对于处理资源(包括内存)非常有用。但是,假设我们需要的资源是用于发送请求的某种连接。每当我们使用连接完成后,我们(所有者)必须记得关闭它。以下是我们手动打开和关闭连接以发送请求时的示例:

auto send_request(const std::string& request) { 
  auto connection = open_connection("http://www.example.com/"); 
  send_request(connection, request); 
  close(connection); 
} 

正如你所看到的,我们必须记得在使用完连接后关闭它,否则连接将保持打开(泄漏)。在这个例子中,似乎很难忘记,但一旦代码在插入适当的错误处理和多个退出路径后变得更加复杂,就很难保证连接总是关闭。RAII 通过依赖自动变量的生命周期以可预测的方式处理这个问题。我们需要的是一个对象,它的生命周期与我们从open_connection()调用中获得的连接相同。我们可以为此创建一个名为RAIIConnection的类:

class RAIIConnection { 
public: 
  explicit RAIIConnection(const std::string& url) 
      : connection_{open_connection(url)} {} 
  ~RAIIConnection() { 
    try { 
      close(connection_);       
    } 
    catch (const std::exception&) { 
      // Handle error, but never throw from a destructor 
    } 
  }
  auto& get() { return connection_; } 

private:  
  Connection connection_; 
}; 

Connection对象现在被包装在一个控制连接(资源)生命周期的类中。现在我们可以让RAIIConnection来处理关闭连接,而不是手动关闭连接:

auto send_request(const std::string& request) { 
  auto connection = RAIIConnection("http://www.example.com/"); 
  send_request(connection.get(), request); 
  // No need to close the connection, it is automatically handled 
  // by the RAIIConnection destructor 
} 

RAII 使我们的代码更安全。即使send_request()在这里抛出异常,连接对象仍然会被销毁并关闭连接。我们可以将 RAII 用于许多类型的资源,不仅仅是内存、文件句柄和连接。另一个例子是来自 C++标准库的std::scoped_lock。它在创建时尝试获取锁(互斥锁),然后在销毁时释放锁。您可以在第十一章 并发中了解更多关于std::scoped_lock的信息。

现在,我们将探索更多使内存所有权在 C++中变得明确的方法。

容器

您可以使用标准容器来处理对象的集合。您使用的容器将拥有存储在其中的对象所需的动态内存。这是一种在代码中最小化手动newdelete表达式的非常有效的方法。

还可以使用std::optional来处理可能存在或可能不存在的对象的生命周期。std::optional可以被视为一个最大大小为 1 的容器。

我们不会在这里再讨论容器,因为它们已经在第四章 数据结构中涵盖过了。

智能指针

标准库中的智能指针包装了一个原始指针,并明确了对象的所有权。当正确使用时,没有疑问谁负责删除动态对象。三种智能指针类型是:std::unique_ptrstd::shared_ptrstd::weak_ptr。正如它们的名称所暗示的那样,它们代表对象的三种所有权类型:

  • 独占所有权表示我,只有我,拥有这个对象。当我使用完它后,我会删除它。

  • 共享所有权表示我和其他人共同拥有对象。当没有人再需要这个对象时,它将被删除。

  • 弱所有权表示如果对象存在,我会使用它,但不会仅仅为了我而保持它的生存。

我们将分别在以下各节中处理这些类型。

独占指针

最安全和最不复杂的所有权是独占所有权,当考虑智能指针时,应该首先想到的是独占所有权。独占指针表示独占所有权;也就是说,一个资源只被一个实体拥有。独占所有权可以转移给其他人,但不能被复制,因为那样会破坏其独特性。以下是如何使用std::unique_ptr

auto owner = std::make_unique<User>("John");
auto new_owner = std::move(owner); // Transfer ownership 

独占指针也非常高效,因为与普通原始指针相比,它们几乎没有性能开销。轻微的开销是由于std::unique_ptr具有非平凡的析构函数,这意味着(与原始指针不同)在传递给函数时无法将其传递到 CPU 寄存器中。这使它们比原始指针慢。

共享指针

共享所有权意味着一个对象可以有多个所有者。当最后一个所有者不存在时,对象将被删除。这是一种非常有用的指针类型,但也比独占指针更复杂。

std::shared_ptr对象使用引用计数来跟踪对象的所有者数量。当计数器达到 0 时,对象将被删除。计数器需要存储在某个地方,因此与独占指针相比,它确实具有一些内存开销。此外,std::shared_ptr在内部是线程安全的,因此需要原子方式更新计数器以防止竞争条件。

创建由共享指针拥有的对象的推荐方式是使用std::make_shared<T>()。这既更安全(从异常安全性的角度来看),也比手动使用new创建对象,然后将其传递给std::shared_ptr构造函数更有效。通过再次重载operator new()operator delete()来跟踪分配,我们可以进行实验,找出为什么使用std::make_shared<T>()更有效:

auto operator new(size_t size) -> void* { 
  void* p = std::malloc(size); 
  std::cout << "allocated " << size << " byte(s)" << '\n'; 
  return p; 
} 
auto operator delete(void* p) noexcept -> void { 
  std::cout << "deleted memory\n"; 
  return std::free(p); 
} 

现在,让我们首先尝试推荐的方式,使用std::make_shared()

int main() { 
  auto i = std::make_shared<double>(42.0); 
  return 0; 
} 

运行程序时的输出如下:

allocated 32 bytes 
deleted memory 

现在,让我们通过使用new显式分配int值,然后将其传递给std::shared_ptr构造函数:

int main() { 
  auto i = std::shared_ptr<double>{new double{42.0}}; 
  return 0; 
} 

程序将生成以下输出:

allocated 4 bytes 
allocated 32 bytes 
deleted memory 
deleted memory 

我们可以得出结论,第二个版本需要两次分配,一次是为double,一次是为std::shared_ptr,而第一个版本只需要一次分配。这也意味着,通过使用std::make_shared(),我们的代码将更加友好地利用缓存,因为具有空间局部性。

弱指针

弱所有权不会保持任何对象存活;它只允许我们在其他人拥有对象时使用对象。为什么要使用这种模糊的弱所有权?使用弱指针的一个常见原因是打破引用循环。引用循环发生在两个或多个对象使用共享指针相互引用时。即使所有外部std::shared_ptr构造函数都消失了,对象仍然通过相互引用而保持存活。

为什么不只使用原始指针?弱指针难道不就是原始指针已经是的东西吗?一点也不是。弱指针是安全的,因为除非对象实际存在,否则我们无法引用该对象,而悬空的原始指针并非如此。一个例子将澄清这一点:

auto i = std::make_shared<int>(10); 
auto weak_i = std::weak_ptr<int>{i};

// Maybe i.reset() happens here so that the int is deleted... 
if (auto shared_i = weak_i.lock()) { 
  // We managed to convert our weak pointer to a shared pointer 
  std::cout << *shared_i << '\n'; 
} 
else { 
  std::cout << "weak_i has expired, shared_ptr was nullptr\n"; 
} 

每当我们尝试使用弱指针时,我们需要首先使用成员函数lock()将其转换为共享指针。如果对象尚未过期,共享指针将是指向该对象的有效指针;否则,我们将得到一个空的std::shared_ptr。这样,我们可以避免在使用std::weak_ptr时出现悬空指针,而不是使用原始指针。

这将结束我们关于内存中对象的部分。C++在处理内存方面提供了出色的支持,无论是关于低级概念,如对齐和填充,还是高级概念,如对象所有权。

对所有权、RAII 和引用计数有着清晰的理解在使用 C++时非常重要。对于新手来说,如果之前没有接触过这些概念,可能需要一些时间才能完全掌握。与此同时,这些概念并不是 C++独有的。在大多数语言中,它们更加普遍,但在其他一些语言中,它们甚至更加突出(Rust 就是后者的一个例子)。因此,一旦掌握,它将提高您在其他语言中的编程技能。思考对象所有权将对您编写的程序的设计和架构产生积极影响。

现在,我们将继续介绍一种优化技术,它将减少动态内存分配的使用,并在可能的情况下使用堆栈。

小对象优化

std::vector这样的容器的一个很大的优点是,它们在需要时会自动分配动态内存。然而,有时为只包含少量小元素的容器对象使用动态内存会影响性能。将元素保留在容器本身,并且只使用堆栈内存,而不是在堆上分配小的内存区域,会更有效率。大多数现代的std::string实现都会利用这样一个事实:在正常程序中,很多字符串都很短,而且短字符串在不使用堆内存的情况下更有效率。

一种选择是在字符串类本身中保留一个小的单独缓冲区,当字符串的内容很短时可以使用。即使短缓冲区没有被使用,这也会增加字符串类的大小。

因此,一个更节省内存的解决方案是使用一个联合,当字符串处于短模式时可以容纳一个短缓冲区,否则,它将容纳它需要处理动态分配缓冲区的数据成员。用于优化处理小数据的容器的技术通常被称为字符串的小字符串优化,或者其他类型的小对象优化和小缓冲区优化。我们对我们喜欢的事物有很多名称。

一个简短的代码示例将演示在我的 64 位系统上,来自 LLVM 的 libc++中的std::string的行为:

auto allocated = size_t{0}; 
// Overload operator new and delete to track allocations 
void* operator new(size_t size) {  
  void* p = std::malloc(size); 
  allocated += size; 
  return p; 
} 

void operator delete(void* p) noexcept { 
  return std::free(p); 
} 

int main() { 
  allocated = 0; 
  auto s = std::string{""}; // Elaborate with different string sizes 

  std::cout << "stack space = " << sizeof(s) 
    << ", heap space = " << allocated 
    << ", capacity = " << s.capacity() << '\n'; 
} 

代码首先通过重载全局的operator newoperator delete来跟踪动态内存分配。现在我们可以开始测试不同大小的字符串s,看看std::string的行为。在我的系统上以发布模式构建和运行前面的示例时,它生成了以下输出:

stack space = 24, heap space = 0, capacity = 22 

这个输出告诉我们,std::string在堆栈上占用 24 个字节,并且在不使用任何堆内存的情况下,它的容量为 22 个字符。让我们通过用一个包含 22 个字符的字符串来验证这一点:

auto s = std::string{"1234567890123456789012"}; 

程序仍然产生相同的输出,并验证没有分配动态内存。但是当我们增加字符串以容纳 23 个字符时会发生什么呢?

auto s = std::string{"12345678901234567890123"}; 

现在运行程序会产生以下输出:

stack space = 24, heap space = 32, capacity = 31 

std::string类现在被强制使用堆来存储字符串。它分配了 32 个字节,并报告容量为 31。这是因为 libc++总是在内部存储一个以空字符结尾的字符串,因此需要在末尾额外的一个字节来存储空字符。令人惊讶的是,字符串类可以只占用 24 个字节,并且可以容纳长度为 22 个字符的字符串而不分配任何内存。它是如何做到的呢?如前所述,通常通过使用具有两种不同布局的联合来节省内存:一种用于短模式,一种用于长模式。在真正的 libc++实现中有很多巧妙之处,以充分利用可用的 24 个字节。这里的代码是为了演示这个概念而简化的。长模式的布局如下:

struct Long { 
  size_t capacity_{}; 
  size_t size_{}; 
  char* data_{}; 
}; 

长布局中的每个成员占用 8 个字节,因此总大小为 24 个字节。data_指针是指向将容纳长字符串的动态分配内存的指针。短模式的布局看起来像这样:

struct Short { 
  unsigned char size_{};
  char data_[23]{}; 
}; 

在短模式下,不需要使用一个变量来存储容量,因为它是一个编译时常量。在这种布局中,size_数据成员也可以使用更小的类型,因为我们知道如果是短字符串,字符串的长度只能在 0 到 22 之间。

这两种布局使用一个联合结合起来:

union u_ { 
  Short short_layout_; 
  Long long_layout_; 
}; 

然而,还有一个缺失的部分:字符串类如何知道它当前是存储短字符串还是长字符串?需要一个标志来指示这一点,但它存储在哪里?事实证明,libc++在长模式下使用capacity_数据成员的最低有效位,而在短模式下使用size_数据成员的最低有效位。对于长模式,这个位是多余的,因为字符串总是分配 2 的倍数的内存大小。在短模式下,可以只使用 7 位来存储大小,以便一个位可以用于标志。当编写此代码以处理大端字节顺序时,情况变得更加复杂,因为无论我们使用联合的短结构还是长结构,位都需要放置在内存的相同位置。您可以在github.com/llvm/llvm-project/tree/master/libcxx上查看 libc++实现的详细信息。

图 7.9总结了我们简化的(但仍然相当复杂)内存布局,该布局由高效实现小字符串优化的联合使用:

图 7.9:用于处理短字符串和长字符串的两种不同布局的并集

像这样的巧妙技巧是您应该在尝试自己编写之前,努力使用标准库提供的高效且经过充分测试的类的原因。然而,了解这些优化以及它们的工作原理是重要且有用的,即使您永远不需要自己编写一个。

自定义内存管理

在本章中,我们已经走了很长的路。我们已经介绍了虚拟内存、堆栈和堆、newdelete表达式、内存所有权以及对齐和填充的基础知识。但在结束本章之前,我们将展示如何在 C++中自定义内存管理。我们将看到,在编写自定义内存分配器时,本章前面介绍的部分将会派上用场。

但首先,什么是自定义内存管理器,为什么我们需要它?

使用newmalloc()来分配内存时,我们使用 C++中的内置内存管理系统。大多数operator new的实现都使用malloc(),这是一个通用的内存分配器。设计和构建通用内存管理器是一项复杂的任务,已经有许多人花了很多时间研究这个主题。然而,有几个原因可能会导致您想要编写自定义内存管理器。以下是一些例子:

  • 调试和诊断:在本章中,我们已经几次通过重载operator newoperator delete来打印一些调试信息。

  • 沙盒:自定义内存管理器可以为不允许分配不受限制内存的代码提供一个沙盒。沙盒还可以跟踪内存分配,并在沙盒代码执行完毕时释放内存。

  • 性能:如果我们需要动态内存并且无法避免分配,可能需要编写一个针对特定需求性能更好的自定义内存管理器。稍后,我们将介绍一些情况,我们可以利用它们来超越malloc()

尽管如此,许多有经验的 C++程序员从未遇到过实际需要定制系统提供的标准内存管理器的问题。这表明了通用内存管理器实际上有多么好,尽管它们必须在不了解我们的具体用例的情况下满足所有要求。我们对应用程序中的内存使用模式了解得越多,我们就越有可能编写比malloc()更有效的东西。例如,记得堆栈吗?与堆相比,从堆栈分配和释放内存非常快,这要归功于它不需要处理多个线程,而且释放总是保证以相反的顺序发生。

构建自定义内存管理器通常始于分析确切的内存使用模式,然后实现一个竞技场。

建立一个竞技场

在使用内存分配器时经常使用的两个术语是竞技场内存池。在本书中,我们不会区分这些术语。我所说的竞技场是指一块连续的内存,包括分配和稍后回收该内存的策略。

竞技场在技术上也可以被称为内存资源分配器,但这些术语将用于指代标准库中的抽象。我们稍后将开发的自定义分配器将使用我们在这里创建的竞技场。

在设计一个竞技场时,有一些通用策略可以使分配和释放内存的性能优于malloc()free()

  • 单线程:如果我们知道一个竞技场只会从一个线程使用,就不需要用同步原语(如锁或原子操作)保护数据。客户端使用竞技场不会被其他线程阻塞的风险,这在实时环境中很重要。

  • 固定大小的分配:如果竞技场只分配固定大小的内存块,那么使用自由列表可以相对容易地高效地回收内存,避免内存碎片化。

  • 有限的生命周期:如果你知道从竞技场分配的对象只需要在有限且明确定义的生命周期内存在,竞技场可以推迟回收并一次性释放所有内存。一个例子可能是在服务器应用程序中处理请求时创建的对象。当请求完成时,可以一次性回收在请求期间分配的所有内存。当然,竞技场需要足够大,以便在不断回收内存的情况下处理请求期间的所有分配;否则,这种策略将不起作用。

我不会详细介绍这些策略,但在寻找改进程序中的内存管理方法时,了解可能性是很好的。与优化软件一样,关键是了解程序运行的环境,并分析特定的内存使用模式。我们这样做是为了找到比通用内存管理器更有效的自定义内存管理器的方法。

接下来,我们将看一个简单的竞技场类模板,它可以用于需要动态存储期的小型或少量对象,但它通常需要的内存量很小,可以放在堆栈上。这段代码基于 Howard Hinnant 的short_alloc,发布在howardhinnant.github.io/stack_alloc.html。如果你想深入了解自定义内存管理,这是一个很好的起点。我认为这是一个很好的示例,因为它可以处理需要正确对齐的多种大小的对象。

但是,请记住,这只是一个简化版本,用于演示概念,而不是为您提供生产就绪的代码:

template <size_t N> 
class Arena { 
  static constexpr size_t alignment = alignof(std::max_align_t); 
public: 
  Arena() noexcept : ptr_(buffer_) {} 
  Arena(const Arena&) = delete; 
  Arena& operator=(const Arena&) = delete; 

  auto reset() noexcept { ptr_ = buffer_; } 
  static constexpr auto size() noexcept { return N; } 
  auto used() const noexcept {
    return static_cast<size_t>(ptr_ - buffer_); 
  } 
  auto allocate(size_t n) -> std::byte*; 
  auto deallocate(std::byte* p, size_t n) noexcept -> void; 

private: 
  static auto align_up(size_t n) noexcept -> size_t { 
    return (n + (alignment-1)) & ~(alignment-1); 
  } 
  auto pointer_in_buffer(const std::byte* p) const noexcept -> bool {
    return std::uintptr_t(p) >= std::uintptr_t(buffer_) &&
           std::uintptr_t(p) < std::uintptr_t(buffer_) + N;
  } 
  alignas(alignment) std::byte buffer_[N]; 
  std::byte* ptr_{}; 
}; 

区域包含一个std::byte缓冲区,其大小在编译时确定。这使得可以在堆栈上或作为具有静态或线程局部存储期的变量创建区域对象。对于除char之外的类型,对齐可能在堆栈上分配;因此,除非我们对数组应用alignas说明符,否则不能保证它对齐。如果你不习惯位操作,辅助函数align_up()可能看起来很复杂。然而,它基本上只是将其舍入到我们使用的对齐要求。这个版本分配的内存将与使用malloc()时一样,适用于任何类型。如果我们使用区域来处理具有较小对齐要求的小类型,这会有点浪费,但我们在这里忽略这一点。

在回收内存时,我们需要知道被要求回收的指针是否实际属于我们的区域。pointer_in_buffer()函数通过比较指针地址与区域的地址范围来检查这一点。顺便说一句,对不相交对象的原始指针进行关系比较是未定义行为;这可能被优化编译器使用,并导致意想不到的效果。为了避免这种情况,我们在比较地址之前将指针转换为std::uintptr_t。如果你对此背后的细节感兴趣,你可以在 Raymond Chen 的文章如何检查指针是否在内存范围内中找到详细的解释,链接为devblogs.microsoft.com/oldnewthing/20170927-00/?p=97095

接下来,我们需要实现分配和释放:

template<size_t N> 
auto Arena<N>::allocate(size_t n) -> std::byte* { 
  const auto aligned_n = align_up(n); 
  const auto available_bytes =  
    static_cast<decltype(aligned_n)>(buffer_ + N - ptr_); 
  if (available_bytes >= aligned_n) { 
    auto* r = ptr_; 
    ptr_ += aligned_n; 
    return r; 
  } 
  return static_cast<std::byte*>(::operator new(n)); 
} 

allocate()函数返回一个指向指定大小n的正确对齐内存的指针。如果缓冲区中没有足够的空间来满足请求的大小,它将退而使用operator new

以下的deallocate()函数首先检查要释放内存的指针是否来自缓冲区,或者是使用operator new分配的。如果不是来自缓冲区,我们就简单地使用operator delete删除它。否则,我们检查要释放的内存是否是我们从缓冲区分配的最后一块内存,然后通过移动当前的ptr_来回收它,就像栈一样。我们简单地忽略其他尝试回收内存的情况:

template<size_t N> 
auto Arena<N>::deallocate(std::byte* p, size_t n) noexcept -> void { 
  if (pointer_in_buffer(p)) { 
    n = align_up(n); 
    if (p + n == ptr_) { 
      ptr_ = p; 
    } 
  } 
  else { 
    ::operator delete(p);
  }
} 

就是这样;我们的区域现在可以使用了。让我们在分配User对象时使用它:

auto user_arena = Arena<1024>{}; 

class User { 
public: 
  auto operator new(size_t size) -> void* { 
    return user_arena.allocate(size); 
  } 
  auto operator delete(void* p) -> void { 
    user_arena.deallocate(static_cast<std::byte*>(p), sizeof(User)); 
  } 
  auto operator new[](size_t size) -> void* { 
    return user_arena.allocate(size); 
  } 
  auto operator delete[](void* p, size_t size) -> void { 
    user_arena.deallocate(static_cast<std::byte*>(p), size); 
  } 
private:
  int id_{};
}; 

int main() { 
  // No dynamic memory is allocated when we create the users 
  auto user1 = new User{}; 
  delete user1; 

  auto users = new User[10]; 
  delete [] users; 

  auto user2 = std::make_unique<User>(); 
  return 0; 
} 

在这个例子中创建的User对象都将驻留在user_area对象的缓冲区中。也就是说,当我们在这里调用newmake_unique()时,不会分配动态内存。但是在 C++中有其他创建User对象的方式,这个例子没有展示。我们将在下一节中介绍它们。

自定义内存分配器

当尝试使用特定类型的自定义内存管理器时,效果很好!但是有一个问题。事实证明,类特定的operator new并没有在我们可能期望的所有场合被调用。考虑以下代码:

auto user = std::make_shared<User>(); 

当我们想要有一个包含 10 个用户的std::vector时会发生什么?

auto users = std::vector<User>{};
users.reserve(10); 

在这两种情况下都没有使用我们的自定义内存管理器。为什么?从共享指针开始,我们必须回到之前的例子,我们在那里看到std::make_shared()实际上为引用计数数据和应该指向的对象分配内存。std::make_shared()无法使用诸如new User()这样的表达式来创建用户对象和只进行一次分配的计数器。相反,它分配内存并使用就地 new 构造用户对象。

std::vector对象也是类似的。当我们调用reserve()时,默认情况下它不会在数组中构造 10 个对象。这将需要所有类都有默认构造函数才能与向量一起使用。相反,它分配内存,可以用于添加 10 个用户对象时使用。再次,放置 new 是使这成为可能的工具。

幸运的是,我们可以为std::vectorstd::shared_ptr提供自定义内存分配器,以便它们使用我们的自定义内存管理器。标准库中的其他容器也是如此。如果我们不提供自定义分配器,容器将使用默认的std::allocator<T>类。因此,为了使用我们的内存池,我们需要编写一个可以被容器使用的分配器。

自定义分配器在 C++社区中长期以来一直是一个备受争议的话题。许多自定义容器已经被实现,用于控制内存的管理,而不是使用具有自定义分配器的标准容器,这可能是有充分理由的。

然而,在 C++11 中,编写自定义分配器的支持和要求得到了改进,现在要好得多。在这里,我们将只关注 C++11 及以后的分配器。

C++11 中的最小分配器现在看起来是这样的:

template<typename T> 
struct Alloc {  
  using value_type = T; 
  Alloc(); 
  template<typename U> Alloc(const Alloc<U>&); 
  T* allocate(size_t n); 
  auto deallocate(T*, size_t) const noexcept -> void; 
}; 
template<typename T> 
auto operator==(const Alloc<T>&, const Alloc<T>&) -> bool;   
template<typename T> 
auto operator!=(const Alloc<T>&, const Alloc<T>&) -> bool; 

由于 C++11 的改进,现在代码量确实不那么多了。使用分配器的容器实际上使用了std::allocator_traits,它提供了合理的默认值,如果分配器省略了它们。我建议您查看std::allocator_traits,看看可以配置哪些特性以及默认值是什么。

通过使用malloc()free(),我们可以相当容易地实现一个最小的自定义分配器。在这里,我们将展示老式而著名的Mallocator,首次由 Stephan T. Lavavej 在博客文章中发布,以演示如何使用malloc()free()编写一个最小的自定义分配器。自那时以来,它已经更新为 C++11,使其更加精简。它是这样的:

template <class T>  
struct Mallocator { 

  using value_type = T; 
  Mallocator() = default;

  template <class U>  
  Mallocator(const Mallocator<U>&) noexcept {} 

  template <class U>  
  auto operator==(const Mallocator<U>&) const noexcept {  
    return true;  
  } 

  template <class U>  
  auto operator!=(const Mallocator<U>&) const noexcept {  
    return false;  
  } 

  auto allocate(size_t n) const -> T* { 
    if (n == 0) {  
      return nullptr;  
    } 
    if (n > std::numeric_limits<size_t>::max() / sizeof(T)) { 
      throw std::bad_array_new_length{}; 
    } 
    void* const pv = malloc(n * sizeof(T)); 
    if (pv == nullptr) {  
      throw std::bad_alloc{};  
    } 
    return static_cast<T*>(pv); 
  } 
  auto deallocate(T* p, size_t) const noexcept -> void { 
    free(p); 
  } 
}; 

Mallocator是一个无状态的分配器,这意味着分配器实例本身没有任何可变状态;相反,它使用全局函数进行分配和释放,即malloc()free()。无状态的分配器应该始终与相同类型的分配器相等。这表明使用Mallocator分配的内存也应该使用Mallocator释放,而不管Mallocator实例如何。无状态的分配器是最简单的分配器,但也是有限的,因为它依赖于全局状态。

为了将我们的内存池作为一个栈分配的对象使用,我们将需要一个有状态的分配器,它可以引用内存池实例。在这里,我们实现的内存池类真正开始变得有意义。比如,假设我们想在一个函数中使用标准容器进行一些处理。我们知道,大多数情况下,我们处理的数据量非常小,可以放在栈上。但一旦我们使用标准库中的容器,它们将从堆中分配内存,这在这种情况下会影响我们的性能。

使用栈来管理数据并避免不必要的堆分配的替代方案是什么?一个替代方案是构建一个自定义容器,它使用了我们为std::string所研究的小对象优化的变体。

也可以使用 Boost 中的容器,比如boost::container::small_vector,它基于 LLVM 的小向量。如果您还没有使用过,我们建议您查看:www.boost.org/doc/libs/1_74_0/doc/html/container/non_standard_containers.html

然而,另一种选择是使用自定义分配器,我们将在下面探讨。由于我们已经准备好了一个竞技场模板类,我们可以简单地在堆栈上创建一个竞技场实例,并让自定义分配器使用它进行分配。然后我们需要实现一个有状态的分配器,它可以持有对堆栈分配的竞技场对象的引用。

再次强调,我们将实现的这个自定义分配器是 Howard Hinnant 的short_alloc的简化版本:

template <class T, size_t N> 
struct ShortAlloc { 

  using value_type = T; 
  using arena_type = Arena<N>; 

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

  ShortAlloc(arena_type& arena) noexcept : arena_{&arena} { }

  template <class U>
  ShortAlloc(const ShortAlloc<U, N>& other) noexcept
      : arena_{other.arena_} {}

  template <class U> struct rebind {
    using other = ShortAlloc<U, N>;
  };
  auto allocate(size_t n) -> T* {
    return reinterpret_cast<T*>(arena_->allocate(n*sizeof(T)));
  }
  auto deallocate(T* p, size_t n) noexcept -> void {
    arena_->deallocate(reinterpret_cast<std::byte*>(p), n*sizeof(T));
  }
  template <class U, size_t M>
  auto operator==(const ShortAlloc<U, M>& other) const noexcept {
    return N == M && arena_ == other.arena_;
  }
  template <class U, size_t M>
  auto operator!=(const ShortAlloc<U, M>& other) const noexcept {
    return !(*this == other);
  }
  template <class U, size_t M> friend struct ShortAlloc;
private:
  arena_type* arena_;
}; 

分配器持有对竞技场的引用。这是分配器唯一的状态。函数allocate()deallocate()只是将它们的请求转发到竞技场。比较运算符确保ShortAlloc类型的两个实例使用相同的竞技场。

现在,我们实现的分配器和竞技场可以与标准容器一起使用,以避免动态内存分配。当我们使用小数据时,我们可以使用堆栈处理所有分配。让我们看一个使用std::set的例子:

int main() { 

  using SmallSet =  
    std::set<int, std::less<int>, ShortAlloc<int, 512>>; 

  auto stack_arena = SmallSet::allocator_type::arena_type{}; 
  auto unique_numbers = SmallSet{stack_arena}; 

  // Read numbers from stdin 
  auto n = int{}; 
  while (std::cin >> n)
    unique_numbers.insert(n); 

  // Print unique numbers  
  for (const auto& number : unique_numbers)
    std::cout << number << '\n'; 
} 

该程序从标准输入读取整数,直到达到文件结尾(在类 Unix 系统上为 Ctrl + D,在 Windows 上为 Ctrl + Z)。然后按升序打印唯一的数字。根据从stdin读取的数字数量,程序将使用堆栈内存或动态内存,使用我们的ShortAlloc分配器。

使用多态内存分配器

如果您已经阅读了本章,现在您知道如何实现一个自定义分配器,可以与包括标准库在内的任意容器一起使用。假设我们想要在我们的代码库中使用我们的新分配器来处理std::vector<int>类型的缓冲区的一些代码,就像这样:

void process(std::vector<int>& buffer) {
  // ...
}
auto some_func() {
  auto vec = std::vector<int>(64);
  process(vec); 
  // ...
} 

我们迫不及待地想尝试一下我们的新分配器,它正在利用堆栈内存,并尝试像这样注入它:

using MyAlloc = ShortAlloc<int, 512>;  // Our custom allocator
auto some_func() {
  auto arena = MyAlloc::arena_type();
  auto vec = std::vector<int, MyAlloc>(64, arena);
  process(vec);
  // ...
} 

在编译时,我们痛苦地意识到process()是一个期望std::vector<int>的函数,而我们的vec变量现在是另一种类型。GCC 给了我们以下错误:

error: invalid initialization of reference of type 'const std::vector<int>&' from expression of type 'std::vector<int, ShortAlloc<int, 512> > 

类型不匹配的原因是我们想要使用的自定义分配器MyAlloc作为模板参数传递给std::vector,因此成为我们实例化的类型的一部分。因此,std::vector<int>std::vector<int, MyAlloc>不能互换。

这可能对您正在处理的用例有影响,您可以通过使process()函数接受std::span或使其成为使用范围而不是要求std::vector的通用函数来解决这个问题。无论如何,重要的是要意识到,当使用标准库中的支持分配器的模板类时,分配器实际上成为类型的一部分。

std::vector<int>使用的是什么分配器?答案是std::vector<int>使用默认模板参数std::allocator。因此,编写std::vector<int>等同于std::vector<int, std::allocator<int>>。模板类std::allocator是一个空类,当它满足容器的分配和释放请求时,它使用全局new和全局delete。这也意味着使用空分配器的容器的大小比使用自定义分配器的容器要小:

std::cout << sizeof(std::vector<int>) << '\n';
// Possible output: 24
std::cout << sizeof(std::vector<int, MyAlloc>) << '\n';
// Possible output: 32 

检查来自 libc++的std::vector的实现,我们可以看到它使用了一个称为compressed pair的巧妙类型,这又基于空基类优化来摆脱通常由空类成员占用的不必要存储空间。我们不会在这里详细介绍,但如果您感兴趣,可以查看compressed_pair的 boost 版本,该版本在www.boost.org/doc/libs/1_74_0/libs/utility/doc/html/compressed_pair.html中有文档。

在 C++17 中,使用不同的分配器时出现了不同类型的问题,通过引入额外的间接层来解决;在std::pmr命名空间下的所有标准容器都使用相同的分配器,即std::pmr::polymorphic_allocator,它将所有分配/释放请求分派给一个内存资源类。因此,我们可以使用通用的多态内存分配器std::pmr::polymorphic_allocator,而不是编写新的自定义内存分配器,并在构造过程中使用新的自定义内存资源。内存资源类似于我们的Arena类,而polymorphic_allocator是额外的间接层,其中包含指向资源的指针。

以下图表显示了向量委托给其分配器实例,然后分配器再委托给其指向的内存资源的控制流程。

图 7.10:使用多态分配器分配内存

要开始使用多态分配器,我们需要将命名空间从std更改为std::pmr

auto v1 = std::vector<int>{};             // Uses std::allocator
auto v2 = std::pmr::vector<int>{/*...*/}; // Uses polymorphic_allocator 

编写自定义内存资源相对比较简单,特别是对于了解内存分配器和区域的知识。但为了实现我们想要的功能,我们甚至可能不需要编写自定义内存资源。C++已经为我们提供了一些有用的实现,在编写自己的实现之前,我们应该考虑一下。所有内存资源都派生自基类std::pmr::memory_resource。以下内存资源位于<memory_resource>头文件中:

  • std::pmr::monotonic_buffer_resource: 这与我们的Arena类非常相似。在我们创建许多寿命短的对象时,这个类是首选。只有在monotonic_buffer_resource实例被销毁时,内存才会被释放,这使得分配非常快。

  • std::pmr::unsynchronized_pool_resource: 这使用包含固定大小内存块的内存池(也称为“slabs”),避免了每个池内的碎片。每个池为特定大小的对象分配内存。如果您正在创建多个不同大小的对象,这个类可以很有益。这个内存资源不是线程安全的,除非提供外部同步,否则不能从多个线程使用。

  • std::pmr::synchronized_pool_resource: 这是unsynchronized_pool_resource的线程安全版本。

内存资源可以被链接。在创建内存资源的实例时,我们可以为其提供一个上游内存资源。如果当前资源无法处理请求(类似于我们在ShortAlloc中使用malloc()一旦我们的小缓冲区已满),或者当资源本身需要分配内存时(例如当monotonic_buffer_resource需要分配其下一个缓冲区时),将使用此上游资源。<memory_resource>头文件为我们提供了一些自由函数,返回指向全局资源对象的指针,这些在指定上游资源时非常有用:

  • std::pmr::new_delete_resource(): 使用全局的operator newoperator delete

  • std::pmr::null_memory_resource(): 一个资源,每当被要求分配内存时总是抛出std::bad_alloc

  • std::pmr::get_default_resource(): 返回一个全局默认的内存资源,可以在运行时通过set_default_resource()进行设置。初始默认资源是new_delete_resource()

让我们看看如何重新编写上一节中的示例,但这次使用std::pmr::set

int main() {
  auto buffer = std::array<std::byte, 512>{};
  auto resource = std::pmr::monotonic_buffer_resource{
    buffer.data(), buffer.size(), std::pmr::new_delete_resource()};
  auto unique_numbers = std::pmr::set<int>{&resource};
  auto n = int{};
  while (std::cin >> n) {
    unique_numbers.insert(n);
  }
  for (const auto& number : unique_numbers) {
    std::cout << number << '\n';
  }
} 

我们将一个栈分配的缓冲区传递给内存资源,然后为其提供从new_delete_resource()返回的对象作为上游资源,以便在缓冲区变满时使用。如果我们省略了上游资源,它将使用默认内存资源,在这种情况下,由于我们的代码不会更改默认内存资源,因此默认内存资源将是相同的。

实现自定义内存资源

实现自定义内存资源相当简单。我们需要公开继承自std::pmr::memory_resource,然后实现三个纯虚函数,这些函数将被基类(std::pmr::memory_resource)调用。让我们实现一个简单的内存资源,它打印分配和释放,然后将请求转发到默认内存资源:

class PrintingResource : public std::pmr::memory_resource {
public:
  PrintingResource() : res_{std::pmr::get_default_resource()} {}
private:
  void* do_allocate(std::size_t bytes, std::size_t alignment)override {
    std::cout << "allocate: " << bytes << '\n';
    return res_->allocate(bytes, alignment);
  }
  void do_deallocate(void* p, std::size_t bytes,
                     std::size_t alignment) override {
    std::cout << "deallocate: " << bytes << '\n';
    return res_->deallocate(p, bytes, alignment);
  }
  bool do_is_equal(const std::pmr::memory_resource& other) 
    const noexcept override {
    return (this == &other);
  }
  std::pmr::memory_resource* res_;  // Default resource
}; 

请注意,我们在构造函数中保存了默认资源,而不是直接从do_allocate()do_deallocate()中直接调用get_default_resource()。原因是在分配和释放之间的时间内,某人可能通过调用set_default_resource()来更改默认资源。

我们可以使用自定义内存资源来跟踪std::pmr容器所做的分配。以下是使用std::pmr::vector的示例:

auto res = PrintingResource{};
auto vec = std::pmr::vector<int>{&res};
vec.emplace_back(1);
vec.emplace_back(2); 

运行程序时可能的输出是:

allocate: 4
allocate: 8
deallocate: 4
deallocate: 8 

在使用多态分配器时需要非常小心的一点是,我们传递的是原始的非拥有指针到内存资源。这不是特定于多态分配器;我们在Arena类和ShortAlloc中也有同样的问题,但是在使用std::pmr容器时可能更容易忘记,因为这些容器使用相同的分配器类型。考虑以下示例:

auto create_vec() -> std::pmr::vector<int> {
  auto resource = PrintingResource{};
  auto vec = std::pmr::vector<int>{&resource}; // Raw pointer
  return vec;                                  // Ops! resource
}                                              // destroyed here 
auto vec = create_vec();
vec.emplace_back(1);                           // Undefined behavior 

由于资源在create_vec()结束时超出范围而被销毁,我们新创建的std::pmr::vector是无用的,很可能在使用时崩溃。

这结束了我们关于自定义内存管理的部分。这是一个复杂的主题,如果您想要使用自定义内存分配器来提高性能,我鼓励您在使用和/或实现自定义分配器之前仔细测量和分析应用程序中的内存访问模式。通常,应用程序中只有一小部分类或对象真正需要使用自定义分配器进行调整。同时,在应用程序中减少动态内存分配的数量或将对象组合在一起,可以对性能产生显著影响。

总结

本章涵盖了很多内容,从虚拟内存的基础开始,最终实现了可以被标准库中的容器使用的自定义分配器。了解程序如何使用内存是很重要的。过度使用动态内存可能成为性能瓶颈,您可能需要优化掉它。

在开始实现自己的容器或自定义内存分配器之前,请记住,您之前可能有很多人面临过与您可能面临的非常相似的内存问题。因此,很有可能您的正确工具已经存在于某个库中。构建快速、安全和健壮的自定义内存管理器是一个挑战。

在下一章中,您将学习如何从 C++概念中受益,以及如何使用模板元编程让编译器为我们生成代码。

第八章:编译时编程

C++具有在编译时评估表达式的能力,这意味着值在程序执行时已经计算出来。尽管自 C++98 以来就一直可以进行元编程,但由于其复杂的基于模板的语法,最初非常复杂。随着constexprif constexpr的引入,以及最近的 C++ 概念,元编程变得更类似于编写常规代码。

本章将简要介绍 C++中的编译时表达式求值以及它们如何用于优化。

我们将涵盖以下主题:

  • 使用 C++模板进行元编程以及如何在 C++20 中编写缩写函数模板

  • 在编译时使用类型特征检查和操作类型

  • 编译器评估的常量表达式

  • C++20 概念以及如何使用它们为我们的模板参数添加约束

  • 元编程的一些真实例子

我们将从介绍模板元编程开始。

介绍模板元编程

在编写常规 C++代码时,最终会将其转换为机器代码。另一方面,元编程允许我们编写能够将自身转换为常规 C++代码的代码。更一般地说,元编程是一种技术,我们编写能够转换或生成其他代码的代码。通过使用元编程,我们可以避免重复使用仅基于我们使用的数据类型略有不同的代码,或者通过预先计算在最终程序执行之前就可以知道的值来最小化运行时成本。没有什么能阻止我们使用其他语言生成 C++代码。例如,我们可以通过广泛使用预处理器宏或编写一个生成或修改 C++文件的 Python 脚本来进行元编程:

图 8.1:一个元程序生成将被编译成机器代码的常规 C++代码

尽管我们可以使用任何语言来生成常规代码,但是使用 C++,我们有特权在语言本身内部使用模板常量表达式编写元程序。C++编译器可以执行我们的元程序,并生成编译器将进一步转换为机器代码的常规 C++代码。

在 C++中直接使用模板和常量表达式进行元编程,而不是使用其他技术,有许多优势:

  • 我们不必解析 C++代码(编译器会为我们做这个工作)。

  • 在使用 C++模板元编程时,对分析和操作 C++类型有很好的支持。

  • 元程序的代码和常规非通用代码混合在 C++源代码中。有时,这可能使人难以理解哪些部分分别在运行时和编译时执行。然而,总的来说,这是使 C++元编程有效使用的一个非常重要的方面。

在其最简单和最常见的形式中,C++中的模板元编程用于生成接受不同类型的函数、值和类。当编译器使用模板生成类或函数时,称模板被实例化。编译器通过评估常量表达式来生成常量值:

图 8.2:C++中的编译时编程。将生成常规 C++代码的元程序是用 C++本身编写的。

这是一个相对简化的观点;没有什么规定 C++编译器必须以这种方式执行转换。然而,将 C++元编程视为在这两个不同阶段进行的是很有用的:

  • 初始阶段,模板和常量表达式生成函数、类和常量值的常规 C++代码。这个阶段通常被称为常量评估

  • 第二阶段,编译器最终将常规 C++代码编译成机器代码。

在本章后面,我将把从元编程生成的 C++代码称为常规 C++代码

在使用元编程时,重要的是要记住它的主要用例是制作出色的库,并因此隐藏用户代码中的复杂构造/优化。请注意,无论代码的内部多么复杂,都很重要将其隐藏在良好的接口后面,以便用户代码库易于阅读和使用。

让我们继续创建我们的第一个用于生成函数和类的模板。

创建模板

让我们看一个简单的pow()函数和一个Rectangle类。通过使用类型模板参数pow()函数和Rectangle类可以与任何整数或浮点类型一起使用。没有模板,我们将不得不为每种基本类型创建一个单独的函数/类。

编写元编程代码可能非常复杂;使其变得更容易的一点是想象预期的常规 C++代码的意图。

下面是一个简单函数模板的示例:

// pow_n accepts any number type 
template <typename T> 
auto pow_n(const T& v, int n) { 
  auto product = T{1}; 
  for (int i = 0; i < n; ++i) { 
    product *= v; 
  }
  return product; 
} 

使用此函数将生成一个返回类型取决于模板参数类型的函数:

auto x = pow_n<float>(2.0f, 3); // x is a float 
auto y = pow_n<int>(3, 3);      // y is an int 

显式模板参数类型(在这种情况下为floatint)可以(最好)省略,而编译器可以自行解决这个问题。这种机制称为模板参数推断,因为编译器推断模板参数。以下示例将导致与先前显示的相同的模板实例化:

auto x = pow_n(2.0f, 3);  // x is a float 
auto y = pow_n(3, 3);     // y is an int 

相应地,可以定义一个简单的类模板如下:

// Rectangle can be of any type 
template <typename T> 
class Rectangle { 
public: 
  Rectangle(T x, T y, T w, T h) : x_{x}, y_{y}, w_{w}, h_{h} {} 
  auto area() const { return w_ * h_; } 
  auto width() const { return w_; } 
  auto height() const { return h_; } 
private:
  T x_{}, y_{}, w_{}, h_{}; 
}; 

当使用类模板时,我们可以明确指定模板应为其生成代码的类型,如下所示:

auto r1 = Rectangle<float>{2.0f, 2.0f, 4.0f, 4.0f}; 

但也可以从类模板参数推断CTAD)中受益,并让编译器为我们推断参数类型。以下代码将实例化一个Rectangle<int>

auto r2 = Rectangle{-2, -2, 4, 4};   // Rectangle<int> 

然后,函数模板可以接受一个Rectangle对象,其中矩形的尺寸是使用任意类型T定义的,如下所示:

template <typename T> 
auto is_square(const Rectangle<T>& r) { 
  return r.width() == r.height(); 
} 

类型模板参数是最常见的模板参数。接下来,您将看到如何使用数值参数而不是类型参数。

使用整数作为模板参数

除了一般类型,模板还可以是其他类型,例如整数类型和浮点类型。在下面的示例中,我们将在模板中使用int,这意味着编译器将为每个唯一的整数传递的模板参数生成一个新函数:

template <int N, typename T> 
auto const_pow_n(const T& v) { 
  auto product = T{1}; 
  for (int i = 0; i < N; ++i) { 
    product *= v; 
  }
  return product; 
} 

以下代码将强制编译器实例化两个不同的函数:一个平方值,一个立方值:

auto x2 = const_pow_n<2>(4.0f);   // Square
auto x3 = const_pow_n<3>(4.0f);   // Cube 

请注意模板参数N和函数参数v之间的差异。对于每个N的值,编译器都会生成一个新函数。但是,v作为常规参数传递,因此不会导致生成新函数。

提供模板的特化

默认情况下,每当我们使用新参数的模板时,编译器将生成常规的 C++代码。但也可以为模板参数的某些值提供自定义实现。例如,假设我们希望在使用整数并且N的值为2时,提供我们的const_pow_n()函数的常规 C++代码。我们可以为这种情况编写一个模板特化,如下所示:

template<>
auto const_pow_n<2, int>(const int& v) {
  return v * v;
} 

对于函数模板,当编写特化时,我们需要固定所有模板参数。例如,不可能只指定N的值,而让类型参数T未指定。但是,对于类模板,可以只指定模板参数的子集。这称为部分模板特化。编译器将首先选择最具体的模板。

我们不能对函数应用部分模板特化的原因是函数可以重载(而类不能)。如果允许混合重载和部分特化,那将很难理解。

编译器如何处理模板函数

当编译器处理模板函数时,它会构造一个展开了模板参数的常规函数。以下代码将使编译器生成常规函数,因为它使用了模板:

auto a = pow_n(42, 3);          // 1\. Generate new function
auto b = pow_n(42.f, 2);        // 2\. Generate new function
auto c = pow_n(17.f, 5);        // 3.
auto d = const_pow_n<2>(42.f);  // 4\. Generate new function
auto e = const_pow_n<2>(99.f);  // 5.
auto f = const_pow_n<3>(42.f);  // 6\. Generate new function 

因此,当编译时,与常规函数不同,编译器将为每组唯一的模板参数生成新函数。这意味着它相当于手动创建了四个不同的函数,看起来像这样:

auto pow_n__float(float v, int n) {/*...*/}   // Used by: 1
auto pow_n__int(int v, int n) {/*...*/}       // Used by: 2 and 3
auto const_pow_n__2_float (float v) {/*...*/} // Used by: 4 and 5
auto const_pow_n__3_float(float v) {/*...*/}  // Used by: 6 

这对于理解元编程的工作原理非常重要。模板代码生成非模板化的 C++代码,然后作为常规代码执行。如果生成的 C++代码无法编译,错误将在编译时被捕获。

缩写函数模板

C++20 引入了一种新的缩写语法,用于编写函数模板,采用了通用 lambda 使用的相同风格。通过使用auto作为函数参数类型,我们实际上创建的是一个函数模板,而不是一个常规函数。回想一下我们最初的pow_n()模板,它是这样声明的:

template <typename T>
auto pow_n(const T& v, int n) { 
  // ... 

使用缩写的函数模板语法,我们可以使用auto来声明它:

auto pow_n(const auto& v, int n) { // Declares a function template
  // ... 

这两个版本之间的区别在于缩写版本没有变量v的显式占位符。由于我们在实现中使用了占位符T,这段代码将不幸地无法编译:

auto pow_n(const auto& v, int n) {
  auto product = T{1}; // Error: What is T?
  for (int i = 0; i < n; ++i) { 
    product *= v; 
  } 
  return product;
} 

为了解决这个问题,我们可以使用decltype指定符。

使用 decltype 接收变量的类型

decltype指定符用于检索变量的类型,并且在没有显式类型名称可用时使用。

有时,我们需要一个显式的类型占位符,但没有可用的,只有变量名。这在我们之前实现pow_n()函数时发生过,当使用缩写的函数模板语法时。

让我们通过修复pow_n()的实现来看一个使用decltype的例子:

auto pow_n(const auto& v, int n) {
  auto product = decltype(v){1};   // Instead of T{1}
  for (int i = 0; i < n; ++i) { product *= v; } 
  return product;
} 

尽管这段代码编译并工作,但我们有点幸运,因为v的类型实际上是一个const引用,而不是我们想要的变量product的类型。我们可以通过使用从左到右的声明样式来解决这个问题。但是,试图将定义产品的行重写为看起来相同的东西会揭示一个问题:

auto pow_n(const auto& v, int n) {
  decltype(v) product{1};
  for (int i = 0; i < n; ++i) { product *= v; } // Error!
  return product;
} 

现在,我们得到了一个编译错误,因为product是一个const引用,可能无法分配新值。

我们真正想要的是从变量v的类型中去掉const引用,当定义变量product时。我们可以使用一个方便的模板std::remove_cvref来实现这个目的。我们的product的定义将如下所示:

typename std::remove_cvref<decltype(v)>::type product{1}; 

哦!在这种特殊情况下,也许最好还是坚持最初的template <typename T>语法。但现在,您已经学会了在编写通用 C++代码时如何使用std::remove_cvrefdecltype,这是一个常见的模式。

在 C++20 之前,在通用 lambda 的主体中经常看到decltype。然而,现在可以通过向通用 lambda 添加显式模板参数来避免相当不方便的decltype

auto pow_n = []<class T>(const T& v, int n) { 
  auto product = T{1};
  for (int i = 0; i < n; ++i) { product *= v; }
  return product;
}; 

在 lambda 的定义中,我们写<class T>以获取一个可以在函数体内使用的参数类型的标识符。

也许需要一些时间来习惯使用decltype和操纵类型的工具。也许std::remove_cvref一开始看起来有点神秘。它是<type_traits>头文件中的一个模板,我们将在下一节中进一步了解它。

类型特征

在进行模板元编程时,您可能经常会发现自己处于需要在编译时获取有关您正在处理的类型的信息的情况。在编写常规(非泛型)C++代码时,我们使用完全了解的具体类型,但在编写模板时情况并非如此;具体类型直到编译器实例化模板时才确定。类型特征允许我们提取有关我们模板处理的类型的信息,以生成高效和正确的 C++代码。

为了提取有关模板类型的信息,标准库提供了一个类型特征库,该库在<type_traits>头文件中可用。所有类型特征都在编译时评估。

类型特征类别

有两类类型特征:

  • 返回关于类型信息的类型特征,作为布尔值或整数值。

  • 返回新类型的类型特征。这些类型特征也被称为元函数。

第一类返回truefalse,取决于输入,并以_v结尾(代表值)。

_v后缀是在 C++17 中添加的。如果您的库实现不提供类型特征的_v后缀,则可以使用旧版本std::is_floating_point<float>::value。换句话说,删除_v扩展并在末尾添加::value

以下是使用类型特征对基本类型进行编译时类型检查的一些示例:

auto same_type = std::is_same_v<uint8_t, unsigned char>; 
auto is_float_or_double = std::is_floating_point_v<decltype(3.f)>; 

类型特征也可以用于用户定义的类型:

class Planet {};
class Mars : public Planet {};
class Sun {};
static_assert(std::is_base_of_v<Planet, Mars>);
static_assert(!std::is_base_of_v<Planet, Sun>); 

类型特征的第二类返回一个新类型,并以_t结尾(代表类型)。当处理指针和引用时,这些类型特征转换(或元函数)非常方便:

// Examples of type traits which transforms types
using value_type = std::remove_pointer_t<int*>;  // -> int
using ptr_type = std::add_pointer_t<float>;      // -> float* 

我们之前使用的类型特征std::remove_cvref也属于这个类别。它从类型中移除引用部分(如果有)以及constvolatile限定符。std::remove_cvref是在 C++20 中引入的。在那之前,通常使用std::decay来执行此任务。

使用类型特征

如前所述,所有类型特征都在编译时评估。例如,以下函数如果值大于或等于零则返回1,否则返回-1,对于无符号整数可以立即返回1,如下所示:

template<typename T>
auto sign_func(T v) -> int {
  if (std::is_unsigned_v<T>) { 
    return 1; 
  } 
  return v < 0 ? -1 : 1; 
} 

由于类型特征在编译时评估,因此当使用无符号整数和有符号整数调用时,编译器将生成下表中显示的代码:

与无符号整数一起使用... ...生成的函数:

|

auto unsigned_v = uint32_t{42};
auto sign = sign_func(unsigned_v); 

|

int sign_func(uint32_t v) {
  if (true) { 
    return 1; 
  } 
  return v < 0 ? -1 : 1; 
} 

|

与有符号整数一起使用... ...生成的函数:

|

auto signed_v = int32_t{-42}; 
auto sign = sign_func(signed_v); 

|

int sign_func(int32_t v) {
  if (false) { 
    return 1; 
  } 
  return v < 0 ? -1 : 1; 
} 

|

表 8.1:基于我们传递给sign_func()的类型(在左列),编译器生成不同的函数(在右列)。

接下来,让我们谈谈常量表达式。

使用常量表达式进行编程

使用constexpr关键字前缀的表达式告诉编译器应在编译时评估该表达式:

constexpr auto v = 43 + 12; // Constant expression 

constexpr关键字也可以与函数一起使用。在这种情况下,它告诉编译器某个函数打算在编译时评估,如果满足所有允许进行编译时评估的条件,则会在运行时执行,就像常规函数一样。

constexpr函数有一些限制;不允许执行以下操作:

  • 处理本地静态变量

  • 处理thread_local变量

  • 调用任何函数,本身不是constexpr函数

使用constexpr关键字,编写编译时评估的函数与编写常规函数一样容易,因为它的参数是常规参数而不是模板参数。

考虑以下constexpr函数:

constexpr auto sum(int x, int y, int z) { return x + y + z; } 

让我们这样调用函数:

constexpr auto value = sum(3, 4, 5); 

由于sum()的结果用于常量表达式,并且其所有参数都可以在编译时确定,因此编译器将生成以下常规的 C++代码:

const auto value = 12; 

然后像往常一样将其编译成机器代码。换句话说,编译器评估constexpr函数并生成常规的 C++代码,其中计算结果。

如果我们调用sum()并将结果存储在未标记为constexpr的变量中,编译器可能(很可能)在编译时评估sum()

auto value = sum(3, 4, 5); // value is not constexpr 

总之,如果从常量表达式调用constexpr函数,并且其所有参数都是常量表达式,那么它保证在编译时评估。

运行时上下文中的 Constexpr 函数

在前面的例子中,编译器在编译时已知的值(345)是已知的,但是constexpr函数如何处理直到运行时才知道值的变量?如前一节所述,constexpr是编译器的指示,表明在某些条件下,函数可以在编译时评估。如果直到运行时调用时才知道值的变量,它们将像常规函数一样被评估。

在下面的例子中,xyz的值是在运行时由用户提供的,因此编译器无法在编译时计算总和:

int x, y, z; 
std::cin >> x >> y >> z;      // Get user input
auto value = sum(x, y, z); 

如果我们根本不打算在运行时使用sum(),我们可以通过将其设置为立即函数来禁止这种用法。

使用consteval声明立即函数

constexpr函数可以在运行时或编译时调用。如果我们想限制函数的使用,使其只在编译时调用,我们可以使用关键字consteval而不是constexpr。假设我们想禁止在运行时使用sum()。使用 C++20,我们可以通过以下代码实现:

consteval auto sum(int x, int y, int z) { return x + y + z; } 

使用consteval声明的函数称为立即函数,只能生成常量。如果我们想调用sum(),我们需要在常量表达式中调用它,否则编译将失败:

constexpr auto s = sum(1, 2, 3); // OK
auto x = 10;
auto s = sum(x, 2, 3);           // Error, expression is not const 

如果我们尝试在编译时使用参数不明确的sum(),编译器也会报错:

int x, y, z; 
std::cin >> x >> y >> z; 
constexpr auto s = sum(x, y, z); // Error 

接下来讨论if constexpr语句。

if constexpr 语句

if constexpr语句允许模板函数在同一函数中在编译时评估不同的作用域(也称为编译时多态)。看看下面的例子,其中一个名为speak()的函数模板尝试根据类型区分成员函数:

struct Bear { auto roar() const { std::cout << "roar\n"; } }; 
struct Duck { auto quack() const { std::cout << "quack\n"; } }; 
template <typename Animal> 
auto speak(const Animal& a) { 
  if (std::is_same_v<Animal, Bear>) { a.roar(); } 
  else if (std::is_same_v<Animal, Duck>) { a.quack(); } 
} 

假设我们编译以下行:

auto bear = Bear{};
speak(bear); 

然后编译器将生成一个类似于这样的speak()函数:

auto speak(const Bear& a) {
  if (true) { a.roar(); }
  else if (false) { a.quack(); } // This line will not compile
} 

如您所见,编译器将保留对成员函数quack()的调用,然后由于Bear不包含quack()成员函数而无法编译。这甚至会发生在quack()成员函数由于else if (false)语句而永远不会被执行的情况下。

为了使speak()函数无论类型如何都能编译,我们需要告诉编译器,如果if语句为false,我们希望完全忽略作用域。方便的是,这正是if constexpr所做的。

以下是我们如何编写speak()函数,以便处理BearDuck,即使它们没有共同的接口:

template <typename Animal> 
auto speak(const Animal& a) { 
  if constexpr (std::is_same_v<Animal, Bear>) { a.roar(); } 
  else if constexpr (std::is_same_v<Animal, Duck>) { a.quack(); } 
} 

当使用Animal == Bear调用speak()时,如下所示:

auto bear = Bear{};
speak(bear); 

编译器生成以下函数:

auto speak(const Bear& animal) { animal.roar(); } 

当使用Animal == Duck调用speak()时,如下所示:

auto duck = Duck{};
speak(duck); 

编译器生成以下函数:

auto speak(const Duck& animal) { animal.quack(); } 

如果使用任何其他原始类型调用speak(),例如Animal == int,如下所示:

speak(42); 

编译器生成一个空函数:

auto speak(const int& animal) {} 

与常规的if语句不同,编译器现在能够生成多个不同的函数:一个使用Bear,另一个使用Duck,如果类型既不是Bear也不是Duck,则生成最后一个。如果我们想让这第三种情况成为编译错误,我们可以通过添加一个带有static_assertelse语句来实现:

template <typename Animal> 
auto speak(const Animal& a) { 
  if constexpr (std::is_same_v<Animal, Bear>) { a.roar(); } 
  else if constexpr (std::is_same_v<Animal, Duck>) { a.quack(); }
  else { static_assert(false); } // Trig compilation error
} 

我们稍后会更多地讨论static_assert的用处。

如前所述,这里使用constexpr的方式可以称为编译时多态。那么,它与运行时多态有什么关系呢?

与运行时多态的比较

顺便说一句,如果我们使用传统的运行时多态来实现前面的例子,使用继承和虚函数来实现相同的功能,实现将如下所示:

struct AnimalBase {
  virtual ~AnimalBase() {}
  virtual auto speak() const -> void {}
};
struct Bear : public AnimalBase {
  auto roar() const { std::cout << "roar\n"; } 
  auto speak() const -> void override { roar(); }
};
struct Duck : public AnimalBase {
  auto quack() const { std::cout << "quack\n"; }
  auto speak() const -> void override { quack(); }
}; 
auto speak(const AnimalBase& a) { 
  a.speak();
} 

对象必须使用指针或引用进行访问,并且类型在运行时推断,这导致性能损失与编译时版本相比,其中应用程序执行时一切都是可用的。下面的图像显示了 C++中两种多态类型之间的区别:

图 8.3:运行时多态由虚函数支持,而编译时多态由函数/操作符重载和 if constexpr 支持。

现在,我们将继续看看如何使用if constexpr来做一些更有用的事情。

使用 if constexpr 的通用模数函数示例

这个例子将向您展示如何使用if constexpr来区分运算符和全局函数。在 C++中,%运算符用于获取整数的模,而std::fmod()用于浮点类型。假设我们想要将我们的代码库泛化,并创建一个名为generic_mod()的通用模数函数。

如果我们使用常规的if语句来实现generic_mod(),如下所示:

template <typename T> 
auto generic_mod(const T& v, const T& n) -> T {
  assert(n != 0);
  if (std::is_floating_point_v<T>) { return std::fmod(v, n); }
  else { return v % n; }
} 

如果以T == float调用它,它将失败,因为编译器将生成以下函数,这将无法编译通过:

auto generic_mod(const float& v, const float& n) -> float {
  assert(n != 0);
  if (true) { return std::fmod(v, n); }
  else { return v % n; } // Will not compile
} 

尽管应用程序无法到达它,编译器将生成return v % n;这一行,这与float不兼容。编译器不在乎应用程序是否能到达它——因为它无法为其生成汇编代码,所以它将无法编译通过。

与前面的例子一样,我们将if语句更改为if constexpr语句:

template <typename T> 
auto generic_mod(const T& v, const T& n) -> T { 
  assert(n != 0);
  if constexpr (std::is_floating_point_v<T>) {
    return std::fmod(v, n);
  } else {                 // If T is a floating point,
    return v % n;          // this code is eradicated
  }
} 

现在,当使用浮点类型调用函数时,它将生成以下函数,其中v % n操作被消除:

auto generic_mod(const float& v, const float& n) -> float { 
  assert(n != 0);
  return std::fmod(v, n); 
} 

运行时的assert()告诉我们,如果第二个参数为 0,我们不能调用这个函数。

在编译时检查编程错误

Assert 语句是验证代码库中调用者和被调用者之间不变性和契约的简单但非常强大的工具(见第二章Essential C++ Techniques)。使用assert()可以在执行程序时检查编程错误。但我们应该始终努力尽早检测错误,如果有常量表达式,我们可以使用static_assert()在编译程序时捕获编程错误。

使用 assert 在运行时触发错误

回顾pow_n()的模板版本。假设我们想要阻止它使用负指数(n值)进行调用。在运行时版本中,其中n是一个常规参数,我们可以添加一个运行时断言来阻止这种情况:

template <typename T> 
auto pow_n(const T& v, int n) { 
  assert(n >= 0); // Only works for positive numbers 
  auto product = T{1}; 
  for (int i = 0; i < n; ++i) {
    product *= v; 
  }
  return product; 
} 

如果函数被调用时n的值为负数,程序将中断并告知我们应该从哪里开始寻找错误。这很好,但如果我们能在编译时而不是运行时跟踪这个错误会更好。

使用static_assert在编译时触发错误

如果我们对模板版本做同样的事情,我们可以利用static_assert()。与常规的 assert 不同,static_assert()声明如果条件不满足将拒绝编译。因此,最好是在编译时中断构建,而不是在运行时中断程序。在下面的例子中,如果模板参数N是一个负数,static_assert()将阻止函数编译:

template <int N, typename T>
auto const_pow_n(const T& v) {
  static_assert(N >= 0, "N must be positive"); 
  auto product = T{1}; 
  for (int i = 0; i < N; ++i) { 
    product *= v; 
  } 
  return product; 
}
auto x = const_pow_n<5>(2);  // Compiles, N is positive
auto y = const_pow_n<-1>(2); // Does not compile, N is negative 

换句话说,对于常规变量,编译器只知道类型,不知道它包含什么。对于编译时值,编译器既知道类型又知道值。这使得编译器能够计算其他编译时值。

我们可以(应该)使用无符号整数而不是使用int并断言它是非负的。在这个例子中,我们只是使用有符号的int来演示assert()static_assert()的使用。

使用编译时断言是一种在编译时检查约束的方法。这是一个简单但非常有用的工具。在过去几年中,C++的编译时编程支持取得了一些非常令人兴奋的进展。现在,我们将继续介绍 C++20 中的一个最重要的特性,将约束检查提升到一个新的水平。

约束和概念

到目前为止,我们已经涵盖了写 C++元编程的一些重要技术。您已经看到模板如何利用类型特征库为我们生成具体的类和函数。此外,您已经看到了constexprconstevalif constexpr的使用可以帮助我们将计算从运行时移动到编译时。通过这种方式,我们可以在编译时检测编程错误,并编写具有较低运行时成本的程序。这很棒,但在编写和使用 C++中的通用代码方面仍有很大的改进空间。我们尚未解决的一些问题包括:

  1. 接口太通用。当使用具有任意类型的模板时,很难知道该类型的要求是什么。如果我们只检查模板接口,这使得模板难以使用。相反,我们必须依赖文档或深入到模板的实现中。

  2. 类型错误由编译器晚期捕获。编译器最终会在编译常规 C++代码时检查类型,但错误消息通常很难解释。相反,我们希望在实例化阶段捕获类型错误。

  3. 无约束的模板参数使元编程变得困难。到目前为止,在本章中我们编写的代码都使用了无约束的模板参数,除了一些静态断言。这对于小例子来说是可以管理的,但如果我们能够像类型系统帮助我们编写正确的非通用 C++代码一样,获得更有意义的类型,那么编写和推理我们的元编程将会更容易。

  4. 使用if constexpr可以进行条件代码生成(编译时多态),但在较大规模上很快变得难以阅读和编写。

正如您将在本节中看到的,C++概念以一种优雅而有效的方式解决了这些问题,引入了两个新关键字:conceptrequires。在探讨约束和概念之前,我们将花一些时间考虑没有概念的模板元编程的缺点。然后,我们将使用约束和概念来加强我们的代码。

Point2D 模板的无约束版本

假设我们正在编写一个处理二维坐标系的程序。我们有一个类模板,表示具有xy坐标的点,如下所示:

template <typename T>
class Point2D {
public:
  Point2D(T x, T y) : x_{x}, y_{y} {}
  auto x() { return x_; }
  auto y() { return y_; }
  // ...
private:
  T x_{};
  T y_{};
}; 

假设我们需要找到两点p1p2之间的欧几里德距离,如下所示:

图 8.4:找到 p1 和 p2 之间的欧几里得距离

为了计算距离,我们实现了一个接受两个点并使用勾股定理的自由函数(这里实际的数学并不重要):

auto dist(auto p1, auto p2) {
  auto a = p1.x() - p2.x();
  auto b = p1.y() - p2.y();
  return std::sqrt(a*a + b*b);
} 

一个小的测试程序验证了我们可以用整数实例化Point2D模板,并计算两点之间的距离:

int main() {
  auto p1 = Point2D{2, 2};
  auto p2 = Point2D{6, 5};
  auto d = dist(p1, p2);
  std::cout << d;
} 

这段代码编译和运行都很好,并在控制台输出5

通用接口和糟糕的错误消息

在继续之前,让我们稍微偏离一下,对函数模板dist()进行一段时间的反思。假设我们无法轻松访问dist()的实现,只能读取接口:

auto dist(auto p1, auto p2) // Interface part 

我们可以说返回类型和p1p2的类型有什么?实际上几乎没有——因为p1p2完全未受约束dist()的接口对我们来说没有透露任何信息。这并不意味着我们可以将任何东西传递给dist(),因为最终生成的常规 C++代码必须编译。

例如,如果我们尝试用两个整数而不是Point2D对象来实例化我们的dist()模板,就像这样:

 auto d = dist(3, 4); 

编译器将很乐意生成一个常规的 C++函数,类似于这样:

auto dist(int p1, int p2) {
  auto a = p1.x() – p2.x();  // Will generate an error:
  auto b = p1.y() – p2.y();  // int does not have x() and y()
  return std::sqrt(a*a + b*b);
} 

当编译器检查常规的 C++代码时,错误将在稍后被捕获。当尝试用两个整数实例化dist()时,Clang 生成以下错误消息:

error: member reference base type 'int' is not a structure or union
auto a = p1.x() – p2.y(); 

这个错误消息是指dist()实现,这是调用函数dist()的调用者不需要知道的东西。这是一个微不足道的例子,但是尝试解释由于向复杂的模板库提供错误类型而引起的错误消息可能是一个真正的挑战。

更糟糕的是,如果我们真的很不幸,通过提供根本没有意义的类型来完成整个编译。在这种情况下,我们正在用const char*实例化Point2D

int main() {
  auto from = Point2D{"2.0", "2.0"}; // Ouch!
  auto to = Point2D{"6.0", "5.0"};   // Point2D<const char*>
  auto d = dist(from, to);
  std::cout << d;
} 

它编译并运行,但输出可能不是我们所期望的。我们希望在过程的早期阶段捕获这些类型的错误,这是我们可以通过使用约束和概念来实现的,如下图所示:

图 8.5:使用约束和概念可以在实例化阶段捕获类型错误

稍后,您将看到如何使此代码更具表现力,以便更容易正确使用并更难滥用。我们将通过向我们的代码添加概念和约束来实现这一点。但首先,我将快速概述如何定义和使用概念。

约束和概念的语法概述

本节是对约束和概念的简要介绍。我们不会在本书中完全覆盖它们,但我会为您提供足够的材料来提高生产力。

定义新概念

使用您已经熟悉的类型特征,可以轻松地定义新概念。以下示例使用关键字concept定义了概念FloatingPoint

template <typename T>
concept FloatingPoint = std::is_floating_point_v<T>; 

赋值表达式的右侧是我们可以指定类型T的约束的地方。还可以使用||(逻辑或)和&&(逻辑与)来组合多个约束。以下示例使用||将浮点数和整数组合成Number概念:

template <typename T>
concept Number = FloatingPoint<T> || std::is_integral_v<T>; 

您将注意到,还可以使用右侧已定义的概念构建概念。标准库包含一个<concepts>头文件,其中定义了许多有用的概念,例如std::floating_point(我们应该使用它而不是定义自己的)。

此外,我们可以使用requires关键字来添加一组语句,这些语句应该添加到我们的概念定义中。例如,这是来自 Ranges 库的概念std::range的定义:

template<typename T>
concept range = requires(T& t) {
  ranges::begin(t);
  ranges::end(t);
}; 

简而言之,这个概念说明了范围是我们可以传递给std::ranges::begin()std::ranges::end()的东西。

可以编写比这更复杂的requires子句,稍后您将看到更多内容。

使用概念约束类型

我们可以通过使用requires关键字向模板参数类型添加约束。以下模板只能使用std::integral概念实例化整数类型的参数:

template <typename T>
requires std::integral<T>
auto mod(T v, T n) { 
  return v % n;
} 

在定义类模板时也可以使用相同的技术:

template <typename T>
requires std::integral<T>
struct Foo {
  T value;
}; 

另一种语法允许我们以更紧凑的方式编写,通过直接用概念替换typename

template <std::integral T>
auto mod(T v, T n) { 
  return v % n;
} 

这种形式也可以用于类模板:

template <std::integral T>
struct Foo {
  T value;
}; 

如果我们想在定义函数模板时使用缩写的函数模板形式,我们可以在auto关键字前面添加概念:

auto mod(std::integral auto v, std::integral auto n) {
  return v % n;
} 

返回类型也可以通过使用概念来约束:

std::integral auto mod(std::integral auto v, std::integral auto n) {
  return v % n;
} 

正如你所看到的,有许多方法可以指定相同的事情。缩写形式与概念的结合确实使有限函数模板的阅读和编写变得非常容易。C++概念的另一个强大特性是以清晰和表达性的方式重载函数。

函数重载

回想一下我们之前使用if constexpr实现的generic_mod()函数。它看起来像这样:

template <typename T> 
auto generic_mod(T v, T n) -> T { 
  if constexpr (std::is_floating_point_v<T>) {
    return std::fmod(v, n);
  } else {
    return v % n;
  } 
} 

通过使用概念,我们可以重载一个函数模板,类似于我们如果编写了一个常规的 C++函数:

template <std::integral T>
auto generic_mod(T v, T n) -> T {             // Integral version
  return v % n;
}
template <std::floating_point T>
auto generic_mod(T v, T n) -> T {             // Floating point version
  return std::fmod(v, n);
} 

有了你对约束和概念的新知识,现在是时候回到我们的Point2D模板的例子,看看它如何改进。

Point2D 模板的约束版本

现在你知道如何定义和使用概念了,让我们通过编写一个更好的模板Point2Ddist()来使用它们。记住,我们的目标是一个更具表现力的接口,并且使由无关参数类型引起的错误在模板实例化时出现。

我们将首先创建一个算术类型的概念:

template <typename T>
concept Arithmetic = std::is_arithmetic_v<T>; 

接下来,我们将创建一个名为Point的概念,它定义了一个点应该具有成员函数x()y()返回相同类型,并且这个类型应该支持算术操作:

template <typename T>
concept Point = requires(T p) {
  requires std::is_same_v<decltype(p.x()), decltype(p.y())>;
  requires Arithmetic<decltype(p.x())>;
}; 

这个概念现在可以通过显式约束使dist()的接口更好:

auto dist(Point auto p1, Point auto p2) {
  // Same as before ... 

这看起来真的很有希望,所以让我们也对我们的返回类型添加一个约束。虽然Point2D可能被实例化为整数类型,但我们知道距离可以是浮点数。标准库中的概念std::floating_point非常适合这个。这是dist()的最终版本:

std::floating_point auto dist(Point auto p1, Point auto p2) { 
  auto a = p1.x() - p2.x();
  auto b = p1.y() - p2.y();
  return std::sqrt(a*a + b*b);
} 

我们的接口现在更加描述性,当我们尝试用错误的参数类型实例化它时,我们将在实例化阶段而不是最终编译阶段获得错误。

现在我们应该对我们的Point2D模板做同样的事情,以避免有人意外地用它实例化它不打算处理的类型。例如,我们希望阻止有人用const char*实例化Point2D类,就像这样:

auto p1 = Point2D{"2.0", "2.0"}; // How can we prevent this? 

我们已经创建了Arithmetic概念,我们可以在这里使用它来在Point2D的模板参数中放置约束。这是我们如何做到的:

template <Arithmetic T> // T is now constrained!
class Point2D {
public:
  Point2D(T x, T y) : x_{x}, y_{y} {}
  auto x() { return x_; }
  auto y() { return y_; }
  // ...
private:
  T x_{};
  T y_{};
}; 

我们唯一需要改变的是指定类型T应该支持概念Arithmetic指定的操作。尝试使用const char*实例化模板现在将生成一个直接的错误消息,而编译器尝试实例化Point2D<const char*>类。

向你的代码添加约束

概念的实用性远远超出了模板元编程。这是 C++20 的一个基本特性,改变了我们使用概念而不是具体类型或完全无约束的变量声明auto来编写和推理代码的方式。

概念非常类似于类型(如intfloatPlot2D<int>)。类型和概念都指定了对象上支持的一组操作。通过检查类型或概念,我们可以确定某些对象如何构造、移动、比较和通过成员函数访问等。然而,一个重大的区别是,概念并不说任何关于对象如何存储在内存中,而类型除了其支持的操作集之外还提供了这些信息。例如,我们可以在类型上使用sizeof运算符,但不能在概念上使用。

通过概念和auto,我们可以声明变量而无需明确指出确切的类型,但仍然非常清楚地表达我们的意图。看一下以下代码片段:

const auto& v = get_by_id(42); // What can I do with v? 

大多数时候,当我们遇到这样的代码时,我们更感兴趣的是我们可以在v上执行哪些操作,而不是知道确切的类型。在auto前面添加一个概念会产生不同的效果:

const Person auto& v = get_by_id(42);
v.get_name(); 

几乎可以在几乎所有可以使用关键字 auto 的上下文中使用概念:局部变量、返回值、函数参数等等。在我们的代码中使用概念使得阅读更加容易。在撰写本书时(2020 年中),已经建立的 C++ IDE 中目前还没有对概念的额外支持。然而,代码补全以及其他基于概念的有用编辑器功能很快就会可用,使得 C++ 编码更加有趣和安全。

标准库中的概念

C++20 还包括一个新的 <concepts> 头文件,其中包含预定义的概念。您已经看到其中一些概念的作用。许多概念都是基于类型特性库中的特性。然而,有一些基本概念以前没有用特性表达。其中最重要的是比较概念,如 std::equality_comparablestd::totally_ordered,以及对象概念,如 std::movablestd::copyablestd::regularstd::semiregular。我们不会在标准库的概念上花费更多时间,但在开始定义自己的概念之前,请记住将它们牢记在心。在正确的泛化级别上定义概念并不是件容易的事,通常明智的做法是基于已经存在的概念定义新的概念。

让我们通过查看 C++ 中一些实际的元编程示例来结束本章。

元编程的实际例子

高级元编程可能看起来非常学术化,因此为了展示其有用性,让我们看一些不仅演示元编程语法的例子,还演示它如何在实践中使用。

示例 1:创建一个通用的安全转换函数

在 C++ 中进行数据类型转换时,有多种不同的方式会出错:

  • 如果将值转换为比特长度较低的整数类型,可能会丢失一个值。

  • 如果将负值转换为无符号整数,可能会丢失一个值。

  • 如果从指针转换为任何其他整数而不是 uintptr_t,正确的地址可能会变得不正确。这是因为 C++ 仅保证 uintptr_t 是唯一可以保存地址的整数类型。

  • 如果从 double 转换为 float,结果可能是 int,如果 double 值太大,float 无法容纳。

  • 如果使用 static_cast() 在指针之间进行转换,如果类型没有共同的基类,可能会得到未定义的行为。

为了使我们的代码更加健壮,我们可以创建一个通用的检查转换函数,在调试模式下验证我们的转换,并在发布模式下尽可能快地执行我们的转换。

根据被转换的类型,会执行不同的检查。如果我们尝试在未经验证的类型之间进行转换,它将无法编译。

这些是 safe_cast() 旨在处理的情况:

  • 相同类型:显然,如果我们转换相同类型,我们只需返回输入值。

  • 指针到指针:如果在指针之间进行转换,safe_cast() 在调试模式下执行动态转换以验证是否可转换。

  • 双精度浮点数到浮点数:safe_cast() 在从 double 转换为 float 时接受精度损失,但有一个例外 - 如果从 double 转换为 float,则有可能 double 太大,使得 float 无法处理结果。

  • 算术到算术:如果在算术类型之间进行转换,值将被转换回其原始类型以验证是否丢失精度。

  • 指针到非指针:如果从指针转换为非指针类型,safe_cast() 验证目标类型是否为 uintptr_tintptr_t,这是唯一保证能够保存地址的整数类型。

在任何其他情况下,safe_cast() 函数将无法编译。

让我们看看如何实现这一点。我们首先获取有关我们的转换操作的constexpr布尔值的信息。它们是constexpr布尔值而不是const布尔值的原因是,我们将在稍后的if constexpr表达式中使用它们,这些表达式需要constexpr条件:

template <typename T> constexpr auto make_false() { return false; }
template <typename Dst, typename Src> 
auto safe_cast(const Src& v) -> Dst{ 
  using namespace std;
  constexpr auto is_same_type = is_same_v<Src, Dst>;
  constexpr auto is_pointer_to_pointer =  
    is_pointer_v<Src> && is_pointer_v<Dst>; 
  constexpr auto is_float_to_float =  
    is_floating_point_v<Src> && is_floating_point_v<Dst>; 
  constexpr auto is_number_to_number =  
    is_arithmetic_v<Src> && is_arithmetic_v<Dst>; 
  constexpr auto is_intptr_to_ptr = 
    (is_same_v<uintptr_t,Src> || is_same_v<intptr_t,Src>)
    && is_pointer_v<Dst>;
  constexpr auto is_ptr_to_intptr =
    is_pointer_v<Src> &&
    (is_same_v<uintptr_t,Dst> || is_same_v<intptr_t,Dst>); 

因此,现在我们已经获得了关于转换的所有必要信息,作为constexpr布尔值,我们在编译时断言我们可以执行转换。如前所述,如果条件不满足,static_assert()将无法编译通过(与常规 assert 不同,后者在运行时验证条件)。

请注意在if/else链的末尾使用了static_assert()make_false<T>。我们不能只输入static_assert(false),因为那样会完全阻止safe_cast()的编译;相反,我们利用模板函数make_false<T>()来推迟生成,直到需要时。

当执行实际的static_cast()时,我们将回到原始类型并验证结果是否等于未转换的参数,使用常规的运行时assert()。这样,我们可以确保static_cast()没有丢失任何数据:

 if constexpr(is_same_type) { 
    return v; 
  }
  else if constexpr(is_intptr_to_ptr || is_ptr_to_intptr){
    return reinterpret_cast<Dst>(v); 
  } 
  else if constexpr(is_pointer_to_pointer) { 
    assert(dynamic_cast<Dst>(v) != nullptr); 
    return static_cast<Dst>(v); 
  } 
  else if constexpr (is_float_to_float) { 
    auto casted = static_cast<Dst>(v); 
    auto casted_back = static_cast<Src>(v); 
    assert(!isnan(casted_back) && !isinf(casted_back)); 
    return casted; 
  }  
  else if constexpr (is_number_to_number) { 
    auto casted = static_cast<Dst>(v); 
    auto casted_back = static_cast<Src>(casted); 
    assert(casted == casted_back); 
    return casted; 
  } 
  else {
    static_assert(make_false<Src>(),"CastError");
    return Dst{}; // This can never happen, 
    // the static_assert should have failed 
  }
} 

请注意我们如何使用if constexpr来使函数有条件地编译。如果我们使用普通的if语句,函数将无法编译通过。

auto x = safe_cast<int>(42.0f); 

这是因为编译器将尝试编译以下行,而dynamic_cast只接受指针:

// type To is an integer
assert(dynamic_cast<int>(v) != nullptr); // Does not compile 

然而,由于if constexprsafe_cast<int>(42.0f)的构造,以下函数可以正确编译:

auto safe_cast(const float& v) -> int {
  constexpr auto is_same_type = false;
  constexpr auto is_pointer_to_pointer = false;
  constexpr auto is_float_to_float = false;
  constexpr auto is_number_to_number = true;
  constexpr auto is_intptr_to_ptr = false;
  constexpr auto is_ptr_to_intptr = false
  if constexpr(is_same_type) { /* Eradicated */ }
  else if constexpr(is_intptr_to_ptr||is_ptr_to_intptr){/* Eradicated */}
  else if constexpr(is_pointer_to_pointer) {/* Eradicated */}
  else if constexpr(is_float_to_float) {/* Eradicated */}
  else if constexpr(is_number_to_number) {
    auto casted = static_cast<int>(v);
    auto casted_back = static_cast<float>(casted);
    assert(casted == casted_back);
    return casted;
  }
  else { /* Eradicated */ }
} 

如你所见,除了is_number_to_number子句之外,在if constexpr语句之间的所有内容都已经被完全消除,从而使函数能够编译。

示例 2:在编译时对字符串进行哈希处理

假设我们有一个资源系统,其中包含一个无序映射的字符串,用于标识位图。如果位图已经加载,系统将返回已加载的位图;否则,它将加载位图并返回:

// External function which loads a bitmap from the filesystem
auto load_bitmap_from_filesystem(const char* path) -> Bitmap {/* ... */}
// Bitmap cache 
auto get_bitmap_resource(const std::string& path) -> const Bitmap& { 
  // Static storage of all loaded bitmaps
  static auto loaded = std::unordered_map<std::string, Bitmap>{};
  // If the bitmap is already in loaded_bitmaps, return it
  if (loaded.count(path) > 0) {
    return loaded.at(path);
  } 
  // The bitmap isn't already loaded, load and return it 
  auto bitmap = load_bitmap_from_filesystem(path.c_str());
  loaded.emplace(path, std::move(bitmap)); 
  return loaded.at(path); 
} 

然后在需要位图资源的地方使用位图缓存:

  • 如果尚未加载,get_bitmap_resource()函数将加载并返回它

  • 如果已经在其他地方加载过,get_bitmap_resource()将简单地返回已加载的函数。

因此,无论哪个绘制函数先执行,第二个函数都不必从磁盘加载位图:

auto draw_something() {
  const auto& bm = get_bitmap_resource("my_bitmap.png");
  draw_bitmap(bm);
}
auto draw_something_again() {
  const auto& bm = get_bitmap_resource("my_bitmap.png");
  draw_bitmap(bm);
} 

由于我们使用了无序映射,每当我们检查位图资源时都需要计算哈希值。现在您将看到我们如何通过将计算移动到编译时来优化运行时代码。

编译时哈希值计算的优势

我们将尝试解决的问题是,每次执行get_bitmap_resource("my_bitmap.png")这一行时,应用程序都会在运行时计算字符串"my_bitmap.png"的哈希值。我们希望在编译时执行这个计算,这样当应用程序执行时,哈希值已经被计算出来。换句话说,就像你们学习使用元编程在编译时生成函数和类一样,我们现在要让它在编译时生成哈希值。

你可能已经得出结论,这是所谓的微优化:计算一个小字符串的哈希值不会对应用程序的性能产生任何影响,因为这是一个非常小的操作。这可能完全正确;这只是一个将计算从运行时移动到编译时的示例,可能还有其他情况下这可能会产生显著的性能影响。

顺便说一句,当为弱硬件编写软件时,字符串哈希是一种纯粹的奢侈,但在编译时对字符串进行哈希处理可以让我们在任何平台上都享受到这种奢侈,因为一切都是在编译时计算的。

实现和验证编译时哈希函数

为了使编译器能够在编译时计算哈希和,我们重写hash_function(),使其以一个高级类(如std::string)的原始空终止char字符串作为参数,这在编译时无法计算。现在,我们可以将hash_function()标记为constexpr

constexpr auto hash_function(const char* str) -> size_t {
  auto sum = size_t{0};
  for (auto ptr = str; *ptr != '\0'; ++ptr)
    sum += *ptr;
  return sum;
} 

现在,让我们使用在编译时已知的原始字面字符串调用它:

auto hash = hash_function("abc"); 

编译器将生成以下代码片段,这是与abc对应的 ASCII 值的总和(979899):

auto hash = size_t{294}; 

只是累积单个值是一个非常糟糕的哈希函数;在实际应用中不要这样做。这里只是因为它容易理解。一个更好的哈希函数是将所有单个字符与boost::hash_combine()结合起来,如第四章数据结构中所解释的那样。

hash_function()只有在编译器在编译时知道字符串时才会在编译时计算;如果不知道,编译器将像任何其他表达式一样在运行时执行constexpr

既然我们已经有了哈希函数,现在是时候创建一个使用它的字符串类了。

构造一个 PrehashedString 类

我们现在准备实现一个用于预哈希字符串的类,它将使用我们创建的哈希函数。这个类包括以下内容:

  • 一个以原始字符串作为参数并在构造时计算哈希的构造函数。

  • 比较运算符。

  • 一个get_hash()成员函数,返回哈希值。

  • std::hash()的重载,简单地返回哈希值。这个重载被std::unordered_mapstd::unordered_set或标准库中使用哈希值的任何其他类使用。简单地说,这使得容器意识到PrehashedString存在一个哈希函数。

这是PrehashedString类的基本实现:

class PrehashedString {
public:
  template <size_t N>
  constexpr PrehashedString(const char(&str)[N])
      : hash_{hash_function(&str[0])}, size_{N - 1},
      // The subtraction is to avoid null at end
        strptr_{&str[0]} {}
  auto operator==(const PrehashedString& s) const {
    return
      size_ == s.size_ &&
      std::equal(c_str(), c_str() + size_, s.c_str());
  }
  auto operator!=(const PrehashedString& s) const {
    return !(*this == s); }
  constexpr auto size()const{ return size_; }
  constexpr auto get_hash()const{ return hash_; }
  constexpr auto c_str()const->const char*{ return strptr_; }
private:
  size_t hash_{};
  size_t size_{};
  const char* strptr_{nullptr};
};
namespace std {
template <>
struct hash<PrehashedString> {
  constexpr auto operator()(const PrehashedString& s) const {
    return s.get_hash();
  }
};
} // namespace std 

请注意构造函数中的模板技巧。这迫使PrehashedString只接受编译时字符串字面值。这样做的原因是PrehashedString类不拥有const char* ptr,因此我们只能在编译时使用它创建的字符串字面值:

// This compiles
auto prehashed_string = PrehashedString{"my_string"};
// This does not compile
// The prehashed_string object would be broken if the str is modified
auto str = std::string{"my_string"};
auto prehashed_string = PrehashedString{str.c_str()};
// This does not compile.
// The prehashed_string object would be broken if the strptr is deleted
auto* strptr = new char[5];
auto prehashed_string = PrehashedString{strptr}; 

所以,既然我们已经准备就绪,让我们看看编译器如何处理PrehashedString

评估 PrehashedString

这是一个简单的测试函数,返回字符串"abc"的哈希值(为了简单起见):

auto test_prehashed_string() {
  const auto& hash_fn = std::hash<PrehashedString>{};
  const auto& str = PrehashedString("abc");
  return hash_fn(str);
} 

由于我们的哈希函数只是对值求和,而"abc"中的字母具有 ASCII 值a = 97,b = 98,c = 99,由 Clang 生成的汇编代码应该输出和为 97 + 98 + 99 = 294。检查汇编代码,我们可以看到test_prehashed_string()函数编译成了一个return语句,返回294

mov eax, 294
ret 

这意味着整个test_prehashed_string()函数已经在编译时执行;当应用程序执行时,哈希和已经被计算!

使用 PrehashedString 评估 get_bitmap_resource()

让我们回到最初的get_bitmap_resource()函数,最初使用的std::string已经被替换为PrehashedString

// Bitmap cache
auto get_bitmap_resource(const PrehashedString& path) -> const Bitmap& 
{
  // Static storage of all loaded bitmaps
  static auto loaded_bitmaps =
    std::unordered_map<PrehashedString, Bitmap>{};
  // If the bitmap is already in loaded_bitmaps, return it
  if (loaded_bitmaps.count(path) > 0) {
    return loaded_bitmaps.at(path);
  }
  // The bitmap isn't already loaded, load and return it
  auto bitmap = load_bitmap_from_filesystem(path.c_str());
  loaded_bitmaps.emplace(path, std::move(bitmap));
  return loaded_bitmaps.at(path);
} 

我们还需要一个测试函数:

auto test_get_bitmap_resource() { return get_bitmap_resource("abc"); } 

我们想知道的是这个函数是否预先计算了哈希和。由于get_bitmap_resource()做了很多事情(构造静态std::unordered_map,检查映射等),生成的汇编代码大约有 500 行。尽管如此,如果我们的魔术哈希和在汇编代码中找到,这意味着我们成功了。

当检查由 Clang 生成的汇编代码时,我们将找到一行对应于我们的哈希和,294

.quad   294                     # 0x126 

为了确认这一点,我们将字符串从"abc"改为"aaa",这应该将汇编代码中的这一行改为 97 * 3 = 291,但其他一切应该完全相同。

我们这样做是为了确保这不只是一些其他与哈希和毫不相关的魔术数字。

检查生成的汇编代码,我们将找到期望的结果:

.quad   291                     # 0x123 

除了这一行之外,其他都是相同的,因此我们可以安全地假设哈希是在编译时计算的。

我们所看到的示例表明,我们可以将编译时编程用于非常不同的事情。添加可以在编译时验证的安全检查,使我们能够在不运行程序并通过覆盖测试搜索错误的情况下找到错误。并且将昂贵的运行时操作转移到编译时可以使我们的最终程序更快。

总结

在本章中,您已经学会了如何使用元编程来在编译时而不是运行时生成函数和值。您还发现了如何以现代 C++的方式使用模板、constexprstatic_assert()if constexpr、类型特征和概念来实现这一点。此外,通过常量字符串哈希,您看到了如何在实际环境中使用编译时评估。

在下一章中,您将学习如何进一步扩展您的 C++工具箱,以便您可以通过构建隐藏的代理对象来创建库。

第九章:基本实用程序

本章将介绍 C++实用库中的一些基本类。在处理包含不同类型元素的集合时,将使用前一章介绍的一些元编程技术以便有效地工作。

C++容器是同类的,意味着它们只能存储单一类型的元素。std::vector<int>存储一组整数,std::list<Boat>中存储的所有对象都是Boat类型。但有时,我们需要跟踪不同类型的元素集合。我将这些集合称为异类集合。在异类集合中,元素可能具有不同的类型。下图显示了一个整数的同类集合的示例和一个具有不同类型元素的异类集合的示例:

图 9.1:同类和异类集合

本章将涵盖 C++实用库中一组有用的模板,这些模板可用于存储各种类型的多个值。本章分为四个部分:

  • 使用std::optional表示可选值

  • 使用std::pairstd::tuplestd::tie()来固定大小的集合

  • 使用标准容器存储具有std::anystd::variant类型的元素的动态大小集合

  • 一些真实世界的例子展示了std::tuplestd::tie()的有用性,以及我们在第八章中涵盖的元编程概念

让我们首先探索std::optional及其一些重要的用例。

使用 std::optional 表示可选值

尽管在 C++17 中是一个相当次要的特性,std::optional是标准库的一个不错的补充。它简化了一个以前无法以清晰和直接的方式表达的常见情况。简而言之,它是任何类型的一个小包装器,其中包装的类型可以是初始化未初始化

用 C++术语来说,std::optional是一个最大大小为一的栈分配容器

可选返回值

在引入std::optional之前,没有明确的方法来定义可能不返回定义值的函数,例如两条线段的交点。引入std::optional后,这样的可选返回值可以得到清晰的表达。接下来是一个返回两条线之间的可选交点的函数的实现:

// Prerequisite
struct Point { /* ... */ }; 
struct Line { /* ... */ };  
auto lines_are_parallel(Line a, Line b) -> bool { /* ... */ }
auto compute_intersection(Line a, Line b) -> Point { /* ... */ }
auto get_intersection(const Line& a, const Line& b) 
  -> std::optional<Point> 
{
  if (lines_are_parallel(a, b))
    return std::optional{compute_intersection(a, b)};
  else
    return {};
} 

std::optional的语法类似于指针;值通过operator*()operator->()访问。尝试使用operator*()operator->()访问空的可选值的值是未定义行为。还可以使用value()成员函数访问值,如果可选值不包含值,则会抛出std::bad_optional_access异常。接下来是一个返回的简单std::optional的示例:

auto set_magic_point(Point p) { /* ... */ }
auto intersection = get_intersection(line0, line1);
if (intersection.has_value()) {
  set_magic_point(*intersection);
} 

std::optional持有的对象始终是栈分配的,将类型包装到std::optional的内存开销是一个布尔值的大小(通常为一个字节),加上可能的填充。

可选成员变量

假设我们有一个表示人头的类。头部可以戴一顶帽子,也可以不戴帽子。通过使用std::optional来表示帽子成员变量,实现就可以尽可能地表达出来:

struct Hat { /* ... */ };
class Head {
public:
  Head() { assert(!hat_); }      // hat_ is empty by default
  auto set_hat(const Hat& h) { 
    hat_ = h; 
  }
  auto has_hat() const { 
    return hat_.has_value(); 
  }
  auto& get_hat() const { 
    assert(hat_.has_value()); 
    return *hat_; 
  }
  auto remove_hat() { 
    hat_ = {};        // Hat is cleared by assigning to {}
  } 
private:
  std::optional<Hat> hat_;
}; 

如果没有std::optional,表示可选成员变量将依赖于例如指针或额外的bool成员变量。两者都有缺点,例如在堆上分配,或者在没有警告的情况下意外访问被认为是空的可选。

避免枚举中的空状态

在旧的 C++代码库中可以看到的一个模式是enum中的空状态空状态。这是一个例子:

enum class Color { red, blue, none };  // Don't do this! 

在前面的enum中,none是所谓的空状态。在Colorenum中添加none值的原因是为了表示可选颜色,例如:

auto get_color() -> Color; // Returns an optional color 

然而,使用这种设计,没有办法表示非可选颜色,这使得所有代码都必须处理额外的空状态none

更好的替代方案是避免额外的空状态,而是用类型std::optional<Color>表示可选颜色:

enum class Color { red, blue };
auto get_color() -> std::optional<Color>; 

这清楚地表明我们可能无法得到一个颜色。但我们也知道一旦有了Color对象,它就不可能为空:

auto set_color(Color c) { /* c is a valid color, now use it ... */ } 

在实现set_color()时,我们知道客户端传递了有效的颜色。

排序和比较 std::optional

std::optional同样可以使用下表中显示的规则进行比较和排序:

两个可选值被认为是相等的。 空的可选值被认为小于非空的可选值。

|

auto a = std::optional<int>{};
auto b = std::optional<int>{};
auto c = std::optional<int>{4};
assert(a == b);
assert(b != c); 

|

auto a = std::optional<int>{};
auto b = std::optional<int>{4};
auto c = std::optional<int>{5};
assert(a < b);
assert(b < c); 

|

因此,如果对std::optional<T>的容器进行排序,空的可选值将出现在容器的开头,而非空的可选值将像通常一样排序,如下所示:

auto c = std::vector<std::optional<int>>{{3}, {}, {1}, {}, {2}};
std::sort(c.begin(), c.end());
// c is {}, {}, {1}, {2}, {3} 

如果您习惯使用指针表示可选值,设计使用输出参数的 API,或在枚举中添加特殊的空状态,那么现在是时候将std::optional添加到您的工具箱中了,因为它提供了这些反模式的高效且安全的替代方案。

让我们继续探讨可以容纳不同类型元素的固定大小集合。

固定大小异构集合

C++实用库包括两个可以用于存储不同类型的多个值的类模板:std::pairstd::tuple。它们都是固定大小的集合。就像std::array一样,在运行时动态添加更多值是不可能的。

std::pairstd::tuple之间的主要区别在于std::pair只能容纳两个值,而std::tuple可以在编译时用任意大小进行实例化。我们将从简要介绍std::pair开始,然后转向std::tuple

使用 std::pair

类模板std::pair位于<utility>头文件中,并且自从标准模板库引入以来就一直可用于 C++。它在标准库中用于算法需要返回两个值的情况,比如std::minmax(),它可以返回初始化列表的最小值和最大值:

std::pair<int, int> v = std::minmax({4, 3, 2, 4, 5, 1});
std::cout << v.first << " " << v.second;     // Outputs: "1 5" 

前面的例子显示了可以通过成员firstsecond访问std::pair的元素。

在这里,std::pair保存相同类型的值,因此也可以在这里返回一个数组。但是std::pair更有趣的地方在于它可以保存不同类型的值。这就是为什么我们认为这是一个异构集合的原因,尽管它只能容纳两个值。

标准库中std::pair保存不同值的一个例子是关联容器std::mapstd::map的值类型是一个由键和与键关联的元素组成的对:

auto scores = std::map<std::string, int>{};
scores.insert(std::pair{"Neo", 12}); // Correct but ineffecient
scores.emplace("Tri", 45);           // Use emplace() instead
scores.emplace("Ari", 33);
for (auto&& it : scores) { // "it" is a std::pair
  auto key = it.first;
  auto val = it.second;
  std::cout << key << ": " << val << '\n';
} 

显式命名std::pair类型的要求已经减少,在现代 C++中,使用初始化列表和结构化绑定来隐藏我们正在处理std::pair值的事实是很常见的。下面的例子表达了相同的事情,但没有明确提到底层的std::pair

auto scores = std::map<std::string, int> {
  {"Neo", 12},                            // Initializer lists
  {"Tri", 45},
  {"Ari", 33}
};
for (auto&& [key, val] : scores) {       // Structured bindings
  std::cout << key << ": " << val << '\n';
} 

我们将在本章后面更多地讨论结构化绑定。

正如其名称所示,std::pair只能容纳两个值。C++11 引入了一个名为std::tuple的新实用类,它是std::pair的泛化,可以容纳任意数量的元素。

std::tuple

std::tuple可以用作固定大小的异构集合,可以声明为任意大小。与std::vector相比,它的大小在运行时不能改变;您不能添加或删除元素。

元组可以这样构造,其成员类型明确指定:

auto t = std::tuple<int, std::string, bool>{}; 

或者,我们可以使用类模板参数推导进行初始化,如下所示:

auto t = std::tuple{0, std::string{}, false}; 

这将使编译器生成一个类,大致可以看作是这样的:

struct Tuple {
  int data0_{};
  std::string data1_{};
  bool data2_{};
}; 

与 C++标准库中的许多其他类一样,std::tuple也有一个对应的std::make_tuple()函数,它可以从参数中自动推断类型:

auto t = std::make_tuple(42, std::string{"hi"}, true); 

但正如前面所述,从 C++17 开始,许多这些std::make_函数都是多余的,因为 C++17 类可以从构造函数中推断出这些类型。

访问元组的成员

可以使用自由函数模板std::get<Index>()访问std::tuple的各个元素。你可能会想为什么不能像常规容器一样使用at(size_t index)成员函数访问成员。原因是at()这样的成员函数只允许返回一个类型,而元组在不同索引处包含不同类型。相反,使用带有索引的函数模板std::get()作为模板参数:

auto a = std::get<0>(t);     // int
auto b = std::get<1>(t);     // std::string
auto c = std::get<2>(t);     // bool 

我们可以想象std::get()函数的实现类似于这样:

template <size_t Index, typename Tuple>
auto& get(const Tuple& t) {
  if constexpr(Index == 0) {
    return t.data0_;
  } else if constexpr(Index == 1) {
    return t.data1_;
  } else if constexpr(Index == 2) {
    return t.data2_;
  }
} 

这意味着当我们创建和访问元组时:

auto t = std::tuple(42, true);
auto v = std::get<0>(t); 

编译器大致生成以下代码:

// The Tuple class is generated first:
class Tuple { 
  int data0_{};
  bool data1_{};
public:
  Tuple(int v0, bool v1) : data0_{v0}, data1_{v1} {} 
};
// get<0>(Tuple) is then generated to something like this:
auto& get(const Tuple& tpl) { return data0_; }

// The generated function is then utilized:
auto t = Tuple(42, true); 
auto v = get(t); 

请注意,这个例子只能被认为是一种简单的想象,用来想象编译器在构造std::tuple时生成的内容;std::tuple的内部非常复杂。然而,重要的是要理解,std::tuple类基本上是一个简单的结构,其成员可以通过编译时索引访问。

std::get()函数模板也可以使用 typename 作为参数。它的使用方式如下:

auto number = std::get<int>(tuple);
auto str = std::get<std::string>(tuple); 

只有当指定的类型在元组中包含一次时才可能。

迭代 std::tuple 成员

从程序员的角度来看,似乎std::tuple可以像任何其他容器一样使用常规的基于范围的for循环进行迭代,如下所示:

auto t = std::tuple(1, true, std::string{"Jedi"});
for (const auto& v : t) {
  std::cout << v << " ";
} 

这不可能的原因是const auto& v的类型只被评估一次,而由于std::tuple包含不同类型的元素,这段代码根本无法编译。

对于常规算法也是一样,因为迭代器不会改变指向的类型;因此,std::tuple不提供begin()end()成员函数,也不提供用于访问值的下标运算符[]。因此,我们需要想出其他方法来展开元组。

展开元组

由于元组不能像通常那样进行迭代,我们需要使用元编程来展开循环。从前面的例子中,我们希望编译器生成类似于这样的东西:

auto t = std::tuple(1, true, std::string{"Jedi"});
std::cout << std::get<0>(t) << " ";
std::cout << std::get<1>(t) << " ";
std::cout << std::get<2>(t) << " ";
// Prints "1 true Jedi" 

如你所见,我们迭代元组的每个索引,这意味着我们需要知道元组中包含的类型/值的数量。然后,由于元组包含不同类型,我们需要编写一个生成元组中每种类型的新函数的元函数。

如果我们从一个为特定索引生成调用的函数开始,它会看起来像这样:

template <size_t Index, typename Tuple, typename Func> 
void tuple_at(const Tuple& t, Func f) {
  const auto& v = std::get<Index>(t);
  std::invoke(f, v);
} 

然后我们可以将其与通用 lambda 结合使用,就像你在第二章 Essential C++ Techniques中学到的那样:

auto t = std::tuple{1, true, std::string{"Jedi"}};
auto f = [](const auto& v) { std::cout << v << " "; };
tuple_at<0>(t, f);
tuple_at<1>(t, f);
tuple_at<2>(t, f);
// Prints "1 true Jedi" 

有了tuple_at()函数,我们就可以继续进行实际的迭代。我们首先需要的是元组中值的数量作为编译时常量。幸运的是,这个值可以通过类型特征std::tuple_size_v<Tuple>获得。使用if constexpr,我们可以通过创建一个类似的函数来展开迭代,根据索引采取不同的操作:

  • 如果索引等于元组大小,它会生成一个空函数

  • 否则,它会在传递的索引处执行 lambda,并生成一个索引增加 1 的新函数

代码将如下所示:

template <typename Tuple, typename Func, size_t Index = 0> void tuple_for_each(const Tuple& t, const Func& f) {
  constexpr auto n = std::tuple_size_v<Tuple>;
  if constexpr(Index < n) {
    tuple_at<Index>(t, f);
    tuple_for_each<Tuple, Func, Index+1>(t, f);
  }
} 

如你所见,默认索引设置为零,这样在迭代时就不必指定它。然后可以像这样调用tuple_for_each()函数,直接放在 lambda 的位置:

auto t = std::tuple{1, true, std::string{"Jedi"}};
tuple_for_each(t, [](const auto& v) { std::cout << v << " "; });
// Prints "1 true Jedi" 

相当不错;从语法上看,它看起来与std::for_each()算法非常相似。

为元组实现其他算法

tuple_for_each()的基础上,可以以类似的方式实现迭代元组的不同算法。以下是std::any_of()为元组实现的示例:

template <typename Tuple, typename Func, size_t Index = 0> 
auto tuple_any_of(const Tuple& t, const Func& f) -> bool { 
  constexpr auto n = std::tuple_size_v<Tuple>; 
  if constexpr(Index < n) { 
    bool success = std::invoke(f, std::get<Index>(t)); 
    if (success) {
      return true;
    }
    return tuple_any_of<Tuple, Func, Index+1>(t, f); 
  } else { 
    return false; 
  } 
} 

它可以这样使用:

auto t = std::tuple{42, 43.0f, 44.0}; 
auto has_44 = tuple_any_of(t, [](auto v) { return v == 44; }); 

函数模板tuple_any_of()遍历元组中的每种类型,并为当前索引处的元素生成一个 lambda 函数,然后将其与44进行比较。在这种情况下,has_44将评估为true,因为最后一个元素,即double值,是44。如果我们添加一个与44不可比较的类型的元素,比如std::string,我们将得到一个编译错误。

访问元组元素

在 C++17 之前,有两种标准方法可以访问std::tuple的元素:

  • 为了访问单个元素,使用了函数std::get<N>(tuple)

  • 为了访问多个元素,使用了函数std::tie()

尽管它们都起作用,但执行这样一个简单任务的语法非常冗长,如下例所示:

// Prerequisite 
using namespace std::string_literals;  // "..."s
auto make_saturn() { return std::tuple{"Saturn"s, 82, true}; }
int main() {
  // Using std::get<N>()
  {
    auto t = make_saturn();
    auto name = std::get<0>(t);
    auto n_moons = std::get<1>(t);
    auto rings = std::get<2>(t);
    std::cout << name << ' ' << n_moons << ' ' << rings << '\n';
    // Output: Saturn 82 true   }
    // Using std::tie()
  {
    auto name = std::string{};
    auto n_moons = int{};
    auto rings = bool{};
    std::tie(name, n_moons, rings) = make_saturn();
    std::cout << name << ' ' << n_moons << ' ' << rings << '\n';
  }
} 

为了能够优雅地执行这个常见任务,C++17 引入了结构化绑定。

结构化绑定

使用结构化绑定,可以使用auto和括号声明列表一次初始化多个变量。与一般情况下的auto关键字一样,可以通过使用相应的修饰符来控制变量是否应该是可变引用、前向引用、const 引用或值。在下面的示例中,正在构造const引用的结构化绑定:

const auto& [name, n_moons, rings] = make_saturn();
std::cout << name << ' ' << n_moons << ' ' << rings << '\n'; 

结构化绑定也可以用于在for循环中提取元组的各个成员,如下所示:

auto planets = { 
  std::tuple{"Mars"s, 2, false}, 
  std::tuple{"Neptune"s, 14, true} 
};
for (auto&& [name, n_moons, rings] : planets) { 
   std::cout << name << ' ' << n_moons << ' ' << rings << '\n'; 
} 
// Output:
// Mars 2 false 
// Neptune 14 true 

这里有一个快速提示。如果你想要返回具有命名变量的多个参数,而不是元组索引,可以在函数内部定义一个结构体并使用自动返回类型推导:

auto make_earth() {
  struct Planet { std::string name; int n_moons; bool rings; };
  return Planet{"Earth", 1, false}; 
}
// ...
auto p = make_earth(); 
std::cout << p.name << ' ' << p.n_moons << ' ' << p.rings << '\n'; 

结构化绑定也适用于结构体,因此,我们可以直接捕获各个数据成员,如下所示,即使它是一个结构体:

auto [name, num_moons, has_rings] = make_earth(); 

在这种情况下,我们可以选择任意名称作为标识符,因为Planet的数据成员的顺序是相关的,就像返回元组时一样。

现在,我们将看看在处理任意数量的函数参数时,std::tuplestd::tie()的另一个用例。

可变模板参数包

可变模板参数包使程序员能够创建可以接受任意数量参数的模板函数。

具有可变数量参数的函数示例

如果我们要创建一个将任意数量的参数转换为字符串的函数,而不使用可变模板参数包,我们需要使用 C 风格的可变参数(就像printf()一样)或为每个参数数量创建一个单独的函数:

auto make_string(const auto& v0) { 
  auto ss = std::ostringstream{}; 
  ss << v0; 
  return ss.str(); 
} 
auto make_string(const auto& v0, const auto& v1) { 
   return make_string(v0) + " " + make_string(v1); 
}
auto make_string(const auto& v0, const auto& v1, const auto& v2) { 
  return make_string(v0, v1) + " " + make_string(v2); 
} 
// ... and so on for as many parameters we might need 

这是我们函数的预期用法:

auto str0 = make_string(42);
auto str1 = make_string(42, "hi");
auto str2 = make_string(42, "hi", true); 

如果我们需要大量的参数,这变得很繁琐,但是使用参数包,我们可以将其实现为一个接受任意数量参数的函数。

如何构造可变参数包

参数包通过在类型名称前面放置三个点和在可变参数后面放置三个点来识别,用逗号分隔扩展包:

template<typename ...Ts> 
auto f(Ts... values) {
  g(values...);
} 

这是个语法解释:

  • Ts是类型列表

  • <typename ...Ts>表示函数处理一个列表

  • values...扩展包,使得每个值之间都添加了逗号。

将其转化为代码,考虑这个expand_pack()函数模板:

template <typename ...Ts>
auto expand_pack(const Ts& ...values) {
   auto tuple = std::tie(values...);
} 

让我们这样调用前面的函数:

expand_pack(42, std::string{"hi"}); 

在这种情况下,编译器将生成一个类似于这样的函数:

auto expand_pack(const int& v0, const std::string& v1) {
  auto tuple = std::tie(v0, v1);
} 

这是各个参数包部分扩展到的内容:

表达式: 扩展为:
template <typename... Ts> template <typename T0, typename T1>
expand_pack(const Ts& ...values) expand_pack(const T0& v0, const T1& v1)
std::tie(values...) std::tie(v0, v1)

表 9.1:扩展表达式

现在,让我们看看如何创建一个带有可变参数包的make_string()函数。

进一步扩展初始的make_string()函数,为了从每个参数创建一个字符串,我们需要迭代参数包。没有直接迭代参数包的方法,但一个简单的解决方法是将其转换为元组,然后使用tuple_for_each()函数模板进行迭代,如下所示:

template <typename ...Ts> 
auto make_string(const Ts& ...values) { 
  auto ss = std::ostringstream{}; 
  // Create a tuple of the variadic parameter pack 
  auto tuple = std::tie(values...); 
  // Iterate the tuple 
  tuple_for_each(tuple, &ss { ss << v; }); 
  return ss.str();
}
// ...
auto str = make_string("C++", 20);  // OK: str is "C++" 

参数包被转换为std::tuple,然后使用tuple_for_each()进行迭代。回顾一下,我们需要使用std::tuple来处理参数的原因是因为我们希望支持各种类型的任意数量的参数。如果我们只需要支持特定类型的参数,我们可以使用带有范围for循环的std::array,如下所示:

template <typename ...Ts>
auto make_string(const Ts& ...values) {
  auto ss = std::ostringstream{};
  auto a = std::array{values...};     // Only supports one type
  for (auto&& v : a) { ss << v; }
  return ss.str();
}
// ...
auto a = make_string("A", "B", "C");  // OK: Only one type
auto b = make_string(100, 200, 300);  // OK: Only one type
auto c = make_string("C++", 20);      // Error: Mixed types 

正如您所见,std::tuple是一个具有固定大小和固定元素位置的异构集合,更或多或少类似于常规结构,但没有命名的成员变量。

我们如何扩展这个以创建一个动态大小的集合(例如std::vectorstd::list),但具有存储混合类型元素的能力?我们将在下一节中看到这个问题的解决方案。

动态大小的异构集合

我们在本章开始时指出,C++提供的动态大小容器是同质的,这意味着我们只能存储单一类型的元素。但有时,我们需要跟踪一个大小动态的集合,其中包含不同类型的元素。为了能够做到这一点,我们将使用包含std::anystd::variant类型元素的容器。

最简单的解决方案是使用std::any作为基本类型。std::any对象可以存储其中的任何类型的值:

auto container = std::vector<std::any>{42, "hi", true}; 

然而,它也有一些缺点。首先,每次访问其中的值时,必须在运行时测试类型。换句话说,我们在编译时完全失去了存储值的类型信息。相反,我们必须依赖运行时类型检查来获取信息。其次,它在堆上分配对象而不是栈上,这可能会对性能产生重大影响。

如果我们想要迭代我们的容器,我们需要明确告诉每个std::any对象:如果你是一个 int,就这样做,如果你是一个 char 指针,就那样做。这是不可取的,因为它需要重复的源代码,并且比使用其他替代方案效率低,我们将在本章后面介绍。

以下示例已编译;类型已明确测试并转换:

for (const auto& a : container) {
  if (a.type() == typeid(int)) {
    const auto& value = std::any_cast<int>(a);
    std::cout << value;
  }
  else if (a.type() == typeid(const char*)) {
    const auto& value = std::any_cast<const char*>(a);
    std::cout << value;
  }
  else if (a.type() == typeid(bool)) {
    const auto& value = std::any_cast<bool>(a);
    std::cout << value;
  }
} 

我们无法使用常规流操作符打印它,因为std::any对象不知道如何访问其存储的值。因此,以下代码不会编译;编译器不知道std::any中存储了什么:

for (const auto& a : container) { 
  std::cout << a;                // Does not compile
} 

通常我们不需要std::any提供的类型的完全灵活性,在许多情况下,我们最好使用std::variant,接下来我们将介绍。

std::variant

如果我们不需要在容器中存储任何类型,而是想要集中在容器初始化时声明的固定类型集合上,那么std::variant是更好的选择。

std::variant相对于std::any有两个主要优势:

  • 它不会将其包含的类型存储在堆上(不像std::any

  • 它可以通过通用 lambda 调用,这意味着您不必明确知道其当前包含的类型(本章后面将更多介绍)

std::variant的工作方式与元组有些类似,只是它一次只存储一个对象。包含的类型和值是我们最后分配的类型和值。以下图示了在使用相同类型实例化std::tuplestd::variant时它们之间的区别:

图 9.2:类型元组与类型变体

以下是使用std::variant的示例:

using VariantType = std::variant<int, std::string, bool>; 
VariantType v{}; 
std::holds_alternative<int>(v);  // true, int is first alternative
v = 7; 
std::holds_alternative<int>(v);  // true
v = std::string{"Anne"};
std::holds_alternative<int>(v);  // false, int was overwritten 
v = false; 
std::holds_alternative<bool>(v); // true, v is now bool 

我们使用std::holds_alternative<T>()来检查变体当前是否持有给定类型。您可以看到,当我们为变体分配新值时,类型会发生变化。

除了存储实际值外,std::variant还通过使用通常为std::size_t大小的索引来跟踪当前持有的备用。这意味着std::variant的总大小通常是最大备用的大小加上索引的大小。我们可以通过使用sizeof运算符来验证我们的类型:

std::cout << "VariantType: "<< sizeof(VariantType) << '\n';
std::cout << "std::string: "<< sizeof(std::string) << '\n';
std::cout << "std::size_t: "<< sizeof(std::size_t) << '\n'; 

使用带有 libc++的 Clang 10.0 编译和运行此代码将生成以下输出:

VariantType: 32
std::string: 24
std::size_t: 8 

如您所见,VariantType的大小是std::stringstd::size_t的总和。

std::variant 的异常安全性

当向std::variant对象分配新值时,它被放置在变体当前持有值的相同位置。如果由于某种原因,新值的构造或分配失败并引发异常,则可能不会恢复旧值。相反,变体可以变为无值。您可以使用成员函数valueless_by_exception()来检查变体对象是否无值。这可以在尝试使用emplace()成员函数构造对象时进行演示:

struct Widget {
  explicit Widget(int) {    // Throwing constructor
    throw std::exception{};
  }
};
auto var = std::variant<double, Widget>{1.0};
try {
  var.emplace<1>(42); // Try to construct a Widget instance
} catch (...) {
  std::cout << "exception caught\n";
  if (var.valueless_by_exception()) {  // var may or may not 
    std::cout << "valueless\n";        // be valueless
  } else {
    std::cout << std::get<0>(var) << '\n';
  }
} 

在异常被抛出并捕获后,初始的double值 1.0 可能存在,也可能不存在。操作不能保证回滚,这通常是我们可以从标准库容器中期望的。换句话说,std::variant不提供强异常安全性保证的原因是性能开销,因为这将要求std::variant使用堆分配。std::variant的这种行为是一个有用的特性,而不是一个缺点,因为这意味着您可以在具有实时要求的代码中安全地使用std::variant

如果您希望使用堆分配版本,但具有强异常安全性保证和“永不为空”的保证,boost::variant提供了这种功能。如果您对实现这种类型的挑战感兴趣,www.boost.org/doc/libs/1_74_0/doc/html/variant/design.html提供了一个有趣的阅读。

访问变体

访问std::variant中的变量时,我们使用全局函数std::visit()。正如你可能已经猜到的那样,当处理异构类型时,我们必须使用我们的主要伴侣:通用 lambda:

auto var = std::variant<int, bool, float>{};
std::visit([](auto&& val) { std::cout << val; }, var); 

在示例中使用通用 lambda 和变体var调用std::visit()时,编译器会将 lambda 概念上转换为一个常规类,该类对变体中的每种类型进行operator()重载。这将看起来类似于这样:

struct GeneratedFunctorImpl {
  auto operator()(int&& v)   { std::cout << v; }
  auto operator()(bool&& v)  { std::cout << v; }
  auto operator()(float&& v) { std::cout << v; }
}; 

然后,std::visit()函数扩展为使用std::holds_alternative<T>()if...else链,或使用std::variant的索引生成正确的调用std::get<T>()的跳转表。

在前面的示例中,我们直接将通用 lambda 中的值传递给std::cout,而不考虑当前持有的备用。但是,如果我们想要根据正在访问的类型执行不同的操作怎么办?在这种情况下可能使用的一种模式是定义一个可变类模板,该模板将继承一组 lambda。然后,我们需要为要访问的每种类型定义这个。听起来有点复杂,不是吗?这一开始可能看起来有点神奇,也考验了我们的元编程技能,但是一旦我们有了可变类模板,使用起来就很容易了。

我们将从可变类模板开始。以下是它的外观:

template<class... Lambdas>
struct Overloaded : Lambdas... {
  using Lambdas::operator()...;
}; 

如果您使用的是 C++17 编译器,还需要添加一个显式的推导指南,但在 C++20 中不需要:

template<class... Lambdas> 
Overloaded(Lambdas...) -> Overloaded<Lambdas...>; 

就是这样。模板类Overloaded将继承我们将使用模板实例化的所有 lambda,并且函数调用运算符operator()()将被每个 lambda 重载一次。现在可以创建一个只包含调用运算符的多个重载的无状态对象:

auto overloaded_lambdas = Overloaded{
  [](int v)   { std::cout << "Int: " << v; },
  [](bool v)  { std::cout << "Bool: " << v; },
  [](float v) { std::cout << "Float: " << v; }
}; 

我们可以使用不同的参数进行测试,并验证是否调用了正确的重载:

overloaded_lambdas(30031);    // Prints "Int: 30031"
overloaded_lambdas(2.71828f); // Prints "Float: 2.71828" 

现在,我们可以在不需要将Overloaded对象存储在左值中的情况下使用std::visit()。最终的效果如下:

auto var = std::variant<int, bool, float>{42};
std::visit(Overloaded{
  [](int v)   { std::cout << "Int: " << v; },
  [](bool v)  { std::cout << "Bool: " << v; },
  [](float v) { std::cout << "Float: " << v; }
}, var);
// Outputs: "Int: 42" 

因此,一旦我们有了Overloaded模板,我们就可以使用这种方便的方式来指定一组不同类型参数的 lambda。在下一节中,我们将开始使用std::variant和标准容器。

使用变体的异构集合

现在我们有了一个可以存储所提供列表中任何类型的变体,我们可以将其扩展为异构集合。我们只需创建一个我们的变体的std::vector

using VariantType = std::variant<int, std::string, bool>;
auto container = std::vector<VariantType>{}; 

现在,我们可以向向量中推送不同类型的元素:

container.push_back(false);
container.push_back("I am a string"s);
container.push_back("I am also a string"s);
container.push_back(13); 

现在,向内存中的向量看起来是这样的,其中向量中的每个元素都包含变体的大小,本例中为sizeof(std::size_t) + sizeof(std::string)

图 9.3:变体的向量

当然,我们也可以使用pop_back()或以容器允许的任何其他方式修改容器:

container.pop_back();
std::reverse(container.begin(), container.end());
// etc... 

访问我们的变体容器中的值

现在我们有了一个大小动态的异构集合的样板,让我们看看如何像常规的std::vector一样使用它:

  1. 构造异构变体容器:在这里,我们构造了一个包含不同类型的std::vector。请注意,初始化列表包含不同的类型:
using VariantType = std::variant<int, std::string, bool>;
auto v = std::vector<VariantType>{ 42, "needle"s, true }; 
  1. 使用常规 for 循环迭代打印内容:要使用常规for循环迭代容器,我们利用std::visit()和一个通用 lambda。全局函数std::visit()负责类型转换。该示例将每个值打印到std::cout,而不考虑类型:
for (const auto& item : v) { 
  std::visit([](const auto& x) { std::cout << x << '\n';}, item);
} 
  1. 检查容器中的类型:在这里,我们通过类型检查容器的每个元素。这是通过使用全局函数std::holds_alternative<type>实现的,该函数在变体当前持有所要求的类型时返回true。以下示例计算当前容器中包含的布尔值的数量:
auto num_bools = std::count_if(v.begin(), v.end(),
                               [](auto&& item) {
  return std::holds_alternative<bool>(item);
}); 
  1. 通过包含的类型和值查找内容:在此示例中,我们通过结合std::holds_alternative()std::get()来检查容器的类型和值。此示例检查容器是否包含值为"needle"std::string
auto contains = std::any_of(v.begin(), v.end(),
                            [](auto&& item) {
  return std::holds_alternative<std::string>(item) &&
    std::get<std::string>(item) == "needle";
}); 

全局函数 std::get()

全局函数模板std::get()可用于std::tuplestd::pairstd::variantstd::array。有两种实例化std::get()的方式,一种是使用索引,一种是使用类型:

  • std::get<Index>(): 当std::get()与索引一起使用时,如std::get<1>(v),它返回std::tuplestd::pairstd::array中相应索引处的值。

  • std::get<Type>(): 当std::get()与类型一起使用时,如std::get<int>(v),返回std::tuplestd::pairstd::variant中的相应值。对于std::variant,如果变体当前不持有该类型,则会抛出std::bad_variant_access异常。请注意,如果vstd::tuple,并且Type包含多次,则必须使用索引来访问该类型。

在讨论了实用程序库中的基本模板之后,让我们看一些实际应用,以了解本章涵盖的内容在实践中的应用。

一些实际示例

我们将通过检查两个示例来结束本章,其中std::tuplestd::tie()和一些模板元编程可以帮助我们编写清晰和高效的代码。

示例 1:投影和比较运算符

在 C++20 中,需要为类实现比较运算符的情况大大减少,但仍然有一些情况下,我们需要为特定场景中的对象提供自定义比较函数。考虑以下类:

struct Player {
  std::string name_{};
  int level_{};
  int score_{};
  // etc...
};
auto players = std::vector<Player>{};
// Add players here... 

假设我们想按照他们的属性对玩家进行排序:首要排序顺序是level_,次要排序顺序是score_。在实现比较和排序时,看到这样的代码并不罕见:

auto cmp = [](const Player& lhs, const Player& rhs) {
  if (lhs.level_ == rhs.level_) {
    return lhs.score_ < rhs.score_;
  }
  else {
    return lhs.level_ < rhs.level_;
  }
};
std::sort(players.begin(), players.end(), cmp); 

当属性数量增加时,使用嵌套的if-else块编写这种风格的比较运算符很容易出错。我们真正想表达的是我们正在比较Player属性的投影(在这种情况下是一个严格的子集)。std::tuple可以帮助我们以更清晰的方式重写这段代码,而不需要if-else语句。

让我们使用std::tie(),它创建一个包含我们传递给它的 lvalue 引用的std::tuple。以下代码创建了两个投影,p1p2,并使用<运算符进行比较:

auto cmp = [](const Player& lhs, const Player& rhs) {
  auto p1 = std::tie(lhs.level_, lhs.score_); // Projection
  auto p2 = std::tie(lhs.level_, lhs.score_); // Projection
  return p1 < p2;
};
std::sort(players.begin(), players.end(), cmp); 

与使用if-else语句的初始版本相比,这非常清晰易读。但这真的有效吗?看起来我们需要创建临时对象来比较两个玩家。在微基准测试中运行这个代码并检查生成的代码时,使用std::tie()实际上没有任何开销;事实上,在这个例子中,使用std::tie()的版本比使用if-else语句的版本稍微快一些。

使用范围算法,我们可以通过将投影作为参数提供给std::ranges::sort()来进行排序,使代码更加清晰:

std::ranges::sort(players, std::less{}, [](const Player& p) {
  return std::tie(p.level_, p.score_); 
}); 

这是std::tuple在不需要完整的具有命名成员的结构的情况下使用的一个例子,而不会在代码中牺牲任何清晰度。

例 2:反射

术语反射指的是在不知道类的内容的情况下检查类的能力。与许多其他编程语言不同,C++没有内置的反射,这意味着我们必须自己编写反射功能。反射计划包括在未来版本的 C++标准中;希望我们能在 C++23 中看到这个功能。

在这个例子中,我们将限制反射,使类能够迭代它们的成员,就像我们可以迭代元组的成员一样。通过使用反射,我们可以创建用于序列化或记录的通用函数,这些函数可以自动适用于任何类。这减少了在 C++中传统上需要的大量样板代码。

使一个类反映其成员

由于我们需要自己实现所有的反射功能,我们将从通过一个名为reflect()的函数公开成员变量开始。我们将继续使用在上一节中介绍的Player类。在这里,我们添加reflect()成员函数和一个构造函数的样子如下:

class Player {
public:
  Player(std::string name, int level, int score)
      : name_{std::move(name)}, level_{level}, score_{score} {}

  auto reflect() const {
    return std::tie(name_, level_, score_);
  } 
private:
  std::string name_;
  int level_{};
  int score_{};
}; 

reflect()成员函数通过调用std::tie()返回成员变量的引用的元组。我们现在可以开始使用reflect()函数,但首先,关于使用手工制作的反射的替代方案的说明。

简化反射的 C++库

在 C++库世界中已经有了相当多的尝试来简化反射的创建。一个例子是 Louis Dionne 的元编程库Boost Hana,它通过一个简单的宏为类提供了反射能力。最近,Boost还添加了Precise and Flat Reflection,由 Anthony Polukhin 编写,它自动反映类的公共内容,只要所有成员都是简单类型。

然而,为了清晰起见,在这个例子中,我们只会使用我们自己的reflect()成员函数。

使用反射

现在Player类具有反射其成员变量的能力,我们可以自动创建大量功能,否则需要我们重新输入每个成员变量。正如您可能已经知道的,C++可以自动生成构造函数、析构函数和比较运算符,但其他运算符必须由程序员实现。其中一个这样的函数是operator<<(),它将其内容输出到流中以便将其存储在文件中,或更常见的是在应用程序日志中记录它们。

通过重载operator<<()并使用我们在本章前面实现的tuple_for_each()函数模板,我们可以简化为类创建std::ostream输出的过程,如下所示:

auto& operator<<(std::ostream& ostr, const Player& p) { 
  tuple_for_each(p.reflect(), &ostr { 
    ostr << m << " "; 
  }); 
  return ostr; 
} 

现在,该类可以与任何std::ostream类型一起使用,如下所示:

auto v = Player{"Kai", 4, 2568}; 
std::cout << v;                  // Prints: "Kai 4 2568 " 

通过通过元组反射我们的类成员,我们只需要在类中添加/删除成员时更新我们的反射函数,而不是更新每个函数并迭代所有成员变量。

有条件地重载全局函数

现在,我们有了一个使用反射而不是手动输入每个变量来编写大量函数的机制,但我们仍然需要为每种类型输入简化的大量函数。如果我们希望这些函数为每种可以反射的类型生成呢?

我们可以通过使用约束条件来有条件地为所有具有reflect()成员函数的类启用operator<<()

首先,我们需要创建一个指向reflect()成员函数的新概念:

template <typename T> 
concept Reflectable = requires (T& t) {
  t.reflect();
}; 

当然,这个概念只是检查一个类是否有一个名为reflect()的成员函数;它并不总是返回一个元组。总的来说,我们应该对这种只使用单个成员函数的弱概念持怀疑态度,但它对于例子来说是有用的。无论如何,我们现在可以在全局命名空间中重载operator<<(),使所有可反射的类都能够被比较并打印到std::ostream中:

auto& operator<<(std::ostream& os, const Reflectable auto& v) {
  tuple_for_each(v.reflect(), &os {
    os << m << " ";
  });
  return os;
} 

前面的函数模板只会为包含reflect()成员函数的类型实例化,并因此不会与任何其他重载发生冲突。

测试反射能力

现在,我们已经准备就绪:

  • 我们将测试的Player类有一个reflect()成员函数,返回对其成员的引用的元组

  • 全局std::ostream& operator<<()已经重载了可反射类型

下面是一个简单的测试,用于验证这个功能:

int main() {
  auto kai = Player{"Kai", 4, 2568}; 
  auto ari = Player{"Ari", 2, 1068}; 

  std::cout << kai; // Prints "Kai 4 2568" 
  std::cout << ari; // Prints "Ari 2 1068" 
} 

这些例子展示了std::tie()std::tuple等小而重要的实用工具与一点元编程结合时的用处。

总结

在本章中,您已经学会了如何使用std::optional来表示代码中的可选值。您还看到了如何将std::pairstd::tuplestd::anystd::variant与标准容器和元编程结合在一起,以存储和迭代不同类型的元素。您还了解到std::tie()是一个概念上简单但功能强大的工具,可用于投影和反射。

在下一章中,您将了解如何进一步扩展您的 C++工具箱,通过学习如何构建隐藏的代理对象来创建库。

第十章:代理对象和延迟评估

在本章中,您将学习如何使用代理对象和延迟评估,以推迟执行某些代码直到需要。使用代理对象可以在后台进行优化,从而保持公开的接口不变。

本章涵盖了:

  • 懒惰和急切评估

  • 使用代理对象避免多余的计算

  • 在使用代理对象时重载运算符

引入延迟评估和代理对象

首先,本章中使用的技术是用于隐藏库中的优化技术,不让库的用户看到。这很有用,因为将每个单独的优化技术公开为一个单独的函数需要用户的大量关注和教育。它还使代码库膨胀了大量特定的函数,使其难以阅读和理解。通过使用代理对象,我们可以在后台实现优化;结果代码既经过优化又易读。

懒惰与急切评估

懒惰 评估是一种技术,用于推迟操作,直到真正需要其结果。相反,立即执行操作的情况称为急切评估。在某些情况下,急切评估是不希望的,因为我们可能最终构造一个从未使用的值。

为了演示急切和懒惰评估之间的差异,让我们假设我们正在编写某种具有多个级别的游戏。每当完成一个级别时,我们需要显示当前分数。在这里,我们将专注于游戏的一些组件:

  • 一个ScoreView类负责显示用户的分数,如果获得了奖励,则显示可选的奖励图像

  • 代表加载到内存中的图像的Image

  • 从磁盘加载图像的load()函数

在这个例子中,类和函数的实现并不重要,但声明看起来是这样的:

class Image { /* ... */ };                   // Buffer with JPG data
auto load(std::string_view path) -> Image;   // Load image at path
class ScoreView {
public:
  // Eager, requires loaded bonus image
  void display(const Image& bonus);
  // Lazy, only load bonus image if necessary
  void display(std::function<Image()> bonus);
  // ...
}; 

提供了两个display()版本:第一个需要完全加载的奖励图像,而第二个接受一个只在需要奖励图像时调用的函数。使用第一个急切版本会是这样:

// Always load bonus image eagerly
const auto eager = load("/images/stars.jpg");
score.display(eager); 

使用第二个懒惰版本会是这样:

// Load default image lazily if needed
auto lazy = [] { return load("/images/stars.jpg"); }; 
score.display(lazy); 

急切版本将始终将默认图像加载到内存中,即使它从未显示过。然而,奖励图像的延迟加载将确保只有在ScoreView真正需要显示奖励图像时才加载图像。

这是一个非常简单的例子,但其思想是,您的代码几乎以与急切声明相同的方式表达。隐藏代码懒惰评估的技术是使用代理对象。

代理对象

代理对象是内部库对象,不打算对库的用户可见。它们的任务是推迟操作直到需要,并收集表达式的数据,直到可以评估和优化。然而,代理对象在黑暗中行事;库的用户应该能够处理表达式,就好像代理对象不存在一样。换句话说,使用代理对象,您可以在库中封装优化,同时保持接口不变。现在您将学习如何使用代理对象来懒惰地评估更高级的表达式。

使用代理对象避免构造对象

急切评估可能会导致不必要地构造对象。通常这不是问题,但如果对象昂贵(例如因为堆分配),可能有合理的理由优化掉无用的短暂对象的构造。

使用代理对象比较连接的字符串

现在我们将通过一个使用代理对象的最小示例,让您了解它们是什么以及可以用于什么。它并不意味着为您提供一个通用的生产就绪的优化字符串比较解决方案。

话虽如此,看看这段代码片段,它连接两个字符串并比较结果:

auto a = std::string{"Cole"}; 
auto b = std::string{"Porter"}; 
auto c = std::string{"ColePorter"}; 
auto is_equal = (a + b) == c;        // true 

这是前面代码片段的可视化表示:

图 10.1:将两个字符串连接成一个新字符串

问题在于,(a + b)构造了一个新的临时字符串,以便将其与c进行比较。我们可以直接比较连接,而不是构造一个新的字符串,就像这样:

auto is_concat_equal(const std::string& a, const std::string& b,
                     const std::string& c) { 
  return  
    a.size() + b.size() == c.size() && 
    std::equal(a.begin(), a.end(), c.begin()) &&  
    std::equal(b.begin(), b.end(), c.begin() + a.size()); 
} 

然后我们可以这样使用它:

auto is_equal = is_concat_equal(a, b, c); 

就性能而言,我们取得了胜利,但从语法上讲,一个代码库中充斥着这种特殊情况的便利函数很难维护。因此,让我们看看如何在保持原始语法不变的情况下实现这种优化。

实现代理

首先,我们将创建一个代表两个字符串连接的代理类:

struct ConcatProxy { 
  const std::string& a; 
  const std::string& b; 
}; 

然后,我们将构建自己的String类,其中包含一个std::string和一个重载的operator+()函数。请注意,这是如何创建和使用代理对象的示例;创建自己的String类不是我推荐的做法:

class String { 
public: 
  String() = default; 
  String(std::string str) : str_{std::move(str)} {} 
  std::string str_{};
}; 

auto operator+(const String& a, const String& b) {
   return ConcatProxy{a.str_, b.str_};
} 

这是前面代码片段的可视化表示:

图 10.2:代表两个字符串连接的代理对象

最后,我们将创建一个全局的operator==()函数,该函数将使用优化的is_concat_equal()函数,如下所示:

auto operator==(ConcatProxy&& concat, const String& str) {
  return is_concat_equal(concat.a, concat.b, str.str_); 
} 

现在我们已经准备就绪,可以兼得两全:

auto a = String{"Cole"}; 
auto b = String{"Porter"}; 
auto c = String{"ColePorter"}; 
auto is_equal = (a + b) == c;     // true 

换句话说,我们在保留使用operator==()的表达语法的同时,获得了is_concat_equal()的性能。

rvalue 修饰符

在前面的代码中,全局的operator==()函数只接受ConcatProxy rvalues:

auto operator==(ConcatProxy&& concat, const String& str) { // ... 

如果我们接受一个ConcatProxy lvalue,我们可能会意外地误用代理,就像这样:

auto concat = String{"Cole"} + String{"Porter"};
auto is_cole_porter = concat == String{"ColePorter"}; 

问题在于,持有"Cole""Porter"的临时String对象在比较执行时已被销毁,导致失败。(请记住,ConcatProxy类只持有对字符串的引用。)但由于我们强制concat对象为 rvalue,前面的代码将无法编译,从而避免了可能的运行时崩溃。当然,你可以通过使用std::move(concat) == String("ColePorter")将其强制编译,但这不是一个现实的情况。

分配一个连接的代理

现在,你可能会想,如果我们实际上想将连接的字符串存储为一个新的字符串而不仅仅是比较它,该怎么办?我们所做的就是简单地重载一个operator String()函数,如下所示:

struct ConcatProxy {
  const std::string& a;
  const std::string& b;
  operator String() const && { return String{a + b}; }
}; 

两个字符串的连接现在可以隐式转换为一个字符串:

String c = String{"Marc"} + String{"Chagall"}; 

不过,有一个小问题:我们无法使用auto关键字初始化新的String对象,因为这将导致ConcatProxy

auto c = String{"Marc"} + String{"Chagall"};
// c is a ConcatProxy due to the auto keyword here 

不幸的是,我们无法绕过这一点;结果必须显式转换为String

现在是时候看看我们优化版本与正常情况相比有多快了。

性能评估

为了评估性能优势,我们将使用以下基准测试,连接并比较大小为5010,000个字符串:

template <typename T>
auto create_strings(int n, size_t length) -> std::vector<T> {
  // Create n random strings of the specified length
  // ...
}
template <typename T> 
void bm_string_compare(benchmark::State& state) {
  const auto n = 10'000, length = 50;
  const auto a = create_strings<T>(n, length);
  const auto b = create_strings<T>(n, length);
  const auto c = create_strings<T>(n, length * 2);
  for (auto _ : state) {
    for (auto i = 0; i < n; ++i) {
      auto is_equal = a[i] + b[i] == c[i];
      benchmark::DoNotOptimize(is_equal);
    }
  }
}
BENCHMARK_TEMPLATE(bm_string_compare, std::string);
BENCHMARK_TEMPLATE(bm_string_compare, String);
BENCHMARK_MAIN(); 

在 Intel Core i7 CPU 上执行时,我使用 gcc 实现了 40 倍的加速。直接使用std::string的版本完成时间为 1.6 毫秒,而使用String的代理版本仅为 0.04 毫秒。当使用长度为 10 的短字符串进行相同的测试时,加速约为 20 倍。造成这种巨大变化的一个原因是,小字符串将通过利用第七章 内存管理中讨论的小字符串优化来避免堆分配。基准测试告诉我们,当我们摆脱临时字符串和可能伴随其而来的堆分配时,使用代理对象的加速是相当可观的。

ConcatProxy 类帮助我们隐藏了在比较字符串时的优化。希望这个简单的例子能激发您开始思考在实现性能优化的同时保持 API 设计清晰的方法。

接下来,您将看到另一个有用的优化,可以隐藏在代理类后面。

推迟 sqrt 计算

本节将向您展示如何使用代理对象来推迟或甚至避免在比较二维向量长度时使用计算量大的 std::sqrt() 函数。

一个简单的二维向量类

让我们从一个简单的二维向量类开始。它有 xy 坐标,以及一个名为 length() 的成员函数,用于计算从原点到位置 (x, y) 的距离。我们将这个类称为 Vec2D。以下是定义:

class Vec2D {
public:
  Vec2D(float x, float y) : x_{x}, y_{y} {}
  auto length() const {
    auto squared = x_*x_ + y_*y_;
    return std::sqrt(squared);
  }
private:
  float x_{};
  float y_{};
}; 

以下是客户端如何使用 Vec2D 的示例:

auto a = Vec2D{3, 4}; 
auto b = Vec2D{4, 4};
auto shortest = a.length() < b.length() ? a : b;
auto length = shortest.length();
std::cout << length; // Prints 5 

该示例创建了两个向量并比较它们的长度。然后将最短向量的长度打印到标准输出。图 10.3 说明了向量和到原点的计算长度:

图 10.3:两个长度不同的二维向量。向量 a 的长度为 5。

底层数学

在计算的数学中,您可能会注意到一些有趣的事情。用于长度的公式如下:

然而,如果我们只需要比较两个向量之间的距离,平方长度就足够了,如下面的公式所示:

平方根可以使用函数 std::sqrt() 计算。但是,正如前面提到的,如果我们只想比较两个向量的长度,就不需要进行平方根运算。好处在于 std::sqrt() 是一个相对缓慢的操作,这意味着如果我们通过长度比较许多向量,就可以获得一些性能。问题是,我们如何在保持清晰语法的同时实现这一点?让我们看看如何使用代理对象在比较长度时在后台执行这种优化。

为了清晰起见,我们从原始的 Vec2D 类开始,但是我们将 length() 函数分成两部分 - length_squared()length(),如下所示:

class Vec2D {
public:
  Vec2D(float x, float y) : x_{x}, y_{y} {}  
  auto length_squared() const {
    return x_*x_ + y_*y_;  
  }
  auto length() const {
    return std::sqrt(length_squared());
  }
private:
  float x_{};
  float y_{};
}; 

现在,我们 Vec2D 类的客户端可以使用 length_squared() 来获得一些性能优势,当只比较不同向量的长度时。

假设我们想要实现一个方便的实用函数,返回一系列 Vec2D 对象的最小长度。现在我们有两个选择:在进行比较时使用 length() 函数或 length_squared() 函数。它们对应的实现如下示例所示:

// Simple version using length()
auto min_length(const auto& r) -> float {
  assert(!r.empty());
  auto cmp = [](auto&& a, auto&& b) {
    return a.length () < b.length();
  };
  auto it = std::ranges::min_element(r, cmp);
  return it->length();
} 

使用 length_squared() 进行比较的第二个优化版本将如下所示:

// Fast version using length_squared()
auto min_length(const auto& r) -> float {
  assert(!r.empty());
  auto cmp = [](auto&& a, auto&& b) {
    return a.length_squared() < b.length_squared(); // Faster
  };
  auto it = std::ranges::min_element(r, cmp);
  return it->length(); // But remember to use length() here!
} 

使用 cmp 内部的 length() 的第一个版本具有更可读和更容易正确的优势,而第二个版本具有更快的优势。提醒一下,第二个版本的加速是因为我们可以避免在 cmp lambda 内部调用 std::sqrt()

最佳解决方案是具有使用 length() 语法的第一个版本和使用 length_squared() 性能的第二个版本。

根据这个类将被使用的上下文,可能有很好的理由暴露 length_squared() 这样的函数。但是让我们假设我们团队中的其他开发人员不理解为什么有 length_squared() 函数,并且觉得这个类很混乱。因此,我们决定想出更好的方法,避免有两个暴露向量长度属性的函数版本。正如您可能已经猜到的那样,是时候使用代理类来隐藏这种复杂性了。

为了实现这一点,我们不是从length()成员函数中返回一个float值,而是返回一个对用户隐藏的中间对象。根据用户如何使用隐藏的代理对象,它应该避免std::sqrt()操作,直到真正需要。在接下来的部分中,我们将实现一个名为LengthProxy的类,它将是我们从Vec2D::length()返回的代理对象的类型。

实现 LengthProxy 对象

现在是时候实现LengthProxy类了,其中包含一个代表平方长度的float数据成员。实际的平方长度永远不会暴露出来,以防止类的用户将平方长度与常规长度混淆。相反,LengthProxy有一个隐藏的friend函数,用于比较其平方长度和常规长度,如下所示:

class LengthProxy { 
public: 
  LengthProxy(float x, float y) : squared_{x * x + y * y} {} 
  bool operator==(const LengthProxy& other) const = default; 
  auto operator<=>(const LengthProxy& other) const = default; 
  friend auto operator<=>(const LengthProxy& proxy, float len) { 
    return proxy.squared_ <=> len*len;   // C++20
  } 
  operator float() const {      // Allow implicit cast to float
    return std::sqrt(squared_); 
  }  
private: 
  float squared_{}; 
}; 

我们已经定义了operator float(),以允许从LengthProxyfloat的隐式转换。LengthProxy对象也可以相互比较。通过使用新的 C++20 比较,我们简单地将等号运算符和三路比较运算符设置为default,让编译器为我们生成所有必要的比较运算符。

接下来,我们重写Vec2D类,以返回LengthProxy类的对象,而不是实际的float长度:

class Vec2D { 
public: 
  Vec2D(float x, float y) : x_{x}, y_{y} {} 
  auto length() const { 
    return LengthProxy{x_, y_};    // Return proxy object
  } 
  float x_{}; 
  float y_{}; 
}; 

有了这些补充,现在是时候使用我们的新代理类了。

使用 LengthProxy 比较长度

在这个例子中,我们将比较两个向量ab,并确定a是否比b短。请注意,代码在语法上看起来与我们没有使用代理类时完全相同:

auto a = Vec2D{23, 42}; 
auto b = Vec2D{33, 40}; 
bool a_is_shortest = a.length() < b.length(); 

在后台,最终语句会扩展为类似于这样的内容:

// These LengthProxy objects are never visible from the outside
LengthProxy a_length = a.length(); 
LengthProxy b_length = b.length(); 
// Member operator< on LengthProxy is invoked, 
// which compares member squared_ 
auto a_is_shortest = a_length < b_length; 

不错!std::sqrt()操作被省略,而Vec2D类的接口仍然完整。我们之前实现的min_length()的简化版本现在执行比较更有效,因为省略了std::sqrt()操作。接下来是简化的实现,现在也变得高效了:

// Simple and efficient 
auto min_length(const auto& r) -> float { 
  assert(!r.empty()); 
  auto cmp = [](auto&& a, auto&& b) { 
    return a.length () < b.length(); 
  }; 
  auto it = std::ranges::min_element(r, cmp); 
  return it->length(); 
} 

Vec2D对象之间的优化长度比较现在是在后台进行的。实现min_length()函数的程序员不需要知道这种优化,就能从中受益。让我们看看如果我们需要实际长度会是什么样子。

使用 LengthProxy 计算长度

当请求实际长度时,调用代码会有一些变化。为了触发对float的隐式转换,我们必须在声明下面的len变量时承诺一个float;也就是说,我们不能像通常那样只使用auto

auto a = Vec2D{23, 42};
float len = a.length(); // Note, we cannot use auto here 

如果我们只写autolen对象将是LengthProxy类型,而不是float。我们不希望我们的代码库的用户明确处理LengthProxy对象;代理对象应该在暗中运行,只有它们的结果应该被利用(在这种情况下,比较结果或实际距离值是float)。尽管我们无法完全隐藏代理对象,让我们看看如何收紧它们以防止误用。

防止误用 LengthProxy

您可能已经注意到,使用LengthProxy类可能会导致性能变差的情况。在接下来的示例中,根据程序员对长度值的请求,多次调用std::sqrt()函数:

auto a = Vec2D{23, 42};
auto len = a.length();
float f0 = len;       // Assignment invoked std::sqrt()
float f1 = len;       // std::sqrt() of len is invoked again 

尽管这是一个人为的例子,但在现实世界中可能会出现这种情况,我们希望强制Vec2d的用户每个LengthProxy对象只调用一次operator float()。为了防止误用,我们使operator float()成员函数只能在 rvalue 上调用;也就是说,只有当LengthProxy对象没有绑定到变量时,才能将其转换为浮点数。

我们通过在operator float()成员函数上使用&&作为修饰符来强制执行此行为。&&修饰符的工作原理与const修饰符相同,但是const修饰符强制成员函数不修改对象,而&&修饰符强制函数在临时对象上操作。

修改如下:

operator float() const && { return std::sqrt(squared_); } 

如果我们在绑定到变量的LengthProxy对象上调用operator float(),例如以下示例中的dist对象,编译器将拒绝编译:

auto a = Vec2D{23, 42};
auto len = a.length(); // len is of type LenghtProxy
float f = len;         // Doesn't compile: len is not an rvalue 

但是,我们仍然可以直接在从length()返回的 rvalue 上调用operator float(),就像这样:

auto a = Vec2D{23, 42}; 
float f = a.length();    // OK: call operator float() on rvalue 

临时的LengthProxy实例仍将在后台创建,但由于它没有绑定到变量,因此我们可以将其隐式转换为float。这将防止滥用,例如在LengthProxy对象上多次调用operator float()

性能评估

为了看看我们实际获得了多少性能,让我们来测试一下min_element()的以下版本:

auto min_length(const auto& r) -> float {
  assert(!r.empty());
  auto it = std::ranges::min_element(r, [](auto&& a, auto&& b) {
    return a.length () < b.length(); });
  return it->length();
} 

为了将代理对象优化与其他内容进行比较,我们将定义一个另一版本Vec2DSlow,它总是使用std::sqrt()计算实际长度:

struct Vec2DSlow {
  float length() const {                  // Always compute
    auto squared = x_ * x_ + y_ * y_;     // actual length
    return std::sqrt(squared);            // using sqrt()
  }
  float x_, y_;
}; 

使用 Google Benchmark 和函数模板,我们可以看到在查找 1,000 个向量的最小长度时获得了多少性能提升:

template <typename T> 
void bm_min_length(benchmark::State& state) {
  auto v = std::vector<T>{};
  std::generate_n(std::back_inserter(v), 1000, [] {
    auto x = static_cast<float>(std::rand());
    auto y = static_cast<float>(std::rand());
    return T{x, y};
  });
  for (auto _ : state) {
    auto res = min_length(v);
    benchmark::DoNotOptimize(res);
  }
}
BENCHMARK_TEMPLATE(bm_min_length, Vec2DSlow);
BENCHMARK_TEMPLATE(bm_min_length, Vec2D);
BENCHMARK_MAIN(); 

在 Intel i7 CPU 上运行此基准测试生成了以下结果:

  • 使用未优化的Vec2DSlowstd::sqrt()花费了 7,900 ns

  • 使用LengthProxyVec2D花费了 1,800 ns

这种性能提升相当于超过 4 倍的加速。

这是一个例子,说明了我们如何在某些情况下避免不必要的计算。但是,我们成功地将优化封装在代理对象内部,而不是使Vec2D的接口更加复杂,以便所有客户端都能从优化中受益,而不会牺牲清晰度。

C++中用于优化表达式的相关技术是表达式模板。这利用模板元编程在编译时生成表达式树。该技术可用于避免临时变量并实现延迟评估。表达式模板是使 Boost 基本线性代数库uBLAS)和Eigen中的线性代数算法和矩阵运算快速的技术之一,eigen.tuxfamily.org。您可以在 Bjarne Stroustrup 的《C++程序设计语言》第四版中了解有关如何在设计矩阵类时使用表达式模板和融合操作的更多信息。

我们将通过查看与重载运算符结合使用代理对象时的其他受益方式来结束本章。

创造性的运算符重载和代理对象

正如您可能已经知道的那样,C++具有重载多个运算符的能力,包括标准数学运算符,例如加号和减号。重载的数学运算符可用于创建自定义数学类,使其行为类似于内置数值类型,以使代码更易读。另一个例子是流运算符,在标准库中重载以将对象转换为流,如下所示:

std::cout << "iostream " << "uses " << "overloaded " << "operators."; 

然而,一些库在其他上下文中使用重载。如前所述,Ranges 库使用重载来组合视图,如下所示:

const auto r = {-5, -4, -3, -2, -1, 0, 1, 2, 3, 4, 5};
auto odd_positive_numbers = r 
  | std::views::filter([](auto v) { return v > 0; }) 
  | std::views::filter([](auto v) { return (v % 2) == 1; }); 

接下来,我们将探讨如何在代理类中使用管道运算符。

管道运算符作为扩展方法

与其他语言相比,例如 C#,Swift 和 JavaScript,C++不支持扩展方法;也就是说,您不能在本地使用新的成员函数扩展类。

例如,您不能使用如下所示的std::vector扩展contains(T val)函数:

auto numbers = std::vector{1, 2, 3, 4};
auto has_two = numbers.contains(2); 

但是,您可以重载管道运算符以实现这种几乎等效的语法:

auto has_two = numbers | contains(2); 

通过使用代理类,可以轻松实现这一点。

管道运算符

我们的目标是实现一个简单的管道操作符,以便我们可以编写以下内容:

auto numbers = std::vector{1, 3, 5, 7, 9}; 
auto seven = 7; 
bool has_seven = numbers | contains(seven); 

使用可管道化语法的contains()函数有两个参数:numbersseven。由于左参数numbers可以是任何东西,我们需要重载以在右侧包含一些唯一的东西。因此,我们创建了一个名为ContainsProxystruct模板,它保存右侧的参数;这样,重载的管道操作符可以识别重载:

template <typename T>
struct ContainsProxy { const T& value_; };
template <typename Range, typename T>
auto operator|(const Range& r, const ContainsProxy<T>& proxy) {
  const auto& v = proxy.value_;
  return std::find(r.begin(), r.end(), v) != r.end();
} 

现在我们可以像这样使用ContainsProxy

auto numbers = std::vector{1, 3, 5, 7, 9}; 
auto seven = 7; 
auto proxy = ContainsProxy<decltype(seven)>{seven};  
bool has_seven = numbers | proxy; 

管道操作符有效,尽管语法仍然很丑陋,因为我们需要指定类型。为了使语法更整洁,我们可以简单地创建一个方便的函数,它接受该值并创建一个包含类型的代理:

template <typename T>
auto contains(const T& v) { return ContainsProxy<T>{v}; } 

这就是我们需要的全部;现在我们可以将其用于任何类型或容器:

auto penguins = std::vector<std::string>{"Ping","Roy","Silo"};
bool has_silo = penguins | contains("Silo"); 

本节涵盖的示例展示了实现管道操作符的一种基本方法。例如,Paul Fultz 的 Ranges 库和 Fit 库(可在github.com/pfultz2/Fit找到)实现了适配器,它们接受常规函数并赋予其使用管道语法的能力。

总结

在本章中,您学会了惰性求值和急性求值之间的区别。您还学会了如何使用隐藏的代理对象在幕后实现惰性求值,这意味着您现在了解如何在保留类的易于使用的接口的同时实现惰性求值优化。将复杂的优化隐藏在库类中,而不是在应用程序代码中暴露它们,可以使应用程序代码更易读,更少出错。

在下一章中,我们将转移重点,转向使用 C++进行并发和并行编程。

第十一章:并发

在上一章中涵盖了惰性求值和代理对象之后,我们现在将探讨如何使用共享内存在 C++中编写并发程序。我们将探讨如何通过编写没有数据竞争和死锁的程序来使并发程序正确运行。本章还将包含关于如何使并发程序以低延迟和高吞吐量运行的建议。

在继续之前,你应该知道本章不是并发编程的完整介绍,也不会涵盖 C++中所有并发的细节。相反,本章是 C++中编写并发程序的核心构建块的介绍,结合了一些与性能相关的指导方针。如果你以前没有编写过并发程序,最好通过一些入门材料来了解并发编程的理论方面。死锁、临界区、条件变量和互斥锁等概念将会被简要讨论,但这将更像是一个复习而不是对概念的彻底介绍。

本章涵盖以下内容:

  • 并发编程的基础知识,包括并行执行、共享内存、数据竞争和死锁

  • C++线程支持库、原子库和 C++内存模型的介绍

  • 无锁编程的简短示例

  • 性能指南

理解并发编程的基础知识

并发程序可以同时执行多个任务。并发编程一般比顺序编程更难,但有几个原因可以使程序从并发中受益:

  • 效率:今天的智能手机和台式电脑都有多个 CPU 核心,可以并行执行多个任务。如果你成功地将一个大任务分割成可以并行运行的子任务,理论上可以将大任务的运行时间除以 CPU 核心数。对于在单核机器上运行的程序,如果一个任务是 I/O 绑定的,仍然可以获得性能上的提升。当一个子任务在等待 I/O 时,其他子任务仍然可以在 CPU 上执行有用的工作。

  • 响应性和低延迟环境:对于具有图形用户界面的应用程序,重要的是永远不要阻塞 UI,以免应用程序变得无响应。为了防止无响应,通常会让长时间运行的任务(如从磁盘加载文件或从网络获取数据)在单独的后台线程中执行,以便负责 UI 的线程永远不会被长时间运行的任务阻塞。低延迟很重要的另一个例子是实时音频。负责生成音频数据缓冲区的函数在单独的高优先级线程中执行,而程序的其余部分可以在低优先级线程中运行,以处理 UI 等。

  • 模拟:并发可以使模拟现实世界中并发系统变得更容易。毕竟,我们周围的大多数事情都是同时发生的,有时很难用顺序编程模型来建模并发流。本书不会专注于模拟,而是专注于并发的性能相关方面。

并发为我们解决了许多问题,但也引入了新问题,接下来我们将讨论这些问题。

并发编程为何如此困难?

有许多原因使并发编程变得困难,如果你以前编写过并发程序,你很可能已经遇到了以下列出的原因:

  • 以安全的方式在多个线程之间共享状态是困难的。每当我们有可以同时读写的数据时,我们需要一些方法来保护这些数据免受数据竞争的影响。稍后你将看到许多这样的例子。

  • 由于多个并行执行流,并发程序通常更难推理。

  • 并发使调试变得复杂。由于数据竞争而导致的错误可能非常难以调试,因为它们依赖于线程的调度方式。这类错误很难复现,并且在最坏的情况下,甚至在使用调试器运行程序时可能会消失。有时,对控制台的无辜调试跟踪可能会改变多线程程序的行为方式,并使错误暂时消失。你已经被警告了!

在我们开始使用 C++进行并发编程之前,将介绍一些与并发和并行编程相关的一般概念。

并发和并行

并发并行是有时可以互换使用的两个术语。然而,它们并不相同,重要的是要理解它们之间的区别。如果程序在重叠的时间段内具有多个单独的控制流运行,则称其并发运行。在 C++中,每个单独的控制流由一个线程表示。这些线程可能会或可能不会同时执行。如果它们同时执行,就称为并行执行。要使并发程序并行运行,需要在支持指令并行执行的机器上执行它;也就是说,具有多个 CPU 核心的机器。

乍一看,似乎很明显我们总是希望并发程序尽可能并行运行,出于效率原因。然而,这并不一定总是正确的。本章涵盖的许多同步原语(如互斥锁)仅需要支持线程的并行执行。不在并行运行的并发任务不需要相同的锁定机制,可能更容易推理。

时间片

你可能会问,“在只有一个 CPU 核心的机器上如何执行并发线程?”答案是时间片。这是操作系统用来支持进程并发执行的相同机制。为了理解时间片,让我们假设我们有两个应该同时执行的独立指令序列,如下图所示:

图 11.1:两个独立的指令序列

编号的方框表示指令。每个指令序列在一个单独的线程中执行,标记为T1T2。操作系统将安排每个线程在 CPU 上有一定的时间,然后执行上下文切换。上下文切换将存储正在运行的线程的当前状态,并加载应该执行的线程的状态。这样做的频率足够高,以至于看起来好像线程在同时运行。然而,上下文切换是耗时的,并且每次新线程在 CPU 核心上执行时很可能会产生大量的缓存未命中。因此,我们不希望上下文切换发生得太频繁。

下图显示了两个线程在单个 CPU 上调度的可能执行顺序:

图 11.2:两个线程的可能执行。点表示上下文切换

T1 线程的第一条指令开始执行,然后进行上下文切换,让 T2 线程执行前两条指令。作为程序员,我们必须确保程序可以按预期运行,无论操作系统调度程序如何调度任务。如果某个序列因某种原因无效,有方法可以通过使用锁来控制指令执行的顺序,这将在后面介绍。

如果一台机器有多个 CPU 核心,就有可能并行执行两个线程。然而,并没有保证(甚至是不太可能)这两个线程在程序的整个生命周期中都会在各自的核心上执行。整个系统共享 CPU 的时间,所以调度程序也会让其他进程执行。这就是为什么线程不会被调度到专用核心上的原因之一。

图 11.3显示了相同的两个线程的执行情况,但现在它们在一个有两个 CPU 核心的机器上运行。正如你所看到的,第一个线程的第二和第三条指令(白色框)与另一个线程同时执行 - 两个线程在并行执行:

图 11.3:两个线程在多核机器上执行。这使得两个线程可以并行执行。

接下来让我们讨论共享内存。

共享内存

在同一进程中创建的线程共享相同的虚拟内存。这意味着一个线程可以访问进程内可寻址的任何数据。操作系统使用虚拟内存在进程之间保护内存,但对于意外访问进程内未打算在不同线程之间共享的内存,操作系统不会提供保护。虚拟内存只保护我们免受访问分配给我们自己的不同进程中的内存的影响。

在多个线程之间共享内存可以是处理线程间通信的一种非常有效的方式。然而,在 C++中编写并发程序时,以安全的方式在线程之间共享内存是一个主要挑战之一。我们应该始终努力将线程之间共享的资源数量最小化。

幸运的是,并非所有内存默认都是共享的。每个线程都有自己的堆栈,用于存储本地变量和处理函数调用所需的其他数据。除非一个线程将本地变量的引用或指针传递给其他线程,否则其他线程将无法访问该线程的堆栈。这是尽可能使用堆栈的另一个原因(如果你在阅读第七章内存管理后还不相信堆栈是一个好地方存储数据)。

还有线程本地存储,有时缩写为TLS,它可以用来存储在线程上下文中是全局的,但在不同线程之间不共享的变量。线程本地变量可以被视为每个线程都有自己副本的全局变量。

其他所有内容默认情况下都是共享的;即堆上分配的动态内存、全局变量和静态局部变量。每当你有被某个线程改变的共享数据时,你需要确保没有其他线程同时访问该数据,否则就会出现数据竞争。

还记得第七章内存管理进程内存部分的图示吗?这里再次展示,但修改后显示了当一个进程包含多个线程时的情况。如下图所示,每个线程都有自己的堆栈内存,但所有线程只有一个堆:

图 11.4:进程的虚拟地址空间的可能布局

在这个例子中,进程包含三个线程。堆内存默认情况下被所有线程共享。

数据竞争

数据竞争发生在两个线程同时访问同一内存且至少一个线程正在改变数据时。如果你的程序有数据竞争,这意味着你的程序有未定义的行为。编译器和优化器会假设你的代码中没有数据竞争,并在这个假设下对其进行优化。这可能导致崩溃或其他完全令人惊讶的行为。换句话说,你绝对不能允许程序中出现数据竞争。编译器通常不会在编译时警告你有数据竞争,因为它们很难在编译时检测到。

调试数据竞争可能是一个真正的挑战,有时需要像ThreadSanitizer(来自 Clang)或Concurrency Visualizer(Visual Studio 扩展)这样的工具。这些工具通常会对代码进行插装,以便运行时库可以在调试程序运行时检测、警告或可视化潜在的数据竞争。

例子:数据竞争

图 11.5显示了两个线程要更新一个名为counter的整数。想象一下,这些线程都在使用指令++counter来增加一个全局计数器变量。事实证明,增加一个int可能涉及多个 CPU 指令。这可以在不同的 CPU 上以不同的方式完成,但假设++counter生成以下虚构的机器指令:

  • R:从内存中读取 counter

  • +1:增加 counter

  • W:将新的 counter 值写入内存

现在,如果我们有两个线程要更新counter的值,初始值为 42,我们期望在这两个线程运行后它变成 44。然而,如下图所示,没有保证指令会按顺序执行以确保counter变量的正确增加。

图 11.5:两个线程都在增加相同的共享变量

没有数据竞争,counter 本应该达到值 44,但实际上只达到了 43。

在这个例子中,两个线程都读取值 42 并将该值增加到 43。然后,它们都写入新值 43,这意味着我们永远不会达到正确的答案 44。如果第一个线程能够在下一个线程开始读取之前写入值 43,我们最终会得到 44。还要注意,即使只有一个 CPU 核心,这也是可能的。调度程序可以以类似的方式安排这两个线程,以便在任何写入之前执行两个读取指令。

再次强调,这只是一种可能的情况,但重要的是行为是未定义的。当程序存在数据竞争时,任何事情都可能发生。其中一个例子是tearing,这是torn readstorn writes的常用术语。当一个线程在另一个线程同时读取值时向内存写入值的部分,因此最终得到一个损坏的值时,就会发生这种情况。

避免数据竞争

我们如何避免数据竞争?有两个主要选项:

  • 使用原子数据类型而不是int。这将告诉编译器以原子方式执行读取、增加和写入。我们将在本章后面花更多时间讨论原子数据类型。

  • 使用互斥锁(mutex)来保证多个线程永远不会同时执行关键部分。关键部分是代码中不得同时执行的地方,因为它更新或读取可能会产生数据竞争的共享内存。

值得强调的是,不可变数据结构——永远不会改变的数据结构——可以被多个线程访问而不会有任何数据竞争的风险。减少可变对象的使用有很多好处,但在编写并发程序时变得更加重要。一个常见的模式是总是创建新的不可变对象,而不是改变现有对象。当新对象完全构建并表示新状态时,它可以与旧对象交换。这样,我们可以最小化代码的关键部分。只有交换是一个关键部分,因此需要通过原子操作或互斥体来保护。

互斥体

互斥锁,简称互斥锁,是用于避免数据竞争的同步原语。需要进入临界区的线程首先需要锁定互斥锁(有时锁定也称为获取互斥锁)。这意味着在持有锁的第一个线程解锁互斥锁之前,没有其他线程可以锁定相同的互斥锁。这样,互斥锁保证一次只有一个线程在临界区内部。

图 11.6中,您可以看到在数据竞争示例部分演示的竞争条件是如何通过使用互斥锁来避免的。标记为L的指令是锁定指令,标记为U的指令是解锁指令。在核心 0 上执行的第一个线程首先到达临界区并在读取计数器的值之前锁定互斥锁。然后,它将 1 添加到计数器并将其写回内存。之后,它释放锁。

第二个线程,在核心 1 上执行,在第一个线程获取互斥锁后立即到达临界区。由于互斥锁已经被锁定,线程被阻塞,直到第一个线程无干扰地更新计数器并释放互斥锁:

图 11.6:互斥锁保护临界区,避免计数器变量的数据竞争

结果是,两个线程可以以安全和正确的方式更新可变的共享变量。然而,这也意味着这两个线程不能再并行运行。如果一个线程大部分工作都不能在不串行化的情况下完成,从性能的角度来看,使用线程就没有意义了。

第二个线程被第一个线程阻塞的状态称为争用。这是我们努力最小化的东西,因为它会影响并发程序的可伸缩性。如果争用程度很高,增加 CPU 核心数量将不会提高性能。

死锁

使用互斥锁保护共享资源时,存在陷入死锁状态的风险。当两个线程互相等待对方释放锁时,就会发生死锁。两个线程都无法继续进行,它们陷入了死锁状态。死锁发生的一个条件是,已经持有一个锁的线程尝试获取另一个锁。当系统增长并变得更大时,跟踪系统中所有线程可能使用的所有锁变得越来越困难。这是始终努力最小化使用共享资源的一个原因,也说明了对独占锁的需求。

图 11.7显示了两个线程处于等待状态,试图获取另一个线程持有的锁:

图 11.7:死锁状态的示例

接下来让我们讨论同步和异步任务。

同步和异步任务

在本章中,我将提到同步任务异步任务。同步任务就像普通的 C++函数。当同步任务完成其任务后,它将控制权返回给任务的调用者。任务的调用者在等待或被阻塞,直到同步任务完成。

另一方面,异步任务将立即将控制权返回给调用者,并同时执行其工作。

图 11.8中的序列显示了分别调用同步和异步任务之间的区别:

图 11.8:同步与异步调用。异步任务立即返回,但在调用者重新获得控制权后继续工作。

如果您以前没有见过异步任务,它们可能一开始看起来很奇怪,因为在 C++中,普通函数遇到返回语句或到达函数体末尾时总是停止执行。然而,异步 API 变得越来越常见,很可能您以前已经遇到过,例如在使用异步 JavaScript 时。

有时,我们使用术语阻塞来表示阻塞调用者的操作;也就是说,使调用者等待操作完成。

在对并发性进行了一般介绍之后,现在是时候探索 C++中的线程编程支持了。

C++中的并发编程

C++中的并发支持使程序能够同时执行多个任务。正如前面提到的,编写正确的并发 C++程序通常比在一个线程中依次执行所有任务的程序要困难得多。本节还将演示一些常见的陷阱,以使您了解编写并发程序所涉及的所有困难。

并发支持首次出现在 C++11 中,并在 C++14、C++17 和 C++20 中得到扩展。在并发成为语言的一部分之前,它是通过操作系统的本机并发支持、POSIX 线程pthreads)或其他一些库来实现的。

有了 C++语言中的并发支持,我们可以编写跨平台的并发程序,这很棒!然而,当处理平台上的并发时,有时必须使用特定于平台的功能。例如,在 C++标准库中没有支持设置线程优先级、配置 CPU 亲和性(CPU 绑定)或设置新线程的堆栈大小。

还应该说一下,随着 C++20 的发布,线程支持库得到了相当大的扩展,未来版本的语言很可能会添加更多功能。由于硬件的发展方式,对良好的并发支持的需求正在增加,而在高度并发程序的效率、可伸缩性和正确性方面还有很多待发现的地方。

线程支持库

我们现在将通过 C++线程支持库进行一次介绍,并涵盖其最重要的组件。

线程

运行中的程序至少包含一个线程。当调用主函数时,它会在通常被称为主线程的线程上执行。每个线程都有一个标识符,在调试并发程序时可能会有用。以下程序打印主线程的线程标识符:

int main() { 
  std::cout << "Thread ID: " <<  std::this_thread::get_id() << '\n'; 
} 

运行上述程序可能会产生类似以下的输出:

 Thread ID: 0x1001553c0 

线程可以休眠。在生产代码中很少使用休眠,但在调试过程中可能非常有用。例如,如果您有一个只在罕见情况下发生的数据竞争,向代码中添加休眠可能会使其更频繁地出现。以下是使当前运行的线程休眠一秒钟的方法:

std::this_thread::sleep_for(std::chrono::seconds{1}); 

在您的程序中插入随机休眠后,程序不应该暴露任何数据竞争。在添加休眠后,您的程序可能无法正常工作;缓冲区可能变满,UI 可能会出现延迟等,但它应该始终以可预测和定义的方式行为。我们无法控制线程的调度,随机休眠模拟了不太可能但可能发生的调度场景。

现在,让我们使用<thread>头文件中的std::thread类创建一个额外的线程。它表示一个执行线程,并且通常是操作系统线程的包装器。print()函数将从我们显式创建的线程中调用:

void print() { 
  std::this_thread::sleep_for(std::chrono::seconds{1}); 
  std::cout << "Thread ID: "<<  std::this_thread::get_id() << '\n'; 
} 

int main() { 
  auto t1 = std::thread{print}; 
  t1.join(); 
  std::cout << "Thread ID: "<<  std::this_thread::get_id() << '\n'; 
} 

在创建线程时,我们传递一个可调用对象(函数、lambda 或函数对象),线程将在 CPU 上获得调度时间时开始执行。我添加了一个调用 sleep,以明显地说明为什么我们需要在线程上调用join()。当std::thread对象被销毁时,它必须已经加入分离,否则将导致程序调用std::terminate(),默认情况下将调用std::abort(),如果我们没有安装自定义的std::terminate_handler

在前面的例子中,join()函数是阻塞的——它会等待线程运行结束。因此,在前面的例子中,main()函数将在线程t1运行结束之前不会返回。考虑以下一行:

t1.join(); 

假设我们通过以下一行替换前面的行来分离线程t1

t1.detach(); 

在这种情况下,我们的主函数将在线程t1唤醒打印消息之前结束,因此程序将(很可能)只输出主线程的线程 ID。请记住,我们无法控制线程的调度,可能但非常不太可能,主线程将在print()函数有时间休眠、唤醒并打印其线程 ID 之后输出其消息。

在这个例子中,使用detach()而不是join()也引入了另一个问题。我们在两个线程中都使用了std::cout,而没有任何同步,而且由于main()不再等待线程t1完成,它们两者理论上都可以并行使用std::cout。幸运的是,std::cout是线程安全的,可以从多个线程中使用而不会引入数据竞争,因此没有未定义的行为。但是,仍然有可能线程生成的输出是交错的,导致类似以下的结果:

Thread ID: Thread ID: 0x1003a93400x700004fd4000 

如果我们想避免交错输出,我们需要将字符的输出视为临界区,并同步访问std::cout。我们将在稍后更多地讨论临界区和竞争条件,但首先,让我们先了解一些关于std::thread的细节。

线程状态

在我们继续之前,您应该对std::thread对象的真正表示以及它可能处于的状态有一个很好的理解。我们还没有讨论在执行 C++程序的系统中通常有哪些类型的线程。

在下图中,您可以看到一个假设运行中系统的快照。

图 11.9:假设运行中系统的快照

从底部开始,图中显示了 CPU 及其硬件线程。这些是 CPU 上的执行单元。在这个例子中,CPU 提供了四个硬件线程。通常这意味着它有四个核心,但也可能是其他配置;例如,一些核心可以执行两个硬件线程。这通常被称为超线程。硬件线程的总数可以在运行时打印出来:

 std::cout << std::thread::hardware_concurrency() << '\n';
  // Possible output: 4 

在运行平台上无法确定硬件线程的数量时,前面的代码也可能输出0

在硬件线程上面的一层包含了操作系统线程。这些是实际的软件线程。操作系统调度程序确定操作系统线程由硬件线程执行的时间和持续时间。在图 11.9中,目前有六个软件线程中的三个正在执行。

图中最上层包含了std::thread对象。std::thread对象只是一个普通的 C++对象,可能与底层操作系统线程相关联,也可能不相关联。两个std::thread实例不能与同一个底层线程相关联。在图中,您可以看到程序当前有三个std::thread实例;两个与线程相关联,一个没有。可以使用std::thread::joinable属性来查找std::thread对象的状态。如果它已经:

  • 默认构造;也就是说,如果它没有任何要执行的内容

  • 从中移动(其关联的运行线程已被转移到另一个std::thread对象)

  • 通过调用detach()分离

  • 通过调用join()已连接

否则,std::thread对象处于可连接状态。请记住,当std::thread对象被销毁时,它不能再处于可连接状态,否则程序将终止。

可连接的线程

C++20 引入了一个名为std::jthread的新线程类。它与std::thread非常相似,但有一些重要的补充:

  • std::jthread支持使用停止令牌停止线程。在 C++20 之前,使用std::thread时,我们必须手动实现这一点。

  • 在非可连接状态下销毁应用程序时,std::jthread的析构函数将发送一个停止请求并在销毁时加入线程。

接下来我将说明后一点。首先,我们将使用如下定义的print()函数:

void print() {
  std::this_thread::sleep_for(std::chrono::seconds{1});
  std::cout << "Thread ID: "<<  std::this_thread::get_id() << '\n';
} 

它休眠一秒,然后打印当前线程标识符:

int main() {
  std::cout << "main begin\n"; 
  auto joinable_thread = std::jthread{print};  
  std::cout << "main end\n";
} // OK: jthread will join automatically 

在我的机器上运行代码时,产生了以下输出:

main begin
main end
Thread ID: 0x1004553c0 

现在让我们改变我们的print()函数,使其在循环中连续输出消息。然后我们需要一些方法来通知print()函数何时停止。std::jthread(而不是std::thread)通过使用停止令牌内置支持这一点。当std::jthread调用print()函数时,如果print()函数接受这样的参数,它可以传递一个std::stop_token的实例。以下是我们如何使用停止令牌来实现这个新的print()函数的示例:

void print(std::stop_token stoken) {
  while (!stoken.stop_requested()) { 
    std::cout << std::this_thread::get_id() << '\n';
    std::this_thread::sleep_for(std::chrono::seconds{1});
  }
  std::cout << "Stop requested\n";
} 

while循环在每次迭代时检查函数是否已被调用stop_requested()请求停止。现在,从我们的main()函数中,可以通过在我们的std::jthread实例上调用request_stop()来请求停止:

int main() {
  auto joinable_thread = std::jthread(print);
  std::cout << "main: goes to sleep\n";
  std::this_thread::sleep_for(std::chrono::seconds{3});
  std::cout << "main: request jthread to stop\n";
  joinable_thread.request_stop();
} 

当我运行这个程序时,它生成了以下输出:

main: goes to sleep
Thread ID: 0x70000f7e1000
Thread ID: 0x70000f7e1000
Thread ID: 0x70000f7e1000
main: request jthread to stop
Stop requested 

在这个例子中,我们本可以省略对request_stop()的显式调用,因为jthread在销毁时会自动调用request_stop()

新的jthread类是 C++线程库中的一个受欢迎的补充,当在 C++中寻找线程类时,它应该是第一选择。

保护关键部分

正如我之前提到的,我们的代码不能包含任何数据竞争。不幸的是,编写带有数据竞争的代码非常容易。在使用线程编写并发程序时,找到关键部分并用锁保护它们是我们不断需要考虑的事情。

C++为我们提供了一个std::mutex类,可以用于保护关键部分并避免数据竞争。我将演示如何使用互斥锁来处理一个经典的例子,其中多个线程更新了一个共享的可变计数器变量。

首先,我们定义一个全局可变变量和一个增加计数器的函数:

auto counter = 0; // Warning! Global mutable variable
void increment_counter(int n) {
  for (int i = 0; i < n; ++i)
    ++counter;
} 

接下来的main()函数创建了两个线程,它们都将执行increment_counter()函数。在这个例子中还可以看到如何向线程调用的函数传递参数。我们可以向线程构造函数传递任意数量的参数,以匹配要调用的函数签名中的参数。最后,我们断言如果程序没有数据竞争,计数器的值将符合我们的预期:

int main() {
  constexpr auto n = int{100'000'000};
  {
    auto t1 = std::jthread{increment_counter, n};
    auto t2 = std::jthread{increment_counter, n};
  }
  std::cout << counter << '\n';
  // If we don't have a data race, this assert should hold:
  assert(counter == (n * 2));
} 

这个程序很可能会失败。assert()函数不起作用,因为程序当前包含竞争条件。当我反复运行程序时,计数器的值会不同。我最终得到的不是达到值200000000,而是最多只有137182234。这个例子与本章前面所举的数据竞争例子非常相似。

带有表达式++counter的那一行是一个关键部分——它使用了一个共享的可变变量,并由多个线程执行。为了保护这个关键部分,我们现在将使用<mutex>头文件中包含的std::mutex。稍后,您将看到我们如何通过使用原子操作来避免这个例子中的数据竞争,但现在我们将使用锁。

首先,在counter旁边添加全局std::mutex对象:

auto counter = 0; // Counter will be protected by counter_mutex
auto counter_mutex = std::mutex{}; 

但是,std::mutex对象本身不是一个可变的共享变量吗?如果被多个线程使用,它不会产生数据竞争吗?是的,它是一个可变的共享变量,但不会产生数据竞争。C++线程库中的同步原语,如std::mutex,是为了这个特定目的而设计的。在这方面,它们非常特殊,并使用硬件指令或者平台上必要的任何东西来保证它们自己不会产生数据竞争。

现在我们需要在读取和更新计数器变量的关键部分使用互斥锁。我们可以在counter_mutex上使用lock()unlock()成员函数,但更倾向于更安全的方法是始终使用 RAII 来处理互斥锁。把互斥锁看作一个资源,当我们使用完毕时总是需要解锁。线程库为我们提供了一些有用的 RAII 类模板来处理锁定。在这里,我们将使用std::scoped_lock<Mutex>模板来确保我们安全地释放互斥锁。下面是更新后的increment_counter()函数,现在受到互斥锁的保护:

void increment_counter(int n) {
  for (int i = 0; i < n; ++i) {
    auto lock = std::scoped_lock{counter_mutex};
    ++counter;
  }
} 

程序现在摆脱了数据竞争,并且按预期工作。如果我们再次运行它,assert()函数中的条件现在将成立。

避免死锁

只要一个线程一次只获取一个锁,就不会有死锁的风险。然而,有时需要在已经持有先前获取的锁的情况下获取另一个锁。在这种情况下,通过同时抓住两个锁来避免死锁的风险。C++有一种方法可以通过使用std::lock()函数来做到这一点,该函数获取任意数量的锁,并在所有锁都被获取之前阻塞。

以下是一个在账户之间转账的示例。在交易期间需要保护两个账户,因此我们需要同时获取两个锁。操作如下:

struct Account { 
  Account() {} 
  int balance_{0}; 
  std::mutex m_{}; 
}; 

void transfer_money(Account& from, Account& to, int amount) { 
   auto lock1 = std::unique_lock<std::mutex>{from.m_, std::defer_lock}; 
   auto lock2 = std::unique_lock<std::mutex>{to.m_, std::defer_lock}; 

   // Lock both unique_locks at the same time 
   std::lock(lock1, lock2); 

   from.balance_ -= amount; 
   to.balance_ += amount; 
} 

我们再次使用 RAII 类模板来确保每当这个函数返回时我们都释放锁。在这种情况下,我们使用std::unique_lock,它为我们提供了推迟锁定互斥锁的可能性。然后,我们通过使用std::lock()函数同时显式锁定两个互斥锁。

条件变量

条件变量使线程能够等待直到某个特定条件得到满足。线程还可以使用条件变量向其他线程发出条件已经改变的信号。

并发程序中的一个常见模式是有一个或多个线程在等待数据以某种方式被消耗。这些线程通常被称为消费者。另一组线程负责生成准备好被消耗的数据。这些生成数据的线程被称为生产者,如果只有一个线程,则称为生产者

生产者和消费者模式可以使用条件变量来实现。我们可以结合使用std::condition_variablestd::unique_lock来实现这个目的。让我们看一个生产者和消费者的示例,使它们不那么抽象:

auto cv = std::condition_variable{}; 
auto q = std::queue<int>{}; 
auto mtx = std::mutex{};     // Protects the shared queue 
constexpr int sentinel = -1; // Value to signal that we are done 

void print_ints() { 
  auto i = 0; 
  while (i != sentinel) { 
    { 
      auto lock = std::unique_lock<std::mutex>{mtx}; 
      while (q.empty()) {
        cv.wait(lock); // The lock is released while waiting 
      }
      i = q.front(); 
      q.pop(); 
    } 
    if (i != sentinel) { 
      std::cout << "Got: " << i << '\n'; 
    } 
  } 
} 

auto generate_ints() { 
  for (auto i : {1, 2, 3, sentinel}) { 
    std::this_thread::sleep_for(std::chrono::seconds(1)); 
    { 
      auto lock = std::scoped_lock{mtx}; 
      q.push(i); 
    } 
    cv.notify_one(); 
  } 
} 

int main() { 
   auto producer = std::jthread{generate_ints}; 
   auto consumer = std::jthread{print_ints}; 
} 

我们创建了两个线程:一个consumer线程和一个producer线程。producer线程生成一系列整数,并在每秒钟将它们推送到全局std::queue<int>中。每当向队列添加元素时,生产者都会使用notify_one()来发出条件已经改变的信号。

程序检查队列中是否有数据可供消费者线程使用。还要注意的是,在通知条件变量时不需要持有锁。

消费者线程负责将数据(即整数)打印到控制台。它使用条件变量等待空队列发生变化。当消费者调用cv.wait(lock)时,线程会进入睡眠状态,让出 CPU 给其他线程执行。重要的是要理解为什么在调用wait()时需要传递变量lock。除了让线程进入睡眠状态,wait()在睡眠时也会释放互斥锁,然后在返回之前重新获取互斥锁。如果wait()没有释放互斥锁,生产者将无法向队列中添加元素。

为什么消费者在条件变量上等待时使用while循环而不是if语句?这是一个常见的模式,有时我们需要这样做,因为可能有其他消费者在我们之前被唤醒并清空了队列。在我们的程序中,我们只有一个消费者线程,所以这种情况不会发生。但是,消费者可能会在等待时被唤醒,即使生产者线程没有发出信号。这种现象称为虚假唤醒,导致这种情况发生的原因超出了本书的范围。

作为使用while循环的替代方案,我们可以使用wait()的重载版本,该版本接受一个谓词。这个wait()版本检查谓词是否满足,并为我们执行循环。在我们的示例中,它看起来像这样:

// ...
auto lock = std::unique_lock<std::mutex>{mtx}; 
cv.wait(lock, [] { return !q.empty(); });
// ... 

您可以在 Anthony Williams 的C++ Concurrency in ActionSecond Edition中找到有关虚假唤醒的更多信息。您现在至少知道如何处理可能发生虚假唤醒的情况:始终在 while 循环中检查条件,或者使用接受谓词的wait()的重载版本。

条件变量和互斥锁是自从 C++引入线程以来就可用的同步原语。C++20 还提供了额外的有用的类模板,用于同步线程,即std::counting_semaphorestd::barrierstd::latch。我们将在后面介绍这些新的原语。首先,我们将花一些时间讨论返回值和错误处理。

返回数据和处理错误

到目前为止,在本章中所呈现的示例都使用了共享变量来在线程之间通信状态。我们使用互斥锁来确保避免数据竞争。在程序规模增大时,使用互斥锁的共享数据可能会非常难以正确实现。在代码库中分散使用显式锁定也需要大量工作。跟踪共享内存和显式锁定使我们远离我们编写程序时真正想要实现和花时间的目标。

此外,我们还没有处理错误处理。如果一个线程需要向另一个线程报告错误怎么办?当函数需要报告运行时错误时,我们通常使用异常,那么我们如何使用异常来做到这一点呢?

在标准库的<future>头文件中,我们可以找到一些类模板,可以帮助我们编写并发代码,而无需全局变量和锁,并且可以在线程之间传递异常以处理错误。我现在将介绍futurepromise,它们代表值的两个方面。future 是值的接收方,promise 是值的返回方。

以下是使用std::promise将结果返回给调用者的示例:

auto divide(int a, int b, std::promise<int>& p) { 
  if (b == 0) { 
    auto e = std::runtime_error{"Divide by zero exception"}; 
    p.set_exception(std::make_exception_ptr(e)); 
  } 
  else { 
    const auto result = a / b; 
    p.set_value(result); 
  } 
} 

int main() { 
   auto p = std::promise<int>{}; 
   std::thread(divide, 45, 5, std::ref(p)).detach(); 

   auto f = p.get_future(); 
   try { 
     const auto& result = f.get(); // Blocks until ready 
     std::cout << "Result: " << result << '\n'; 
   } 
   catch (const std::exception& e) { 
     std::cout << "Caught exception: " << e.what() << '\n'; 
   } 
} 

调用者(main()函数)创建std::promise对象并将其传递给divide()函数。我们需要使用<functional>中的std::ref,以便引用可以通过std::thread正确地转发到compute()

divide()函数计算出结果时,通过调用set_value()函数通过 promise 传递返回值。如果divide()函数发生错误,则在 promise 上调用set_exception()函数。

future 代表可能已经计算或尚未计算的计算值。由于 future 是一个普通对象,我们可以将其传递给需要计算值的其他对象。最后,当某个客户端需要该值时,它调用get()来获取实际值。如果在那时没有计算,调用get()将阻塞,直到完成。

还要注意的是,我们成功地进行了适当的错误处理来回传递数据,而没有使用任何共享全局数据,并且没有显式锁定。promise 为我们处理了这一切,我们可以专注于实现程序的基本逻辑。

任务

通过 future 和 promise,我们成功摆脱了显式锁定和共享全局数据。在可能的情况下,我们的代码将受益于使用更高级的抽象。在这里,我们将进一步探索自动为我们设置未来和承诺的类。您还将看到我们如何摆脱手动管理线程,并将其留给库。

在许多情况下,我们并不需要管理线程;相反,我们真正需要的是能够异步执行任务,并使该任务与程序的其余部分同时执行,然后最终将结果或错误传达给需要它的程序部分。任务应该在隔离环境中执行,以最小化争用和数据竞争的风险。

我们将从重写我们之前的例子开始,该例子将两个数字相除。这一次,我们将使用<future>中的std::packaged_task,它为我们设置 promise 的所有工作都是正确的:

int divide(int a, int b) { // No need to pass a promise ref here! 
  if (b == 0) { 
    throw std::runtime_error{"Divide by zero exception"}; 
  } 
  return a / b; 
} 

int main() { 
  auto task = std::packaged_task<decltype(divide)>{divide}; 
  auto f = task.get_future(); 
  std::thread{std::move(task), 45, 5}.detach(); 

  // The code below is unchanged from the previous example 
  try { 
    const auto& result = f.get(); // Blocks until ready 
    std::cout << "Result: " << result << '\n'; 
  } 
  catch (const std::exception& e) { 
    std::cout << "Caught exception: " << e.what() << '\n'; 
  } 
  return 0; 
} 

std::packaged_task本身是一个可调用对象,可以移动到我们正在创建的std::thread对象中。正如你所看到的,std::packaged_task现在为我们做了大部分工作:我们不必自己创建 promise。但更重要的是,我们可以像编写普通函数一样编写我们的divide()函数,而不需要通过 promise 显式返回值或异常;std::packaged_task会为我们做这些。

在本节的最后一步,我们还希望摆脱手动线程管理。创建线程并不是免费的,您将在后面看到,程序中的线程数量会影响性能。似乎是否为我们的divide()函数创建一个新线程并不一定由divide()的调用者决定。库再次通过提供另一个有用的函数模板std::async()来帮助我们。在我们的divide()示例中,我们唯一需要做的是用一个简单的调用std::async()替换创建std::packaged_taskstd::thread对象的代码:

 auto f = std::async(divide, 45, 5); 

我们现在已经从基于线程的编程模型切换到了基于任务的模型。完整的基于任务的示例现在看起来是这样的:

int divide(int a, int b) { 
  if (b == 0) { 
    throw std::runtime_error{"Divide by zero exception"}; 
  } 
  return a / b; 
} 

int main() { 
  auto future = std::async(divide, 45, 5); 
  try { 
    const auto& result = future.get(); 
    std::cout << "Result: " << result << '\n'; 
  } 
  catch (const std::exception& e) { 
    std::cout << "Caught exception: " << e.what() << '\n'; 
  } 
} 

这里真的只剩下很少的代码来处理并发。异步调用函数的推荐方式是使用std::async()。关于为什么以及何时首选std::async()的更深入讨论,我强烈推荐 Scott Meyers 的Effective Modern C++中的并发章节。

C++20 中的额外同步原语

C++20 带来了一些额外的同步原语,即std::latchstd::barrierstd::counting_semaphore(以及模板特化std::binary_semaphore)。本节将概述这些新类型以及它们可以有用的一些典型场景。我们将从std::latch开始。

使用门闩

门闩是一种同步原语,可用于同步多个线程。它创建一个同步点,所有线程都必须到达。您可以将门闩视为递减计数器。通常,所有线程都会递减计数器一次,然后等待门闩达到零,然后再继续。

门闩是通过传递内部计数器的初始值来构造的:

auto lat = std::latch{8}; // Construct a latch initialized with 8 

然后线程可以使用count_down()递减计数器:

lat.count_down(); // Decrement but don't wait 

线程可以等待在门闩上达到零:

lat.wait(); // Block until zero 

还可以检查(不阻塞)计数器是否已经达到零:

if (lat.try_wait()) { 
  // All threads have arrived ...
} 

通常在递减计数器后立即等待门闩达到零,如下所示:

lat.count_down();
lat.wait(); 

事实上,这种用法很常见,值得一个定制的成员函数;arrive_and_wait()递减门闩,然后等待门闩达到零:

lat.arrive_and_wait(); // Decrement and block while not zero 

在并发工作时,加入一组分叉任务是一种常见情况。如果任务只需要在最后加入,我们可以使用一个未来对象数组(等待)或者只等待所有线程完成。但在其他情况下,我们希望一组异步任务到达一个共同的同步点,然后让任务继续运行。这些情况通常发生在多个工作线程开始实际工作之前需要某种初始化的情况下。

示例:使用 std::latch 初始化线程

以下示例演示了当多个工作线程需要在开始工作之前运行一些初始化代码时,如何使用std::latch

当创建一个线程时,会为堆栈分配一块连续的内存。通常,当首次在虚拟地址空间中分配内存时,这块内存尚未驻留在物理内存中。相反,当堆栈被使用时,将生成页错误,以便将虚拟内存映射到物理内存。操作系统会为我们处理映射,这是一种在需要时懒惰地映射内存的有效方式。通常,这正是我们想要的:我们尽可能晚地支付映射内存的成本,只有在需要时才会支付。然而,在低延迟很重要的情况下,例如在实时代码中,可能需要完全避免页错误。堆栈内存不太可能被操作系统分页出去,因此通常只需运行一些代码,生成页错误,从而将虚拟堆栈内存映射到物理内存。这个过程称为预缓存

没有一种可移植的方法来设置或获取 C++线程的堆栈大小,所以这里我们只是假设堆栈至少为 500 KB。以下代码尝试预先分配堆栈的前 500 KB:

void prefault_stack() {
  // We don't know the size of the stack
  constexpr auto stack_size = 500u * 1024u; 
  // Make volatile to avoid optimization
  volatile unsigned char mem[stack_size]; 
  std::fill(std::begin(mem), std::end(mem), 0);
} 

这里的想法是在堆栈上分配一个数组,它将占用大量的堆栈内存。然后,为了生成页面错误,我们使用std::fill()写入数组中的每个元素。之前没有提到volatile关键字,它是 C++中一个有些令人困惑的关键字。它与并发无关;它只是在这里添加以防止编译器优化掉这段代码。通过声明mem数组为volatile,编译器不允许忽略对数组的写入。

现在,让我们专注于实际的std::latch。假设我们想要创建一些工作线程,只有在所有线程堆栈都被预分配后才能开始它们的工作。我们可以使用std::latch来实现这种同步,如下所示:

auto do_work() { /* ... */ }
int main() {
  constexpr auto n_threads = 2;
  auto initialized = std::latch{n_threads};
  auto threads = std::vector<std::thread>{};
  for (auto i = 0; i < n_threads; ++i) {
    threads.emplace_back([&] {
      prefault_stack();
      initialized.arrive_and_wait(); 
      do_work();
    });
  }
  initialized.wait();
  std::cout << "Initialized, starting to work\n";
  for (auto&& t : threads) {
    t.join();
  }
} 

所有线程到达后,主线程可以开始向工作线程提交工作。在这个例子中,所有线程都在等待其他线程到达,通过在屏障上调用arrive_and_wait()来实现。一旦屏障达到零,就不能再重用它了。没有重置屏障的函数。如果我们有一个需要多个同步点的场景,我们可以使用std::barrier来代替。

使用屏障

屏障类似于 latch,但有两个主要的附加功能:屏障可以被重用,并且当所有线程到达屏障时可以运行完成函数

通过传递内部计数器的初始值和完成函数来构造屏障:

auto bar = std::barrier{8, [] {
  // Completion function
  std::cout "All threads arrived at barrier\n";
}}; 

线程可以以与使用 latch 相同的方式到达并等待:

bar.arrive_and_wait(); // Decrement but don't wait 

每当所有线程都到达(也就是说,当屏障的内部计数器达到零时)时,会发生两件事:

  • 提供给构造函数的完成函数由屏障调用。

  • 完成函数返回后,内部计数器将被重置为其初始值。

屏障在基于fork-join 模型的并行编程算法中非常有用。通常,迭代算法包含一个可以并行运行的部分和一个需要顺序运行的部分。多个任务被分叉并并行运行。然后,当所有任务都完成并加入时,会执行一些单线程代码来确定算法是否应该继续还是结束。

图 11.10:fork-join 模型的示例

遵循 fork-join 模型的并发算法将受益于使用屏障,并可以以一种优雅和高效的方式避免其他显式的锁定机制。让我们看看如何在一个简单的问题中使用屏障但有两个主要的问题。

示例:使用 std::barrier 进行 fork-join

我们的下一个示例是一个玩具问题,将演示 fork-join 模型。我们将创建一个小程序,模拟一组骰子被掷出,并计算在获得所有 6 之前需要掷出的次数。掷一组骰子是我们可以并发执行的(分叉)操作。在单个线程中执行的加入步骤检查结果,并确定是重新掷骰子还是结束。

首先,我们需要实现掷骰子的代码,有六个面。为了生成 1 到 6 之间的数字,我们可以使用<random>头文件中的类的组合,如下所示:

auto engine = 
  std::default_random_engine{std::random_device{}()};
auto dist = std::uniform_int_distribution<>{1, 6};
auto result = dist(engine); 

这里的std::random_device负责生成一个种子,用于产生伪随机数的引擎。为了以相等的概率选择 1 到 6 之间的整数,我们使用std::uniform_int_distribution。变量result是掷骰子的结果。

现在我们想将此代码封装到一个函数中,该函数将生成一个随机整数。生成种子并创建引擎通常很慢,我们希望避免在每次调用时都这样做。通常的做法是使用static持续时间声明随机引擎,以便它在整个程序的生命周期内存在。但是,<random>中的类不是线程安全的,因此我们需要以某种方式保护static引擎。我将利用这个机会演示如何使用线程本地存储,而不是使用互斥锁同步访问,这将使随机数生成器按顺序运行。

以下是如何将引擎声明为static thread_local对象的方法:

auto random_int(int min, int max) {
  // One engine instance per thread
  static thread_local auto engine = 
    std::default_random_engine{std::random_device{}()};
  auto dist = std::uniform_int_distribution<>{min, max};
  return dist(engine);
} 

具有thread_local存储期的静态变量将在每个线程中创建一次;因此,可以在不使用任何同步原语的情况下同时从多个线程调用random_int()。有了这个小的辅助函数,我们可以继续使用std::barrier实现程序的其余部分:

int main() {
  constexpr auto n = 5; // Number of dice
  auto done = false;
  auto dice = std::array<int, n>{};
  auto threads = std::vector<std::thread>{};
  auto n_turns = 0;
  auto check_result = [&] { // Completion function
    ++n_turns;
    auto is_six = [](auto i) { return i == 6; };
    done = std::all_of(dice.begin(), dice.end(), is_six); 
  };
  auto bar = std::barrier{n, check_result}; 
  for (int i = 0; i < n; ++i) {
    threads.emplace_back([&, i] {
      while (!done) {
        dice [i] = random_int(1, 6); // Roll dice        
        bar.arrive_and_wait();       // Join
      }});
  }
  for (auto&& t : threads) { 
    t.join();
  }
  std::cout << n_turns << '\n';
} 

lambdacheck_result()是完成函数,每次所有线程都到达屏障时都会调用它。完成函数检查每个骰子的值,并确定是否应该玩新一轮,或者我们已经完成。

传递给std::thread对象的 lambda 通过值捕获索引i,以便所有线程都具有唯一的索引。其他变量donedicebar通过引用捕获。

还要注意,我们可以在不引入任何数据竞争的情况下从不同线程中对引用捕获的变量进行突变和读取,这要归功于屏障执行的协调。

使用信号量进行信号传递和资源计数

信号量一词表示可以用于信号传递的东西,例如旗帜或灯。在接下来的示例中,您将看到我们如何使用信号量来传递其他线程可能正在等待的不同状态。

信号量还可以用于控制对资源的访问,类似于std::mutex限制对临界区的访问:

class Server {
public:
  void handle(const Request& req) {
    sem_.acquire();
    // Restricted section begins here.
    // Handle at most 4 requests concurrently.
    do_handle(req);
    sem_.release();
  }
private:
  void do_handle(const Request& req) { /* ... */ }
  std::counting_semaphore<4> sem_{4};
}; 

在这种情况下,信号量的初始值为4,这意味着最多可以同时处理四个并发请求。与代码中的某个部分相互排斥的访问不同,多个线程可以访问相同的部分,但受限于当前在该部分的线程数量。

成员函数acquire()在信号量大于零时减少信号量。否则,acquire()将阻塞,直到信号量允许其减少并进入受限制的部分。release()在不阻塞的情况下增加计数器。如果在release()增加计数器之前信号量为零,则会发出信号通知等待的线程。

除了acquire()函数之外,还可以使用try_acquire()函数无阻塞地尝试减少计数器。如果成功减少计数器,则返回true,否则返回false。函数try_acquire_for()try_acquire_until()可以类似地使用。但是,它们在计数器已经为零时不会立即返回false,而是在指定时间内自动尝试减少计数器,然后再返回给调用者。

这三个函数的模式与标准库中的其他类型相同,例如std::timed_mutex及其try_lock()try_lock_for()try_lock_until()成员函数。

std::counting_semaphore是一个模板,具有一个模板参数,接受信号量的最大值。在增加(释放)信号量超过其最大值时被认为是编程错误。

具有最大大小为 1 的std::counting_semaphore称为二进制信号量<semaphore>头文件包括二进制信号量的别名声明:

std::binary_semaphore = std::counting_semaphore<1>; 

二进制信号量的实现效率比具有更高最大值的计数信号量更高。

信号量的另一个重要属性是释放信号量的线程可能不是获取它的线程。这与std::mutex相反,后者要求获取互斥锁的线程也必须释放它。然而,使用信号量时,通常有一种类型的任务负责等待(获取),另一种类型的任务负责信号(释放)。这将在我们的下一个示例中演示。

示例:使用信号量的有界缓冲区

以下示例演示了一个有界缓冲区。这是一个固定大小的缓冲区,可以有多个线程从中读取和写入。同样,这个示例演示了你已经使用条件变量看到的生产者-消费者模式。生产者线程是写入缓冲区的线程,而读取线程是从缓冲区中读取(和弹出元素)的线程。

以下图显示了缓冲区(一个固定大小的数组)和跟踪读取和写入位置的两个变量:

图 11.11:有界缓冲区具有固定大小

我们将一步一步地开始,从一个专注于有界缓冲区内部逻辑的版本开始。使用信号量进行信号传递将在下一个版本中添加。在这里,初始尝试演示了读取和写入位置的使用方式:

template <class T, int N> 
class BoundedBuffer {
  std::array<T, N> buf_;
  std::size_t read_pos_{};
  std::size_t write_pos_{};
  std::mutex m_;
  void do_push(auto&& item) {
    /* Missing: Should block if buffer is full */
    auto lock = std::unique_lock{m_};
    buf_[write_pos_] = std::forward<decltype(item)>(item);
    write_pos_ = (write_pos_ + 1) % N;
  }
public:
  void push(const T& item) { do_push(item); }
  void push(T&& item) { do_push(std::move(item)); }
  auto pop() {
    /* Missing: Should block if buffer is empty */
    auto item = std::optional<T>{};
    {
      auto lock = std::unique_lock{m_};
      item = std::move(buf_[read_pos_]);
      read_pos_ = (read_pos_ + 1) % N;
    }
    return std::move(*item);
  }
}; 

这个第一次尝试包含了固定大小的缓冲区,读取和写入位置,以及一个互斥锁,用于保护数据成员免受数据竞争的影响。这个实现应该能够让任意数量的线程同时调用push()pop()

push()函数重载了const T&T&&。这是标准库容器使用的一种优化技术。T&&版本在调用者传递一个右值时避免了参数的复制。

为了避免重复推送操作的逻辑,一个辅助函数do_push()包含了实际的逻辑。通过使用转发引用(auto&& item)以及std::forwarditem参数将根据客户端使用右值还是左值调用push()而进行移动分配或复制分配。

这个有界缓冲区的版本并不完整,因为它没有保护我们免受write_pos指向(或超出)read_pos的影响。同样,read_pos绝不能指向write_pos(或超出)。我们想要的是一个缓冲区,在缓冲区满时生产者线程被阻塞,而在缓冲区为空时消费者线程被阻塞。

这是使用计数信号量的完美应用。信号量阻塞试图将信号量减少到已经为零的线程。信号量信号被阻塞的线程,每当一个值为零的信号量增加时。

对于有界缓冲区,我们需要两个信号量:

  • 第一个信号量n_empty_slots跟踪缓冲区中空槽的数量。它将以缓冲区大小的值开始。

  • 第二个信号量n_full_slots跟踪缓冲区中满槽的数量。

确保你理解为什么需要两个计数信号量(而不是一个)。原因是有两个不同的状态需要被信号:当缓冲区时和当缓冲区时。

在添加了使用两个计数信号量进行信号处理后,有界缓冲区现在看起来像这样(在此版本中添加的行用“new”标记):

template <class T, int N> 
class BoundedBuffer {
  std::array<T, N> buf_;
  std::size_t read_pos_{};
  std::size_t write_pos_{};
  std::mutex m_;
  std::counting_semaphore<N> n_empty_slots_{N}; // New
  std::counting_semaphore<N> n_full_slots_{0};  // New
  void do_push(auto&& item) {
    // Take one of the empty slots (might block)
    n_empty_slots_.acquire();                   // New
    try {
      auto lock = std::unique_lock{m_};
      buf_[write_pos_] = std::forward<decltype(item)>(item);
      write_pos_ = (write_pos_ + 1) % N;
    } catch (...) {
      n_empty_slots_.release();                 // New
      throw;
    }
    // Increment and signal that there is one more full slot
    n_full_slots_.release();                    // New
  }
public:
  void push(const T& item) { do_push(item); }
  void push(T&& item) { do_push(std::move(item)); }
  auto pop() {
    // Take one of the full slots (might block)
    n_full_slots_.acquire();                // New
    auto item = std::optional<T>{};
    try {
      auto lock = std::unique_lock{m_};
      item = std::move(buf_[read_pos_]);
      read_pos_ = (read_pos_ + 1) % N;
    } catch (...) {
      n_full_slots_.release();             // New
      throw;
    }
    // Increment and signal that there is one more empty slot
    n_empty_slots_.release();              // New
    return std::move(*item);
  }
}; 

这个版本支持多个生产者和消费者。两个信号量的使用保证了两者都不会达到缓冲区中元素的最大数量。例如,生产者线程无法在首先检查是否有至少一个空槽之前添加值并增加n_full_slots信号量。

还要注意,acquire()release()是从不同的线程调用的。例如,消费者线程正在等待(acquire()n_full_slots信号量,而生产者线程正在对同一个信号量进行信号(release())。

C++20 中添加的新同步原语是常见的线程库中常见的构造。与std::mutexstd::condition_variable相比,它们提供了方便且通常更有效的替代方案来同步对共享资源的访问。

C++中的原子支持

标准库包含对原子变量的支持,有时被称为原子。原子变量是一种可以安全地从多个线程使用和变异而不引入数据竞争的变量。

您还记得我们之前看过的两个线程更新全局计数器的数据竞争示例吗?我们通过添加互斥锁和计数器来解决了这个问题。我们可以使用 std::atomic<int> 来代替显式锁:

std::atomic<int> counter; 

auto increment_counter(int n) { 
  for (int i = 0; i < n; ++i) 
    ++counter; // Safe, counter is now an atomic<int> 
} 

++counter 是一种方便的方式,相当于 counter.fetch_add(1)。可以从多个线程同时调用的所有成员函数都是安全的。

原子类型来自<atomic>头文件。对于所有标量数据类型,都有命名为std::atomic_int的 typedef。这与std::atomic<int>相同。只要自定义类型是平凡可复制的,就可以将自定义类型包装在std::atomic模板中。基本上,这意味着类的对象完全由其数据成员的位描述。这样,对象可以通过例如std::memcpy()仅复制原始字节来复制。因此,如果一个类包含虚函数、指向动态内存的指针等,就不再可能仅仅复制对象的原始位并期望它能够工作,因此它不是平凡可复制的。这可以在编译时检查,因此如果尝试创建一个不是平凡可复制的类型的原子,将会得到编译错误:

struct Point { 
  int x_{}; 
  int y_{}; 
}; 

auto p = std::atomic<Point>{};       // OK: Point is trivially copyable 
auto s = std::atomic<std::string>{}; // Error: Not trivially copyable 

还可以创建原子指针。这使得指针本身是原子的,但指向的对象不是。我们将在稍后更多地讨论原子指针和引用。

无锁属性

使用原子而不是用互斥锁保护变量的原因是避免使用std::mutex引入的性能开销。此外,互斥锁可能会阻塞线程一段非确定性的时间,并引入优先级反转(参见线程优先级部分),这排除了在低延迟环境中使用互斥锁。换句话说,您的代码中可能有延迟要求的部分完全禁止使用互斥锁。在这些情况下,了解原子变量是否使用互斥锁是很重要的。

原子变量可能会或可能不会使用锁来保护数据;这取决于变量的类型和平台。如果原子变量不使用锁,则称为无锁。您可以在运行时查询变量是否无锁:

auto variable = std::atomic<int>{1};
assert(variable.is_lock_free());          // Runtime assert 

这很好,因为现在至少在运行程序时我们可以断言使用 variable 对象是无锁的。通常,同一类型的所有原子对象都将是无锁或有锁的,但在一些奇异的平台上,有可能两个原子对象会生成不同的答案。

通常更有趣的是知道在特定平台上是否保证了原子类型(std::atomic<T>)是无锁的,最好是在编译时而不是运行时知道。自 C++17 以来,还可以使用is_always_lock_free()在编译时验证原子特化是否是无锁的,就像这样:

static_assert(std::atomic<int>::is_always_lock_free); 

如果我们的目标平台上 atomic<int> 不是无锁的,这段代码将生成编译错误。现在,如果我们编译一个假设 std::atomic<int> 不使用锁的程序,它将无法编译,这正是我们想要的。

在现代平台上,任何std::atomic<T>,其中T适合本机字大小,通常都是始终无锁的。在现代 x64 芯片上,甚至可以获得双倍的数量。例如,在现代英特尔 CPU 上编译的 libc++上,std::atomic<std::complex<double>>始终是无锁的。

原子标志

保证始终是无锁的原子类型是std::atomic_flag(无论目标平台如何)。因此,std::atomic_flag不提供is_always_lock_free()/is_lock_free()函数,因为它们总是返回true

原子标志可以用来保护临界区,作为使用std::mutex的替代方案。由于锁的概念容易理解,我将在这里以此为例。但需要注意的是,我在本书中演示的锁的实现并不是生产就绪的代码,而是概念上的实现。以下示例演示了如何概念上实现一个简单的自旋锁:

class SimpleMutex {       
  std::atomic_flag is_locked_{};           // Cleared by default
public:
  auto lock() noexcept {
    while (is_locked_.test_and_set()) {
      while (is_locked_.test());           // Spin here
    }
  } 
  auto unlock() noexcept {
    is_locked_.clear();
  }
}; 

lock()函数调用test_and_set()来设置标志并同时获取标志的先前值。如果test_and_set()返回false,意味着调用者成功获取了锁(在先前清除标志时设置标志)。否则,内部的while循环将不断使用test()在一个自旋循环中轮询标志的状态。我们在额外的内部循环中使用test()的原因是性能:test()不会使缓存行失效,而test_and_set()会。这种锁定协议称为测试和测试并设置

这个自旋锁可以工作,但不太节约资源;当线程执行时,它不断使用 CPU 来一遍又一遍地检查相同的条件。我们可以在每次迭代中添加一个短暂的休眠和指数退避,但是为各种平台和场景微调这一点是很困难的。

幸运的是,C++20 为std::atomic添加了等待和通知 API,使线程可以等待(以一种节约资源的方式)原子变量改变其值。

原子等待和通知

自 C++20 以来,std::atomicstd::atomic_flag提供了等待和通知的功能。wait()函数阻塞当前线程,直到原子变量的值发生变化,并且其他线程通知等待线程。线程可以通过调用notify_one()notify_all()来通知发生了变化。

有了这个新功能,我们可以避免不断轮询原子的状态,而是以更节约资源的方式等待值的改变;这类似于std::condition_variable允许我们等待和通知状态改变的方式。

通过使用等待和通知,前一节中实现的SimpleMutex可以重写如下:

class SimpleMutex {       
  std::atomic_flag is_locked_{}; 
public:
  auto lock() noexcept {
    while (is_locked_.test_and_set())
      is_locked_.wait(true);    // Don't spin, wait
  } 
  auto unlock() noexcept {
    is_locked_.clear();
    is_locked_.notify_one();   // Notify blocked thread
  }
}; 

我们将旧值(true)传递给wait()。在wait()返回时,可以保证原子变量已经改变,不再是true。但不能保证我们会捕捉到所有变量的改变。变量可能已经从状态 A 改变到状态 B,然后再回到状态 A,而没有通知等待的线程。这是无锁编程中的一种现象,称为ABA 问题

这个示例演示了使用std::atomic_flag的等待和通知功能。相同的等待和通知 API 也适用于std::atomic类模板。

请注意,本章中介绍的自旋锁不是生产就绪的代码。实现高效的锁通常涉及正确使用内存顺序(稍后讨论)和用于让出的非可移植代码,这超出了本书的范围。详细讨论可在timur.audio/using-locks-in-real-time-audio-processing-safely找到。

现在,我们将继续讨论原子指针和原子引用。

在多线程环境中使用 shared_ptr

std::shared_ptr怎么样?它能在多线程环境中使用吗?当多个线程访问由多个共享指针引用的对象时,引用计数是如何处理的?

要理解共享指针和线程安全,我们需要回顾std::shared_ptr通常是如何实现的(也可以参见第七章内存管理)。考虑以下代码:

// Thread 1 
auto p1 = std::make_shared<int>(42); 

代码在堆上创建了一个int和一个指向int对象的引用计数智能指针。使用std::make_shared()创建共享指针时,会在int旁边创建一个控制块。控制块包含引用计数等内容,每当创建指向int的新指针时,引用计数就会增加,每当销毁指向int的指针时,引用计数就会减少。总之,当执行上述代码行时,会创建三个单独的实体:

  • 实际的std::shared_ptr对象p1(堆栈上的局部变量)

  • 一个控制块(堆对象)

  • 一个int(堆对象)

下图显示了三个对象:

图 11.12:一个指向整数对象的 shared_ptr 实例 p1 和包含引用计数的控制块。在这种情况下,只有一个共享指针使用 int,因此引用计数为 1。

现在,考虑如果以下代码被第二个线程执行会发生什么?

// Thread 2 
auto p2 = p1; 

我们正在创建一个新的指针指向int(和控制块)。创建p2指针时,我们读取p1,但在更新引用计数时也需要改变控制块。控制块位于堆上,并且在两个线程之间共享,因此需要同步以避免数据竞争。由于控制块是隐藏在std::shared_ptr接口后面的实现细节,我们无法知道如何保护它,结果发现它已经被实现照顾了。

通常,它会使用可变的原子计数器。换句话说,引用计数更新是线程安全的,因此我们可以在不担心同步引用计数的情况下,从不同线程使用多个共享指针。这是一个良好的实践,也是在设计类时需要考虑的事情。如果在客户端视角下,对变量进行了语义上只读(const)的方法中进行了变异,那么应该使变异变量线程安全。另一方面,客户端可以检测到的一切作为变异函数的东西应该留给类的客户端来同步。

下图显示了两个std::shared_ptrp1p2,它们都可以访问相同的对象。int是共享对象,控制块是std::shared_ptr实例之间内部共享的对象。控制块默认是线程安全的:

图 11.13:两个共享指针访问相同的对象

总结:

  • 在这个例子中,共享对象,即int,不是线程安全的,如果从多个线程访问,需要显式加锁。

  • 控制块已经是线程安全的,因此引用计数机制在多线程环境中可以工作。

让我们继续保护shared_ptr实例。

保护 shared_ptr 实例

现在只剩下一个部分:在前面的例子中,实际的std::shared_ptr对象p1p2怎么样?为了理解这一点,让我们来看一个只使用一个名为p的全局std::shared_ptr对象的例子:

// Global, how to protect? 
auto p = std::shared_ptr<int>{}; 

如何在多个线程中改变p而不引入数据竞争?一种选择是在使用p时用显式互斥锁保护p。或者,我们可以使用std::atomic的模板特化来处理std::shared_ptr(在 C++20 中引入)。换句话说,可以这样声明p为原子共享指针:

// Global, protect using atomic
auto p = std::atomic<std::shared_ptr<int>>{}; 

这个模板特化可能是锁定的,也可能不是。您可以使用 is_lock_free() 成员函数来验证这一点。另一个需要注意的是,特化 std::atomic<std::shared_ptr<T>> 是一个例外,它违反了 std::atomic 只能用可以平凡复制的类型进行特化的规则。不管怎样,我们很高兴最终在标准库中拥有了这个有用的类型。

以下示例演示了如何从多个线程原子地加载和存储共享指针对象:

// Thread T1 calls this function
auto f1() { 
  auto new_p = std::make_shared<int>(std::rand());  // ... 
  p.store(new_p);
} 

// Thread T2 calls this function
auto f2() { 
  auto local_p = p.load(); 
  // Use local_p... 
} 

在前面的例子中,我们假设有两个线程 T1T2,分别调用函数 f1()f2()。从线程 T1 中使用 std::make_shared<int>() 调用创建了新的堆分配的 int 对象。

在这个例子中有一个微妙的细节需要考虑:堆分配的 int 在哪个线程中被删除?当 f2() 函数中的 local_p 超出范围时,它可能是对 int 的最后一个引用(引用计数达到零)。在这种情况下,堆分配的 int 将从线程 T2 中删除。否则,当调用 std::atomic_store() 时,删除将从线程 T1 中进行。因此,答案是 int 的删除可以从两个线程中进行。

原子引用

到目前为止,您已经看到了 std::atomc_flagstd::atomic<> 以及许多有用的特殊化。std::atomic 可以用指针进行特殊化,比如 std::atomic<T*>,但您还没有看到如何使用引用类型的原子操作。不可能编写 std::atomic<T&>;相反,标准库为我们提供了一个名为 std::atomic_ref 的模板。

std::atomic_ref 模板在 C++20 中引入。它的接口与 std::atomic 相同,之所以有一个单独的名称是为了避免影响使用 std::atomic<T> 的现有通用代码的风险。

原子引用允许我们对我们拥有引用的非原子对象执行原子操作。当我们引用由客户端或一些不提供内部同步对象的第三方代码提供的对象时,这可能很方便。我们将看一个例子来演示原子引用的有用性。

示例:使用原子引用

假设我们正在编写一个函数,该函数会将硬币翻转指定次数:

void flip_coin(std::size_t n, Stats& outcomes); 

结果累积在类型为 Statsoutcomes 对象中,它看起来像这样:

struct Stats {
  int heads_{};
  int tails_{};
};
std::ostream& operator<<(std::ostream& os, const Stats &s) {
  os << "heads: " << s.heads_ << ", tails: " << s.tails_;
  return os;
} 

客户端可以多次调用 flip_coins(),使用相同的 Stats 实例,翻转的结果将被添加到 Stats 中:

auto outcomes = Stats{};
flip_coin(30, outcomes); 
flip_coin(10, outcomes); 

假设我们想要并行化 flip_coin() 的实现,并让多个线程改变 Stats 对象。此外,我们可以假设以下情况:

  • Stats 结构体无法更改(可能来自第三方库)。

  • 我们希望客户端不知道我们的实用函数 flip_coin() 是并发的;也就是说,flip_coin() 函数的并发应该对调用者完全透明。

对于这个示例,我们将重用我们之前定义的用于生成随机数的函数。

int random_int(int min, int max); // See implementation above 

现在我们准备定义我们的 flip_coin() 函数,它将使用两个线程来翻转硬币 n 次:

void flip_coin(std::size_t n, Stats &outcomes) {
  auto flip = &outcomes {
    auto heads = std::atomic_ref<int>{outcomes.heads_};
    auto tails = std::atomic_ref<int>{outcomes.tails_};
    for (auto i = 0u; i < n; ++i) {
      random_int(0, 1) == 0 ? ++heads : ++tails;
    }
  };
  auto t1 = std::jthread{flip, n / 2};       // First half
  auto t2 = std::jthread{flip, n - (n / 2)}; // The rest
} 

两个线程都会在抛硬币后更新非原子结果对象。我们将创建两个 std::atomic_ref<int> 变量,用于原子更新结果对象的成员,而不是使用 std::mutex。重要的是要记住,为了保护头和尾计数器免受数据竞争的影响,所有对计数器的并发访问都需要使用 std::atomic_ref 进行保护。

以下小程序演示了 flip_coin() 函数可以在不了解 flip_coin() 的并发实现的情况下被调用:

int main() {
  auto stats = Stats{};
  flip_coin(5000, stats);       // Flip 5000 times
  std::cout << stats << '\n';
  assert((stats.tails_ + stats.heads_) == 5000);
} 

在我的机器上运行此程序产生了以下输出:

heads: 2592, tails: 2408 

这个例子结束了我们关于 C++中各种原子类模板的部分。原子操作自 C++11 以来就已经成为标准库的一部分,并且不断发展。C++20 引入了:

  • 特化std::atomic<std::shared_ptr<T>>

  • 原子引用;即std::atomic_ref<T>模板

  • 等待和通知 API,这是使用条件变量的轻量级替代方案

我们现在将继续讨论 C++内存模型以及它与原子操作和并发编程的关系。

C++内存模型

为什么在并发章节中我们要谈论 C++的内存模型?内存模型与并发密切相关,因为它定义了内存读写在线程之间如何可见。这是一个相当复杂的主题,涉及编译器优化和多核计算机架构。不过好消息是,如果你的程序没有数据竞争,并且使用原子库默认提供的内存顺序,你的并发程序将遵循一个直观易懂的内存模型。但是,至少了解内存模型是什么以及默认内存顺序保证是很重要的。

这一部分涵盖的概念由 Herb Sutter 在他的演讲原子武器:C++内存模型和现代硬件 1 和 2中得到了深入解释。这些演讲可以在herbsutter.com/2013/02/11/atomic-weapons-the-c-memory-model-and-modern-hardware/上免费获取,并且强烈推荐如果你需要更深入地了解这个主题。

指令重新排序

理解内存模型的重要性,首先需要了解我们编写的程序实际上是如何执行的一些背景知识。

当我们编写和运行程序时,合理地假设源代码中的指令将按照它们在源代码中出现的顺序执行。这是不正确的。我们编写的代码将在最终执行之前经过多个阶段的优化。编译器和硬件都会重新排序指令,以更有效地执行程序。这并不是新技术:编译器长期以来一直在做这个,这也是为什么优化构建比非优化构建运行得更快的原因之一。编译器(和硬件)可以自由地重新排序指令,只要在运行程序时不可观察到重新排序。程序运行时好像一切都按照程序顺序发生。

让我们看一个代码片段的例子:

int a = 10;      // 1 
std::cout << a;  // 2 
int b = a;       // 3 
std::cout << b;  // 4 
// Observed output: 1010 

在这里,很明显第二行和第三行可以交换而不会引入任何可观察的效果:

int a = 10;      // 1 
int b = a;       // 3 This line moved up  
std::cout << a;  // 2 This line moved down 
std::cout << b;  // 4 
// Observed output: 1010 

这是另一个例子,类似但不完全相同于第四章数据结构中的例子,编译器可以在遍历二维矩阵时优化一个不友好的缓存版本:

constexpr auto ksize = size_t{100}; 
using MatrixType = std::array<std::array<int, ksize>, ksize>; 

auto cache_thrashing(MatrixType& matrix, int v) { // 1 
  for (size_t i = 0; i < ksize; ++i)              // 2 
    for (size_t j = 0; j < ksize; ++j)            // 3 
      matrix[j][i] = v;                           // 4 
} 

你在第四章数据结构中看到,类似这样的代码会产生大量的缓存未命中,从而影响性能。编译器可以通过重新排序for语句来优化这个问题,就像这样:

auto cache_thrashing(MatrixType& matrix, int v) { // 1 
  for (size_t j = 0; j < ksize; ++j)              // 3 Line moved up 
    for (size_t i = 0; i < ksize; ++i)            // 2 Line moved down 
      matrix[j][i] = v;                           // 4  
} 

在执行程序时,无法观察到这两个版本之间的差异,但后者将运行得更快。

编译器和硬件执行的优化(包括指令流水线、分支预测和缓存层次结构)是非常复杂且不断发展的技术。幸运的是,原始程序的所有这些转换都可以看作是源代码中读写的重新排序。这也意味着无论是编译器还是硬件的某个部分执行转换都无关紧要。对于 C++程序员来说,重要的是知道指令可以被重新排序,但没有任何可观察的效果。

如果您一直在尝试调试程序的优化版本,您可能已经注意到很难逐步执行,因为重新排序。因此,通过使用调试器,重新排序在某种意义上是可观察的,但在正常运行程序时是不可观察的。

原子操作和内存顺序

在 C++中编写单线程程序时,不会发生数据竞争的风险。我们可以快乐地编写程序,而不必关心指令重新排序。然而,在多线程程序中涉及共享变量时,情况完全不同。编译器(和硬件)基于仅对一个线程为真和可观察的内容进行所有优化。编译器无法知道其他线程通过共享变量能观察到什么,因此我们作为程序员的工作就是告知编译器允许进行哪些重新排序。事实上,当我们使用原子变量或互斥锁保护我们免受数据竞争时,这正是我们所做的。

当用互斥锁保护临界区时,可以保证只有当前拥有锁的线程才能执行临界区。但是,互斥锁还在临界区周围创建内存栅栏,以通知系统在临界区边界不允许某些重新排序。在获取锁时,会添加一个“获取”栅栏,在释放锁时,会添加一个“释放”栅栏。

我将用一个例子来证明这一点。假设我们有四条指令:i1i2i3i4。它们之间没有依赖关系,因此系统可以任意重新排序指令而不会产生可观察的影响。指令 i2 和 i3 使用共享数据,因此它们是需要通过互斥锁保护的临界区。在添加互斥锁的“获取”和“释放”后,现在有一些重新排序不再有效。显然,我们不能将临界区的指令移出临界区,否则它们将不再受互斥锁的保护。单向栅栏确保没有指令可以从临界区移出。i1 指令可以通过获取栅栏移入临界区,但不能超过释放栅栏。i4 指令也可以通过释放栅栏移入临界区,但不能超过获取栅栏。

下图显示了单向栅栏如何限制指令的重新排序。没有读取或写入指令可以通过获取栅栏上方,也没有任何指令可以通过释放栅栏下方:

图 11.14:单向栅栏限制指令的重新排序

在获取互斥锁时,我们创建了一个获取内存栅栏。它告诉系统不能将内存访问(读取或写入)移动到获取栅栏所在的线以上。系统可以将 i4 指令移动到释放栅栏之上,超过 i3 和 i2 指令,但不能超过获取栅栏。

现在,让我们看看原子变量而不是互斥锁。当我们在程序中使用共享原子变量时,它给我们两件事:

  • 防止写入时出现撕裂:原子变量始终以原子方式更新,因此读取者无法读取部分写入的值。

  • 通过添加足够的内存栅栏同步内存:这可以防止某些指令重新排序,以保证原子操作指定的特定内存顺序。

如果我们的程序没有数据竞争,并且在使用原子操作时使用默认的内存顺序,C++内存模型会保证顺序一致性。那么,什么是顺序一致性?顺序一致性保证执行的结果与按照原始程序指定的顺序执行操作时的结果相同。线程之间指令的交错是任意的;也就是说,我们无法控制线程的调度。这一开始可能听起来很复杂,但这可能是你已经对并发程序的执行方式有所了解的方式。

顺序一致性的缺点是可能会影响性能。因此,可以使用松散的内存模型来代替原子操作。这意味着你只能获得对撕裂写入的保护,而无法获得顺序一致性提供的内存顺序保证。

我强烈建议你除了默认的顺序一致性内存顺序之外,不要使用其他任何东西,除非你非常了解更弱的内存模型可能引入的影响。

我们不会在这里进一步讨论松散的内存顺序,因为这超出了本书的范围。但值得一提的是,你可能会对知道std::shared_ptr中的引用计数器在增加计数时使用了松散模型(但在减少计数时没有使用)。这就是为什么在多线程环境中使用std::shared_ptr成员函数use_count()时,它只会报告大约的实际引用数量。

内存模型和原子操作非常相关的一个领域是无锁编程。接下来的部分将让你对无锁编程有所了解,并介绍一些应用场景。

无锁编程

无锁编程很难。我们不会在本书中花费很多时间讨论无锁编程,而是会为你提供一个非常简单的无锁数据结构的示例。有很多资源(网上和书籍中,比如之前提到的 Anthony Williams 的书)专门讨论无锁编程,这些资源会解释在编写自己的无锁数据结构之前需要理解的概念。一些你可能听说过的概念,比如比较和交换CAS)和 ABA 问题,在本书中不会进一步讨论。

示例:无锁队列

在这里,你将看到一个无锁队列的示例,这是一个相对简单但有用的无锁数据结构。无锁队列可用于与无法使用锁来同步对共享数据的访问的线程进行单向通信。

由于对队列的要求有限,它只支持一个读取线程和一个写入线程。队列的容量也是固定的,在运行时无法更改。

无锁队列是一个可能在通常放弃异常的环境中使用的组件的示例。因此,后面的队列设计中没有异常,这使得 API 与本书中其他示例不同。

类模板LockFreeQueue<T>具有以下公共接口:

  • push(): 将一个元素添加到队列中,并在成功时返回true。这个函数只能被(唯一的)写入线程调用。为了避免在客户端提供右值时进行不必要的复制,push()重载了const T&T&&。这种技术也在本章前面介绍的BoundedBuffer类中使用过。

  • pop(): 返回一个std::optional<T>,其中包含队列的第一个元素,除非队列为空。这个函数只能被(唯一的)读取线程调用。

  • size(): 返回队列的当前大小。这个函数可以被两个线程同时调用。

以下是队列的完整实现:

template <class T, size_t N>
class LockFreeQueue {
  std::array<T, N> buffer_{};   // Used by both threads
  std::atomic<size_t> size_{0}; // Used by both threads
  size_t read_pos_{0};          // Used by reader thread
  size_t write_pos_{0};         // Used by writer thread
  static_assert(std::atomic<size_t>::is_always_lock_free);
  bool do_push(auto&& t) {      // Helper function
    if (size_.load() == N) { 
      return false; 
    }
    buffer_[write_pos_] = std::forward<decltype(t)>(t);
    write_pos_ = (write_pos_ + 1) % N;
    size_.fetch_add(1);
    return true;
  }
public:
  // Writer thread
  bool push(T&& t) { return do_push(std::move(t)); }
  bool push(const T& t) { return do_push(t); }
  // Reader thread
  auto pop() -> std::optional<T> {
    auto val = std::optional<T>{};    
    if (size_.load() > 0) {
      val = std::move(buffer_[read_pos_]);
      read_pos_ = (read_pos_ + 1) % N;
      size_.fetch_sub(1);
    }
    return val;
  }
  // Both threads can call size()
  auto size() const noexcept { return size_.load(); }
}; 

唯一需要原子访问的数据成员是size_变量。read_pos_成员仅由读取线程使用,write_pos_仅由写入线程使用。那么std::array类型的缓冲区呢?它是可变的,并且被两个线程访问?这不需要同步吗?由于算法确保两个线程永远不会同时访问数组中的相同元素,C++保证可以在没有数据竞争的情况下访问数组中的单个元素。元素有多小都没关系;即使是char数组也具有这一保证。

这种非阻塞队列何时会有用?一个例子是在音频编程中,当主线程上运行着一个 UI 需要与实时音频线程发送或接收数据时,实时线程在任何情况下都不能阻塞。实时线程不能使用互斥锁,分配/释放内存,或执行任何可能导致线程等待低优先级线程的操作。这些情况下需要无锁数据结构。

LockFreeQueue中,读取器和写入器都是无锁的,因此我们可以有两个队列实例在主线程和音频线程之间双向通信,如下图所示:

图 11.15:使用两个无锁队列在主线程和实时音频线程之间传递状态

正如前面提到的,本书只是浅尝辄止无锁编程的表面。现在是时候用一些关于编写并发程序时性能的指南来结束本章了。

性能指南

我无法强调在尝试提高性能之前,正确运行并发程序的重要性。此外,在应用与性能相关的任何指南之前,您首先需要建立一种可靠的方式来衡量您要改进的内容。

避免争用

每当多个线程使用共享数据时,就会发生争用。争用会影响性能,有时由争用引起的开销会使并行算法的工作速度比单线程替代方案更慢。

使用导致等待和上下文切换的锁是明显的性能惩罚,但同样不明显的是,锁和原子操作都会禁用编译器生成的代码中的优化,并且在 CPU 执行代码时会在运行时这样做。这是为了保证顺序一致性。但请记住,这类问题的解决方案绝不是忽略同步,从而引入数据竞争。数据竞争意味着未定义行为,拥有快速但不正确的程序不会让任何人满意。

相反,我们需要尽量减少在关键部分的时间。我们可以通过更少地进入关键部分,并通过尽量减少关键部分本身来做到这一点,以便一旦进入关键部分,我们就尽快离开它。

避免阻塞操作

要编写一个现代响应式 UI 应用程序,始终保持流畅运行,绝对不能阻塞主线程超过几毫秒。一个流畅运行的应用程序每秒更新其界面 60 次。这意味着如果您正在做一些阻塞 UI 线程超过 16 毫秒的事情,FPS 将会下降。

您可以在设计应用程序的内部 API 时考虑这一点。每当编写执行 I/O 或可能需要超过几毫秒的其他操作的函数时,它需要被实现为异步函数。这种模式在 iOS 和 Windows 中变得非常普遍,例如,所有网络 API 都已变成异步。

线程数/CPU 核心数

机器的 CPU 核心越多,您可以运行的活动线程就越多。如果您设法将顺序的 CPU 绑定任务拆分为并行版本,您可以通过多个核心并行处理任务来提高性能。

从单线程算法转变为可以由两个线程运行的算法,在最佳情况下可能会使性能翻倍。但是,添加越来越多的线程后,最终会达到一个极限,此时不会再有性能增益。超过该极限添加更多线程实际上会降低性能,因为上下文切换引起的开销会随着添加的线程数量增加而变得更加显著。

例如,I/O 密集型任务,例如等待网络数据的网络爬虫,在达到 CPU 过度订阅的极限之前需要大量线程。等待 I/O 的线程很可能会从 CPU 中切换出来,以为其他准备执行的线程腾出空间。对于 CPU 密集型任务,通常没有必要使用超过机器上核心数量的线程。

控制大型程序中的线程总数可能很困难。控制线程数量的一个好方法是使用可以根据当前硬件大小调整大小的线程池。

第十四章并行算法中,您将看到如何并行化算法的示例,以及如何根据 CPU 核心数量调整并发量。

线程优先级

线程的优先级会影响线程的调度。具有高优先级的线程可能比具有较低优先级的线程更频繁地被调度。线程优先级对降低任务的延迟很重要。

操作系统提供的线程通常具有优先级。目前,使用当前的 C++线程 API 无法设置线程的优先级。但是,通过使用std::thread::native_handle,您可以获取到底层操作系统线程的句柄,并使用本机 API 来设置优先级。

与线程优先级相关的一种可能会影响性能并且应该避免的现象称为优先级反转。当一个具有高优先级的线程正在等待获取当前由低优先级线程持有的锁时,就会发生这种情况。这种依赖关系会影响高优先级线程,因为它被阻塞,直到下一次低优先级线程被调度以释放锁。

对于实时应用程序来说,这是一个大问题。实际上,这意味着您不能使用锁来保护需要实时线程访问的任何共享资源。例如,生成实时音频的线程以最高可能的优先级运行,为了避免优先级反转,不可能让音频线程调用任何可能阻塞并引起上下文切换的函数(包括std::malloc())。

线程亲和性

线程亲和性使得调度程序可以提示哪些线程可以受益于共享相同的 CPU 缓存。换句话说,这是对调度程序的请求,如果可能的话,一些线程应该在特定的核心上执行,以最小化缓存未命中。

为什么要让一个线程在特定的核心上执行?答案是(再次)缓存。在相同内存上操作的线程可能会受益于在同一核心上运行,从而利用热缓存。对于调度程序来说,这只是分配线程到核心时需要考虑的众多参数之一,因此这几乎不是任何保证,但是,操作系统之间的行为差异非常大。线程优先级,甚至利用所有核心(以避免过热)是现代调度程序需要考虑的要求之一。

使用当前的 C++ API 无法以便携的方式设置线程亲和性,但大多数平台支持在线程上设置亲和性掩码的某种方式。为了访问特定于平台的功能,您需要获取本机线程的句柄。接下来的示例演示了如何在 Linux 上设置线程亲和性掩码:

#include <pthreads> // Non-portable header 
auto set_affinity(const std::thread& t, int cpu) {
  cpu_set_t cpuset;
  CPU_ZERO(&cpuset);
  CPU_SET(cpu, &cpuset);
  pthread_t native_thread = t.native_handle(); 
  pthread_set_affinity(native_thread, sizeof(cpu_set_t), &cpuset); 
} 

请注意,这不是便携式的 C++,但如果您正在进行性能关键的并发编程,很可能需要对线程进行一些不便携式的配置。

虚假共享

虚假共享,或者破坏性干扰,可能会严重降低性能。当两个线程使用一些数据(这些数据在逻辑上不共享)但碰巧位于同一个缓存行时,就会发生虚假共享。想象一下,如果两个线程在不同的核心上执行,并且不断更新位于共享缓存行上的变量,会发生什么。尽管线程之间没有真正共享数据,但它们会相互使缓存行失效。

虚假共享很可能发生在使用全局数据或动态分配的数据在线程之间共享时。一个可能发生虚假共享的例子是分配一个数组,该数组在线程之间共享,但每个线程只使用数组的一个元素。

解决这个问题的方法是对数组中的每个元素进行填充,以便相邻的两个元素不能位于同一个缓存行上。自 C++17 以来,有一种便携式的方法可以使用<new>中定义的std::hardware_destructive_interference_size常量和alignas说明符来实现这一点。以下示例演示了如何创建一个元素来防止虚假共享:

struct alignas(std::hardware_destructive_interference_size) Element {
   int counter_{};
}; 

auto elements = std::vector<Element>(num_threads); 

现在,向量中的元素被保证位于不同的缓存行上。

总结

在本章中,您已经学会了如何创建可以同时执行多个线程的程序。我们还介绍了如何通过使用锁或原子操作来保护关键部分,以避免数据竞争。您了解到 C++20 带来了一些有用的同步原语:屏障、障碍和信号量。然后我们研究了执行顺序和 C++内存模型,在编写无锁程序时理解这些内容变得很重要。您还发现了不可变数据结构是线程安全的。本章最后介绍了一些改进并发应用程序性能的指南。

接下来的两章专门介绍了一个全新的 C++20 特性,称为协程,它允许我们以顺序方式编写异步代码。

第十二章:协程和惰性生成器

计算已经成为一个等待的世界,我们需要编程语言的支持来表达等待。一般的想法是在当前流到达我们知道可能需要等待某些东西的点时,暂停(暂时暂停)当前流,并将执行交给其他流。我们需要等待的某些东西可能是网络请求、用户的点击、数据库操作,甚至是花费太长时间的内存访问。相反,我们在代码中说我们会等待,继续一些其他流,然后在准备好时回来。协程允许我们这样做。

在本章中,我们主要将关注添加到 C++20 中的协程。您将学习它们是什么,如何使用它们以及它们的性能特征。但我们也将花一些时间来更广泛地看待协程,因为这个概念在许多其他语言中都是明显的。

C++协程在标准库中的支持非常有限。为 C++23 发布添加协程的标准库支持是一个高优先级的功能。为了在日常代码中有效地使用协程,我们需要实现一些通用的抽象。本书将向您展示如何实现这些抽象,以便学习 C++协程,而不是为您提供生产就绪的代码。

了解存在的各种类型的协程,协程可以用于什么,以及是什么促使 C++添加新的语言特性来支持协程。

本章涵盖了很多内容。下一章也是关于协程,但重点是异步应用程序。总之,本章将引导您完成:

  • 关于协程的一般理论,包括有栈和无栈协程之间的区别,以及编译器如何转换它们并在计算机上执行。

  • 介绍 C++中无栈协程。将讨论和演示 C++20 对协程的新语言支持,包括co_awaitco_yieldco_return

  • 使用 C++20 协程作为生成器所需的抽象。

  • 一些真实世界的例子展示了使用协程的可读性和简单性的好处,以及我们如何通过使用协程编写可组合的组件,以便进行惰性评估。

如果您已经在其他语言中使用协程,那么在阅读本章的其余部分之前,您需要做好两件事:

  • 对您来说,一些内容可能感觉很基础。尽管 C++协程的工作原理的细节远非微不足道,但使用示例可能对您来说感觉微不足道。

  • 本章中我们将使用的一些术语(协程、生成器、任务等)可能与您当前对这些内容的看法不一致。

另一方面,如果您对协程完全不熟悉,本章的部分内容可能看起来像魔术一样,需要一些时间来理解。因此,我将首先向您展示一些使用协程时 C++代码的例子。

一些激励性的例子

协程是一种类似于 lambda 表达式的功能,它提供了一种完全改变我们编写和思考 C++代码的方式。这个概念非常普遍,可以以许多不同的方式应用。为了让您了解使用协程时 C++的样子,我们将在这里简要地看两个例子。

使用 yield 表达式来实现生成器——产生值序列的对象。在这个例子中,我们将使用关键字co_yieldco_return来控制流程:

auto iota(int start) -> Generator<int> {
  for (int i = start; i < std::numeric_limits<int>::max(); ++i) {
    co_yield i;
  }
}
auto take_until(Generator<int>& gen, int value) -> Generator<int> {
  for (auto v : gen) {
    if (v == value) {
      co_return;
    }
    co_yield v;
  }
}
int main() {
  auto i = iota(2);
  auto t = take_until(i, 5);
  for (auto v : t) {          // Pull values
    std::cout << v << ", ";
  }
  return 0;
}
// Prints: 2, 3, 4 

在前面的示例中,iota()take_until()是协程。iota()生成一个整数序列,take_until()在找到指定值之前产生值。Generator模板是一种自定义类型,我将在本章后面向您展示如何设计和实现它。

构建生成器是协程的一个常见用例,另一个是实现异步任务。下一个示例将演示我们如何使用操作符co_await来等待某些内容,而不会阻塞当前执行的线程:

auto tcp_echo_server() -> Task<> {
  char data[1024];
  for (;;) {
    size_t n = co_await async_read(socket, buffer(data));
    co_await async_write(socket, buffer(data, n));
  }
} 

co_await不会阻塞,而是在异步读写函数完成并恢复执行之前暂停执行。这里介绍的示例是不完整的,因为我们不知道Tasksocketbuffer和异步 I/O 函数是什么。但是在下一章中,当我们专注于异步任务时,我们会了解到这些内容。

如果目前还不清楚这些示例是如何工作的,不要担心——我们将在本章后面花费大量时间深入了解细节。这些示例是为了给你一个关于协程允许我们做什么的提示,如果你以前从未遇到过它们。

在深入研究 C++20 协程之前,我们需要讨论一些术语和共同的基础知识,以更好地理解为什么在 2020 年向 C++中添加一个相当复杂的语言特性的设计和动机。

协程抽象

现在我们将退后一步,谈论一般的协程,而不仅仅是专注于添加到 C++20 的协程。这将让你更好地理解为什么协程是有用的,以及有哪些类型的协程以及它们之间的区别。如果你已经熟悉了有栈和无栈协程以及它们是如何执行的,你可以跳过这一部分,直接转到下一部分,C++中的协程

协程抽象已经存在了 60 多年,许多语言已经将某种形式的协程纳入其语法或标准库中。这意味着协程在不同的语言和环境中可能表示不同的东西。由于这是一本关于 C++的书,我将使用 C++标准中使用的术语。

协程与子例程非常相似。在 C++中,我们没有明确称为子例程的东西;相反,我们编写函数(例如自由函数或成员函数)来创建子例程。我将交替使用术语普通函数子例程

子例程和协程

为了理解协程和子例程(普通函数)之间的区别,我们将在这里专注于子例程和协程的最基本属性,即如何启动、停止、暂停和恢复它们。当程序的其他部分调用子例程时,子例程就会启动。当子例程返回到调用者时,子例程就会停止:

auto subroutine() {
  // Sequence of statements ...

  return;     // Stop and return control to caller
}
subroutine(); // Call subroutine to start it
// subroutine has finished 

子例程的调用链是严格嵌套的。在接下来的图表中,子例程f()在子例程g()返回之前无法返回到main()

图 12.1:子例程调用和返回的链

协程也可以像子例程一样启动和停止,但它们也可以被挂起(暂停)和恢复。如果你以前没有使用过协程,这一点可能一开始看起来很奇怪。协程被挂起和恢复的地方称为挂起/恢复点。有些挂起点是隐式的,而其他挂起点则以某种方式在代码中明确标记。以下伪代码显示了使用awaityield标记的三个显式挂起/恢复点:

// Pseudo code
auto coroutine() {
  value = 10;  
  await something;        // Suspend/Resume point
  // ...
  yield value++;          // Suspend/Resume point
  yield value++;          // Suspend/Resume point
  // ...
  return;
}
auto res = coroutine();    // Call
res.resume();              // Resume 

在 C++中,使用关键字co_awaitco_yield标记显式的挂起点。下面的图表显示了协程如何从一个子例程中调用,然后稍后从代码的不同部分恢复:

图 12.2:协程的调用可以挂起和恢复。协程调用在被挂起时保持其内部状态。

协程被挂起时,协程内部的局部变量状态会被保留。这些状态属于协程的某次调用。也就是说,它们不像静态局部变量那样,静态局部变量在函数的所有调用之间是全局共享的。

总之,协程是可以被挂起和恢复的子例程。另一种看待它的方式是说,子例程是无法被挂起或恢复的协程的一种特例。

从现在开始,我将在区分调用恢复,以及挂起返回时非常严格。它们意味着完全不同的事情。调用协程会创建一个可以被挂起和恢复的协程的新实例。从协程返回会销毁协程实例,它将无法再恢复。

要真正理解协程如何帮助我们编写高效的程序,您需要了解一些关于 C++函数通常如何转换为机器代码然后执行的低级细节。

在 CPU 上执行子例程和协程

在本书中,我们已经讨论了内存层次结构、缓存、虚拟内存、线程调度和其他硬件和操作系统概念。但我们并没有真正讨论指令是如何使用 CPU 寄存器和堆栈在 CPU 上执行的。当比较子例程与各种协程时,了解这些概念是很重要的。

CPU 寄存器、指令和堆栈

本节将提供一个非常简化的 CPU 模型,以便理解上下文切换、函数调用以及关于调用堆栈的更多细节。在这种情况下,当我提到 CPU 时,我指的是一些类似于带有多个通用寄存器的 x86 系列 CPU 的 CPU。

程序包含 CPU 执行的一系列指令。指令序列存储在计算机的某个地方的内存中。CPU 通过一个称为程序计数器的寄存器跟踪当前执行指令的地址。这样,CPU 就知道下一个要执行的指令是什么。

CPU 包含固定数量的寄存器。寄存器类似于具有预定义名称的变量,可以存储值或内存地址。寄存器是计算机上最快的数据存储器,并且最接近 CPU。当 CPU 操作数据时,它使用寄存器。一些寄存器对 CPU 具有特殊意义,而其他寄存器可以由当前执行的程序更自由地使用。

对 CPU 具有特殊意义的两个非常重要的寄存器是:

  • 程序计数器PC):存储当前执行指令的内存地址的寄存器。每当执行一条指令时,该值会自动递增。有时它也被称为指令指针

  • 堆栈指针SP):它存储当前使用的调用堆栈顶部的地址。分配和释放堆栈内存只是改变这个单个寄存器中存储的值的问题。

图 12.3:带有寄存器的 CPU

假设寄存器被称为R0R1R2R3,如前图所示。典型的算术指令可能如下所示:

add 73, R1   // Add 73 to the value stored in R1 

数据也可以在寄存器和内存之间复制:

mov SP, R2   // Copy the stack pointer address to R2
mov R2, [R1] // Copy value of R2 to memory address stored in R1 

一组指令隐含地指向调用堆栈。CPU 通过堆栈指针知道调用堆栈的顶部在哪里。在堆栈上分配内存只是更新堆栈指针的问题。该值增加或减少取决于堆栈是向更高地址还是更低地址增长。

以下指令使用了堆栈:

push R1     // Push value of R1 to the top of the stack 

push 指令将寄存器中的值复制到由堆栈指针指向的内存位置,并递增(或递减)堆栈指针。

我们还可以使用pop指令从堆栈中弹出值,并读取和更新堆栈指针:

pop R2      // Pop value from the stack into R2 

每当执行一条指令时,CPU 会自动递增程序计数器。但程序计数器也可以通过指令明确更新,例如jump指令:

jump R3     // Set the program counter to the address in R3 

CPU 可以以两种模式运行:用户模式或内核模式。当 CPU 在用户模式下运行时,它以不同的方式使用 CPU 寄存器。当 CPU 在用户模式下执行时,它以无法访问硬件的受限权限运行。操作系统提供在内核模式下运行的系统调用。因此,C++库函数(例如std::puts())必须进行系统调用才能完成其任务,迫使 CPU 在用户模式和内核模式之间切换。

在用户模式和内核模式之间转换是昂贵的。要理解原因,让我们再次考虑我们的示意 CPU。CPU 通过使用其寄存器高效运行,因此避免不必要地将值溢出到堆栈上。但是 CPU 是所有用户进程和操作系统之间共享的资源,每当我们需要在任务之间切换时(例如,进入内核模式时),处理器的状态,包括其所有寄存器,都需要保存在内存中,以便以后可以恢复。

调用和返回

现在您已经基本了解了 CPU 如何使用寄存器和堆栈,我们可以讨论子例程调用。在调用和返回子例程时涉及许多机制,我们可能会认为这是理所当然的。当编译器将 C++函数转换为高度优化的机器代码时,它们的工作非常出色。

以下列表显示了调用、执行和从子例程返回时需要考虑的方面:

  • 调用和返回(在代码中跳转)。

  • 传递参数——参数可以通过寄存器或堆栈传递,也可以两者兼而有之。

  • 在堆栈上为局部变量分配存储空间。

  • 返回值——从子例程返回的值需要存储在调用者可以找到的地方。通常,这是一个专用的 CPU 寄存器。

  • 在不干扰其他函数的情况下使用寄存器——子例程使用的寄存器需要在调用子例程之前恢复到其调用之前的状态。

有关如何执行函数调用的确切细节由称为调用约定的东西指定。它们为调用者/被调用者提供了一个协议,以便双方就谁负责哪些部分达成一致。调用约定在 CPU 架构和编译器之间不同,并且是构成应用程序二进制接口ABI)的主要部分之一。

当调用函数时,该函数的调用帧(或激活帧)被创建。调用帧包含:

  • 传递给函数的参数

  • 函数的局部变量

  • 我们打算使用的寄存器的快照,因此需要在返回之前恢复。

  • 返回地址,它链接回调用者从中调用函数的内存位置。

  • 可选的帧指针,指向调用者的调用帧顶部。在检查堆栈时,帧指针对调试器很有用。我们在本书中不会进一步讨论帧指针。

由于子例程的严格嵌套性质,我们可以将子例程的调用帧有效地保存在堆栈上,以支持嵌套调用。存储在堆栈上的调用帧通常称为堆栈帧

以下图表显示了调用堆栈上的多个调用帧,并突出显示了单个调用帧的内容:

图 12.4:具有多个调用帧的调用堆栈。右侧的调用帧是单个调用帧的放大版本。

当子程序返回给调用者时,它使用返回地址来知道要跳转到哪里,恢复它已经改变的寄存器,并弹出(释放)整个调用帧。通过这种方式,堆栈和寄存器都恢复到调用子程序被调用之前的状态。但是,有两个例外。首先,程序计数器(PC)已经移动到调用后的指令。其次,将值返回给其调用者的子程序通常将该值存储在一个专用寄存器中,调用者知道在哪里找到它。

理解了子程序是如何通过临时使用堆栈来执行,然后在将控制返回给调用者之前恢复 CPU 寄存器,我们现在可以开始看看如何挂起和恢复协程。

挂起和恢复

考虑以下伪代码,定义了一个具有多个挂起/恢复点的协程:

// Pseudo code
auto coroutine() { 
  auto x = 0;
  yield x++;       // Suspend
  g();             // Call some other function
  yield x++;       // Suspend
  return;          // Return 
}
auto co = coroutine(); // Call subroutine to start it
// ...                 // Coroutine is suspended
auto a = resume(co);   // Resume coroutine to get
auto b = resume(co);   // next value 

coroutine()挂起时,我们无法像子程序返回给调用者时那样删除调用帧。为什么?因为我们需要保留变量x的当前值,并且还需要记住在协程中应该在何处继续执行下次协程恢复时。这些信息被放入一个称为协程帧的东西中。协程帧包含恢复暂停协程所需的所有信息。然而,这引发了一些新问题:

  • 协程帧存储在哪里?

  • 协程帧有多大?

  • 当协程调用子程序时,它需要一个堆栈来管理嵌套的调用帧。如果我们尝试从嵌套的调用帧内恢复会发生什么?那么当协程恢复时,我们需要恢复整个堆栈。

  • 调用和从协程返回的运行时开销是多少?

  • 挂起和恢复协程的运行时开销是多少?

对这些问题的简短回答是,这取决于我们讨论的协程类型:无堆栈或有堆栈的协程。

有堆栈的协程有一个单独的侧堆栈(类似于线程),其中包含协程帧和嵌套的调用帧。这使得可以从嵌套的调用帧中挂起:

图 12.5:对堆栈协程的每次调用都会创建一个具有唯一堆栈指针的单独侧堆栈

挂起和恢复无堆栈协程

无堆栈协程需要在其他地方(通常在堆上)存储协程帧,然后使用当前执行线程的堆栈来存储嵌套调用帧。

但这并不是全部真相。调用者负责创建调用帧,保存返回地址(程序计数器的当前值)和堆栈上的参数。调用者不知道自己正在调用一个会挂起和恢复的协程。因此,协程本身在被调用时需要创建协程帧,并将参数和寄存器从调用帧复制到协程帧中:

图 12.6:无堆栈协程具有单独的协程帧(通常在堆上),其中包含恢复协程所需的状态

当协程最初挂起时,协程的堆栈帧从堆栈中弹出,但协程帧继续存在。协程帧的内存地址(句柄/指针)被返回给调用者:

图 12.7:挂起的协程。协程帧包含恢复协程所需的所有信息。

要恢复协程,调用者使用先前收到的句柄,并调用一个恢复函数,并将协程句柄作为参数传递。恢复函数使用存储在协程帧中的挂起/恢复点来继续执行协程。对恢复函数的调用也是一个普通的函数调用,将生成一个堆栈帧,如下图所示:

图 12.8:恢复协程为恢复调用创建一个新的调用帧。恢复函数使用协程状态的句柄从正确的挂起点恢复。

最后,当协程返回时,通常会被挂起并最终被释放。堆栈的状态如下图所示:

图 12.9:协程帧在返回时被释放

没有为每个协程调用分配单独的侧边堆栈的一个重要后果是,当无堆栈协程被挂起时,它不能在堆栈上留下任何嵌套调用帧。记住,当控制权转回调用者时,调用者的调用帧必须位于堆栈顶部。

最后要提到的是,在某些情况下,协程帧所需的内存可以在调用者的调用帧内分配。当我们查看 C++20 协程时,我们将更详细地讨论这一点。

无堆栈与有堆栈协程

正如前一节所述,无堆栈协程使用当前运行线程的堆栈来处理嵌套函数调用。这样做的效果是无堆栈协程永远不会从嵌套调用帧中挂起。

有时堆栈式协程被称为纤程,在 Go 编程语言中被称为goroutines。堆栈式协程让我们想起线程,每个线程管理自己的堆栈。然而,堆栈式协程(或纤程)与操作系统线程之间有两个重大区别:

  • 操作系统线程由内核调度,并在两个线程之间进行切换是内核模式操作。

  • 大多数操作系统抢占式地切换操作系统线程(线程被调度程序中断),而两个纤程之间的切换是合作的。运行中的纤程会一直运行,直到将控制权交给可以调度另一个纤程的管理器。

还有一类称为用户级线程绿色线程的线程。这些是轻量级线程,不涉及内核模式切换(因为它们在用户模式下运行,因此内核不知道)。纤程是用户级线程的一个例子。但用户级线程也可以由用户库或虚拟机抢占地调度。Java 线程是抢占式用户级线程的一个例子。

无堆栈协程还允许我们编写和组合多个并发运行的任务,但不需要每个流程单独的侧边堆栈。无堆栈协程和状态机密切相关。可以将状态机转换为协程,反之亦然。为什么了解这一点很有用?首先,这让你更好地理解无堆栈协程是什么。其次,如果你已经擅长识别可以使用状态机解决的问题,你可以更容易地看到协程可能适合作为适当解决方案的地方。状态机是非常通用的抽象,可以应用于各种问题。然而,状态机通常应用的一些领域包括解析、手势识别和 I/O 多路复用等。这些都是无堆栈协程在表达和性能方面真正闪耀的领域。

性能成本

协程是一种抽象,使我们能够以清晰简洁的方式编写惰性评估代码和异步程序。但是,创建和销毁协程以及挂起和恢复协程都会带来性能成本。在比较无堆栈和有堆栈协程的性能成本时,需要解决两个主要方面:内存占用上下文切换

内存占用

有栈协程需要一个单独的调用栈来处理来自嵌套调用帧的挂起。因此,在调用协程时,我们需要动态分配一块内存来存储这个新的侧栈。这立即引发了一个问题:我们需要分配多大的栈?除非我们有关于协程及其嵌套调用帧可以消耗多少栈的一些策略,否则我们可能需要一个大约与线程的正常调用栈大小相同的栈。

一些实现已经尝试使用分段栈,这将允许栈在必要时增长。另一种选择是从一个小的连续栈开始,然后在需要时将栈复制到一个更大的新分配的内存区域(类似于std::vector的增长)。Go 语言中的协程实现(goroutines)已经从使用分段栈切换到了动态增长的连续栈。

无栈协程不需要为单独的侧栈分配内存。相反,它们需要为每个协程帧分配一个单独的内存以支持挂起和恢复。这种分配发生在调用协程时(但不是在挂起/恢复时)。当协程返回时,调用帧被释放。

总之,有栈协程需要为协程帧和侧栈进行大量的初始内存分配,或者需要支持一个增长的栈。无栈协程只需要为协程帧分配内存。调用协程的内存占用可以总结如下:

  • 无栈:协程帧

  • 有栈:协程帧+调用栈

性能成本的下一个方面与挂起和恢复协程有关。

上下文切换

上下文切换可以发生在不同的级别。一般来说,当我们需要 CPU 在两个或多个正在进行的任务之间切换时,就会发生上下文切换。即将暂停的任务需要保存 CPU 的整个状态,以便在以后恢复时可以恢复。

在不同进程和操作系统线程之间切换是相当昂贵的操作,涉及系统调用,需要 CPU 进入内核模式。内存缓存被使无效,对于进程切换,包含虚拟内存和物理内存映射的表需要被替换。

挂起和恢复协程也是一种上下文切换,因为我们在多个并发流之间切换。在协程之间切换比在进程和操作系统线程之间切换要快得多,部分原因是它不涉及需要 CPU 在内核模式下运行的任何系统调用。

然而,当在有栈协程和无栈协程之间切换时仍然存在差异。有栈协程和无栈协程的上下文切换的相对运行时性能可能取决于调用模式。但总的来说,有栈协程的上下文切换操作更昂贵,因为在挂起和恢复时需要保存和恢复更多的信息,而无栈协程的恢复类似于正常的函数调用。

关于有栈与无栈的辩论在 C++社区已经进行了好几年,我会尽力避开这场辩论,总结它们都有有效的用例——有些用例会偏向有栈协程,而其他用例会偏向无栈协程。

为了让你更好地理解协程的执行和性能,这一部分稍微偏离了一下。让我们简要回顾一下你学到的内容。

到目前为止你学到的内容

协程是可以挂起和恢复的函数。普通函数没有这种能力,这使得可以删除返回的函数的调用帧。然而,一个被挂起的协程需要保持调用帧活动,以便在恢复时能够恢复协程的状态。协程比子例程更强大,并且在生成的机器代码中涉及更多的簿记工作。然而,由于协程与普通函数之间的密切关系,今天的编译器非常擅长优化无堆栈协程。

堆栈式协程可以看作是非抢占式用户级线程,而无堆栈协程提供了一种以直接命令方式编写状态机的方法,使用关键字awaityield来指定挂起点。

在对协程的一般抽象介绍之后,现在是时候了解 C++中如何实现无堆栈协程。

C++中的协程

C++20 中添加的协程是无堆栈协程。也有使用第三方库在 C++中使用堆栈式协程的选项。最知名的跨平台库是 Boost.Fiber。C++20 无堆栈协程引入了新的语言构造,而 Boost.Fiber 是一个可以在 C++11 及以后版本中使用的库。在本书中我们不会进一步讨论堆栈式协程,而是专注于 C++20 中标准化的无堆栈协程。

C++20 中的无堆栈协程设计有以下目标:

  • 在内存开销方面可扩展,这使得可以有更多的协程同时存在,与可能存在的线程或堆栈式协程数量相比。

  • 高效的上下文切换,这意味着挂起和恢复协程应该与普通函数调用一样廉价。

  • 高度灵活。C++协程有 15 多个自定义点,这为应用程序开发人员和库编写人员提供了很大的自由度,可以根据自己的喜好配置和塑造协程。关于协程应该如何工作的决定可以由我们开发人员确定,而不是硬编码在语言规范中。一个例子是协程在被调用后是否应该直接挂起,还是继续执行到第一个显式挂起点。在其他语言中,这些问题通常是硬编码的,但在 C++中,我们可以使用自定义点来定制这种行为。

  • 不要求 C++异常来处理错误。这意味着您可以在关闭异常的环境中使用协程。请记住,协程是一种低级功能,类似于普通函数,在嵌入式环境和具有实时要求的系统中非常有用。

有了这些目标,C++协程可能一开始会有点复杂。

标准 C++中包含了什么(以及不包含什么)?

一些 C++特性是纯库特性(例如 Ranges 库),而其他特性是纯语言特性(例如使用auto关键字进行类型推断)。然而,有些特性需要对核心语言和标准库进行补充。C++协程就是其中之一;它们为语言引入了新的关键字,同时也向标准库添加了新的类型。

在语言方面,总结一下,我们有以下与协程相关的关键字:

  • co_await:挂起当前协程的运算符

  • co_yield:向调用者返回一个值并挂起协程

  • co_return:完成协程的执行,并且可以选择返回一个值

在库方面,有一个新的<coroutine>头文件,其中包括以下内容:

  • std::coroutine_handle:引用协程状态的模板类,使协程能够挂起和恢复

  • std::suspend_never:一个从不挂起的平凡等待类型

  • std::suspend_always:一个始终暂停的平凡等待类型

  • std::coroutine_traits:用于定义协程的承诺类型

C++20 附带的库类型是绝对最低限度的。例如,用于协程和调用者之间通信的基础设施不是 C++标准的一部分。为了有效地在应用程序代码中使用协程,我们需要的一些类型和函数已经在新的 C++提案中提出,例如模板类taskgenerator以及函数sync_wait()when_all()。C++协程的库部分很可能会在 C++23 中得到补充。

在本书中,我将提供一些简化的类型来填补这一空白,而不是使用第三方库。通过实现这些类型,您将深入了解 C++协程的工作原理。然而,设计可以与协程一起使用的健壮库组件很难在不引入生命周期问题的情况下正确实现。因此,如果您计划在当前项目中使用协程,使用第三方库可能是比从头开始实现更好的选择。在撰写本文时,CppCoro库是这些通用原语的事实标准。该库由 Lewis Baker 创建,可在github.com/lewissbaker/cppcoro上找到。

什么使 C++函数成为协程?

如果 C++函数包含关键字co_awaitco_yieldco_return,则它是一个协程。此外,编译器对协程的返回类型也有特殊要求。但是,我们需要检查定义(主体)而不仅仅是声明,才能知道我们是否面对的是协程还是普通函数。这意味着协程的调用者不需要知道它调用的是协程还是普通函数。

与普通函数相比,协程还有以下限制:

  • 协程不能使用像f(const char*...)这样的可变参数

  • 协程不能返回auto或概念类型:auto f()

  • 协程不能声明为constexpr

  • 构造函数和析构函数不能是协程

  • main()函数不能是协程

一旦编译器确定一个函数是协程,它就会将协程与多种类型关联起来,以使协程机制工作。以下图表突出显示了在调用者使用协程时涉及的不同组件:

图 12.10:协程与其调用者之间的关系

调用者和协程是我们通常在应用程序代码中实现的实际函数。

返回对象是协程返回的类型,通常是为某个特定用例设计的通用类模板,例如生成器异步任务调用者与返回对象交互以恢复协程并获取从协程中发出的值。返回对象通常将其所有调用委托给协程句柄。

协程句柄是对协程状态的非拥有句柄。通过协程句柄,我们可以恢复和销毁协程状态。

协程状态是我之前所说的协程帧。它是一个不透明的对象,这意味着我们不知道它的大小,也不能以其他方式访问它,而只能通过句柄。协程状态存储了恢复协程的一切必要条件。协程状态还包含Promise

承诺对象是协程本身间接通过关键字co_awaitco_yieldco_return进行通信的。如果从协程提交值或错误,它们将首先到达承诺对象。承诺对象充当协程和调用者之间的通道,但它们都无法直接访问承诺。

诚然,乍一看这可能看起来相当密集。一个完整但简单的例子将帮助你更好地理解不同的部分。

一个简单但完整的例子

让我们从一个最小的例子开始,以便理解协程的工作原理。首先,我们实现一个小的协程,在返回之前被挂起和恢复:

auto coroutine() -> Resumable {    // Initial suspend
  std::cout << "3 ";
  co_await std::suspend_always{};  // Suspend (explicit)
  std::cout << "5 ";
}                                  // Final suspend then return 

其次,我们创建协程的调用者。注意程序的输出和控制流。这里是:

int main() {            
  std::cout << "1 ";
  auto resumable = coroutine(); // Create coroutine state
  std::cout << "2 ";
  resumable.resume();           // Resume
  std::cout << "4 ";
  resumable.resume();           // Resume
  std::cout << "6 ";
}                               // Destroy coroutine state
// Outputs: 1 2 3 4 5 6 

第三,协程的返回对象Resumable需要被定义:

class Resumable {                // The return object
  struct Promise { /*...*/ };    // Nested class, see below
  std::coroutine_handle<Promise> h_;
  explicit Resumable(std::coroutine_handle<Promise> h) : h_{h} {}
public:
  using promise_type = Promise;
  Resumable(Resumable&& r) : h_{std::exchange(r.h_, {})} {}
  ~Resumable() { if (h_) { h_.destroy(); } }
  bool resume() {
    if (!h_.done()) { h_.resume(); }
    return !h_.done();
  }
}; 

最后,承诺类型被实现为Resumable内部的嵌套类,如下所示:

struct Promise {
  Resumable get_return_object() {
    using Handle = std::coroutine_handle<Promise>;
    return Resumable{Handle::from_promise(*this)};
  }
  auto initial_suspend() { return std::suspend_always{}; }
  auto final_suspend() noexcept { return std::suspend_always{}; }
  void return_void() {}
  void unhandled_exception() { std::terminate(); }
}; 

这个例子很简单,但涉及了很多值得注意和需要理解的东西:

  • coroutine()函数是一个协程,因为它包含了使用co_await的显式挂起/恢复点

  • 协程不会产生任何值,但仍然需要返回一个类型(Resumable),具有一定的约束,以便调用者可以恢复协程。

  • 我们正在使用一个名为std::suspend_always可等待类型

  • resumable对象的resume()函数从协程被挂起的地方恢复协程

  • Resumable是协程状态的所有者。当Resumable对象被销毁时,它使用coroutine_handle销毁协程

调用者、协程、协程句柄、承诺和可恢复之间的关系如下图所示:

图 12.11:可恢复示例中涉及的函数/协程和对象之间的关系

现在是时候仔细看看每个部分了。我们将从Resumable类型开始。

协程返回对象

我们的协程返回一个Resumable类型的对象。这个Resumable类非常简单。这是协程返回的对象,调用者可以使用它来恢复和销毁协程。以下是完整的定义,以供您方便查看:

class Resumable {               // The return object
  struct Promise { /*...*/ };   // Nested class
  std::coroutine_handle<Promise> h_;
  explicit Resumable(std::coroutine_handle<Promise> h) : h_{h} {}
public:
  using promise_type = Promise;
  Resumable(Resumable&& r) : h_{std::exchange(r.h_, {})} {}
  ~Resumable() { if (h_) { h_.destroy(); } }
  bool resume() {
    if (!h_.done()) { h_.resume(); }
    return !h_.done();
  }
}; 

Resumable是一个移动类型,它是协程句柄的所有者(因此控制协程的生命周期)。移动构造函数确保通过使用std::exchange()在源对象中清除协程句柄。当Resumable对象被销毁时,如果仍然拥有它,它将销毁协程。

resume()成员函数将恢复调用委托给协程句柄,如果协程仍然存活。

为什么我们需要在Resumable内部有成员类型别名promise_type = Promise?对于每个协程,还有一个关联的承诺对象。当编译器看到一个协程(通过检查函数体),它需要找出关联的承诺类型。为此,编译器使用std::coroutine_traits<T>模板,其中T是您的协程的返回类型。您可以提供std::coroutine_traits<T>的模板特化,或者利用std::coroutine_traits的默认实现将在协程的返回类型T中查找名为promise_typepublic成员类型或别名。在我们的情况下,Resumable::promise_typePromise的别名。

承诺类型

承诺类型控制协程的行为。以下是完整的定义,以供您方便查看:

struct Promise {
  auto get_return_object() { return Resumable{*this}; }
  auto initial_suspend() { return std::suspend_always{}; }
  auto final_suspend() noexcept { return std::suspend_always{}; }
  void return_void() {}
  void unhandled_exception() { std::terminate(); }
}; 

我们不应直接调用这些函数;相反,编译器在将协程转换为机器代码时会插入对 promise 对象的调用。如果我们不提供这些成员函数,编译器就不知道如何为我们生成代码。您可以将 promise 视为协程控制器对象,负责:

  • 产生从协程调用返回的值。这由函数get_return_object()处理。

  • 通过实现函数initial_suspend()final_supsend()定义协程创建时和销毁前的行为。在我们的Promise类型中,我们通过返回std::suspend_always(见下一节)来表示协程应在这些点挂起。

  • 自定义协程最终返回时的行为。如果协程使用带有类型T的值的表达式的co_return,则 promise 必须定义一个名为return_value(T)的成员函数。我们的协程不返回任何值,但 C++标准要求我们提供称为return_void()的定制点,我们在这里留空。

  • 处理在协程体内未处理的异常。在函数unhandled_exception()中,我们只是调用std::terminate(),但在后面的示例中我们将更优雅地处理它。

还有一些代码的最后部分需要更多的关注,即co_await表达式和可等待类型。

可等待类型

我们在代码中使用co_await添加了一个显式的挂起点,并传递了一个可等待类型std::suspend_always的实例。std::suspend_always的实现大致如下:

struct std::suspend_always {
  constexpr bool await_ready() const noexcept { return false; }
  constexpr void await_suspend(coroutine_handle<>) const noexcept {}
  constexpr void await_resume() const noexcept {}
}; 

std::suspend_always被称为微不足道的可等待类型,因为它总是使协程挂起,说它永远不会准备好。还有另一种微不足道的可等待类型,总是报告自己准备好的,称为std::suspend_never

struct std::suspend_never {
  constexpr bool await_ready() const noexcept { return true; }
  constexpr void await_suspend(coroutine_handle<>) const noexcept {}
  constexpr void await_resume() const noexcept {}
}; 

我们可以创建自己的可等待类型,这将在下一章中介绍,但现在我们可以使用这两种微不足道的标准类型。

这完成了示例。但是当我们有了PromiseResumable类型时,我们可以进行更多的实验。让我们看看在启动的协程中我们能做些什么。

传递我们的协程

一旦Resumable对象被创建,我们可以将它传递给其他函数,并从那里恢复它。我们甚至可以将协程传递给另一个线程。下面的示例展示了一些这种灵活性:

auto coroutine() -> Resumable {
  std::cout << "c1 ";
  co_await std::suspend_always{};
  std::cout << "c2 ";
}                                
auto coro_factory() {             // Create and return a coroutine
  auto res = coroutine();
  return res;
}
int main() {
  auto r = coro_factory();
  r.resume();                     // Resume from main
  auto t = std::jthread{[r = std::move(r)]() mutable {
    using namespace std::chrono_literals;
    std::this_thread::sleep_for(2s);
    r.resume();                   // Resume from thread
  }};
} 

前面的示例表明,一旦我们调用了我们的协程并获得了对它的句柄,我们就可以像任何其他可移动类型一样移动它。将它传递给其他线程的能力实际上在需要避免在特定线程上对协程状态进行可能的堆分配的情况下非常有用。

分配协程状态

协程状态,或协程帧,是协程在挂起时存储其状态的地方。协程状态的生命周期始于协程被调用时,并在协程执行co_return语句(或控制流离开协程体的末尾)时被销毁,除非它在此之前通过协程句柄被销毁。

协程状态通常在堆上分配。编译器会插入一个单独的堆分配。然而,在某些情况下,可以通过将协程状态内联到调用者的帧中(可以是普通的堆栈帧或另一个协程帧)来省略这个单独的堆分配。不幸的是,永远不能保证省略堆分配。

为了使编译器能够省略堆分配,协程状态的完整生存期必须严格嵌套在调用者的生存期内。此外,编译器需要找出协程状态的总大小,并且通常需要能够看到被调用协程的主体,以便其中的部分可以内联。像虚函数调用和调用其他翻译单元或共享库中的函数的情况通常会使这种情况变得不可能。如果编译器缺少所需的信息,它将插入堆分配。

协程状态的堆分配是使用operator new执行的。可以在 promise 类型上提供自定义的类级operator new,然后将其用于全局operator new。因此,可以检查堆分配是否被省略。如果没有,我们可以找出协程状态需要多少内存。以下是使用我们之前定义的Promise类型的示例:

struct Promise {
  /* Same as before ... */
  static void* operator new(std::size_t sz) {
    std::cout << "custom new for size " << sz << '\n';
    return ::operator new(sz);
  }
  static void operator delete(void* ptr) {
    std::cout << "custom delete called\n";
    ::operator delete(ptr);
  }
} 

另一个验证使用特定 promise 类型的所有协程完全省略了堆分配的技巧是声明operator newoperator delete,但不包括它们的定义。如果编译器插入了对这些操作符的调用,程序将由于未解析的符号而无法链接。

避免悬空引用

协程可以在我们的代码中传递,这意味着我们需要非常小心地处理传递给协程的参数的生存期,以避免悬空引用。协程帧包含通常存储在堆栈上的对象的副本,例如局部变量和传递给协程的参数。如果协程通过引用接受参数,则引用被复制,而不是对象。这意味着当遵循函数参数的通常指导方针时,即通过引用传递const对象,我们很容易遇到悬空引用。

向协程传递参数

以下协程使用了对const std::string的引用:

auto coroutine(const std::string& str) -> Resumable { 
  std::cout << str;
  co_return;
} 

假设我们有一个创建并返回协程的工厂函数,就像这样:

auto coro_factory() {
  auto str = std::string{"ABC"};
  auto res = coroutine(str);
  return res;
} 

最后,一个使用协程的main()函数:

int main() {
  auto coro = coro_factory();
  coro.resume();
} 

这段代码表现出未定义的行为,因为包含字符串"ABC"std::string对象在协程尝试访问它时已经不再存在。希望这对你来说不是什么意外。这个问题类似于让 lambda 通过引用捕获变量,然后将 lambda 传递给其他代码而不保持引用对象的生存。当传递捕获变量的 lambda 时也可以实现类似的例子:

auto lambda_factory() {
  auto str = std::string{"ABC"};
  auto lambda = [&str]() {         // Capture str by reference
    std::cout << str;     
  };
  return lambda;                   // Ops! str in lambda becomes
}                                  // a dangling reference
int main() {
  auto f = lambda_factory();
  f();                             // Undefined behavior
} 

正如你所看到的,使用 lambda 也可能出现相同的问题。在第二章基本的 C++技术中,我警告过你使用 lambda 捕获引用的问题,通常最好通过值捕获来避免这个问题。

避免悬空引用的解决方案与协程类似:在使用协程时避免通过引用传递参数。而是使用按值传递,整个参数对象将安全地放置在协程帧中:

auto coroutine(std::string str) -> Resumable {  // OK, by value!
  std::cout << str;
  co_return;
}
auto coro_factory() {
  auto str = std::string{"ABC"};
  auto res = coroutine(str);
  return res;
}
int main() {
  auto coro = coro_factory();
  coro.resume();                                 // OK!
} 

参数是使用协程时生存期问题的一个重要且常见的来源,但并不是唯一的来源。现在我们将探讨一些与协程和悬空引用相关的其他陷阱。

作为协程的成员函数

成员函数也可以是协程。例如,我们可以在成员函数中使用co_await,就像下面的例子一样:

struct Widget {
auto coroutine() -> Resumable {       // A member function 
    std::cout << i_++ << " ";         // Access data member
    co_await std::suspend_always{};
    std::cout << i_++ << " ";
  }
  int i_{};
};
int main() {
  auto w = Widget{99};
  auto coro = w.coroutine();
  coro.resume();
  coro.resume();
}
// Prints: 99 100 

重要的是要理解,调用者coroutine()(在这种情况下是main())有责任确保Widget对象w在整个协程的生命周期内保持存活。协程正在访问所属对象的数据成员,但Widget对象本身由协程保持存活。如果我们将协程传递给程序的其他部分,这很容易成为一个问题。

假设我们正在使用一些协程工厂函数,就像之前演示的那样,但是返回一个成员函数协程:

auto widget_coro_factory() {      // Create and return a coroutine
  auto w = Widget{};
  auto coro = w.coroutine();
  return coro; 
}                                 // Object w destructs here
int main() {
  auto r = widget_coro_factory();
  r.resume();                     // Undefined behavior 
  r.resume();                  
} 

这段代码表现出未定义的行为,因为我们现在有一个从协程到在widget_coro_factory()函数中创建和销毁的Widget对象的悬空引用。换句话说,我们最终得到了两个具有不同生命周期的对象,其中一个对象引用另一个对象,但没有明确的所有权。

作为协程的 lambda

不仅成员函数可以成为协程。还可以通过在 lambda 的主体中插入co_awaitco_return和/或co_yield来使用 lambda 表达式创建协程。

协程 lambda 可能会有一些额外的棘手问题。更好地理解协程 lambda 最常见的生命周期问题的一种方法是考虑函数对象。回想一下第二章Essential C++ Techniques,lambda 表达式被编译器转换为函数对象。这个对象的类型是一个实现了调用运算符的类。现在,假设我们在 lambda 的主体中使用co_return;这意味着调用运算符operator()()变成了一个协程。

考虑以下使用 lambda 的代码:

auto lambda = [](int i) -> Resumable {
  std::cout << i;
  co_return;              // Make it a coroutine
};
auto coro = lambda(42);   // Call, creates the coroutine frame
coro.resume();            // Outputs: 42 

lambda 对应的类型看起来像这样:

struct LambdaType {
  auto operator()(int i) -> Resumable {  // Member function
    std::cout << i;                      // Body
    co_return;
  }
};
auto lambda = LambdaType{};
auto coro = lambda(42);
coro.resume(); 

这里需要注意的重要事情是,实际的协程是一个成员函数,即调用运算符operator()()。前面的部分已经展示了拥有协程成员函数的陷阱:我们需要在协程的生命周期内保持对象的存活。在前面的例子中,这意味着我们需要在协程帧存活期间保持名为lambda的函数对象存活。

一些 lambda 的用法很容易在协程帧被销毁之前意外销毁函数对象。例如,通过使用立即调用 lambda,我们很容易陷入麻烦:

auto coro = [i = 0]() mutable -> Resumable { 
  std::cout << i++; 
  co_await std::suspend_always{};
  std::cout << i++;
}();               // Invoke lambda immediately
coro.resume();     // Undefined behavior! Function object
coro.resume();     // already destructed 

这段代码看起来无害;lambda 没有通过引用捕获任何东西。然而,lambda 表达式创建的函数对象是一个临时对象,一旦被调用并且协程捕获了对它的引用,它将被销毁。当协程恢复时,程序很可能会崩溃或产生垃圾。

再次,更好地理解这一点的方法是将 lambda 转换为具有定义的operator()的普通类:

struct LambdaType {
  int i{0};
  auto operator()() -> Resumable {
    std::cout << i++; 
    co_await std::suspend_always{};
    std::cout << i++;
  }
};
auto coro = LambdaType{}(); // Invoke operator() on temporary object
coro.resume();              // Ops! Undefined behavior 

现在你可以看到,这与我们有一个成员函数是协程的情况非常相似。函数对象不会被协程帧保持存活。

防止悬空引用的指导方针

除非你有接受引用参数的充分理由,如果你正在编写一个协程,选择通过值接受参数。协程帧将保持你传递给它的对象的完整副本,并且保证对象在协程帧存活期间存活。

如果你正在使用 lambda 或成员函数作为协程,特别注意协程所属对象的生命周期。记住对象(或函数对象)存储在协程帧中。调用协程的责任是保持其存活。

处理错误

有不同的方法将错误从协程传递回调用它或恢复它的代码部分。我们不必使用异常来标志错误。相反,我们可以根据需要自定义错误处理。

协程可以通过抛出异常或在客户端从协程获取值时返回错误代码,将错误传递回客户端。

如果我们使用异常并且异常从协程体中传播出来,那么承诺对象的函数unhandled_exception()就会被调用。这个调用发生在编译器插入的 catch 块内部,因此可以使用std::current_exception()来获取抛出的异常。然后可以将std::current_exception()的结果存储在协程中作为std::exception_ptr,并在以后重新抛出。在下一章中使用异步协程时,您将看到这方面的例子。

定制点

您已经看到了许多定制点,我认为一个有效的问题是:为什么有这么多定制点?

  • 通用性:定制点使得可以以各种方式使用协程。对于如何使用 C++协程,几乎没有什么假设。库编写者可以定制co_awaitco_yieldco_return的行为。

  • 效率:一些定制点是为了根据使用情况启用可能的优化。一个例子是await_ready(),如果值已经计算出来,它可以返回true以避免不必要的暂停。

还应该说的是,我们暴露于这些定制点,是因为 C++标准没有提供任何类型(除了std::coroutine_handle)来与协程通信。一旦它们就位,我们就可以重用这些类型,而不用太担心其中一些定制点。然而,了解定制点对于充分理解如何有效使用 C++协程是有价值的。

生成器

生成器是一种向其调用者产生值的协程类型。例如,在本章开头,我演示了生成器iota()产生递增的整数值。通过实现一个通用的生成器类型,它可以充当迭代器,我们可以简化实现与基于范围的for循环、标准库算法和范围兼容的迭代器的工作。一旦我们有了生成器模板类,我们就可以重用它。

到目前为止,在本书中,您大多数时候看到的是在访问容器元素和使用标准库算法时的迭代器。然而,迭代器不一定要与容器绑定。可以编写产生值的迭代器。

实现生成器

我们即将实现的生成器是基于 CppCoro 库中的生成器。生成器模板旨在用作协程的返回类型,用于生成一系列值。应该可以将此类型的对象与基于范围的for循环和接受迭代器和范围的标准算法一起使用。为了实现这一点,我们将实现三个组件:

  • Generator,这是返回对象

  • Promise,作为协程控制器

  • Iterator,是客户端和Promise之间的接口

这三种类型紧密耦合,它们与协程状态之间的关系在下图中呈现:

图 12.12:迭代器、生成器、Promise 和协程状态之间的关系

返回对象,这种情况下是Generator类,与Promise类型紧密耦合;Promise类型负责创建Generator对象,而Generator类型负责向编译器公开正确的promise_type。这是Generator的实现:

template <typename T>
class Generator {
  struct Promise { /* ... */ };   // See below
  struct Sentinel {};  
  struct Iterator { /* ... */ };  // See below

  std::coroutine_handle<Promise> h_;
  explicit Generator(std::coroutine_handle<Promise> h) : h_{h} {}
public: 
  using promise_type = Promise;
  Generator(Generator&& g) : h_(std::exchange(g.h_, {})) {}
  ~Generator() { if (h_) { h_.destroy();  } }
  auto begin() {
    h_.resume();
    return Iterator{h_};
  }
  auto end() { return Sentinel{}; }
}; 

PromiseIterator的实现将很快跟进。Generator与我们之前定义的Resumable类并没有太大的不同。Generator是协程的返回对象,也是std::coroutine_handle的所有者。生成器是可移动类型。在移动时,协程句柄被转移到新构造的Generator对象。当拥有协程句柄的生成器被销毁时,它通过在协程句柄上调用destroy来销毁协程状态。

begin()end()函数使得可以在基于范围的for循环和接受范围的算法中使用这个生成器。Sentinel类型是空的——它是一个虚拟类型——Sentinel实例是为了能够将某些东西传递给Iterator类的比较运算符。Iterator的实现如下:

struct Iterator {
  using iterator_category = std::input_iterator_tag;
  using value_type = T;
  using difference_type = ptrdiff_t;
  using pointer = T*;
  using reference = T&;

  std::coroutine_handle<Promise> h_;  // Data member

  Iterator& operator++() {
    h_.resume();
    return *this;
  }
  void operator++(int) { (void)operator++(); }
  T operator*() const { return h_.promise().value_; }
  T* operator->() const { return std::addressof(operator*()); }
  bool operator==(Sentinel) const { return h_.done(); }
}; 

迭代器需要在数据成员中存储协程句柄,以便它可以将调用委托给协程句柄和 promise 对象:

  • 当迭代器被解引用时,它返回由 promise 持有的当前值

  • 当迭代器递增时,它恢复协程

  • 当迭代器与哨兵值进行比较时,迭代器会忽略哨兵并将调用委托给协程句柄,协程句柄知道是否还有更多元素要生成

现在只剩下Promise类型需要我们实现。Promise的完整定义如下:

struct Promise {
  T value_;
  auto get_return_object() -> Generator {
    using Handle = std::coroutine_handle<Promise>;
    return Generator{Handle::from_promise(*this)};
  }
  auto initial_suspend() { return std::suspend_always{}; }
  auto final_suspend() noexcept { return std::suspend_always{}; }
  void return_void() {}
  void unhandled_exception() { throw; }
  auto yield_value(T&& value) {
    value_ = std::move(value);
    return std::suspend_always{};
  }
  auto yield_value(const T& value) {
    value_ = value;
    return std::suspend_always{};
  }
}; 

我们的生成器的 promise 对象负责:

  • 创建Generator对象

  • 定义初始和最终挂起点达到时的行为

  • 跟踪从协程中产生的最后一个值

  • 处理协程主体抛出的异常

就是这样!我们现在已经把所有的部分都放在了一起。一个返回某种Generator<T>类型的协程现在可以使用co_yield来懒惰地产生值。协程的调用者与GeneratorIterator对象交互以检索值。对象之间的交互如下所示:

图 12.13:调用者与生成器和迭代器对象通信,以从协程中检索值

现在,让我们看看如何使用新的Generator模板以及它如何简化各种迭代器的实现。

使用Generator

这个例子受到了 Gor Nishanov 在 CppCon 2016 上的讲座C++ Coroutines: Under the covers的启发(sched.co/7nKt)。它清楚地演示了我们如何从刚刚实现的生成器类型中受益。现在可以像这样实现小型可组合的生成器:

template <typename T>
auto seq() -> Generator<T> {
  for (T i = {};; ++i) {
    co_yield i;
  }
}
template <typename T>
auto take_until(Generator<T>& gen, T value) -> Generator<T> {
  for (auto&& v : gen) {
    if (v == value) {
      co_return;
    }
    co_yield v;
  }
}
template <typename T>
auto add(Generator<T>& gen, T adder) -> Generator<T> {
  for (auto&& v : gen) {
    co_yield v + adder;
  }
} 

一个小的使用示例演示了我们可以将生成器传递给基于范围的for循环:

int main() {
  auto s = seq<int>();
  auto t = take_until<int>(s, 10);
  auto a = add<int>(t, 3);
  int sum = 0;
  for (auto&& v : a) {
      sum += v;
  }
  return sum; // returns 75
} 

生成器是惰性评估的。直到程序达到for循环时,才会产生值,从生成器链中拉取值。

这个程序的另一个有趣之处是,当我使用启用优化的 Clang 10 编译它时,整个程序的汇编代码看起来像这样:

main:  # @main
mov  eax, 75
ret 

太棒了!程序简单地定义了一个返回值为75的主函数。换句话说,编译器优化器能够在编译时完全评估生成器链,并得出单个值75

我们的Generator类也可以与范围算法一起使用。在下面的示例中,我们使用算法includes()来查看序列{5,6,7}是否是生成器产生的数字的子序列:

int main() { 
  auto s = seq<int>();                           // Same as before
  auto t = take_until<int>(s, 10);
  auto a = add<int>(t, 3);
  const auto v = std::vector{5, 6, 7};
  auto is_subrange = std::ranges::includes(a, v); // True
} 

通过实现Generator模板,我们可以重用它来实现各种生成器函数。我们已经实现了一个通用且非常有用的库组件,应用代码可以在构建惰性生成器时从中受益。

解决生成器问题

现在我将提出一个小问题,我们将尝试使用不同的技术来解决它,以了解我们可以用生成器替换哪些编程习惯。我们即将编写一个小型实用程序,用于在起始值和停止值之间生成线性间隔序列。

如果您一直在使用 MATLAB/Octave 或 Python NumPy,您可能会认识到使用名为linspace()的函数生成均匀(线性)间隔数字的方式。这是一个方便的实用程序,可以在各种上下文中使用任意范围。

我们将称我们的生成器为lin_space()。以下是一个使用示例,在2.03.0之间生成五个等间距值:

for (auto v: lin_space(2.0f, 3.0f, 5)) {
  std::cout << v << ", ";
}
// Prints: 2.0, 2.25, 2.5, 2.75, 3.0, 

在生成浮点值时,我们必须要小心,因为我们不能简单地计算每个步骤的大小(在前面的示例中为 0.25)并累积它,因为步长可能无法使用浮点数据类型精确表示。可能的舍入误差将在每次迭代中累积,最终我们可能会得到完全荒谬的值。相反,我们需要做的是使用线性插值在特定增量上计算开始和停止值之间的数字。

C++20 在<cmath>中添加了一个方便的实用程序,称为std::lerp(),它计算两个值之间的线性插值,并指定一个特定的量。在我们的情况下,量将是 0.0 到 1.0 之间的值;量为 0 返回start值,量为 1.0 返回stop值。以下是使用std::lerp()的几个示例:

auto start = -1.0;
auto stop = 1.0;
std::lerp(start, stop, 0.0);    // -1.0
std::lerp(start, stop, 0.5);    //  0.0
std::lerp(start, stop, 1.0);    //  1.0 

我们即将编写的lin_space()函数将全部使用以下小型实用函数模板:

template <typename T>
auto lin_value(T start, T stop, size_t index, size_t n) {  
  assert(n > 1 && index < n);
  const auto amount = static_cast<T>(index) / (n - 1);
  const auto v = std::lerp(start, stop, amount);   // C++20
  return v;
} 

该函数返回范围[startstop]中线性序列中的一个值。index参数是我们即将生成的n个总数中的当前数字。

有了lin_value()辅助程序,我们现在可以轻松实现lin_space()生成器。在看到使用协程的解决方案之前,我们将研究其他常见技术。接下来的部分将探讨在实现lin_space()时使用的不同方法:

  • 急切地生成并返回所有值

  • 使用回调(惰性)

  • 使用自定义迭代器(惰性)

  • 使用 Ranges 库(惰性)

  • 使用我们的Generator类的协程(惰性)

对于每个示例,都将简要反映每种方法的优缺点。

一个急切的线性范围

我们将首先实现一个简单的急切版本,计算范围内的所有值并返回一个包含所有值的向量:

template <typename T>
auto lin_space(T start, T stop, size_t n) {
  auto v = std::vector<T>{};
  for (auto i = 0u; i < n; ++i)
    v.push_back(lin_value(start, stop, i, n));
  return v;
} 

由于这个版本返回一个标准容器,所以可以将返回值与基于范围的for循环和其他标准算法一起使用:

for (auto v : lin_space(2.0, 3.0, 5)) {
  std::cout << v << ", ";
}
// Prints: 2, 2.25, 2.5, 2.75, 3, 

这个版本很直接,而且相当容易阅读。缺点是我们需要分配一个向量并填充所有值,尽管调用者不一定对所有值感兴趣。这个版本也缺乏可组合性,因为没有办法在首先生成所有值之前过滤中间的元素。

现在让我们尝试实现lin_space()生成器的惰性版本。

使用回调的惰性版本

第十章 代理对象和惰性求值中,我们得出结论,可以通过使用回调函数来实现惰性求值。我们将要实现的惰性版本将基于将回调传递给lin_space()并在发出值时调用回调函数:

template <typename T, typename F>
requires std::invocable<F&, const T&>               // C++20 
void lin_space(T start, T stop, std::size_t n, F&& f) {
  for (auto i = 0u; i < n; ++i) {
    const auto y = lin_value(start, stop, i, n);
    f(y);
  }
} 

如果我们想打印生成器产生的值,可以这样调用该函数:

auto print = [](auto v) { std::cout << v << ", "; };
lin_space(-1.f, 1.f, 5, print);
// Prints: -1, -0.5, 0, 0.5, 1, 

现在迭代发生在lin_space()函数内部。无法取消生成器,但通过一些更改,我们可以让回调函数返回一个bool来指示是否希望生成更多元素。

这种方法有效,但不太优雅。这种设计的问题在尝试组合生成器时变得更加明显。如果我们想要添加一个选择一些特殊值的过滤器,我们最终会有嵌套的回调函数。

我们现在将继续看如何实现基于迭代器的解决方案。

迭代器实现

另一种选择是实现一个符合范围概念的类型,通过暴露begin()end()迭代器。在这里定义的类模板LinSpace使得可以迭代线性值的范围:

template <typename T>
struct LinSpace {
  LinSpace(T start, T stop, std::size_t n)
      : begin_{start, stop, 0, n}, end_{n} {}
  struct Iterator {
    using difference_type = void;
    using value_type = T;
    using reference = T;
    using pointer = T*;
    using iterator_category = std::forward_iterator_tag;
    void operator++() { ++i_; }
    T operator*() { return lin_value(start_, stop_, i_, n_);}
    bool operator==(std::size_t i) const { return i_ == i; } 
    T start_{};
    T stop_{};
    std::size_t i_{};
    std::size_t n_{};
  };
  auto begin() { return begin_; }
  auto end() { return end_; }
 private:
  Iterator begin_{};
  std::size_t end_{};
};
template <typename T>
auto lin_space(T start, T stop, std::size_t n) {
  return LinSpace{start, stop, n};
} 

这个实现非常高效。然而,它受到大量样板代码的困扰,我们试图封装的小算法现在分散在不同的部分:LinSpace构造函数实现了设置起始和停止值的初始工作,而计算值所需的工作最终在Iterator类的成员函数中完成。与我们看到的其他版本相比,这使得算法的实现更难理解。

使用 Ranges 库的解决方案

另一种选择是使用 Ranges 库(C++20)中的构建模块来组合我们的算法,如下所示:

template <typename T>
auto lin_space(T start, T stop, std::size_t n) {
  return std::views::iota(std::size_t{0}, n) |
    std::views::transform(= {
      return lin_value(start, stop, i, n);
    });
} 

在这里,我们将整个算法封装在一个小函数中。我们使用std::views::iota为我们生成索引。将索引转换为线性值是一个简单的转换,可以在iota视图之后链接。

这个版本高效且可组合。从lin_space()返回的对象是std::ranges::view类型的随机访问范围,可以使用基于范围的for循环进行迭代,或者传递给其他算法。

最后,是时候使用我们的Generator类来将我们的算法实现为一个协程。

使用协程的解决方案

在看了不少于四个版本的同一个问题之后,我们现在已经达到了最后的解决方案。在这里,我将呈现一个使用之前实现的通用Generator类模板的版本:

template <typename T> 
auto lin_space(T start, T stop, std::size_t n) -> Generator<T> {
   for (auto i = 0u; i < n; ++i) {
     co_yield lin_value(start, stop, i, n);
   }
 } 

它紧凑、简单明了。通过使用co_yield,我们可以以类似于简单的急切版本的方式编写代码,但不需要收集所有值到一个容器中。可以基于协程链式多个生成器,正如你将在本章末尾看到的那样。

这个版本也兼容基于范围的for循环和标准算法。然而,这个版本暴露了一个输入范围,所以不可能跳过任意数量的元素,而使用 Ranges 库的版本是可以的。

结论

显然,有多种方法可以做到这一点。但为什么我展示了所有这些方法呢?

首先,如果你是新手协程,希望你能开始看到在哪些情况下使用协程是有利的。

其次,Generator模板和使用co_yield允许我们以非常清晰简洁的方式实现惰性生成器。当我们将解决方案与其他版本进行比较时,这一点变得很明显。

最后,一些方法在这个例子问题中可能看起来很牵强,但在其他情境中经常被使用。C++默认是一种急切的语言,许多人(包括我自己)已经习惯于创建类似急切版本的代码。使用回调的版本可能看起来很奇怪,但在异步代码中是一个常用的模式,协程可以包装或替代那些基于回调的 API。

我们实现的生成器类型部分基于 CppCoro 库中的同步生成器模板。CppCoro 还提供了一个async_generator模板,它使得可以在生成器协程中使用co_await运算符。我在本章中提供了Generator模板,以演示如何实现生成器以及如何与协程交互。但是,如果您计划在代码中开始使用生成器,请考虑使用第三方库。

使用生成器的真实世界示例

当示例稍微复杂时,使用协程简化迭代器的示例效果非常好。使用Generator类的co_yield允许我们高效地实现和组合小算法,而无需编写大量模板代码来将它们粘合在一起。下一个示例将尝试证明这一点。

问题

我们将在这里通过一个示例来演示如何使用我们的Generator类来实现一个压缩算法,该算法可以用于搜索引擎中压缩通常存储在磁盘上的搜索索引。该示例在 Manning 等人的书籍《信息检索导论》中有详细描述,该书可以在nlp.stanford.edu/IR-book/免费获取。以下是简要背景和问题的简要描述。

搜索引擎使用称为倒排索引的数据结构的某种变体。它类似于书末的索引。使用该索引,我们可以找到包含我们正在搜索的术语的所有页面。

现在想象一下,我们有一个充满食谱的数据库,并且我们为该数据库构建了一个倒排索引。该索引的部分可能看起来像这样:

图 12.14:具有三个术语及其相应的文档引用列表的倒排索引

每个术语都与一个排序的文档标识符列表相关联。(例如,术语苹果包含在 ID 为496789的食谱中。)如果我们想要查找同时包含豆子辣椒的食谱,我们可以运行类似合并的算法来找到豆子辣椒列表的交集:

图 12.15:“豆子”和“辣椒”术语的文档列表的交集

现在想象一下,我们有一个大型数据库,并且我们选择用 32 位整数表示文档标识符。对于出现在许多文档中的术语,文档标识符列表可能会变得非常长,因此我们需要压缩这些列表。其中一种可能的方法是使用增量编码结合可变字节编码方案。

增量编码

由于列表是排序的,我们可以不保存文档标识符,而是存储两个相邻元素之间的间隔。这种技术称为增量编码间隔编码。以下图表显示了使用文档 ID 和间隔的示例:

图 12.16:间隔编码存储列表中两个相邻元素之间的间隔

间隔编码非常适合这种类型的数据;因此经常使用的术语将具有许多小间隔。真正长的列表将只包含非常小的间隔。在列表进行间隔编码之后,我们可以使用可变字节编码方案来实际压缩列表,通过使用较少的字节来表示较小的间隔。

但首先,让我们开始实现间隔编码功能。我们将首先编写两个小协程,用于执行间隔编码/解码。编码器将排序的整数序列转换为间隔序列:

template <typename Range>
auto gap_encode(Range& ids) -> Generator<int> {
  auto last_id = 0;
  for (auto id : ids) {
    const auto gap = id - last_id;
    last_id = id;
    co_yield gap;
  }
} 

通过使用co_yield,我们无需急切地传递完整的数字列表并分配一个大的输出间隔列表。相反,协程会懒惰地处理一个数字。请注意,函数gap_encode()包含了有关如何将文档 ID 转换为间隔的所有信息。将其实现为传统的迭代器是可能的,但这将使逻辑分散在构造函数和迭代器操作符中。

我们可以编写一个小程序来测试我们的间隔编码器:

int main() {
  auto ids = std::vector{10, 11, 12, 14};
  auto gaps = gap_encode();
  for (auto&& gap : gaps) {
    std::cout << gap << ", ";
  }
} // Prints: 10, 1, 1, 2, 

解码器则相反;它以间隔的范围作为输入,并将其转换为有序数字列表:

template <typename Range>
auto gap_decode(Range& gaps) -> Generator<int> {
  auto last_id = 0;
  for (auto gap : gaps) {
    const auto id = gap + last_id;
    co_yield id;
    last_id = id;
  }
} 

通过使用间隔编码,我们平均可以存储更小的数字。但由于我们仍然使用int值来存储小间隔,如果将这些间隔保存到磁盘上,我们并没有真正获得任何好处。不幸的是,我们不能只使用较小的固定大小数据类型,因为仍然有可能遇到需要完整 32 位int的非常大的间隔。我们希望的是以更少的位数存储小间隔,如下图所示:

图 12.17:小数字应该使用更少的字节

为了使这个列表在物理上更小,我们可以使用可变字节编码,这样小间隔可以用比大间隔更少的字节进行编码,如前图所示。

可变字节编码

可变字节编码是一种非常常见的压缩技术。UTF-8 和 MIDI 消息是一些使用这种技术的众所周知的编码。为了在编码时使用可变数量的字节,我们使用每个字节的 7 位作为实际有效载荷。每个字节的第一位表示续位。如果还有更多字节要读取,则设置为0,如果是编码数字的最后一个字节,则设置为1。编码方案在下图中有例示:

图 12.18:使用可变字节编码,只需要一个字节来存储十进制值 3,而需要两个字节来编码十进制值 1025

现在我们准备实现可变字节编码和解码方案。这比增量编码要复杂一些。编码器应该将一个数字转换为一个或多个字节的序列:

auto vb_encode_num(int n) -> Generator<std::uint8_t> {
  for (auto cont = std::uint8_t{0}; cont == 0;) {
    auto b = static_cast<std::uint8_t>(n % 128);
    n = n / 128;
    cont = (n == 0) ? 128 : 0;
    co_yield (b + cont);
  }
} 

续位,代码中称为cont,要么是 0,要么是 128,对应的位序列是 10000000。这个例子中的细节并不重要,但为了使编码更容易,字节是以相反的顺序生成的,这样最不重要的字节首先出现并不是问题,因为我们可以在解码过程中轻松处理这个问题。

有了数字编码器,就可以轻松地对一系列数字进行编码,并将它们转换为一系列字节:

template <typename Range>
auto vb_encode(Range& r) -> Generator<std::uint8_t> {
  for (auto n : r) {
    auto bytes = vb_encode_num(n);
    for (auto b : bytes) {
      co_yield b;
    }
  }
} 

解码器可能是最复杂的部分。但同样,它完全封装在一个单一函数中,并具有清晰的接口:

template <typename Range>
auto vb_decode(Range& bytes) -> Generator<int> {
  auto n = 0;
  auto weight = 1;
  for (auto b : bytes) {
    if (b < 128) {  // Check continuation bit
      n += b * weight;
      weight *= 128;
    } 
    else {
      // Process last byte and yield
      n += (b - 128) * weight;
      co_yield n;
      n = 0;       // Reset
      weight = 1;  // Reset
    }
  }
} 

如您所见,这段代码中几乎没有需要的样板代码。每个协程封装了所有状态,并清楚地描述了如何一次处理一个部分。

我们还需要将间隔编码器与可变字节编码器结合起来,以压缩我们的文档标识符排序列表:

template <typename Range>
auto compress(Range& ids) -> Generator<int> {
  auto gaps = gap_encode(ids);
  auto bytes = vb_encode(gaps);
  for (auto b : bytes) {
    co_yield b;
  }
} 

解压缩是vb_decode()后跟gap_decode()的简单链接:

template <typename Range>
auto decompress(Range& bytes) -> Generator<int> {
  auto gaps = vb_decode(bytes);
  auto ids = gap_decode(gaps);
  for (auto id : ids) {
    co_yield id;
  }
} 

由于Generator类公开了迭代器,我们甚至可以进一步使用 iostreams 将值流式传输到磁盘上。 (尽管更现实的方法是使用内存映射 I/O 以获得更好的性能。)以下是两个将压缩数据写入磁盘并从磁盘读取的小函数:

template <typename Range>
void write(const std::string& path, Range& bytes) {
  auto out = std::ofstream{path, std::ios::out | std::ofstream::binary};
  std::ranges::copy(bytes.begin(), bytes.end(),    
                    std::ostreambuf_iterator<char>(out));
}
auto read(std::string path) -> Generator<std::uint8_t> {
  auto in = std::ifstream {path, std::ios::in | std::ofstream::binary};
  auto it = std::istreambuf_iterator<char>{in};
  const auto end = std::istreambuf_iterator<char>{};
  for (; it != end; ++it) {
    co_yield *it;
  }
} 

一个小的测试程序将结束这个例子:

int main() {
  {
    auto documents = std::vector{367, 438, 439, 440};
    auto bytes = compress(documents);
    write("values.bin", bytes);
  }
  {
    auto bytes = read("values.bin");
    auto documents = decompress(bytes);
    for (auto doc : documents) {
      std::cout << doc << ", ";
    }
  }
}
// Prints: 367, 438, 439, 440, 

这个例子旨在表明我们可以将惰性程序分成小的封装协程。C++协程的低开销使它们适合构建高效的生成器。我们最初实现的Generator是一个完全可重用的类,可以帮助我们最小化这类示例中的样板代码量。

这结束了关于生成器的部分。我们现在将继续讨论在使用协程时的一些一般性能考虑。

性能

每次创建协程(首次调用时),都会分配一个协程帧来保存协程状态。帧可以在堆上分配,或者在某些情况下在堆栈上分配。但是,并没有完全避免堆分配的保证。如果您处于禁止堆分配的情况(例如,在实时环境中),协程可以在不同的线程中创建并立即挂起,然后传递给实际需要使用协程的程序部分。挂起和恢复保证不会分配任何内存,并且具有与普通函数调用相当的成本。

在撰写本书时,编译器对协程有实验性支持。小型实验显示了与性能相关的有希望的结果,表明协程对优化器友好。但是,我不会在本书中为您提供任何协程的基准测试。相反,我向您展示了无栈协程是如何评估的,以及如何可能以最小的开销实现协程。

生成器示例表明,协程可能对编译器非常友好。我们在该示例中编写的生成器链是在运行时完全评估的。实际上,这是 C++协程的一个非常好的特性。它们使我们能够编写对编译器和人类都易于理解的代码。C++协程通常会产生易于优化的干净代码。

在同一线程上执行的协程可以共享状态,而无需使用任何锁原语,因此可以避免同步多个线程所产生的性能开销。这将在下一章中进行演示。

摘要

在本章中,您已经了解了如何使用 C++协程来使用关键字co_yieldco_return构建生成器。为了更好地理解 C++无栈协程与有栈协程的区别,我们对两者进行了比较,并查看了 C++协程提供的定制点。这使您深刻了解了 C++协程的灵活性,以及它们如何实现效率。无栈协程与状态机密切相关。通过将传统实现的状态机重写为使用协程的代码,我们探索了这种关系,您看到编译器如何将我们的协程转换和优化为机器语言。

在下一章中,我们将继续讨论协程,重点放在异步编程上,并加深您对co_await关键字的理解。

第十三章:使用协程进行异步编程

在上一章中实现的生成器类帮助我们使用协程构建惰性求值序列。C++协程也可以用于异步编程,通过让协程表示异步计算或异步任务。尽管异步编程是 C++中协程的最重要驱动因素,但标准库中没有基于协程的异步任务支持。如果你想使用协程进行异步编程,我建议你找到并使用一个补充 C++20 协程的库。我已经推荐了 CppCoro(github.com/lewissbaker/cppcoro),在撰写本文时似乎是最有前途的替代方案。还可以使用成熟的 Boost.Asio 库来使用异步协程,稍后在本章中将会看到。

本章将展示使用协程进行异步编程是可能的,并且有可用的库来补充 C++20 协程。更具体地,我们将重点关注:

  • co_await关键字和可等待类型

  • 实现了一个基本任务类型——一种可以从执行一些异步工作的协程中返回的类型

  • 使用协程来举例说明 Boost.Asio 中的异步编程

在继续之前,还应该说一下,本章没有涉及与性能相关的主题,也没有提出很多指导方针和最佳实践。相反,本章更多地作为 C++中异步协程的新特性的介绍。我们将通过探索可等待类型和co_await语句来开始这个介绍。

重新审视可等待类型

我们在上一章已经谈到了一些关于可等待类型的内容。但现在我们需要更具体地了解co_await的作用以及可等待类型是什么。关键字co_await是一个一元运算符,意味着它接受一个参数。我们传递给co_await的参数需要满足本节中将要探讨的一些要求。

当我们在代码中使用co_await时,我们表达了我们正在等待一些可能或可能不准备好的东西。如果它还没有准备好,co_await会暂停当前执行的协程,并将控制返回给它的调用者。当异步任务完成时,它应该将控制权转回最初等待任务完成的协程。从现在开始,我通常会将等待函数称为续体

现在考虑以下表达式:

co_await X{}; 

为了使这段代码编译通过,X需要是一个可等待类型。到目前为止,我们只使用了一些简单的可等待类型:std::suspend_alwaysstd::suspend_never。任何直接实现了接下来列出的三个成员函数,或者另外定义了operator co_wait()以产生一个具有这些成员函数的对象的类型,都是可等待类型:

  • await_ready()返回一个bool,指示结果是否已准备就绪(true),或者是否需要暂停当前协程并等待结果变得就绪。

  • await_suspend(coroutine_handle) - 如果await_ready()返回false,将调用此函数,传递一个执行co_await的协程的句柄。这个函数给了我们一个机会来开始异步工作,并订阅一个通知,当任务完成后触发通知,然后恢复协程。

  • await_resume()是负责将结果(或错误)解包回协程的函数。如果在await_suspend()启动的工作中发生了错误,这个函数可以重新抛出捕获的错误,或者返回一个错误代码。整个co_await表达式的结果是await_resume()返回的内容。

operator co_await for a time interval:
using namespace std::chrono;
template <class Rep, class Period> 
auto operator co_await(duration<Rep, Period> d) { 
  struct Awaitable {     
    system_clock::duration d_;
    Awaitable(system_clock::duration d) : d_(d) {} 
    bool await_ready() const { return d_.count() <= 0; }
    void await_suspend(std::coroutine_handle<> h) { /* ... */ } 
    void await_resume() {}
  }; 
  return Awaitable{d};
} 

有了这个重载,我们现在可以将一个时间间隔传递给co_await运算符,如下所示:

std::cout << "just about to go to sleep...\n";
co_await 10ms;                   // Calls operator co_await()
std::cout << "resumed\n"; 

示例并不完整,但是给出了如何使用一元运算符co_await的提示。正如您可能已经注意到的那样,三个await_*()函数不是直接由我们调用的;相反,它们是由编译器插入的代码调用的。另一个示例将澄清编译器所做的转换。假设编译器在我们的代码中遇到以下语句:

auto result = co_await expr; 

然后编译器将(非常)粗略地将代码转换为以下内容:

// Pseudo code
auto&& a = expr;         // Evaluate expr, a is the awaitable
if (!a.await_ready()) {  // Not ready, wait for result
  a.await_suspend(h);    // Handle to current coroutine
                         // Suspend/resume happens here
}
auto result = a.await_resume(); 

首先调用await_ready()函数来检查是否需要挂起。如果需要,将使用一个句柄调用await_suspend(),该句柄将挂起协程(具有co_await语句的协程)。最后,请求等待结果并将其分配给result变量。

隐式挂起点

正如您在众多示例中所看到的,协程通过使用co_awaitco_yield来定义显式挂起点。每个协程还有两个隐式挂起点:

  • 初始挂起点,在协程体执行之前协程的初始调用时发生

  • 最终挂起点,在协程体执行后和协程被销毁前发生

承诺类型通过实现initial_suspend()final_suspend()来定义这两个点的行为。这两个函数都返回可等待的对象。通常,我们从initial_suspend()函数中传递std::suspend_always,以便协程是懒惰启动而不是急切启动。

最终的挂起点对于异步任务非常重要,因为它使我们能够调整co_await的行为。通常,已经co_await:的协程应在最终挂起点恢复等待的协程。

接下来,让我们更好地了解三个可等待函数的用法以及它们如何与co_await运算符配合。

实现一个基本的任务类型

我们即将实现的任务类型是可以从代表异步任务的协程中返回的类型。任务是调用者可以使用co_await等待的东西。目标是能够编写看起来像这样的异步应用程序代码:

auto image = co_await load("image.jpg");
auto thumbnail = co_await resize(image, 100, 100);
co_await save(thumbnail, "thumbnail.jpg"); 

标准库已经提供了一种类型,允许函数返回一个调用者可以用于等待计算结果的对象,即std::future。我们可以将std::future封装成符合可等待接口的东西。但是,std::future不支持连续性,这意味着每当我们尝试从std::future获取值时,我们都会阻塞当前线程。换句话说,在使用std::future时,没有办法组合异步操作而不阻塞。

另一种选择是使用std::experimental::future或 Boost 库中的 future 类型,它支持连续性。但是这些 future 类型会分配堆内存,并包含不需要的同步原语。相反,我们将创建一个新类型,具有最小的开销和以下职责:

  • 将返回值和异常转发给调用者

  • 恢复等待结果的调用者

已经提出了协程任务类型(请参阅www7.open-std.org/JTC1/SC22/WG21/docs/papers/2018/p1056r0.html的 P1056R0),该提案为我们提供了关于我们需要的组件的良好提示。接下来的实现基于 Gor Nishanov 提出的工作和 Lewis Baker 分享的源代码,该源代码可在 CppCoro 库中找到。

这是用于表示异步任务的类模板的实现:

template <typename T>
class [[nodiscard]] Task {
  struct Promise { /* ... */ };          // See below
  std::coroutine_handle<Promise> h_;
  explicit Task(Promise & p) noexcept
      : h_{std::coroutine_handle<Promise>::from_promise(p)} {}
 public:
  using promise_type = Promise;
  Task(Task&& t) noexcept : h_{std::exchange(t.h_, {})} {}
  ~Task() { if (h_) h_.destroy(); }
  // Awaitable interface
  bool await_ready() { return false; }
  auto await_suspend(std::coroutine_handle<> c) {
    h_.promise().continuation_ = c;
    return h_;
  }
  auto await_resume() -> T {
    auto& result = h_.promise().result_;
    if (result.index() == 1) {
      return std::get<1>(std::move(result));
    } else {
      std::rethrow_exception(std::get<2>(std::move(result)));
    }
  }
}; 

接下来将在后续部分解释每个部分,但首先我们需要实现一个 promise 类型,该类型使用std::variant来保存值或错误。promise 还保持对使用continuation_数据成员等待任务完成的协程的引用:

struct Promise {
  std::variant<std::monostate, T, std::exception_ptr> result_;
  std::coroutine_handle<> continuation_;  // A waiting coroutine
  auto get_return_object() noexcept { return Task{*this}; }
  void return_value(T value) { 
    result_.template emplace<1>(std::move(value)); 
  }
  void unhandled_exception() noexcept {
    result_.template emplace<2>(std::current_exception());
  }
  auto initial_suspend() { return std::suspend_always{}; }
  auto final_suspend() noexcept {
    struct Awaitable {
      bool await_ready() noexcept { return false; }
      auto await_suspend(std::coroutine_handle<Promise> h) noexcept {
        return h.promise().continuation_;
      }
      void await_resume() noexcept {}
    };
    return Awaitable{};
  }
}; 

重要的是要区分我们正在使用的两个协程句柄:标识当前协程的句柄和标识继续执行的句柄。

请注意,由于std::variant的限制,此实现不支持Task<void>,并且我们不能在同一个 promise 类型上同时具有return_value()return_void()的限制。不支持Task<void>是不幸的,因为并非所有异步任务都必然返回值。我们将通过为Task<void>提供模板特化来克服这个限制。

由于我们在上一章中实现了一些协程返回类型(ResumableGenerator),您已经熟悉了可以从协程返回的类型的要求。在这里,我们将专注于对您新的事物,例如异常处理和恢复当前等待我们的调用者的能力。让我们开始看一下TaskPromise如何处理返回值和异常。

处理返回值和异常

异步任务可以通过返回(一个值或void)或抛出异常来完成。值和错误需要交给调用者,调用者一直在等待任务完成。通常情况下,这是 promise 对象的责任。

Promise类使用std::variant来存储三种可能结果的结果:

  • 根本没有值(std::monostate)。我们在我们的 variant 中使用这个来使其默认可构造,但不需要其他两种类型是默认可构造的。

  • 类型为T的返回值,其中TTask的模板参数。

  • std::exception_ptr,它是对先前抛出的异常的句柄。

异常是通过在Promise::unhandled_exception()函数内部使用std::current_exception()函数来捕获的。通过存储std::exception_ptr,我们可以在另一个上下文中重新抛出此异常。当异常在线程之间传递时,也是使用的机制。

使用co_return value;的协程必须具有实现return_value()的 promise 类型。然而,使用co_return;或在没有返回值的情况下运行的协程必须具有实现return_void()的 promise 类型。实现同时包含return_void()return_value()的 promise 类型会生成编译错误。

恢复等待的协程

当异步任务完成时,它应该将控制权转移到等待任务完成的协程。为了能够恢复这个继续执行,Task对象需要coroutine_handle到继续执行的协程。这个句柄被传递给Task对象的await_suspend()函数,并且我们方便地确保将该句柄保存到 promise 对象中:

class Task {
  // ...
  auto await_suspend(std::coroutine_handle<> c) {
    h_.promise().continuation_ = c;      // Save handle
    return h_;
  }
  // ... 

final_suspend()函数负责在此协程的最终挂起点挂起,并将执行转移到等待的协程。这是Promise的相关部分,供您参考:

auto Promise::final_suspend() noexcept {
  struct Awaitable {
    bool await_ready() noexcept { return false; } // Suspend
    auto await_suspend(std::coroutine_handle<Promise> h) noexcept{
      return h.promise().continuation_;  // Transfer control to
    }                                    // the waiting coroutine
    void await_resume() noexcept {}
  };
  return Awaitable{};
} 

首先,从await_ready()返回false将使协程在最终挂起点挂起。我们这样做的原因是为了保持 promise 仍然存活,并且可以让继续执行有机会从 promise 中取出结果。

接下来,让我们看一下await_suspend()函数。这是我们想要恢复执行的地方。我们可以直接在continuation_句柄上调用resume(),并等待它完成,就像这样:

// ...
auto await_suspend(std::coroutine_handle<Promise> h) noexcept {
  h.promise().resume();         // Not recommended
}
// ... 

然而,这样做会有在堆栈上创建一长串嵌套调用帧的风险,最终可能导致堆栈溢出。让我们看看通过一个简短的例子使用两个协程a()b()会发生什么:

auto a() -> Task<int> {  co_return 42; } 
auto b() -> Task<int> {         // The continuation
  auto sum = 0;
  for (auto i = 0; i < 1'000'000; ++i) {
    sum += co_await a();
  }
  co_return sum;
} 

如果与协程a()关联的Promise对象直接在协程b()的句柄上调用resume(),则在a()的调用帧之上会在堆栈上创建一个新的调用帧来恢复b()。这个过程会在循环中一遍又一遍地重复,为每次迭代在堆栈上创建新的嵌套调用帧。当两个函数互相调用时,这种调用顺序是一种递归形式,有时被称为相互递归:

图 13.1:协程 b()调用协程 a(),协程 a()恢复 b(),协程 b()调用 a(),协程 a()恢复 b(),依此类推

尽管为b()创建了一个协程帧,但每次调用resume()来恢复协程b()都会在堆栈上创建一个新的帧。避免这个问题的解决方案称为对称传输。任务对象不是直接从即将完成的协程中恢复继续,而是从await_suspend()中返回标识继续的coroutine_handle

// ...
auto await_suspend(std::coroutine_handle<Promise> h) noexcept {
  return h.promise().continuation_;     // Symmetric transfer
}
// ... 

然后编译器保证会发生一种叫做尾递归优化的优化。在我们的情况下,这意味着编译器将能够直接将控制转移到继续,而不会创建新的嵌套调用帧。

我们不会再花更多时间讨论对称传输和尾调用的细节,但可以在 Lewis Baker 的文章C++ Coroutines: Understanding Symmetric Transfer中找到关于这些主题的出色且更深入的解释,网址为lewissbaker.github.io/2020/05/11/understanding_symmetric_transfer

如前所述,我们的Task模板有一个限制,即不能处理void类型的模板参数。现在是时候修复这个问题了。

支持 void 任务

为了克服之前提到的关于无法处理不产生任何值的任务的限制,我们需要为Task<void>进行模板特化。这里为了完整起见进行了详细说明,但除了之前定义的一般Task模板之外,并没有添加太多新的见解:

template <>
class [[nodiscard]] Task<void> {

  struct Promise {
    std::exception_ptr e_;   // No std::variant, only exception
    std::coroutine_handle<> continuation_; 
    auto get_return_object() noexcept { return Task{*this}; }
    void return_void() {}   // Instead of return_value() 
    void unhandled_exception() noexcept { 
      e_ = std::current_exception(); 
    }
    auto initial_suspend() { return std::suspend_always{}; }
    auto final_suspend() noexcept {
      struct Awaitable {
        bool await_ready() noexcept { return false; }
        auto await_suspend(std::coroutine_handle<Promise> h) noexcept {
          return h.promise().continuation_;
        }
        void await_resume() noexcept {}
      };
      return Awaitable{};
    }
  };
  std::coroutine_handle<Promise> h_;
  explicit Task(Promise& p) noexcept 
      : h_{std::coroutine_handle<Promise>::from_promise(p)} {}
public:
  using promise_type = Promise;

  Task(Task&& t) noexcept : h_{std::exchange(t.h_, {})} {}
  ~Task() { if (h_) h_.destroy(); }
  // Awaitable interface
  bool await_ready() { return false; }
  auto await_suspend(std::coroutine_handle<> c) {
    h_.promise().continuation_ = c;
    return h_;
  }
  void await_resume() {
    if (h_.promise().e_)
      std::rethrow_exception(h_.promise().e_);
  }
}; 

这个模板特化中的 promise 类型只保留对潜在未处理异常的引用。而不是定义return_value(),promise 包含成员函数return_void()

我们现在可以表示返回值或void的任务。但在我们实际构建一个独立程序来测试我们的Task类型之前,还有一些工作要做。

同步等待任务完成

Task类型的一个重要方面是,无论是什么调用了返回Task的协程,都必须对其进行co_await,因此也是一个协程。这创建了一系列协程(继续)。例如,假设我们有这样一个协程:

Task<void> async_func() {      // A coroutine
  co_await some_func();
} 

然后,就不可能以以下方式使用它:

void f() {                          
  co_await async_func(); // Error: A coroutine can't return void
} 

一旦我们调用返回Task的异步函数,我们需要对其进行co_await,否则什么都不会发生。这也是我们声明Tasknodiscard的原因:这样如果忽略返回值,它会生成编译警告,就像这样:

void g() {        
  async_func();          // Warning: Does nothing
} 

协程的强制链接具有一个有趣的效果,即我们最终到达程序的main()函数,而 C++标准规定不允许它是一个协程。这需要以某种方式解决,提出的解决方案是提供至少一个函数来同步等待异步链完成。例如,CppCoro 库包括函数sync_wait(),它具有打破协程链的效果,使得普通函数可以使用协程成为可能。

不幸的是,实现 sync_wait() 相当复杂,但为了至少使得编译和测试我们的 Task 类成为可能,我将在这里提供一个基于标准 C++ 提案 P1171R0 的简化版本,wg21.link/P1171R0。我们的目标是能够编写如下的测试程序:

auto some_async_func() -> Task<int> { /* ... */ }
int main() { 
  auto result = sync_wait(some_async_func());
  return result;
} 

为了测试和运行异步任务,让我们继续实现 sync_wait()

实现 sync_wait()

sync_wait() 在内部使用了一个专门为我们的目的设计的自定义任务类,称为 SyncWaitTask。它的定义将在稍后揭示,但首先让我们看一下函数模板 sync_wait() 的定义:

template<typename T>
using Result = decltype(std::declval<T&>().await_resume());
template <typename T>
Result<T> sync_wait(T&& task) {
  if constexpr (std::is_void_v<Result<T>>) {
    struct Empty {};
    auto coro = [&]() -> detail::SyncWaitTask<Empty> {
      co_await std::forward<T>(task);
      co_yield Empty{};
      assert(false);
    };
    coro().get();
  } else {
    auto coro = [&]() -> detail::SyncWaitTask<Result<T>> {
      co_yield co_await std::forward<T>(task);
      // This coroutine will be destroyed before it
      // has a chance to return.
      assert(false);
    };
    return coro().get();
  }
} 

首先,为了指定任务返回的类型,我们使用了 decltypedeclval 的组合。这种相当繁琐的 using 表达式给出了由传递给 sync_wait() 的任务的类型 T::await_resume() 返回的类型。

sync_wait() 中,我们区分返回值和返回 void 的任务。我们在这里做出区分,以避免需要实现 SyncWaitTask 的模板特化来处理 void 和非 void 类型。通过引入一个空的 struct,可以将这两种情况类似地处理,该结构可以作为模板参数提供给 SyncWaitTask,用于处理 void 任务。

在实际返回值的情况下,使用 lambda 表达式来定义一个协程,该协程将在结果上进行 co_await,然后最终产生其值。重要的是要注意,协程可能会从 co_await 在另一个线程上恢复,这要求我们在 SyncWaitTask 的实现中使用同步原语。

在协程 lambda 上调用 get() 会恢复协程,直到它产生一个值。SyncWaitTask 的实现保证协程 lambda 在 co_yield 语句之后永远不会有机会再次恢复。

在前一章中我们广泛使用了 co_yield,但没有提及它与 co_await 的关系;即以下 co_yield 表达式:

 co_yield some_value; 

被编译器转换为:

co_await promise.yield_value(some_value); 

promise 是与当前执行的协程关联的 promise 对象。当尝试理解 sync_wait()SyncWaitTask 类之间的控制流时,了解这一点是有帮助的。

实现 SyncWaitTask

现在我们准备检查 SyncWaitTask,这是一种类型,只用作 sync_wait() 的辅助。因此,我们将其添加到名为 detail 的命名空间下,以明确表示这个类是一个实现细节:

namespace detail { // Implementation detail
template <typename T>
class SyncWaitTask {  // A helper class only used by sync_wait()
  struct Promise { /* ... */ }; // See below
  std::coroutine_handle<Promise> h_;
  explicit SyncWaitTask(Promise& p) noexcept
      : h_{std::coroutine_handle<Promise>::from_promise(p)} {}
 public:
  using promise_type = Promise;

  SyncWaitTask(SyncWaitTask&& t) noexcept 
      : h_{std::exchange(t.h_, {})} {}
  ~SyncWaitTask() { if (h_) h_.destroy();}
  // Called from sync_wait(). Will block and retrieve the
  // value or error from the task passed to sync_wait()
  T&& get() {
    auto& p = h_.promise();
    h_.resume();
    p.semaphore_.acquire();               // Block until signal
    if (p.error_)
      std::rethrow_exception(p.error_);
    return static_cast<T&&>(*p.value_);
  }
  // No awaitable interface, this class will not be co_await:ed
};
} // namespace detail 

最值得注意的部分是函数 get() 及其对 promise 对象拥有的信号量的 acquire() 的阻塞调用。这是使得这种任务类型同步等待结果准备好的关键。拥有二进制信号量的 promise 类型如下:

struct Promise {
  T* value_{nullptr};
  std::exception_ptr error_;
  std::binary_semaphore semaphore_;
  SyncWaitTask get_return_object() noexcept { 
    return SyncWaitTask{*this}; 
  }
  void unhandled_exception() noexcept { 
    error_ = std::current_exception(); 
  }
  auto yield_value(T&& x) noexcept {     // Result has arrived
    value_ = std::addressof(x);
    return final_suspend();
  }
  auto initial_suspend() noexcept { 
    return std::suspend_always{}; 
  }
  auto final_suspend() noexcept { 
  struct Awaitable {
      bool await_ready() noexcept { return false; }
      void await_suspend(std::coroutine_handle<Promise> h) noexcept {
        h.promise().semaphore_.release();          // Signal! 
      }
      void await_resume() noexcept {}
    };
    return Awaitable{};
  }
  void return_void() noexcept { assert(false); }
}; 

这里有很多我们已经讨论过的样板代码。但要特别注意 yield_value()final_suspend(),这是这个类的有趣部分。回想一下,在 sync_wait() 中,协程 lambda 产生了返回值,如下所示:

// ...
auto coro = [&]() -> detail::SyncWaitTask<Result<T>> {
  co_yield co_await std::forward<T>(task);  
  // ... 

因此,一旦值被产出,我们就会进入 promise 对象的 yield_value()。而 yield_value() 可以返回一个可等待类型的事实,使我们有机会定制 co_yield 关键字的行为。在这种情况下,yield_value() 返回一个可等待对象,该对象将通过二进制信号量发出信号,表明原始 Task 对象已经产生了一个值。

await_suspend() 中发出信号。我们不能比这更早发出信号,因为等待信号的代码的另一端最终会销毁协程。销毁协程只能在协程处于挂起状态时发生。

SyncWaitTask::get()中对semaphore_.acquire()的阻塞调用将在信号上返回,最终计算值将被传递给调用sync_wait()的客户端。

使用 sync_wait()测试异步任务

最后,可以构建一个使用Tasksync_wait()的小型异步测试程序,如下所示:

auto height() -> Task<int> { co_return 20; }     // Dummy coroutines
auto width() -> Task<int> { co_return 30; }
auto area() -> Task<int> { 
  co_return co_await height() * co_await width(); 
}

int main() {
  auto a = area();
  int value = sync_wait(a);
  std::cout << value;          // Outputs: 600
} 

我们已经实现了使用 C++协程的最低限度基础设施。然而,为了有效地使用协程进行异步编程,还需要更多的基础设施。这与生成器(在上一章中介绍)有很大的不同,生成器在我们真正受益之前需要进行相当少量的准备工作。为了更接近现实世界,我们将在接下来的章节中探索一些使用 Boost.Asio 的示例。我们将首先尝试将基于回调的 API 包装在与 C++协程兼容的 API 中。

包装基于回调的 API

有许多基于回调的异步 API。通常,异步函数接受调用者提供的回调函数。异步函数立即返回,然后最终在异步函数计算出一个值或完成等待某事时调用回调(完成处理程序)。

为了向您展示异步基于回调的 API 是什么样子,我们将一窥名为Boost.Asio的异步 I/O 的 Boost 库。关于 Boost.Asio 有很多内容需要学习,这里不会涉及到太多;我只会描述与 C++协程直接相关的 Boost 代码的绝对最低限度。

为了使代码适应本书的页面,示例假设每当我们使用 Boost.Asio 的代码时,已经定义了以下命名空间别名:

namespace asio = boost::asio; 

这是使用 Boost.Asio 延迟函数调用但不阻塞当前线程的完整示例。这个异步示例在单个线程中运行:

#include <boost/asio.hpp>
#include <chrono>
#include <iostream>
using namespace std::chrono;
namespace asio = boost::asio;
int main() {
  auto ctx = asio::io_context{};
  auto timer = asio::system_timer{ctx};
  timer.expires_from_now(1000ms);
  timer.async_wait([](auto error) {       // Callback
    // Ignore errors..                          
    std::cout << "Hello from delayed callback\n"; 
  });
  std::cout << "Hello from main\n";
  ctx.run();
} 

编译和运行此程序将生成以下输出:

Hello from main
Hello from delayed callback 

在使用 Boost.Asio 时,我们总是需要创建一个运行事件处理循环的io_context对象。对async_wait()的调用是异步的;它立即返回到main()并在计时器到期时调用回调(lambda)。

计时器示例不使用协程,而是使用回调 API 来提供异步性。Boost.Asio 也与 C++20 协程兼容,我稍后会进行演示。但在探索可等待类型的过程中,我们将绕道而行,而是假设我们需要在 Boost.Asio 的基于回调的 API 之上提供一个基于协程的 API,该 API 返回可等待类型。这样,我们可以使用co_await表达式来调用并等待(但不阻塞当前线程)异步任务完成。我们希望能够写出类似这样的代码,而不是使用回调:

std::cout << "Hello! ";
co_await async_sleep(ctx, 100ms);
std::cout << "Delayed output\n"; 

让我们看看如何实现函数async_sleep(),以便可以与co_await一起使用。我们将遵循的模式是让async_sleep()返回一个可等待对象,该对象将实现三个必需的函数:await_ready()await_suspend()await_resume()。代码解释将在此后跟随:

template <typename R, typename P>
auto async_sleep(asio::io_context& ctx,
                 std::chrono::duration<R, P> d) {
  struct Awaitable {
    asio::system_timer t_;
    std::chrono::duration<R, P> d_;
    boost::system::error_code ec_{};
    bool await_ready() { return d_.count() <= 0; }
    void await_suspend(std::coroutine_handle<> h) {
      t_.expires_from_now(d_);
      t_.async_wait(this, h mutable {
        this->ec_ = ec;
        h.resume();
      });
    } 
    void await_resume() {
      if (ec_) throw boost::system::system_error(ec_);
    }
  };
  return Awaitable{asio::system_timer{ctx}, d};
} 

再次,我们正在创建一个自定义的可等待类型,它完成了所有必要的工作:

  • 除非计时器已经达到零,否则await_ready()将返回false

  • await_suspend()启动异步操作并传递一个回调,当计时器到期或产生错误时将调用该回调。回调保存错误代码(如果有)并恢复挂起的协程。

  • await_resume()没有结果需要解包,因为我们正在包装的异步函数boost::asio::timer::async_wait()除了可选的错误代码外不返回任何值。

在我们实际测试async_sleep()的独立程序之前,我们需要一种方法来启动io_context运行循环并打破协程链,就像我们之前测试Task类型时所做的那样。我们将通过实现两个函数run_task()run_task_impl()以及一个称为Detached的天真协程返回类型来以一种相当巧妙的方式来做到这一点,该类型忽略错误处理并可以被调用者丢弃:

// This code is here just to get our example up and running
struct Detached { 
  struct promise_type {
    auto get_return_object() { return Detached{}; }
    auto initial_suspend() { return std::suspend_never{}; }
    auto final_suspend() noexcept { return std::suspend_never{};}
    void unhandled_exception() { std::terminate(); } // Ignore
    void return_void() {}
  };
};
Detached run_task_impl(asio::io_context& ctx, Task<void>&& t) {
  auto wg = asio::executor_work_guard{ctx.get_executor()};
  co_await t;
}
void run_task(asio::io_context& ctx, Task<void>&& t) {
  run_task_impl(ctx, std::move(t));
  ctx.run();
} 

Detached类型使协程立即启动并从调用者分离运行。executor_work_guard防止run()调用在协程run_task_impl()完成之前返回。

通常应避免启动操作并分离它们。这类似于分离的线程或分配的没有任何引用的内存。然而,此示例的目的是演示我们可以使用可等待类型以及如何编写异步程序并在单线程中运行。

一切就绪;名为async_sleep()的包装器返回一个Task和一个名为run_task()的函数,该函数可用于执行任务。是时候编写一个小的协程来测试我们实现的新代码了:

auto test_sleep(asio::io_context& ctx) -> Task<void> {
  std::cout << "Hello!  ";
  co_await async_sleep(ctx, 100ms);
  std::cout << "Delayed output\n";
}
int main() {
  auto ctx = asio::io_context{};
  auto task = test_sleep(ctx);
  run_task(ctx, std::move(task));  
}; 

执行此程序将生成以下输出:

Hello! Delayed output 

您已经看到了如何将基于回调的 API 包装在可以被co_await使用的函数中,因此允许我们使用协程而不是回调进行异步编程。该程序还提供了可等待类型中的函数如何使用的典型示例。然而,正如前面提到的,最近的 Boost 版本,从 1.70 开始,已经提供了与 C++20 协程兼容的接口。在下一节中,我们将在构建一个小型 TCP 服务器时使用这个新的协程 API。

使用 Boost.Asio 的并发服务器

本节将演示如何编写并发程序,该程序具有多个执行线程,但仅使用单个操作系统线程。我们将要实现一个基本的并发单线程 TCP 服务器,可以处理多个客户端。C++标准库中没有网络功能,但幸运的是,Boost.Asio 为我们提供了一个平台无关的接口,用于处理套接字通信。

我将演示如何使用boost::asio::awaitable类,而不是包装基于回调的 Boost.Asio API,以展示使用协程进行异步应用程序编程的更真实的示例。类模板boost::asio::awaitable对应于我们之前创建的Task模板;它用作表示异步计算的协程的返回类型。

实施服务器

服务器非常简单;一旦客户端连接,它就开始更新一个数字计数器,并在更新时写回该值。这次我们将从上到下跟踪代码,从main()函数开始:

#include <boost/asio.hpp>
#include <boost/asio/awaitable.hpp>
#include <boost/asio/use_awaitable.hpp>
using namespace std::chrono;
namespace asio = boost::asio;
using boost::asio::ip::tcp;
int main() {
  auto server = [] {
    auto endpoint = tcp::endpoint{tcp::v4(), 37259};
    auto awaitable = listen(endpoint);
    return awaitable;
  };
  auto ctx = asio::io_context{};
  asio::co_spawn(ctx, server, asio::detached);
  ctx.run(); // Run event loop from main thread
} 

强制性的io_context运行事件处理循环。也可以从多个线程调用run(),如果我们希望服务器执行多个操作系统线程。在我们的情况下,我们只使用一个线程,但具有多个并发流。函数boost::asio::co_spawn()启动一个分离的并发流。服务器使用 lambda 表达式实现;它定义了一个 TCP 端点(端口 37259),并开始在端点上监听传入的客户端连接。

协程listen()相当简单,如下所示:

auto listen(tcp::endpoint endpoint) -> asio::awaitable<void> {
  auto ex = co_await asio::this_coro::executor;
  auto a = tcp::acceptor{ex, endpoint};
  while (true) {
    auto socket = co_await a.async_accept(asio::use_awaitable);
    auto session = [s = std::move(socket)]() mutable {
      auto awaitable = serve_client(std::move(s));
      return awaitable;
    };
    asio::co_spawn(ex, std::move(session), asio::detached);
  }
} 

执行器是实际执行我们的异步函数的对象。执行器可以表示线程池或单个系统线程,例如。我们很可能会在即将推出的 C++版本中看到某种形式的执行器,以便让我们程序员更多地控制和灵活地执行我们的代码(包括 GPU)。

接下来,协程运行一个无限循环,并等待 TCP 客户端连接。第一个co_await表达式在新客户端成功连接到服务器时返回一个套接字。然后将套接字对象移动到协程serve_client()中,该协程将为新连接的客户端提供服务,直到客户端断开连接。

服务器的主要应用逻辑发生在处理每个客户端的协程中。下面是它的样子:

auto serve_client(tcp::socket socket) -> asio::awaitable<void> {
  std::cout << "New client connected\n";
  auto ex = co_await asio::this_coro::executor;
  auto timer = asio::system_timer{ex};
  auto counter = 0;
  while (true) {
    try {
      auto s = std::to_string(counter) + "\n";
      auto buf = asio::buffer(s.data(), s.size());
      auto n = co_await async_write(socket, buf, asio::use_awaitable);
      std::cout << "Wrote " << n << " byte(s)\n";
      ++counter;
      timer.expires_from_now(100ms);
      co_await timer.async_wait(asio::use_awaitable);
    } catch (...) {
      // Error or client disconnected
      break;
    }
  }
} 

每个协程调用在整个客户端会话期间为一个唯一的客户端提供服务;它在客户端断开连接之前一直运行。协程会定期更新计数器(每 100 毫秒一次),并使用async_write()异步将值写回给客户端。请注意,尽管它调用了两个异步操作:async_write()async_wait(),但我们可以以线性方式编写函数serve_client()

运行和连接到服务器

一旦我们启动了这个服务器,我们可以在端口 37259 上连接客户端。为了尝试这个,我使用了一个叫做nc(netcat)的工具,它可以用于通过 TCP 和 UDP 进行通信。下面是一个客户端连接到运行在本地主机上的服务器的短会话的示例:

[client] $ nc localhost 37259              
0
1
2
3 

我们可以启动多个客户端,它们都将由专用的serve_client()协程调用来提供服务,并且拥有自己的递增计数变量的副本,如下面的屏幕截图所示:

图 13.2:运行中的服务器与两个连接的客户端

另一种创建同时为多个会话提供服务的应用程序的方法是为每个连接的新客户端创建一个线程。然而,与使用协程的模型相比,线程的内存开销会大大降低会话数量的限制。

这个例子中的协程都在同一个线程上执行,这使得共享资源的锁定变得不必要。想象一下,如果我们有一个每个会话都会更新的全局计数器。如果我们使用多个线程,对全局计数器的访问就需要某种形式的同步(使用互斥锁或原子数据类型)。但是对于在同一线程上执行的协程来说,这是不必要的。换句话说,在同一线程上执行的协程可以共享状态,而不需要使用任何锁定原语。

我们通过服务器实现了什么(以及我们没有实现的)

Boost.Asio 示例应用程序演示了协程可以用于异步编程。我们可以使用co_await语句以线性方式编写代码,而不是使用嵌套回调来实现延续。然而,这个例子很简单,避开了一些真正重要的异步编程方面,比如:

  • 异步读写操作。服务器只向其客户端写入数据,并忽略了同步读写操作的挑战。

  • 取消异步任务和优雅关闭。服务器在一个无限循环中运行,完全忽略了干净关闭的挑战。

  • 使用多个co_await语句时的错误处理和异常安全。

这些主题非常重要,但超出了本书的范围。我已经提到了最好避免使用分离的操作。使用boost::asio::co_spawn()创建分离的任务,应该非常谨慎。一个相当新的用于避免分离工作的编程范式被称为结构化并发。它旨在通过将并发封装到通用和可重用的算法中(例如when_all()stop_when())来解决异常安全和多个异步任务的取消。关键思想是永远不允许某个子任务的生命周期超过其父任务。这使得可以安全地通过引用传递本地变量给异步子操作,并且性能更好。严格嵌套的并发任务生命周期也使得代码更容易理解。

另一个重要的方面是,异步任务应该始终是懒惰的(立即挂起),这样在抛出任何异常之前就可以附加继续。如果您想要能够以安全的方式取消任务,这也是一个要求。

未来几年很可能会有很多关于这一重要主题的讲座、库和文章。CppCon 2019 的两场讲座涉及了这个主题。

  • 用于 C++中异步的统一抽象,Eric Neibler 和 D.S. Hollman,sched.co/SfrC

  • 结构化并发:使用协程和算法编写更安全的并发代码,Lewis Baker,sched.co/SfsU

总结

在本章中,您已经看到了如何使用 C++协程来编写异步任务。为了能够以Task类型和sync_wait()函数的形式实现基础设施,您需要充分理解可等待类型的概念以及它们如何用于自定义 C++中协程的行为。

通过使用 Boost.Asio,我们可以构建一个真正最小但完全功能的并发服务器应用程序,该应用程序在单个线程上执行,同时处理多个客户会话。

最后,我简要介绍了一种称为结构化并发的方法论,并指出了一些关于这个主题的更多信息的方向。

在下一章中,我们将继续探讨并行算法,这是一种通过利用多个核心来加速并发程序的方法。

第十四章:并行算法

之前的章节着重介绍了如何通过使用线程和协程在我们的程序中引入并发和异步性。本章侧重于独立任务的并行执行,这与并发相关但又不同。

在之前的章节中,我强调我更喜欢使用标准库算法而不是手工制作的for循环。在本章中,您将看到使用 C++17 引入的执行策略与标准库算法的一些巨大优势。

本章不会深入探讨并行算法或并行编程的理论,因为这些主题太复杂,无法在一章中涵盖。此外,关于这个主题有很多书籍。相反,本章将采取更实际的方法,演示如何扩展当前的 C++代码库以利用并行性,同时保持代码库的可读性。换句话说,我们不希望并行性影响可读性;相反,我们希望将并行性抽象出来,使得并行化代码只是改变算法的一个参数。

在本章中,您将学到:

  • 实现并行算法的各种技术

  • 如何评估并行算法的性能

  • 如何调整代码库以使用标准库算法的并行扩展

并行编程是一个复杂的话题,因此在开始之前,您需要了解引入并行性的动机。

并行性的重要性

从程序员的角度来看,如果今天的计算机硬件是一个 100 GHz 的单核 CPU 而不是一个 3 GHz 的多核 CPU,那将非常方便;我们就不需要关心并行性。不幸的是,使单核 CPU 变得越来越快已经达到了物理极限。因此,随着计算机硬件的发展朝着多核 CPU 和可编程 GPU 的方向发展,程序员必须使用高效的并行模式来充分利用硬件。

并行算法允许我们通过在多核 CPU 或 GPU 上同时执行多个单独的任务或子任务来优化我们的程序。

并行算法

正如在第十一章并发中提到的,并发并行这两个术语有时很难区分。作为提醒,如果程序在重叠的时间段内具有多个单独的控制流,则称该程序在并发运行。另一方面,并行程序同时执行多个任务或子任务(在完全相同的时间),这需要具有多个核心的硬件。我们使用并行算法来优化延迟或吞吐量。如果没有硬件可以同时执行多个任务以实现更好的性能,那么并行化算法就毫无意义。现在将介绍一些简单的公式,以帮助您了解在评估并行算法时需要考虑哪些因素。

评估并行算法

在本章中,加速比被定义为顺序算法和并行算法之间的比率,如下所示:

T[1]是使用顺序算法在一个核心上执行解决问题所需的时间,T[n]是使用n个核心解决相同问题所需的时间。时间指的是挂钟时间(而不是 CPU 时间)。

与其顺序等效物相比,并行算法通常更复杂,需要更多的计算资源(例如 CPU 时间)。并行版本的好处来自于能够将算法分布到多个处理单元上。

在这种情况下,值得注意的是,并非所有算法在并行运行时都能获得相同的性能提升。并行算法的效率可以通过以下公式计算:

在这个公式中,n是执行算法的核心数。由于T[1]/T[n]表示加速比,效率也可以表示为加速比/n

如果效率为1.0,则算法并行化完美。例如,这意味着在具有八个核心的计算机上执行并行算法时,我们可以实现 8 倍的加速。但在实践中,有许多参数限制了并行执行,比如创建线程、内存带宽和上下文切换,正如第十一章并发中所述。因此,通常效率远低于 1.0。

并行算法的效率取决于每个工作块的独立处理程度。例如,std::transform()在某种意义上是非常容易并行化的,因为每个元素的处理完全独立于其他元素。这将在本章后面进行演示。

效率还取决于问题的规模和核心数量。例如,由于并行算法的复杂性增加而导致的开销,一个并行算法在小数据集上可能表现非常糟糕。同样,在计算机上执行程序时,可能会遇到其他瓶颈,比如内存带宽,这可能会导致在大量核心上执行程序时性能下降。我们说一个并行算法是可扩展的,如果效率在改变核心数量和/或输入规模时保持不变。

同样重要的是要记住,并非程序的所有部分都可以并行化。即使我们有无限数量的核心,这个事实也限制了程序的理论最大加速。我们可以使用阿姆达尔定律来计算最大可能的加速,这是在第三章分析和测量性能中介绍的。

阿姆达尔定律的再审视

在这里,我们将阿姆达尔定律应用于并行程序。它的工作原理是:程序的总运行时间可以分为两个不同的部分或比例

  • F[seq]是程序中只能按顺序执行的部分的比例

  • F[par]是程序中可以并行执行的部分的比例

由于这两个比例加起来构成了整个程序,这意味着F[seq] = 1 - F[par]。现在,阿姆达尔定律告诉我们,程序在n个核心上执行的最大加速是:

为了可视化这个定律的效果,下面的图像显示了一个程序的执行时间,其中底部是顺序部分,顶部是并行部分。增加核心数量只会影响并行部分,这限制了最大加速比。

图 14.1:阿姆达尔定律定义了最大加速;在这种情况下是 2 倍

在上图中,当在单个 CPU 上运行时,顺序部分占执行时间的 50%。因此,当执行这样的程序时,通过增加更多核心,我们可以实现的最大加速是 2 倍。

为了让您了解并行算法是如何实现的,我们现在将通过一些示例来说明。我们将从std::transform()开始,因为它相对容易分成多个独立的部分。

实现并行的 std::transform()

尽管从算法上来说std::transform()很容易实现,但在实践中,实现一个初级的并行版本比看上去更复杂。

算法std::transform()为序列中的每个元素调用一个函数,并将结果存储在另一个序列中。std::transform()的顺序版本的一个可能实现可能看起来像这样:

template<class SrcIt, class DstIt, class Func>
auto transform(SrcIt first, SrcIt last, DstIt dst, Func func) {
  while (first != last) {
      *dst++ = func(*first++);
  }
} 

标准库版本也返回dst迭代器,但在我们的示例中将忽略它。为了理解std::transform()并行版本的挑战,让我们从一个天真的方法开始。

天真的实现

std::transform()的一个天真的并行实现可能看起来像这样:

  • 将元素分成与计算机核心数相对应的块

  • 在单独的任务中处理每个块

  • 等待所有任务完成

使用 std::thread::hardware_concurrency() 来确定支持的硬件线程数量,可能的实现如下:

template <typename SrcIt, typename DstIt, typename Func>
auto par_transform_naive(SrcIt first, SrcIt last, DstIt dst, Func f) {
  auto n = static_cast<size_t>(std::distance(first, last));
  auto n_cores = size_t{std::thread::hardware_concurrency()};
  auto n_tasks = std::max(n_cores, size_t{1});
  auto chunk_sz = (n + n_tasks - 1) / n_tasks;
  auto futures = std::vector<std::future<void>>{};
  // Process each chunk on a separate
  for (auto i = 0ul; i < n_tasks; ++i) {
    auto start = chunk_sz * i;
    if (start < n) {
      auto stop = std::min(chunk_sz * (i + 1), n);
      auto fut = std::async(std::launch::async,
         [first, dst, start, stop, f]() {
          std::transform(first + start, first + stop, dst + start, f);
      });
      futures.emplace_back(std::move(fut));
    }
  }
  // Wait for each task to finish
  for (auto&& fut : futures) {
    fut.wait();
  }
} 

请注意,如果 hardware_concurrency() 由于某种原因无法确定,可能会返回 0,因此会被夹制为至少为 1。

std::transform() 和我们的并行版本之间的一个细微差别是它们对迭代器有不同的要求。std::transform() 可以操作输入和输出迭代器,比如绑定到 std::cinstd::istream_iterator<>。这对于 par_transform_naive() 是不可能的,因为迭代器被复制并且从多个任务中使用。正如你将看到的,本章中没有呈现可以操作输入和输出迭代器的并行算法。相反,并行算法至少需要允许多次遍历的前向迭代器。

性能评估

继续使用朴素实现,让我们通过与在单个 CPU 核心上执行的顺序版本 std::transform() 的简单性能评估来测量其性能。

在这个测试中,我们将测量数据的输入大小变化时的时间(挂钟时间)和在 CPU 上花费的总时间。

我们将使用在 第三章 分析和测量性能 中介绍的 Google Benchmark 来设置这个基准。为了避免重复代码,我们将实现一个函数来为我们的基准设置一个测试夹具。夹具需要一个包含一些示例值的源范围,一个用于结果的目标范围,以及一个转换函数:

auto setup_fixture(int n) {
  auto src = std::vector<float>(n);
  std::iota(src.begin(), src.end(), 1.0f); // Values from 1.0 to n
  auto dst = std::vector<float>(src.size());
  auto transform_function = [](float v) { 
    auto sum = v;
    for (auto i = 0; i < 500; ++i) {
      sum += (i * i * i * sum);
    }
    return sum;
  };
  return std::tuple{src, dst, transform_function};
} 

现在我们已经设置好了我们的夹具,是时候实现实际的基准了。将会有两个版本:一个用于顺序的 std::transform(),一个用于我们的并行版本 par_transform_naive()

void bm_sequential(benchmark::State& state) {
  auto [src, dst, f] = setup_fixture(state.range(0));
  for (auto _ : state) {
    std::transform(src.begin(), src.end(), dst.begin(), f);
  }
}
void bm_parallel(benchmark::State& state) {
  auto [src, dst, f] = setup_fixture(state.range(0));
  for (auto _ : state) {
    par_transform_naive(src.begin(), src.end(), dst.begin(), f);
  }
} 

只有 for-循环内的代码将被测量。通过使用 state.range(0) 作为输入大小,我们可以通过将一系列值附加到每个基准来生成不同的值。实际上,我们需要为每个基准指定一些参数,因此我们创建一个帮助函数,应用我们需要的所有设置:

void CustomArguments(benchmark::internal::Benchmark* b) {
  b->Arg(50)->Arg(10'000)->Arg(1'000'000)  // Input size
      ->MeasureProcessCPUTime()            // Measure all threads
      ->UseRealTime()                      // Clock on the wall 
      ->Unit(benchmark::kMillisecond);     // Use ms
} 

关于自定义参数的一些注意事项:

  • 我们将值 50、10,000 和 1,000,000 作为基准的参数传递。它们在创建 setup_fixture() 函数中的向量时用作输入大小。在测试函数中使用 state.range(0) 访问这些值。

  • 默认情况下,Google Benchmark 只在主线程上测量 CPU 时间。但由于我们对所有线程上的 CPU 时间总量感兴趣,我们使用 MeasureProcessCPUTime()

  • Google Benchmark 决定每个测试需要重复多少次,直到达到统计上稳定的结果。我们希望库在这方面使用挂钟时间而不是 CPU 时间,因此我们应用设置 UseRealTime()

这几乎就是了。最后,注册基准并调用 main:

BENCHMARK(bm_sequential)->Apply(CustomArguments);
BENCHMARK(bm_parallel)->Apply(CustomArguments);
BENCHMARK_MAIN(); 

在使用优化后的代码(使用 gcc 和 -O3)编译后,我在一台具有八个核心的笔记本电脑上执行了这个基准。以下表格显示了使用 50 个元素时的结果:

算法 CPU 时间 加速比
std::transform() 0.02 毫秒 0.02 毫秒 0.25x
par_transform_naive() 0.17 毫秒 0.08 毫秒

CPU 是在 CPU 上花费的总时间。时间 是挂钟时间,这是我们最感兴趣的。加速比 是比较顺序版本的经过时间和并行版本的相对加速比(在这种情况下为 0.02/0.08)。

显然,对于只有 50 个元素的小数据集,顺序版本的性能优于并行算法。但是,当有 10,000 个元素时,我们真的开始看到并行化的好处:

算法 CPU 时间 加速比
std::transform() 0.89 毫秒 0.89 毫秒 4.5x
par_transform_naive() 1.95 毫秒 0.20 毫秒

最后,使用 1,000,000 个元素给我们带来了更高的效率,如下表所示:

算法 CPU 时间 加速比
std::transform() 9071 ms 9092 ms 7.3x
par_transform_naive() 9782 ms 1245 ms

在这次运行中,并行算法的效率非常高。它在八个核心上执行,因此效率为 7.3x/8 = 0.925。这里呈现的结果(绝对执行时间和相对加速比)不应过分依赖。结果取决于计算机架构、操作系统调度程序以及在执行测试时当前机器上运行的其他工作量。尽管如此,基准测试结果证实了前面讨论的一些重要观点:

  • 对于小数据集,由于创建线程等产生的开销,顺序版本std::transform()比并行版本快得多。

  • std::transform()相比,并行版本总是使用更多的计算资源(CPU 时间)。

  • 对于大数据集,当测量挂钟时间时,并行版本的性能优于顺序版本。在具有八个核心的机器上运行时,加速比超过 7 倍。

我们算法效率高的一个原因(至少对于大数据集来说)是计算成本均匀分布,每个子任务高度独立。然而,并非总是如此。

朴素实现的缺点

如果每个工作块的计算成本相同,并且算法在没有其他应用程序利用硬件的环境中执行,那么朴素实现可能会做得很好。然而,这种情况很少发生;相反,我们希望有一个既高效又可扩展的通用并行实现。

以下插图显示了我们要避免的问题。如果每个块的计算成本不相等,实现将受限于花费最长时间的块:

图 14.2:计算时间与块大小不成比例的可能场景

如果应用程序和/或操作系统有其他进程需要处理,操作将无法并行处理所有块:

图 14.3:计算时间与块大小成比例的可能场景

如您在图 14.3中所见,将操作分割成更小的块使并行化适应当前条件,避免了使整个操作停滞的单个任务。

还要注意,对于小数据集,朴素实现是不成功的。有许多方法可以调整朴素实现以获得更好的性能。例如,我们可以通过将核心数乘以大于 1 的某个因子来创建更多任务和更小的任务。或者,为了避免在小数据集上产生显着的开销,我们可以让块大小决定要创建的任务数量等。

现在您已经知道如何实现和评估简单的并行算法。我们不会对朴素实现进行任何微调;相反,我将展示在实现并行算法时使用的另一种有用技术。

分而治之

将问题分解为较小子问题的算法技术称为分而治之。我们将在这里实现另一个使用分而治之的并行转换算法版本。它的工作原理如下:如果输入范围小于指定的阈值,则处理该范围;否则,将范围分成两部分:

  • 第一部分在新分支的任务上处理

  • 另一部分在调用线程中进行递归处理

以下插图显示了如何使用以下数据和参数递归地转换范围的分治算法:

  • 范围大小:16

  • 源范围包含从 1.0 到 16.0 的浮点数

  • 块大小:4

  • 转换函数:[](auto x) { return x*x; }

图 14.4:一个范围被递归地分割以进行并行处理。源数组包含从 1.0 到 8.0 的浮点值。目标数组包含转换后的值。

图 14.4中,您可以看到主任务生成了两个异步任务(任务 1任务 2),最后转换了范围中的最后一个块。任务 1生成了任务 3,然后转换了包含值 5.0、6.0、7.0 和 8.0 的剩余元素。让我们来看看实现。

实施

在实施方面,这是一小段代码。输入范围被递归地分成两个块;第一个块被调用为一个新任务,第二个块在同一个任务上被递归处理:

template <typename SrcIt, typename DstIt, typename Func>
auto par_transform(SrcIt first, SrcIt last, DstIt dst,
                   Func func, size_t chunk_sz) {
  const auto n = static_cast<size_t>(std::distance(first, last));
  if (n <= chunk_sz) {
    std::transform(first, last, dst, func);
    return;
  }
  const auto src_middle = std::next(first, n / 2);
  // Branch of first part to another task
  auto future = std::async(std::launch::async, [=, &func] {
    par_transform(first, src_middle, dst, func, chunk_sz);
  });
  // Recursively handle the second part
  const auto dst_middle = std::next(dst, n / 2);
  par_transform(src_middle, last, dst_middle, func, chunk_sz);
  future.wait(); 
} 

将递归与多线程结合起来可能需要一段时间才能理解。在以下示例中,您将看到这种模式在实现更复杂的算法时可以使用。但首先,让我们看看它的性能如何。

性能评估

为了评估我们的新版本,我们将通过更新 transform 函数来修改基准测试装置,使其根据输入值的不同需要更长的时间。通过使用std::iota()填充范围来增加输入值的范围。这样做意味着算法需要处理不同大小的作业。以下是新的setup_fixture()函数:

auto setup_fixture(int n) {
  auto src = std::vector<float>(n);
  std::iota(src.begin(), src.end(), 1.0f);  // From 1.0 to n
  auto dst = std::vector<float>(src.size());
  auto transform_function = [](float v) { 
    auto sum = v;
    auto n = v / 20'000;                  // The larger v is, 
    for (auto i = 0; i < n; ++i) {        // the more to compute
      sum += (i * i * i * sum);
    }
    return sum;
  };
  return std::tuple{src, dst, transform_function};
} 

现在我们可以尝试通过使用递增的参数来找到分而治之算法的最佳块大小。看看我们的分而治之算法在这个新的装置上与朴素版本相比的表现,这需要处理不同大小的作业。以下是完整的代码:

// Divide and conquer version
void bm_parallel(benchmark::State& state) {
  auto [src, dst, f] = setup_fixture(10'000'000);
  auto n = state.range(0);        // Chunk size is parameterized
  for (auto _ : state) {
    par_transform(src.begin(), src.end(), dst.begin(), f, n);
  }
}
// Naive version
void bm_parallel_naive(benchmark::State& state) {
  auto [src, dst, f] = setup_fixture(10'000'000);
  for (auto _ : state) {
    par_transform_naive(src.begin(), src.end(), dst.begin(), f);
  }
}
void CustomArguments(benchmark::internal::Benchmark* b) {
  b->MeasureProcessCPUTime()
    ->UseRealTime()
    ->Unit(benchmark::kMillisecond);
}
BENCHMARK(bm_parallel)->Apply(CustomArguments)
  ->RangeMultiplier(10)           // Chunk size goes from 
  ->Range(1000, 10'000'000);      // 1k to 10M
BENCHMARK(bm_parallel_naive)->Apply(CustomArguments);
BENCHMARK_MAIN(); 

下图显示了我在 macOS 上运行测试时所获得的结果,使用了一个拥有八个核心的英特尔 Core i7 CPU:

图 14.5:比较我们的朴素算法和使用不同块大小的分而治之算法

当使用大约 10,000 个元素的块大小时,可以实现最佳效率,这将创建 1,000 个任务。使用更大的块时,性能会在处理最终块所需的时间上受到瓶颈,而使用太小的块会导致在创建和调用任务方面产生过多的开销,与计算相比。

从这个例子中可以得出的结论是,调度 1,000 个较小的任务而不是几个大任务所带来的性能惩罚在这里并不是一个问题。可以通过使用线程池来限制线程的数量,但在这种情况下std::async()似乎运行得相当好。通用的实现会选择使用相当大数量的任务,而不是试图匹配确切的核心数量。

在实现并行算法时,找到最佳的块大小和任务数量是一个真正的问题。如您所见,这取决于许多变量,也取决于您是优化延迟还是吞吐量。获得洞察力的最佳方法是在您的算法应该运行的环境中进行测量。

现在您已经学会了如何使用分而治之来实现并行转换算法,让我们看看相同的技术如何应用到其他问题上。

实现并行 std::count_if()

分而治之的好处是它可以应用到许多问题上。我们可以很容易地使用相同的技术来实现std::count_if()的并行版本,唯一的区别是我们需要在函数末尾累加返回的值,就像这样:

template <typename It, typename Pred> 
auto par_count_if(It first, It last, Pred pred, size_t chunk_sz) { 
  auto n = static_cast<size_t>(std::distance(first, last)); 
  if (n <= chunk_sz) 
    return std::count_if(first, last, pred);
  auto middle = std::next(first, n/2); 
  auto fut = std::async(std::launch::async, [=, &pred] { 
    return par_count_if(first, middle, pred, chunk_sz); 
  }); 
  auto num = par_count_if(middle, last, pred, chunk_sz); 
  return num + fut.get(); 
} 

如您所见,这里唯一的区别是我们需要在函数末尾对结果进行求和。如果您希望块大小取决于核心数量,您可以很容易地将par_count_if()包装在一个外部函数中:

template <typename It, typename Pred> 
auto par_count_if(It first, It last, Pred pred) { 
  auto n = static_cast<size_t>(std::distance(first, last));
  auto n_cores = size_t{std::thread::hardware_concurrency()};
  auto chunk_sz = std::max(n / n_cores * 32, size_t{1000});

  return par_count_if(first, last, pred, chunk_sz);
} 

这里的神奇数字 32 是一个相当任意的因子,如果我们有一个大的输入范围,它将给我们更多的块和更小的块。通常情况下,我们需要测量性能来得出一个好的常数。现在让我们继续尝试解决一个更复杂的并行算法。

实现并行 std::copy_if()

我们已经研究了std::transform()std::count_if(),它们在顺序和并行实现上都相当容易。如果我们再考虑另一个在顺序中容易实现的算法std::copy_if(),在并行中执行起来就会变得更加困难。

顺序地,实现std::copy_if()就像这样简单:

template <typename SrcIt, typename DstIt, typename Pred> 
auto copy_if(SrcIt first, SrcIt last, DstIt dst, Pred pred) { 
  for (auto it = first; it != last; ++it) { 
    if (pred(*it)) { 
      *dst = *it; 
      ++dst;
    }
  }
  return dst;
} 

为了演示如何使用它,考虑以下示例,其中我们有一个包含整数序列的范围,我们想要将奇数整数复制到另一个范围中:

const auto src = {1, 2, 3, 4}; 
auto dst = std::vector<int>(src.size(), -1); 
auto new_end = std::copy_if(src.begin(), src.end(), dst.begin(), 
                            [](int v) { return (v % 2) == 1; }); 
// dst is {1, 3, -1, -1}
dst.erase(new_end, dst.end()); // dst is now {1, 3} 

现在,如果我们想要制作copy_if()的并行版本,我们立即遇到问题,因为我们不能同时向目标迭代器写入。这是一个失败的尝试,具有未定义的行为,因为两个任务将同时写入目标范围中的相同位置:

// Warning: Undefined behavior
template <typename SrcIt, typename DstIt, typename Func> 
auto par_copy_if(SrcIt first, SrcIt last, DstIt dst, Func func) { 
  auto n = std::distance(first, last);
  auto middle = std::next(first, n / 2); 
  auto fut0 = std::async([=]() { 
    return std::copy_if(first, middle, dst, func); }); 
  auto fut1 = std::async([=]() { 
    return std::copy_if(middle, last, dst, func); });
  auto dst0 = fut0.get();
  auto dst1 = fut1.get();
  return *std::max(dst0, dst1); // Just to return something...
} 

现在我们有了两种简单的方法:要么我们同步我们写入的索引(使用原子/无锁变量),要么我们将算法分成两部分。接下来我们将探索这两种方法。

方法 1:使用同步的写入位置

我们可能考虑的第一种方法是使用原子size_tfetch_add()成员函数来同步写入位置,就像你在第十一章 并发中学到的那样。每当一个线程尝试写入一个新元素时,它原子地获取当前索引并添加一个;因此,每个值都被写入到一个唯一的索引。

在我们的代码中,我们将算法分成两个函数:一个内部函数和一个外部函数。原子写入索引将在外部函数中定义,而算法的主要部分将在内部函数中实现。

内部函数

内部函数需要一个同步写入位置的原子size_t。由于算法是递归的,它不能自己存储原子size_t;它需要一个外部函数来调用算法:

template <typename SrcIt, typename DstIt, typename Pred>
void inner_par_copy_if_sync(SrcIt first, SrcIt last, DstIt dst,
                            std::atomic_size_t& dst_idx,
                            Pred pred, size_t chunk_sz) {
  const auto n = static_cast<size_t>(std::distance(first, last));
  if (n <= chunk_sz) {
    std::for_each(first, last, & {
      if (pred(v)) {
        auto write_idx = dst_idx.fetch_add(1);
        *std::next(dst, write_idx) = v;
      }
    });
    return;
  }
  auto middle = std::next(first, n / 2);
  auto future = std::async([first, middle, dst, chunk_sz, &pred, &dst_idx] {
    inner_par_copy_if_sync(first, middle, dst, dst_idx, pred, chunk_sz);
  });
  inner_par_copy_if_sync(middle, last, dst, dst_idx, pred, chunk_sz);
  future.wait();
} 

这仍然是一个分而治之的算法,希望你现在开始看到我们正在使用的模式。写入索引dst_idx的原子更新确保多个线程永远不会写入相同的目标序列中的索引。

外部函数

从客户端代码调用的外部函数只是原子size_t的占位符,它被初始化为零。然后函数初始化内部函数,进一步并行化代码:

template <typename SrcIt, typename DstIt, typename Pred>
auto par_copy_if_sync(SrcIt first,SrcIt last,DstIt dst,
                      Pred p, size_t chunk_sz) {
  auto dst_write_idx = std::atomic_size_t{0};
  inner_par_copy_if_sync(first, last, dst, dst_write_idx, p, chunk_sz);
  return std::next(dst, dst_write_idx);
} 

内部函数返回后,我们可以使用dst_write_idx来计算目标范围的结束迭代器。现在让我们来看看解决相同问题的另一种方法。

方法 2:将算法分成两部分

第二种方法是将算法分成两部分。首先,在并行块中执行条件复制,然后将结果稀疏范围压缩为连续范围。

第一部分 - 并行复制元素到目标范围

第一部分将元素分块复制,得到了在图 14.6中说明的稀疏目标数组。每个块都是以并行方式有条件地复制的,结果范围迭代器存储在std::future对象中以供以后检索:

图 14.6:第一步条件复制后的稀疏目标范围

以下代码实现了算法的前半部分:

template <typename SrcIt, typename DstIt, typename Pred>
auto par_copy_if_split(SrcIt first, SrcIt last, DstIt dst, 
                       Pred pred, size_t chunk_sz) -> DstIt {
  auto n = static_cast<size_t>(std::distance(first, last));
  auto futures = std::vector<std::future<std::pair<DstIt, DstIt>>>{};
  futures.reserve(n / chunk_sz);
  for (auto i = size_t{0}; i < n; i += chunk_sz) {
    const auto stop_idx = std::min(i + chunk_sz, n);
    auto future = std::async([=, &pred] {
      auto dst_first = dst + i;
      auto dst_last = std::copy_if(first+i, first+stop_idx,                                   dst_first, pred);
      return std::make_pair(dst_first, dst_last);
    });
    futures.emplace_back(std::move(future));
  }
  // To be continued ... 

现在我们已经将(应该被复制的)元素复制到了稀疏的目标范围中。现在是时候通过将元素向左移动到范围中来填补空白了。

第二部分 - 将稀疏范围顺序地移动到连续范围

当创建稀疏范围时,它使用每个std::future的结果值进行合并。合并是顺序执行的,因为部分重叠:

 // ...continued from above... 
  // Part #2: Perform merge of resulting sparse range sequentially 
  auto new_end = futures.front().get().second; 
  for (auto it = std::next(futures.begin()); it != futures.end(); ++it)  { 
    auto chunk_rng = it->get(); 
    new_end = std::move(chunk_rng.first, chunk_rng.second, new_end);
  } 
  return new_end; 
} // end of par_copy_if_split 

将所有子范围移动到范围开始的算法的第二部分如下图所示:

图 14.7:将稀疏范围合并到连续范围中

有两个解决同一个问题的算法,现在是时候看看它们的表现如何了。

性能评估

使用这个并行化版本的copy_if()的性能提升严重依赖于谓词的昂贵程度。因此,在我们的基准测试中,我们使用了两个不同计算成本的谓词。这是廉价的谓词:

auto is_odd = [](unsigned v) { 
  return (v % 2) == 1; 
}; 

昂贵的谓词检查其参数是否为质数:

auto is_prime = [](unsigned v) {
  if (v < 2) return false;
  if (v == 2) return true;
  if (v % 2 == 0) return false;
  for (auto i = 3u; (i * i) <= v; i+=2) {
    if ((v % i) == 0) {
      return false; 
     }
  }
  return true;
}; 

请注意,这不是实现is_prime()的特别优化的方式,仅仅是为了基准测试的目的而使用。

基准测试代码没有在这里详细说明,但包含在附带的源代码中。比较了三个算法:std::copy_if()par_copy_if_split()par_copy_if_sync()。下图显示了在使用英特尔 Core i7 CPU 进行测量时的结果。这个基准测试中,并行算法使用了一个大小为 100,000 的块。

图 14.8:条件复制策略与计算时间

在测量性能时最明显的观察是,当使用廉价的is_odd()谓词时,同步版本par_copy_if_sync()的性能是多么慢。灾难性的性能实际上并不是由于原子写入索引,而是因为硬件的缓存机制由于多个线程写入同一缓存行而被破坏(正如你在第七章内存管理中学到的)。

因此,有了这个知识,我们现在明白了为什么par_copy_if_split()的性能更好。在廉价的谓词is_odd()上,par_copy_if_split()std::copy_if()快大约 2 倍,但在昂贵的is_prime()上,效率增加到了近 5 倍。增加的效率是由于大部分计算在算法的第一部分中并行执行。

现在你应该掌握了一些用于并行化算法的技术。这些新的见解将帮助你理解使用标准库中的并行算法时的要求和期望。

并行标准库算法

从 C++17 开始,标准库已经扩展了大多数算法的并行版本,但并非所有算法都有。将你的算法更改为允许并行执行只是添加一个参数,告诉算法使用哪个并行执行策略。

本书早些时候强调过,如果你的代码基于标准库算法,或者至少习惯于使用算法编写 C++,那么通过在适当的地方添加执行策略,你几乎可以免费获得即时的性能提升:

auto v = std::vector<std::string>{ 
  "woody", "steely", "loopy", "upside_down" 
};
// Parallel sort
std::sort(std::execution::par, v.begin(), v.end()); 

一旦指定了执行策略,你就进入了并行算法的领域,这些算法与它们原始的顺序版本有一些显著的区别。首先,最小的迭代器类别要求从输入迭代器变为前向迭代器。其次,你的代码抛出的异常(从复制构造函数或传递给算法的函数对象)永远不会到达你。相反,算法要求调用std::terminate()。第三,由于并行实现的复杂性增加,算法的复杂性保证(时间和内存)可能会放宽。

在使用标准库算法的并行版本时,你需要指定一个执行策略,该策略规定了算法允许并行执行的方式。但是,实现可能会决定按顺序执行算法。如果你比较不同标准库实现中并行算法的效率和可伸缩性,你会发现巨大的差异。

执行策略

执行策略通知算法执行是否可以并行化以及如何并行化。标准库的并行扩展中包括四种默认执行策略。编译器和第三方库可以为特定的硬件和条件扩展这些策略。例如,已经可以使用特定供应商的策略从标准库算法中使用现代图形卡的并行能力。

执行策略在头文件<execution>中定义,并驻留在命名空间std::execution中。目前有四种不同的标签类型,每种执行策略对应一种。这些类型不能由您实例化;相反,每种类型有一个预定义对象。例如,并行执行策略有一个名为std::execution::parallel_policy的类型,该类型的预定义实例名为std::execution::par。每种策略有一个类型(而不是具有多个预定义实例的一个类型)的原因是,您提供的策略可以在库中在编译时区分。

顺序策略

顺序执行策略std::execution::seq使算法以顺序方式执行,没有并行性,类似于没有额外执行策略参数的算法将运行的方式。然而,每当您指定执行策略时,这意味着您正在使用具有放宽的复杂性保证和更严格的迭代器要求的算法的版本;它还假定您提供的代码不会抛出异常,否则算法将调用std::terminate()

并行策略

并行执行策略std::execution::par可以被认为是并行算法的标准执行策略。您提供给算法的代码需要是线程安全的。理解这一要求的一种方法是考虑您将要使用的算法的顺序版本中的循环主体。例如,考虑我们在本章前面这样拼写出来的copy_if()的顺序版本:

template <typename SrcIt, typename DstIt, typename Pred> 
auto copy_if(SrcIt first, SrcIt last, DstIt dst, Pred pred) { 
  for (auto it = first; it != last; ++it) 
  {                            // Start of loop body
    if (pred(*it)) {           // Call predicate
      *dst = *it;              // Copy construct 
      ++dst;
    }
  }                            // End of loop body 
  return dst;
} 

在这个算法中,循环主体内的代码将调用您提供的谓词,并在范围内的元素上调用复制赋值运算符。如果您将std::execution::par传递给copy_if(),则您有责任保证这些部分是线程安全的,并且可以安全地并行执行。

让我们看一个例子,我们提供不安全的代码,然后看看我们能做些什么。假设我们有一个字符串向量:

auto v = std::vector<std::string>{"Ada", "APL" /* ... */ }; 

如果我们想要使用并行算法计算向量中所有字符串的总大小,一个不足的方法是使用std::for_each(),就像这样:

auto tot_size = size_t{0};
std::for_each(std::execution::par, v.begin(), v.end(),
              & { 
  tot_size += s.size(); // Undefined behavior, data race!
}); 

由于函数对象的主体不是线程安全的(因为它从多个线程更新共享变量),这段代码表现出未定义的行为。当然,我们可以使用std::mutex保护tot_size变量,但这将破坏以并行方式执行此代码的整个目的,因为互斥锁只允许一个线程一次进入主体。使用std::atomic数据类型是另一种选择,但这也可能降低效率。

这里的解决方案是使用std::for_each()来解决这个问题。相反,我们可以使用std::transform_reduce()std::reduce(),这些都是专门为这种工作量身定做的。以下是使用std::reduce()的方法:

auto tot_size = std::reduce(std::execution::par, v.begin(), v.end(),                             size_t{0}, [](auto i, const auto& s) { 
  return i + s.size();   // OK! Thread safe
}); 

通过消除 lambda 内部的可变引用,lambda 的主体现在是线程安全的。对std::string对象的const引用是可以的,因为它从不改变任何字符串对象,因此不会引入任何数据竞争。

通常,您传递给算法的代码是线程安全的,除非您的函数对象通过引用捕获对象或具有其他诸如写入文件的副作用。

非顺序策略

无序策略是在 C++20 中添加的。它告诉算法,循环允许使用例如 SIMD 指令进行矢量化。实际上,这意味着您不能在传递给算法的代码中使用任何同步原语,因为这可能导致死锁。

为了理解死锁是如何发生的,我们将回到之前不足的例子,当计算向量中所有字符串的总大小时。假设,我们不是使用std::reduce(),而是通过添加互斥锁来保护tot_size变量,像这样:

auto v = std::vector<std::string>{"Ada", "APL" /* ... */ };
auto tot_size = size_t{0};
auto mut = std::mutex{};
std::for_each(std::execution::par, v.begin(), v.end(),
              & { 
    auto lock = std::scoped_lock{mut}; // Lock
    tot_size += s.size(); 
  }                                    // Unlock
); 

现在,使用std::execution::par执行此代码是安全的,但效率很低。如果我们将执行策略更改为std::execution::unseq,结果不仅是一个低效的程序,还是一个有死锁风险的程序!

无序执行策略告诉算法,它可以重新排序我们的代码的指令,这通常是优化编译器不允许的。

为了使算法受益于矢量化,它需要从输入范围中读取多个值,然后一次应用 SIMD 指令于多个值。让我们分析一下for_each()循环中的两次迭代可能是什么样子,有无重新排序。以下是两次迭代没有任何重新排序的情况:

{ // Iteration 1
  const auto& s = *it++;
  mut.lock();
  tot_size += s.size();
  mut.unlock();
}
{ // Iteration 2
  const auto& s = *it++;
  mut.lock();
  tot_size += s.size();
  mut.unlock();
} 

算法允许以以下方式合并这两次迭代:

{ // Iteration 1 & 2 merged
  const auto& s1 = *it++;
  const auto& s2 = *it++;
  mut.lock();
  mut.lock();                // Deadlock!
  tot_size += s1.size();     // Replace these operations
  tot_size += s2.size();     // with vectorized instructions
  mut.unlock();
  mut.unlock();
} 

尝试在同一线程上执行此代码将导致死锁,因为我们试图连续两次锁定同一个互斥锁。换句话说,当使用std::execution::unseq策略时,您必须确保您提供给算法的代码不会获取任何锁。

请注意,优化编译器随时可以对您的代码进行矢量化。然而,在这些情况下,由编译器来保证矢量化不会改变程序的含义,就像编译器和硬件允许执行的任何其他优化一样。在这里,当显式地为算法提供std::execute::unseq策略时,保证您提供的代码是安全的可矢量化的。

并行无序策略

并行无序策略std::execution::par_unseq像并行策略一样并行执行算法,另外还可以对循环进行矢量化。

除了四种标准执行策略之外,标准库供应商可以为您提供具有自定义行为的其他策略,并对输入施加其他约束。例如,英特尔并行 STL 库定义了四种只接受随机访问迭代器的自定义执行策略。

异常处理

如果您为算法提供了四种标准执行策略中的一种,您的代码不能抛出异常,否则算法将调用std::terminate()。这与正常的单线程算法有很大的不同,后者总是将异常传播回调用者:

auto v = {1, 2, 3, 4};
auto f = [](auto) { throw std::exception{}; };
try {
  std::for_each(v.begin(), v.end(), f);
} catch (...) {
  std::cout << "Exception caught\n";
} 

使用执行策略运行相同的代码会导致调用std::terminate()

try {
  std::for_each(std::execution::seq, v.begin(), v.end(), f);
} catch (...) {
  // The thrown std:::exception never reaches us.
  // Instead, std::terminate() has been called 
} 

您可能认为这意味着并行算法声明为noexcept,但事实并非如此。许多并行算法需要分配内存,因此标准并行算法本身可以抛出std::bad_alloc

还应该说,其他库提供的执行策略可能以不同的方式处理异常。

现在,我们将继续讨论在 C++17 中首次引入并行算法时添加和修改的一些算法。

并行算法的添加和更改

标准库中的大多数算法都可以直接作为并行版本使用。但是,也有一些值得注意的例外,包括std::accumulate()std::for_each(),因为它们的原始规范要求按顺序执行。

std::accumulate()和 std::reduce()

std::accumulate()算法不能并行化,因为它必须按元素的顺序执行,这是不可能并行化的。相反,已添加了一个新算法叫做std::reduce(),它的工作方式与std::accumulate()相同,只是它是无序执行的。

对于可交换的操作,它们的结果是相同的,因为累积的顺序无关紧要。换句话说,给定一个整数范围:

const auto r = {1, 2, 3, 4}; 

通过加法或乘法累积它们:

auto sum = 
  std::accumulate(r.begin(), r.end(), 0, std::plus<int>{});

auto product = 
  std::accumulate(r.begin(), r.end(), 1, std::multiplies<int>{}); 

将产生与调用std::reduce()而不是std::accumulate()相同的结果,因为整数的加法和乘法都是可交换的。例如:

但是,如果操作不是可交换的,结果是不确定的,因为它取决于参数的顺序。例如,如果我们要按如下方式累积字符串列表:

auto v = std::vector<std::string>{"A", "B", "C"};
auto acc = std::accumulate(v.begin(), v.end(), std::string{});
std::cout << acc << '\n'; // Prints "ABC" 

这段代码将始终产生字符串"ABC"。但是,通过使用std::reduce(),结果字符串中的字符可能以任何顺序出现,因为字符串连接不是可交换的。换句话说,字符串"A" + "B"不等于"B" + "A"。因此,使用std::reduce()的以下代码可能产生不同的结果:

auto red = std::reduce(v.begin(), v.end(), std::string{}); 
std::cout << red << '\n'; 
// Possible output: "CBA" or "ACB" etc 

与性能相关的一个有趣点是浮点数运算不是可交换的。通过在浮点值上使用std::reduce(),结果可能会有所不同,但这也意味着std::reduce()可能比std::accumulate()快得多。这是因为std::reduce()允许重新排序操作并利用 SIMD 指令,而在使用严格的浮点数运算时,std::accumulate()是不允许这样做的。

std::transform_reduce()

作为标准库算法的补充,std::transform_reduce()也已添加到<numeric>头文件中。它确切地做了它所说的:它将一个元素范围转换为std::transform(),然后应用一个函数对象。这样累积它们是无序的,就像std::reduce()一样:

auto v = std::vector<std::string>{"Ada","Bash","C++"}; 
auto num_chars = std::transform_reduce( 
  v.begin(), v.end(), size_t{0}, 
  [](size_t a, size_t b) { return a + b; },     // Reduce
  [](const std::string& s) { return s.size(); } // Transform 
); 
// num_chars is 10 

当并行算法被引入时,std::reduce()std::transform_reduce()也被添加到 C++17 中。另一个必要的更改是调整std::for_each()的返回类型。

std::for_each()

std::for_each()的一个相对不常用的特性是它返回传递给它的函数对象。这使得可以使用std::for_each()在有状态的函数对象内累积值。以下示例演示了可能的用例:

struct Func {
  void operator()(const std::string& s) {
    res_ += s;
  };
  std::string res_{};    // State
};
auto v = std::vector<std::string>{"A", "B", "C"};
auto s = std::for_each(v.begin(), v.end(), Func{}).res_;
// s is "ABC" 

这种用法类似于使用std::accumulate()可以实现的用法,因此在尝试并行化时也会出现相同的问题:无序执行函数对象将产生不确定的结果,因为调用顺序是未定义的。因此,std::for_each()的并行版本简单地返回void

基于索引的 for 循环的并行化

尽管我建议使用算法,但有时特定任务需要原始的基于索引的for循环。标准库算法通过在库中包含算法std::for_each()提供了等效于基于范围的for循环。

然而,并没有基于索引的for循环的等效算法。换句话说,我们不能简单地通过向其添加并行策略来轻松并行化这样的代码:

auto v = std::vector<std::string>{"A", "B", "C"};
for (auto i = 0u; i < v.size(); ++i) { 
  v[i] += std::to_string(i+1); 
} 
// v is now { "A1", "B2", "C3" } 

但让我们看看如何通过组合算法来构建一个。正如您已经得出的结论,实现并行算法是复杂的。但在这种情况下,我们将使用std::for_each()作为构建块构建一个parallel_for()算法,从而将复杂的并行性留给std::for_each()

结合 std::for_each()和 std::views::iota()

基于标准库算法的基于索引的for循环可以通过将标准库算法std::for_each()与范围库中的std::views::iota()结合使用来创建(见第六章范围和视图)。它看起来是这样的:

auto v = std::vector<std::string>{"A", "B", "C"};
auto r = std::views::iota(size_t{0}, v.size()); 
std::for_each(r.begin(), r.end(), &v { 
  v[i] += std::to_string(i + 1); 
}); 
// v is now { "A1", "B2", "C3" } 

然后可以通过使用并行执行策略进一步并行化:

std::for_each(std::execution::par, r.begin(), r.end(), &v { 
  v[i] += std::to_string(i + 1); 
}); 

正如前面所述,我们在像这样从多个线程调用的 lambda 中传递引用时必须非常小心。通过仅通过唯一索引i访问向量元素,我们避免了在向量中突变字符串时引入数据竞争。

通过包装简化构造

为了以简洁的语法迭代索引,先前的代码被封装到一个名为parallel_for()的实用函数中,如下所示:

template <typename Policy, typename Index, typename F>
auto parallel_for(Policy&& p, Index first, Index last, F f) {
  auto r = std::views::iota(first, last);
  std::for_each(p, r.begin(), r.end(), std::move(f));
} 

然后可以直接使用parallel_for()函数模板,如下所示:

auto v = std::vector<std::string>{"A", "B", "C"};
parallel_for(std::execution::par, size_t{0}, v.size(),
              & { v[i] += std::to_string(i + 1); }); 

由于parallel_for()是建立在std::for_each()之上的,它接受std::for_each()接受的任何策略。

我们将用一个简短的介绍性概述来总结本章,介绍 GPU 以及它们如何在现在和将来用于并行编程。

在 GPU 上执行算法

图形 处理单元GPU)最初是为了处理计算机图形渲染中的点和像素而设计和使用的。简而言之,GPU 所做的是检索像素数据或顶点数据的缓冲区,对每个缓冲区进行简单操作,并将结果存储在新的缓冲区中(最终将被显示)。

以下是一些可以在早期阶段在 GPU 上执行的简单独立操作的示例:

  • 将点从世界坐标转换为屏幕坐标

  • 在特定点执行光照计算(通过光照计算,我指的是计算图像中特定像素的颜色)

由于这些操作可以并行执行,GPU 被设计用于并行执行小操作。后来,这些图形操作变得可编程,尽管程序是以计算机图形的术语编写的(也就是说,内存读取是以从纹理中读取颜色的形式进行的,结果总是以颜色写入纹理)。这些程序被称为着色器

随着时间的推移,引入了更多的着色器类型程序,着色器获得了越来越多的低级选项,例如从缓冲区中读取和写入原始值,而不是从纹理中读取颜色值。

从技术上讲,CPU 通常由几个通用缓存核心组成,而 GPU 由大量高度专门化的核心组成。这意味着良好扩展的并行算法非常适合在 GPU 上执行。

GPU 有自己的内存,在算法可以在 GPU 上执行之前,CPU 需要在 GPU 内存中分配内存,并将数据从主内存复制到 GPU 内存。接下来发生的事情是 CPU 在 GPU 上启动例程(也称为内核)。最后,CPU 将数据从 GPU 内存复制回主内存,使其可以被在 CPU 上执行的“正常”代码访问。在 CPU 和 GPU 之间来回复制数据所产生的开销是 GPU 更适合批处理任务的原因之一,其中吞吐量比延迟更重要。

今天有几个库和抽象层可用,使得从 C++进行 GPU 编程变得容易。然而,标准 C++在这方面几乎没有提供任何东西。但是,并行执行策略std::execution::parstd::execution::par_unseq允许编译器将标准算法的执行从 CPU 移动到 GPU。其中一个例子是 NVC++,NVIDIA HPC 编译器。它可以配置为将标准 C++算法编译为在 NVIDIA GPU 上执行。

如果您想了解 C++和 GPU 编程的当前状态,我强烈推荐 Michael Wong 在 ACCU 2019 年会议上的演讲使用现代 C++进行 GPU 编程accu.org/video/spring-2019-day-3/wong/)。

总结

在本章中,您已经了解了手工编写并行算法的复杂性。您现在也知道如何分析、测量和调整并行算法的效率。在学习并行算法时获得的见解将加深您对 C++标准库中并行算法的要求和行为的理解。C++带有四种标准执行策略,可以由编译器供应商进行扩展。这为利用 GPU 执行标准算法打开了大门。下一个 C++标准,C++23,很可能会增加对 GPU 并行编程的支持。

您现在已经到达了本书的结尾。恭喜!性能是代码质量的重要方面。但往往性能是以牺牲其他质量方面(如可读性、可维护性和正确性)为代价的。掌握编写高效和干净代码的艺术需要实际训练。我希望您从本书中学到了一些东西,可以将其融入到您的日常生活中,创造出令人惊叹的软件。

解决性能问题通常需要愿意进一步调查事情。往往需要足够了解硬件和底层操作系统,以便能够从测量数据中得出结论。在这本书中,我在这些领域只是浅尝辄止。在第二版中写了关于 C++20 特性之后,我现在期待着开始在我的职业作为软件开发人员中使用这些特性。正如我之前提到的,这本书中呈现的许多代码今天只有部分得到编译器的支持。我将继续更新 GitHub 存储库,并添加有关编译器支持的信息。祝你好运!

分享您的经验

感谢您抽出时间阅读本书。如果您喜欢这本书,帮助其他人找到它。在www.amazon.com/dp/1839216549留下评论。

第十五章:其他你可能喜欢的书籍

如果你喜欢这本书,你可能会对 Packt 的其他书感兴趣:

Modern C++ Programming Cookbook - Second Edition

Marius Bancila

ISBN: 978-1-80020-898-8

  • 了解新的 C++20 语言和库特性以及它们解决的问题

  • 熟练使用标准支持的线程和并发性来处理日常任务

  • 利用标准库并使用容器、算法和迭代器

  • 使用正则表达式解决文本搜索和替换问题

  • 使用不同类型的字符串并学习编译的各个方面

  • 利用文件系统库处理文件和目录

  • 实现各种有用的模式和习语

  • 探索广泛使用的 C++ 测试框架

Extreme C

Kamran Amini

ISBN: 978-1-78934-362-5

  • 在根植于第一原则的坚实基础上建立高级的 C 知识

  • 了解内存结构和编译流水线以及它们的工作原理,以及如何充分利用它们

  • 将面向对象的设计原则应用到过程化的 C 代码中

  • 编写接近硬件的低级代码,并从计算机系统中挤取最大性能

  • 掌握并发、多线程、多进程和与其他语言的集成

  • C 编程的单元测试和调试、构建系统和进程间通信

posted @ 2024-05-04 22:46  绝不原创的飞龙  阅读(299)  评论(0编辑  收藏  举报