C---设计模式实用指南-全-

C++ 设计模式实用指南(全)

原文:zh.annas-archive.org/md5/5df30dc5dcbc557ce09d30b4c7fbe454

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

当考虑这本书时,你们中的一些人可能会问:又有一本关于 C++设计模式的书籍?为什么是现在,为什么是这个?关于设计模式的所有知识不是都已经 被写出来了?

写了关于设计模式的又一本书,有几个原因,但首先,这是一本非常典型的 C++书籍——这不是一本关于 C++中设计模式的书,而是一本关于在 C++中设计模式的书,这种强调使其与众不同。C++拥有传统面向对象语言的所有功能,因此所有经典面向对象设计模式,如工厂模式和策略模式,都可以在 C++中实现。本书涵盖了其中的一些。但是,当你利用其泛型编程能力时,C++的强大功能才得以实现。记住,设计模式是经常出现的设计挑战和普遍接受的解决方案——模式的两面同样重要。从逻辑上讲,当新的工具变得可用时,新的解决方案也成为可能。随着时间的推移,社区将一些解决方案确定为最有利的整体解决方案,从而产生了旧设计模式的新变体——相同的挑战,但不同的首选解决方案。但是,扩展功能也开辟了新的领域——有了我们手中的新工具,新的设计挑战也随之产生。

另一些人可能会期待找到经典书籍“《设计模式:可复用面向对象软件元素》”的新版本。但这“不是那本书,我也不认为现在是出版这样一本书的好时机”。“四人帮”的书籍是新颖的,甚至是革命性的,它将设计模式的语言引入了广泛的编程社区。这已经一劳永逸地完成了。它还建立了一个整洁的设计模式分类法,但这一部分并没有像我们想象的那么经久不衰:随着我们的模式词汇量的扩大,我们发现有些模式并不容易融入特定的类别。我们还扩展了设计模式的概念,超越了面向对象编程,并发现其中一些模式与面向对象的近亲非常相似,而另一些则是全新的、不同的。此外,在 C++和其他具有显著不同功能的语言中,解决相同需求的设计模式可能看起来完全不同。总的来说,原始的模式分类法虽然仍然有用,但现在有更多的例外而不是典型例子,模式景观变得过于分歧,以至于新的分类法似乎过于人为。也许随着时间的推移,当我们进一步发展这门艺术时,从对扩展模式景观的鸟瞰中可能会出现新的趋势,但这种情况尚未发生。

这本书的目标不那么雄心勃勃,但非常实用。在这本书中,我们专注于 C++至少对模式的两面都有重要贡献的设计模式。一方面,我们有像访问者(Visitor)这样的模式,C++的泛型编程能力允许有更好的解决方案。这种更好的解决方案是通过语言从 C++11 到 C++17 的演变中添加的新特性而实现的。另一方面,泛型编程仍然是编程(只是程序的执行发生在编译时);编程需要设计,而设计有常见的挑战,这些挑战与传统编程的挑战并不完全不同。因此,许多传统模式在泛型编程中都有其双胞胎,或者至少是近亲,我们在这本书中主要关注这些模式。一个主要的例子是策略模式(Strategy pattern),在泛型编程社区中更广为人知的是它的别名,策略模式(Policy pattern)。此外,随着语言中添加了新特性,它可以提供解决新问题或旧问题的解决方案,这两种解决方案最终都发展成为设计模式。这是 C++协程的情况,它在最后一章中有所体现。

最后,像 C++这样复杂的语言必然会有一些独特的特性,这些特性往往会导致 C++特有的挑战,这些挑战有共同或标准的解决方案。虽然这些 C++特有的惯用用法不完全值得被称为模式,但本书也涵盖了这些惯用用法。

关于第二版的一些改动:首先,新增了一章关于并发模式的章节。所有示例都已更新,以使用 C++17 或 C++20,只要合理,但绝不为追求而使用。许多模式、惯用用法以及展示它们使用的示例都随着最近几年的发展进行了更新——这是 C++编程社区工作的结果。

所有这些话,这本书被写出来的主要有三个原因:

  • 为了涵盖针对其他通用、经典设计模式的 C++特定解决方案。

  • 展示在新的泛型编程领域中,当旧的设计挑战出现时,C++特定的模式变体。

  • 为了使我们的模式与语言的演变保持同步。

这本书面向的对象

这本书旨在为想要从社区智慧中学习的 C++程序员提供帮助——从公认的优良解决方案到常见的设计问题。另一种说法是,这本书是程序员从他人错误中学习的一种方式。

这不是一本学习C++的书籍;目标读者主要是对工具和语言语法有相当了解的程序员,他们更感兴趣的是了解这些工具应该如何以及为什么应该被使用。然而,这本书对想要了解更多关于 C++的程序员来说也将非常有用,尤其是那些希望他们的学习能够通过具体和实际例子来指导的程序员(对于这样的程序员,我们建议同时手头备有一本 C++参考书)。最后,对于那些不仅想了解 C++17 或 C++20 中有什么新内容,还想了解所有这些新功能可以用来做什么的程序员,这本书也许也能提供一些启发。

本书涵盖的内容

第一章“继承和多态简介”,简要概述了 C++的面向对象特性。本章的目的不是作为 C++面向对象编程的参考,而是突出其对于后续章节重要性的方面。

第二章“类和函数模板”,概述了 C++的泛型编程功能——类模板、函数模板和 lambda 表达式。本章涵盖了模板实例化和特化,以及模板函数参数推导和重载解析,为后续章节中更复杂的模板使用做准备。

第三章“内存和所有权”,描述了在 C++中表达不同类型内存所有权的现代惯用法。这是一系列约定或惯用法——编译器不会强制执行这些规则,但如果程序员使用共享的惯用法词汇,他们将更容易理解彼此。

第四章“从简单到微妙地交换”,探讨了 C++中最基本的一个操作,即两个值的交换或交换操作。这一操作与其他在章节中讨论的 C++特性有着惊人的复杂交互。

第五章“全面了解 RAII”,详细探讨了 C++的一个基本概念,即资源管理,并介绍了可能是最受欢迎的 C++惯用法,即 RAII,这是 C++管理资源的标准方法。

第六章“理解类型擦除”,深入探讨了 C++中一种已经存在很长时间的技术,自从 C++11 引入以来,其受欢迎程度和重要性也在不断增长。类型擦除允许程序员编写不显式提及某些类型的抽象程序。

第七章**,SFINAE、概念和重载解析管理,讨论了 SFINAE——一种 C++惯用语,一方面对于 C++中模板的使用是必不可少的,而且“恰好”透明地使用,另一方面,在有意使用时,需要对 C++模板有非常深入和微妙的理解。

第八章**,奇特重复的模板模式,描述了一种令人着迷的基于模板的模式,它结合了面向对象编程的优点和模板的灵活性。本章解释了该模式,并教你如何正确地使用它来解决实际问题。最后,本章为你准备在后续章节中识别这种模式。

第九章**,命名参数、方法链和构建者模式,介绍了一种在 C++中调用函数的不寻常技术,使用命名参数而不是位置参数。这是我们在每个 C++程序中隐式使用的那些惯用语之一,但它的明确目的性使用需要一些思考。

第十章**,局部缓冲区优化,是本书中唯一纯粹以性能为导向的章节。性能和效率是影响语言本身每个设计决策的关键考虑因素——在标准被接受之前,没有一项特性不是从效率的角度进行审查的。为提高 C++程序的性能而使用的常见惯用语,设立一个章节是公平的。

第十一章**,作用域保护,介绍了一种几乎在 C++最新版本中无法辨认的旧 C++模式。本章教你如何轻松编写异常安全或更普遍的错误安全代码。

第十二章**,友元工厂,描述了另一种在现代 C++中找到新用途的旧模式。这种模式用于生成与模板相关的函数,例如每个由模板生成的类型的算术运算符。

第十三章**,虚构造函数和工厂,涵盖了应用于 C++的另一个经典面向对象编程模式,即工厂模式。在这个过程中,本章还展示了如何从 C++构造函数中获得多态行为的外观,尽管构造函数不能是虚的。

第十四章**,模板方法模式和非常量惯用语,描述了经典面向对象模式、模板和非常 C++中心的惯用语之间的有趣交叉。它们共同形成了一个模式,描述了在 C++中最佳使用虚函数的方法。

第十五章**,基于策略的设计,涵盖了 C++设计模式中的瑰宝之一,即策略模式(更常见的是策略模式),它在编译时应用,即作为一种泛型编程模式,而不是面向对象模式。

第十六章**,适配器和装饰器,讨论了两种非常广泛且密切相关的设计模式,它们适用于 C++。本章考虑了这些模式在面向对象设计以及泛型程序中的应用。

第十七章**,访问者模式和多重分派,通过永受欢迎的访问者模式结束我们的经典面向对象编程模式系列。本章解释了该模式本身,然后重点介绍了现代 C++如何使访问者模式的实现更加简单、健壮和错误率更低。

第十八章**,并发模式,是本书的新增内容。虽然 C++在 C++11 为我们提供“官方”工具之前就已经被用来编写并发程序,但广泛的特定问题解决方案使得识别常见模式变得困难。本章介绍了成为设计 C++并发软件基本构建块的模式。

要充分利用本书

要运行本书中的示例,您需要一个运行 Windows、Linux 或 macOS 的计算机(C++程序可以在像树莓派这样小的设备上构建)。您还需要一个支持 C++语言至 C++20 的现代 C++编译器,如 GCC、Clang、Visual Studio 或其他编译器。您还需要具备 GitHub 和 Git 的基本知识,以便克隆带有示例的项目。

下载示例代码文件

您可以从 GitHub(github.com/PacktPublishing/Hands-On-Design-Patterns-with-CPP-Second-Edition/)下载本书的示例代码文件。如果代码有更新,它将在 GitHub 仓库中更新。

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

使用的约定

本书使用了多种文本约定。

文本中的代码: 表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“insert()函数的实现必须将记录插入存储和索引中,别无他法。”

代码块应如下设置:

class Database {
  class Storage { ... };    // Disk storage Storage S;
  class Index { ... };    // Memory index Index I;
  public:
  void insert(const Record& r);
  ...
};

任何命令行输入或输出都应如下编写:

Benchmark                              Time
-------------------------------------------
BM_delete_explicit                  4.54 ns
BM_delete_type_erased               13.4 ns
BM_delete_type_erased_fast          12.7 ns
BM_delete_template                  4.56 ns

小贴士或重要注意事项

看起来像这样。

联系我们

我们读者的反馈总是受欢迎的。

一般反馈:如果您对本书的任何方面有任何疑问,请通过电子邮件与我们联系 customercare@packtpub.com,并在邮件主题中提及书名。

勘误表:尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在本书中发现错误,我们将不胜感激,如果您能向我们报告,我们将不胜感激。请访问www.packtpub.com/support/errata并填写表格。

盗版:如果您在互联网上以任何形式发现我们作品的非法副本,我们将不胜感激,如果您能提供位置地址或网站名称,我们将不胜感激。请通过 copyright@packt.com 与我们联系,并提供材料的链接。

如果您有兴趣成为作者:如果您在某个主题上具有专业知识,并且您有兴趣撰写或为本书做出贡献,请访问authors.packtpub.com

分享您的想法

一旦您阅读了《动手实践 C++设计模式(第 2 版)》,我们很乐意听听您的想法!请点击此处直接访问亚马逊评论页面并分享您的反馈。

您的评论对我们和科技社区非常重要,并将帮助我们确保我们提供高质量的内容。

下载本书的免费 PDF 副本

感谢您购买本书!

您喜欢在路上阅读,但无法携带您的印刷书籍到处走?

您的电子书购买是否与您选择的设备不兼容?

别担心,现在,随着每本 Packt 书籍,您都可以免费获得该书的 DRM 免费 PDF 版本。

在任何地方、任何设备上阅读。直接从您最喜欢的技术书籍中搜索、复制和粘贴代码到您的应用程序中。

优惠远不止于此,您还可以获得独家折扣、时事通讯和每日免费内容的每日电子邮件。

按照以下简单步骤获取这些好处:

  1. 扫描下面的二维码或访问以下链接

https://packt.link/free-ebook/9781804611555

  1. 提交您的购买证明

  2. 就这样!我们将直接将免费 PDF 和其他好处发送到您的电子邮件。

第一部分:C++特性和概念入门

本部分介绍了 C++面向对象编程、泛型编程以及一些其他高级语言工具的特性,这些工具对于您理解本书的其余部分是必要的。我们还讨论了语言强加的一些更令人烦恼的限制:我们在后续章节中展示的许多模式只是对这些限制的普遍认可解决方案。这并不是对任何特性的完整指南,而是帮助本书作为一个面向程序员的实战指南更加自包含。本部分包含以下章节:

  • 第一章, 继承与多态简介

  • 第二章, 类与函数模板

  • 第三章, 内存与所有权

第一章:继承和多态简介

C++首先是一种面向对象的语言,对象是 C++程序的基本构建块。类层次结构用于表达软件系统不同部分之间的关系和交互,定义和实现组件之间的接口,以及组织和代码。虽然这不是一本专门教授 C++的书,但本章的目的是让读者对与类和继承相关的 C++语言特性有足够的了解,这些特性将在后面的章节中使用。为此,我们不会试图完全描述用于处理类的 C++工具,而是介绍本书中将使用的概念和语言结构。

本章将涵盖以下主题:

  • 什么是类以及它们在 C++中的作用?

  • 什么是类层次结构以及 C++是如何使用继承的?

  • 运行时多态是什么以及如何在 C++中使用它?

类和对象

面向对象编程是一种通过将算法及其操作的数据组合成单个实体(称为对象)来结构化程序的方法。大多数面向对象的语言,包括 C++,都是基于类的。是对对象的定义——它描述了算法和数据,其格式以及与其他类的关系。对象是类的具体实例化,即变量。对象有一个地址,它是内存中的一个位置。类是用户定义的类型。一般来说,可以从类提供的定义中实例化任意数量的对象(有些类限制了可以创建的对象数量,但这是一种例外,而不是常态)。

在 C++中,类中包含的数据被组织为不同类型的数据成员或变量的集合。算法通过函数实现——类的成员方法。虽然没有语言要求类中的数据成员必须以某种方式与方法的实现相关,但当数据在类中得到良好的封装,并且方法与外部数据的交互有限时,这是良好设计的一个标志。

这种封装的概念是 C++ 中类的基础——该语言允许我们控制哪些数据成员和方法是公共的——在类外可见,哪些是内部的——私有的。一个设计良好的类主要或只有私有数据成员,唯一的公共方法是需要表达类的公共接口的方法——换句话说,类做什么。这个公共接口就像一份合同——类的设计者承诺这个类提供某些特性和操作。类的私有数据和方法是实现的一部分,只要公共接口,我们承诺的合同保持有效,它们就可以被更改。例如,以下类表示一个有理数并支持增量操作,如其公共接口所公开的:

class Rational { public:
  Rational& operator+=(const Rational& rhs);
};

一个设计良好的类不会通过其公共接口暴露比它必须暴露的更多实现细节。实现不是合同的一部分,尽管文档化的接口可能对它施加一些限制。例如,如果我们承诺所有有理数在分子和分母中不包含任何公共的乘数,那么加法应该包括取消这些乘数的步骤。这将是一个很好的使用私有成员函数的例子——实现其他几个操作将需要调用它,但类的客户端永远不需要调用它,因为每个有理数在暴露给调用者之前都已经化简到最简形式:

class Rational {
  public:
  Rational& operator+=(const Rational& rhs); private:
  long n_; // numerator
  long d_; // denominator
  void  reduce();
};
Rational& Rational::operator+=(const Rational& rhs) {
  n_ = n_*rhs.d_ + rhs.n_*d_;
  d_ = d_*rhs.d_; reduce();
  return *this;
}
Rational a, b; a += b;

类方法可以特殊访问数据成员——它们可以访问类的私有数据。注意这里类和对象的区别——operator+=()Rational 类的方法,并在对象 a 上调用。然而,它也可以访问 b 对象的私有数据,因为 ab 是同一类的对象。如果一个成员函数通过名称引用类成员而没有任何额外的限定符,那么它是在访问它所调用的同一类的成员(我们可以通过写入 this->n_this->d_ 来使其更明确)。访问同一类中另一个对象的成员需要一个指向该对象的指针或引用,但除此之外没有其他限制,就像我们尝试从一个非成员函数访问私有数据成员时的情况一样。

顺便说一下,C++ 也支持 C 风格的结构体。但在 C++ 中,结构体不仅仅是一个数据成员的聚合——它可以有方法、公有和私有访问修饰符,以及类具有的一切。从语言的角度来看,类和结构体之间的唯一区别是默认访问——在类中,所有成员和方法默认为私有,而在结构体中它们是公有的。除此之外,使用结构体而不是类是一个约定问题——传统上,结构体用于 C 风格的结构体(在 C 中合法的结构体)以及几乎是 C 风格的结构体,例如,只添加了构造函数的结构体。当然,这个边界并不精确,是每个项目或团队编码风格和实践的问题。

除了我们看到的 方法和数据成员之外,C++ 还支持静态数据和静态方法。静态方法与常规的非成员函数非常相似——它不会在特定的对象上调用,它能够访问任何类型的对象的方式只有通过其参数。然而,与非成员函数不同,静态方法保留了其对类私有数据的特权访问。

类本身是一种将算法和它们操作的数据(封装)在一起的有用方式,并限制对某些数据的访问。然而,C++ 最强大的面向对象特性是继承和由此产生的类层次。

继承和类层次

C++ 中的类层次具有双重作用。一方面,它们允许我们表达对象之间的关系。另一方面,它们使我们能够从更简单的类型组合出更复杂的类型。这两种用途都是通过继承实现的。

继承的概念是 C++ 中类和对象使用的关键。继承使我们能够将新类定义为现有类的扩展。当一个派生类从基类继承时,它以某种形式包含了基类中的所有数据和算法,并添加了一些自己的。在 C++ 中,区分两种主要的继承类型——公有继承和私有继承——非常重要。

公有继承继承了类的公共接口。它也继承了实现——基类的数据成员也是派生类的一部分。但接口的继承是区分公有继承的关键——派生类作为其公共接口的一部分,包含了基类的公共成员函数。

记住,公共接口就像一份契约——我们向类的客户端承诺它支持某些操作,维护某些不变性,并遵守指定的限制。通过从基类公开继承,我们将派生类绑定到相同的契约(以及如果我们决定定义额外的公共接口,契约的任何扩展)。因为派生类也尊重基类的接口契约,所以我们可以将派生类用于代码中任何期望基类的地方——我们无法使用接口的任何扩展(代码期望基类,我们不知道在那个点有任何扩展),但基类接口及其限制必须是有效的。

这通常被表达为“is-a 原则”——派生类的实例也是基类的实例。然而,我们在 C++中对“is-a”关系的解释并不完全直观。例如,正方形是矩形吗?如果是的话,那么我们可以从Rectangle类派生出Square类:

class Rectangle {
  public:
  double Length() const { return length_; }
  double Width() const { return width_; }
  ...
  private:
  double l_;
  double w_;
};
class Square : public Rectangle {
  ...
};

立刻就有一些事情看起来不对——派生类有两个表示尺寸的数据成员,但实际上只需要一个。我们必须以某种方式强制它们始终相同。这看起来并不那么糟糕——Rectangle类有一个接口,允许长度和宽度的任何正值,而Square施加了额外的限制。但问题更严重——Rectangle类有一个允许用户使尺寸不同的契约。这可以非常明确:

class Rectangle {
  public:
  void Scale(double sl, double sw) {
     // Scale the dimensions
    length_ *= sl;
    width_ *= sw;
  }
  ...
};

现在,我们有一个公共方法,允许我们扭曲矩形,改变其宽高比。与其他任何公共方法一样,它被派生类继承,所以现在Square类也有这个方法。实际上,通过使用公共继承,我们断言一个Square对象可以在任何需要Rectangle对象的地方使用,即使不知道它实际上是一个Square。显然,这是一个我们无法兑现的承诺——当我们的类层次结构的客户端试图改变正方形的宽高比时,我们无法做到。我们可以忽略这个调用或在运行时报告错误。无论如何,我们都违反了基类提供的契约。唯一的解决方案是——在 C++中,正方形不是矩形。注意,矩形通常也不是正方形——Square接口提供的契约可能包含任何我们无法维持的保证,如果我们从Square派生出Rectangle类。

类似地,在 C++中,如果鸟类接口包括飞行,那么企鹅不是鸟类。对于这种情况的正确设计通常包括一个更抽象的基类Bird,它不做出任何承诺,即至少有一个派生类无法保持(例如,Bird对象不保证它可以飞行)。然后,我们创建基于中间类的类,例如FlyingBirdFlightlessBird,这些类是从公共基类派生出来的,并作为更具体类(如EaglePenguin)的基类。这里的重要教训是,企鹅在 C++中是否是鸟类取决于我们如何定义鸟类,或者用 C++术语来说,Bird类的公共接口是什么。

由于公有继承隐含了is-a关系,语言允许在同一层次结构中不同类的引用和指针之间进行广泛的转换。首先,从派生类指针到基类指针的转换是隐式的(这同样适用于引用):

class Base { ... };
class Derived : public Base { ... };
Derived* d = new Derived;
Base* b = d;    // Implicit conversion

这种转换始终有效,因为派生类的实例也是基类的实例。逆转换是可能的,但必须明确进行:

Base* b = new Derived;     // *b is really Derived
Derived* d = b; // Does not compile, not implicit Derived*
Derived* d1 =
     static_cast<Derived*>(b);    // Explicit conversion

这个转换不是隐式的,因为只有在基类指针确实指向一个派生对象时才是有效的(否则行为是未定义的)。因此,程序员必须显式地断言,通过程序的逻辑、先前的测试或其他方式,已知这种转换是有效的。如果你不确定转换是否有效,有一种更安全的方法来尝试它而不会导致未定义的行为;我们将在下一节中了解这一点。

注意,基类和派生类指针之间的静态(或隐式)转换并不像你想象的那么简单。任何对象的第一个基类始终具有与派生对象本身相同的地址,但之后事情就变得复杂了。对于具有多个基类的派生类的内存布局通常没有标准要求:

class Base1 { ... };
class Base2 { ... };
class Derived : public Base1, public Base2 { ... };

大多数编译器会首先布局基类,然后是派生类的数据成员:

图 1.1 – 派生类的可能内存布局

图 1.1可以看出,基类和派生类之间的指针转换通常涉及偏移量计算。我们可以在一个例子中轻松地看到这一点:

// Example 01_cast.C
Derived d;
Derived* p = &d;
std::cout << "Derived: " << (void*)(p) <<
  " Base1: " << (void*)(static_cast<Base1*>(p)) <<
  " Base2: " << (void*)(static_cast<Base2*>(p)) <<
  std::endl;

程序会打印出类似以下内容:

Derived: 0x7f97e550 Base1: 0x7f97e550 Base2: 0x7f97e560

你可以看到 Base1 对象位于与 Derived 对象相同的地址,而 Base2 从一个偏移量(在我们的例子中是 16 字节)开始。看起来类型转换是一个简单的计算:如果你有一个指向 Derived 的指针,并且想要转换到 Base2,就加 16。基类之间的偏移量在编译时是已知的,编译器知道它使用的布局。指针偏移量计算通常在硬件中实现(所有现代 CPU 都支持它们,并且不需要单独的加法指令)。这听起来并不那么困难。

现在,如果指针是 null,你会怎么做?指针的值为 0。如果你应用相同的 转换,你会得到 16 (0x10),现在你的 null 检查失败了:

void f(Base2* p) {
  if (p != nullptr) do_work(*p);
}
Derived* p = nullptr;
f(p); // Will it try to dereference 0x10?

显然,这会很糟糕,因此我们可以假设 null 指针仍然保持原样。确实如此:

Derived* p = nullptr;
std::cout << "Derived: " << (void*)(p) <<
  " Base1: " << (void*)(static_cast<Base1*>(p)) <<
  " Base2: " << (void*)(static_cast<Base2*>(p)) <<
  std::endl;

这会为所有指针打印相同的值:

Derived: 0x0 Base1: 0x0 Base2: 0x0

这是进行类型转换的唯一方法,但它意味着从 Derived*Base* 的简单隐式转换隐藏在一个带有 null 指针检查的条件计算中。

C++ 中的另一种继承类型是 私有继承。当私有继承时,派生类不会扩展基类的公共接口——所有基类方法在派生类中都变为私有。任何公共接口都必须由派生类创建,从一张白纸开始。没有假设派生类的对象可以替代基类的对象。派生类从基类获得的是实现细节——方法和数据成员都可以由派生类用来实现自己的算法。因此,可以说私有继承实现了 has-a 关系——派生对象在其内部包含基类的一个实例。

因此,私有派生类与其基类之间的关系类似于类与其数据成员之间的关系。后者实现技术被称为 using 声明:

class Container : private std::vector<int> {
  public:
  using std::vector<int>::size;
  ...
};

这种方法在罕见情况下可能很有用,但它也相当于一个内联转发函数:

class Container {
  private:
  std::vector<int> v_;
  public:
  size_t size() const { return v_.size(); }
  ...
};

其次,派生对象的指针或引用可以转换为基对象的指针或引用,但仅限于派生类成员函数内部。同样,组合的等效功能是通过取数据成员的地址来提供的。到目前为止,我们还没有看到使用私有继承的好理由,确实,常见的建议是优先考虑组合。但接下来的两个理由更为重要,任何一个都可能是使用私有继承的动机。

使用私有继承的一个好理由与组合或派生对象的大小有关。基类只提供方法而不提供数据成员的情况并不少见。这样的类没有自己的数据,因此不应该占用任何内存。但在 C++中,它们必须被赋予一个非零的大小。这与任何两个不同的对象或变量都必须有不同的唯一地址的要求有关。通常,如果我们连续声明两个变量,第二个变量的地址将是第一个变量的地址加上第一个变量的大小:

int x;     // Created at address 0xffff0000, size is 4
int y;     // Created at address 0xffff0004

为了避免需要以不同的方式处理零大小对象,C++ 将空对象的大小分配为 1。如果这样的对象被用作类的数据成员,它至少占用 1 个字节(下一个数据成员的对齐要求可能会增加这个值)。这是浪费的内存;它永远不会被用于任何事情。另一方面,如果空类被用作基类,没有要求对象的部分必须有非零的大小。派生类的整个对象必须有非零的大小,但派生对象的地址、其基对象和其第一个数据成员都可以在同一个地址。因此,在 C++中,即使 sizeof() 返回 1,也可以为空基类分配零内存。虽然这是合法的,但这种空基类优化不是必需的,并且被认为是一种优化。尽管如此,大多数现代编译器都会进行这种优化:

class Empty {
  public:
  void useful_function();
};
class Derived : private Empty {
  int i;
};    // sizeof(Derived) == 4
class Composed {
  int i;
  Empty e;
};    // sizeof(Composed) == 8

如果我们创建许多派生对象,空基优化可以节省显著的内存。

使用私有继承的第二个理由与虚函数有关,这将在下一节中解释。

多态性和虚函数

当我们之前讨论公有继承时,我们提到派生对象可以在任何期望基对象的地方使用。即使有这个要求,了解对象的实际类型通常很有用——换句话说,对象被创建的类型:

Derived d;
Base& b = d;
...
b.some_method(); // b is really a Derived object

some_method()Base 类公共接口的一部分,并且对于 Derived 类也必须有效。但是,在基类接口合同允许的灵活性内,它可以执行不同的操作。作为一个例子,我们之前已经使用鸟类层次结构来表示不同的鸟类,特别是会飞的鸟类。可以假设 FlyingBird 类有一个 fly() 方法,并且从它派生出的每个特定鸟类类都必须支持飞行。但是老鹰和秃鹫的飞行方式不同,因此 EagleVulture 两个派生类中 fly() 方法的实现可以不同。任何操作任意 FlyingBird 对象的代码都可以调用 fly() 方法,但结果将取决于对象的实际类型。

这种功能在 C++中是通过虚函数实现的。必须在一个基类中声明虚公有函数:

class FlyingBird : public Bird {
  public:
  virtual void fly(double speed, double direction) {
    ... move the bird at the specified speed
        in the given direction ...
  }
  ...
};

派生类继承了该函数的声明和实现。必须遵守声明及其提供的契约。如果实现满足派生类的需求,则无需做任何事情。但如果派生类需要更改实现,它可以覆盖基类的实现:

class Vulture : public FlyingBird {
  public:
  virtual void fly(double speed, double direction) {
    ... move the bird but accumulate
        exhaustion if too fast ...
  }
};

注意,当在派生类中使用关键字virtual来覆盖基类虚函数的方法时,这是完全可选的,并且没有任何效果;我们稍后会看到省略它的原因。

当调用虚函数时,C++运行时系统必须确定对象的实际类型。通常,这种信息在编译时并不为人所知,必须在运行时确定:

void hunt(FlyingBird& b) {
  b.fly(...);    // Could be Vulture or Eagle
  ...
};
Eagle e;
hunt(e);   // Now b in hunt() is Eagle
           // FlyingBird::fly() is called
Vulture v;
hunt(v);   // Now b in hunt() is Vulture
           // Vulture::fly() is called

一种编程技术,其中某些代码可以作用于任意数量的基类对象,并调用相同的方法,但结果取决于这些对象的实际类型,这种技术被称为运行时多态,支持这种技术的对象被称为多态。在 C++中,多态对象必须至少有一个虚函数,并且只有它们接口中使用虚函数实现的部分才是多态的。

从这个解释中应该很明显,虚函数的声明及其重写应该是相同的——程序员在基类对象上调用该函数,但运行的是派生类中实现的版本。这只有在两个函数具有相同的参数和返回类型的情况下才会发生。一个例外是,如果基类中的虚函数返回某种类型的指针或对该类型的对象的引用,则重写可以返回对该类型派生对象的指针或对该类型派生对象的引用(这被称为协变****返回类型)。

多态层次结构的一个非常常见的特殊情况是基类没有好的默认虚函数实现。例如,所有飞行的鸟都会飞,但它们的飞行速度各不相同,因此没有必要选择一个速度作为默认值。在 C++中,我们可以拒绝在基类中为虚函数提供任何实现。

这样的函数被称为纯虚函数,任何包含纯虚函数的基类都被称为抽象类

class FlyingBird {
  public:
  virtual void fly(...) = 0;     // Pure virtual function
};

抽象类仅定义了一个接口;具体派生类的任务是实现它。如果基类包含一个纯虚函数,程序中实例化的每个派生类都必须提供实现。换句话说,不能创建基类对象(派生类也可以是抽象类,但那时也不能直接实例化,我们必须从它派生另一个类)。然而,我们可以有一个指向基类对象的指针或引用——它们实际上指向派生类,但我们可以通过基类接口来操作它。

关于 C++语法的几点说明——在重写虚函数时,不需要重复virtual关键字。如果基类声明了一个具有相同名称和参数的虚函数,派生类中的函数将始终是虚函数,并覆盖基类中的函数。注意,如果参数不同,派生类函数不会覆盖任何内容,而是会隐藏基类函数的名称。这可能导致程序员意图覆盖基类函数但未正确复制声明的微妙错误:

class Eagle : public FlyingBird {
  public:
  void fly(int speed, double direction);
};

在这里,参数的类型略有不同。Eagle::fly()函数也是虚函数,但它没有覆盖FlyingBird::fly()。如果后者是一个纯虚函数,错误将被捕获,因为每个纯虚函数都必须在派生类中实现。但如果FlyingBird::fly()有默认实现,那么错误将不会被编译器检测到。C++11 提供了一个非常有用的功能,可以极大地简化查找此类错误——任何打算覆盖基类虚函数的函数都可以使用override关键字声明:

class Eagle : public FlyingBird {
  public:
  void fly(int speed, double direction) override;
};

virtual关键字仍然是可选的,但如果FlyingBird类没有我们可以用此声明覆盖的虚函数,则此代码无法编译。

还可以通过将虚函数声明为final来防止派生类覆盖虚函数:

class Eagle : public FlyingBird {
  public:
  // All Eagles fly the same way, derived classes BaldEagle
  // and GoldenEagle cannot change this.
  void fly(int speed, double direction) final;
};

注意,使用final关键字的情况很少:通常情况下,设计不需要从这一点开始禁用层次结构中的自定义设置。final关键字也可以应用于整个类:这意味着不能从这个类派生出更多的类。同样,这也是一种罕见的情况。

那么,是否应该在覆盖时使用virtual关键字呢?这是一个风格问题,但风格会影响代码的可读性和可维护性。以下是一种推荐的做法:

  • 任何不覆盖基类中函数的虚函数都必须使用virtual关键字。这包括没有基类的类中的函数和在派生类中添加的函数。

  • 任何其他虚拟函数都不应该使用 virtual 关键字。所有覆盖都应该使用 override 关键字,以下例外除外,这也是另一条规则。

  • 最终覆盖必须使用 final 关键字,并且不应该使用 override 关键字。

这种方法有两个优点。第一个是清晰度和可读性:如果你看到 virtual,这是一个没有覆盖任何内容的虚拟函数。如果你看到 override,这必须是一个覆盖(否则代码将无法编译)。如果你看到 final,这也是一个覆盖(否则代码将无法编译),并且是层次结构中的最后一个。第二个优点在代码维护期间显现出来。维护层次结构最大的问题之一是基类脆弱性:你编写了一组基类和派生类,其他人随后向基类函数添加了一个参数,突然间,所有派生类函数都没有覆盖基类函数,并且永远不会被调用。通过一致地使用 override 关键字,这种情况不会发生。

虚拟函数最常见的使用是在使用公共继承的层次结构中——因为每个派生对象也是一个基类对象(is-a 关系),程序通常可以像处理同一类型的对象集合一样处理派生对象集合,而虚拟函数覆盖确保对每个对象都执行正确的处理:

void MakeLoudBoom(std::vector<FlyingBird*> birds)
  for (auto bird : birds) {
    bird->fly(...);   // Same action, different results
  }
}

但虚拟函数也可以与私有继承一起使用。这种用法不太直接(并且远不如公共继承常见)——毕竟,通过私有继承派生的对象无法通过基类指针访问(私有基类被称为不可访问基类,尝试将派生类指针强制转换为基类指针将失败)。然而,有一种情况下这种转换是被允许的,那就是在派生类的成员函数内部。那么,从私有继承的基类到派生类的虚拟函数调用可以这样安排:

class Base {
  public:
  virtual void f() {
      std::cout << "Base::f()" << std::endl;
    }
  void g() { f(); }
};
class Derived : private Base {
  public:
  virtual void f() {
    std::cout << "Derived::f()" << std::endl;
  }
  void h() { g(); }
};
Derived d;
d.h(); // Prints "Derived::f()"

Base 类的公共方法在 Derived 类中变为私有,因此我们无法直接调用它们。然而,我们可以从 Derived 类的另一个方法中调用它们,例如公共方法 h()。然后我们可以直接从 h() 中调用 f(),但这并不能证明什么——如果 Derived::h() 调用了 Derived::f(),这并不会让人感到惊讶。

相反,我们调用从 Base 类继承的 Base::g() 函数。在该函数内部,我们处于 Base 类中——这个函数的主体可能是在 Derived 类实现之前编写和编译的。然而,在这个上下文中,虚拟函数覆盖仍然可以正确工作,并且会调用 Derived::f(),就像继承是公共的一样。

在上一节中,我们建议,除非有其他原因,否则优先使用组合而不是私有继承。没有好的方法使用组合来实现类似的功能;因此,如果需要虚函数行为,私有继承是唯一的选择。

具有虚方法的类必须将其类型编码到每个对象中——这是在运行时知道对象在构造时的类型,在我们将指针转换为基类指针并丢失任何其他原始类型信息之后的唯一方法。这种类型信息不是免费的;它需要空间——多态对象总是比具有相同数据成员但没有虚函数的对象大(通常是一个指针的大小)。

额外的空间大小并不取决于类有多少个虚函数——只要有一个,类型信息就必须编码在对象中。现在,回想一下,基类指针可以被转换为派生类指针,但前提是我们知道派生类的正确类型。使用静态转换,我们无法测试我们的知识是否正确。对于非多态类(没有虚函数的类),没有更好的方法;一旦它们的原始类型丢失,就无法恢复。但对于多态对象,类型信息编码在对象中,因此必须有一种方法来使用这些信息来检查我们的假设是否正确,即这个派生对象的真实类型是什么。确实,有一种方法。它由动态转换提供:

class Base { ... };
class Derived : public Base { ... };
Base* b1 = new Derived;     // Really Derived
Base* b2 = new Base;   // Not Derived
Derived* d1 = dynamic_cast<Derived*>(b1);  // Succeeds
Derived* d2 = dynamic_cast<Derived*>(b2);  // d2 == nullptr

动态转换不会告诉我们对象的真正类型;相反,它允许我们提出问题——这个对象的真正类型是Derived吗?如果我们的类型猜测正确,转换成功并返回派生对象的指针。如果真实类型是其他类型,转换失败并返回一个null指针。动态转换也可以与引用一起使用,效果相似,但有一个例外——没有null引用。返回引用的函数必须始终返回对某个有效对象的引用。由于动态转换无法在请求的类型与实际类型不匹配时返回对有效对象的引用,唯一的替代方案是抛出异常。

对于注重性能的代码,了解动态转换的运行时成本非常重要。天真地认为,虚拟函数调用和动态转换花费的时间差不多:两者都归结为同一个问题——这个指向Base的指针是否真的是指向Derived的指针?一个简单的基准测试表明并非如此:

// Example 02_dynamic_cast.C
class Base {
  protected:
  int i = 0;
  public:
  virtual ~Base() {}
  virtual int f() { return ++i; }
};
class Derived : public Base {
  int f() override { return --i; }
};
Derived* p = new Derived;
// Measure the runtime of p->f();
// Measure the runtime of dynamic_cast<Derived*>(p);

基准结果应该看起来像这样(绝对数值将取决于硬件):虚函数调用为 1 纳秒,动态转换为 5 到 10 纳秒。为什么动态转换如此昂贵?在我们能够回答这个问题之前,我们需要更多地了解层次结构。

到目前为止,我们只限制了自己使用一个基类。虽然如果我们想象它们作为树,基类是根,分支是多个类从同一个基类派生,这样思考类层次结构会容易得多,但 C++并没有强加这样的限制。接下来,我们将学习一次性从多个基类继承。

多重继承

在 C++中,一个类可以从多个基类派生。回到我们的鸟类,让我们做一个观察——虽然飞行的鸟类彼此之间有很多共同之处,但它们也与其他飞行动物有共同之处,特别是飞行的能力。由于飞行并不局限于鸟类,我们可能希望将处理飞行相关的数据和算法移动到一个单独的基类中。但也不能否认,老鹰也是一种鸟类。如果我们使用两个基类来构建Eagle类,我们可以表达这种关系:

class Eagle : public Bird, public FlyingAnimal { ... };

在这种情况下,从两个基类的继承都是公开的,这意味着派生类继承了两个接口,并且必须满足两个单独的合同。如果两个接口定义了具有相同名称的方法会发生什么?如果这个方法不是虚拟的,那么在派生类上调用它的尝试是模糊的,程序无法编译。如果方法是虚拟的,并且派生类有对它的重写,那么由于派生类的方法被调用,因此没有歧义。此外,Eagle现在既是Bird也是FlyingAnimal

Eagle* e = new Eagle;
Bird* b = e;
FlyingAnimal* f = e;

从派生类转换为基类指针的转换是允许的。反向转换必须显式地使用静态或动态转换。还有一个有趣的转换——如果我们有一个指向FlyingAnimal类的指针,它也是一个Bird类,我们能否从一个类转换到另一个类?是的,我们可以使用动态转换:

Bird* b = new Eagle;   // Also a FlyingAnimal
FlyingAnimal* f = dynamic_cast<FlyingAnimal*>(b);

在这个上下文中,动态转换有时被称为交叉转换——我们不是在层次结构(派生类和基类之间)的上下文中进行转换,而是在层次结构之间进行转换——在层次结构树的不同分支上的类之间。

交叉转换也主要是我们之前章节中看到的动态转换高运行时成本的原因。虽然dynamic_cast最常见的使用是从Base*Derived*转换以验证给定的对象确实是派生类,但这种转换也可以用于在相同派生类的基类之间进行转换。这是一个更难的问题。如果你只想检查基类对象确实是派生类,编译器此时知道Derived类型(你不能在未完成类型上使用动态转换)。

因此,编译器确切地知道这个派生类型有哪些基类,并且可以轻易地检查你的类型是否是其中之一。但是,在跨越层次结构进行类型转换时,编译器只知道两个基类:在编写此代码时,可能不存在同时结合这两个基类的派生类,它将在以后被编写。但是,编译器必须现在就生成正确的代码。因此,编译器必须生成在运行时挖掘所有可能从这两个基类派生出来的类,以查看你的类型是否是其中之一(实际的实现比这更直接且更高效,但需要完成的任务仍然是相同的)。

实际上,这种开销通常是不必要的,因为,大多数时候,动态类型转换确实用于确定基类指针是否真正指向一个派生对象。在许多情况下,这种开销并不显著。但是,如果需要更好的性能,就没有办法使动态类型转换更快。如果你想快速检查一个多态对象是否确实是给定类型,你必须使用虚函数,而且不幸的是,你必须使用所有可能的类型(或者至少是你可能感兴趣的)的列表:

enum type_t { typeBase, typeDerived1, typeDerived2 };
class Base {
  virtual type_t type() const { return typeBase; }
};
class Derived1 : public Base {
  type_t type() const override { return typeDerived1; }
};
…
void process_derived1(Derived1* p);
void do_work(Base* p) {
  if (p->type() == typeDerived1) {
    process_derived1(static_cast<Derived1*>(p));
  }
}

在 C++中,多重继承经常受到诋毁和不被青睐。大部分这些建议已经过时,并且源于编译器在实现多重继承时表现不佳且效率低下的时候。如今,随着现代编译器的出现,这已不再是问题。人们常说多重继承使得类层次结构更难以理解和推理。或许更准确的说法是,设计一个能够准确反映不同属性之间关系的良好多重继承层次结构更困难,而设计不良的层次结构则难以理解和推理。

这些担忧主要适用于使用公有继承的层次结构。多重继承也可以是私有的。与使用单一私有继承相比,没有更多的理由使用多重私有继承而不是组合。然而,在多个空基类上进行空基优化仍然是一个有效的理由来使用私有继承,如果适用的话:

class Empty1 {};
class Empty2 {};
class Derived : private Empty1, private Empty2 {
  int i;
};   // sizeof(Derived) == 4
class Composed {
  int i;
  Empty1 e1;
  Empty2 e2;
};   // sizeof(Composed) == 8

当派生类代表一个结合了几个不相关、不重叠属性的系统时,多重继承可以特别有效。在我们探索各种设计模式和它们的 C++表示时,本书中我们将遇到这样的案例。

摘要

尽管这绝对不是类和对象的完整指南或参考,但本章介绍了并解释了您需要理解本书其余部分示例和解释的概念。由于我们的兴趣和将会是表示 C++ 中的设计模式,本章重点介绍了类和继承的正确使用。我们特别注意了通过不同的 C++ 特征表达的关系——正是通过这些特征,我们将表达构成设计模式的不同组件之间的关系和交互。

下一章将类似地涵盖 C++ 模板的知识,这对于理解本书后续章节是必要的。

问题

  • C++ 中对象的重要性是什么?

  • 公共继承和私有继承分别表达了哪种关系?多态对象是什么?

  • 动态转换和静态转换之间的区别是什么?为什么动态转换如此昂贵?

进一步阅读

第二章:类和函数模板

C++的模板编程特性是一个庞大而复杂的主题,许多书籍专门用于教授这些特性。在这本书中,我们将使用许多高级的 C++泛型编程特性。那么,我们应该如何准备自己,以便理解这些语言结构,它们将在本书的各个部分出现?本章采用非正式的方法——而不是精确的定义,我们通过示例演示模板的使用,并解释不同的语言特性是如何工作的。如果你在这个时候发现自己的知识不足,鼓励你寻求更深入的理解,并阅读一本或更多专注于解释 C++语言语法和语义的书籍。当然,如果你希望有一个更精确、更正式的描述,你可以参考 C++标准或参考书籍。

本章将涵盖以下主题:

  • C++中的模板

  • 类和函数模板

  • 模板实例化

  • 模板特化

  • 模板函数的重载

  • 可变模板

  • Lambda 表达式

  • 概念

C++中的模板

C++最伟大的优势之一是它对泛型编程的支持。在泛型编程中,算法和数据结构是用泛型类型编写的,这些类型将在以后指定。这允许程序员一次实现一个函数或一个类,然后,为许多不同的类型实例化它。模板是 C++的一个特性,允许在泛型类型上定义类和函数。C++支持三种类型的模板——函数、类和变量模板。

函数模板

函数模板是泛型函数——与常规函数不同,模板函数不声明其参数类型。相反,类型是模板参数:

// Example 01
template <typename T>
T increment(T x) { return x + 1; }

此模板函数可用于将任何类型的值增加一,其中加一是一个有效的操作:

increment(5);    // T is int, returns 6
increment(4.2);    // T is double, return 5.2 char c[10];
increment(c);    // T is char*, returns &c[1]

大多数模板函数对其模板参数的类型有一些限制。例如,我们的increment()函数要求表达式x + 1x的类型是有效的。否则,尝试实例化模板将失败,并伴随着一些冗长的编译错误。

非成员函数和类成员函数都可以是函数模板;然而,虚函数不能是模板。泛型类型不仅可以用来声明函数参数,还可以用来声明函数体内的任何变量:

template <typename T> T sum(T from, T to, T step) {
  T res = from;
  while ((from += step) < to) { res += from; }
  return res;
}

在 C++20 中,简单的模板声明可以被缩写:我们不需要写

template <typename T> void f(T t);

我们可以写

// Example 01a
void f(auto t);

除了更简洁的声明外,这种缩写没有特别的优势,这个特性相当有限。首先,auto只能用作“顶级”参数类型;例如,这是无效的(但某些编译器允许):

void f(std::vector<auto>& v);

并且仍然需要写成

template <typename T> void f(std::vector<T>& v);

此外,如果你需要在函数声明的其他地方使用模板类型参数,你不能简化它们:

template <typename T> T f(T t);

当然,你可以将返回类型声明为auto并使用尾随返回类型:

auto f(auto t) -> decltype(t);

但在这个阶段,模板实际上并没有“简化”。

我们将在稍后看到更多关于函数模板的内容,但接下来我们先介绍类模板。

类模板

类模板是使用泛型类型的类,通常用于声明其数据成员,但也可以在它们内部声明方法和局部变量:

// Example 02
template <typename T> class ArrayOf2 {
  public:
  T& operator[](size_t i) { return a_[i]; }
  const T& operator[](size_t i) const { return a_[i]; }
  T sum() const { return a_[0] + a_[1]; }
  private:
  T a_[2];
};

这个类只实现一次,然后可以用来定义任何类型的两个元素的数组:

ArrayOf2<int> i; i[0] = 1; i[1] = 5;
std::cout << i.sum();                       // 6
ArrayOf2<double> x; x[0] = -3.5; x[1] = 4;
std::cout << x.sum();                       // 0.5
ArrayOf2<char*> c; char s[] = "Hello";
c[0] = s; c[1] = s + 2;

特别注意最后一个例子——你可能认为ArrayOf2模板与char*这样的类型不兼容——毕竟,它有一个sum()方法,如果a_[0]a_[1]的类型是指针,则无法编译。然而,我们的示例按原样编译——类模板的方法不需要在我们尝试使用它之前就有效。如果我们从未调用c.sum(),那么它无法编译的事实永远不会出现,程序仍然有效。如果我们调用了一个对于所选模板参数无法编译的成员函数,我们会在模板体中得到语法错误(在我们的例子中,关于无法将两个指针相加的错误)。这些错误信息很少直接明了。即使它们是直接的,也不清楚问题是否出在函数体中,或者函数从一开始就不应该被调用。在本章的后面部分,我们将看到如何改善这种情况。

变量模板

C++中的最后一种模板是变量模板,它在 C++14 中引入。这种模板允许我们定义一个具有泛型类型的变量:

// Example 03
template <typename T> constexpr T pi =
T(3.14159265358979323846264338327950288419716939937510582097494459230781L);
pi<float>;      // 3.141592
pi<double>;     // 3.141592653589793

变量模板在大多数情况下非常简单易用,主要用于定义自己的常量,但也有一些有趣的模式可以利用它们;我们将在下一节中看到一个例子。

非类型模板参数

通常,模板参数是类型,但 C++还允许几种非类型参数。首先,模板参数可以是整数或枚举类型的值:

// Example 04
template <typename T, size_t N> class Array {
  public:
  T& operator[](size_t i) {
    if (i >= N) throw std::out_of_range("Bad index");
     return data_[i];
  }
  private:
  T data_[N];
};
Array<int, 5> a;      // OK
cin >> a[0];
Array<int, a[0]> b;   // Error

这是一个有两个参数的模板——第一个是类型,但第二个不是。它是一个size_t类型的值,用于确定数组的大小;这种模板与内置的 C 风格数组相比的优势在于它可以进行范围检查。C++标准库中有一个std::array类模板,应该用于替代在任意实际程序中实现自己的数组,但它确实是一个易于理解的示例。

用于实例化模板的非类型参数的值必须是编译时常量或 constexpr 值——前一个示例中的最后一行是无效的,因为 a[0] 的值直到程序在运行时读取它才已知。C++20 允许非类型模板参数使用浮点数和用户定义的类型;在此之前,参数仅限于整型、指针(包括函数和成员指针)、引用和枚举。当然,非类型参数的值必须是编译时常量,因此,例如,不允许指向局部变量的指针。

在 C++ 中,数值模板参数曾经非常流行,因为它们允许实现复杂的编译时计算,但在最近的版本标准中,constexpr 函数可以用来达到相同的效果,并且更容易阅读。当然,标准一手拿走,一手又给予,因此非模板参数与 constexpr 函数结合出现了一个有趣的新用例:这些函数首次在 C++11 中引入,用于定义“即时函数”,或编译时评估的函数。constexpr 函数的问题在于它们可能在编译时评估,但这不是必需的;它们也可能在运行时评估:

constexpr size_t length(const char* s) {
  size_t res = 0;
  while (*(s++)) ++res;
  return res;
}
std::cout << length("abc") << std::endl;
char s[] = "runtime";
std::cout << length(s) << std::endl;

这里有一个 constexpr 函数 length()。长度计算实际上是在编译时发生的吗?除了检查生成的汇编代码(这可能会因编译器而异)外,没有其他方法可以知道。唯一确定的方法是在编译时上下文中调用该函数,例如:

static_assert(length("abc") == 3, ""); // OK
char s[] = "runtime";
static_assert(length(s) == 7, ""); // Fails

第一个断言可以编译,而第二个即使值 7 是正确的也无法编译:论点是它不是一个编译时值,因此评估必须在运行时发生。

在 C++20 中,函数可以被声明为 consteval 而不是 constexpr:这保证了评估发生在编译时或者根本不发生(因此,前一个示例中的第二个 cout 语句将无法编译)。在 C++20 之前,我们必须发挥创意。这里有一种强制编译时执行的方法:

// Example 05c
template <auto V>
static constexpr auto force_consteval = V;

force_consteval 变量模板可以用来强制编译时评估,如下所示:

std::cout << force_consteval<length("abc")> << std::endl;
char s[] = "runtime";
std::cout << force_consteval<length(s)> << std::endl;

第二个 cout 语句无法编译,因为 length() 函数不能被评估为即时函数。变量模板 force_consteval 使用一个非类型模板参数,其类型未指定,而是从模板参数(一个 auto 模板参数)推导得出。这是一个 C++17 的特性;在 C++14 中,我们必须使用一个相当不优雅的宏来实现相同的结果:

// Example 05d
template <typename T, T V>
static constexpr auto force_consteval_helper = V;
#define force_consteval(V)
force_consteval_helper<decltype(V), (V)>
std::cout << force_consteval(length("abc")) << std::endl;

如果一个非类型模板参数看起来“不如类型”,你会喜欢下一个选项,一个肯定比简单类型更复杂的参数。

模板模板参数

值得一提的第二种非类型模板参数是模板模板参数——即本身也是模板的模板参数。在本书的后续章节中我们将需要它们。这个模板参数的替换不是用类的名称,而是用整个模板的名称。

这里有一个具有模板模板参数的类模板:

// Example 06a
template <typename T,
         template <typename> typename Container>
class Builder {
  Container<T> data_;
  public:
  void add(const T& t) { data_.push_back(t); }
  void print() const {
    for (const auto& x : data_) std::cout << x << " ";
    std::cout << std::endl;
  }
};

Builder模板声明了一个用于构造(构建)任意类型T的容器的类。容器本身没有特定的类型,它本身就是一个模板。

它可以用任何接受一个类型参数的容器模板实例化:

template <typename T> class my_vector { … };
Builder<int, my_vector> b;
b.add(1);
b.add(2);
b.print();

当然,对Container模板还有额外的要求:它必须有一个单一的类型参数T(其余可以默认),它应该可以默认构造,它必须有一个push_back()方法,等等。C++20 为我们提供了一种简洁的方式来表述这些要求,并将它们作为模板接口的一部分;我们将在本章的概念部分学习它。

这里有一个具有两个模板模板参数的函数模板:

// Example 06b
template <template <typename> class Out_container,
          template <typename> class In_container,
          typename T> Out_container<T>
resequence(const In_container<T>& in_container) {
  Out_container<T> out_container;
  for (auto x : in_container) {
    out_container.push_back(x);
  }
  return out_container;
}

这个函数接受一个任意的容器作为参数,并返回另一个容器,一个不同的模板,但实例化在相同的类型上,其值从输入容器复制而来:

my_vector<int> v { 1, 2, 3, 4, 5 };
template <typename T> class my_deque { … };
auto d = resequence<my_deque>(v);// my_deque with 1 … 5

注意,编译器推导出模板参数的类型(In_container作为my_vector)以及其模板参数的类型(T作为int)。当然,剩余的模板参数Out_container无法推导(它不在模板函数的任何参数中使用),必须显式指定,这符合我们的预期用途。

模板模板参数有一个主要的限制,由于不同的编译器执行不均,这使得问题更加复杂(即,一些编译器通过了本应无法编译的代码,但你确实希望它能编译)。限制是,为模板模板指定的模板参数数量必须与参数的数量匹配。考虑这个模板函数:

template <template <typename> class Container, typename T>
void print(const Container<T>& container) {
  for (auto x : container) { std::cout << x << " "; }
  std::cout << std::endl;
}
std::vector<int> v { 1, 2, 3, 4, 5 };
print(v);

这段代码可能可以编译,但这取决于标准的版本和编译器对标准的严格遵循:std::vector模板有两个模板参数,而不是一个。第二个参数是分配器;它有一个默认值,这就是为什么在声明向量对象时我们不必指定分配器类型。GCC、Clang 和 MSVC 都在一定程度上放宽了这一要求(但程度不同)。变长模板,我们将在本章后面看到,提供了一个更稳健的解决方案(至少在 C++17 及以后版本中)。

模板是生成代码的一种配方。接下来,我们将看到如何将这些配方转换为我们可以运行的实际代码。

模板实例化

模板名称不是一个类型,不能用来声明变量或调用函数。要创建类型或函数,模板必须被实例化。大多数时候,模板在使用时隐式实例化。我们再次从函数模板开始。

函数模板

要使用函数模板生成函数,我们必须指定所有模板类型参数应使用的类型。我们可以直接指定这些类型:

template <typename T> T half(T x) { return x/2; }
int i = half<int>(5);

这将 half 函数模板实例化为 int 类型。类型是显式指定的;我们可以用另一种类型的参数调用该函数,只要它可转换为请求的类型:

double x = half<double>(5);

即使参数是 int 类型,实例化的是 half<double>,返回类型是 double。整数值 5 被隐式转换为 double

尽管每个函数模板都可以通过指定所有类型参数来实例化,但这很少发生。函数模板的大部分使用都涉及到类型的自动推导。考虑以下情况:

auto x = half(8);    // int
auto y = half(1.5);    // double

模板类型只能从模板函数参数推导——编译器将尝试选择 T 参数的类型以匹配声明为相同类型的函数参数。在我们的例子中,函数模板具有 T 类型的 x 参数。对这个函数的任何调用都必须为这个参数提供某个值,并且这个值必须有一个类型。编译器将推导出 T 必须是那种类型。在前面的代码块中的第一次调用中,参数是 5,其类型是 int。在这种情况下,假设 T 应该是 int 是最好的选择。同样,在第二次调用中,我们可以推导出 T 必须是 double

在此推导之后,编译器执行类型替换:所有其他对 T 类型的提及都被推导出的类型所替换;在我们的例子中,T 的其他使用只有一个是返回类型。

模板参数推导广泛用于捕获我们难以确定类型的场景:

long x = ...;
unsigned int y = ...;
auto x = half(y + z);

在这里,我们推导出 T 类型为表达式 y + z 的类型(它是 long,但使用模板推导时,我们不需要显式指定,并且推导出的类型将跟随参数类型,如果我们更改 yz 的类型)。考虑以下示例:

template <typename U> auto f(U);
half(f(5));

我们推导出 T 以匹配 f() 模板函数对于 int 参数返回的类型(当然,在调用之前必须提供 f() 模板函数的定义,但我们不需要深入到定义 f() 的头文件中,因为编译器会为我们推导正确的类型)。

只有用于声明函数参数的类型才能被推导。没有规则要求所有模板类型参数都必须以某种方式出现在参数列表中,但任何无法推导的参数必须显式指定:

template <typename U, typename V> U half(V x) {
  return x/2;
}
auto y = half<double>(8);

在这里,第一个模板类型参数被显式指定,所以Udouble,而V被推断为int

有时候,编译器无法推断模板类型参数,即使它们被用来声明参数:

template <typename T> T Max(T x, T y) {
  return (x > y) ? x : y;
}
auto x = Max(7L, 11); // Error

在这里,我们可以从第一个论据中推断出T必须是long,但从第二个论据中,我们推断出T必须是int。对于学习模板的程序员来说,通常很令人惊讶的是在这种情况下long类型没有被推断出来——毕竟,如果我们将long替换到每个地方,第二个论据将隐式转换,函数将能够编译。那么为什么没有推断出更大的类型呢?因为编译器并不试图找到一个类型,使得所有参数转换都是可能的:毕竟,通常有不止一个这样的类型。在我们的例子中,T可以是doubleunsigned long,函数仍然有效。如果一个类型可以从多个参数中推断出来,那么所有这些推断的结果必须相同。

否则,模板实例化被认为是模糊的。

类型推断并不总是像使用类型参数的类型那样直接。参数可能被声明为一个比类型参数本身更复杂的类型:

template <typename T> T decrement(T* p) {
  return --(*p);
}
int i = 7;
decrement(&i);    // i == 6

这里,参数的类型是一个指向int指针,但推断给T的类型是int。类型的推断可以是任意复杂的,只要它是明确的:

template <typename T> T first(const std::vector<T>& v) {
  return v[0];
}
std::vector<int> v{11, 25, 67};
first(v);    // T is int, returns 11

这里,参数是另一个模板std::vector的实例化,我们必须从创建这个向量实例化的类型中推断模板参数类型。

正如我们所看到的,如果一个类型可以从多个函数参数中推断出来,那么这些推断的结果必须相同。另一方面,一个参数可以用来推断多个类型:

template <typename U, typename V>
std::pair<V, U> swap12(const std::pair<U, V>& x) {
  return std::pair<V, U>(x.second, x.first);
}
swap12(std::make_pair(7, 4.2)); // pair of 4.2, 7

在这里,我们从单个论据中推断出两个类型,UV,然后使用这两个类型来形成一个新类型,std::pair<V, U>。这个例子过于冗长,我们可以利用一些更多的 C++特性来使其更加紧凑且易于维护。首先,标准已经有一个函数可以推断参数类型并使用它们来声明一个 pair,我们甚至已经使用了这个函数——std::make_pair()

其次,函数的返回类型可以从return语句中的表达式推断出来(这是一个 C++14 特性)。这种推断的规则与模板参数类型推断的规则相似。有了这些简化,我们的例子变成了以下这样:

template <typename U, typename V>
auto swap12(const std::pair<U, V>& x) {
  return std::make_pair(x.second, x.first);
}

注意,我们不再显式使用类型UV。我们仍然需要这个函数是一个模板,因为它操作的是通用类型,即两个类型的组合,我们不知道直到实例化函数。然而,我们可以只使用一个模板参数来代表参数的类型:

template <typename T> auto swap12(const T& x) {
  return std::make_pair(x.second, x.first);
}

这两个变体之间存在一个显著的区别——最后一个函数模板将从任何只有一个参数的调用中成功推导出类型,无论该参数的类型如何。如果该参数不是 std::pair,或者更一般地说,如果参数不是一个类或结构体,或者它没有 firstsecond 数据成员,推导仍然会成功,但类型替换会失败。另一方面,上一个版本甚至不会考虑不是某些类型对的参数。对于任何 std::pair 参数,将推导出对类型,并且替换应该没有问题。我们能否使用最后一个声明并仍然将类型 T 限制为对或具有类似接口的另一个类?是的,我们将在本书后面的部分看到几种方法来实现这一点。

成员函数模板与非成员函数模板非常相似,它们的参数也是类似推导的。成员函数模板可以在类或类模板中使用,我们将在下一节中回顾这一点。

类模板

类模板的实例化类似于函数模板的实例化——使用模板创建类型会隐式地实例化模板。要使用类模板,我们需要指定模板参数的类型参数:

template <typename N, typename D> class Ratio {
  public:
  Ratio() : num_(), denom_() {}
  Ratio(const N& num, const D& denom) :
    num_(num), denom_(denom) {}
  explicit operator double() const {
    return double(num_)/double(denom_);
  }
  private:
  N num_;
  D denom_;
};
Ratio<int, double> r;

r 变量的定义隐式地实例化了 Ratio 类模板的 intdouble 类型。它还实例化了该类的默认构造函数。在这个代码中,第二个构造函数没有被使用,也没有被实例化。这是类模板的一个特性——实例化一个模板会实例化所有数据成员,但直到它们被使用时才实例化方法——这使得我们能够编写只针对某些类型编译部分方法的类模板。如果我们使用第二个构造函数来初始化 Ratio 的值,那么这个构造函数就会被实例化,并且必须对给定的类型有效:

Ratio<int, double> r(5, 0.1);

在 C++17 中,这些构造函数可以用来从构造函数参数推导出类模板的类型:

Ratio r(5, 0.1);

当然,这只有在有足够的构造函数参数可以推导类型时才有效。例如,默认构造的 Ratio 对象必须使用显式指定的类型来实例化;没有其他方法可以推导它们。在 C++17 之前,经常使用辅助函数模板来构造一个可以从参数推导出类型的对象。类似于我们之前看过的 std::make_pair(),我们可以实现一个 make_ratio 函数,它将执行与 C++17 构造函数参数推导相同的功能:

template <typename N, typename D>
Ratio<N, D> make_ratio(const N& num, const D& denom) {
  return { num, denom };
}
auto r(make_ratio(5, 0.1));

如果 C++17 提供了推导模板参数的方式,应该优先使用:它不需要编写另一个本质上重复类构造函数的函数,也不需要调用复制或移动构造函数来初始化对象(尽管在实践中,大多数编译器都会执行返回值优化并优化掉对复制或移动构造函数的调用)。

当使用模板生成类型时,它会隐式实例化。类和函数模板也可以显式实例化。这样做会实例化模板而不使用它:

template class Ratio<long, long>;
template Ratio<long, long> make_ratio(const long&,
                                      const long&);

显式实例化很少需要,并且本书的其他地方不会使用。

虽然具有特定模板参数的类模板实例化行为(主要是)像常规类一样,但类模板的静态数据成员值得特别提及。首先,让我们回顾一下静态类数据成员的常见挑战:它们必须在某个地方定义,并且只能定义一次:

// In the header:
class A {
  static int n;
};
// In a C file:
int A::n = 0;
std::cout << A::n;

没有这样的定义,程序将无法链接:名称 A::n 未定义。但如果定义被移动到头文件中,并且头文件被包含在几个编译单元中,程序也将无法链接,这次名称 A::n 是多重定义的。

对于类模板,要求恰好定义一次静态数据成员是不切实际的:我们需要为模板实例化的每一组模板参数定义它们,我们无法在任何一个编译单元中做到这一点(其他编译单元可能以不同的类型实例化相同的模板)。幸运的是,这并不必要。类模板的静态成员可以(并且应该)与模板本身一起定义:

// In the header:
template <typename T> class A {
  static T n;
};
template <typename T> T A<T>::n {};

虽然从技术上讲这会导致多个定义,但链接器的任务是合并它们,这样我们就能得到一个单一的定义(对于相同类型的所有对象,静态成员变量的值只有一个)。

在 C++17 中,内联变量提供了一个更简单的解决方案:

// In the header:
template <typename T> class A {
  static inline T n {};
};

这也适用于非模板类:

// In the header:
class A {
  static inline int n = 0;
};

如果类模板的静态数据成员有一个非平凡的构造函数,则该构造函数会为每个模板实例化调用一次(而不是每个对象——对于相同类型的所有对象,静态成员变量只有一个实例)。

到目前为止,我们使用的类模板允许我们声明泛型类,即可以用许多不同类型实例化的类。到目前为止,所有这些类看起来完全相同,除了类型外,并且生成相同的代码。这并不总是希望的——不同的类型可能需要以某种方式有所不同地处理。

例如,假设我们想要能够表示不仅存储在Ratio对象中的两个数字的比例,而且还想表示存储在其他地方的两个数字的比例,其中Ratio对象包含对这些数字的指针。显然,如果对象存储了分子和分母的指针,那么Ratio对象的一些方法,如转换为double的转换操作符,需要以不同的方式实现。在 C++中,这是通过特化模板来实现的,我们将在下面进行操作。

模板特化

模板特化允许我们对某些类型生成不同的模板代码——不仅仅是用不同类型替换后的相同代码,而是完全不同的代码。在 C++中,模板特化有两种类型——显式特化(或完全特化)和部分特化。让我们先从前者开始。

显式特化

显式模板特化定义了针对一组特定类型的模板的特殊版本。在显式特化中,所有泛型类型都被替换为具体的、具体的类型。由于显式特化不是一个泛型类或函数,因此它不需要在以后进行实例化。出于同样的原因,有时它被称为完全特化。如果泛型类型被完全替换,就没有任何泛型剩余了。显式特化不应与显式模板实例化混淆——虽然两者都为给定的一组类型参数创建了一个模板的实例,但显式实例化创建了一个泛型代码的实例,其中泛型类型被具体类型替换。显式特化创建了一个具有相同名称的函数或类的实例,但它覆盖了实现,因此生成的代码可以完全不同。一个例子可以帮助我们理解这种区别。

让我们从类模板开始。假设,如果Ratio的分子和分母都是double类型,我们想要计算这个比例并将其存储为一个单独的数字。通用的Ratio代码应该保持不变,但对于一组特定的类型,我们希望类看起来完全不同。我们可以通过显式特化来实现这一点:

template <> class Ratio<double, double> {
  public:
  Ratio() : value_() {}
  template <typename N, typename D>
    Ratio(const N& num, const D& denom) :
      value_(double(num)/double(denom)) {}
  explicit operator double() const { return value_; }
  private:
  double value_;
};

两个模板类型参数都被指定为double。类的实现与通用版本完全不同——我们只有一个数据成员,而不是两个;转换操作符简单地返回值,而构造函数现在计算分子和分母的比例。但这甚至不是同一个构造函数——我们提供了一个模板构造函数,它可以接受任何类型的两个参数,只要它们可以转换为double,而不是通用版本中如果为两个double模板参数实例化时将拥有的非模板构造函数Ratio(const double&, const double&)

有时,我们不需要特化整个类模板,因为大部分泛型代码仍然适用。然而,我们可能想要更改一个或几个成员函数的实现。我们也可以显式特化成员函数:

template <> Ratio<float, float>::operator double() const {
  return num_/denom_;
}

模板函数也可以显式特化。同样,与显式实例化不同,我们可以编写函数体,并以我们想要的方式实现它:

template <typename T> T do_something(T x) {
  return ++x;
}
template <> double do_something<double>(double x) {
  return x/2;
}
do_something(3);        // 4
do_something(3.0);    // 1.5

然而,我们无法更改参数的数量或类型,或者返回类型——它们必须与泛型类型的替换结果相匹配,因此以下代码无法编译:

template <> long do_something<int>(int x) { return x*x; }

在使用模板之前,必须显式声明一个特化,以避免对相同类型的泛型模板进行隐式实例化。这很有道理——隐式实例化将创建一个与显式特化具有相同名称和相同类型的类或函数。现在程序中会有两个相同类或函数的版本,这违反了单一定义规则,并使程序无效(具体规则可以在标准中的[基本定义.ODR]部分找到)。

当我们有一个或几个类型需要模板以非常不同的方式行为时,显式特化是有用的。然而,这并没有解决我们关于指针比例的问题——我们想要一个仍然部分泛型的特化,即它可以处理任何类型的指针,但不能处理其他类型的指针。这是通过部分特化实现的,我们将在下一节中探讨。

部分特化

现在,我们正在进入 C++模板编程的真正有趣部分——部分模板特化。当一个类模板部分特化时,它仍然保持为泛型代码,但比原始模板不那么泛型。部分模板的最简单形式是其中一些泛型类型被具体类型替换,但其他类型仍然是泛型:

template <typename N, typename D> class Ratio {
  .....
};
template <typename D> class Ratio<double, D> {
  public:
  Ratio() : value_() {}
  Ratio(const double& num, const D& denom) :
    value_(num/double(denom)) {}
  explicit operator double() const { return value_; }
  private:
  double value_;
};

在这里,如果分子是double类型,无论分母类型如何,我们都将Ratio转换为double值。可以为同一个模板定义多个部分特化。例如,我们还可以为分母是double而分子是任何类型的情况进行特化:

template <typename N> class Ratio<N, double> {
  public:
  Ratio() : value_() {}
  Ratio(const N& num, const double& denom) :
    value_(double(num)/denom) {}
  explicit operator double() const { return value_; }
  private:
  double value_;
};

当模板被实例化时,会选择给定类型集的最佳特殊化。在我们的情况下,如果分子和分母都不是double,那么必须实例化通用模板——没有其他选择。如果分子是double,那么第一个部分特殊化比通用模板是一个更好的(更具体的)匹配。如果分母是double,那么第二个部分特殊化是一个更好的匹配。但如果两个项都是double呢?在这种情况下,两个部分特殊化是等效的;没有一个比另一个更具体。这种情况被认为是模糊的,实例化失败。请注意,只有这个特定的实例化Ratio<double, double>失败——定义两个特殊化不是错误(至少,不是一个语法错误),但请求一个无法唯一解析到最窄特殊化的实例化是错误的。为了允许我们的模板的任何实例化,我们必须消除这种模糊性,而做到这一点的方法是提供一个比其他两个更窄的特殊化。在我们的情况下,只有一个选项——为Ratio<double, double>提供一个完全特殊化:

template <> class Ratio<double, double> {
  public:
  Ratio() : value_() {}
  template <typename N, typename D>
    Ratio(const N& num, const D& denom) :
      value_(double(num)/double(denom)) {}
  explicit operator double() const { return value_; }
  private:
  double value_;
};

现在,对于Ratio<double, double>实例化而言,部分特殊化是不明确的这一事实已不再相关——我们有一个比它们任何一个都更具体的模板版本,因此该版本优先于两者。

部分特殊化不必完全指定一些泛型类型。因此,可以保持所有类型都是泛型,但对其施加一些限制。例如,我们仍然想要一个特殊化,其中分子和分母都是指针。它们可以是任何东西的指针,所以它们是泛型类型,但比通用模板的任意类型不那么泛型

template <typename N, typename D> class Ratio<N*, D*> {
  public:
  Ratio(N* num, D* denom) : num_(num), denom_(denom) {}
  explicit operator double() const {
    return double(*num_)/double(*denom_);
  }
  private:
  N* const num_;
  D* const denom_;
};
int i = 5; double x = 10;
auto r(make_ratio(&i, &x));        // Ratio<int*, double*>
double(r);                    // 0.5
x = 2.5;
double(r);                    // 2

这个部分特殊化仍然有两个泛型类型,但它们都是指针类型,N*D*,对于任何ND类型。其实现与通用模板完全不同。当用两个指针类型实例化时,部分特殊化比通用模板更具体,被认为是更好的匹配。请注意,在我们的例子中,分母是double。那么为什么没有考虑对double分母的特殊化呢?那是因为,尽管从程序逻辑的角度来看,分母是double,但从技术上讲,它是double*,这是一个完全不同的类型,我们没有为其提供特殊化。

要定义一个特殊化,必须首先声明一个通用模板。然而,它不需要被定义——可以特殊化在通用情况下不存在的模板。为此,我们必须提前声明通用模板,然后定义我们需要的所有特殊化:

template <typename T> class Value; // Declaration 
template <typename T> class Value<T*> {
  public:
  explicit Value(T* p) : v_(*p) {} private:
  T v_;
};
template <typename T> class Value<T&> {
  public:
  explicit Value(T& p) : v_(p) {}
  private:
  T v_;
};
int i = 5; int* p = &i; int& r = i;
Value<int*> v1(p); // T* specialization
Value<int&> v2(r); // T& specialization

在这里,我们没有通用的Value模板,但我们为任何指针或引用类型提供了部分特殊化。如果我们尝试在某种其他类型上实例化该模板,例如int,我们将得到一个错误,指出Value<int>类型是不完整的——这和尝试仅使用类的声明前缀来定义一个对象没有区别。

到目前为止,我们只看到了类模板的部分特殊化示例。与前面关于完全特殊化的讨论不同,我们没有在这里看到单个函数特殊化。这有一个非常好的原因——C++中不存在部分函数模板特殊化。有时错误地称为部分特殊化的是模板函数重载的简单形式。另一方面,模板函数的重载可以变得相当复杂,值得学习——我们将在下一节中介绍这一点。

模板函数重载

我们习惯于常规函数或类方法的重载——具有相同名称的多个函数具有不同的参数类型。每个调用都会调用与调用参数参数类型最佳匹配的函数,如下例所示:

// Example 07
void whatami(int x) {
  std::cout << x << " is int" << std::endl;
}
void whatami(long x) {
  std::cout << x << " is long" << std::endl;
}
whatami(5);    // 5 is int
whatami(5.0);    // Compilation error

如果参数与给定名称的重载函数之一完全匹配,则调用该函数。否则,编译器会考虑将参数类型转换为可用函数的转换。如果其中一个函数提供了更好的转换,则选择该函数。否则,调用是模糊的,就像在前面示例的最后一行一样。关于构成最佳转换的确切定义可以在标准中找到(见重载部分,更具体地说,见子部分[over.match])。一般来说,最便宜的转换是添加const或移除引用等;然后,有内置类型之间的转换,从派生类指针到基类指针的转换等。在多个参数的情况下,所选函数的每个参数都必须有最佳转换。没有投票——如果一个函数有三个参数,其中两个与第一个重载完全匹配,而第三个与第二个重载完全匹配,那么即使剩余的参数可以隐式转换为相应的参数类型,重载调用仍然是模糊的。

模板的存在使得重载解析变得更加复杂。除了非模板函数外,还可以定义具有相同名称和可能相同数量的参数的多个函数模板。所有这些函数都是重载函数调用的候选者,但函数模板可以生成具有不同参数类型的函数,那么我们如何决定实际的重载函数是什么?确切的规则甚至比非模板函数的规则更复杂,但基本思想是这样的——如果有一个非模板函数与调用参数几乎完美匹配,则选择该函数。当然,标准使用比“几乎完美”更精确的术语,但“平凡”转换,如添加const,属于这一类别——你可以“免费”获得它们。如果没有这样的函数,编译器将尝试将所有具有相同名称的函数模板实例化成与调用参数几乎完美匹配的形式,使用模板参数推导。如果恰好只有一个模板被实例化,则调用由这种实例化创建的函数。否则,重载解析将在非模板函数中继续以通常的方式进行。

这是对一个非常复杂过程的非常简化的描述,但有两个重要的要点——首先,如果对一个模板函数和一个非模板函数的调用有同样好的匹配,则优先选择非模板函数,其次,编译器不会尝试将函数模板实例化成可能转换为所需类型的对象。模板函数在参数类型推导后必须几乎完美匹配调用,否则根本不会被调用。让我们在我们的上一个例子中添加一个模板:

void whatami(int x); // Same as above
void whatami(long x); // Same as above
template <typename T> void whatami(T* x) {
  std::cout << x << " is a pointer" << std::endl;
}
int i = 5;
whatami(i);    // 5 is int
whatami(&i);    // 0x???? is a pointer

这里看起来像是一个函数模板的部分特化。但实际上并不是——它只是一个函数模板——没有一般模板可以特化。相反,它只是一个类型参数从相同参数推导出来的函数模板,但使用不同的规则。如果参数是任何类型的指针,则可以推导出模板的类型。这包括指向const的指针——T可以是const类型,所以如果我们调用whatami(ptr),其中ptrconst int*,那么当Tconst int时,第一个模板重载是一个完美的匹配。如果推导成功,由模板生成的函数,即模板实例化,将被添加到重载集中。

对于int*参数,它是唯一可以工作的重载,因此被调用。但如果多个函数模板可以匹配调用,并且两种实例化都是有效的重载会发生什么?让我们再添加一个模板:

void whatami(int x); // Same as above
void whatami(long x); // Same as above
template <typename T> void whatami(T* x); // Same as above
template <typename T> void whatami(T&& x) {
  std::cout << "Something weird" << std::endl;
}
class C {    };
C c;
whatami(c);    // Something weird
whatami(&c);    // 0x???? is a pointer

这个模板函数通过通用引用接受其参数,因此它可以针对任何只有一个参数的 whatami() 调用进行实例化。第一次调用,whatami(c),很简单——最后一个重载,带有 T&&,是唯一可以调用的。没有从 c 到指针或整数的转换。但第二次调用很棘手——我们有一个,而不是一个,与调用完全匹配的模板实例化,且不需要任何转换。那么为什么这不是一个模糊的重载呢?因为解决重载函数模板的规则与非模板函数的规则不同,类似于选择类模板部分特殊化的规则(这也是为什么函数模板重载经常与部分特殊化混淆的另一个原因)。更具体的模板是一个更好的匹配。

在我们的情况下,第一个模板更具体——它可以接受任何指针参数,但只能是指针。第二个模板可以接受任何参数,所以每次第一个模板是一个可能的匹配时,第二个也是,但反之则不然。如果可以使用更具体的模板实例化一个有效的重载函数,那么就使用这个模板。

否则,我们必须回退到更通用的模板。

重载集中的非常通用的模板函数有时会导致意想不到的结果。假设我们有以下三个针对 intdouble 和任何东西的重载:

void whatami(int x) {
  std::cout << x << " is int" << std::endl;
}
void whatami(double x) {
  std::cout << x << " is double" << std::endl;
}
template <typename T> void whatami(T&& x) {
  std::cout << "Something weird" << std::endl;
}
int i = 5;
float x = 4.2;
whatami(i);    // i is int
whatami(x);    // Something weird
whatami(1.2);    // 1.2 is double

第一次调用有一个 int 参数,所以 whatami(int) 是一个完美的匹配。如果我没有模板重载,第二次调用将转到 whatami(double)——从 floatdouble 的转换是隐式的(从 floatint 的转换也是隐式的,但到 double 的转换更优先)。但这仍然是一个转换,所以当函数模板实例化到 whatami(float&&) 的完美匹配时,这是最佳匹配,也是选择的重载。最后一个调用有一个 double 参数,再次我们有一个与非模板函数 whatami(double) 的完美匹配,所以它比任何其他替代方案更优先。

应该注意的是,为相同参数类型重载按值传递和按引用传递的函数通常会在重载解析中产生歧义。例如,这两个函数几乎总是模糊的:

template <typename T> void whatami(T&& x) {
  std::cout << "Something weird" << std::endl;
}
template <typename T> void whatami(T x) {
  std::cout << "Something copyable" << std::endl;
}
class C {};
C c;
whatami(c);

只要函数的参数可以被复制(并且我们的对象 c 是可复制的),重载就是模糊的,调用将无法编译。当更具体的函数重载更一般的函数时,问题不会发生(在我们所有的前例中,whatami(int) 使用按值传递且没有问题),但将两种类型的参数传递混合在类似通用的函数中是不明智的。

最后,还有一种函数在重载解析顺序中有一个特殊的位置——可变参数函数。

可变参数函数使用 ... 而不是参数来声明,并且可以用任何类型和数量的参数调用(printf 就是一个这样的函数)。这个函数是最后的手段重载——只有当没有其他重载可用时才会调用:

void whatami(...) {
  std::cout << "It's something or somethings" << std::endl;
}

只要我们有 whatami(T&& x) 重载可用,可变参数函数就不会是首选的重载,至少对于任何只有一个参数的 whatami() 调用来说是这样。没有那个模板,whatami(...) 将会用于任何非数字或指针类型的参数。可变参数函数自 C 语言时代起就存在了,不要与在 C++11 中引入的可变参数模板混淆,这正是我们接下来要讨论的内容。

可变参数模板

C 和 C++ 之间最显著的差异可能是类型安全。在 C 中可以编写泛型代码——标准函数 qsort() 就是一个完美的例子——它可以对任何类型的值进行排序,并且它们通过 void* 指针传递,这实际上可以是任何类型的指针。当然,程序员必须知道实际的类型,并将指针转换为正确的类型。在泛型 C++ 程序中,类型要么在实例化时显式指定,要么推导出来,泛型类型的类型系统与常规类型的类型系统一样强大。除非我们想要一个具有未知数量参数的函数,即在 C++11 之前,唯一的方法是使用旧的 C 风格的可变参数函数,其中编译器根本不知道参数类型是什么;程序员只需知道并正确地解包可变参数即可。

C++11 引入了可变参数函数的现代等价物——可变参数模板。现在我们可以声明具有任何数量参数的泛型函数:

template <typename ... T> auto sum(const T& ... x);

这个函数接受一个或多个参数,这些参数可能具有不同的类型,并计算它们的总和。返回类型不容易确定,但幸运的是,我们可以让编译器来决定——我们只需将返回类型声明为 auto。我们实际上如何实现这个函数来累加未知数量的值,而这些值的类型我们甚至无法命名,甚至不能作为泛型类型?在 C++17 中,这很容易,因为有了折叠表达式:

// Example 08a
template <typename ... T> auto sum(const T& ... x) {
  return (x + ...);
}
sum(5, 7, 3);        // 15, int
sum(5, 7L, 3);        // 15, long
sum(5, 7L, 2.9);        // 14.9, double

您可以验证结果类型正是我们所声称的类型:

static_assert(std::is_same_v<
  decltype(sum(5, 7L, 2.9)), double>);

在 C++14 以及 C++17 中,当折叠表达式不足以使用(并且它们仅在有限的上下文中有用,主要是在使用二进制或一元运算符组合参数时),标准技术是递归,这在模板编程中一直很受欢迎:

// Example 08b
template <typename T1> auto sum(const T1& x1) {
  return x1;
}
template <typename T1, typename ... T>
auto sum(const T1& x1, const T& ... x) {
  return x1 + sum(x ...);
}

第一个重载(不是部分特化!)是为具有任何类型单个参数的 sum() 函数。该值将被返回。第二个重载用于一个以上的参数,并且第一个参数被明确地加到剩余参数的总和中。递归继续进行,直到只剩下一个参数,此时将调用另一个重载并停止递归。这是在变长模板中解开参数包的标准技术,我们将在本书中多次看到这一点。编译器将内联所有递归函数调用并生成直接将所有参数相加的代码。

类模板也可以是变长的——它们有任意数量的类型参数,并且可以从不同类型的不同数量的对象构建类。其声明与函数模板类似。例如,让我们构建一个类模板 Group,它可以保存任何数量的不同类型的对象,并在转换为它所保存的类型时返回正确的对象:

// Example 09
template <typename ... T> struct Group;

这种模板的通常实现仍然是递归的,使用深度嵌套的继承,尽管有时可能存在非递归实现。我们将在下一节中看到一个例子。当只剩下一个类型参数时,递归必须终止。这是通过部分特化来完成的,因此我们将之前显示的通用模板仅作为声明保留,并为一个类型参数定义一个特化:

template <typename ... T> struct Group;
template <typename T1> struct Group<T1> {
  T1 t1_;
  Group() = default;
  explicit Group(const T1& t1) : t1_(t1) {}
  explicit Group(T1&& t1) : t1_(std::move(t1)) {}
  explicit operator const T1&() const { return t1_; }
  explicit operator T1&() { return t1_; }
};

这个类保存一个类型 T1 的值,通过复制或移动来初始化它,并在转换为 T1 类型时返回对其的引用。对于任意数量的类型参数的特化包含第一个类型作为数据成员,以及相应的初始化和转换方法,并从剩余类型的 Group 类模板继承:

template <typename T1, typename ... T>
struct Group<T1, T ...> : Group<T ...> {
  T1 t1_;
  Group() = default;
  explicit Group(const T1& t1, T&& ... t) :
    Group<T ...>(std::forward<T>(t) ...), t1_(t1) {}
  explicit Group(T1&& t1, T&& ... t) :
    Group<T...>(std::forward<T>(t)...),
                t1_(std::move(t1)) {}
  explicit operator const T1&() const { return t1_; }
  explicit operator T1&() { return t1_; }
};

对于 Group 类中包含的每个类型,它有两种可能的初始化方式——复制或移动。幸运的是,我们不必为复制和移动操作的每种组合指定构造函数。相反,我们有两种构造函数版本,用于初始化第一个参数(存储在特化中的那个);我们使用完美转发来处理剩余的参数。

现在,我们可以使用我们的 Group 类模板来保存不同类型的一些值(它无法处理相同类型的多个值,因为检索此类型的尝试将是模糊的):

Group<int, long> g(3, 5);
int(g);    // 3
long(g);    // 5

明确写出所有组类型并确保它们与参数类型匹配相当不方便。在 C++17 中,我们可以使用推导指南来启用从构造函数的类模板参数推导:

template <typename ... T> Group(T&&... t) -> Group<T...>;
Group g(3, 2.2, std::string("xyz"));
int(g);            // 3
double(g);            // 2.2
std::string(g);        // "xyz"

在 C++17 之前,解决这个问题的通常方法是使用辅助函数模板(当然是一个变长模板)来利用模板参数推导:

template <typename ... T> auto makeGroup(T&& ... t) {
  return Group<T ...>(std::forward<T>(t) ...);
}
auto g = makeGroup(3, 2.2, std::string("xyz"));

注意,C++ 标准库包含一个类模板 std::tuple,这是我们的 Group 的一个更完整、功能更丰富的版本。

变长模板也可以有非类型参数;在这种情况下,makeGroup 模板可以用任意数量的参数实例化。通常,这些非类型参数包会与 auto(推导)类型结合使用。例如,这里有一个模板,它持有不同类型编译时常量值的列表:

// Example 10
template <auto... Values> struct value_list {};

没有使用 auto(即,在 C++17 之前),几乎不可能声明这样的模板,因为类型必须被显式指定。请注意,这是一个完整的模板:它将其定义的一部分作为常量值持有。为了提取它们,我们需要另一个变长模板:

template <size_t N, auto... Values>
struct nth_value_helper;
template <size_t n, auto v1, auto... Values>
struct nth_value_helper<n, v1, Values...> {
  static constexpr auto value =
    nth_value_helper<n - 1, Values...>::value;
};
template <auto v1, auto... Values>
struct nth_value_helper<0, v1, Values...> {
  static constexpr auto value = v1;
};
template <size_t N, auto... Values>
constexpr auto nth_value(value_list<Values...>) {
  return nth_value_helper<N, Values...>::value;
}

模板函数 nth_valuevalue_list 参数(该参数本身不包含数据,除了其类型外没有其他兴趣)的类型推导出参数包 Values。然后使用部分类特化的递归实例化来遍历参数包,直到我们得到第 N 个值。请注意,为了以这种方式存储浮点常量,我们需要 C++20。

变长模板可以与模板模板参数结合使用,以解决例如当标准库容器用作模板模板参数的替代参数时产生的一些问题。一个简单的解决方案是将参数声明为接受任意数量的类型:

template <template <typename...> class Container,
         typename... T>
void print(const Container<T...>& container);
std::vector<int> v{ … };
print(v);

注意,std::vector 模板有两个类型参数。在 C++17 中,一个标准更改使其成为 Container 模板模板参数中指定的参数包的有效匹配。大多数编译器甚至更早之前就允许这种匹配。

变长模板,特别是与完美转发结合使用,对于编写非常通用的模板类非常有用——例如,一个向量可以包含任意类型的对象,为了就地构造这些对象而不是复制它们,我们必须调用具有不同数量参数的构造函数。当编写向量模板时,无法知道需要多少参数来初始化向量将包含的对象,因此必须使用变长模板(实际上,std::vector 的就地构造函数,如 emplace_back,是变长模板)。

在 C++ 中,我们还要提到一种类似于模板的实体,它既有类又有函数的外观——这就是 lambda 表达式。下一节将专门介绍这个内容。

Lambda 表达式

在 C++ 中,常规函数语法通过 可调用 的概念得到了扩展,可调用是 可调用实体 的简称——可调用是像函数一样可以调用的东西。可调用的例子包括函数(当然)、函数指针或具有 operator() 的对象,也称为仿函数

void f(int i); struct G {
  void operator()(int i);
};
f(5);            // Function
G g; g(5);        // Functor

在局部上下文中定义一个可调用实体通常很有用,就在它被使用的地方附近。例如,为了对一个对象的序列进行排序,我们可能需要定义一个自定义的比较函数。我们可以使用普通函数来完成这个任务:

bool compare(int i, int j) { return i < j; }
void do_work() {
  std::vector<int> v;
  .....
  std::sort(v.begin(), v.end(), compare);
}

然而,在 C++中,函数不能在其它函数内部定义,因此我们的compare()函数可能必须定义在它被使用的地方相当远的地方。如果它是一个单次使用的比较函数,这种分离是不方便的,并且会降低代码的可读性和可维护性。

有一种方法可以绕过这个限制——虽然我们无法在函数内部声明函数,但我们可以在函数内部声明类,并且类是可以调用的:

void do_work() {
  std::vector<int> v;
  .....
  struct compare {
    bool operator()(int i, int j) const { return i < j; }
  };
  std::sort(v.begin(), v.end(), compare());
}

这既紧凑又局部,但相当冗长。实际上,我们并不需要给这个类命名,而且我们只希望有一个这个类的实例。在 C++11 中,我们有一个更好的选择,那就是 Lambda 表达式:

void do_work() {
  std::vector<int> v;
  .....
  auto compare = [](int i, int j) { return i < j; };
  std::sort(v.begin(), v.end(), compare);
}

如果我们只为一次调用std::sort使用这个比较函数,甚至不需要给它命名,可以直接在调用内部定义:

  std::sort(v.begin(), v.end(),
            [](int i, int j) { return i < j; });

这是最紧凑的方式。可以指定返回类型,但通常可以由编译器推导出来。Lambda 表达式创建了一个对象,因此它有一个类型,但这个类型是由编译器生成的,因此对象声明必须使用auto

Lambda 表达式是对象,因此它们可以有数据成员。当然,局部可调用类也可以有数据成员。通常,它们是从包含作用域中的局部变量初始化的:

// Example 11
void do_work() {
  std::vector<double> v;
  .....
  struct compare_with_tolerance {
    const double tolerance;
    explicit compare_with_tolerance(double tol) :
      tolerance(tol) {}
    bool operator()(double x, double y) const {
      return x < y && std::abs(x - y) > tolerance;
    }
  };
  double tolerance = 0.01;
  std::sort(v.begin(), v.end(),
            compare_with_tolerance(tolerance));
}

再次强调,这是一种非常冗长的简单操作方式。我们必须三次提到容差变量——作为数据成员、构造函数参数以及在成员初始化列表中。Lambda 表达式使这段代码更简洁,因为它可以捕获局部变量。在局部类中,我们不允许通过构造函数参数以外的任何方式引用包含作用域中的变量,但对于 Lambda 表达式,编译器会自动生成一个构造函数来捕获表达式体中提到的所有局部变量:

void do_work() {
  std::vector<double> v;
  .....
  double tolerance = 0.01;
  auto compare_with_tolerance = = {
    return x < y && std::abs(x - y) > tolerance;
  };
  std::sort(v.begin(), v.end(), compare_with_tolerance);
}

在这里,Lambda 表达式内部的tolerance这个名字指的是具有相同名称的局部变量。变量是通过值捕获的,这在 Lambda 表达式的捕获子句[=]中指定。我们也可以通过[&]像这样使用引用捕获:

auto compare_with_tolerance = & {
  return x < y && std::abs(x - y) > tolerance;
};

不同之处在于,当按值捕获时,在 Lambda 对象构造的点会创建捕获变量的副本。这个局部副本默认是const的,尽管我们可以声明 Lambda 为可变的,这样我们就可以改变捕获的值:

double tolerance = 0.01;
size_t count = 0; // line 2
auto compare_with_tolerance = = mutable {
  std::cout << "called " << ++count << " times\n";
  return x < y && std::abs(x - y) > tolerance;
};
std::vector<double> v;
… store values in v …
// Counts calls but does not change the value on line 2
std::sort(v.begin(), v.end(), compare_with_tolerance);

另一方面,通过引用捕获外部作用域中的变量会使 Lambda 表达式内部对这个变量的每次提及都指向原始变量。通过引用捕获的值可以被更改:

double tolerance = 0.01;
size_t count = 0;
auto compare_with_tolerance = & mutable {
  ++count; // Changes count above
  return x < y && std::abs(x - y) > tolerance;
};
std::vector<double> v;
… store values in v …
std::sort(v.begin(), v.end(), compare_with_tolerance);
std::cout << "lambda called " << count << " times\n";

还可以通过值或引用显式捕获一些变量;例如,捕获 [=, &count] 通过值捕获了一切,除了 count,它通过引用捕获。

而不是将早期示例中的 lambda 表达式的参数从 int 改为 double,我们可以将它们声明为 auto,这实际上使得 lambda 表达式的 operator() 成为一个模板(这是一个 C++14 特性)。

Lambda 表达式最常被用作局部函数。然而,它们实际上并不是函数;它们是可调用对象,因此它们缺少函数的一个特性——重载它们的能力。在本节中我们将学习的最后一个技巧是如何绕过这一点,并从 lambda 表达式中创建一个重载集。

首先,主要思想——确实不可能重载可调用对象。另一方面,在同一个对象中重载多个 operator() 方法非常容易——方法的重载就像任何其他函数一样。当然,lambda 表达式对象的 operator() 是由编译器生成的,而不是由我们声明的,因此不可能强迫编译器在同一个 lambda 表达式中生成多个 operator()。但类有自己的优点,主要优点是我们可以从它们继承。

Lambda 表达式是对象——它们的类型是类,因此我们也可以从它们继承。如果一个类公开继承自基类,那么基类的所有公共方法都成为派生类的公共方法。如果一个类公开继承自多个基类(多重继承),那么它的公共接口是由所有基类的所有公共方法组成的。如果在这个集合中有多个同名方法,它们将变成重载方法,并应用通常的重载解析规则(特别是,可能创建一个模糊的重载集,在这种情况下程序将无法编译)。

因此,我们需要创建一个类,它可以自动从任意数量的基类继承。我们刚刚看到了完成这个任务的正确工具——变长模板。正如我们在上一节中学到的,遍历变长模板参数包中的任意数量项的通常方法是递归:

// Example 12a
template <typename ... F> struct overload_set;
template <typename F1>
struct overload_set<F1> : public F1 {
  overload_set(F1&& f1) : F1(std::move(f1)) {}
  overload_set(const F1& f1) : F1(f1) {}
  using F1::operator();
};
template <typename F1, typename ... F>
struct overload_set<F1, F ...> :
    public F1, public overload_set<F ...> {
  overload_set(F1&& f1, F&& ... f) :
    F1(std::move(f1)),
    overload_set<F ...>(std::forward<F>(f) ...) {}
  overload_set(const F1& f1, F&& ... f) :
    F1(f1), overload_set<F ...>(std::forward<F>(f) ...) {}
  using F1::operator();
  using overload_set<F ...>::operator();
};
template <typename ... F> auto overload(F&& ... f) {
  return overload_set<F ...>(std::forward<F>(f) ...);
}

overload_set 是一个变长类模板;在我们可以特化它之前,必须先声明通用模板,但它没有定义。第一个定义是为只有一个 lambda 表达式的特殊情况——overload_set 类从 lambda 表达式继承,并将其 operator() 添加到其公共接口中。对于 N 个 lambda 表达式(N>1)的特化从第一个继承,并从剩余的 N-1 个 lambda 表达式构成的 overload_set 继承。最后,我们有一个辅助函数,可以从任意数量的 lambda 表达式中构建重载集——在我们的情况下,这是一个必要性而不是便利性,因为我们不能显式指定 lambda 表达式的类型,而必须让函数模板推导它们。现在,我们可以从任意数量的 lambda 表达式中构建重载集:

int i = 5;
double d = 7.3;
auto l = overload(
  [](int* i) { std::cout << "i=" << *i << std::endl; },
  [](double* d) { std::cout << "d=" << *d << std::endl; }
);
l(&i);    // i=5
l(&d);    // d=5.3

这种解决方案并不完美,因为它处理模糊重载的能力不佳。在 C++17 中,我们可以做得更好,这给了我们一个机会来展示一种使用参数包的替代方法,这种方法不需要递归。以下是 C++17 版本:

// Example 12b
template <typename ... F>
struct overload_set : public F ... {
  overload_set(F&& ... f) : F(std::forward<F>(f)) ... {}
  using F::operator() ...;    // C++17
};
template <typename ... F> auto overload(F&& ... f) {
  return overload_set<F ...>(std::forward<F>(f) ...);
}

可变参数模板不再依赖于部分特化;相反,它直接从参数包继承(这部分实现也在 C++14 中工作,但 using 声明需要 C++17)。模板辅助函数与之前相同——它推导所有 lambda 表达式的类型,并从具有这些类型的 overload_set 实例化中构建一个对象。lambda 表达式本身通过完美转发传递给基类,在那里它们用于初始化 overload_set 对象的所有基对象(lambda 表达式是可移动的)。无需递归或部分特化,这是一个更加紧凑和直接的模板。它的使用与 overload_set 的上一个版本相同,但它更好地处理了几乎模糊的重载。

我们还可以弃用模板函数,并使用模板推导指南:

// Example 12c
template <typename ... F>
struct overload : public F ... {
  using F::operator() ...;
};
template <typename ... F> // Deduction guide
overload(F&& ... ) -> overload<F ...>;

overload 模板的使用基本保持不变;注意用于构建对象的括号:

int i = 5;
double d = 7.3;
auto l = overload{
  [](int* i) { std::cout << "i=" << *i << std::endl; },
  [](double* d) { std::cout << "d=" << *d << std::endl; },
};
l(&i);    // i=5
l(&d);    // d=5.3

在本书的后续章节中,我们将广泛使用 lambda 表达式,当我们需要编写一段代码并将其附加到对象上以便稍后执行时。

接下来,我们将学习一个新的 C++ 特性,它在某种程度上与我们迄今为止试图做的相反:它使模板 更不通用。正如我们已经看到的,使用模板过度承诺很容易:我们可以定义模板,其定义在某些情况下无法编译。最好将任何对模板参数的限制作为声明的一部分,让我们看看这是如何实现的。

概念

C++20 对 C++ 模板机制进行了重大增强:概念。

在 C++20 中,模板(包括类模板和函数模板)以及非模板函数(通常是类模板的成员)可以使用约束来指定对模板参数的要求。这些约束对于生成更好的错误消息很有用,但在需要根据模板参数的一些属性来选择函数重载或模板特化时,它们确实是不可或缺的。

约束的基本语法相当简单:约束是通过关键字 requires 引入的,它可以在函数声明之后或返回类型之前指定(在这本书中,我们两种方式交替使用,以便读者熟悉不同的代码编写风格)。表达式本身通常使用模板参数,并且必须评估为布尔值,例如:

// Example 13a
template <typename T> T copy(T&& t)
  requires (sizeof(T) > 1)
{
  return std::forward<T>(t);
}

在这里,函数 copy() 要求其参数的类型至少有 2 个字节的长度。如果我们尝试用 char 参数调用此函数,该调用将无法编译。请注意,如果违反了约束,它就相当于在特定调用中该函数不存在:如果有另一个重载,即使在没有约束的情况下重载是模糊的,它也会被考虑。

这里是一个更复杂(也更实用)的例子:

template <typename T1, typename T2>
std::common_type_t<T1, T2> min2(T1&& t1, T2&& t2)
{
  if (t1 < t2) return std::forward<T1>(t1);
  return std::forward<T2>(t2);
}

这是一个类似于 std::min 的函数,但它接受两个不同类型的参数。这会引发两个潜在问题:首先,返回类型是什么?返回值是两个参数之一,但必须有一个单一的返回类型。我们可以使用来自 <type_traits> 头文件的 std::common_type 特性作为合理的答案:对于数值类型,它执行通常的类型提升,对于类,如果可能,它将基类转换为派生类,并且它尊重隐式用户指定的转换。但还有一个问题:如果表达式 t1 < t2 无法编译,我们会在函数体中得到一个错误。这是不幸的,因为错误很难分析,可能具有误导性:它暗示函数体实现不正确。我们可以通过添加一个静态断言来解决第二个问题:

static_assert(sizeof(t1 < t2) > 0);

这至少清楚地表明,如果没有匹配的 operator<(),我们希望代码无法编译。注意我们表达断言的奇怪方式:表达式 t1 < t2 本身通常必须在运行时评估,并且很可能为假。我们需要一个编译时值,而不关心哪个参数较小,只关心它们可以进行比较。因此,我们断言的不是比较的结果,而是这个结果的大小:sizeof() 总是编译时值,而在 C++ 中任何东西的大小至少为 1。这个断言唯一可能失败的方式是表达式根本无法编译。

这仍然没有解决问题的另一部分:对参数类型的约束没有被包含在函数的接口中。函数可以在任何两种类型上调用,然后可能编译或不编译。使用 C++20 约束,我们可以将要求从函数体内的隐式(编译失败)或显式(静态断言)错误移动到函数声明中,并使其成为函数接口的一部分:

// Example 13b
template <typename T1, typename T2>
std::common_type_t<T1, T2> min2(T1&& t1, T2&& t2)
  requires (sizeof(t1 < t2) > 0)
{
  if (t1 < t2) return std::forward<T1>(t1);
  return std::forward<T2>(t2);
}

当您学习构建更复杂的约束时,重要的是要记住,约束表达式必须评估为 bool 值;不允许任何转换,这就是为什么一个非常类似的表达式不起作用的原因:

template <typename T1, typename T2>
std::common_type_t<T1, T2> min2(T1&& t1, T2&& t2)
  requires (sizeof(t1 < t2));

sizeof() 的整数值始终非零,并且会转换为 true,但在这个上下文中不会。好消息是,我们根本不需要使用 sizeof() 诡计来编写约束。还有一种类型的约束表达式,即 requires 表达式,它更强大,并且能更清晰地表达我们的意图:

// Example 13b
template <typename T1, typename T2>
std::common_type_t<T1, T2> min2(T1&& t1, T2&& t2)
  requires (requires { t1 < t2; });

requires 表达式以 requires 关键字开头,后跟大括号 {};它可以包含任意数量的必须编译的表达式,或者整个 requires 表达式的值为假(这些表达式的结果如何并不重要,它们只需是有效的 C++ 表达式)。您还可以使用类型、类型特性和不同类型要求的组合。由于语言的一个特性,requires 表达式周围的大括号是可选的,这意味着您可能会看到像 requires requires { t1 < t2 } 这样的代码,其中第一个和第二个 requires 是完全不同的关键字。

模板类型的要求可能相当复杂;通常,相同的约束在许多不同的模板中都适用。这样的要求集可以命名并定义以供以后使用;这些命名要求被称为概念。每个概念都是在约束中使用时在编译时评估的条件。

约束的语法类似于模板:

// Example 13c
template <typename T1, typename T2> concept Comparable =
  requires(T1 t1, T2 t2) { t1 < t2; };

我们在这本书中不会详细讲解语法——对于这一点,请使用像 cppreference.com 这样的参考资源。一个概念可以用它所包含的要求来代替:

template <typename T1, typename T2>
std::common_type_t<T1, T2> min2(T1&& t1, T2&& t2)
  requires Comparable<T1, T2>;

限制单个类型的概念也可以用作模板参数占位符。让我们考虑一个 swap() 函数的例子。对于整型,有一个技巧允许我们在不使用临时变量的情况下交换两个值。它依赖于位异或操作的性质。为了演示的目的,我们假设,在特定的硬件上,这个版本比通常实现交换的方式更快。我们希望编写一个自动检测类型 T 是否支持异或操作并使用它的 MySwap(T& a, T& b) 模板函数;如果可用,否则我们回退到常规的交换。

首先,我们需要一个支持异或操作的类型的概念:

// Example 14a,b
template <typename T> concept HasXOR =
  requires(T a, T b) { a ^ b; };

该概念有一个requires表达式;大括号内的每个表达式都必须编译,否则,概念的要求没有得到满足。

现在,我们可以实现一个基于 XOR 的交换模板。我们可以使用requires约束来实现,但有一个更紧凑的方法:

template <HasXOR T> void MySwap(T& x, T& y) {
     x = x ^ y;
     y = x ^ y;
     x = x ^ y;
}

概念名称HasXOR可以用作typename关键字来声明模板参数。这限制了我们的MySwap()函数只能用于具有operator^()的类型。但我们也需要一个通用情况的重载。我们还应该注意,在我们的情况下,“通用”并不意味着“任何”:类型必须支持移动赋值和移动构造。我们需要另一个概念:

template <typename T> concept Assignable =
  requires(T a, T b) {
    T(std::move(b));
    b = std::move(a);
  };

这是一个非常类似的概念,只不过我们有两个表达式;这两个表达式都必须有效,这个概念才是正确的。

第二个MySwap()重载接受所有Assignable类型。然而,我们必须明确排除具有 XOR 的类型,否则我们将有模糊的重载。这是一个很好的例子,说明了我们可以将概念作为模板占位符与要求中的概念结合起来:

template <Assignable T> void MySwap(T& x, T& y)
  requires (!HasXOR<T>)
{
  T tmp(std::move(x));
  x = std::move(y);
  y = std::move(tmp);
}

现在如果可能的话,调用MySwap()将选择基于 XOR 的重载,否则,它将使用通用重载(交换不可赋值类型根本无法编译)。

最后,让我们回到本章的第一个例子之一:在“类模板”部分中的类模板ArrayOf2。回想一下,它有一个成员函数sum(),这个函数对模板类型的要求比类中的其他部分要严格得多:它添加数组元素的值。如果元素没有operator+(),只要我们不调用sum(),就没有问题,但如果我们调用它,就会得到一个语法错误。如果这个函数根本不是类接口的一部分,除非类型支持它,那就更好了。我们可以通过一个约束来实现这一点:

// Example 15
template <typename T> class ArrayOf2 {
  public:
  T& operator[](size_t i) { return a_[i]; }
  const T& operator[](size_t i) const { return a_[i]; }
  T sum() const requires (requires (T a, T b) { a + b; }) {
    return a_[0] + a_[1];
  }
  private:
  T a_[2];
};

如果表达式a + b无法编译,代码的行为就像在类接口中没有声明成员函数sum()一样。当然,我们也可以使用一个命名的概念来实现这一点。

我们将在第七章中看到更多管理模板参数要求的方法。现在,让我们回顾我们已经学到的内容,并继续使用这些工具来解决常见的 C++问题。

摘要

模板、变长模板和 lambda 表达式都是 C++的强大功能,它们在用法上简单,但在细节上丰富。本章中的示例应该有助于为读者准备本书的后续章节,在这些章节中,我们将使用这些技术使用现代 C++语言工具来实现经典和新型设计模式。希望学习如何充分利用这些复杂而强大的工具的读者可以参考其他专门教授这些主题的书籍,其中一些可以在本章末尾找到。

现在,读者已经准备好学习常见的 C++惯用法,从下一章中表达内存所有权的惯用法开始。

问题

  1. 类型与模板之间有什么区别?

  2. C++ 有哪些类型的模板?

  3. C++ 模板有哪些类型的模板参数?

  4. 模板特化与模板实例化之间有什么区别?

  5. 你如何访问变长模板的参数包?

  6. lambda 表达式有什么用途?

  7. 概念是如何细化模板接口的?

进一步阅读

第三章:内存与所有权

内存管理不当是 C++程序中最常见的问题之一。许多这些问题归结为对代码的哪一部分或哪个实体拥有特定内存的错误假设。然后,我们得到内存泄漏、访问未分配的内存、过度使用内存和其他难以调试的问题。现代 C++有一套内存所有权惯用法,当结合起来时,允许程序员在内存所有权方面清楚地表达他们的设计意图。这反过来又使得编写正确分配、访问和释放内存的代码变得容易得多。

本章涵盖了以下主题:

  • 什么是内存所有权和资源所有权?

  • 优秀设计的资源所有权的特征是什么?何时以及如何对资源所有权保持无知?我们如何在 C++中表达独占内存所有权?

  • 我们如何在 C++中表达共享内存所有权?

  • 不同内存所有权语言结构的成本是什么?

技术要求

您可以在github.com/isocpp/CppCoreGuidelines/blob/master/CppCoreGuidelines.md找到 C++核心指南。

您可以在github.com/Microsoft/GSL找到 C++ 指南支持库GSL)。示例可在github.com/PacktPublishing/Hands-On-Design-Patterns-with-CPP-Second-Edition/tree/master/Chapter03找到。

什么是内存所有权?

在 C++中,术语内存所有权指的是负责执行特定内存分配生命周期的实体。实际上,我们很少谈论原始内存的所有权。通常,我们管理驻留在该内存中的对象的所有权和生命周期,而内存所有权实际上只是对象所有权的简称。内存所有权的概念与资源所有权的概念紧密相关。首先,内存是一种资源。它不是程序可以管理的唯一资源,但它是迄今为止最常用的一个。其次,C++管理资源的方式是让对象拥有它们。因此,管理资源的问题简化为管理拥有对象的 问题,正如我们刚刚学到的,这就是我们谈论内存所有权时真正所指的。在这种情况下,内存所有权不仅仅是关于拥有内存,管理不当的所有权可能会导致泄漏、误计或丢失任何程序可以控制的资源——内存、互斥锁、文件、数据库句柄、猫视频、航班座位预订或核弹头。

优秀设计的内存所有权

设计良好的内存所有权看起来是什么样子?首先浮现出的朴素答案是,在程序的每个点上,都清楚谁拥有哪个对象。然而,这过于约束——程序的大部分内容并不处理资源的所有权,包括内存。这些程序部分仅仅使用资源。在编写此类代码时,知道特定的函数或类不拥有内存就足够了。知道谁做什么是完全无关紧要的:

struct MyValues { long a, b, c, d; }
void Reset(MyValues* v) {
  // Don't care who owns v, as long as we don't
  v->a = v->b = v->c = v->d = 0;
}

那么,这样呢——在程序的每个点上,是否清楚谁拥有那个对象,或者是否清楚所有者没有改变?这更好,因为大部分代码将属于我们答案的第二部分。然而,这仍然过于约束——在获取对象所有权时,通常并不重要知道它是从哪里获取的:

class A {
  public:
  // Constructor transfers ownership from whomever
  A(std::vector<int>&& v) : v(std::move(v)) {}
  private:
  std::vector<int> v_;    // We own this now
};

同样,共享所有权的全部意义(通过引用计数的std::shared_ptr表达)是我们不需要知道谁还拥有该对象:

class A {
  public:
  // No idea who owns v, don't care
  A(std::shared_ptr<std::vector<int>> v) : v_(v) {}
  // Sharing ownership with any number of owners
  private:
  std::shared_ptr<std::vector<int>> v_;
};

对设计良好的内存所有权的更准确描述需要不止一句话。一般来说,以下是一些良好的内存所有权实践的特征:

  • 如果一个函数或类以任何方式不改变内存所有权,这一点应该对每个函数或类的客户端以及实现者都是清晰的。

  • 如果一个函数或类独占拥有传递给它的某些对象,这一点应该对客户端是清晰的(我们假设实现者已经知道这一点,因为他们必须编写代码)。

  • 如果一个函数或类与它传递的对象共享所有权,这一点应该对客户端(或者任何阅读客户端代码的人)是清晰的。

  • 对于每个创建的对象,对于每个使用它的代码单元,都清楚这段代码是否预期删除该对象。

设计不良的内存所有权

正如良好的内存所有权无法用简单的描述来概括,而是通过它满足的一系列标准来定义一样,不良的内存所有权实践也可以通过它们的共同表现来识别。一般来说,良好的设计使代码是否拥有资源变得清晰,而糟糕的设计则需要额外的知识,这些知识不能从上下文中推断出来。例如,以下MakeWidget()函数返回的对象由谁拥有?

Widget* w = MakeWidget();

客户是否需要在不再需要时删除小部件?如果是,应该如何删除?如果我们决定删除小部件并以错误的方式执行,例如,在实际上没有通过operator new分配的小部件上调用operator delete,则肯定会发生内存损坏。在最佳情况下,程序将只是崩溃:

WidgetFactory WF;
Widget* w = WF.MakeAnother();

工厂是否拥有它创建的小部件?当工厂对象被删除时,它会删除它们吗?或者,客户端被期望去做这件事?如果我们决定工厂可能知道它创建了什么,并且将在适当的时候删除所有这样的对象,我们可能会遇到内存泄漏(或者更糟,如果对象拥有其他资源):

Widget* w = MakeWidget();
Widget* w1 = Transmogrify(w);

Transmogrify() 是否拥有小部件的所有权?在 Transmogrify() 完成对小部件的处理后,w 小部件是否仍然存在?如果为了构建一个新的、经过转换的 w1 小部件而删除了小部件,我们现在有一个悬垂指针。如果我们没有删除小部件,但假设它可能被删除,我们就有了一个内存泄漏。

不要以为所有糟糕的内存管理实践都可以通过存在原始指针来识别,这里有一个关于内存管理的较差方法的例子,这种做法通常是对使用原始指针造成的问题的一种本能反应:

void Double(std::shared_ptr<std::vector<int>> v) {
  for (auto& x : *v) {
    x *= 2;
  }
};
...
std::shared_ptr<std::vector<int>> v(...);
Double(v);
...

Double() 函数在其接口中声称它对向量拥有共享所有权。然而,这种所有权完全是多余的——Double() 没有必要拥有其参数——它不试图延长其生命周期,也不将所有权转让给任何人;它只是修改了调用者传入的向量。我们可以合理地期望调用者拥有该向量(或者甚至更高层次的调用栈中的某人拥有),并且当 Double() 将控制权返回给调用者时,向量仍然存在——毕竟,调用者希望我们将元素加倍,可能以便进行其他操作。

虽然这个列表远非完整,但它有助于展示由于对内存所有权的草率处理可能引起的问题范围。在下一节中,我们将回顾 C++ 社区开发的模式和指南,以帮助避免这些问题并清楚地表达程序员的意图。

在 C++ 中表达内存所有权

在其历史发展过程中,C++语言在表达内存所有权方面的方法不断演变。有时,相同的语法结构被赋予了不同的假设语义。这种演变部分是由语言中添加的新特性所驱动的(如果没有共享指针,就很难谈论共享内存所有权)。另一方面,C++11 及以后版本中添加的大多数内存管理工具并非新想法或新概念。共享指针的概念已经存在很长时间了。这种语言支持使得实现它变得更加容易(并且标准库中的共享指针使得大多数自定义实现变得不必要),但共享指针在 C++11 将其添加到标准之前就已经在 C++中使用。更为重要的变化是 C++社区对内存所有权的理解演变,以及共同实践和习惯用法的出现。正是在这个意义上,作为与不同语法特征相关联的一组惯例和语义,我们可以将内存管理实践视为 C++语言的设计模式。现在,让我们学习可以表达不同类型内存所有权的不同方法。

表达非拥有权

让我们从最常见的内存所有权类型开始。大多数代码并不分配、释放、构造或删除。它只是在由他人先前创建并由他人稍后删除的对象上执行其工作。你如何表达一个函数将要操作一个对象,但不会尝试删除它,或者相反,在函数本身完成之后延长其生命周期的概念?

实际上非常简单,每个 C++程序员都多次这样做:

// Example 01
void Transmogrify(Widget* w) {        // I will not delete w
  ...
}
void MustTransmogrify(Widget& w) {   // Neither will I
  ...
}

在一个编写良好的程序中,具有原始指针参数的函数表明它以任何方式都不参与相应对象的拥有权;引用也是同样的情况。同样,包含成员函数指针的类引用一个对象,但期望其他人拥有它并管理其生命周期。请注意,下一个示例中WidgetProcessor类的析构函数不会删除该类所指向的对象——这是我们不拥有该对象的明确标志:

// Example 02
class WidgetProcessor {
  public:
  WidgetProcessor(Widget* w) : w_(w) {}
  WidgetProcessor() {} // DO NOT delete w_!!!
    ...
  private:
  Widget* w_;    // I do not own w_
};

应该通过使用原始指针或引用来授予对对象的非拥有访问权限。是的——即使在 C++14 中,尽管它拥有所有智能指针,原始指针也有其位置。不仅如此,在大量代码中,大多数指针将是原始指针——所有非拥有指针(正如我们将在下一节中看到的,C++17 和 C++20 在这方面走得更远)。

你可能会在这个时候合理地指出,前面推荐的授予非拥有访问权的示例看起来与之前展示的坏习惯示例之一完全一样。区别在于上下文——在一个设计良好的程序中,只有非拥有访问权是通过原始指针和引用授予的。实际的所有权总是以某种其他方式表达。因此,当遇到原始指针时,函数或类不会以任何方式干涉对象的所有权。当然,这会在将带有原始指针的旧遗产代码转换为现代实践时造成一些混淆。为了清晰起见,建议一次转换一部分代码,并在遵循现代指南的代码和不遵循的代码之间清楚地标明过渡。

另一个需要讨论的问题是指针与引用的使用。就语法而言,引用基本上是一个永远不会为空的指针,并且不能被未初始化。采用一种约定,任何传递给函数的指针都可能为空,因此必须进行检查,任何不能接受空指针的函数必须改为接受引用。这是一个好习惯,并且被广泛使用,但使用得还不够广泛,以至于可以被视为一个接受的设计模式。也许是为了认识到这一点,C++ Core Guidelines 库提供了一个表达非空指针的替代方案——not_null<T*>。请注意,这本身不是语言的一部分,但可以在标准 C++ 中实现,而不需要任何语言扩展。

表达独占所有权

第二种最常见的所有权类型是独占所有权——代码创建一个对象,稍后会删除它。删除任务不会被委托给其他人,并且不允许对象寿命的扩展。这种内存所有权如此常见,以至于我们经常这样做,甚至没有意识到:

void Work() {
  Widget w;
  Transmogrify(w);
  Draw(w);
}

所有局部(栈)变量都表达独特的内存所有权!请注意,在这个上下文中,“所有权”并不意味着其他人不会修改该对象。它仅仅意味着当w小部件的创建者——在我们的例子中是DoWork()函数——决定删除它时;删除将成功(还没有人删除它)并且对象实际上将被删除(没有人试图在作用域结束时保持对象存活)。

这是在 C++ 中构建对象的最古老方式,但仍然是最好的方式。如果栈变量能满足你的需求,就使用它。C++ 11 提供了另一种表达独家所有权的方法,它主要用在对象不能在栈上创建而必须分配在堆上的情况下。堆分配通常发生在所有权共享或转移时——毕竟,栈分配的对象将在包含作用域的末尾被删除;这是无法避免的。如果我们需要让对象存活更长时间,它必须分配在其他地方。创建对象在堆上的另一个原因是对象的大小或类型可能在编译时未知。这通常发生在对象是多态的情况下——创建了一个派生对象,但使用了基类指针。无论不分配对象在栈上的原因是什么,我们都有一种方法可以使用 std::unique_ptr 来表达这种对象的独家所有权:

// Example 03
class FancyWidget : public Widget { ... };
std::unique_ptr<Widget> w(new FancyWidget);

还有一个技术原因,即使栈分配的对象似乎足够用,你也可能需要在堆上构建对象:栈的大小相当有限,通常在 2 MB 到 10 MB 之间。这是单个线程中所有栈分配的空间,当它超过这个限制时,程序会崩溃。足够大的对象可能会耗尽栈空间,或者将其推得太接近限制,以至于后续的分配无法进行。这样的对象必须创建在堆上,并由栈分配的唯一指针或其他资源拥有对象拥有。

如果创建对象的方式比仅仅使用 operator new 更复杂,我们需要一个工厂函数?这就是我们将考虑的类型的所有权。

表达独家所有权的转移

在前面的例子中,创建了一个新的对象,并立即将其绑定到一个唯一的指针,std::unique_ptr,这保证了独家所有权。如果对象是由工厂创建的,客户端代码看起来完全一样:

std::unique_ptr<Widget> w(WidgetFactory());

但工厂函数应该返回什么?它当然可以返回一个原始指针,Widget*。毕竟,这就是 new 返回的内容。但这为错误使用 WidgetFactory 开辟了道路——例如,我们可能不会在唯一指针中捕获返回的原始指针,而是将其传递给一个像 Transmogrify 这样的函数,该函数接受原始指针因为它不处理所有权。现在,没有人拥有小部件,最终导致内存泄漏。理想情况下,WidgetFactory 应该被编写成强制调用者接收返回对象的拥有权。

我们在这里需要的是所有权转移——WidgetFactory 当然是它所构建的对象的独家所有者,但在某个时候,它需要将所有权转交给一个新的、也是独家所有者。执行此操作的代码非常简单:

// Example 04
std::unique_ptr<Widget> WidgetFactory() {
  Widget* new_w = new Widget;
    ...
  return std::unique_ptr<Widget>(new_w);
}
std::unique_ptr<Widget> w(WidgetFactory());

这正是我们想要的方式,但为什么?难道唯一指针不提供独占所有权吗?答案是,它确实提供了,但它也是一个可移动对象(它有一个移动构造函数)。将唯一指针的内容移动到另一个唯一指针中会转移对象的所有权;原始指针将留在移动前的状态(它的销毁不会删除任何对象)。这种习语有什么好处呢?它清楚地表达了,并在编译时强制要求,工厂期望调用者对对象拥有独占(或共享)所有权。例如,以下代码,它会导致新的小部件没有所有者,无法编译:

void Transmogrify(Widget* w);
Transmogrify(WidgetFactory());

那么,在我们正确假定所有权之后,我们如何调用Transmogrify()函数呢?这仍然是通过原始指针来完成的:

std::unique_ptr<Widget> w(WidgetFactory());
Transmogrify(w.get());
Transmogrify(&*w);     // same as above if w is not null

但栈变量怎么办?在变量被销毁之前,所有权能否转移到其他人那里?这将会稍微复杂一些——对象的内存是在栈上分配的,并且正在消失,因此涉及到一些复制操作。确切需要复制的量取决于对象是否可移动。一般来说,移动操作会将所有权从被移动的对象转移到移动到的对象。这可以用于返回值,但更常用于传递参数给需要独占所有权的函数。这些函数必须声明为通过rvalue引用T&&来接受参数:

// Example 05
void Consume(Widget&& w) {
  auto my_w = std::move(w);
    ...
}
Widget w, w1;
Consume(std::move(w));    // No more w
// w is in a moved-from state now
Consume(w1);    // Does not compile - must consent to move

注意,调用者必须通过将参数包裹在std::move中来显式放弃所有权。这是这种习语的优点之一;没有它,所有权转移的调用将看起来与普通调用完全相同。

表达共享所有权

我们需要覆盖的最后一种所有权类型是共享所有权,其中多个实体平等地拥有该对象。首先,提醒一下——共享所有权经常被误用,或者过度使用。考虑前面的例子,其中函数传递了一个它不需要拥有的对象的共享指针。让引用计数处理对象的所有权并且不担心删除是很诱人的。然而,这通常是设计不佳的迹象。在大多数系统中,在某个层面上,资源有明确的归属,并且这一点应该反映在资源管理的选择设计中。不担心删除的担忧仍然有效;显式删除对象应该是罕见的,但自动删除不需要共享所有权,只需要明确表达的所有权(唯一指针、数据成员和容器同样可以提供自动删除)。

话虽如此,共享所有权确实有一些明确的用例。共享所有权最常见的有效应用是在底层,例如在列表、树等数据结构内部。一个数据元素可能被同一数据结构的其他节点拥有,也可能被任何数量的当前指向它的迭代器拥有,还可能是由操作整个结构或其一部分(例如树的重新平衡)的数据结构成员函数中的某些临时变量拥有。在精心设计的情况下,整个数据结构的所有权通常是明确的。但每个节点或数据元素的所有权在真正意义上可能是共享的,即任何所有者都等同于其他所有者;没有一个是特权或主要的。

在 C++中,共享所有权的概念通过共享指针std::shared_ptr来表示:

// Example 06
struct ListNode {
  T data;
  std::shared_ptr<ListNode> next, prev;
};
class ListIterator {
  ...
  std::shared_ptr<ListNode> node_;
};
class List {
  ...
  std::shared_ptr<ListNode> head_;
};

这种设计的优点是,从列表中解除链接的列表元素只要存在一种通过迭代器访问它的方式就会保持活跃。这并不是std::list的做法,它不提供这样的保证(删除std::list对象会使所有迭代器失效)。请注意,共享指针的双向链表使得列表中任何两个连续的节点都相互拥有对方,即使列表头被删除,它们也不会被删除;这会导致拥有的对象泄漏。因此,实际的设计可能会使用std::weak_pointer作为nextprev之一。

除了这些复杂性之外,这可能是一些应用程序的有效设计,在这些应用程序中,迭代器需要在列表被删除或某些元素从列表中删除后仍然拥有它们所引用的数据。一个例子是线程安全的列表,在这种情况下,很难保证一个线程不会在另一个线程仍然有一个指向它的迭代器时删除列表元素。请注意,这个特定的应用程序还需要原子共享指针,这些指针仅在 C++ 20 中可用(或者你可以使用 C++ 11 编写自己的)。

现在,关于接受共享指针作为参数的函数呢?在一个遵循良好内存所有权实践的程序中,这样的函数会向调用者传达它打算获取比函数调用本身持续时间更长的部分所有权——将创建共享指针的副本。在并发环境中,这也可能表明函数需要保护对象免受另一个线程删除,至少在它执行期间。

共享所有权的几个缺点你必须牢记在心。最著名的一个是共享指针的祸害,即循环依赖。如果两个具有共享指针的对象相互指向对方,整个对将无限期地保持使用状态。C++通过std::weak_ptr的形式提供了一个解决方案,它是共享指针的对应物,提供了一个指向可能已经被删除的对象的安全指针。如果前面提到的对象对使用了一个共享指针和一个弱指针,循环依赖就会被打破。

循环依赖问题确实是存在的,但它更常出现在使用共享所有权来掩盖资源所有权不明确这一更大问题的设计中。然而,共享所有权还有其他缺点。共享指针的性能始终会低于原始指针。另一方面,唯一指针可以与原始指针一样高效(实际上,std::unique_ptr 就是)。当共享指针首次创建时,必须进行额外的内存分配来存储引用计数。

在 C++11 中,可以使用 std::make_shared 来结合对象本身的分配和引用计数器的分配,但这意味着对象是带有共享意图创建的(通常,对象工厂返回的是唯一指针,其中一些后来被转换为共享指针)。复制或删除共享指针时,也必须增加或减少引用计数器。共享指针在并发数据结构中通常很有吸引力,在这些数据结构中,至少在低级别上,所有权的概念可能确实模糊,同时有多个访问同一对象的情况发生。然而,设计一个在所有上下文中都线程安全的共享指针并不容易,并且会带来额外的运行时开销。

到目前为止,我们主要将指针作为拥有对象(及其内存和其他资源)的手段(非拥有性也通过原始指针和引用或简单的非拥有性指针来表示)。然而,这并不是拥有资源的唯一方式(我们之前也提到,最常见的独占拥有形式是栈变量)。现在我们将看到如何直接使用拥有资源的对象来表示拥有性和非拥有性。

拥有对象和视图

C++ 自创建以来并未局限于拥有指针:任何对象都可以拥有资源,我们之前也提到,表达独占所有权的最简单方式是在栈上创建一个局部变量。当然,这样的对象也可以被指针(唯一或共享)拥有,当需要非拥有性访问时,这些对象通常通过原始指针或引用来访问。然而,在 C++17 和 C++20 中出现了一种不同的模式,这值得探索。

拥有资源的对象

每个熟悉 C++ 的程序员都熟悉拥有资源的对象;可能最常见的一个是 std::string – 一个拥有字符字符串的对象。当然,它也有许多专门的操作字符串的成员函数,但从内存所有权的角度来看,std::string 实质上是一个拥有 char* 指针。同样,std::vector 是任意类型对象数组的拥有对象。

构建此类对象最常见的方式是作为局部变量或作为类的数据成员。在后一种情况下,整个类的所有权问题由其他地方管理,但在类内部,所有数据成员都由对象本身独占拥有。考虑这个简单的例子:

class C {
  std::string s_;
};
 …
std::vector<int> v = … ;    // v owns the array of int
C c;                       // c owns the string s

到目前为止,与本章前面几页关于独占所有权的部分相比,我们并没有说出任何新的内容。然而,我们已经微妙地将焦点从拥有指针转变为拥有对象。只要我们关注所有权方面,这些对象本质上就是专门的拥有(唯一)指针。然而,有一个重要的区别:大多数这样的对象会传递额外的信息,例如std::string的字符串长度或std::vector的数组大小。请记住:当我们到达 C++17/20 带来的变化时,这个问题还会再次出现。

虽然资源拥有对象自 C++开始以来就存在,但它们自身通常是通过指针拥有的。可能有两个主要原因;这两个原因都已被 C++的进步所淘汰。例如,通过拥有指针拥有字符串的第一个原因是需要转移所有权。栈对象在作用域结束时被销毁。类数据成员在对象被销毁时被销毁。在两种情况下,都没有办法将对象本身的所有权(例如std::string)转移到其他人。然而,如果我们关注所有权方面,那么字符串对象本身就是一个(装饰过的)拥有指针,目标是把底层资源(std::string的字符字符串)的所有权转移到另一个所有者。当我们这样表述时,答案很明显:自 C++11 以来,字符串有了移动语义,移动字符串几乎和移动指针一样便宜(记住,字符串是一个知道长度的拥有指针,所以长度也必须移动)。

我们可以更普遍地说,如果唯一的原因是所有权转移,就没有必要通过指针拥有一个易于移动的所有权对象。例如,考虑这个字符串构建器类:

class Builder {
  std::string* str_;
  public:
  Builder(…) : str_(new std::string){
    … construct string str_ …
  }
  std::string* get(){
    std::string* tmp = str_;
    str_ = nullptr;
    return tmp;
  }
};

虽然这样做可以完成任务,但编写相同类的更好方法是简单地移动字符串:

// Example 07
class Builder {
  std::string str_;
  public:
  Builder(…){ … construct string str_ … }
  std::string get(){ return std::move(str_); }
};
std::string my_string = Builder(…).get();

对于构建拥有易于移动对象的工厂来说,也是如此。工厂不是通过std::unique_ptr返回它们,而是可以直接返回对象本身:

std::string MakeString(…) {
  std::string str;
  … construct the string …
  return str;
}
std::string my_string = MakeString(…);

返回值可能受益于返回值优化(编译器直接在为最终对象my_string分配的内存中构造返回值)。即使没有这种优化,我们也有保证这里没有复制字符串,只有移动(如果这个移动被优化掉,这种优化有时被称为移动省略,类似于更为人所知的复制省略,它优化掉了复制构造函数)。

使用所有权指针来处理资源拥有对象的第二个原因是对象的存在本身可能是条件性的:

std::unique_ptr<std::string*> str;
if (need_string) str.reset(new std::string(…args…));

在许多情况下,可以使用“”对象来代替,例如零长度的字符串。同样,对于许多拥有对象,尤其是对于所有廉价的移动 STL 容器,构建此类对象的开销是微不足道的。但是,空字符串和完全没有字符串(即空字符串可能是一个有效的结果,而没有任何字符串的存在对程序的其他部分有特殊意义)之间可能存在有意义的差异。在 C++17 中,我们可以使用std::optional来直接表达这种行为:

std::optional<std::string> str;
if (need_string) str.emplace(…args…);

std::optional<std::string>类型的对象可能包含一个字符串或为空。非空的std::optional拥有它包含的对象(删除std::optional也会删除字符串)。与std::unique_pointer不同,这里没有堆内存分配:std::optional对象内部有足够的空间来存储std::string对象。std::optional也是可移动的,就像字符串本身一样,因此这种模式可以与之前的一种模式结合。总的来说,我们可以说在现代 C++中,没有必要间接拥有像std::string这样的轻量级所有权对象。然而,表达此类对象非所有权的做法直到最近才得到足够的关注。

对资源拥有对象的非所有权访问

我们已经看到std::string对象在大多数情况下可以替代char*(或std::string)的所有权指针。那么,我们如何表达非所有权访问呢?让我们假设我们需要将一个字符串传递给一个操作字符串但不拥有它(不销毁它)的函数。这是一个简单的练习:

void work_on_string(const std::string& str);
std::string my_string = …;
work_on_string(my_string);

这就是我们自从 C++被创建以来一直在做的事情。但这种简单性隐藏着一个深刻的区别:记住,只要我们不关心所有额外的方法和它们提供的功能,std::string就只是一个指向字符字符串的所有权指针,同时它也知道自己的长度。那么,如果我们用一个所有权指针而不是字符串来处理相同的情况,我们会怎么处理呢?相应的指针是std::unique_ptr<char[]>,所以我们会写一些像这样的事情:

void work_on_string(const char* str);
std::unique_ptr<char[]> my_string(new char[…length…]);
… initialize the string …
work_on_string(my_string.get());

按照之前的指南,我们向函数传递了一个非所有权的原始指针。我们绝对不会写这个声明:

void work_on_string(const std::unique_ptr<char[]>& str);

然而,当相同的字符数组由std::string对象拥有时,我们却毫不犹豫地这样做。为什么我们对这些非常相似的问题采取如此不同的方法?现在是时候记住为什么字符串不仅仅是一个限制在字符数组中的拥有指针;它包含的信息不仅仅是指针:它还知道字符串的长度。在 C++中没有很好的方法可以授予对这种“丰富”拥有指针的非拥有访问权限,除非通过引用传递整个指针对象。相比之下,唯一指针(或任何其他拥有指针)包含的信息与基本指针相同,所以当不需要所有权时,拥有指针自然地减少到原始指针,而没有任何信息损失。

这种差异不仅仅是关于对称性。考虑一下,通过const引用传递字符串可以防止work_on_string函数改变字符串的内容。另一方面,非const引用允许函数清除字符串(释放它拥有的内存),这是所有权的一个方面。我们被迫通过混合两种无关的类型来模糊意图的清晰性,我们可以授予函数的访问类型:改变数据内容的能力和数据所有权。

C++17 在非常有限的范围内解决了这个问题:具体来说,对于字符串,它引入了一个新的类型std::string_view。字符串视图是一个指向字符串的(const)非拥有指针,同时也存储了字符串的长度。换句话说,它是一个完美的非拥有等价物,类似于std::string:字符串视图到字符串的关系正好与const原始指针到唯一指针的关系相同。现在,为了授予对std::string对象的非拥有访问权限,我们编写:

// Example 09
void work_on_string(std::string_view str);
std::string my_string = …;
work_on_string(my_string);

相比之下,一个接收std::string对象所有权的函数仍然必须通过引用来接收它。具体来说,使用右值引用来转移所有权:

// Example 09
void consume_string(std::string&& str);
std::string my_string = …;
consume_string(std::move(my_string));
// Do not use my_string anymore!

仅使用非const左值引用来允许函数更改字符串;在 C++17 中,没有好的丰富指针可以与非const原始指针等价。可能没有必要使用const std::string&,除非现有的接口需要它,因为std::string_view提供了等效的功能。

使用std::string_view(特别是,它极大地简化了处理 C 和 C++字符串的常见代码的编写)还有其他好处和优势,但在这个章节中,我们关注的是所有权方面。此外,记住字符串视图仅限于字符字符串。我们可以就另一个拥有类进行完全相同的讨论,例如,std::vector<int>

我们现在看到一个新的模式出现:对于“丰富”的拥有指针,除了拥有内存外,还包含有关它所拥有的数据的一些信息,相应的非拥有对象(相当于原始指针)应该是一个视图对象,它包含相同的信息但不拥有资源。我们在 C++20 中找到了这个视图对象,即std::span。在此之前,唯一授予对整数向量非拥有访问权的好方法是按引用传递它:

void work_on_data(std::vector<int>& data);
std::vector<int> my_data = …;
work_on_data(my_data);

在 C++20 中,我们可以使用范围来清楚地区分非拥有视图(原始指针等价物)和拥有对象(唯一指针等价物):

// Example 10
void ModifyData(std::span<int> data);
std::vector<int> my_data = …;
ModifyData(my_data); // Can change my_data

因此,std::span<int>是一个与int*等价的“丰富指针”——它包含一个非const指针,大小便宜地复制且不拥有它所指向的资源。与std::string_view不同,我们可以修改通过范围访问的对象。但如果我们想要const指针的等价物,我们可以使用std::span<const int>

// Example 10
void UseData(std::span<const int> data);
std::vector<int> my_data = …;
UseData(my_data); // Cannot change my_data

由于std::string包含一个连续的字符数组,它也可以与范围一起使用,在这种情况下,是std::span<char>std::span<const char>。后者本质上与std::string_view相同,包括从字符串字面量构造它们的选择。前者相当于非constchar指针。

范围与向量或字符串配合良好,因为它们提供了对数组的非拥有视图。但对于其他 STL 容器则不适用,因为它们都在多个非连续分配中分配内存。为此,我们需要使用 C++20 的范围库。例如,将前面的非拥有向量访问泛化到任意容器可以写成这样:

// Example 11
void ModifyData(std::ranges::view auto data) { … }
std::list<int> my_data = …;
ModifyData(std::views::all(my_data));

如果你从未见过 C++20 的模板,这需要一些习惯。第一行是一个模板函数:auto参数使“普通”函数成为模板,即使没有template关键字。在auto之前的std::ranges::view咒语限制了模板参数只能满足视图概念。视图是一个类似容器的对象,它具有begin()end()成员函数,并且,此外,必须便宜地移动,或者便宜地复制或不可复制(这当然是对标准中列举的精确要求的宽松解释)。我们可以用templaterequires关键字编写相同的函数,但这种紧凑的语法在 C++20 中是惯用的。

注意,在这种基于概念的开发风格中,对函数参数的限制由概念要求指定。我们可以编写相同的模板函数,要求范围而不是视图:

void ModifyData(std::ranges::range auto data) { … }
std::list<int> my_data = …;
ModifyData(my_data);

范围本质上是有begin()end()的任意对象,所以std::list是一个范围(但不是视图,它可以复制但并不便宜)。注意,按照目前的写法,函数是通过值传递参数的,因此会创建一个副本。除非这是意图(在这种情况下,并不是),正确编写此函数的方式如下:

void ModifyData(std::ranges::range auto&& data) { … }

如果我们想要表达非修改性访问,const引用也会起作用。但需要注意的是,我们不必对视图做同样的事情:通过将work_on_data函数限制为仅接受视图,我们已经将其限制为类似std::string_view(或原始指针)的便宜复制类型。确实,通过引用传递范围就像传递字符串或向量本身一样:这给了被调用者访问所有权。如果我们想要编写一个明确不拥有范围的所有权的函数,视图是表达这种意思的正确方式。

对于 C++20 范围的模式,现在谈论还为时过早:它们存在的时间还不够长,还没有形成公认的、被广泛接受的使用惯例(模式所必需的要求),并且库仍然不完整。预计 C++23 将包含几个重大增强(特别是,C++20 范围中没有std::span<const char>的良好等效项——它将在 C++23 中添加)。

然而,我们可以自信地谈论在 C++中逐渐形成的更一般的模式:资源所有权,包括内存,应由拥有对象来处理,而非拥有访问应通过视图来授予。拥有对象可以是智能指针或更复杂和专业的容器对象。这些容器除了以更复杂的方式管理内存外,还嵌入有关它们包含的数据的更多信息。一般来说,对于每个容器,都应该有一个相应的视图,它授予非拥有访问权限,同时保留所有附加信息。对于智能指针,这个视图是一个原始指针或引用。对于std::string,这个视图是std::string_view。对于std::vector、数组以及任何其他拥有连续内存的容器,你将想要std::span。对于任意容器,相应的视图可以在 C++20 范围库中找到;对于自定义容器,你可能还需要编写自己的视图对象(只需确保它们满足相关的视图概念即可)。

摘要

在 C++中,内存所有权实际上只是对象所有权的简称,而对象所有权则是管理任意资源、其所有权和访问的方式。我们已经回顾了 C++社区为表达不同类型的内存所有权而开发的当代习惯用语。C++允许程序员表达独占或共享内存所有权。同样重要的是,在关于资源所有权无知的程序中表达非所有权。我们还了解了一个设计良好的程序中资源所有权的实践和属性。

我们现在有了习惯用语来清楚地表达程序中哪个实体拥有每个对象或资源,以及何时授予非拥有访问权限。下一章将介绍对资源进行最简单操作的习惯用语:交换或交换。

问题

  1. 为什么在程序中清楚地表达内存所有权很重要?

  2. 不清晰的内存所有权会导致哪些常见问题?

  3. C++中可以表达哪些类型的内存所有权?

  4. 你如何编写不拥有内存的函数和类?

  5. 为什么应该优先选择独占内存所有权而不是共享所有权?

  6. 你如何在 C++中表达独占内存的所有权?

  7. 你如何在 C++中表达共享内存的所有权?

  8. 共享内存所有权的潜在缺点是什么?

  9. 视图是什么?字符串视图为什么比通过引用传递字符串更好?

进一步阅读

第二部分:常见的 C++惯用法

本部分描述了一些更常见的 C++惯用法:这些是公认的、普遍认可的方式来表达特定的想法或实现频繁需要的任务。在“模式”和“惯用法”之间的界限模糊至极。在这本书中,我们认为更完整的设计解决方案是模式,而更简单的技术是惯用法。换句话说,选择一个模式可能会影响你整个应用程序或其主要组件的设计,而使用惯用法则更多是借鉴他人的错误而做出的实现决策。

本部分包含以下章节:

  • 第四章交换 - 从简单到微妙

  • 第五章全面审视 RAII

  • 第六章理解类型擦除

  • 第七章SFINAE、概念和重载解析管理

第四章:交换——从简单到微妙

我们从一个非常简单、甚至可以说是谦逊的操作——std::swap开始,来探索基本的 C++惯用法。请放心,C++能够将交换这样基本的事情变成一个复杂的问题,具有细微的差别。

本章涵盖了以下主题:

  • 标准 C++库中是如何使用swap的?

  • 交换的应用有哪些?

  • 我们如何使用交换编写异常安全的代码?

  • 我们如何正确地为我们自己的类型实现交换?

  • 我们如何正确地交换任意类型的变量?

技术要求

这里有一个链接到本章的所有示例代码:github.com/PacktPublishing/Hands-On-Design-Patterns-with-CPP-Second-Edition/tree/main/Chapter04

这是一个链接到 C++核心指南:github.com/isocpp/CppCoreGuidelines/blob/master/CppCoreGuidelines.md

这是一个链接到 C++ 指南支持库GSL):github.com/Microsoft/GSL

交换与标准模板库

交换操作在 C++标准库中被广泛使用。所有std::swap。STL 算法中也有交换的使用。标准库也是一个模板,用于实现类似于标准库的自定义功能。

因此,我们将从查看标准提供的功能开始研究交换操作。

交换与 STL 容器

从概念上讲,交换等同于以下操作:

template <typename T> void swap(T& x, T& y) { T tmp(x);
  x = y;
  y = tmp;
}

在调用swap()之后,xy对象的内容被交换。然而,这可能是实现交换的最糟糕的方式。这种实现的第一和最明显的问题是它不必要地复制了两个对象(实际上进行了三次复制操作)。这个操作的执行时间与T类型的大小成比例。对于 STL 容器,大小指的是实际容器的大小,而不是元素类型的大小:

void swap(std::vector<int>& x, std::vector<int>& y) {
  std::vector<int> tmp(x);
  x = y;
  y = tmp;
}

注意,这段代码可以编译,并且在大多数情况下甚至做对了。然而,它复制了向量的每个元素多次。第二个问题是它临时分配了资源——例如,在交换过程中,我们创建了一个使用与被交换的向量一样多的内存的第三个向量。考虑到最终状态中我们拥有的数据与开始时完全相同;只是我们用来访问这些数据的名称发生了变化,这种分配似乎是不必要的。原始实现中的最后一个问题是在考虑例如我们刚才提到的内存分配失败时出现的。

整个交换操作,本应像交换访问向量元素的名称一样简单且万无一失,却因为内存分配失败而失败。

但这并不是它失败的唯一方式——拷贝构造函数和赋值运算符都可以抛出异常。

所有 STL 容器,包括std::vector,都提供保证,它们可以在常数时间内进行交换。如果你考虑到 STL 容器对象本身只包含指向数据的指针和一些状态(如对象大小),那么这种实现方式相当直接。为了交换这些容器,我们只需要交换指针(以及当然的其他状态)——容器中的元素仍然保持在它们始终所在的位置,即在动态分配的内存中,不需要复制或甚至访问。交换的实现只需要交换指针、大小和其他状态变量(在真实的 STL 实现中,容器类,如向量,并不直接由内置类型(如指针)的数据成员组成,而是有一个或多个类数据成员,这些数据成员反过来由指针和其他内置类型组成)。

由于任何指针或其他向量数据成员都不是公开可访问的,交换操作必须实现为容器的成员函数,或者声明为友元。STL 采用前者方法——所有 STL 容器都有一个swap()成员函数,用于与同一类型的另一个对象交换对象(参见示例01a01b)。

通过交换指针来实现这一点的实现间接地解决了我们提到的另外两个问题。首先,因为只有容器的数据成员被交换,所以没有内存分配。其次,复制指针和其他内置类型不能抛出异常,因此整个交换操作不会抛出(也不能以其他方式失败)。

我们迄今为止描述的简单且一致的图景只是大部分情况下是正确的。第一个复杂问题,而且是最简单的一个,仅适用于那些不仅参数化元素类型,还参数化某种可调用对象的容器。例如,std::map容器接受可选的比较函数来比较映射中的元素,默认情况下是std::less。这些可调用对象必须与容器一起存储。由于它们被频繁调用,出于性能考虑,最好将它们与容器对象本身放在相同的内存分配中,并且确实它们被作为容器类的数据成员。

然而,这种优化是有代价的——现在交换两个容器需要交换比较函数;也就是说,交换的是实际的对象,而不是指向它们的指针。比较对象由库的客户实现,因此没有保证交换它们是可能的,更不用说它不会抛出异常。

因此,对于std::map,标准提供了以下保证——为了使映射可交换,可调用对象也必须是可交换的。此外,交换两个映射不会抛出异常,除非交换比较对象可能会抛出异常,在这种情况下,任何由该交换抛出的异常都将从std::map交换中传播。

这种考虑不适用于不使用任何可调用对象的容器,例如std::vector,并且交换这些容器仍然不会抛出异常(至少到目前为止我们所知)。

在交换操作中,除了交换操作本身的一致性和自然行为之外,另一个复杂问题是由分配器引起的,这是一个难以解决的问题。考虑以下问题——两个交换的容器必须必然具有相同类型的分配器,但不一定是相同的分配器对象。每个容器都由其自己的分配器为其元素分配内存,并且它们必须由相同的分配器进行释放。交换之后,第一个容器拥有来自第二个容器的元素,并最终必须释放它们。这只能通过使用第一个容器的分配器(正确地)来完成;因此,分配器也必须进行交换。

allocator_type分配器类出现之前,C++标准中有一个trait类,它定义了诸如std::allocator_traits<allocator_type>::propagate_on_container_swap::value这样的特性属性,如果这个值是true,那么分配器将通过非成员交换的不限定调用进行交换;即简单的swap(allocator1, allocator2)调用(参见下一节了解该调用实际执行的操作)。如果这个值不是true,那么分配器根本不会交换,并且两个容器对象必须使用相同的分配器。如果这也不是true,那么我们就回到了未定义行为。STL 容器中的swap()成员函数将条件性地声明为noexcept(),具有相同的限制。

要求交换两个容器不能抛出异常,至少在分配器不涉及并且容器不使用可调用对象或使用不抛出异常的对象的情况下是这样,这最终对容器的实现施加了一个相当微妙限制——它阻止了使用局部缓冲区优化。

我们将在第十章中详细讨论这种优化,局部 缓冲区优化,但简而言之,这个想法是通过在容器类内部定义一个缓冲区来避免为元素非常少的容器(如短字符串)进行动态内存分配。然而,这种优化通常与非抛出异常的交换概念不兼容,因为容器对象内部的元素不能再通过交换指针来交换,而必须在不同容器之间进行复制。

非成员交换

标准还提供了一个模板std::swap()函数。在 C++11 之前,它在<algorithm>头文件中声明;在 C++11 中,它被移动到<utility>。函数的声明如下:

template <typename T>
  void swap (T& a, T& b);
template <typename T, size_t N>
  void swap(T (&a)[N], T (&b)[N]);    // Since C++11

C++11 中增加了数组重载。在 C++20 中,这两个版本都被额外声明为constexpr。对于 STL 容器,std::swap()调用成员函数swap()。正如我们将在下一节中看到的,swap()的行为也可以为其他类型定制,但如果没有特别的努力,默认实现就会被使用。这个实现使用一个临时对象进行交换。在 C++11 之前,临时对象是通过拷贝构造来创建的,交换是通过两个赋值操作完成的,就像我们在前一节中所做的那样。类型必须是可拷贝的(即必须可拷贝构造和可拷贝赋值),否则std::swap()将无法编译(参见示例02a02b)。在 C++11 中,std::swap()被重新定义为使用移动构造和移动赋值(参见示例02c)。通常,如果类是可拷贝的,但没有声明移动操作,那么就会使用拷贝构造函数和赋值操作。注意,如果类声明了拷贝操作,但声明了移动操作为删除,则没有自动回退到拷贝——那个类是非可移动类型,std::swap()将无法编译(参见示例02d)。

由于通常拷贝对象可能会抛出异常,因此对于没有提供自定义交换行为的两个对象进行交换也可能抛出异常。移动操作通常不会抛出异常,并且在 C++11 中,如果对象有一个移动构造函数和一个赋值操作符,并且它们都没有抛出异常,std::swap()也提供了一个无异常保证。这种行为在 C++17 中通过条件noexcept()规范得到了正式化。

标准化的交换

从对标准库如何处理交换的前述回顾中,我们可以得出以下准则:

  • 支持 swap 的类应该实现执行操作常时间的swap()成员函数

  • 应为所有可以交换的类型提供一个独立的swap()非成员函数

  • 交换两个对象不应该抛出异常或以其他方式失败

后一个准则不那么严格,并且并不总是可能遵循。一般来说,如果类型有不会抛出异常的移动操作,那么也可以有一个非抛出异常的 swap 实现。还要注意,许多异常安全性保证,特别是标准库提供的,要求移动和交换操作不能抛出异常。

何时以及为什么使用 swap

交换功能为什么如此重要,以至于值得拥有自己的章节?关于这一点,为什么甚至要使用 swap,而不是继续通过原始名称引用对象?

主要是因为异常安全性,这也是我们为什么一直提到何时 swap 可以抛出异常,何时不可以。

交换和异常安全性

在 C++中,交换操作最重要的应用是编写异常安全代码,或者更普遍地说,是错误安全代码。简而言之,在异常安全程序中,抛出异常不应该使程序处于未定义状态。

更普遍地说,错误条件不应该使程序处于未定义状态。请注意,错误不需要通过异常处理方式来处理——例如,从函数返回错误代码也应该在不创建未定义行为的情况下处理。特别是,如果操作导致错误,则应释放操作过程中已消耗的资源。通常,人们还希望有一个更强的保证——每个操作要么成功,要么完全回滚。

让我们考虑一个例子,我们将对向量的所有元素应用转换,并将结果存储在一个新的向量中:

// Example 03a
class C;            // Our element type
C transmogrify(C x) {    // Some operation on C
  return C(...);
}
void transmogrify(const std::vector<C>& in,
                  std::vector<C>& out) {
  out.resize(0);
  out.reserve(in.size());
  for (const auto& x : in) {
    out.push_back(transmogrify(x));
  }
}

在这里,我们通过输出参数返回向量。让我们假设使用一个已经存在的向量作为输出是一个要求,而我们并不是出于性能原因这样做。在所有最近的 C++版本中,通过值返回向量相当快:编译器要么应用返回值优化并完全省略复制(复制省略不是保证的但很可能是的)或者用移动来替换复制(也很快,是保证的)。向量最初为空,并增长到与输入向量相同的大小。out向量可能拥有的任何数据都消失了。注意,使用reserve()调用来避免重复释放正在增长的向量。

只要没有错误,即没有抛出异常,这段代码就能正常工作。但这并不保证。首先,reserve()执行内存分配,可能会失败。如果发生这种情况,transmogrify()函数将通过异常退出,并且输出向量将为空,因为resize(0)调用已经执行。输出向量的初始内容丢失,并且没有写入任何内容来替换它。其次,对向量元素的任何迭代都可能抛出异常。异常可能由输出向量的新元素的复制构造函数抛出,或者由转换本身抛出。无论如何,循环都会中断。STL 保证,即使在push_back()调用中的复制构造函数失败的情况下,输出向量也不会处于未定义状态——新元素不是部分创建的,向量大小也不会增加。

然而,已经存储的元素将保留在输出向量中(并且原始存在的任何元素都消失了)。这可能不是我们想要的——要求transmogrify()操作要么成功并应用于整个向量,要么失败而不做任何改变,这是合理的。

这种异常安全实现的关键是交换操作:

// Example 03b
void transmogrify(const std::vector<C>& in,
                  std::vector<C>& out) {
  std::vector<C> tmp;
  tmp.reserve(in.size());
  for (const C& x : in) {
    tmp.push_back(transmogrify(x));
  }
  out.swap(tmp);    // Must not throw!
}

在这个例子中,我们改变了代码,在整个转换过程中操作临时向量。请注意,在典型的输出向量为空输入的情况下,这并不会增加内存的使用量。如果输出向量中有些数据,新数据和旧数据将一直存在于内存中,直到函数结束。这是必要的,以确保旧数据只有在新数据可以完全计算的情况下才会被删除。如果需要,可以通过降低整体内存使用量来交换这个保证,并在函数开始时(另一方面,任何想要进行这种权衡的调用者只需在调用transmogrify()之前清空向量)清空输出向量。

如果在执行transmogrify()函数的过程中,任何时刻抛出异常,直到最后一行,那么临时向量将被删除,就像在栈上分配的任何局部变量一样(参见第五章,本书后面的RAII 全面探讨)。最后一行是异常安全的关键——它交换输出向量和临时向量的内容。如果这一行可以抛出异常,那么我们所有的努力都将白费——交换失败,输出向量将处于未定义状态,因为我们不知道在异常抛出之前交换成功了多少。但是,如果交换没有抛出异常,就像std::vector的情况一样,那么只要控制流到达最后一行,整个transmogrify()操作就成功了,结果将返回给调用者。输出向量的旧内容会发生什么?现在它由临时向量拥有,而临时向量将在下一行(闭括号)隐式删除。假设C的析构函数遵循 C++指南并且不会抛出异常(否则将招致可怕的未定义行为),那么我们的整个函数已经实现了异常安全。

这种惯用方法有时被称为拷贝-交换,可能是实现具有提交-回滚语义或强异常安全保证的操作的最简单方法。这个惯用方法的关键是能够以低成本且不抛出异常的方式交换对象。

其他常见的交换惯用方法

还有几种更常用的技术依赖于交换,尽管它们都没有像交换用于异常安全那样至关重要。

让我们从一种非常简单的方法开始,将容器或任何其他可交换对象重置为其默认构造状态:

// Example 04
class C {
    public:
    void swap(C& rhs) noexcept {
        … swap data members …
    }
};
C c = ....;    // Object with stuff in it
{
  C tmp;
  c.swap(tmp);    // c is now default-constructed
}            // Old c is now gone

注意,此代码明确创建了一个默认构造的()对象,仅为了与之交换,并使用额外的范围(一对大括号)来确保该对象尽快被删除。我们可以通过使用一个没有名称的临时对象来交换,做得更好:

C c = ....;    // Object with stuff in it
C().swap(c);    // Temporary is created and deleted

在这里,临时对象是在同一行代码中创建和删除的,并且它携带了对象 c 的旧内容。请注意,交换内容的顺序非常重要——swap() 成员函数是在临时对象上被调用的。尝试进行反向操作将无法编译:

C c = ....;    // Object with stuff in it
c.swap(C());    // Close but does not compile

这是因为 swap() 成员函数通过 C& 非常量引用来接收其参数,非非常量引用不能绑定到临时对象(更一般地说,到 r-值)。请注意,出于同样的原因,swap() 非成员函数也不能用于交换对象与临时对象,因此如果类没有 swap() 成员函数,那么必须显式创建一个命名对象。

这种习语的更一般形式用于在不更改程序中名称的情况下应用转换到原始对象。假设我们在程序中有一个向量,我们想要应用前面的 transmogrify() 函数;然而,我们不想创建一个新的向量。相反,我们想在程序中继续使用原始向量(或者至少它的变量名),但里面包含新的数据。这种习语是实现所需结果的一种优雅方式:

// Example 05
std::vector<C> vec;
...                     // Write data into the vector
{
  std::vector<C> tmp;
  transmogrify(vec, tmp);    // tmp is the result
  swap(vec, tmp);        // Now vec is the result!
}                    // and now old vec is destroyed
...                    // Keep using vec, with new data

注意,如果 transmogrify() 可以抛出异常,我们必须使用整个作用域,包括交换,作为一个 try 块来实现异常安全性:

std::vector<C> vec;
...                     // Write data into the vector
try {
  std::vector<C> tmp;
  transmogrify(vec, tmp);    // throws an exception
  swap(vec, tmp);        // we never get here
} catch (...) {}            // vec is unchanged
...                     // Keep using vec, with old data

这种模式可以根据需要重复多次,替换对象的内容,而不需要在程序中引入新的名称。将其与传统的不使用交换的 C 风格方法进行对比:

std::vector<C> vec;
...    // Write data into the vector std::vector<C> vec1;
transmogrify(vec, vec1);    // Must use vec1 from now on!
std::vector<C> vec2;
transmogrify(vec1, vec2);     // Must use vec2 from now on!

注意,在计算新数据后,旧名称 vecvec1 仍然可访问。在以下代码中使用 vec 而不是 vec1 将是一个容易犯的错误。使用之前演示的交换技术,程序不会被新的变量名称所污染。

如何正确实现和使用交换

我们已经看到了标准库如何实现交换功能,以及对于交换实现的期望。现在让我们看看如何正确支持您自己的类型的交换。

实现交换

我们已经看到,所有 STL 容器以及许多其他标准库类型(例如,std::thread)都提供了一个 swap() 成员函数。虽然这不是必需的,但这是实现交换的最简单方法,因为它需要访问类的私有数据,以及交换相同类型的对象与临时对象的唯一方法。正确声明 swap() 成员函数的方式如下:

class C {
  public:
  void swap(C& rhs) noexcept;
};

当然,只有当确实可以提供无抛出保证时,才应包含 noexcept 规范;在某些情况下,它可能需要基于其他类型的属性进行条件化。如果适当,函数也可以声明为 constexpr

如何实现交换?有几种方法。对于许多类,我们可以简单地逐个交换数据成员。这会将交换对象的问题委托给它们包含的类型,如果它们遵循该模式,最终会结束于交换构成一切的内建类型。如果你知道你的数据成员有一个swap()成员函数,那么你可以调用它。否则,你必须调用非成员交换。这很可能会调用std::swap()模板的一个实例化,但你不应通过该名称调用它,原因将在下一节中解释。

相反,你应该将名称引入包含作用域,并调用swap()而不使用std::限定符:

//Example 06a
#include <utility>    // <algorithm> before C++11
...
class C {
  public:
  void swap(C& rhs) noexcept {
    using std::swap;    // Brings in std::swap into this scope
    v_.swap(rhs.v_);
    swap(i_, rhs.i_);    // Calls std::swap
  }
  ...
  private:
  std::vector<int> v_;
  int i_;
};

一种非常适用于交换的特定实现习惯用法是所谓的.C文件。实现数据的指针成员通常被称为p_implpimpl,因此得名该习惯用法。交换 pimpl 实现的类就像交换两个指针一样简单:

// Example 06b
// In the header C.h:
class C_impl;        // Forward declaration
class C {
  public:
  void swap(C& rhs) noexcept {
    swap(pimpl_, rhs.pimpl_);
  }
  void f(...);        // Declaration only
  ...
  private:
  C_impl* pimpl_;
};
// In the C file:
class C_impl {
  ... real implementation ...
};
void C::f(...) {
  pimpl_->f(...);    // Actual implementation of C::f()
}

这就处理了成员函数swap()。但如果有人在我们的自定义类型上调用非成员swap()函数呢?按照目前的写法,这个调用将调用std::swap()的默认实现,如果它是可见的(例如,由于using std::swap声明),即使用复制或移动操作的那个实现。

// Example 07a
class C {
  public:
  void swap(C& rhs) noexcept;
};
...
C c1(...), c2(...);
swap(c1, c2);    // Either does not compile
             // or calls std::swap

尽管我们有一个swap()成员函数,但std::swap并没有使用它。很明显,我们也必须支持一个非成员的swap()函数。我们可以在类声明之后轻松地声明一个,然而,我们也应该考虑如果类不是在全局作用域中声明,而是在一个命名空间中声明会发生什么:

// Example07b
namespace N {
  class C {
    public:
    void swap(C& rhs) noexcept;
  };
  void swap(C& lhs, C& rhs) noexcept { lhs.swap(rhs); }
}
...
N::C c1(...), c2(...);
swap(c1, c2);    // Calls non-member N::swap()

无限定符调用swap()会调用N命名空间内的swap()非成员函数,该函数反过来会调用其中一个参数上的成员函数swap()(标准库采用的约定是调用lhs.swap())。请注意,然而,我们没有调用N::swap(),而是只调用了swap()。在N命名空间之外,并且没有using namespace N;指定的情况下,无限定符调用通常不会解析为命名空间内的函数。然而,在这种情况下,它确实解析了,这是由于标准中的一个特性,称为参数依赖查找ADL),也称为科宁查找。ADL 将所有在函数参数声明的作用域中声明的函数添加到重载解析中。

在我们的例子中,编译器在弄清楚swap(...)函数中的c1c2参数所引用的swap名称之前,就已经将它们的类型识别为N::C。由于这些参数位于N命名空间中,因此该命名空间中声明的所有函数都会被添加到重载解析中,从而使N::swap函数变得可见。

如果类型有一个 swap() 成员函数,那么实现非成员 swap() 函数的最简单方法就是调用它。然而,这样的成员函数不是必需的;如果决定不支持 swap() 成员函数,那么非成员 swap() 必须能够访问类的私有数据。它必须被声明为一个 friend 函数:

// Example 07c
class C {
  friend void swap(C& rhs) noexcept;
};
void swap(C& lhs, C& rhs) noexcept {
  ... swap data members of C ...
}

还可以在不使用单独定义的情况下,内联定义 swap() 函数的实现:

// Example 07d
class C {
  friend void swap(C& lhs, C& rhs) noexcept {
    ... swap data members of C ...
  }
};

当我们有一个类模板而不是单个类时,这尤其方便。我们将在稍后的 第十一章作用域保护 中更详细地考虑这个模式。

一个常被遗忘的实现细节是自我交换——swap(x, x),或者,在成员函数调用的情况下,x.swap(x)。这是否定义良好?它到底做了什么?答案似乎是在 C++03 和 C++11(以及之后的版本)中,它应该是定义良好的,但最终什么也不做;也就是说,它不会改变对象(尽管不一定是零成本)。用户定义的 swap 实现应该对自我交换是隐式安全的,或者应该显式检查它。如果 swap 是基于复制或移动赋值实现的,那么需要注意的是,标准要求复制赋值必须对自我赋值是安全的,而移动赋值可能会改变对象,但必须将其置于一个有效状态,称为 已移动状态(在这个状态下,我们仍然可以将其他东西赋值给对象)。

我们还应该注意,同名但功能不同的 STL 函数 std::iter_swapstd::swap_ranges 实际上是使用 swap() – 可能是 std::swap – 来交换迭代器或迭代器范围的值的算法。它们也是如何正确调用 swap() 函数的一个例子,不仅是在 STL 中,而且在代码的任何地方,如下一节所示。

正确使用 swap

到目前为止,我们一直在调用 swap() 成员函数、swap() 非成员函数和显式命名的 std::swap() 操作之间切换,没有任何模式或理由。我们现在应该在这方面引入一些纪律。

首先,只要你知道它存在,调用 swap() 成员函数总是安全且合适的。后者的条件通常在编写模板代码时出现——当处理具体类型时,你通常知道它们提供了什么接口。这让我们只剩下一个问题——在调用 swap() 非成员函数时,我们应该使用 std:: 前缀吗?

考虑以下情况,如图所示:

// Example 08
namespace N {
  class C {
    public:
    void swap(C& rhs) noexcept;
  };
  void swap(C& lhs, C& rhs) noexcept { lhs.swap(rhs); }
}
...
N::C c1(...), c2(...);
std::swap(c1, c2);    // Calls std::swap()
swap(c1, c2);        // Calls N::swap()

注意,基于参数的查找不适用于合格名称,这就是为什么对std::swap()的调用仍然调用 STL 的<utility>头文件中模板swap的实例化。因此,建议永远不要显式调用std::swap(),而是使用using声明将那个重载引入当前作用域,然后调用无前缀的swap

using std::swap;    // Makes std::swap() available
swap(c1, c2);    // Calls N::swap() if provided
             // Otherwise, calls std::swap()

这正是 STL 算法所做的事情。例如,std::iter_swap通常是这样实现的:

template <typename Iter1, typename Iter2>
void iter_swap(Iter1 a, ITer2 b) {
  using std::swap;
  swap(*a, *b);
}

不幸的是,std::swap()的完全合格调用在许多程序中很常见。为了保护自己免受此类代码的影响,并确保无论发生什么情况都能调用你的自定义swap实现,你可以为你自己的类型实例化std::swap()模板:

// Example 09
namespace std {
void swap(N::C& lhs, N::C& rhs) noexcept {
  lhs.swap(rhs); }
}

通常,根据标准,不允许声明自己的函数或类用于保留的std::命名空间。然而,标准为某些模板函数的显式实例化(包括std::swap())做出了例外。有了这样的特化,对std::swap()的调用将调用那个实例化,并将其转发到我们的自定义swap实现。请注意,仅实例化std::swap()模板是不够的,因为这样的实例化不参与基于参数的查找。如果未提供其他非成员swap函数,我们将遇到相反的问题:

using std::swap;        // Makes std::swap() available
std::swap(c1, c2);    // Calls our std::swap() overload
swap(c1, c2);        // Calls default std::swap()

现在,非合格调用最终会调用默认的std::swap()操作的实例化——即带有移动构造函数和赋值操作的实例。为了确保每个swap调用都得到正确处理,应该实现一个非成员swap()函数和显式的std::swap()实例化(当然,它们可以,并且应该,都转发到相同的实现)。最后,请注意,标准允许我们通过模板实例化扩展std::命名空间,但不允许通过额外的模板重载。因此,如果我们有一个类模板而不是单个类型,我们不能为它特化std::swap;这样的代码很可能会编译,但标准不保证会选择所需的重载(技术上,标准调用未定义的行为并保证一切)。因此,直接调用std::swap应该避免。

摘要

C++中的 swap 功能用于实现几个重要模式。其中最关键的是异常安全事务的拷贝-交换实现。所有标准库容器以及大多数其他 STL 对象都提供了快速且在可能的情况下不抛出异常的 swap 成员函数。需要支持 swap 的用户定义类型应遵循相同的模式。然而,请注意,实现非抛出异常的 swap 函数通常需要额外的间接引用,并违反了几个优化模式。除了成员函数 swap 之外,我们还回顾了非成员 swap 的使用和实现。鉴于std::swap总是可用,并且可以在任何可拷贝或可移动的对象上调用,如果给定类型存在更好的 swap 方式(特别是任何具有成员函数 swap 的类型也应提供调用该成员函数的非成员函数重载),程序员应确保实现非成员 swap 函数。

最后,虽然不推荐,但非成员 swap 的替代用法足够常见,以至于应该考虑隐式实例化std::swap模板。

下一章将带我们游览 C++中最流行且强大的惯用法之一——C++管理资源的方式。

问题

  1. swap 的作用是什么?

  2. swap 在异常安全程序中是如何使用的?

  3. 为什么 swap 函数应该是非抛出异常的?

  4. 应该优先选择成员或非成员的 swap 实现?

  5. 标准库对象是如何实现 swap 的?

  6. 为什么非成员 swap 函数应该不带std::限定符调用?

第五章:RAII 的全面审视

资源管理可能是程序做的第二频繁的事情,仅次于计算。但仅仅因为它是频繁进行的,并不意味着它是可见的——某些语言会隐藏大部分或全部的资源管理,不让用户看到。而且,即使它被隐藏,并不意味着它不存在。

每个程序都需要使用一些内存,而内存是一种资源。如果程序从未以某种方式与外界交互(至少要打印结果),那么它将毫无用处,输入输出通道(文件、套接字等)也是资源。

在本章中,我们将首先回答以下问题:

  • 在 C++ 程序中,什么被认为是资源?

  • 在 C++ 中管理资源的关键关注点是什么?

然后,我们将介绍资源获取即初始化RAII)并解释它如何通过回答以下问题来帮助 C++ 中的高效资源管理:

  • 在 C++ 中管理资源的标准方法(RAII)是什么?

  • RAII 如何解决资源管理的问题?

我们将结束本章的讨论,通过回答以下问题来探讨使用 RAII 的影响和可能的关注点:

  • 在编写 RAII 对象时必须采取哪些预防措施?

  • 使用 RAII 进行资源管理会有什么后果?

C++ 具有零开销抽象哲学,它不会在核心语言级别隐藏资源或其管理。但我们最好不要将隐藏资源与资源管理混淆。

技术要求

这里有一些有用的链接:

C++ 中的资源管理

每个程序都在操作资源,并需要管理它们。最常用的资源当然是内存。因此,你经常会在 C++ 中读到内存****管理。但事实上,资源可以是任何东西。许多程序专门用于管理真实的有形物理资源,或者更短暂的(但同样有价值)数字资源。银行账户中的钱、航班座位、汽车零件和组装好的汽车,甚至牛奶箱——在当今世界,如果需要计数和跟踪的东西,某个地方总有一款软件在做这件事。但即使在只做纯计算的程序中,也可能存在各种复杂资源,除非程序也放弃了抽象,在纯数字级别上操作。例如,一个物理模拟程序可能有粒子作为资源。

所有这些资源有一个共同点——它们都需要被考虑在内。它们不应该无影无踪地消失,程序也不应该虚构不存在资源。通常,需要特定实例的资源——你不会希望别人的购买从你的银行账户中扣除;资源的特定实例很重要。因此,在评估不同资源管理方法时最重要的考虑因素是正确性——设计如何确保资源得到适当管理,犯错的难易程度,以及发现此类错误的难度如何?因此,我们使用测试框架来展示本章中资源管理的编码示例也就不足为奇了。

安装微基准测试库

在我们的案例中,我们对内存分配效率和可能包含此类分配的小段代码的效率感兴趣。测量小段代码性能的适当工具是微基准测试。市面上有许多微基准测试库和工具;在本章中,我们将使用 Google Benchmark 库。要跟随本章的示例,你必须首先下载并安装库(按照Readme.md文件中的说明操作)。然后你可以编译并运行示例。你可以构建库中包含的样本文件,以了解如何在你的特定系统上构建基准测试;你也可以使用本章存储库中的示例基准测试:

// Example 01
#include <stdlib.h>
#include "benchmark/benchmark.h"
void BM_malloc(benchmark::State& state) {
  constexpr size_t size = 1024;
  for (auto _ : state) {
    void* p = malloc(size);
    benchmark::DoNotOptimize(p);
    free(p);
  }
  state.SetItemsProcessed(state.iterations());
}
BENCHMARK(BM_malloc);
BENCHMARK_MAIN();

注意对DoNotOptimize()的调用:这是一个不会生成任何代码但欺骗编译器认为其参数是必要的且不能被优化掉的特殊函数。没有这个,编译器可能会推断出整个基准测试循环没有可观察的效果,可以被优化为无。

在 Linux 机器上,构建并运行名为01_benchmark.C的基准测试程序的命令可能看起来像这样:

$CXX 01_benchmark.C -I. -I$GBENCH_DIR/include –O3 \
  --std=c++17 $GBENCH_DIR/lib/libbenchmark.a -lpthread \
  -o 01_benchmark && ./01_benchmark

这里,$CXX是你的 C++编译器,例如g++clang++,而$GBENCH_DIR是基准测试安装的目录。

上述示例应该打印出类似以下内容:

在这台特定的机器上,单个迭代(一次对malloc()free()的调用)需要 6.37 纳秒,这相当于每秒 1.57 亿次内存分配。

有时我们必须对非常短的操作进行基准测试:

void BM_increment(benchmark::State& state) {
  size_t i = 0;
  for (auto _ : state) {
    ++i;
    benchmark::DoNotOptimize(i);
  }
  state.SetItemsProcessed(state.iterations());
}

我们可能会对基准测试循环本身的开销表示合理的关注。在这种情况下,我们可以在循环体中执行多个基准测试操作的副本。我们甚至可以让 C++预处理器为我们复制:

// Example 02
#define REPEAT2(x) x x
#define REPEAT4(x) REPEAT2(x) REPEAT2(x)
#define REPEAT8(x) REPEAT4(x) REPEAT4(x)
#define REPEAT16(x) REPEAT8(x) REPEAT8(x)
#define REPEAT32(x) REPEAT16(x) REPEAT16(x)
#define REPEAT(x) REPEAT32(x)
void BM_increment32(benchmark::State& state) {
  size_t i = 0;
  for (auto _ : state) {
    REPEAT(
      ++i;
      benchmark::DoNotOptimize(i);
    )
  }
  state.SetItemsProcessed(32*state.iterations());
}

单个”迭代的现在包括 32 次迭代,因此使用每秒项目数值要容易得多。请记住在处理的项目数量中包含重复计数:

编写快速程序固然很好,但它们首先必须是正确的。为此,我们需要编写测试,因此我们还需要一个测试框架。

安装 Google Test

我们将测试非常小的代码片段的正确性。一方面,这仅仅是因为每个片段说明了特定的概念或想法。另一方面,即使在大型软件系统中,资源管理也是通过小的代码块来完成的。它们可能组合成一个相当复杂的资源管理器,但每个块执行特定的功能并且是可测试的。这种情况下的适当测试系统是单元测试框架。有许多这样的框架可供选择;在本章中,我们将使用 Google Test 单元测试框架。要跟随本章中的示例,您必须首先下载并安装该框架(遵循 README 文件中的说明)。一旦安装,您就可以编译并运行示例。您可以构建库中包含的示例测试,以了解如何在您的特定系统上构建和链接 Google Test;您还可以使用本章存储库中的示例:

#include <vector>
#include "gtest/gtest.h"
TEST(Memory, Vector) {
  std::vector<int> v(10);
  EXPECT_EQ(10u, v.size());
  EXPECT_LE(10u, v.capacity());
}

在 Linux 机器上,构建和运行 02_test.C 测试的命令可能看起来像这样:

$CXX 02_test.C -I. -I$GTEST_DIR/include -g -O0 -I. \
  -Wall -Wextra -Werror -pedantic --std=c++17 \
  $GTEST_DIR/lib/libgtest.a $GTEST_DIR/lib/libgtest_main.a\
  -lpthread -lrt -lm -o -2_test && ./02_test

在这里,$CXX 是您的 C++ 编译器,例如 g++clang++,而 $GTEST_DIR 是 Google Test 安装的目录。如果所有测试都通过,您应该得到以下输出:

Running main() from gtest_main.cc
[==========] Running 1 test from 1 test case.
[----------] Global test environment set-up.
[----------] 1 test from Memory
[ RUN      ] Memory.Vector
[       OK ] Memory.Vector (0 ms)
[----------] 1 test from Memory (0 ms total)
[----------] Global test environment tear-down
[==========] 1 test from 1 test case ran. (0 ms total)
[  PASSED  ] 1 test.

编写好的测试是一种艺术。我们必须确定代码中需要验证的方面,并想出观察这些方面的方法。在本章中,我们关注资源管理,让我们看看我们如何测试资源的利用和释放。

资源计数

单元测试框架,如 Google Test,允许我们执行一些代码并验证结果是否符合预期。我们可以查看的结果包括从测试程序可以访问的任何变量或表达式。这个定义并不包括例如当前使用的内存量。因此,如果我们想验证资源没有消失,我们必须计数。

在以下简单的测试用例中,我们使用一个特殊的资源类而不是,比如说,int 关键字。这个类被配置为统计已创建的此类对象的数量以及当前活跃的对象数量:

// Example 03
struct object_counter {
  static int count;
  static int all_count;
  object_counter() { ++count; ++all_count; }
  ~object_counter() { --count; }
};
int object_counter::count = 0;
int object_counter::all_count = 0;

现在我们可以测试我们的程序是否正确管理资源,如下所示:

// Example 03
#include "gtest/gtest.h"
TEST(Memory, NewDelete) {
  object_counter::all_count = object_counter::count = 0;
  object_counter* p = new object_counter;
  EXPECT_EQ(1, object_counter::count);
  EXPECT_EQ(1, object_counter::all_count);
  delete p;
  EXPECT_EQ(0, object_counter::count);
  EXPECT_EQ(1, object_counter::all_count);
}

在 Google Test 中,每个测试都实现为一个 测试用例。有几种类型;最简单的一种是独立的测试函数,就像我们在这里使用的那样。运行这个简单的测试程序告诉我们测试已经通过,如下所示:

[----------] 1 test from Memory
[ RUN      ] Memory.NewDelete
[       OK ] Memory.NewDelete (0 ms)
[----------] 1 test from Memory (0 ms total)
[  PASSED  ] 1 test.

预期结果是通过使用 EXPECT_* 宏之一来验证的,任何测试失败都会被报告。这个测试验证了在创建和删除 object_counter 类型的实例之后,没有留下这样的对象,并且恰好创建了一个实例。

手动资源管理的危险

C++允许我们在几乎硬件级别上管理资源,实际上,某处确实有人在这个级别上管理它们。对于每种语言来说,这实际上都是正确的,即使是那些不向程序员暴露此类细节的高级语言。但“某处”不必就在你的程序中!在我们学习 C++的资源管理解决方案和工具之前,让我们首先了解不使用此类工具会引发的问题。

手动资源管理容易出错

手动管理每个资源,通过显式调用获取和释放每个资源的方式,最明显的危险是很容易忘记释放。例如,看看以下情况:

{
  object_counter* p = new object_counter;
  ... many more lines of code ...
  // Were we supposed to do something here?
  // Can't remember now...
}

我们现在正在泄露一个资源(在这种情况下是object_counter对象)。如果我们在一个单元测试中这样做,它将会失败,如下所示:

// Example 04
TEST(Memory, Leak1) {
  object_counter::all_count = object_counter::count = 0;
  object_counter* p = new object_counter;
  EXPECT_EQ(1, object_counter::count);
  EXPECT_EQ(1, object_counter::all_count);
  //delete p;  // Forgot that
  EXPECT_EQ(0, object_counter::count); // This test fails!
  EXPECT_EQ(1, object_counter::all_count);
}

你可以看到失败的测试和失败的地点,这是由单元测试框架报告的:

[ RUN      ] Memory.Leak1
04_memory.C:31: Failure
Expected equality of these values:
  0
  object_counter::count
    Which is: 1
[  FAILED  ] Memory.Leak1 (0 ms)

在一个真实程序中,找到这样的错误要困难得多。内存调试器和清理器可以帮助处理内存泄露,但它们需要程序实际执行有缺陷的代码,因此它们依赖于测试覆盖率。

资源泄露可能更加微妙且难以发现。考虑以下代码,我们并没有忘记释放资源:

bool process(... some parameters ... ) {
  object_counter* p = new object_counter;
  ... many more lines of code ...
  delete p;    // A-ha, we remembered!
  return true;    // Success
}

在后续的维护中,发现了一个可能的问题状态,并添加了相应的测试:

bool process(... some parameters ... ) {
  object_counter* p = new object_counter;
  ... many more lines of code ...
  if (!success) return false;   // Failure, cannot continue
  ... even more lines of code ...
  delete p;    // Still here
  return true;    // Success
}

这个更改引入了一个微妙的错误——现在,只有在中间计算失败并触发提前返回时才会泄露资源。如果失败足够罕见,这个错误可能会逃过所有测试,即使测试过程使用了常规的内存清理器运行。这个错误也很容易犯,因为编辑可能是在远离对象构建和删除的地方进行的,而且立即的上下文中没有任何东西给程序员提示需要释放资源。

在这种情况下,资源泄露的替代方案是释放它。请注意,这会导致一些代码重复:

bool process(... some parameters ... ) {
  object_counter* p = new object_counter;
  ... many more lines of code ...
  if (!success) {
    delete p;
    return false;    // Failure, cannot continue
  }
  ... even more lines of code ...
  delete p;    // Still here
  return true;    // Success
}

就像任何代码重复一样,存在代码分歧的危险。假设下一轮代码增强需要多个object_counter,现在它们作为一个数组被分配如下:

bool process(... some parameters ... ) {
  object_counter* p = new object_counter[10]; // Array now
  ... many more lines of code ...
  if (!success) {
    delete p;
    return false;     // Old scalar delete
  }
  ... even more lines of code ...
  delete [] p;    // Matching array delete
  return true;    // Success
}

如果我们将new改为new数组,我们必须也将delete改为数组形式的delete;这种想法是,函数的末尾可能有一个。谁知道中间还有一个呢?即使程序员没有忘记资源,随着程序变得更加复杂,手动资源管理会变得不成比例地容易出错。并不是所有资源都像计数器对象那样宽容。考虑以下执行一些并发计算并必须获取和释放互斥锁的代码。注意,获取释放这两个词,作为锁的通用术语,暗示锁被当作一种资源(这里的资源是锁保护的数据的独占访问):

std::mutex m1, m2, m3;
bool process_concurrently(... some parameters ... ) {
  m1.lock();
  m2.lock();
  ... need both locks in this section ...
  if (!success) {
    m1.unlock();
    m2.unlock();
    return false;
  } // Both locks unlocked
  ... more code ...
  m2.unlock();    // Don't need access to m1-guarded data
                // Still need m1
  m3.lock();
  if (!success) {
    m1.unlock();
    return false;
  } // No need to unlock m2 here
  ... more code ...
  m1.unlock();
  m3.unlock();
  return true;
}

这段代码既有重复也有分歧。它还包含一个错误——看看你是否能找到它(提示——计算m3解锁的次数,与它之后有多少return语句)。随着资源变得越来越多且管理起来越来越复杂,这样的错误将会更频繁地出现。

资源管理和异常安全

记得上一节开头提到的代码吗——我们说它是正确的,因为我们没有忘记释放资源?考虑以下代码:

bool process(... some parameters ... ) {
  object_counter* p = new object_counter;
  ... many more lines of code ...
  delete p;
  return true;    // Success
}

我有一个坏消息要告诉你——这段代码可能也不正确。如果任何更多的代码行可以抛出异常,那么delete p永远不会被执行:

bool process(... some parameters ... ) {
  object_counter* p = new object_counter;
  ... many more lines of code ...
  if (!success) // Cannot continue
    throw process_exception();
  ... even more lines of code ...
  // Won't do anything if an exception is thrown!
  delete p;
  return true;
}

这看起来与早期的return问题非常相似,但更糟糕——异常可以由process()函数调用的任何代码抛出。异常甚至可以添加到process()函数调用的某些代码中,而函数本身没有任何变化。它曾经工作得很好,然后有一天它就不行了。

除非我们改变资源管理的方法,否则唯一的解决方案是使用try … catch块:

bool process(... some parameters ... ) {
  object_counter* p = new object_counter;
  try {
    ... many more lines of code ...
    if (!success) // Cannot continue
      throw process_exception();
      ... even more lines of code ...
  } catch ( ... ) {
    delete p;    // For exceptional case
  }
  delete p;    // For normal case return true;
}

这里明显的问题是代码重复,以及try … catch块无处不在的泛滥。更糟糕的是,如果我们需要管理多个资源,或者只是管理比单个获取和释放更复杂的事情,这种方法就不适用了:

std::mutex m;
bool process(... some parameters ... ) {
  m.lock(); // Critical section starts here
  object_counter* p = new object_counter;
  // Problem #1: constructor can throw
  try {
    ... many more lines of code ...
    m.unlock();    // Critical section ends here
    ... even more lines of code ...
  } catch ( ... ) {
    delete p;    // OK, always needed
    m.unlock();    // Do we need this? Maybe…
    throw;    // Rethrow the exception for the client to handle
  }
  delete p;    // For normal case, no need to unlock mutex
  return true;
}

现在,我们甚至不能决定是否在捕获块中释放互斥锁——这取决于异常是在unlock()操作之前还是之后抛出的。此外,object_counter构造函数可能会抛出异常(不是我们之前遇到的简单异常,而是一个更复杂的异常,我们的代码可能会演变成)。这将会发生在try … catch块之外,互斥锁将永远不会被解锁。

到现在为止,我们应该很清楚,我们需要一个完全不同的解决方案来处理资源管理问题,而不仅仅是修补。在下一节中,我们将讨论成为 C++中资源管理黄金标准的模式。

RAII 习语

我们在前一节中看到了尝试管理资源是如何变得不可靠,然后是错误倾向,最终失败的。我们需要确保资源获取始终与资源释放配对,并且这两个动作分别在使用资源的代码段之前和之后发生。在 C++ 中,这种通过一对动作括起来的代码序列称为 Execute Around 设计模式。

小贴士

更多信息,请参阅 Kevlin Henney 撰写的文章 C++ Patterns – Executing Around Sequences,可在 www.two-sdg.demon.co.uk/curbralan/papers/europlop/ExecutingAroundSequences.pdf 获取。

当具体应用于资源管理时,这种模式更广为人知的是 资源获取即初始化RAII)。

RAII 简而言之

RAII(资源获取即初始化)背后的基本思想非常简单——在 C++ 中有一种函数可以保证自动调用,那就是在栈上创建的对象的析构函数,或者另一个对象的数据成员的析构函数(在后一种情况下,保证仅在包含的类本身被销毁时成立)。如果我们能将资源的释放与这种对象的析构函数挂钩,那么释放就不会被遗忘或跳过。从逻辑上讲,如果释放资源由析构函数处理,那么获取资源应该由构造函数在对象的初始化期间处理。因此,本章标题中介绍的 RAII 的完整意义——全面了解 RAII

让我们看看在内存分配的最简单情况下,通过 operator new 是如何工作的。首先,我们需要一个可以从新分配的对象的指针初始化的类,并且其析构函数将删除该对象:

// Example 05
template <typename T> class raii {
  public:
  explicit raii(T* p) : p_(p) {}
  ~raii() { delete p_; }
  private:
  T* p_;
};

现在确保删除永远不会被遗漏变得非常容易,我们可以通过使用 object_counter 的测试来验证它是否按预期工作:

// Example 05
TEST(RAII, AcquireRelease) {
  object_counter::all_count = object_counter::count = 0;
  {
    raii<object_counter> p(new object_counter);
    EXPECT_EQ(1, object_counter::count);
    EXPECT_EQ(1, object_counter::all_count);
  } // No need to delete p, it's automatic
  EXPECT_EQ(0, object_counter::count);
  EXPECT_EQ(1, object_counter::all_count);
}

注意,在 C++17 中,类模板类型是从构造函数推导出来的,我们可以简单地写出以下内容:

raii p(new object_counter);

当拥有对象因任何原因被销毁时,RAII 资源释放就会发生;因此,在抛出异常后的清理工作会自动完成:

// Example 05
struct my_exception {};
TEST(Memory, NoLeak) {
  object_counter::all_count = object_counter::count = 0;
  try {
    raii p(new object_counter);
    throw my_exception();
  } catch ( my_exception& e ) {
  }
  EXPECT_EQ(0, object_counter::count);
  EXPECT_EQ(1, object_counter::all_count);
}

当然,我们可能希望使用新对象做更多的事情,而不仅仅是创建和删除它,因此能够访问 RAII 对象内部存储的指针会很好。没有理由以外的方式授予这种访问权限,除了标准的指针语法,这使得我们的 RAII 对象本身就像一个指针:

// Example 06
template <typename T> class scoped_ptr {
  public:
  explicit scoped_ptr(T* p) : p_(p) {}
  ~scoped_ptr() { delete p_; }
  T* operator->() { return p_; }
  const T* operator->() const { return p_; }
  T& operator*() { return *p_; }
  const T& operator*() const { return *p_; }
  private:
  T* p_;
};

这个指针可以在作用域结束时自动删除它所指向的对象(因此得名):

// Example 06
TEST(Scoped_ptr, AcquireRelease) {
  object_counter::all_count = object_counter::count = 0;
  {
    scoped_ptr p(new object_counter);
    EXPECT_EQ(1, object_counter::count);
    EXPECT_EQ(1, object_counter::all_count);
  }
  EXPECT_EQ(0, object_counter::count);
  EXPECT_EQ(1, object_counter::all_count);
}

析构函数会在包含 scoped_ptr 对象的作用域退出时被调用。无论以何种方式退出——函数中的早期 return,循环中的 breakcontinue 语句,或者抛出异常——都会以完全相同的方式处理,并且不会发生泄漏。当然,我们可以通过测试来验证这一点:

// Example 06
TEST(Scoped_ptr, EarlyReturnNoLeak) {
  object_counter::all_count = object_counter::count = 0;
  do {
    scoped_ptr p(new object_counter);
    break;
  } while (false);
  EXPECT_EQ(0, object_counter::count);
  EXPECT_EQ(1, object_counter::all_count);
}
TEST(Scoped_ptr, ThrowNoLeak) {
  object_counter::all_count = object_counter::count = 0;
  try {
    scoped_ptr p(new object_counter);
   throw 1;
  } catch ( ... ) {}
  EXPECT_EQ(0, object_counter::count);
  EXPECT_EQ(1, object_counter::all_count);
}

所有测试都通过,确认没有泄漏:

[----------] 6 tests from Scoped_ptr
[ RUN      ] Scoped_ptr.AcquireRelease
[       OK ] Scoped_ptr.AcquireRelease (0 ms)
[ RUN      ] Scoped_ptr.EarlyReturnNoLeak
[       OK ] Scoped_ptr.EarlyReturnNoLeak (0 ms)
[ RUN      ] Scoped_ptr.ThrowNoLeak
[       OK ] Scoped_ptr.ThrowNoLeak (0 ms)
[ RUN      ] Scoped_ptr.DataMember
[       OK ] Scoped_ptr.DataMember (0 ms)
[ RUN      ] Scoped_ptr.Reset
[       OK ] Scoped_ptr.Reset (0 ms)
[ RUN      ] Scoped_ptr.Reseat
[       OK ] Scoped_ptr.Reseat (0 ms)
[----------] 6 tests from Scoped_ptr (0 ms total)

同样,我们可以在另一个类中将作用域指针用作数据成员——一个具有二级存储并在销毁时必须释放它的类:

class A {
  public:
  A(object_counter* p) : p_(p) {}
  private:
  scoped_ptr<object_counter> p_;
};

这样,我们就不需要在类 A 的析构函数中手动删除对象,实际上,如果类 A 的每个数据成员都以类似的方式自行处理,类 A 甚至可能不需要显式的析构函数。

任何熟悉 C++11 的人都会认出我们的 scoped_ptrstd::unique_ptr 的一个非常基础的版本,它可以用于相同的目的。正如你所期望的,标准唯一指针的实现要复杂得多,而且有很好的理由。我们将在本章后面回顾一些这些理由,但为了清楚起见:你应该在你的代码中使用 std::unique_ptr,我们在这里实现自己的 scoped_ptr 的唯一原因是为了理解 RAII 指针的工作原理。

最后要考虑的一个问题是性能。C++在可能的情况下总是追求零开销抽象。在这种情况下,我们将原始指针包装到智能指针对象中。然而,编译器不需要生成任何额外的机器指令;包装器只是迫使编译器生成代码,在正确的程序中,它本来就会这样做。我们可以通过简单的基准测试来确认我们的 scoped_ptr(或者说是 std::unique_ptr,无论如何)的构造/删除和解除引用操作与原始指针上的相应操作所需时间完全相同。例如,以下微基准测试(使用 Google 基准库)比较了三种指针类型解除引用的性能:

// Example 07
void BM_rawptr_dereference(benchmark::State& state) {
  int* p = new int;
  for (auto _ : state) {
    REPEAT(benchmark::DoNotOptimize(*p);)
  }
  delete p;
  state.SetItemsProcessed(32*state.iterations());
}
void BM_scoped_ptr_dereference(benchmark::State& state) {
  scoped_ptr<int> p(new int);
  for (auto _ : state) {
    REPEAT(benchmark::DoNotOptimize(*p);)
  }
  state.SetItemsProcessed(32*state.iterations());
}
void BM_unique_ptr_dereference(benchmark::State& state) {
  std::unique_ptr<int> p(new int);
  for (auto _ : state) {
     REPEAT(benchmark::DoNotOptimize(*p);)
  }
  state.SetItemsProcessed(32*state.iterations());
}
BENCHMARK(BM_rawptr_dereference);
BENCHMARK(BM_scoped_ptr_ dereference);
BENCHMARK(BM_unique_ptr_dereference);
BENCHMARK_MAIN();

基准测试显示智能指针确实没有开销:

----------------------------------------------------------------------
Benchmark                   Time        CPU Iterations UserCounters...
BM_rawptr_dereference      3.42 ns  3.42 ns  817698667 items_per_second=9.35646G/s
BM_scoped_ptr_dereference  3.37 ns  3.37 ns  826869427 items_per_second=9.48656G/s
BM_unique_ptr_dereference  3.42 ns  3.42 ns  827030287 items_per_second=9.36446G/s

我们已经详细介绍了 RAII 在管理内存中的应用。但还有其他资源需要 C++程序进行管理和跟踪,因此我们现在必须扩展我们对 RAII 的看法。

对于其他资源的 RAII

名称 RAII 指的是资源而不是内存,并且确实相同的策略适用于其他资源。对于每种资源类型,我们需要一个特殊对象,尽管泛型编程和 lambda 表达式可以帮助我们编写更少的代码(我们将在第十一章作用域保护者)中了解更多)。资源在构造函数中获取,在析构函数中释放。请注意,RAII 有两种略微不同的风格。第一种是我们已经看到的选择——资源的实际获取是在初始化时,但不在 RAII 对象的构造函数外部。

构造函数仅捕获由此获取的结果句柄(例如指针)。这正是我们刚才看到的scoped_ptr对象的情况——内存分配和对象构造都是在scoped_ptr对象的构造函数之外完成的,但仍然在其初始化期间。RAII 对象的构造函数的第二个选项是实际获取资源。让我们看看这是如何工作的,以下是一个管理互斥锁的 RAII 对象的例子:

// Example 08
class mutex_guard {
  public:
  explicit mutex_guard(std::mutex& m) : m_(m) {
    m_.lock();
  }
  ~mutex_guard() { m_.unlock(); }
  private:
  std::mutex& m_;
};

在这里,mutex_guard类的构造函数本身获取资源;在这种情况下,是互斥锁保护的共享数据的独占访问。析构函数释放该资源。再次强调,这种模式完全消除了泄漏锁的可能性(即在没有释放锁的情况下退出作用域),例如,当抛出异常时:

// Example 08
std::mutex m;
TEST(MutexGuard, ThrowNoLeak) {
  try {
    mutex_guard lg(m);
    EXPECT_FALSE(m.try_lock());    // Expect to be locked
    throw 1;
  } catch ( ... ) {}
  EXPECT_TRUE(m.try_lock());    // Expect to be unlocked
  m.unlock();    // try_lock() will lock, undo it
}

在这个测试中,我们通过调用std::mutex::try_lock()来检查互斥锁是否被锁定——如果互斥锁已经被锁定,我们不能调用lock(),因为这会导致死锁。通过调用try_lock(),我们可以检查互斥锁的状态,而不会面临死锁的风险(但请记住,如果try_lock()成功,则必须解锁互斥锁,因为我们只是使用try_lock()来测试,不希望再次锁定互斥锁)。

再次强调,标准提供了一个用于互斥锁定的 RAII 对象,std::lock_guard。它以类似的方式使用,但可以应用于任何具有lock()unlock()成员函数的互斥锁类型:

  try {
    std::lock_guard lg(m);        // C++17 constructor
    EXPECT_FALSE(m.try_lock());    // Expect to be locked
    throw 1;
  } catch ( ... ) {}
  EXPECT_TRUE(m.try_lock());    // Expect to be unlocked

在 C++17 中,我们有一个用于锁定多个互斥锁的类似 RAII 对象——std::scoped_lock。除了 RAII 释放之外,它还提供了一种在同时锁定多个互斥锁时的死锁避免算法。当然,C++程序可能需要管理的资源种类还有很多,所以我们经常需要编写自己的 RAII 对象。有时,标准会提供帮助,例如 C++20 中添加的std::jthread(线程也是一种资源,“释放”它通常意味着连接线程,这是std::jthread在其析构函数中所做的)。随着可以使用 RAII 技术管理的资源种类繁多,有时我们的需求超出了在作用域结束时自动释放资源。

提前发布

函数或循环体的作用域并不总是与持有资源的期望持续时间相匹配。如果我们不想在作用域的非常开始处获取资源,这很简单——RAII 对象可以在任何地方创建,而不仅仅是作用域的开始。资源只有在 RAII 对象被构造时才会获取,如下所示:

void process(...) {
  ... do work that does not need exclusive access ...
  mutex_guard lg(m);    // Now we lock
  ... work on shared data, now protected by mutex ...
} // lock is released here

然而,释放仍然发生在函数体作用域的末尾。如果我们只想在函数内部锁定一小段代码怎么办?最简单的答案是创建一个额外的作用域:

void process(...) {
  ... do work that does not need exclusive access ...
  {
    mutex_guard lg(m);    // Now we lock
    ... work on shared data, now protected by mutex ...
  } // lock is released here
  ... more non-exclusive work ...
}

如果你以前从未见过,可能会感到惊讶,但在 C++中,任何语句序列都可以包含在大括号{ ... }中。这样做会创建一个新的作用域,它有自己的局部变量。与跟在循环或条件语句后面的花括号不同,这个作用域的唯一目的是控制这些局部变量的生命周期。广泛使用 RAII 的程序通常有许多这样的作用域,它们包围着生命周期比整体函数或循环体更短的不同变量。这种做法也通过明确指出某些变量在某个点之后将不再使用来提高可读性,因此读者不需要扫描代码的其余部分来寻找对这些变量的可能引用。此外,如果意图是过期一个变量并且不再使用它,用户也不太可能意外地添加这样的引用。

如果资源可能提前释放,但只有满足某些条件时才释放,那会怎样?一种可能的方法是,再次,将资源的使用包含在作用域内,当资源不再需要时退出该作用域。能够使用break来退出作用域会非常方便。实现这一点的常见方法是编写一个do once循环:

// Example 08
void process(...) {
  ... do work that does not need exclusive access ...
  do {    // Not really a loop
    mutex_guard lg(m);    // Now we lock
    ... work on shared data, now protected by mutex ...
    if (work_done) break;    // Exit the scope
    ... work on the shared data some more ...
  } while (false);        // lock is released here
  ... more non-exclusive work ...
}

然而,这种方法并不总是有效(我们可能想要释放资源,但不是在同一作用域中定义的其他局部变量),随着控制流的复杂化,代码的可读性也受到影响。抵制通过使用operator new动态分配 RAII 对象来完成这一点的冲动!这完全违背了 RAII 的整个目的,因为你现在必须记得调用operator delete。我们可以通过添加客户端触发的释放,除了析构函数自动释放之外,来增强我们的资源管理对象。我们只需确保同一资源不会被释放两次。考虑以下使用scoped_ptr的示例:

// Example 06
template <typename T> class scoped_ptr {
  public:
  explicit scoped_ptr(T* p) : p_(p) {}
  ~scoped_ptr() { delete p_; }
  ...
  void reset() {
    delete p_;
    p_ = nullptr;     // Releases resource early private:
  }
  T* p_;
};

在调用reset()之后,由scoped_ptr对象管理的对象被删除,scoped_ptr对象的指针数据成员被重置为 null。请注意,我们不需要在析构函数中添加条件检查,因为按照标准,对 null 指针调用 delete 是允许的——它什么也不做。资源只释放一次,要么是显式地通过reset()调用,要么是在包含scoped_ptr对象的范围结束时隐式地释放。正如我们之前提到的,你不需要自己编写scoped_ptr,除非是为了学习 RAII 指针的工作原理:std::unique_ptr也可以重置。

对于mutex_guard类,仅从锁定操作中无法推断出是否调用了提前释放,我们需要一个额外的数据成员来跟踪这一点:

// Example 08
class mutex_guard {
  public:
  explicit mutex_guard(std::mutex& m) :
    m_(m), must_unlock_(true) { m_.lock(); }
  ~mutex_guard() { if (must_unlock_) m_.unlock(); }
   void reset() { m_.unlock(); must_unlock_ = false; }
  private:
  std::mutex& m_;
  bool must_unlock_;
};

现在我们可以通过这个测试来验证互斥锁只被释放一次,在正确的时间:

TEST(MutexGuard, Reset) {
  {
    mutex_guard lg(m);
    EXPECT_FALSE(m.try_lock());
    lg.reset();
    EXPECT_TRUE(m.try_lock()); m.unlock();
  }
  EXPECT_TRUE(m.try_lock()); m.unlock();
}

标准的std::unique_ptr指针支持reset(),而std::lock_guard不支持,因此如果你需要提前释放互斥锁,你需要使用不同的标准 RAII 对象,即std::unique_lock

// Example 08
TEST(LockGuard, Reset) {
  {
    std::unique_lock lg(m);
    EXPECT_FALSE(m.try_lock());
    lg.unlock();
    EXPECT_TRUE(m.try_lock()); m.unlock();
  }
  EXPECT_TRUE(m.try_lock()); m.unlock();
}

对于其他资源,你可能需要编写自己的 RAII 对象,这通常是一个相当简单的类,但在开始编写之前,请先完成本章的阅读,因为有一些需要注意的陷阱。

注意,std::unique_ptrreset()方法实际上做的不仅仅是提前删除对象。它还可以通过在删除旧对象的同时使指针指向新对象来重置指针。它的工作方式大致如下(标准中的实际实现要复杂一些,因为唯一指针具有额外的功能):

template <typename T> class scoped_ptr {
  public:
  explicit scoped_ptr(T* p) : p_(p) {}
  ~scoped_ptr() { delete p_; }
  ...
  void reset(T* p = nullptr) {
    delete p_; p_ = p;    // Reseat the pointer
  }
  private:
  T* p_;
};

注意,如果作用域指针被重置为其自身(例如,如果reset()被调用时使用与存储在p_中的相同值),则此代码会出错。我们可以检查这种条件并什么都不做;值得注意的是,标准并不要求对std::unique_ptr进行此类检查。

资源获取即初始化对象的谨慎实现

显然,资源管理对象不误管理它们所托管的资源非常重要。不幸的是,我们迄今为止编写的简单 RAII 对象有几个明显的漏洞。

第一个问题出现在有人试图复制这些对象时。在本章中我们考虑的每个 RAII 对象都负责管理其资源的唯一实例,然而,没有任何东西阻止我们复制这个对象:

scoped_ptr<object_counter> p(new object_counter);
scoped_ptr<object_counter> p1(p);

此代码调用了默认的复制构造函数,它只是简单地复制对象内部的位;在我们的例子中,指针被复制到object_counter。现在我们有两个 RAII 对象,它们都控制着相同的资源。最终将调用两个析构函数,并且它们都将尝试删除相同的对象。第二次删除是未定义的行为(如果我们非常幸运,程序将在那个点崩溃)。

RAII 对象的赋值同样存在问题:

scoped_ptr<object_counter> p(new object_counter);
scoped_ptr<object_counter> p1(new object_counter);
p = p1;

默认赋值运算符也会复制对象的位。同样麻烦的是,我们没有 RAII 对象来管理第二个object_counterp1内部的旧指针已经消失,而且没有其他引用指向此对象,所以我们没有删除它的方法。

mutex_guard的表现也好不到哪里去——尝试复制它会导致两个将解锁相同互斥锁的互斥锁。第二次解锁将是在一个未锁定(至少不是由调用线程锁定)的互斥锁上进行的,根据标准,这是未定义的行为。mutex_guard对象的赋值是不可能的,因为默认情况下不会为具有引用数据成员的对象生成赋值运算符。

您可能已经注意到了,问题是由 默认 复制构造函数和默认赋值运算符引起的。这意味着我们应该实现自己的吗?它们会做什么?每个构造的对象应该只调用一个析构函数;互斥量在被锁定后只能解锁一次。这表明 RAII 对象根本不应该被复制,我们应该禁止复制和赋值:

template <typename T> class scoped_ptr {
  public:
  explicit scoped_ptr(T* p) : p_(p) {}
  ~scoped_ptr() { delete p_; }
  ...
  private:
  T* p_;
  scoped_ptr(const scoped_ptr&) = delete;
  scoped_ptr& operator=(const scoped_ptr&) = delete;
};

有些 RAII 对象是可以复制的。这些是引用计数的资源管理对象;它们跟踪同一托管资源实例的 RAII 对象的副本数量。最后一个 RAII 对象在删除时必须释放资源。我们将在 第三章 中更详细地讨论资源的共享管理,内存 和所有权

对于移动构造函数和赋值运算符,存在不同的考虑因素。移动对象并不违反只有一个 RAII 对象拥有特定资源的假设。它只是改变了哪个 RAII 对象。在许多情况下,例如互斥量保护,移动 RAII 对象是没有意义的(实际上,标准不使 std::lock_guardstd::scoped_lock 可移动,但 std::unique_lock 是可移动的,可以用来转移互斥量的所有权)。在某些情况下,移动唯一指针是可能的,并且是有意义的,我们也会在第三章,内存 和所有权 中探讨这一点。

然而,对于作用域指针,移动是不希望的,因为它允许托管对象的生命周期扩展到创建它的作用域之外。请注意,如果我们已经删除了复制构造函数或复制赋值运算符,我们不需要删除移动构造函数或移动赋值运算符(尽管这样做没有坏处)。另一方面,std::unique_ptr 是一个可移动对象,这意味着将其用作作用域保护智能指针不会提供相同的保护,因为资源可能会被移动出去。然而,如果您需要一个作用域指针,有一个非常简单的方法可以使 std::unique_ptr 完美地完成这项工作——您只需声明一个 const std::unique_ptr 对象:

std::unique_ptr<int> p;
{
  // Can be moved out of the scope
  std::unique_ptr<int> q(new int);
  q = std::move(p);    // and here it happens
  // True scoped pointer, cannot be moved anywhere
  const std::unique_ptr<int> r(new int);
  q = std::move(r);    // Does not compile
}

到目前为止,我们已经保护了我们的 RAII 对象免受资源复制或丢失。但我们还没有考虑的一种资源管理错误。显然,资源应该以与获取时匹配的方式释放。然而,没有任何东西可以保护我们的 scoped_ptr 对象免受构造和删除之间的这种不匹配:

scoped_ptr<int> p(new int[10]);

这里的问题是,我们使用 operator new 的数组版本分配了多个对象;它应该使用 operator delete 的数组版本来删除 - delete [] p_ 必须在 scoped_ptr 析构函数内部调用,而不是我们现在使用的 delete p_

更普遍地,一个在初始化期间接受资源句柄而不是直接获取资源的 RAII 对象(例如mutex_guard所做的那样)必须以某种方式确保资源以与获取方式相匹配的正确方式释放。显然,这在一般情况下是不可能的。事实上,即使是对于简单的new数组与delete标量的不匹配情况,自动执行也是不可能的(尽管像std::make_unique这样的工具使得编写此类代码的错误更少)。

通常,RAII 类要么被设计为以特定方式释放资源,要么调用者必须指定资源释放的方式。前者当然更容易,在许多情况下也相当足够。特别是,如果 RAII 类也获取资源,例如我们的mutex_guard,它当然知道如何释放它。即使是对于scoped_ptr,创建两个版本也不会太难,即scoped_ptrscoped_array;后者是为由operator new数组分配的对象设计的。标准通过为数组特化唯一指针来处理它:你可以写std::unique_ptr<int[]>,数组delete将被使用(程序员仍然需要确保由new[]分配的数组由指针的正确实例拥有)。

RAII 类的更通用版本不仅由资源类型参数化,还由用于释放此类型的可调用对象参数化,通常称为删除器。删除器可以是一个函数指针、成员函数指针或定义了operator()的对象——基本上,任何可以像函数一样调用的东西。请注意,删除器必须在构造函数中传递给 RAII 对象,并存储在 RAII 对象内部,这使得对象更大。此外,删除器的类型是 RAII 类的模板参数,除非它从 RAII 类型中被擦除(这将在第六章理解类型擦除)中讨论)。标准为我们提供了两个示例:std::unique_ptr有删除器模板参数,而std::shared_ptr使用类型擦除。

RAII 的缺点

实际上,RAII 没有显著的缺点。它无疑是 C++中资源管理最广泛使用的惯用法。唯一需要关注的重要问题是与异常有关。释放资源可能会失败,就像其他任何东西一样。在 C++中,表示失败的一种常用方法是抛出异常。当这不可取时,我们退回到从函数返回错误代码。在 RAII 中,我们无法做到这两者中的任何一项。

很容易理解为什么错误代码不是一种选择——析构函数不返回任何内容。此外,我们也不能将错误代码写入对象的一些状态数据成员,因为对象正在被销毁,其数据成员以及其他包含 RAII 对象的作用域中的局部变量都消失了。唯一保存错误代码以供将来检查的方法是将它写入某种全局状态变量,或者至少是包含作用域中的变量。这在绑定中是可能的,但这样的解决方案非常不优雅且容易出错。这正是 C++在引入异常时试图解决的问题:手动传播的错误代码是容易出错且不可靠的。

那么,如果异常是 C++中错误报告的答案,为什么不用在这里呢?通常的回答是“C++中的析构函数不能抛出异常”。这抓住了问题的关键,但实际的限制要微妙一些。首先,在 C++11 之前,析构函数在技术上可以抛出异常,但异常会传播,并且(希望)最终会被捕获和处理。在 C++11 中,所有析构函数默认都是noexcept,除非明确指定为noexcept(false)。如果一个noexcept函数抛出了异常,程序将立即终止。

因此,在 C++11 中,析构函数不能抛出异常,除非你明确允许它们这样做。但在析构函数中抛出异常有什么问题呢?如果析构函数被执行是因为对象被删除,或者因为控制流到达了栈对象的作用域末尾,那么就没有问题。如果控制流没有正常到达作用域的末尾,而是因为已经抛出了一个异常而执行了析构函数,那么就会出现问题。在 C++中,两个异常不能同时传播。如果发生这种情况,程序将立即终止(注意,析构函数可以抛出和捕获异常,这没有问题,只要该异常不会从析构函数中传播出去)。当然,在编写程序时,没有办法知道在特定作用域中从某个地方调用的某个函数何时会抛出异常。如果资源释放抛出了异常,并且 RAII 对象允许该异常从其析构函数中传播出去,那么如果在异常处理期间调用了该析构函数,程序将会终止。唯一安全的方法是永远不允许异常从析构函数中传播。

这并不意味着释放资源的函数本身不能抛出异常,但如果它抛出了异常,RAII 析构函数必须捕获该异常:

class raii {
  ...
  ~raii() {
    try {
      release_resource();    // Might throw
    } catch ( ... ) {
      ... handle the exception, do NOT rethrow ...
    }
  }
};

这仍然没有给我们提供一种方法来指示在资源释放过程中发生了错误——抛出了一个异常,我们不得不捕获它并防止其逃逸。

这个问题有多严重?实际上并不严重。首先,释放内存——最常管理的资源——不会抛出异常。通常,内存不是简单地释放,而是通过删除对象来释放。但请记住,析构函数不应该抛出异常,这样通过删除对象释放内存的整个过程也不会抛出异常。在这个时候,读者可能会寻找一个反例,查看标准中如果解锁互斥锁失败会发生什么(这将迫使std::lock_guard的析构函数处理错误)。答案是既令人惊讶又发人深省——解锁互斥锁不能抛出异常,但如果它失败了,将产生未定义的行为。这不是偶然;互斥锁旨在与 RAII 对象一起工作。总的来说,这是 C++释放资源的方法:如果释放失败,不应该抛出异常,或者至少不允许其传播。它可以被捕获并记录,例如,但调用程序通常不会意识到失败,这可能会以未定义行为为代价。

尽管语言从 C++11 之前的版本到 C++20 发生了显著变化,RAII 仍然是一个非常成功的技巧,其发展变化很小(除了诸如构造函数参数推导等小的语法便利之外)。这是因为它实际上没有任何显著的缺点。但是,随着语言获得新的功能,有时我们发现方法来改进甚至最好的和最成熟的模式,这就是其中之一。

非常现代的 RAII

如果我们真的想挑剔一点,我们还可以对 RAII 提出另一个抱怨;在现实中,这只有在获取或释放代码很长且复杂时才是一个缺点。获取和释放分别在 RAII 对象的构造函数和析构函数中完成,而这段代码可能相当远离资源获取的地方(因此我们不得不在程序中跳来跳去才能弄清楚它做了什么)。

类似地,如果资源管理需要很多状态(例如,根据几个因素和条件采取的适当行动),我们必须在 RAII 对象中捕获所有这些状态。一个真正挑战 RAII 可读性的例子,在书页上也会完全不可读,因此我们不得不对其进行压缩。假设我们想要一个 RAII 锁保护器,它在锁定和解锁互斥锁时执行多个操作,甚至它处理资源的方式也取决于一些外部参数:

// Example 09a
class lock_guard {
  std::mutex& m_;
  const bool log_;
  const bool mt_;
  public:
  lock_guard(std::mutex& m, bool log, bool mt);
  ~lock_guard();
};
lock_guard ::lock_guard(std::mutex& m, bool log, bool mt)
  : m_(m), log_(log), mt_(mt) {
  if (log_) std::cout << "Before locking" << std::endl;
  if (mt_) m.lock();
}
lock_guard::~lock_guard() {
  if (mt_) m.unlock();
  if (log_) std::cout << "After locking" << std::endl;
}

下面是如何使用这个保护对象的示例:

#include <mutex>
std::mutex m;
const bool mt_run = true;
void work() {
  try {
    lock_guard lg(m, true, mt_run);
    … this code might throw …
    std::cout << "Work is happening" << std::endl;
  } catch (...) {}
}

在这里,我们除了锁定和解锁之外,只执行一个可能的行为——我们可以选择性地记录这些事件;你现在已经可以看到,构造函数和析构函数的实现,这两段必须紧密匹配的代码,已经有些分离。此外,跟踪状态(我们需要记录事件吗?我们是在多线程还是单线程上下文中运行?)变得有些冗长。再次强调,你必须记住这是一个简化的例子:在实际程序中,这仍然是一个很好的 RAII 对象。但是,如果代码变得更长,你可能希望有更好的方法。

在这种情况下,更好的方法借鉴自 Python(具体来说,来自 contextmanager 装饰器)。这种技术使用了协程,因此需要 C++20(所以我们成功地将 C++ 中最古老的工具之一与最前沿的工具结合在一起)。关于协程的一般解释以及 C++ 协程机制的特殊解释超出了本书的范围(你可以在我的书中找到,例如,“高效编程的艺术”,(www.packtpub.com/product/the-art-of-writing-efficient-programs/9781800208117))。

现在,记住两件事就足够了:

  • 首先,C++ 协程本质上与常规函数相同,除了它们可以在任何时候挂起自己并将控制权返回给调用者。调用者可以恢复协程,它将从挂起点继续执行,就像什么都没发生一样。

  • 其次,C++ 协程需要大量的标准样板代码。在接下来的示例中,我们将突出显示重要的片段;你可以安全地假设其余的代码是由标准所要求的,以便协程机制能够工作。

让我们先看看使用这种基于协程的 RAII 方法编写的锁卫代码是什么样的:

#include <mutex>
std::mutex m;
const bool mt_run = true;
co_resource<std::mutex> make_guard(std::mutex& m, bool log)
{
  if (log) std::cout << "Before locking" << std::endl;
  if (mt_run) m.lock();
  co_yield m;
  if (mt_run) m.unlock();
  if (log) std::cout << "After locking" << std::endl;
}
void work () {
  try {
    co_resource<std::mutex> lg { make_guard(m, true) };
    … this code might throw …
    std::cout << "Work is happening" << std::endl;
  } catch (...) {}
}

我们没有使用 lock_guard 对象,而是有 make_guard 函数。这个函数是一个协程;你可以通过它具有 co_yield 操作符来判断——这是 C++ 协程向调用者返回值的好几种方式之一。co_yield 之前的代码是资源获取,它在资源获取时执行,相当于我们 lock_guard 对象的构造函数。co_yield 之后的代码与 lock_guard 的析构函数相同。可以说,这更容易阅读和维护(至少在你不再盯着协程语法之后),因为所有代码都在同一个地方。你可以把 co_yield 看作是调用者将要执行的工作的占位符(在我们的例子中是互斥锁)。此外,没有要写的类成员和成员初始化——协程执行期间函数参数都是可访问的。

协程返回一个 co_resource<std::mutex> 类型的对象:这就是我们的现代 RAII 类型作为 co_resource 类模板实现的;它的实现如下:

// Example 09
#include <cassert>
#include <coroutine>
#include <cstddef>
#include <memory>
#include <utility>
template <typename T> class co_resource {
  public:
  using promise_type = struct promise_type<T>;
  using handle_type = std::coroutine_handle<promise_type>;
  co_resource(handle_type coro) : coro_(coro) {}
  co_resource(const co_resource&) = delete;
  co_resource& operator=(const co_resource&) = delete;
  co_resource(co_resource&& from)
     : coro_(std::exchange(from.coro_, nullptr)) {}
  co_resource& operator=(co_resource&& from) {
    std::destroy_at(this);
    std::construct_at(this, std::move(from));
    return *this;
  }
  ~co_resource() {
    if (!coro_) return;
    coro_.resume();     // Resume from the co_yield point
    coro_.destroy();    // Clean up
  }
  private:
  handle_type coro_;
};

这是拥有资源的对象,但你无法直接看到资源或其类型 T:它隐藏在协程句柄 coro_ 里面。这个句柄就像指向协程状态的指针。如果你暂时将句柄视为资源,我们有一个相当常规的资源拥有对象。它在构造函数中获取资源并保持独占所有权:除非通过移动转移所有权,否则不允许复制资源,句柄在 co_resource 对象的析构函数中被销毁。

这个资源拥有对象将由协程函数返回;每个这样的对象都必须包含一个名为 promise_type 的嵌套类型。通常,它是一个嵌套类,但也可以是一个单独的类型(在这个例子中,我们将其设置为这样的类型主要是为了避免一个非常长的代码片段)。标准对承诺类型的接口提出了几个要求,下面是我们为了这个目的满足这些要求的类型:

template <typename T> struct promise_type {
  const T* yielded_value_p = nullptr;
  std::suspend_never initial_suspend() noexcept {
    return {};
  }
  std::suspend_always final_suspend() noexcept {
    return {};
  }
  void return_void() noexcept {}
  void unhandled_exception() { throw; }
  std::suspend_always yield_value(const T& val) noexcept {
    yielded_value_p = &val;
    return {};
  }
  using handle_type = std::coroutine_handle<promise_type>;
  handle_type get_return_object() {
    return handle_type::from_promise(*this);
  }
};

我们的承诺类型包含一个指向协程返回值的指针。这个值是如何到达那里的?当协程通过 co_yield 返回结果时,会调用成员函数 yield_value()(编译器会生成这个调用)。co_yield 返回的值被传递给这个函数,该函数反过来捕获其地址(值的生命周期与协程本身相同)。承诺类型的另一个重要成员函数是 get_return_object():它由编译器调用,以将 co_resource 对象本身返回给 make_guard() 协程的调用者。请注意,它并不返回一个 co_resource 对象,而是一个可以隐式转换为 co_resource 的句柄(它有一个从 handle_type 的隐式构造函数)。对于我们的目的,promise_type 接口的其他部分是标准所要求的样板代码。

下面是如何 co_resource RAII 工作的:首先,我们调用函数 make_guard()

co_resource<std::mutex> make_guard(std::mutex& m, bool log)
{
  if (log) std::cout << "Before locking" << std::endl;
  if (mt_run) m.lock();
  co_yield m;
  if (mt_run) m.unlock();
  if (log) std::cout << "After locking" << std::endl;
}

从调用者的角度来看,它就像任何其他函数一样开始执行,尽管内部细节相当不同。在执行 co_yield 之前的所有代码都会被执行,然后协程被挂起,并且 co_resource<std::mutex> 对象被构造并返回给调用者,然后它被移动到一个栈变量中:

co_resource<std::mutex> lg { make_guard(m, true) };

执行过程如常,由锁定的互斥锁保护。在作用域结束时,对象 lg 被销毁;这无论我们是正常退出作用域还是通过抛出异常退出都会发生。在 co_resource 对象的析构函数中,通过调用协程句柄 coro_ 的成员函数 resume() 来恢复协程。这导致协程在停止之前立即恢复执行,因此控制权跳转到 co_yield 之后的下一行。我们编写在那里释放资源的代码被执行,协程通过作用域的底部退出,现在最后一次。co_resource 的析构函数有一些清理工作要做,但除此之外,我们(主要)完成了。

我们在避免过度扩展示例的过程中省略了一些内容。首先,按照目前的写法,如果资源类型 T 是引用,则 co_resource 模板将无法正常工作。这可能完全是可以接受的:通过 RAII 处理引用并不常见。在这种情况下,使用静态断言或概念检查就足够了。否则,我们必须在模板内部仔细处理依赖类型。其次,对协程的隐式要求,例如 make_guard(),是它必须通过 co_yield 返回一个值恰好一次(协程体中可以有多个 co_yield,但针对特定调用只能执行一个)。为了使代码健壮,我们必须使用运行时断言来验证这一要求是否得到满足。

现在,获取和释放代码紧挨在一起,我们不需要像构造函数和析构函数处理 RAII 那样将函数参数转换为数据成员。唯一可能使它变得更好的事情是,我们不需要为它编写单独的 make_guard() 函数,至少在我们只有一个调用的情况下是这样。结果证明,我们可以将协程和 lambda 结合起来达到这样的效果:

void work(){
  try {
    auto lg { &->co_resource<std::mutex> {
      if (log) std::cout << "Before locking" << std::endl;
      if (mt_run) m.lock();
      co_yield m;
      if (mt_run) m.unlock();
      if (log) std::cout << "After locking" << std::endl;
    }(true) };
    … this code might throw …
    std::cout << "Work is happening" << std::endl;
  } catch (...) {}
}

在这里,协程是 lambda 表达式的 operator();请注意,我们必须显式指定返回类型。lambda 立即被调用;并且,像往常一样,在这种情况下,使用捕获或参数归结为每种情况下更方便的做法。

基于协程的 RAII 资源管理使我们能够将所有相关的代码部分保持得很近。当然,这是有代价的:启动、挂起和恢复协程需要时间(以及一点内存)。基本的 RAII 对象总是更快,所以不要尝试用co_resource类替换std::unique_pointer。但请记住,我们最初对 RAII 的不满始于观察到,当执行资源获取或释放的代码很长、很复杂且使用大量状态变量时,RAII 类可能难以阅读。在这种情况下,协程的开销可能不太重要(我们也应该指出,稍后将在第十一章作用域保护者模式中描述的“作用域保护者”模式解决了相同的一些问题,有时是一个更好的选择)。

我们学到的 RAII 技术是一些最持久的 C++模式;它们从 C++的第一天开始使用,并继续从最新的语言特性中受益。在这本书中,我们将随意使用类,例如std::unique_ptrstd::lock_guard,而无需再思考。现在,我们留下这些最后的思考。

摘要

在这一章之后,你应该对资源管理临时方法的风险有清醒的认识。幸运的是,我们已经学习了 C++中最广泛使用的资源管理惯用语——RAII 惯用语。使用这种惯用语,每个资源都由一个对象拥有。对象的构造(或初始化)会获取资源,而对象的删除会释放资源。我们看到了使用 RAII 如何解决资源管理的问题,例如资源泄漏、意外共享资源和不正确释放资源。我们还学习了编写异常安全代码的基础,至少就资源泄漏或其他不当处理而言。编写 RAII 对象足够简单,但有几个注意事项需要记住。最后,我们回顾了当错误处理必须与 RAII 结合时出现的复杂性。

RAII 是一种资源管理惯用语,但它也可以被视为一种抽象技术:复杂的资源被隐藏在简单的资源句柄之后。下一章将介绍另一种抽象惯用语,类型擦除:我们将不再隐藏复杂对象,而是隐藏复杂类型。

问题

  1. 程序可以管理哪些资源?

  2. 在 C++程序中管理资源时,主要考虑因素是什么?

  3. 什么是 RAII?

  4. RAII 是如何解决资源泄漏问题的?

  5. RAII 是如何解决悬挂资源句柄问题的?

  6. C++标准库提供了哪些 RAII 对象?

  7. 在编写 RAII 对象时必须采取哪些预防措施?

  8. 如果释放资源失败会发生什么?

进一步阅读

第六章:理解类型擦除

类型擦除通常被视为一种神秘、神秘的编程技术。它不仅限于 C++(大多数关于类型擦除的教程都使用 Java 作为示例)。本章的目标是揭开神秘的面纱,教您什么是类型擦除以及如何在 C++ 中使用它。

本章将涵盖以下主题:

  • 什么是类型擦除?

  • 类型擦除是设计模式,还是实现技术?

  • 我们如何实现类型擦除?

  • 在决定使用类型擦除时,必须考虑哪些设计和性能方面的因素?对于类型擦除的使用,还可以提供哪些其他指导方针?

技术要求

示例代码可以在以下链接找到:github.com/PacktPublishing/Hands-On-Design-Patterns-with-CPP-Second-Edition/tree/main/Chapter06

您需要安装和配置 Google Benchmark 库,具体细节可以在此处找到:github.com/google/benchmark(参见第四章从简单到微妙

什么是类型擦除?

类型擦除,一般而言,是一种编程技术,通过该技术从程序中移除显式的类型信息。这是一种抽象类型,确保程序不显式依赖于某些数据类型。

这个定义虽然完全正确,但也完美地服务于将类型擦除笼罩在神秘之中。它通过一种循环推理的方式做到这一点——在你面前悬挂着一种看似不可能的希望——用强类型语言编写的程序实际上不使用类型。这怎么可能?当然是通过抽象掉类型!因此,希望和神秘得以延续。

很难想象一个不明确提及类型的程序(至少是一个 C++ 程序;当然,肯定有语言在运行时所有类型都不是最终的)。

因此,我们首先通过一个示例来展示类型擦除的含义。这应该能让我们对类型擦除有一个直观的理解,在本书的后续章节中,我们将对其进行发展和完善。这里的目的是提高抽象级别——而不是编写一些特定类型的代码,可能为不同类型编写几个版本,我们可以编写一个更抽象的版本,表达概念——例如,而不是编写一个接口表达“对整数数组进行排序”的概念的函数,我们希望编写一个更抽象的函数,“排序”任何数组。

通过示例进行类型擦除

我们将详细解释什么是类型擦除以及如何在 C++ 中实现它。但首先,让我们看看一个从程序中移除了显式类型信息的程序是什么样的。

我们从一个使用唯一指针的简单例子开始,std::unique_ptr

std::unique_ptr<int> p(new int(0));

这是一个拥有指针(参见第三章内存和所有权)——包含此指针的实体,例如对象或函数作用域,也控制我们分配的整数的生命周期,并负责其删除。删除在代码中不是显式的——当p指针被删除时(例如,当它超出作用域时)将发生删除。这种删除方式也不是显式的——默认情况下,std::unique_ptr使用operator delete删除它拥有的对象,或者更准确地说,通过调用std::default_delete,它反过来调用operator delete。如果我们不想使用常规的标准delete呢?例如,我们可能有在自定义堆上分配的对象:

class MyHeap {
  public:
  ...
  void* allocate(size_t size);
  void deallocate(void* p);
  ...
};
void* operator new(size_t size, MyHeap* heap) {
  return heap->allocate(size);
}

分配没有问题,借助重载的operator new

MyHeap heap;
std::unique_ptr<int> p(new(&heap) int(0));

这个语法调用了双参数的operator new函数;第一个参数总是大小,由编译器添加,第二个参数是堆指针。由于我们声明了这样的重载,它将被调用,并返回从堆中分配的内存。但我们没有做任何改变对象删除方式的事情。常规的operator delete函数将被调用,并尝试将一些未从那里分配的内存返回给全局堆。结果很可能是内存损坏,并且可能崩溃。我们可以定义一个具有相同额外参数的operator delete函数,但这在这里对我们没有好处——与operator new不同,没有地方可以传递参数给delete(你经常会看到定义这样的operator delete函数,并且它应该这样行为,但它与程序中看到的任何delete都没有关系;它用于构造函数抛出异常时的栈回溯)。

某种程度上,我们需要告诉唯一指针,这个特定的对象要以不同的方式被删除。结果发现std::unique_ptr有一个第二个template参数。你通常看不到它,因为它默认为std::default_delete,但这是可以改变的,可以定义一个自定义的deleter对象来匹配分配机制。deleter有一个非常简单的接口——它需要是可调用的:

template <typename T> struct MyDeleter {
  void operator()(T* p);
};

std::default_delete策略的实现基本上就是这样,它简单地调用p指针上的delete。我们的自定义deleter需要一个非平凡的构造函数来存储堆指针。请注意,虽然deleter通常需要能够删除任何可以分配的类型的对象,但它不必是一个模板类。一个具有模板成员函数的非模板类也可以做到这一点,只要类的数据成员不依赖于被删除的类型。在我们的情况下,数据成员只依赖于堆的类型,而不是被删除的内容:

class MyDeleter {
  MyHeap* heap_;
  public:
  MyDeleter(MyHeap* heap) : heap_(heap) {}
  template <typename T> void operator()(T* p) {
    p->~T();
    heap_->deallocate(p);
  }
};

deleter必须执行标准operator delete函数的两个函数的等效操作——它必须调用被删除对象的析构函数,然后它必须释放为该对象分配的内存。

现在我们有了合适的deleter,我们终于可以使用我们自己的堆来使用std::unique_ptr

// Example 01
MyHeap heap;
MyDeleter deleter(&heap);
std::unique_ptr<int, MyDeleter> p(
  new(&heap) int(0), deleter);

注意,deleter对象通常在需要时创建,即在分配点:

MyHeap heap;
std::unique_ptr<int, MyDeleter> p(
  new(&heap) int(0), MyDeleter(&heap));

无论哪种方式,deleter都必须是不可抛出复制的或不可抛出移动的;也就是说,它必须有一个复制构造函数或移动构造函数,并且构造函数必须声明为noexcept。内置类型,如原始指针,当然是可复制的,并且默认的编译器生成的构造函数不会抛出异常。任何将一个或多个这些类型作为数据成员的组合聚合类型,例如我们的deleter,都有一个默认构造函数,也不会抛出异常(除非它已经被重新定义,当然)。

注意,deleter是唯一指针类型的一部分。拥有相同类型对象但具有不同deleter的唯一指针是不同的类型:

// Example 02
MyHeap heap;
std::unique_ptr<int, MyDeleter> p(
  new(&heap) int(0), MyDeleter(&heap));
std::unique_ptr<int> q(new int(0));
p = std::move(q);    // Error: p and q are different types

同样,唯一指针必须使用正确类型的deleter来构造:

std::unique_ptr<int> p(new(&heap) int(0),
  MyDeleter(&heap));    // Does not compile

作为旁注,在实验不同类型的唯一指针时,你可能会注意到前面代码中的两个指针pq,虽然不可赋值,但可以比较:p == q可以编译。这是因为比较运算符实际上是一个模板——它接受两种不同类型的唯一指针并比较其底层原始指针(如果该类型也不同,编译错误可能不会提到唯一指针,而是说一些关于比较没有转换的不同类型的指针的事情)。

现在,让我们用共享指针std::shared_ptr来做同样的例子。首先,我们将共享指针指向使用常规operator new函数构造的对象,如下所示:

std::unique_ptr<int> p(new int(0));
std::shared_ptr<int> q(new int(0));

为了比较,我们也将唯一指针的声明留在了那里。这两个智能指针以完全相同的方式声明和构造。现在,在下面的代码块中,是分配在我们heap上的对象的共享指针:

MyHeap heap;
std::unique_ptr<int, MyDeleter> p(
  new(&heap) int(0), MyDeleter(&heap));
std::shared_ptr<int> q(
  new(&heap) int(0), MyDeleter(&heap));

现在你可以看到区别了——使用自定义deleter创建的共享指针,尽管如此,其类型与使用默认deleter的指针相同!实际上,所有指向int的共享指针都具有相同的类型,std::shared_ptr<int>——模板没有另一个参数。仔细思考一下——deleter在构造函数中指定,但仅在析构函数中使用,因此它必须存储在智能指针对象中,直到需要时。如果我们失去了在构造过程中给出的对象,就没有办法恢复它。std::shared_ptrstd::unique_ptr都必须在指针对象内部存储任意类型的deleter对象。但只有std::unique_ptr类在其类型中包含删除器信息。std::shared_ptr类对所有删除器类型都是相同的。回到本节的开头,使用std::shared_ptr<int>的程序没有关于删除器类型的任何显式信息。

这个类型已经被从程序中擦除。这就是类型擦除程序的样子:

// Example 03
void some_function(std::shared_ptr<int>);     // no deleter
MyHeap heap;
{
  std::shared_ptr<int> p(    // No deleter in the type
    new(&heap) int(0),
    MyDeleter(&heap));    // Deleter in constructor only
  std::shared_ptr<int> q(p);    // No deleter type anywhere
  some_function(p);    // uses p, no deleter
}    // Deletion happens, MyDeleter is invoked

我们花费了大量的时间来剖析std::shared_ptr,因为它提供了一个非常简单的类型擦除示例,尤其是当我们将其与必须解决相同问题但选择相反方法的std::unique_ptr进行对比时。然而,这个简单的例子并没有突出选择类型擦除的设计含义,也没有说明这个模式解决了哪些设计问题。为了了解这一点,我们应该看看 C++中典型的类型擦除对象:std::function

从例子到一般化

在 C++中,std::function是一个通用的多态函数包装器,或者是一个通用的可调用对象。它用于存储任何可调用实体,如函数、lambda 表达式、仿函数(具有operator()的对象)或成员函数指针。这些不同可调用实体的唯一要求是它们必须具有相同的调用签名,即接受相同的参数并返回相同类型的结果。签名是在声明特定std::function对象时指定的:

std::function<int(long, double)> f;

我们刚刚声明了一个可以接受两个参数(longdouble,或者更准确地说,接受任何两个可以转换为longdouble的参数)的可调用对象,并且返回的结果可以被转换为int。它对参数做了什么,结果是什么?这取决于分配给f的具体可调用实体:

// Example 04
std::function<size_t(const std::string&)> f;
size_t f1(const std::string& s) { return s.capacity(); }
f = f1;
std::cout << f("abcde");    // 15
char c = 'b';
f = = { return s.find(c); };
std::cout << f("abcde");    // 1
f = &std::string::size;
std::cout << f("abcde");    // 5

在这个例子中,我们首先将一个非成员函数f1赋值给f;现在调用f(s)返回字符串s的容量,因为这就是f1所做的事情。接下来,我们将f改为包含一个 lambda 表达式;现在调用f(s)将调用该表达式。这两个函数唯一共同之处是接口:它们接受相同的参数并具有相同的返回类型。最后,我们将成员函数指针赋值给f;虽然std::string::size()函数不接受任何参数,但所有成员函数都有一个隐含的第一个参数,即对对象的引用,因此它符合接口的要求。

现在,我们可以看到类型擦除的更一般形式:它是对许多不同实现提供相同行为的抽象。让我们考虑它打开了哪些设计能力。

类型擦除作为设计模式

我们已经看到了类型擦除在程序中的表现:代码期望某些语义行为,但不是处理提供它的特定类型,而是使用一个抽象并“擦除”那些与当前任务无关的类型属性(从类型的名称开始)。

这样,类型擦除具有其他几个设计模式的属性,但它并不等同于任何一种。它可以合理地被认为是一种独立的设计模式。那么,类型擦除作为设计模式提供了什么?

在类型擦除中,我们发现了一种对某些行为(如函数调用)的抽象表达,可以用来将接口与实现分离。到目前为止,这听起来非常类似于继承。现在回想一下,在上一个部分的结尾,我们是如何让一个std::function对象调用几个完全不同的可调用对象的:一个函数、一个 lambda 表达式和一个成员函数。这说明了类型擦除与继承之间的基本区别:在继承中,基类决定了抽象行为(接口),任何需要实现该接口的类都必须从同一个基类派生。而在类型擦除中,没有这样的要求:提供共同行为的类型不需要形成任何特定的层次结构;实际上,它们甚至不需要是类。

可以说类型擦除提供了一种非侵入式的方法来分离接口和实现。当我们说“侵入式”时,指的是我们必须改变类型才能使用抽象:例如,我们可能有一个具有所需行为的类,但为了能够多态地使用,它还必须继承自公共基类。这就是“侵入” —— 我们必须对原本非常好的类进行强制更改,以便使其能够作为某个抽象接口的具体实现使用。正如我们刚才看到的,类型擦除没有这样的需求。只要类(或任何其他类型)具有所需的行为——通常,以函数调用类似的方式使用某些参数来调用它——就可以用来实现这种行为。类型的其他属性对我们关注的接口支持并不相关,并且被“擦除”。

我们也可以说类型擦除提供了“外部多态性”:不需要统一的层次结构,可以用来实现特定抽象的类型集是可扩展的,不仅限于从公共基类派生的类。

那么,为什么类型擦除不能完全取代 C++中的继承?在某种程度上,这是传统;不过,不要过于迅速地摒弃传统——传统的另一个名字是“惯例”,惯例代码也是熟悉且易于理解的代码。但还有两个“真实”的原因。第一个是性能。我们将在本章后面研究类型擦除的实现及其相应的性能;然而,不提前剧透,我们可以这样说,高性能的类型擦除实现最近才变得可用。第二个原因是便利性,我们已经在其中看到了这一点。如果我们需要为一系列相关操作声明一个抽象,我们可以声明一个具有必要虚拟成员函数的基类。如果我们使用std::function方法,类型擦除的实现将不得不分别处理这些操作中的每一个。正如我们很快就会看到的,这不是一个要求——我们可以一次性实现一组操作的类型擦除抽象。然而,使用继承来做这件事更容易。此外,记住,所有隐藏在类型擦除背后的具体类型都必须提供所需的行为;如果我们要求所有这些类型支持多个不同的成员函数,那么它们更有可能来自相同的层次结构,出于其他原因。

类型擦除作为一种实现技术

并非每个类型擦除的使用都背后有一个宏伟的设计理念。通常,类型擦除纯粹作为一种实现技术(继承也是如此,我们即将看到这样一个用法)。特别是,类型擦除是打破大型系统组件之间依赖关系的一个伟大工具。

这里有一个简单的例子。我们正在构建一个大型分布式软件系统,因此我们的核心组件之一是网络通信层:

class Network {
  …
  void send(const char* data);
  void receive(const char* buffer);
  …
};

当然,这是一个非常简化和抽象的组件视图,这个组件至多是非平凡的,但我们现在不想关注通过网络发送数据。重要的是,这是我们的基础组件之一,系统的其余部分都依赖于它。我们可能有几个不同的程序作为我们的软件解决方案的一部分构建;它们都包含这个通信库。

现在,在某个具体的应用程序中,我们需要在网络发送数据包之前和之后处理这些数据包;这可能是一个需要高级加密的高安全性系统,或者它可能是我们系统中唯一设计用于在不可靠网络上工作并需要插入错误纠正代码的工具。重点是,网络层的开发者现在被要求引入对来自更高层应用程序特定组件的外部代码的依赖:

class Network {
  …
  bool needs_processing;
  void send(const char* data) {
    if (needs_processing) apply_processing(buffer);
    …
  }
  …
};

虽然这段代码看起来很简单,但它却是一个依赖噩梦:低级库现在必须使用特定应用程序的apply_processing()函数来构建。更糟糕的是,所有不需要这个功能的其他程序仍然必须编译和链接这段代码,即使它们从未设置needs_processing

虽然这个问题可以用“老式”的方法处理——使用一些函数指针或(更糟的是)全局变量,但类型擦除提供了一个优雅的解决方案:

// Example 05
class Network {
  static const char* default_processor(const char* data) {
    std::cout << "Default processing" << std::endl;
    return data;
  }
  std::function<const char*(const char*)> processor =
    default_processor;
  void send(const char* data) {
    data = processor(data);
    …
  }
  public:
  template <typename F>
  void set_processor(F&& f) { processor = f; }
};

这是一个策略设计模式的例子,其中特定行为的实现可以在运行时选择。现在,系统的任何更高层组件都可以指定它自己的处理器函数(或 lambda 表达式,或可调用对象),而无需强迫软件的其他部分与其代码链接:

Network N;
N.set_processor([](const char* s){ char* c; …; return c; };

现在我们已经知道了类型擦除的样子以及它如何作为设计模式和方便的实现技术帮助解耦组件,只剩下最后一个问题——它是如何工作的?

类型擦除在 C++中是如何实现的?

我们已经看到了 C++中类型擦除的样子,现在我们理解了程序不显式依赖于类型意味着什么。但谜团仍然存在——程序没有提及类型,然而,在正确的时间,它却调用了它一无所知的类型的操作。如何?这正是我们即将看到的。

非常古老的擦除类型

编写没有显式类型信息的程序的想法当然不是新的。实际上,它比面向对象编程和对象的概念要早得多。以这个 C 程序(这里没有 C++)为例:

// Example 06
int less(const void* a, const int* b) {
  return *(const int*)a - *(const int*)b;
}
int main() {
  int a[10] = { 1, 10, 2, 9, 3, 8, 4, 7, 5, 0 };
  qsort(a, 10, sizeof(int), less);
}

现在记住标准C库中qsort函数的声明:

void qsort(void *base, size_t nmemb, size_t size,
  int (*compare)(const void *, const void *));

注意,虽然我们使用它来对整数数组进行排序,但qsort函数本身没有任何显式类型——它使用void*来传递要排序的数组。同样,比较函数接受两个void*指针,其声明中没有显式类型信息。当然,在某个时候,我们需要知道如何比较实际类型。在我们的 C 程序中,理论上可以指向任何内容的指针被重新解释为指向整数的指针。这种反转抽象的行为被称为具体化

在 C 语言中,恢复具体类型完全是程序员的职责——我们的less()比较函数实际上只比较整数,但从接口中无法推断出来。同样,在程序运行时验证整个程序中是否使用了正确的类型也是不可能的,程序自动选择运行时实际类型的正确比较操作当然也是不可能的。

尽管如此,这个简单的例子让我们揭开了类型擦除的神秘面纱:一般代码确实不依赖于被擦除的具体类型,但这种类型隐藏在通过类型擦除接口调用的函数的代码中。在我们的例子中,是比较函数:

int less(const void* a, const int* b) {
  return *(const int*)a - *(const int*)b;
}

调用代码对类型int一无所知,但less()函数的实现操作的是这个类型。类型“隐藏”在通过类型无关接口调用的函数的代码中。

这种 C 语言方法的重大缺点是程序员必须完全负责确保所有类型擦除代码的各个部分保持一致;在我们的例子中,这是排序数据和比较函数必须引用相同的类型。

在 C++中,我们可以做得更好,但理念仍然是相同的:被擦除的类型通过通过类型无关接口调用的某些特定类型代码的实现而具体化。关键的区别是我们将强迫编译器为我们生成此代码。从根本上讲,有两种技术可以使用。第一种依赖于运行时多态(继承),第二种使用模板魔法。让我们从多态实现开始。

使用继承进行类型擦除

我们现在将看到std::shared_ptr是如何施展其魔法的。我们将用一个简化的智能指针示例来完成,这个示例专门关注类型擦除方面。了解到这是通过泛型和面向对象编程的组合来完成,你不会感到惊讶:

// Example 07
template <typename T> class smartptr {
  struct destroy_base {
    virtual void operator()(void*) = 0;
    virtual ~deleter_base() {}
  };
  template <typename Deleter>
  struct destroy : public destroy _base {
    destroy (Deleter d) : d_(d) {}
    void operator()(void* p) override {
      d_(static_cast<T*>(p));
    }
    Deleter d_;
  };
  public:
  template <typename Deleter> smartptr(T* p, Deleter d) :
    p_(p), d_(new destroy<Deleter>(d)) {}
  ~smartptr() { (*d_)(p_); delete d_; }
  T* operator->() { return p_; }
  const T* operator->() const { return p_; }
  private:
  T* p_;
  destroy _base* d_;
};

smartptr模板只有一个类型参数。由于擦除的类型不是智能指针类型的组成部分,它必须被捕获在其他某个对象中。在我们的例子中,这个对象是嵌套的smartptr<T>::destroy模板的一个实例化。这个对象是由构造函数创建的,这是代码中显式存在删除器类型的最后一个点。但是smartptr必须通过一个不依赖于destroy(因为智能指针对象对所有删除器都有相同的类型)的指针来引用destroy实例。因此,所有destroy模板的实例都继承自同一个基类,destroy_base,实际的删除器是通过一个虚拟函数调用的。构造函数是一个模板,它推导出删除器的类型,但这个类型只是隐藏的,因为它实际上是这个模板特定实例化的声明的一部分。智能指针类本身,特别是它的析构函数,实际上使用了删除器类型,进行了擦除。编译时类型检测用于创建一个在运行时重新发现删除器类型并执行正确操作的构造正确性的多态对象。因此,我们不需要动态类型转换,而可以使用静态类型转换,这只有在我们知道实际的派生类型时才有效(我们确实知道)。

同样的技术可以用来实现std::function和其他类型擦除类型,例如终极类型擦除类std::any(在 C++17 及以上版本)。这是一个类,而不是模板,但它可以持有任何类型的值:

// Example 08
std::any a(5);
int i = std::any_cast<int>(a);    // i == 5
std::any_cast<long>(a);        // throws bad_any_cast

当然,如果不了解类型,std::any就无法提供任何接口。你可以将它存储任何值,如果你知道正确的类型(或者你可以请求类型并获取一个std::type_info对象)。

在我们学习其他(通常更有效)实现类型擦除的方法之前,我们必须解决我们设计中一个明显低效的问题:每次我们创建或删除一个共享指针或一个按上述方式实现的std::function对象时,我们必须为隐藏擦除类型的派生对象分配和释放内存。

无内存分配的类型擦除

然而,有方法可以优化类型擦除指针(以及任何其他类型擦除数据结构),并避免在构建多态的smartptr::destroy对象时发生的额外内存分配。我们可以通过预先为这些对象分配内存缓冲区来避免这种分配,至少在某些情况下可以这样做。这种优化的细节以及它的限制在第十章本地缓冲区优化中进行了讨论。以下是优化的要点:

// Example 07
template <typename T> class smartptr {
  …
  public:
  template <typename Deleter> smartptr(T* p, Deleter d) :
    p_(p) {
    static_assert(sizeof(Deleter) <= sizeof(buf_));
    ::new (static_cast<void*>(buf_)) destroy<Deleter>(d));
  }
  ~smartptr() {
    destroy_base* d = (destroy_base*)buf_;
    (*d)(p_);
    d->~destroy_base();
  }
  private:
  T* p_;
  alignas(8) char buf_[16];
};

本地缓冲区优化确实使类型擦除指针和函数变得更加高效,正如我们将在本章后面看到的那样。当然,它对删除器的尺寸施加了限制;因此,大多数实际应用使用本地缓冲区来存储足够小的擦除类型,而对于不适合缓冲区的类型则使用动态内存。上述替代方案——断言并强制程序员增加缓冲区——在非常高性能的应用中通常被采用。

使用这种优化的某些细微后果:删除器(或另一个擦除对象)现在作为类的一部分存储,并且必须与类的其余部分一起复制。我们如何复制一个我们不再知道其类型的对象?这个问题和其他细节将留待第第十章**,本地缓冲区优化中讨论。现在,我们将继续在其余的示例中使用本地缓冲区优化,以展示其用法并简化代码。

无继承的类型擦除

类型擦除的另一种实现不使用内部类层次结构来存储擦除类型。相反,类型被捕获在函数的实现中,就像在 C 中做的那样:

void erased_func(void* p) {
  TE* q = static_cast<T*>(p);
  … do work on type TE …
}

在 C++中,我们将函数做成模板,以便编译器为每个我们需要的类型 TE 生成实例:

template <typename TE> void erased_func(void* p) {
  TE* q = static_cast<T*>(p);
  … do work on type TE …
}

这是一个有些不寻常的模板函数:类型参数不能从参数中推断出来,必须显式指定。我们已经知道这将在类型擦除类的构造函数中完成,例如我们的智能指针:在那里,我们仍然知道即将被擦除的类型。另一个非常重要的点是,由前面的模板生成的任何函数都可以通过相同的函数指针调用:

void(*)(void*) fp = erased_func<int>; // or any other type

现在我们可以看到类型擦除的魔法是如何工作的:我们有一个函数指针,其类型不依赖于我们正在擦除的类型 TE。我们将生成一个使用此类型的实现,并将其分配给此指针。当我们需要使用擦除类型 TE 时,例如使用指定的删除器删除对象,我们将通过这个指针调用一个函数;我们可以做到这一点而无需知道 TE 是什么。我们只需将这些全部组合成一个正确构建的实现,这就是我们的类型擦除智能指针:

// Example 07
template <typename T>
class smartptr_te_static {
  T* p_;
  using destroy_t = void(*)(T*, void*);
  destroy_t destroy_;
  alignas(8) char buf_[8];
  template<typename Deleter>
  static void invoke_destroy(T* p, void* d) {
    (*static_cast<Deleter*>(d))(p);
  }
  public:
  template <typename Deleter>
  smartptr_te_static(T* p, Deleter d)
    : p_(p), destroy_(invoke_destroy<Deleter>)
  {
    static_assert(sizeof(Deleter) <= sizeof(buf_));
    ::new (static_cast<void*>(buf_)) Deleter(d);
  }
  ~smartptr_te_static() {
    this->destroy_(p_, buf_);
  }
  T* operator->() { return p_; }
  const T* operator->() const { return p_; }
};

我们将用户提供的删除器存储在一个小的本地缓冲区中;在这个例子中,我们没有展示对于需要动态内存分配的较大删除器的替代实现。保留关于擦除类型信息的函数模板是 invoke_destroy()。请注意,它是一个静态函数;静态函数可以通过常规函数指针而不是更繁琐的成员函数指针来调用。

smartptr类的构造函数中,我们实例化invoke_destroy<Deleter>并将其赋值给destroy_函数指针。我们还需要删除器对象的副本,因为删除器可能包含状态(例如,指向为智能指针拥有的对象提供内存的分配器的指针)。我们在局部缓冲区buf_提供的空间中构建这个删除器。此时,原始的Deleter类型已被擦除:我们拥有的只是一个不依赖于Deleter类型的函数指针和一个字符数组。

当需要销毁共享指针拥有的对象时,我们需要调用删除器。相反,我们通过destroy_指针调用函数,并将要销毁的对象以及删除器所在的缓冲区传递给它。擦除的Deleter类型无处可寻,但它隐藏在invoke_destroy()的具体实现中。在那里,缓冲区的指针被转换回实际存储在缓冲区中的类型(Deleter),然后调用删除器。

这个例子可能是 C++中类型擦除机制最简洁的演示。但它并不完全等同于前一个章节中我们使用继承的例子。当我们对智能指针拥有的类型为T的对象调用删除器时,我们并没有对删除器对象本身进行任何销毁操作,特别是我们存储在局部缓冲区内的副本。这里的局部缓冲区并不是问题:如果我们动态分配内存,它仍然会通过一个通用指针,如char*void*来访问,而现在我们知道如何正确地删除它。为此,我们需要另一个可以具体化原始类型的函数。好吧,也许:平凡可销毁的删除器(以及在一般情况下,平凡可销毁的可调用对象)非常常见。所有函数指针、成员函数指针、无状态对象以及不通过值捕获任何非平凡对象的 lambda 表达式都是平凡可销毁的。因此,我们可以在构造函数中简单地添加一个静态断言,并将我们的智能指针限制为平凡可销毁的删除器,实际上,在大多数情况下它都会很好地为我们服务。但我也想向你展示一个更通用的解决方案。

我们当然可以使用另一个指向静态销毁删除器的指针,并在构造函数中以正确的类型实例化它。但析构函数并不是我们需要的结束:通常,我们还需要复制和移动删除器,甚至可能需要比较它们。这会导致我们的smartptr类变得臃肿。相比之下,基于继承的实现只需要一个指向destroy对象的指针(存储为基类destroy_base的指针)就完成了所有操作。有一种方法我们可以做到同样的事情。对于这个例子,没有好的方法可以逐步揭示魔法,所以我们不得不直接跳进去,并逐行进行解释:

// Example 07
template <typename T>
class smartptr_te_vtable {
  T* p_;
  struct vtable_t {
    using destroy_t = void(*)(T*, void*);
    using destructor_t = void(*)(void*);
    destroy_t destroy_;
    destructor_t destructor_;
  };
  const vtable_t* vtable_ = nullptr;
  template <typename Deleter>
  constexpr static vtable_t vtable = {
    smartptr_te_vtable::template destroy<Deleter>,
    smartptr_te_vtable::template destructor<Deleter>
  };
  template <typename Deleter>
  static void destroy(T* p, void* d) {
    (*static_cast<Deleter*>(d))(p);
  }
  template <typename Deleter>
  static void destructor(void* d) {
    static_cast<Deleter*>(d)->~Deleter();
  }
  alignas(8) char buf_[8];
  public:
  template <typename Deleter>
  smartptr_te_vtable(T* p, Deleter d)
    : p_(p), vtable_(&vtable<Deleter>)
  {
    static_assert(sizeof(Deleter) <= sizeof(buf_));
    ::new (static_cast<void*>(buf_)) Deleter(d);
  }
  ~smartptr_te_vtable() {
    this->vtable_->destroy_(p_, buf_);
    this->vtable_->destructor_(buf_);
  }
  T* operator->() { return p_; }
  const T* operator->() const { return p_; }
};

让我们解释一下这段代码是如何工作的。首先,我们声明一个 struct vtable_t,它包含指向我们需要在擦除的 Deleter 类型上实现的所有操作的函数指针。在我们的例子中,只有两个:对一个将被销毁的对象调用删除器,以及销毁删除器本身。一般来说,我们至少会在那里有复制和移动操作(你将在 第十章**,局部缓冲区优化)中找到这样的实现)。接下来,我们有 vtable_ 指针。在对象构建之后,它将指向 vtable_t 类型的对象。虽然这可能会暗示接下来会有动态内存分配,但我们将做得更好。接下来是一个变量模板 vtable;在具体的 Deleter 类型上实例化它将创建一个 vtable_t 类型的静态变量实例。这可能是最棘手的部分:通常,当我们有一个类的静态数据成员时,它只是一个变量,我们可以通过名称访问它。但这里有一些不同:名称 vtable 可以引用许多对象,它们都是同一类型 vtable_t。我们都没有显式创建它们:我们不会为它们分配内存,也不会调用 operator new 来构建它们。编译器为每个我们使用的 Deleter 类型创建一个这样的对象。对于每个 smartptr 对象,我们想要它使用的特定 vtable 对象的地址存储在 vtable_ 指针中。

类型为 vtable_t 的对象包含指向静态函数的指针。我们的 vtable 也必须这样做:正如你所见,我们在 vtable 中初始化了函数指针,使其指向 smartptr 类静态成员函数的实例化。这些实例化是为了与 vtable 本身实例化的相同 Deleter 类型。

vtable 这个名字不是随便选择的:我们确实实现了一个虚表;编译器为每个多态层次结构构建了一个非常类似的结构,其中包含函数指针,每个虚拟类都有一个虚拟指针,指向其原始类型(它被构建时的类型)的表。

vtable 之后,我们有两个静态函数模板。这就是擦除的类型真正隐藏的地方,稍后会被重新实现。正如我们之前所看到的,函数签名不依赖于 Deleter 类型,但它们的实现是。最后,我们有一个相同的缓冲区用于在本地存储删除器对象。

如前所述,构造函数将一切联系在一起;这是必须的,因为构造函数是此代码中唯一一个显式知道 Deleter 类型的位置。我们的构造函数做了三件事:首先,它存储了对象的指针,就像任何其他智能指针一样。其次,它将 vtable_ 指针指向正确的 Deleter 类型的静态 vtable 变量的一个实例。最后,它在局部缓冲区中构造了删除器的副本。此时,Deleter 类型被擦除:smartptr 对象中的任何内容都没有显式依赖于它。

当调用智能指针的析构函数,需要销毁所拥有的对象和析构函数本身时,销毁者和它的真实类型再次发挥作用。这些操作都是通过间接函数调用来完成的。要调用的函数存储在 *vtable_ 对象中(就像对于多态类,正确的虚拟函数重写可以在函数指针的虚表中找到)。销毁者通过缓冲区的地址传递给这些函数——那里没有类型信息。但是,这些函数是为特定的 Deleter 类型生成的,因此它们将 void* 缓冲区地址转换为正确的类型,并使用存储在缓冲区中的销毁者。

此实现允许我们在对象本身中只存储一个 vtable_ 指针的同时执行多个类型擦除操作。

我们也可以结合两种方法:通过虚拟表调用一些操作,并为其他操作保留专用函数指针。为什么?可能是性能:通过虚拟表调用函数可能稍微慢一些。这需要针对任何特定应用程序进行测量。

到目前为止,我们使用类型擦除来提供针对非常特定行为的抽象接口。我们已经知道类型擦除不需要如此受限——我们已经看到了 std::function 的例子。本节最后的例子将是我们的通用类型擦除函数。

高效的类型擦除

在上一节的示例和解释之后,类型擦除函数不会构成太大的挑战。尽管如此,在这里展示它的价值仍然存在。我们将展示一个非常高效的类型擦除实现(本书中找到的实现受到了 Arthur O’Dwyer 和 Eduardo Magrid 的工作的启发)。

通用函数的模板如下所示:

template<typename Signature> class Function;

这里 Signature 类似于 int(std::string),这是一个接受字符串并返回整数的函数。只要可以像具有指定签名的函数一样调用此类型的对象,该函数就可以构造为调用任何 Callable 类型。

我们将再次使用局部缓冲区,但不是将其硬编码到类中,而是将模板参数添加到其中以控制缓冲区大小和对齐:

template<typename Signature, size_t Size = 16,
         size_t Alignment = 8> struct Function;

为了便于编码,将函数签名拆分为参数类型 Args... 和返回类型 Res 是方便的。这样做最简单的方法是使用类模板特化:

// Example 09
template<typename Signature, size_t Size = 16,
         size_t Alignment = 8> struct Function;
template<size_t Size, size_t Alignment,
         typename Res, typename... Args>
struct Function<Res(Args...), Size, Alignment> {…};

现在剩下的只是实现的小问题。首先,我们需要一个缓冲区来存储其中的 Callable 对象:

// Example 09
alignas(Alignment) char space_[Size];

其次,我们需要一个函数指针 executor_ 来存储从模板生成的静态函数 executor 的地址,该模板具有 Callable 对象的类型:

// Example 09
using executor_t = Res(*)(Args..., void*);
executor_t executor_;
template<typename Callable>
static Res executor(Args... args, void* this_function) {
  return (*reinterpret_cast<Callable*>(
    static_cast<Function*>(this_function)->space_))
  (std::forward<Args>(args)...);
}

接下来,在构造函数中,我们必须初始化执行器并将可调用对象存储在缓冲区中:

// Example 09
template <typename CallableArg,
          typename Callable = std::decay_t<CallableArg>>
  requires(!std::same_as<Function, Callable>)
Function(CallableArg&& callable) :
  executor_(executor<Callable>)
{
  ::new (static_cast<void*>(space_))
    Callable(std::forward<CallableArg>(callable));
}

构造函数有两个微妙之处。第一个是处理可调用对象类型的方式:我们推断其为CallableArg,但随后将其用作Callable。这是因为CallableArg可能是指向可调用对象类型的引用,例如函数指针,我们不希望构造一个引用的副本。第二个是概念限制:Function本身是一个具有相同签名的可调用对象,但我们不希望这个构造函数在这种情况下适用——那是复制构造函数的工作。如果你不使用 C++20,你必须使用 SFINAE 来实现相同的效果(有关详细信息,请参阅第七章**,SFINAE、概念和重载解析管理)。如果你喜欢概念风格,你可以在一定程度上模拟它:

#define REQUIRES(...) \
  std::enable_if_t<__VA_ARGS__, int> = 0
template <typename CallableArg,
          typename Callable = std::decay_t<CallableArg>,
          REQUIRES(!std::is_same_v<Function, Callable>)>)
Function(CallableArg&& callable) …

谈到复制,我们的Function函数仅适用于可以轻易复制和轻易破坏的Callable类型,因为我们没有提供任何销毁或复制存储在缓冲区中的可调用对象的手段。这仍然覆盖了很多领域,但我们可以使用 vtable 方法处理非平凡的可调用对象(你可以在第十章**,本地 缓冲区优化)中找到一个示例)。

现在我们还需要注意的一个细节是:std::function可以无任何可调用对象进行默认构造;调用这样的“空”函数会抛出std::bad_function_call异常。如果我们初始化执行器为一个预定义的什么也不做只是抛出这个异常的函数,我们也可以这样做:

// Example 09
static constexpr Res default_executor(Args..., void*) {
  throw std::bad_function_call();
}
constexpr static executor_t default_executor_ =
  default_executor;
executor_t executor_ = default_executor_;

现在我们有一个与std::function非常相似(或者如果添加了对调用成员函数、复制和移动语义以及少数缺失的成员函数的支持,它将是这样)的泛型函数。它确实以相同的方式工作:

Function<int(int, int, int, int)> f =
  [](int a, int b, int c, int d) { return a + b + c + d; };
int res = f(1, 2, 3, 4);

那这种便利性又让我们付出了什么代价呢?所有性能都应该被衡量,但通过检查编译器在必须调用类型擦除函数时生成的机器代码,我们也可以得到一些启示。以下是我们使用与我们刚刚使用的相同签名的std::function进行调用的过程:

// Example 09
using Signature = int(int, int, int, int);
using SF = std::function<Signature>;
auto invoke_sf(int a, int b, int c, int d, const SF& f) {
  return f(a, b, c, d);
}

编译器(GCC-11 与 O3)将此代码转换为以下形式:

endbr64
sub    $0x28,%rsp
mov    %r8,%rax
mov    %fs:0x28,%r8
mov    %r8,0x18(%rsp)
xor    %r8d,%r8d
cmpq   $0x0,0x10(%rax)
mov    %edi,0x8(%rsp)
mov    %esi,0xc(%rsp)
mov    %edx,0x10(%rsp)
mov    %ecx,0x14(%rsp)
je     62 <_Z9invoke_sfiiiiRKSt8functionIFiiiiiEE+0x62>
lea    0xc(%rsp),%rdx
lea    0x10(%rsp),%rcx
mov    %rax,%rdi
lea    0x8(%rsp),%rsi
lea    0x14(%rsp),%r8
callq  *0x18(%rax)
mov    0x18(%rsp),%rdx
sub    %fs:0x28,%rdx
jne    67 <_Z9invoke_sfiiiiRKSt8functionIFiiiiiEE+0x67>
add    $0x28,%rsp
retq
callq  67 <_Z9invoke_sfiiiiRKSt8functionIFiiiiiEE+0x67>
callq  6c <_Z9invoke_sfiiiiRKSt8functionIFiiiiiEE+0x6c>

现在我们的函数:

// Example 09
using F = Function<Signature>;
auto invoke_f(int a, int b, int c, int d, const F& f) {
  return f(a, b, c, d);
}

这次,编译器可以做得更好:

endbr64
jmpq   *0x10(%r8)

我们看到的是所谓的尾调用:编译器简单地将执行权转移到需要调用的实际可调用对象。你可能会问,难道不是总是这样吗?通常不是:大多数函数调用都是通过 callret 指令实现的。为了调用一个函数,其参数必须存储在预定义的位置,然后返回地址被压入栈中,通过 call 指令将执行权转移到函数入口点。返回指令 ret 从栈中取出地址并将执行权转移到它。尾调用的美妙之处在于:虽然我们希望 Function 调用最终调用原始可调用对象,但我们不需要执行权返回到 Function 对象。如果在 Function 执行器中没有其他事情要做,除了将控制权返回给调用者,我们完全可以简单地保留原始返回地址不变,让可调用对象将控制权返回到正确的位置,而不需要额外的间接引用。当然,这假设执行器在调用后没有其他事情要做。

我们代码中有两个关键的优化使得这种紧凑的实现成为可能。第一个是处理空函数的方式:大多数 std::function 的实现将执行器初始化为 nullptr 并在每次调用时进行指针比较。我们没有进行这样的比较;我们总是调用执行器。但是,我们的执行器永远不会是空的:除非其他初始化,它指向默认的执行器。

第二种优化更为微妙。你可能已经注意到执行器比可调用对象多一个参数:为了调用具有签名 int(int, int) 的函数,我们的执行器需要两个原始函数参数(当然)以及指向可调用对象的指针(在我们的例子中存储在局部缓冲区中)。因此,我们的执行器签名是 int(int, int, void*)。为什么不先传递对象呢?这正是 std::function 所做的(至少是我们刚刚看到的汇编版本)。问题是原始函数参数也位于栈上。在栈末尾添加一个参数很容易。但是,为了插入新的第一个参数,我们必须将所有现有参数移动一个位置(这就是为什么 std::function 生成的代码随着参数数量的增加而变长的原因)。

虽然听起来很有说服力,但关于性能的任何推测几乎不值得用电子来记录下来。性能必须始终测量,这是我们在这个章节中剩下的最后一个任务。

类型擦除的性能

我们将要测量类型擦除的泛型函数和类型擦除的智能指针删除器的性能。首先,我们需要正确的工具;在这种情况下,一个微基准测试库。

安装微基准测试库

在我们的案例中,我们感兴趣的是使用不同类型的智能指针构建和删除对象的非常小代码片段的效率。测量小代码片段性能的适当工具是微基准测试。现在有许多微基准测试库和工具;在这本书中,我们将使用 Google Benchmark 库。要跟随本节中的示例,你必须首先下载并安装库(为此,请遵循 Readme.md 文件中的说明)。然后你可以编译并运行示例。你可以构建库中包含的示例文件,以了解如何在你的特定系统上构建基准测试。例如,在 Linux 机器上,构建和运行 smartptr.C 基准测试程序的命令可能看起来像这样:

$CXX smartptr.C smartptr_ext.C -o smartptr -g –O3 \
  -I. -I$GBENCH_DIR/include \
  -Wall -Wextra -Werror -pedantic --std=c++20 \
  $GBENCH_DIR/lib/libbenchmark.a -lpthread -lrt -lm && \
./smartptr

在这里,$CXX 是你的 C++ 编译器,例如 clang++g++-11,而 $GBENCH_DIR 是基准测试安装的目录。

类型擦除的开销

每个基准测试都需要一个基线。在我们的案例中,基线是原始指针。我们可以合理地假设没有任何智能指针能够超越原始指针,并且最好的智能指针将没有开销。因此,我们首先测量使用原始指针构建和销毁一个小对象所需的时间:

// Example 07
struct deleter {
  template <typename T> void operator()(T* p) { delete p; }
};
deleter d;
void BM_rawptr(benchmark::State& state) {
  for (auto _ : state) {
    int* p = new int(0);
    d(p);
  }
  state.SetItemsProcessed(state.iterations());
}

一个好的优化编译器可以通过优化“不必要的”工作(实际上,这是程序所做的所有工作)对像这样的微基准测试造成很大的破坏。我们可以通过将分配移动到不同的编译单元来防止此类优化:

// 07_smartptr.C:
void BM_rawptr(benchmark::State& state) {
  for (auto _ : state) {
    int* p = get_raw_ptr()
    d(p);
  }
  state.SetItemsProcessed(state.iterations());
}
// 07_smartptr_ext.C:
int* get_raw_ptr() { return new int(0); }

如果你有一个可以进行整个程序优化的编译器,请为这个基准测试关闭它。但是不要关闭每个文件的优化:我们希望分析优化后的代码,因为这才是实际程序将使用的代码。

基准测试报告的实际数字当然取决于运行它的机器。但我们对相对变化感兴趣,所以任何机器都可以,只要我们在所有测量中都使用它:

Benchmark                      Time
BM_rawptr                   8.72 ns

现在,我们可以验证 std::unique_ptr 确实没有开销(当然,只要我们以相同的方式构建和删除对象):

// smartptr.C
void BM_uniqueptr(benchmark::State& state) {
  for (auto _ : state) {
    auto p(get_unique_ptr());
  }
  state.SetItemsProcessed(state.iterations());
}
// smartptr_ext.C
auto get_unique_ptr() {
  return std::unique_ptr<int, deleter>(new int(0), d);
}

结果在原始指针的测量噪声范围内,如下所示:

Benchmark                      Time
BM_uniqueptr                8.82 ns

我们可以类似地测量 std::shared_ptr 以及我们自己的智能指针的不同版本的性能:

Benchmark                      Time
BM_sharedptr                22.9 ns
BM_make_sharedptr           17.5 ns
BM_smartptr_te              19.5 ns

第一行,BM_sharedptr,使用我们的自定义删除器构建和删除std::shared_ptr<int>。共享指针比唯一指针昂贵得多。当然,这不止一个原因——std::shared_ptr是一个引用计数智能指针,维护引用计数有其自身的开销。使用std::make_shared来分配共享指针使其创建和删除显著更快,正如我们在BM_make_sharedptr基准测试中所看到的,但为了确保我们只测量类型擦除的开销,我们应该实现一个类型擦除唯一指针。但我们已经做到了——这是我们在本章如何在 C++中实现类型擦除部分看到的smartptr。它具有刚好足够的功能来测量与其他所有指针相同的基准测试的性能:

void BM_smartptr_te(benchmark::State& state) {
  for (auto _ : state) {
    auto get_smartptr_te();
  }
  state.SetItemsProcessed(state.iterations());
}

在这里,smartptr_te代表使用继承实现的智能指针的类型擦除版本。它比std::shared_ptr略快,这证实了我们的怀疑,即后者有多个开销来源。就像std::shared_ptr一样,删除smartptr_te会触及两个内存位置:在我们的案例中,它是被删除的对象和删除器(内嵌在多态对象中)。这正是std::make_shared通过合并std::shared_ptr的两个内存位置来避免的,这肯定是有益的。我们可以合理地假设第二个内存分配也是我们类型擦除智能指针性能不佳(大约是原始或唯一指针的两倍慢)的原因。如果我们使用智能指针对象内部预留的内部缓冲区,我们可以避免这种分配。我们已经在类型擦除不涉及内存分配部分看到了智能指针的本地缓冲区实现(在这个基准测试中,它被重命名为smartptr_te_lb0)。这里以BM_smartptr_te_lb0的名字进行了基准测试。当可能时使用本地缓冲区,但对于较大的删除器切换到动态分配的版本被命名为smartptr_te_lb,并且略慢(BM_smartptr_te_lb):

Benchmark                      Time
BM_smartptr_te_lb           11.3 ns
BM_smartptr_te_lb0          10.5 ns
BM_smartptr_te_static       9.58 ns
BM_smartptr_te_vtable       10.4 ns

我们还对两种不使用继承实现的类型擦除智能指针进行了基准测试。静态函数版本BM_smartptr_te_static比使用虚表的版本BM_smartptr_te_vtable略快。这两个版本都使用本地缓冲区;编译器生成的虚表与我们所精心制作的等效结构表现完全相同,这并不令人惊讶。

总体而言,即使是最好的类型擦除实现也存在一些开销,在我们的案例中不到 10%。是否可以接受这个开销,取决于应用程序。

我们还应该测量泛型类型擦除函数的性能。我们可以用任何可调用实体来测量其性能,例如,一个 lambda 表达式:

// Example 09
void BM_fast_lambda(benchmark::State& state) {
  int a = rand(), b = rand(), c = rand(), d = rand();
  int x = rand();
  Function<int(int, int, int, int)> f {
    = {
      return x + a + b + c + d; }
  };
  for (auto _ : state) {
    benchmark::DoNotOptimize(f(a, b, c, d));
    benchmark::ClobberMemory();
  }
}

我们也可以对std::function进行相同的测量,并比较结果:

Benchmark                      Time
BM_fast_lambda                 0.884 ns
BM_std_lambda                   1.33 ns

虽然这可能看起来是一个巨大的成功,但这个基准也隐藏了对过度使用类型擦除的警告。要揭示这个警告,我们只需测量对同一 lambda 的直接调用的性能:

Benchmark                      Time
BM_lambda                      0.219 ns

我们如何将这种主要的减速与我们在比较智能指针和原始指针时看到的类型擦除的微小成本相协调?

重要的是要注意正在擦除的内容。一个实现良好的类型擦除接口可以提供与虚拟函数调用非常相似的性能。非内联的非虚拟函数调用将稍微快一点(在我们的例子中,耗时不到 9 纳秒的调用产生了大约 10%的开销)。但类型擦除的调用始终是间接的。它无法接近的一个竞争点是内联函数调用。这正是我们在比较类型擦除和直接调用 lambda 的性能时观察到的。

在我们了解了类型擦除的性能之后,我们何时可以推荐使用它?

使用类型擦除的指南

类型擦除解决了哪些问题,何时解决方案的成本是可以接受的?首先,重要的是不要忘记原始目标:类型擦除是一种设计模式,有助于关注点的分离,这是一种非常强大的设计技术。它用于在实现该行为可以由一组可能无关的类型提供时,为某种行为创建一个抽象。

它还用作实现技术,主要用来帮助打破编译单元和其他程序组件之间的依赖关系。

在我们能够回答“类型擦除值得付出代价吗?”这个问题之前,我们需要考虑替代方案。在许多情况下,替代方案是另一种实现相同抽象的方法:多态类层次结构或函数指针。这两种选项的性能与类型擦除(在其最佳实现中)相似,所以这取决于便利性和代码质量。对于单个函数,使用类型擦除函数比开发新的类层次结构更容易,比使用函数指针更灵活。对于具有许多成员函数的类,维护类层次结构通常更容易且更不容易出错。

另一个可能的替代方案是不采取任何行动,允许设计部分之间更紧密的耦合。这种决定的缺点通常与其性能收益成反比:系统紧密耦合的部分通常需要协调实现以达到良好的性能,但它们紧密耦合是有原因的。逻辑上分离良好的组件不应进行大量交互,因此,这种交互的性能不应是关键的。

当性能很重要但我们仍然需要抽象时,我们该怎么办?通常,这是类型擦除的直接对立面:我们将一切变成模板。

考虑 C++20 的范围。一方面,它们是抽象序列。我们可以编写一个操作范围的函数,并用向量、deque、从这些容器之一创建的范围、该范围的子范围或过滤视图来调用它。只要可以从begin()迭代到end(),任何东西都是范围。但是,从向量和一个 deque 创建的范围是不同的类型,尽管它们在接口上是序列的抽象。标准库提供了多个范围适配器和范围视图,它们都是模板。操作这些范围的函数也是模板。

我们能否实现一个类型擦除的范围?是的,这甚至并不难。我们最终得到一个单一的类型,GenericRange,可以从向量、deque、列表或其他具有begin()end()和前向迭代器的任何东西中构建。我们还得到一些东西,其速度大约是大多数容器迭代器的一半,除了向量:它们的迭代器实际上只是指针,向量化的编译器可以进行优化,至少将代码速度提高一个数量级。当我们擦除原始容器的类型时,这种优化的可能性就丢失了。

C++设计者做出了决定,一方面,范围提供了一种对某些行为的抽象,并让我们将接口与实现分离。另一方面,他们不愿意牺牲性能。因此,他们选择将范围及其所有操作代码都做成模板。

作为软件系统的设计者,你可能不得不做出类似的决策。一般准则是在这种耦合对于性能至关重要的情况下,更倾向于紧密耦合相关组件。相反,对于交互不需要高效率的松耦合组件,更倾向于更好的分离。当处于这个领域时,类型擦除至少应该与多态和其他解耦技术同等考虑。

摘要

在本章中,我们希望已经解除了被称为类型擦除的编程技术的神秘感。我们展示了如何编写一个程序,其中并非所有的类型信息都是显式可见的,以及为什么这可能是一种理想的实现方式的原因。我们还展示了,当高效实现并明智使用时,它是一种强大的技术,可能导致更简单、更灵活的接口和明显分离的组件。

下一章将改变方向——我们已经处理了一些抽象惯用法一段时间了,现在转向 C++惯用法,这些惯用法有助于将模板组件绑定到复杂交互系统中。我们首先从 SFINAE 惯用法开始。

问题

  1. 真正的类型擦除是什么?

  2. 类型擦除在 C++中是如何实现的?

  3. auto后面隐藏类型和擦除它之间有什么区别?

  4. 当程序需要使用具体类型时,它是如何被具体化的?

  5. 类型擦除的性能开销是什么?

第七章:SFINAE、概念和重载解析管理

本章我们研究的惯用表达式替换失败不是错误SFINAE)在使用的语言特性方面更为复杂。因此,它往往吸引大量的 C++程序员的关注。这个特性中有些东西符合典型 C++程序员的思维方式——普通人认为,如果它没有坏,就不要去动它。程序员,尤其是 C++程序员,往往认为,如果它没有坏,你就没有充分利用它。我们只能说,SFINAE 有很大的潜力。

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

  • 函数重载和重载解析是什么?类型推导和替换是什么?

  • SFINAE 是什么,为什么它在 C++中是必要的?

  • 如何使用 SFINAE 编写极其复杂,有时有用的程序?

技术要求

本章的示例代码可以在github.com/PacktPublishing/ Hands-On-Design-Patterns-with-CPP-Second-Edition/tree/master/Chapter07找到。

重载解析和重载集

本节将测试你对 C++标准最新和最先进补充的了解。我们将从 C++最基本的功能之一——函数及其重载开始。

C++函数重载

f(x),那么必须存在多个名为f的函数。如果出现这种情况,我们就处于一个重载情况,必须进行重载解析以确定应该调用这些函数中的哪一个。

让我们从简单的例子开始:

// Example 01
void f(int i) { cout << “f(int)” << endl; }        // 1
void f(long i) { cout << “f(long)” << endl; }    // 2
void f(double i) { cout << “f(double)” << endl; }    // 3
f(5);        // 1
f(5l);    // 2
f(5.0);    // 3

在这里,我们有三个名为f的函数定义和三个函数调用。请注意,函数签名都是不同的(参数类型不同)。这是一个要求——重载函数必须在参数上有所不同。不可能有两个重载接受完全相同的参数,但返回类型或函数体不同。此外,请注意,虽然这个例子是针对常规函数的,但相同的规则也适用于重载成员函数,因此我们不会特别关注成员函数。

回到我们的例子,每一行调用的是哪个f()函数?为了理解这一点,我们需要知道 C++中重载函数是如何解析的。重载解析的确切规则相当复杂,并且在不同版本的规范中存在细微的差异,但大部分情况下,它们被设计成编译器会做你期望它在最常见情况下做的事情。我们预计f(5)会调用接受整数参数的重载,因为5是一个int变量。确实如此。同样,5l具有长类型,因此f(5l)调用第二个重载。最后,5.0是一个浮点数,因此调用最后一个重载。

这并不难,对吧?但如果参数与参数类型不完全匹配会发生什么?那么,编译器必须考虑类型转换。例如,5.0字面量的类型是double。让我们看看如果我们用float类型的参数调用f()会发生什么:

f(5.0f);

现在我们必须将参数从float类型转换为intlongdouble类型之一。同样,标准有规则,但将转换为double视为首选并且调用重载应该不会令人感到意外。

让我们看看不同整数类型会发生什么,比如说,unsigned int

f(5u);

现在我们有两个选择;将unsigned int转换为signed int,或者转换为signed long。虽然可以争论转换为long更安全,因此更好,但标准认为这两种转换非常接近,以至于编译器无法选择。这个调用无法编译,因为重载解析被认为是模糊的;错误信息应该说明这一点。如果你在代码中遇到这样的问题,你必须通过将参数转换为使解析无歧义的类型来帮助编译器。通常,最简单的方法是将重载函数所需参数的类型进行转换:

unsigned int i = 5u;
f(static_cast<int>(i));

到目前为止,我们已经处理了参数类型不同但数量相同的情况。当然,如果不同名称的函数声明中参数的数量不同,只需要考虑可以接受所需数量参数的函数。以下是一个具有相同名称但参数数量不同的两个函数的示例:

void f(int i) { cout << “f(int)” << endl; }            // 1
void f(long i, long j) { cout << “f(long2)” << endl; }    // 2
f(5.0, 7);

在这里,重载解析非常简单——我们需要一个可以接受两个参数的函数,而且只有一个选择。两个参数都将必须转换为long。但如果存在多个具有相同参数数量的函数,会怎样呢?让我们看看以下示例:

// Example 02
void f(int i, int j) { cout << “f(int, int)” << endl; }// 1
void f(long i, long j) { cout << “f(long2)” << endl; }    // 2
void f(double i) { cout << “f(double)” << endl; }      // 3
f(5, 5);    // 1
f(5l, 5l);    // 2
f(5, 5.0);    // 1
f(5, 5l);    // ?

首先,最明显的情况 - 如果所有参数的类型与某个重载的参数类型完全匹配,则调用该重载。接下来,事情开始变得有趣 - 如果没有完全匹配,我们可以在每个参数上进行转换。让我们考虑第三次调用,f(5, 5.0)。第一个参数,int,与第一个重载完全匹配,但在必要时可以转换为long。第二个参数,double,与任何重载都不匹配,但可以转换为匹配两者。第一个重载是更好的匹配 - 它需要的参数转换更少。最后,关于最后一行呢?第一个重载可以通过对第二个参数进行转换来调用。第二个重载也可以通过在第一个参数上进行转换来工作。这又是一个模糊的重载,并且这一行将无法编译。请注意,通常情况下,转换较少的重载并不总是获胜;在更复杂的情况下,即使一个重载需要的转换较少,也可能有模糊的重载(一般规则是,如果有一个重载在所有参数上都有最佳的转换,则获胜;否则,调用是模糊的)。为了解决这种歧义,你必须通过类型转换(通常是通过类型转换,在我们的例子中是通过改变数字字面量的类型)来改变一些参数的类型,以便使预期的重载成为首选的重载。

注意第三个重载是如何完全被忽略的,因为它对于所有函数调用都具有错误的参数数量。但这并不总是那么简单 - 函数可以有默认参数,这意味着参数的数量并不总是必须与参数的数量匹配。

考虑以下代码块:

// Example 03
void f(int i) { cout << “f(int)” << endl; }            // 1
void f(long i, long j) { cout << “f(long2)” << endl; }    // 2
void f(double i, double j = 0) {                    // 3
  cout << “f(double, double = 0)” << endl;
}
f(5);        // 1
f(5l, 5);    // 2
f(5, 5);    // ?
f(5.0);    // 3
f(5.0f);    // 3
f(5l);    // ?

我们现在有三个重载。第一个和第二个永远不会混淆,因为它们具有不同数量的参数。然而,第三个重载可以用一个或两个参数调用;在前一种情况下,第二个参数被假定为零。第一次调用是最简单的 - 一个参数,其中类型与第一个重载的参数类型完全匹配。第二次调用让我们想起了之前见过的案例 - 两个参数,其中第一个与某个重载完全匹配,但第二个需要转换。替代重载需要在两个参数上执行转换,因此第二个函数定义是最好的匹配。

第三个调用似乎足够简单,因为它有两个整数参数,但这种简单性具有欺骗性——存在两个接受两个参数的重载版本,并且在两种重载情况下,两个参数都需要转换。虽然从intlong的转换可能看起来比从intdouble的转换更好,但 C++并不这样认为。这个调用是模糊的。下一个调用f(5.0)只有一个参数,它可以转换为int,这是单参数重载中参数的类型。但它仍然更适合第三个重载,在那里它根本不需要转换。将参数类型从double改为float,我们得到下一个调用。转换为double比转换为int更好,并且利用默认参数不被视为转换,因此在重载比较时不会带来任何其他惩罚。最后一个调用再次是模糊的——转换为double和转换为int都被认为是同等重要的,因此第一个和第三个重载同样好。第二个重载提供了对第一个参数的精确匹配;然而,没有方法可以不提供第二个参数就调用该重载,因此它甚至没有被考虑。

到目前为止,我们只考虑了普通的 C++函数,尽管我们所学的所有内容同样适用于成员函数。现在,我们需要将模板函数也加入其中。

模板函数

除了常规函数之外,对于参数类型已知的函数,C++还有template函数。当调用这些函数时,参数类型是从调用位置的参数类型中推断出来的。模板函数可以与非模板函数具有相同的名称,也可以有多个模板函数具有相同的名称,因此我们需要了解在模板存在的情况下如何进行重载解析。

考虑以下示例:

// Example 04
void f(int i) { cout << “f(int)” << endl; }        // 1
void f(long i) { cout << “f(long)” << endl; }    // 2
template <typename T>
void f(T i) { cout << “f(T)” << endl; }        // 3
f(5);        // 1
f(5l);    // 2
f(5.0);    // 3

f函数名可以指代这三个函数中的任何一个,其中一个是一个模板。每次都会从这三个中选择最佳的重载。考虑特定函数调用重载解析的函数集合被称为f()匹配正好与重载集中第一个非模板函数相匹配——参数类型是int,第一个函数是f(int)。如果在重载集中找到了与非模板函数的精确匹配,它总是被认为是最佳的重载。

模板函数也可以通过精确匹配进行实例化——用具体类型替换模板参数的过程称为模板参数替换(或类型替换),如果将 int 替换为 T 模板参数,那么我们得到另一个与调用完全匹配的函数。然而,与调用完全匹配的非模板函数被认为是一个更好的重载。第二次调用以类似的方式处理,但它与重载集中的第二个函数完全匹配,因此将调用该函数。最后一个调用有一个 double 类型的参数,可以转换为 intlong,或者替换 T 以使模板实例化与调用完全匹配。由于没有与调用完全匹配的非模板函数,因此实例化为精确匹配的模板函数是下一个最佳重载并被选中。

但是,当有多个模板函数可以替换其模板参数以匹配调用参数类型时会发生什么?让我们来看看:

// Example 05
void f(int i) { cout << “f(int)” << endl; }    // 1
template <typename T>
void f(T i) { cout << “f(T)” << endl; }    // 2
template <typename T>
void f(T* i) { cout << “f(T*)” << endl; }    // 3
f(5);        // 1
f(5l);    // 2
int i = 0;
f(&i);    // 3

第一次调用再次与非模板函数完全匹配,因此得到解决。第二次调用与第一个非模板重载匹配,通过转换,或者如果将 long 类型替换为 T,则与第二个重载完全匹配。最后一个重载与这两个调用都不匹配——没有替换可以使 T* 参数类型与 intlong 匹配。然而,如果将 int 替换为 T,最后一个调用可以与第三个重载匹配。问题是,如果将 int* 替换为 T,它也可以与第二个重载匹配。那么选择哪个模板重载呢?答案是更具体的那个——第一个重载 f(T) 可以与任何单参数函数调用匹配,而第二个重载 f(T*) 只能与带有指针参数的调用匹配。更具体、更窄的重载被认为是一个更好的匹配,并被选中。这是一个新的概念,专门针对模板——我们选择更难实例化的重载,而不是选择更好的转换(通常,更少更简单 的转换)。

这条规则似乎对空指针不适用:f(NULL) 可以调用第一个或第二个重载(f(int)f(T)),而 f(nullptr) 则调用第二个重载,f(T)。指针重载从未被调用,尽管 NULLnullptr 都被认为是空指针。然而,这实际上是编译器严格遵循规则的情况。C++ 中的 NULL 是一个整数零,实际上是一个宏:

#define NULL 0 // Or 0L

根据 00L 的定义,调用 f(int)f(T)(其中 T==long)取决于 f(int)。尽管名称中有“ptr”,但 nullptr 实际上是一个 nullptr_t 类型的常量值。它可以 转换为 任何指针类型,但它不是任何指针类型。这就是为什么在处理接受不同类型指针的函数时,经常声明带有 nullptr_t 参数的重载。

最后,还有一种函数可以匹配几乎任何具有相同名称的函数调用,那就是接受可变参数的函数:

// Example 06
void f(int i) { cout << “f(int)” << endl; }    // 1
void f(...) { cout << “f(...)” << endl; }    // 2
f(5);        // 1
f(5l);    // 1
f(5.0);    // 1
struct A {};
A a;
f(a);    {};    // 2

重载中的第一个可以用于前三个函数调用 - 它是第一个调用的精确匹配,并且存在转换可以使其他两个调用适合f()的第一个重载的签名。在这个示例中的第二个函数可以用任何数量和类型的参数调用。这被认为是最后的手段 - 具有可以转换为正确转换以匹配调用的特定参数的函数更受欢迎。这包括用户定义的转换,如下所示:

struct B {
  operator int() const { return 0; }
};
B b;
f(b);        // 1

只有在没有转换可以让我们避免调用f(...)可变函数的情况下,才必须调用它。

现在我们知道了重载解析的顺序 - 首先选择一个与参数完全匹配的非模板函数。如果在重载集中没有这样的匹配,则选择一个模板函数,如果其参数可以用具体类型替换以给出精确匹配。如果有多个这样的模板函数选项,则更具体的重载优先于更一般的一个。如果以这种方式尝试匹配模板函数也失败,则如果参数可以转换为参数类型,则调用非模板函数。最后,如果所有其他方法都失败,但有一个接受可变参数的正确名称的函数可用,则调用该函数。请注意,某些转换被认为是平凡的,并包含在精确匹配的概念中,例如,从Tconst T的转换。在每一步中,如果存在多个同样好的选项,则重载被认为是模糊的,程序是不良的。

模板函数中的类型替换过程决定了模板函数参数的最终类型,以及它们与函数调用参数的匹配程度。这个过程可能会导致一些意想不到的结果,必须更详细地考虑。

模板函数中的类型替换

在实例化模板函数以匹配特定调用时,我们必须仔细区分两个步骤 - 首先,从参数类型推导出模板参数的类型(这个过程称为类型推导)。一旦推导出类型,就用具体类型替换所有参数类型(这个过程称为类型替换)。当函数有多个参数时,这种差异变得更加明显。

类型推导和替换

类型推导和替换密切相关,但并不完全相同。推导是“猜测:”为了匹配调用,模板类型或类型应该是什么的过程?当然,编译器并不是真的猜测,而是应用标准中定义的一组规则。考虑以下示例:

// Example 07
template <typename T>
void f(T i, T* p) { std::cout << “f(T, T*)” << std::endl; }
int i;
f(5, &i);    // T == int
f(5l, &i);    // ?

在考虑第一次调用时,我们可以从第一个参数推导出T模板参数应该是int。因此,int被替换为函数的两个参数中的T。模板被实例化为f(int, int*),与参数类型完全匹配。在考虑第二次调用时,我们可以从第一个参数推导出T应该是long,或者我们可以从第二个参数推导出T应该是int。这种歧义导致类型推导过程失败。如果这是唯一可用的重载,则不会选择任何选项,程序无法编译。如果存在更多重载,它们将依次考虑,包括可能是最后的手段,即f(...)可变函数的重载。在这里需要注意的一个重要细节是,在推导模板类型时不会考虑转换——将T推导为int将为第二次调用产生f(int, int*),这是调用f(long, int*)时转换第一个参数的一个可行选项。然而,这个选项根本没有被考虑,并且类型推导因为歧义而失败。

可以通过明确指定模板类型来解决模糊的推导,从而消除类型推导的需要:

f<int>(5l, &i);    // T == int

现在,类型推导根本就没有进行:我们从函数调用中知道T是什么,因为它被明确指定了。另一方面,类型替换仍然必须发生——第一个参数是int类型,第二个是int*类型。函数调用通过转换第一个参数而成功。我们也可以强制进行反向推导:

f<long>(5l, &i);    // T == long

再次,由于我们知道T是什么,所以不需要推导。替换过程是直接的,我们最终得到f(long, long*)。由于没有从int*long*的有效转换,这个函数不能使用int*作为第二个参数来调用。因此,程序无法编译。请注意,通过明确指定类型,我们也指定了f()必须是一个模板函数。对于f()的非模板重载不再考虑。另一方面,如果有多个f()模板函数,那么这些重载将按常规考虑,但这次是使用我们通过明确指定强制进行的参数推导的结果。

模板函数可以有默认参数,就像非模板函数一样,然而,这些参数的值并不用于推导类型(在 C++11 中,模板函数可以为它们的类型参数提供默认值,这提供了一种替代方案)。考虑以下示例:

// Example 08
void f(int i, int j = 1) {                      // 1
  cout << “f(int2)” << endl;
}
template <typename T> void f(T i, T* p = nullptr) {    // 2
  cout << “f(T, T*)” << endl;
}
int i;
f(5);        // 1
f(5l);    // 2

第一次调用与f(int, int)非模板函数完全匹配,第二个参数的默认值为1。请注意,如果我们将函数声明为f(int i, int j = 1L),默认值作为long,这也不会有任何区别。默认参数的类型并不重要——如果它可以转换为指定的参数类型,那么就使用那个值,否则,程序将从第一行开始就无法编译。第二次调用与f(T, T*)模板函数完全匹配,T == long,第二个参数的默认值为NULL。同样,那个值的类型不是long*并不重要。

我们现在理解了类型推导和类型替换之间的区别。当可以从不同的参数推导出不同的具体类型时,类型推导可能会产生歧义。如果发生这种情况,这意味着我们没有推导出参数类型,无法使用此模板函数。类型替换永远不会产生歧义——一旦我们知道T是什么,我们每次在函数定义中看到T时,就简单地替换那个类型。这个过程也可能失败,但方式不同。

替换失败

一旦我们推导出模板参数类型,类型替换就是一个纯粹机械的过程:

// Example 09
template <typename T> T* f(T i, T& j) {
  j = 2*i;
  return new T(i);
}
int i = 5, j = 7;
const int* p = f(i, j);

在这个例子中,T类型可以从第一个参数推导为int。它也可以从第二个参数推导为int。请注意,返回类型不用于类型推导。由于对T只有一个可能的推导,我们现在在函数定义中每次看到T时,都将其替换为int

int* f(int i, int& j) {
  j = 2*i;
  return new int(i);
}

然而,并非所有类型都是平等的,有些类型比其他类型允许更多的自由度。考虑以下代码:

// Example 10
template <typename T>
void f(T i, typename T::t& j) {
  std::cout << “f(T, T::t)” << std::endl;
}
template <typename T>
void f(T i, T j) {
  std::cout << “f(T, T)” << std::endl;
}
struct A {
struct t { int i; }; t i; };
A a{5};
f(a, a.i);    // T == A
f(5, 7);    // T == int

在考虑第一次调用时,编译器从第一个和第二个参数推导出T模板参数为A类型——第一个参数是A类型的值,第二个参数是A::t嵌套类型的引用,如果我们坚持我们最初对T作为A的推导,它匹配T::t。第二个重载从两个参数中为T提供冲突的值,因此不能使用。因此,调用第一个重载。

现在,仔细看看第二个调用。对于两个重载,T 类型都可以从第一个参数推导为 int。然而,将 int 替换为 T,在第一个重载的第二个参数中会产生一些奇怪的东西 - int::t。当然,这不会编译 - int 不是一个类,也没有任何嵌套类型。实际上,我们可以预期,对于任何不是类或没有名为 t 的嵌套类型的 T 类型,第一个模板重载都将无法编译。确实,尝试在第一个模板函数中将 int 替换为 T 将因为第二个参数的类型无效而失败。然而,这种替换失败并不意味着整个程序无法编译。相反,它被默默地忽略,并且原本可能是不良的重载从重载集中移除。然后,重载解析继续如常进行。当然,我们可能会发现没有任何重载与函数调用匹配,程序仍然无法编译,但错误信息不会提到 int::t 无效的问题;它只会说没有可以调用的函数。

再次,区分类型推导失败和类型替换失败非常重要。我们可以完全不考虑前者:

f<int>(5, 7);    // T == int

现在,推导是不必要的,但将 int 替换为 T 的过程仍然必须发生,并且这种替换在第一个重载中产生了一个无效的表达式。再次,这种替换失败将这个 f() 的候选者从重载集中排除,并且重载解析继续(在这种情况下,成功)使用剩余的候选者。通常,这将是我们的重载练习的结束:模板生成的代码无法编译,因此整个程序也不应该编译。幸运的是,C++ 在这种情况下更为宽容,并且有一个特殊的异常,我们需要了解。

替换失败不是错误

由表达式引起的替换失败,该表达式在指定的或推导的类型中将是无效的,不会使整个程序无效的规则被称为替换失败不是错误SFINAE)。这个规则对于在 C++ 中使用模板函数是必不可少的;没有 SFINAE,将无法编写许多其他方面完全有效的程序。考虑以下模板重载,它区分了普通指针和成员指针:

// Example 11
template <typename T> void f(T* i) {        // 1
  std::cout << “f(T*)” << std::endl;
}
template <typename T> void f(int T::* p) {    // 2
  std::cout << “f(T::*)” << std::endl;
}
struct A { int i; };
A a;
f(&a.i);    // 1
f(&A::i);    // 2

到目前为止,一切顺利 - 第一次调用时,函数使用特定变量的指针调用,a.iT 类型被推导为 int。第二次调用是使用 A 类的数据成员的指针,其中 T 被推导为 A。但现在,让我们用指向不同类型的指针来调用 f()

int i;
f(&i);    // 1

第一个重载仍然工作得很好,这正是我们想要调用的。但第二个重载不仅不太合适,它完全是无效的——如果我们尝试用 int 替换 T,它将导致语法错误。这个语法错误被编译器观察到并被静默忽略,连同重载本身。

注意,SFINAE 规则不仅限于无效类型,例如对不存在类成员的引用。有几种方式可能导致替换失败:

// Example 12
template <size_t N>
void f(char(*)[N % 2] = nullptr) {    // 1
  std::cout << “N=” << N << “ is odd” << std::endl;
}
template <size_t N>
void f(char(*)[1 - N % 2] = nullptr) { // 2
  std::cout << “N=” << N << “ is even” << std::endl;
}
f<5>();
f<8>();

在这里,模板参数是一个值,而不是一个类型。我们有两个模板重载,它们都接受字符数组指针,并且数组大小表达式仅对 N 的某些值有效。具体来说,零大小数组在 C++ 中是无效的。因此,第一个重载仅在 N % 2 非零时有效,即 N 是奇数。同样,第二个重载仅在 N 是偶数时有效。没有给函数提供任何参数,所以我们打算使用默认参数。如果没有这两个重载在所有方面都是模糊的,那么在两次调用中,其中一个重载在模板参数替换期间失败并被静默移除。

上述示例非常简洁——特别是模板参数值推断,相当于数值参数的类型推断被显式指定禁用。我们可以恢复推断,并且替换可能成功或失败,这取决于表达式是否有效:

// Example 13
template <typename T, size_t N = T::N>
void f(T t, char(*)[N % 2] = NULL) {
  std::cout << “N=” << N << “ is odd” << std::endl;
}
template <typename T, size_t N = T::N>
void f(T t, char(*)[1 - N % 2] = NULL) {
  std::cout << “N=” << N << “ is even” << std::endl;
}
struct A { enum {N = 5}; };
struct B { enum {N = 8}; };
A a;
B b;
f(a);
f(b);

现在,编译器必须从第一个参数中推断类型。对于第一次调用,f(a)A 类型很容易推断出来。无法推断第二个模板参数,N,因此使用默认值(我们现在处于 C++11 领域)。推断出两个模板参数后,我们现在进行替换,其中 T 被替换为 AN 被替换为 5。这种替换在第二个重载中失败,但在第一个重载中成功。在重载集中只剩下一个候选者时,重载解析结束。同样,第二次调用 f(b) 最终调用的是第二个重载。

注意,前一个示例和更早的示例之间存在一个微妙但非常重要的区别,其中我们有了这个函数:

template <typename T> void f(T i, typename T::t& j);

在这个模板中,替换失败是“自然的”:可能引起失败的参数是必需的,并且意图是成员指针类型。在前一个情况下,模板参数 N 是多余的:它除了人为地导致替换失败和禁用一些重载之外,对任何其他事情都不需要。你为什么要人为地造成替换失败呢?我们已经看到了一个原因,即强制选择其他情况下模糊的重载。更普遍的原因与这样一个事实有关,即类型替换有时可能导致错误。

当替换失败仍然是一个错误时

注意,SFINAE 并不能保护我们免受在模板实例化过程中可能发生的任何语法错误。例如,如果模板参数被推导,并且模板参数被替换,我们仍然可能得到一个无效的模板函数:

// Example 14
template <typename T> void f(T) {
  std::cout << sizeof(T::i) << std::endl;
}
void f(...) { std::cout << “f(...)” << std::endl; }
f(0);

这个代码片段与我们之前考虑的代码片段非常相似,只有一个例外——我们直到检查函数体时才知道模板重载假设T类型是一个类,并且有一个名为T::i的数据成员。到那时,已经太晚了,因为重载解析仅基于函数声明——参数、默认参数和返回类型(后者不用于推导类型或选择更好的重载,但仍会进行类型替换并受 SFINAE 覆盖)。一旦模板被实例化并被重载解析选择,任何语法错误,如函数体内的无效表达式,都不会被忽略——这种失败是一个非常严重的错误。替换失败是否被忽略的确切上下文列表由标准定义;它在 C++11 中得到了显著扩展,后续标准进行了一些细微的调整。

另有一种情况,尝试使用 SFINAE 会导致错误。以下是一个例子:

// Example 15a
template <typename T> struct S {
  typename T::value_type f();
};

在这里,我们有一个类模板。如果类型T没有嵌套类型value_type,类型替换会导致错误,这是一个真正的错误,不会被忽略。你甚至不能使用没有value_type的类型实例化这个类模板。将函数变成模板并不能解决这个问题:

template <typename T> struct S {
  template <typename U> typename T::value_type f();
};

记住这一点非常重要,即 SFINAE 仅在模板函数推导的类型替换过程中发生错误时才适用。在上一个例子中,替换错误并不依赖于模板类型参数U,因此它始终会是一个错误。如果你真的需要解决这个问题,你必须使用成员函数模板,并使用模板类型参数来触发替换错误。由于我们不需要额外的模板参数,我们可以将其默认为与类模板类型参数T相同:

// Example 15b
template <typename T> struct S {
  template <typename U = T>
  std::enable_if_t<std::is_same_v<U, T>
  typename U::value_type f();
};

现在,如果存在替换错误,它将发生在依赖于模板类型参数U::value_type的类型上。我们不需要指定类型U,因为它默认为T,并且由于类型UT必须相同的要求(否则函数的返回类型无效,这是一个 SFINAE 错误),它不能是其他任何东西。因此,我们的模板成员函数f()(几乎)完全做了原始非模板函数f()所做的事情(如果函数在类中有重载,则存在细微的差异)。所以,如果你真的需要“隐藏”由类模板参数引起的替换错误,你可以通过引入冗余的函数模板参数并将这两个参数限制为始终相同来实现。

在继续之前,让我们回顾一下我们遇到的三种替换失败类型。

替换失败发生在哪里?为什么会发生?

为了理解本章的其余部分,我们必须清楚地区分在模板函数中可能发生的几种替换失败类型。

第一种发生在模板声明使用依赖类型或其他可能导致失败的构造时,并且它们的使用对于正确声明模板是必要的。以下是一个旨在使用容器参数调用的模板函数(所有 STL 容器都有一个嵌套类型 value_type):

// Example 16
template <typename T>
bool find(const T& cont, typename T::value_type val);

如果我们尝试用没有定义嵌套类型 value_type 的参数调用这个函数,函数调用将无法编译(假设我们没有其他重载)。还有许多其他例子,我们自然使用依赖类型和其他可能对某些模板参数值无效的表达式。这些无效表达式会导致替换失败。它不必发生在参数声明中。以下是一个返回类型可能未定义的模板:

// Example 16
template <typename U, typename V>
std::common_type_t<U, V> compute(U u, V v);

在这个模板中,返回类型是两个模板参数类型的公共类型。但如果模板参数的类型 UV 没有公共类型,会发生什么?那么类型表达式 std::common_type_t<U, V> 是无效的,类型替换失败。以下又是另一个例子:

// Example 15
template <typename T>
auto process(T p) -> decltype(*p);

在这里,再次,替换失败可能发生在返回类型中,但我们使用尾随返回类型,这样我们可以直接检查表达式 *p 是否可以编译(或者更正式地说,是否有效)。如果是,结果类型就是返回类型。否则,替换失败。请注意,这种声明与以下类似的东西之间有一个区别:

template <typename T> T process(T* p);

如果函数参数是一个原始指针,两种版本都相当于同一件事。但第一种变体也可以为任何可以解引用的类型编译,例如容器迭代器和智能指针,而第二种版本仅适用于原始指针。

第二种替换失败发生在函数声明成功编译,包括类型替换,然后我们在函数体中得到语法错误。我们可以轻松地修改这些示例,看看这种情况是如何发生的。让我们从 find() 函数开始:

// Example 17
template <typename T, typename V>
bool find(const T& cont, V val) {
  for (typename T::value_type x : cont) {
    if (x == val) return true;
  }
  return false;
}

这次,我们决定接受任何类型的值。这本身并不一定错误,但我们的模板函数的主体是在假设容器类型 T 有嵌套类型 value_type,并且这个类型可以与类型 V 相比较的情况下编写的。如果我们用错误的参数调用函数,调用仍然可以编译,因为模板声明中的替换对参数类型没有特别的要求。但然后我们在模板本身的主体中得到语法错误,而不是在调用位置。

类似的情况也可能发生在compute()模板中:

// Example 17
template <typename U, typename V>
auto compute(U u, V v) {
  std::common_type_t<U, V> res = (u > v) ? u : v;
  return res;
}

这个模板函数可以对任何两个参数进行调用,但如果没有为两者提供一个共同类型,那么它将无法编译。

注意两种替换失败之间的非常显著的区别:如果失败发生在 SFINAE 上下文中,函数将从重载解析中移除,就像它不存在一样。如果有另一个重载(具有相同名称的函数),它将被考虑,并最终可能被调用。如果没有,我们将在调用位置得到一个语法错误,这归结为“没有这样的函数。”另一方面,如果失败发生在模板的主体中(或在 SFINAE 规则未覆盖的其他地方),假设函数是最好的或唯一的重载,它将被调用。客户端代码——调用本身——将编译正常,但模板将不会。

有几个原因使得第一个选项更可取。首先,调用者可能想要调用一个不同的重载版本,这个版本可以正常编译,但由于模板重载解析的规则复杂,错误地选择了错误的重载。调用者可能很难修复这个错误并强制选择预期的重载。其次,当模板的主体在编译失败时,你收到的错误信息通常难以理解。我们的例子很简单,但在更现实的情况下,你可能会看到一个涉及一些你一无所知的内部类型和对象的错误。最后一个原因是更概念性的陈述:模板函数的接口,就像任何其他接口一样,应该尽可能完整地描述对调用者的要求。接口是一个合同;如果调用者遵守了它,函数的实现者必须履行承诺。

假设我们有一个模板函数,其主体对类型参数有一些要求,而这些要求没有以自然、直接的方式(类型替换成功但模板无法编译)被接口捕获。将硬替换失败转换为 SFINAE 失败的唯一方法是在 SFINAE 上下文中使其发生。为此,我们需要在接口中添加一些不是声明函数所必需的东西。这种添加的唯一目的是触发替换失败,并在函数主体中导致编译错误之前,将函数从重载解析集中移除。这种“人工”的失败是第三种替换失败。以下是一个例子,我们强制要求类型是指针,尽管接口本身即使没有这个要求也可以正常工作:

// Example 18
template <typename U, typename V>
auto compare(U pu, V pv) -> decltype(bool(*pu == *pv)) {
  return *pu < *pv;
}

此函数接受两个指针(或任何其他可以解引用的指针类对象)并返回比较它们指向的值的布尔结果。为了使函数体能够编译,两个参数都必须是可以解引用的。此外,解引用它们的结果必须可以比较相等。最后,比较的结果必须可以转换为 bool。尾随的返回类型声明是不必要的:我们本来可以直接声明函数返回 bool。但它确实有影响:它将可能的替换失败从函数体移动到其声明中,在那里它成为了一个 SFINAE 失败。除非 decltype() 内部的表达式无效,否则返回类型始终是 bool。这可能会发生的原因与函数体无法编译的原因相同:其中一个参数无法解引用,值不可比较,或者比较的结果无法转换为 bool(后者通常是多余的,但我们还是应该强制整个合同)。

注意到“自然”和“人工”替换失败之间的界限并不总是清晰的。例如,有人可能会争论,之前使用 std::common_type_t<U, V> 作为返回类型是人工的(第三种替换失败,而不是第一种),“自然”的方式应该是声明返回类型为 auto,并让函数体在无法推导出公共类型时失败。确实,这种差异通常归结为程序员的风格和意图:如果不是因为需要强制类型限制,程序员是否仍然会在模板声明中写出类型表达式?

第一类失败的解决方法是直接的:模板接口本身形成一个合同,尝试调用违反了合同,并且函数没有被调用。理想情况下,应完全避免第二类失败。但为了做到这一点,我们必须使用第三类失败,即 SFINAE 上下文中的人工替换失败。本章的其余部分将讨论编码此类接口限制模板合同的方法。自从 C++的第一天起,SFINAE 技术就被用来人为地引起替换失败,从而将这些函数从重载集中删除。C++20 为解决这个问题添加了一种全新的机制:概念。在我们讨论使用 SFINAE 控制重载解析之前,我们需要更多地了解这种语言最新的补充。

C++20 中的概念和约束

本章的其余部分都是关于添加到模板声明中用于对模板参数施加限制的“人工”替换失败。在本节中,我们将了解 C++20 中编码这些限制的新方法。在下一节中,我们将展示如果你不能使用 C++20 但仍想约束你的模板,你可以做什么。

C++20 中的约束

C++20 通过引入概念和约束来改变我们使用 SFINAE 限制模板参数的方式。尽管整体特性通常被称为“概念”,但约束才是最重要的部分。以下内容不是这些特性的完整或正式描述,而是一种最佳实践的演示(由于社区仍在确定哪些是足够广泛接受的,因此说“模式”可能还为时过早)。

指定约束的第一种方式是通过编写一个具有以下形式的 requires 子句:

requires(constant-boolean-expression)

关键字 requires 和括号中的常量(编译时)表达式必须出现在模板参数之后,或者作为函数声明的最后一个元素:

// Example 19
template <typename T> requires(sizeof(T) == 8) void f();
template <typename T> void g(T p) requires(sizeof(*p) < 8);

就像在上一节中一样,声明末尾的约束可以按名称引用函数参数,而参数列表之后的约束只能引用模板参数(这两种语法之间的其他,更微妙的不同超出了本章的范围)。与上一节不同的是,如果约束失败,编译器通常会提供一个清晰的诊断信息,而不是简单地报告“找不到函数 f”和模板推导失败。

requires 子句中的常量表达式中可以使用什么?实际上,任何可以在编译时计算的表达式都可以,只要整体结果是 bool。例如,可以使用类型特性如 std::is_convertible_vstd::is_default_constructible_v 来限制类型。如果表达式复杂,constexpr 函数可以帮助简化它们:

template <typename V> constexpr bool valid_type() {
  return sizeof(T) == 8 && alignof(T) == 8 &&
    std::is_default_constructible_v<T>;
}
template <typename T> requires(valid_type<T>()) void f();

但有一个我们之前没有见过的特殊表达式——requires 表达式。这个表达式可以用来检查某个任意表达式是否可以编译(技术上,它“是有效的”):

requires { a + b; }

假设 ab 的值是在表达式使用的上下文中定义的,如果表达式 a + b 是有效的,则此表达式评估为 true。如果我们知道我们想要测试的类型,但没有变量怎么办?那么我们可以使用 requires 表达式的第二种形式:

requires(A a, B b) { a + b; }

类型 AB 通常指的是模板参数或某些依赖类型。

注意,我们说的是“任意表达式是有效的”,而不是“任意代码是有效的”。这是一个重要的区别。例如,你不能写

requires(C cont) { for (auto x: cont) {}; }

并要求类型 C 满足范围-for 循环的所有要求。大多数时候,你测试的是像 cont.begin()cont.end() 这样的表达式。然而,你也可以通过在 lambda 表达式中隐藏代码来提出更复杂的要求:

requires(C cont) {
  [](auto&& c) {
    for (auto x: cont) { return x; };
  }(cont);
}

如果这样的代码失败,你必须找出错误信息,那可就糟糕了。

当在模板约束中使用 requires 表达式时,模板的限制不是由特定的特性,而是由类型所需的行为来决定的:

// Example 20
template <typename T, typename P>
void f(T i, P p) requires( requires { i = *p; } );
template <typename T, typename P>
void f(T i, P p) requires( requires { i.*p; } );

首先,是的,有两个关键字 requires(顺便说一句,在这种情况下括号是可选的,你可以找到这个约束被写成 requires requires)。第一个 requires 引入了一个约束,一个 requires 子句。第二个 requires 开始了 requires 表达式。第一个函数 f() 中的表达式在第二个模板参数 p 可以解引用(它可以是一个指针,但不一定必须是)并且结果可以赋值给第一个参数 i 时是有效的。我们不需要要求赋值两边的类型相同,甚至不需要 *p 可以转换为 T(通常是这样的,但不是必需的)。我们只需要 i = *p 这个表达式能够编译。最后,如果我们没有现成的正确变量,我们可以将它们声明为 requires 表达式的参数:

// Example 20
template <typename T, typename P>
requires(requires(T t, P p) { t = *p; }) void f(T i, P p);
template <typename T, typename P>
requires(requires(T t, P p) { t.*p; }) void f(T i, P p);

这两个例子还表明,我们可以使用约束来进行 SFINAE 覆盖控制:如果约束失败,模板函数将从重载解析集中移除,并且解析继续。

如我们所见,有时我们需要检查的不是表达式,而是一个依赖类型;我们也可以在 requires 表达式中这样做:

requires { typename T::value_type; }

requires 表达式计算结果为 bool,因此它可以用在逻辑表达式中:

requires(
  requires { typename T::value_type; } &&
  sizeof(T) <= 32
)

我们可以通过这种方式组合多个 requires 表达式,但也可以在单个表达式中编写更多的代码:

requires(T t) { typename T::value_type; t[0]; }

在这里,我们要求类型 T 有一个嵌套类型 value_type 和一个接受整数索引的索引运算符。

最后,有时我们需要检查的不仅仅是某个表达式能否编译,还要检查它的结果是否具有某种类型(或满足某些类型要求)。这可以通过 requires 表达式的复合形式来完成:

requires(T t) { { t + 1 } -> std::same_as<T>; }

在这里,我们要求表达式 t + 1 能够编译,并且产生与变量 t 本身相同类型的结果。最后一部分是通过一个概念完成的;你将在下一节中了解到它们,但现在是把它看作是编写 std::is_same_v 类型特性的一种替代方法。

说到概念……到目前为止我们所描述的一切都可以在任何 C++20 书籍的“概念”标题下找到,但我们还没有提到概念本身。这即将改变。

C++20 中的概念

概念只是对一组要求的命名集合——就是我们刚刚学习到的那些要求。从某种意义上说,它们类似于 constexpr 函数,除了它们操作的是类型,而不是值。

当有一组经常引用的要求,或者你想要给它们一个有意义的名称时,你会使用概念。例如,范围由一个非常简单的要求定义:它必须有一个开始迭代器和结束迭代器。每次我们声明一个接受范围参数的函数模板时,我们都可以写一个简单的 requires 表达式,但这既方便又易于阅读,给这个要求一个名称:

// Example 21
template <typename R> concept Range = requires(R r) {
  std::begin(r);
  std::end(r);
};

我们刚刚介绍了一个名为Range的概念,它有一个模板类型参数R;此类型必须具有开始和结束迭代器(我们使用std::begin()而不是成员函数begin()的原因是 C 数组也是范围,但没有成员函数)。

注意,C++20 有一个范围库和相应的一组概念(包括std::ranges::range,在任何实际代码中应使用它而不是我们自制的Range),但范围的概念是方便的教学材料,我们将用它来驱动示例。

一旦我们有一个命名的概念,我们就可以用它来代替在模板约束中详细说明的要求:

// Example 21
template <typename R> requires(Range<R>) void sort(R&& r);

如你所见,概念可以在requires子句中使用,就像它是一个类型为boolconstexpr变量一样。确实,概念也可以在静态断言等上下文中使用:

static_assert(Range<std::vector<int>>);
static_assert(!Range<int>);

对于概念是整个要求的简单模板声明,语言提供了一种更简单的方式来表述它:

// Example 21
template <Range R> void sort(R&& r);

换句话说,可以在模板声明中使用概念名称代替typename关键字。这样做会自动将相应的类型参数限制为满足该概念的类型。如果需要,仍然可以使用requires子句来定义额外的约束。最后,概念也可以与新的 C++20 模板语法一起使用:

// Example 21
void sort(Range auto&& r);

所有三个声明具有相同的效果,选择主要取决于风格和便利性。

概念和类型限制

我们已经看到如何使用概念和约束来对函数模板的参数施加限制。requires子句可以出现在模板参数之后或函数声明的末尾;这两个地方都是 SFINAE 上下文,任一位置的替换失败都不会停止整个程序的编译。在这方面,概念与替换失败没有本质的不同:虽然你可以在 SFINAE 上下文之外使用约束,但替换失败仍然是一个错误。例如,你不能通过使用约束来断言一个类型没有嵌套类型value_type

static_assert(!requires{ typename T::value_type; });

你可能期望如果未满足要求,requires表达式将评估为假,但在此情况下,它根本无法编译(你会得到错误信息,即T::value_type未引用一个有效的类型)。

然而,使用概念可以实施一些以前无法实现的限制。这些是针对类模板的要求。在最简单的情况下,我们可以使用一个概念来限制类模板的类型参数:

// Example 21
template <Range R> class C { … };

此类模板只能用满足Range概念的类型实例化。

然后,我们可以约束单个成员函数,无论它们是否是模板:

// Example 21
template <typename T> struct holder {
  T& value;
  holder(T& t) : value(t) {}
  void sort() requires(Range<T>) {
    std::sort(std::begin(value), std::end(value));
  }
};

现在类模板本身可以在任何类型上实例化。然而,如果类型满足Range约束,其接口才包括一个成员函数sort()

这是在约束和旧 SFINAE 之间的一个非常重要的区别:人工替换失败只有在替换函数模板中推导出的类型参数时才会起到帮助作用。在本章的早期,我们不得不向一个成员函数添加一个虚拟模板类型参数,只是为了能够创建一个 SFINAE 失败。有了概念,就无需做这些了。

概念和约束是指定模板参数限制的最佳方式。它们使多年来发明的许多 SFINAE 技巧变得过时。但并非每个人都能访问 C++20。此外,即使有概念,一些 SFINAE 技术仍然在使用。在最后一节中,我们将学习这些技术,并了解如果你没有 C++20 但仍然想要约束模板类型,可以做什么。

SFINAE 技术

模板参数替换失败不是错误——SFINAE 规则——必须添加到语言中,只是为了使某些狭窄定义的模板函数成为可能。但是,C++程序员的独创性没有界限,因此 SFINAE 被重新定位并利用来通过故意造成替换失败来手动控制重载集。多年来,发明了大量的基于 SFINAE 的技术,直到 C++20 的概念使其中大多数变得过时。

尽管如此,即使在 C++20 中,一些 SFINAE 的使用仍然存在,而且还有大量的 C++20 之前的代码,你可能需要阅读、理解和维护。

让我们从 SFINAE 的应用开始,即使有概念可用,这些应用仍然是有用的。

C++20 中的 SFINAE

首先,即使在 C++20 中,仍然存在“自然”的类型替换失败。例如,你可能想编写这个函数:

template <typename T> typename T::value_type f(T&& t);

这仍然是可行的,假设你真的想返回由嵌套的value_type给出的类型。然而,在你匆忙回答“是的”之前,你应该仔细检查你真正想要返回的类型是什么。你想要强制执行与调用者的哪种契约?也许value_type的存在被用作真实要求的代理,例如类型 T 具有索引操作符或可以用作迭代范围。在这种情况下,你现在可以直接声明这些要求,例如:

template <typename T> auto f(T&& t)
requires( requires { *t.begin(); t.begin() != t.end(); } );

这意味着你真正需要的是一个具有成员函数begin()end()的类型。这些函数返回的值(假设是迭代器)被解引用并比较;如果这些操作受支持,返回值对我们来说足够接近迭代器。最后,在前面的例子中,我们让编译器确定返回类型。这通常很方便,但缺点是接口——我们的契约——没有说明返回类型是什么;我们的代码的客户必须阅读实现。假设我们返回通过解引用迭代器得到的值,我们可以明确地指出这一点:

template <typename T> auto f(T&& t)->decltype(*t.begin())
requires( requires {
  *t.begin();
  t.begin() != t.end();
  ++t.begin();
} );

这是一个非常全面的客户合同,当然,前提是我们作为实施者保证如果满足所列要求,函数的主体将能够编译。否则,合同是不完整的:例如,如果我们确实在函数的主体中使用了T::value_type,我们应该将typename T::value_type添加到要求列表中,无论这最终返回的类型是什么(如果是,我们仍然可以使用 SFINAE 来处理返回类型,这没有问题)。

当使用依赖类型声明模板函数参数时,也存在类似的考虑,例如:

template <typename T>
bool find(const T& t, typename T::value_type x);

再次,我们应该问自己这些是否真的是我们想要施加的要求。假设函数正在查找容器t中的值x,只要它可以与容器中存储的值进行比较,我们是否真的关心x的类型?考虑这个替代方案:

template <typename T, typename X>
bool find(const T& t, X x)
requires( requires {
  *t.begin() == x;
  t.begin() == t.end();
  ++t.begin();
} );

现在我们要求容器具有范围-for 循环所需的一切,并且容器中存储的值可以与x进行比较以实现相等。假设我们只是迭代容器,如果找到等于x的值则返回 true,这就是我们从调用者那里需要的要求。

你不应该推断出在 C++20 中“自然”的 SFINAE 不再使用,而是被独立的模板参数和约束绑定所取代。我们建议的只是检查你的代码,以确定接口表达并通过 SFINAE 执行的合同是否真的是你想要的,或者仅仅是编写代码方便的。在后一种情况下,概念提供了一种表达你真正想要要求但无法(但请继续阅读,因为有一些受概念启发的技术可以在 C++20 之前使用并满足相同需求)的方法。另一方面,如果模板函数最好以在客户端提供无效参数时触发替换失败的方式编写,那么,无论如何,继续使用 SFINAE——没有必要重写一切以使用概念。

即使是“人工”的 SFINAE 在 C++20 中仍然有用途,正如我们即将看到的。

SFINAE 和类型特性

在 C++20 中,“人工”SFINAE 最重要的应用是编写类型特性。类型特性不会消失:即使你在代码中将std::is_same_v(特性)替换为std::same_as(概念),你应该知道概念的实现使用了它所取代的特性。

并非所有类型特性都需要使用 SFINAE,但许多确实需要。这些特性检查某些语法特征的存在,例如嵌套类型的存在。这些特性的实现面临一个共同问题:如果类型没有所需的功能,某些代码将无法编译。但我们不希望出现编译错误。我们希望表达式评估为false。那么我们如何让编译器忽略错误呢?当然是通过使其在 SFINAE 上下文中发生。

让我们从整个章节中一直阻碍我们的一个例子开始:我们将编写一个特性来检查一个类型是否有嵌套类型 value_type。我们将使用 SFINAE,因此需要一个模板函数。这个函数必须在一个 SFINAE 上下文中使用嵌套类型。有几个选择。通常,添加一个依赖于可能失败的表达式的模板参数是很方便的,例如:

template <typename T, typename = T::value_type> void f();

注意,第二个参数没有名字——我们从未使用过它。如果我们尝试用任何没有嵌套 value_type 的类型 T 实例化这个模板,例如 f<int>(),替换将失败,但这不是错误(SFINAE!)。当然,当我们写 f(ptr) 时没有函数可以调用是一个错误,所以我们必须提供一个后备的重载:

template <typename T> void f(…);

你可能会觉得“双重通用”的 template f(...) 函数的概念很奇特——它可以接受任何类型的任何参数,甚至在没有模板的情况下,那么为什么还要使用模板呢?当然,如果一个显式指定类型的调用,例如 f<int>(),会把这个函数视为一个可能的重载(记住,通过指定模板参数类型,我们也排除了所有非模板函数的考虑)。然而,我们希望这个重载的优先级尽可能低,只要存在,就优先选择第一个重载。这就是为什么我们使用 f(…),这是“最后的手段”的重载。唉,f()f(…) 的重载仍然被认为是模糊的,所以我们需要至少有一个参数。只要我们能轻松地构造出该类型的对象,参数的类型就无关紧要:

template <typename T, typename = T::value_type>
void f(int);
template <typename T> void f(…);

现在,如果 T::value_type 是一个有效的类型,调用 f<T>(0) 将选择第一个重载。否则,只有一个重载可供选择,即第二个。我们需要的只是一个方法来确定如果进行调用,会选择哪个重载,而不必实际进行调用。

这实际上非常简单:我们可以使用 decltype() 来检查函数的结果类型(在 C++11 之前,使用 sizeof())。现在,我们只需要给这两个重载不同的返回类型。可以使用任何两种不同的类型。然后,我们可以根据这些类型编写一些条件代码。然而,记住我们正在编写一个类型特性,检查存在性的特性通常会在存在时结束为 std::true_type,不存在时为 std::false_type。我们没有理由使我们的实现过于复杂——我们只需从两个重载中返回所需类型,并将其用作特性:

// Example 22
namespace detail {
template <typename T, typename = T::value_type>
void test_value_type(int);
template <typename T> void test_value_type (…);
}
template <typename T> using has_value_type =
  decltype(detail::test_value_type <T>(0));

由于函数从未被调用,只是在decltype()内部使用,我们不需要提供函数的定义,只需要它们的声明(但请参见下一节,以获得更完整和细致的解释)。为了避免将客户端不需要关心的测试函数污染全局命名空间,通常的做法是将它们隐藏在detailinternal等命名空间中。说到惯例,我们应该定义两个别名:

template <typename T>
using has_value_type_t = has_value_type<T>::type;
template <typename T> inline constexpr
bool has_value_type_v = has_value_type<T>::value;

现在,我们可以像使用任何标准特质一样使用我们的特质,例如:

static_assert(has_value_type_v<T>, “I require value_type”);

正如我们之前看到的,我们还可以使用几个其他 SFINAE 上下文来“隐藏”使用T::value_type时可能出现的潜在错误。可以使用尾返回类型,但这并不方便,因为我们已经有一个需要的返回类型(有一种方法可以绕过这一点,但它比其他替代方案更复杂)。此外,如果我们需要使用 SFINAE 与构造函数一起,那么返回类型不是一种选择。

另一种常见的技术是在函数中添加额外的参数;替换错误发生在参数类型中,并且参数必须具有默认值,这样调用者甚至不知道它们的存在。这曾经更受欢迎,但我们正在远离这种做法:虚拟参数可能会干扰重载解析,并且可能很难为这样的参数找到可靠的默认值。

另一种正在成为标准做法的技术是将替换失败发生在可选的非类型模板参数中:

// Example 22a
template <typename T, std::enable_if_t<
  sizeof(typename T::value_type) !=0, bool> = true>
std::true_type test_value_type(int);

这里我们有一个非类型模板参数(一个类型为bool的值)和一个默认值true。在这个参数中替换类型T可能会以与这一节中所有早期失败相同的方式失败:如果嵌套类型T::value_type不存在(如果存在,逻辑表达式sizeof(…) != 0永远不会失败,因为任何类型的尺寸都是非负的)。这种方法的优点是,如果我们需要同时检查多个失败,更容易组合多个表达式,例如:

template <typename T, std::enable_if_t<
  sizeof(typename T::value_type) !=0 &&
  sizeof(typename T::size_type) !=0, bool> = true>
std::true_type test_value_type(int);

这种技术有时会与默认值中的失败表达式一起使用,而不是使用类型:

template <typename T,
          bool = sizeof(typename T::value_type)>
std::true_type test_value_type(int);

这是一个坏习惯:虽然它有时有效,看起来也更容易编写,但它有一个主要的缺点。通常,你需要声明几个具有不同条件的重载,以便只有一个成功。你可以使用前面的方法来做到这一点:

template <typename T, std::enable_if_t<cond1, bool> = true>
res_t func();
template <typename T, std::enable_if_t<cond2, bool> = true>
res_t func(); // OK as long as only one cond1,2 is true

但你不能这样做:

template <typename T, bool = cond1> = true>
res_t func();
template <typename T, bool = cond2 > = true>
res_t func();

两个具有相同参数但不同默认值的模板被认为是重复声明,即使其中一个条件cond1cond2总是导致替换失败。更好的做法是养成在非类型参数的类型中使用(可能失败的)条件的习惯。

为了回顾我们关于 SFINAE 所学到的一切,让我们再写一个特质。这次,我们将检查一个类型是否是类:

// Example 23
namespace detail {
template <typename T> std::true_type test(int T::*);
template <typename T> std::false_type test(...);
}
template <typename T>
using is_class = decltype(detail::test<T>(nullptr));

类与不是类之间的关键区别在于,类有成员,因此有成员指针。这次最简单的方法是声明一个成员函数参数,该参数是成员指针(无论是什么类型的成员,我们都不会调用该函数)。如果类型 T 没有任何成员,则在参数类型T::*中发生替换失败。

这几乎与标准特质std::is_class的定义完全相同,但它还检查了联合:联合不被std::is_class视为类,但实现std::is_union需要编译器支持,而不是 SFINAE。

我们学到的技术使我们能够编写任何检查类型特定属性的特质:它是否是一个指针,它是否有嵌套类型或成员等。另一方面,概念使得检查行为变得容易:类型是否可以被解引用,两个类型是否可以比较等?请注意,我说的是“容易”而不是“可能”:你可以使用概念来检查非常狭窄定义的特征,你可以使用特质来检测行为,但这并不直接。

本章主要面向那些在应用代码中编写模板和模板库的程序员:如果你编写了一个具有 STL 复杂性和严谨性的库,你需要在你的定义上非常精确(你还需要一个标准委员会来辩论并精确到必要的程度)。对于我们其他人来说,“如果*p可以编译,则调用f(p)”提供的正式程度通常足够。在 C++20 中,我们可以使用概念来实现这一点。如果你还没有使用 C++20,你必须使用 SFINAE 技术之一。本章讨论了几种这样的技术;社区在多年中发展了更多。然而,概念的发展对这些实践产生了有趣的影响:除了我们可以在 C++20 中直接使用的工具外,标准还提供了一种思考这个问题的方法,这种方法的应用范围更广。因此,一些与概念相似的技术(例如,在尾随的decltype()中测试行为)变得越来越受欢迎,而其他实践则不再受欢迎。甚至有人尝试使用 C++20 之前的语言特性来实现一个概念库。当然,不可能复制概念;在许多方面,我们甚至无法接近。然而,即使我们不能使用该语言本身,我们仍然可以从开发概念语言所投入的思维中受益。因此,我们可以“在精神上”使用 SFINAE,这提供了一种一致的方式来实现基于 SFINAE 的限制,而不是一个临时的技术集合。以下是一种在不使用 C++20 的情况下实现类似概念限制的方法。

概念之前的技术

我们的目标并不是在这里实现一个完整的概念库:你可以在网上找到这样的库,这本书是关于设计模式和最佳实践的,而不是编写特定的库。本节的目标是从众多可用的选项中挑选出一些最佳的基于 SFINAE 的技术。这些技术尽可能符合基于概念的心态。我们没有选择的方法和技巧并不一定比其他方法差,但本节提供了一套 SFINAE 工具和实践,它是一致的、统一的,并且足以满足绝大多数应用程序开发者的需求。

正如与真实的概念一样,我们需要两种类型的实体:概念和限制。

如果你看看概念的使用方式,它们强烈地类似于常量布尔变量:

template <typename R> concept Range = …;
template <typename R> requires(Range<R>) void sort(…);

requires()子句需要一个布尔值,它不仅限于概念(考虑表达式requires(std::is_class_v<T>))。因此,Range<R>概念就像一个布尔值。不可避免地,我们将使用constexpr bool变量来代替概念以模拟它们的行为。从Range<R>std::is_class_v<T>的比较中,我们还可以推断出,类似于特质的机制可能是实现概念的最佳选择:毕竟std::is_class_v也是一个constexpr bool变量。

从上一节中我们学习的特质的实现中,我们知道我们需要两个重载:

template <typename R> constexpr yes_t RangeTest(some-args);
template <typename R> constexpr no_t RangeTest(...);

第一个重载对于满足Range要求的任何类型R都是有效且首选的(一旦我们弄清楚如何实现)。第二个重载始终可用但不是首选的,因此只有在没有其他重载可用时才会调用。

我们可以通过返回类型(yes_tno_t只是对我们尚未选择的某些类型的占位符)来确定调用了哪个重载。但有一个更简单的方法;我们需要的只是Range“概念”的一个常量布尔值,所以为什么不直接让constexpr函数返回正确的值,如下所示:

template <typename R> constexpr bool RangeTest(some-args) {
  return true;
}
template <typename R> constexpr bool RangeTest(...) {
  return false;
}
template <typename R>
constexpr inline bool Range = RangeTest<R>(0);

最后两个语句(变量和后备重载)已经完成。“所有”我们需要的只是确保当R不是范围时,第一个重载会因替换失败而失败。那么,在我们的目的中,什么是范围呢?就像我们在Concepts in C++20这一节中所做的那样,我们将定义范围为任何具有begin()end()方法的类型。由于我们正在测试特定的行为,这可能会失败编译,但不应导致错误,因此我们应该在 SFINAE 上下文中触发这种失败。正如我们已经看到的,这种可能无效的代码的最容易放置位置是尾随返回类型:

template <typename R>
constexpr auto RangeTest(??? r) -> decltype(
  std::begin(r),        // Ranges have begin()
  std::end(r),         // Ranges have end()
  bool{}            // But return type should be bool
) { return true; }

后置返回类型让我们能够编写使用参数名称的代码。我们只需要一个类型为R的参数r。当使用 SFINAE 调用任何预期被调用的模板函数时,这很容易做到。但这个函数永远不会用实际的范围来调用。我们可以尝试声明一个类型为R&的参数,然后用默认构造的范围R{}来调用该函数,但这不会起作用,因为constexpr函数必须具有constexpr参数(否则它们仍然可以被调用,但不是在常量表达式中,即在编译时),而R{}对于大多数范围来说不会是一个constexpr值。

我们可以完全放弃使用引用,改用指针:

// Example 24
template <typename R>
constexpr auto RangeTest(R* r) -> decltype(
  std::begin(*r),    // Ranges have begin()
  std::end(*r),         // Ranges have end()
  bool{}            // But return type should be bool
) { return true; }
template <typename R> constexpr bool RangeTest(...) {
  return false;
}
template <typename R>
constexpr inline bool Range = RangeTest<R>(nullptr);

虽然你可能预计“概念类似”的 SFINAE 将会非常复杂,但实际上这正是你需要定义一个如Range这样的概念:

static_assert(Range<std::vector<int>>);
static_assert(!Range<int>);

这两个语句看起来与它们的 C++20 等价物完全一样!我们的“概念”甚至在 C++14 中也能工作,只是那里没有inline变量,所以我们必须使用static代替。

在现在完成概念之后,我们还需要对约束做一些处理。在这里,我们的成功将受到很大的限制。首先,由于我们正在使用 SFINAE,我们只能对模板函数参数施加限制(正如我们所见,C++20 约束甚至可以应用于非模板函数,例如类模板的成员函数)。此外,我们在哪里可以编写这些约束也非常有限。最通用的方法是将非模板参数添加到模板中并在那里测试约束:

template <typename R,
    std::enable_if_t<Range<R>, bool> = true>
void sort(R&& r);

我们可以在宏中隐藏模板代码:

// Example 24
#define REQUIRES(...) \
  std::enable_if_t<(__VA_ARGS__), bool> = true
template <typename R, REQUIRES(Range<R>)> void sort(R&& r);

可变参数宏巧妙地解决了宏在参数为代码时常见的难题:逗号被解释为参数之间的分隔符。这当然没有 C++20 约束那么方便,但几乎是最接近的方法了。

现在让我们回到概念上来。我们之前写的内容是有效的,但有两个问题:首先,那里也有大量的模板代码。其次,我们必须使用指针来引入我们稍后可以用来测试所需行为的函数参数名称。这限制了我们可以要求的行为,因为函数可以通过引用传递参数,行为可能依赖于所使用的引用类型,而我们无法形成引用的指针。实际上,我们刚才写的代码在许多情况下是无法编译的,因为模板函数sort()的参数R的类型被推断为引用。为了可靠地使用它,我们必须检查其底层类型:

// Example 24
template <typename R, REQUIRES(Range<std::decay_t<R>>)>
void sort(R&& r);

如果我们可以使用引用参数,那将更加方便,但这样我们又回到了之前遇到的问题:如何调用这样的函数?我们不能使用对应类型的值,例如R{},因为它不是一个常量表达式。如果我们尝试将R{}用作默认参数值,也会出现同样的问题——它仍然不是一个常量表达式。

就像软件工程中的大多数问题一样,这个问题可以通过添加另一个间接层来解决:

template <typename R>
constexpr static auto RangeTest(R& r = ???) ->
  decltype(std::begin(r), std::end(r));
template <typename R>        // Overload for success
constexpr static auto RangeTest(int) ->
  decltype(RangeTest<R>(), bool{}) { return true; }
template <typename R>        // Fallback overload
constexpr bool RangeTest(...) { return false; }
template <typename R>
constexpr static bool Range = RangeTest<R>(0);

我们的后备重载保持不变,但如果 SFINAE 测试成功,将要被调用的重载现在尝试在 decltype 上下文中调用 RangeTest(r)(此外,我们回到了使用 int 而不是指针作为占位参数)。最后一个问题是使用什么作为参数 r 的默认值。

在代码中获取永远不会被调用的对象的引用的常用方法是 std::declval,因此我们可能想要这样写:

template <typename R>
constexpr static auto RangeTest(R& r=std::declval<R>()) ->
  decltype(std::begin(r), std::end(r));

不幸的是,这不会编译,错误信息可能类似于“std::declval 不能使用。”这很奇怪,我们实际上并没有使用它(整个函数仅在 decltype() 内部使用),但让我们尝试解决这个问题。毕竟,std::declval 中没有魔法,我们只需要一个返回我们对象引用的函数:

template <typename T> constexpr T& lvalue();
template <typename R>
constexpr static auto RangeTest(R& r = lvalue<R>()) ->
  decltype(std::begin(r), std::end(r));

在一个符合标准的编译器上,这同样不会编译,但错误将不同,这次编译器会说出类似这样的话:

inline function 'lvalue<std::vector<int>>' is used but not defined."

好的,我们可以定义这个函数,但请确保它永远不会被调用:

template <typename T> constexpr T& lvalue() { abort(); }
template <typename R>
constexpr static auto RangeTest(R& r = lvalue<R>()) ->
  decltype(std::begin(r), std::end(r));

添加 { abort(); } 会带来很大的不同——程序现在可以编译了,并且在添加了其余缺失的部分之后,它可以在不终止的情况下运行。这正是应该的:函数 lvalue() 仅在 decltype 内部使用,其实现根本无关紧要。我不会再让你悬着了,这是一个与标准本身相关的问题;如果你想要深入了解棘手细节,可以在这里跟随 核心问题 1581www.open-std.org/jtc1/sc22/wg21/docs/papers/2017/p0859r0.html。现在,我们只能保留这个无用的函数体(这不会伤害到任何东西)。当然,我们也可以为初始化默认的右值参数以及 const 左值引用定义类似的函数,并将它们包含在某个仅用于实现的命名空间中:

namespace concept_detail {
template <typename T>
  constexpr const T& clvalue() { abort(); }
template <typename T> constexpr T& lvalue() { abort(); }
template <typename T> constexpr T&& rvalue() { abort(); }
}

现在我们可以定义测试我们想要的引用类型的行为的概念:

// Example 24a
template <typename R>
constexpr static auto RangeTest(
  R& r = concept_detail::lvalue<R>()) ->
  decltype(std::begin(r), std::end(r));
template <typename R>
constexpr static auto RangeTest(int) ->
  decltype(RangeTest<R>(), bool{}) { return true; }
template <typename R>
constexpr bool RangeTest(...) { return false; }
template <typename R>
constexpr static bool Range = RangeTest<R>(0);

包括我们的 REQUIRES 宏在内的约束仍然以完全相同的方式工作(毕竟,概念本身并没有改变——Range 仍然是一个常量布尔变量)。

仍然存在样板代码的问题;实际上,我们有了更多难以处理的默认参数值。不过,借助一些宏,这最容易处理:

// Example 25
#define CLVALUE(TYPE, NAME) const TYPE& NAME = \
  Concepts::concept_detail::clvalue<TYPE>()
#define LVALUE(TYPE, NAME) TYPE& NAME = \
  Concepts::concept_detail::lvalue<TYPE>()
#define RVALUE(TYPE, NAME) TYPE&& NAME = \
  Concepts::concept_detail::rvalue<TYPE>()

在这三个模板函数(如 RangeTest)中,第一个函数相当于 C++20 的 concept 声明——这就是我们想要要求的行为被编码的地方。除了这些宏之外,实际上无法再缩短它:

// Example 25
template <typename R> CONCEPT RangeTest(RVALUE(R, r)) ->
  decltype(std::begin(r), std::end(r));

在这里,我们也定义了一个宏:

#define CONCEPT constexpr inline auto

这样做并不是为了缩短代码,而是为了让读者(如果不是编译器)清楚地知道我们正在定义一个概念。将其与 C++20 的语句进行比较:

template <typename R> concept Range =
  requires(R r) { std::begin(r); std::end(r); };

其他两个重载(RangeTest(int)RangeTest(…)),以及概念变量的定义本身,可以很容易地使任何概念通用(当然,除了名称之外)。事实上,唯一从一个概念到另一个概念有所不同的声明是第一个:

template <typename R>
constexpr static auto RangeTest(int) ->
  decltype(RangeTest<R>(), bool{}) { return true; }

如果我们使用变长模板,我们可以使它适用于任何概念测试函数:

// Example 25
template <typename… T>
constexpr static auto RangeTest(int) ->
  decltype(RangeTest<T…>(), bool{}) { return true; }

由于我们所有的参数宏,例如LVALUE(),都包含了每个参数的默认值,所以函数总是可以在没有参数的情况下调用。我们必须注意我们定义的测试函数可能与RangeTest(int)函数冲突的可能性。这里不会发生这种情况,因为int不是一个有效的范围,但对于其他参数可能会发生。由于我们控制这些常见的重载以及概念变量的定义本身,我们可以确保它们使用一个不会与我们在常规代码中可能编写的任何内容冲突的参数:

// Example 25
struct ConceptArg {};
template <typename… T>
constexpr static auto RangeTest(ConceptArg, int) ->
  decltype(RangeTest<T…>(), bool{}) { return true; }
template <typename T>
constexpr bool RangeTest(ConceptArg, ...) { return false; }
template <typename R>
constexpr static bool Range = RangeTest<R>(ConceptArg{},0);

这段代码对于所有概念都是相同的,除了像RangeRangeTest这样的名称。一个宏可以仅通过两个命名参数生成所有这些行:

// Example 25
#define DECLARE_CONCEPT(NAME, SUFFIX) \
template <typename... T> constexpr inline auto     \
  NAME ## SUFFIX(ConceptArg, int) -> \
  decltype(NAME ## SUFFIX<T...>(), bool{}){return true;} \
template <typename... T> constexpr inline bool \
  NAME ## SUFFIX(ConceptArg, ...) { return false; } \
template <typename... T> constexpr static bool NAME = \
  NAME ## SUFFIX<T...>(ConceptArg{}, 0)

我们没有这样做是为了简洁,但如果你想在代码中使用这些类似概念的工具,你应该将这些所有实现细节隐藏在一个命名空间中。

现在我们可以如下定义我们的范围概念:

// Example 25
template <typename R> CONCEPT RangeTest(RVALUE(R, r)) ->
  decltype(std::begin(r), std::end(r));
DECLARE_CONCEPT(Range, Test);

多亏了变长模板,我们不仅限于只有一个模板参数的概念。这是一个可以相加的两个类型的概念:

// Example 25
template <typename U, typename V> CONCEPT
  AddableTest(CLVALUE(U, u), CLVALUE(V, v)) ->
  decltype(u + v);
DECLARE_CONCEPT(Addable, Test);

作为比较,这是 C++20 版本的样子:

template <typename U, typename V> concept Addable =
  require(U u, V v) { u + v; }

当然,它要短得多,也强大得多。但 C++14 版本几乎是最接近的(这不是唯一的方法,但它们都产生类似的结果)。

这些“假概念”可以用来约束模板,就像 C++20 概念一样:

 // Example 25
template <typename R, REQUIRES(Range<R>)> void sort(R&& r);

好吧,并不完全像 C++20 的概念——我们限制在模板函数中,任何要求都必须至少涉及一个模板参数。所以,如果你想限制模板类的非模板成员函数,你必须玩模板游戏:

template <typename T> class C {
  template <typename U = T, REQUIRE(Range<U>)> void f(T&);
  …
};

但我们最终确实得到了相同的结果:对向量的排序调用可以编译,而对非范围对象的排序调用则不行:

std::vector<int> v = …;
sort(v);         // OK
sort(0);        // Does not compile

不幸的是,我们的伪概念在错误信息方面确实存在不足——一个 C++20 编译器通常会告诉我们哪个概念没有满足以及原因,而模板替换错误信息并不容易解读。

顺便说一句,当你编写测试以确保某些内容无法编译时,你现在可以使用一个概念(或伪概念)来做这件事:

// Example 25
template <typename R>
CONCEPT SortCompilesTest(RVALUE(R, r))->decltype(sort(r));
DECLARE_CONCEPT(SortCompiles, Test);
static_assert(SortCompiles<std::vector<int>>);
static_assert(!SortCompiles<int>);

C++20 版本留给你作为练习。

在我们结束这一章之前,让我们看看 SFINAE 和概念在模板中使用时的推荐和最佳实践。

限制模板——最佳实践

我们在整章中遇到了最有用的 SFINAE 和概念化技术,并推荐了它们,但由于要覆盖的材料很多,因此简要重申这些指南可能是有帮助的。这些指南主要针对在应用程序代码中使用模板的程序员。这包括基础代码,如应用程序的核心模板库,但编写库(如 STL)的程序员,该库旨在在极端多变的情况下尽可能广泛地使用,并在正式标准中非常精确地记录,会发现这些指南在精确性和形式上有所欠缺:

  • 学习 SFINAE 的基本规则:它在哪些上下文中适用(声明)以及在哪些上下文中不适用(函数体)。

  • 从在模板声明中使用参数依赖类型和在尾随返回类型中使用参数依赖表达式产生的 SFINAE 的“自然”使用几乎总是表达模板参数约束的最简单方式(但请参见下一条指南)。

  • 反问自己你是否使用了依赖类型,例如T::value_type,因为这正是你在使用它的上下文中正确的类型,或者它只是比在接口上编写真实约束(例如“任何可以转换为T::value_type的类型”)更简单?在后一种情况下,这一章应该已经说服你,这样的限制并不难表达。

  • 在合理的情况下,通过使用额外的模板参数及其必要的约束来使你的模板更通用(而不是使用T::value_type作为参数类型,使用另一个模板参数并将其约束为可转换为T::value_type)。

  • 如果你使用 C++20 并且可以访问概念,请避免使用“人工”的 SFINAE,即不要创建仅用于约束模板的替换失败。根据需要使用requires子句,无论是否使用概念。

  • 如果你不能使用 C++20 的概念,选择一个通用的统一方法来处理基于 SFINAE 的约束,并遵循它。即使你不能使用语言工具,也要利用为 C++20 开发的概念化方法:在应用基于 SFINAE 的技术时,遵循相同的风格和模式。上一节介绍了一种这样的方法。

  • 理想情况下,如果一个模板声明满足所有指定的限制,模板体中不应出现替换错误(即如果函数被调用,则可以编译)。在实践中,这是一个难以实现的目标:限制条件可能变得冗长,有时难以编写,你可能甚至没有意识到你的实现隐式地要求的所有约束。即使是设计时受益于委员会审查其要求的每个词的 STL,也没有完全达到这个目标。尽管如此,这是一个好的实践目标。此外,如果你必须允许函数被调用但不能编译,至少在函数体中将要求编码为静态断言——它们比用户从未听说过的类型中的奇怪替换错误更容易理解。

阅读本章后,这些指南对你来说不应太过令人畏惧。

摘要

SFINAE 是 C++ 标准中的一种较为晦涩的特性——它复杂且有许多细微之处。虽然它通常在 手动控制重载解析 的上下文中被提及,但它的主要目的实际上并不是为了允许非常复杂的专家级代码,而是为了让常规(自动)的重载解析按照程序员期望的方式工作。在这个角色中,它通常能如预期般工作,且无需额外努力——实际上,程序员通常甚至不需要意识到这个特性。大多数时候,当你编写一个泛型重载和一个针对指针的特殊重载时,你期望后者在非指针类型上不会被调用。大多数时候,你可能甚至不会停下来注意到被拒绝的重载会是无效的——谁在乎呢,它本就不应该被使用。但为了找出它不应该被使用,必须替换类型,这会导致无效的代码。SFINAE 打破了这种“先有鸡还是先有蛋”的问题——为了找出重载应该被拒绝,我们必须替换类型,但那样会创建不应该编译的代码,这不应该是一个问题,因为重载最初就应该被拒绝,但我们直到替换了类型才知道这一点,如此循环。这就是我们所说的“自然”SFINAE。

当然,我们并没有翻阅几十页的书籍只是为了学习编译器神奇地做正确的事情,你不必担心。SFINAE 更为精细的使用是创建一个人工的替换失败,通过移除一些重载来控制重载解析。在本章中,我们学习了这些最终被 SFINAE 抑制的 临时 错误的 安全 环境。通过谨慎的应用,这项技术可以在编译时检查和区分从不同类型的基本特性(这是一个类吗?)到任何数量的 C++ 语言特性可以提供的复杂行为(有没有办法添加这两种类型?)。在 C++20 中,通过引入约束和概念,此类代码得到了极大的简化。然而,我们甚至可以将概念启发的思维应用于为早期标准编写的代码。

在下一章中,我们将介绍另一种用于极大地增强 C++ 中类层次结构能力的先进模板模式:类继承使我们能够从基类传递信息到派生类,而 Curiously Recurring Template Pattern 则相反,它使基类意识到派生类。

问题

  1. 重载集是什么?

  2. 重载解析是什么?

  3. 类型推导和类型替换是什么?

  4. SFINAE 是什么?

  5. 在什么情况下可能无效的代码可以存在而不触发编译错误,除非该代码实际上需要?

  6. 我们如何确定选择了哪个重载,而不实际调用它?

  7. SFINAE 如何用于控制条件编译?

  8. 为什么 C++20 的约束优于 SFINAE 用于模板约束?

  9. C++20 的概念标准如何使使用早期语言版本的程序员受益?

第三部分:C++ 设计模式

本部分从本书的主要内容开始。它介绍了最重要的、最常用的 C++ 设计模式。每个模式通常被用作解决某一类问题的公认方法。确切的问题是什么,差异很大:有些是系统架构挑战,有些是接口设计问题,还有些是处理程序性能。

本部分包含以下章节:

  • 第八章Curiously Recurring Template Pattern

  • 第九章命名参数、方法链和 Builder 模式

  • 第十章局部缓冲区优化

  • 第十一章作用域保护

  • 第十二章友元工厂

  • 第十三章虚构造函数和工厂

  • 第十四章模板方法模式和 Non-Virtual 习语

第八章:奇特重复模板模式

我们已经熟悉了继承、多态和虚函数的概念。派生类从基类继承,并通过重写基类的虚函数来自定义基类的行为。所有操作都是在基类的一个实例上多态执行的。当基类对象实际上是派生类的实例时,会调用正确的自定义重写。基类对派生类一无所知,派生类可能甚至在基类代码编写和编译时还没有被编写。奇特重复模板模式CRTP)将这个有序的画面颠倒过来,并彻底翻转。

本章将涵盖以下主题:

  • CRTP 是什么?

  • 静态多态是什么,它与动态多态有什么区别?

  • 虚函数调用的缺点是什么,为什么可能更希望在编译时解决这些调用?

  • CRTP 还有其他什么用途?

技术要求

Google Benchmark 库:github.com/google/benchmark

示例代码:github.com/PacktPublishing/Hands-On-Design-Patterns-with-CPP-Second-Edition/tree/master/Chapter08

理解 CRTP

CRTP(Curiously Recurring Template Pattern)这个名称最早由 James Coplien 在 1995 年提出,在他的文章《C++ Report》中。它是一种更一般的有界多态的特殊形式(Peter S. Canning 等人,面向对象编程的有界多态,功能编程语言和计算机架构会议,1989 年)。虽然它不是虚函数的一般替代品,但它为 C++程序员提供了一个在适当情况下提供几个优势的类似工具。

虚函数有什么问题?

在我们讨论虚函数的更好替代方案之前,我们应该考虑为什么我们想要有替代方案。有什么不喜欢的虚函数?

问题在于性能开销。虚函数调用可能比非虚调用贵几倍,对于本来可以内联但因为是虚函数而不能内联的非常简单的函数来说更是如此(回想一下,虚函数永远不能内联)。我们可以通过微基准测试来衡量这种差异,微基准测试是衡量代码小片段性能的理想工具。现在有很多微基准测试库和工具;在这本书中,我们将使用 Google Benchmark 库。为了跟随本章的示例,您必须首先下载并安装该库(详细说明可以在第五章RAII 的全面探讨中找到)。然后,您可以编译并运行示例。

现在我们已经准备好了微基准测试库,我们可以测量虚函数调用的开销。我们将比较一个非常简单的虚函数,代码量最少,与执行相同操作的非虚函数进行对比。下面是我们的虚函数:

// Example 01
class B {
  public:
  B() : i_(0) {}
  virtual ~B() {}
  virtual void f(int i) = 0;
  int get() const { return i_; }
  protected:
  int i_;
};
class D : public B {
  public:
  void f(int i) override { i_ += i; }
};

下面是等效的非虚函数:

// Example 01
class A {
  public:
  A() : i_(0) {}
  void f(int i) { i_ += i; }
  int get() const { return i_; }
  protected:
  int i_;
};

我们现在可以在微基准测试环境中调用这两个函数,并测量每个调用所需的时间:

void BM_none(benchmark::State& state) {
  A* a = new A;
  int i = 0;
  for (auto _ : state) a->f(++i);
  benchmark::DoNotOptimize(a->get());
  delete a;
}
void BM_dynamic(benchmark::State& state) {
  B* b = new D;
  int i = 0;
  for (auto _ : state) b->f(++i);
  benchmark::DoNotOptimize(b->get());
  delete b;
}

benchmark::DoNotOptimize 包装器阻止编译器优化掉未使用的对象,以及随之移除的整个函数调用集,因为它们被认为是多余的。注意,在测量虚函数调用时间时存在一个细微之处;编写代码的一个更简单的方法是避免使用 newdelete 操作符,而直接在栈上构造派生对象:

void BM_dynamic(benchmark::State& state) {
  D d;
  int i = 0;
  for (auto _ : state) d.f(++i);
  benchmark::DoNotOptimize(b->get());
}

然而,这个基准测试可能产生的结果与非虚函数调用相同。原因不是虚函数调用没有开销。相反,在这个代码中,编译器能够推断出对虚函数的调用,即 f(),总是调用 D::f()(这得益于调用不是通过基类指针,而是通过派生类引用进行的,所以它几乎不可能是其他任何东西)。一个优秀的优化编译器会取消这种调用的虚化,例如,生成一个直接调用 D::f() 的调用,而不需要间接和 v-table 的引用。这样的调用甚至可以被内联。

另一个可能的复杂情况是,这两个微基准测试,尤其是非虚函数调用,可能太快——基准测试循环的主体可能花费的时间少于循环的开销。我们可以通过在循环体内部进行多次调用来解决这个问题。这可以通过编辑器的复制粘贴功能或使用 C++预处理器宏来完成:

#define REPEAT2(x) x x
#define REPEAT4(x) REPEAT2(x) REPEAT2(x)
#define REPEAT8(x) REPEAT4(x) REPEAT4(x)
#define REPEAT16(x) REPEAT8(x) REPEAT8(x)
#define REPEAT32(x) REPEAT16(x) REPEAT16(x)
#define REPEAT(x) REPEAT32(x)

现在,在基准测试循环中,我们可以编写以下代码:

REPEAT(b->f(++i);)

基准测试报告的每次迭代时间现在指的是 32 次函数调用。虽然这并不影响比较两次调用,但可能方便基准测试本身通过在基准测试环境末尾添加此行来报告每秒真正的调用次数:

state.SetItemsProcessed(32*state.iterations());

我们现在可以比较两个基准测试的结果:

Benchmark           Time UserCounters...
BM_none          1.60 ns items_per_second=19.9878G/s
BM_dynamic       9.04 ns items_per_second=3.54089G/s

我们看到虚函数调用几乎是非虚函数调用的 10 倍昂贵。注意,这并不是一个完全公平的比较;虚调用提供了额外的功能。然而,其中一些功能可以通过其他方式实现,而不必付出性能开销。

介绍 CRTP

现在,我们将介绍 CRTP,它颠覆了继承的传统:

template <typename D> class B {
  ...
};
class D : public B<D> {
  ...
};

第一个引人注目的变化是,基类现在是一个class模板。派生类仍然从基类继承,但现在是从基类模板的具体实例化继承——独立地!类B在类D上实例化,而类D则从类B的该实例化继承,这个实例化是在类D上进行的,它又从类B继承,以此类推——这就是递归的作用。习惯它吧,因为在本章中你经常会看到它。

这个令人困惑的模式背后的动机是什么?考虑一下,现在基类有了关于派生类的编译时信息。因此,以前是虚拟函数调用现在可以在编译时绑定到正确的函数:

// Example 01
template <typename D> class B {
  public:
  B() : i_(0) {}
  void f(int i) { static_cast<D*>(this)->f(i); }
  int get() const { return i_; }
  protected:
  int i_;
};
class D : public B<D> {
  public:
  void f(int i) { i_ += i; }
};

调用本身仍然可以在基类指针上进行:

B<D>* b = ...;
b->f(5);

没有间接引用和虚拟调用的开销。编译器可以在编译时跟踪调用,直到实际调用的函数,甚至可以内联它:

void BM_static(benchmark::State& state) {
  B<D>* b = new D;
  int i = 0;
  for (auto _ : state) {
    REPEAT(b->f(++i);)
  }
  benchmark::DoNotOptimize(b->get());
  state.SetItemsProcessed(32*state.iterations());
}

基准测试表明,通过 CRTP 进行的函数调用所需的时间与常规函数调用完全相同:

Benchmark           Time UserCounters...
BM_none          1.60 ns items_per_second=19.9878G/s
BM_dynamic       9.04 ns items_per_second=3.54089G/s
BM_static        1.55 ns items_per_second=20.646G/s

CRTP 的主要限制是基类B的大小不能依赖于其模板参数D。更普遍地说,类B的模板必须实例化,使得类型D是一个不完整类型。例如,以下代码将无法编译:

template <typename D> class B {
  using T = typename D::T;
  T* p_;
};
class D : public B<D> {
  using T = int;
};

这种代码无法编译的认识可能会让人有些惊讶,考虑到它与许多广泛使用的模板非常相似,这些模板引用了它们的模板参数的嵌套类型。例如,考虑以下模板,它将任何具有push_back()pop_back()函数的序列容器转换为栈:

template <typename C> class stack {
  C c_;
  public:
  using value_type = typename C::value_type;
  void push(const valuetype& v) { c.push_back(v); }
  value_type pop() {
    value_type v = c.back();
    c.pop_back();
    return v;
  }
};
stack<std::vector<int>> s;

注意,using类型别名value_type看起来与我们在尝试声明类B时使用的完全相同。那么,B中的那个有什么问题?实际上,类B本身并没有问题。在类似于我们的栈类的上下文中,它完全可以编译:

class A {
  public:
  using T = int;
  T x_;
};
B<A> b; // Compiles with no problems

问题不在于类B本身,而在于我们对其的预期使用:

class D : public B<D> ...

B<D>必须被知道的时候,类型D还没有被声明。它不能被声明——类D的声明需要我们知道基类B<D>的确切内容。所以,如果类D还没有被声明,编译器怎么知道标识的D甚至指的是一个类型呢?毕竟,你不能在完全未知的类型上实例化一个模板。答案就在其中——类D是提前声明的,就像我们有了以下代码一样:

class A;
B<A> b; // Now does not compile

一些模板可以在前置声明的类型上实例化,而另一些则不能。确切规则可以从标准中痛苦地收集到,但精髓是这样的——任何可能影响类大小的元素都必须完全声明。例如,using T = typename D::T中对内部声明的类型的引用,将是一个嵌套类的提前声明,这些也是不允许的。

另一方面,类模板的成员函数的体直到被调用时才会实例化。事实上,对于给定的模板参数,成员函数甚至不需要编译,只要它没有被调用。因此,在基类的成员函数内部对派生类的引用、其嵌套类型及其成员函数的引用是完全正常的。此外,由于派生类类型在基类内部被认为是前向声明的,我们可以声明指向它的指针和引用。以下是一个非常常见的 CRTP 基类重构示例,它将静态转换的使用集中在了一个地方:

template <typename D> class B {
  ...
  void f(int i) { derived()->f(i); }
  D* derived() { return static_cast<D*>(this); }
};
class D : public B<D> {
  ...
  void f(int i) { i_ += i; }
};

基类声明拥有一个指向不完整(前向声明的)类型 D 的指针。它就像任何指向不完整类型的指针一样工作;在指针被解引用之前,类型必须完整。在我们的例子中,这发生在成员函数的体内;B::f(),正如我们讨论的那样,它只有在客户端代码调用它时才会被编译。

那么,如果我们需要在编写基类时使用派生类的嵌套类型,我们应该怎么办?在函数体内,没有问题。如果我们需要在基类本身中使用嵌套类型,通常有两个原因。第一个是声明成员函数的返回类型:

// Example 01a
template <typename D> class B {
  typename D::value_type get() const {
    return static_cast<const D*>(this)->get();
  }
  …
};
D : public B<D> {
  using value_type = int;
  value_type get() const { … };
  …
};

正如我们刚才讨论的,这不会编译。幸运的是,这个问题很容易解决,我们只需要让编译器推断出返回类型:

// Example 01a
template <typename D> class B {
  auto get() const {
    return static_cast<const D*>(this)->get();
  }
  …
};

第二种情况更困难:需要嵌套类型来声明数据成员或参数。在这种情况下,只剩下一种选择:类型应该作为额外的模板参数传递给基类。当然,这会在代码中引入一些冗余,但这是不可避免的:

// Example 01a
template <typename T, typename value_type> class B {
  value_type value_;
  …
};
class D : public B<D, int> {
  using value_type = int;
  value_type get() const { … }
  …
};

现在我们已经知道了 CRTP 是什么以及如何编码它,让我们看看它能解决哪些设计问题。

CRTP 和静态多态

由于 CRTP 允许我们用派生类的函数覆盖基类函数,它实现了多态行为。关键的区别在于多态发生在编译时,而不是运行时。

编译时多态

正如我们刚才看到的,CRTP 可以用来允许派生类自定义基类的行为:

template <typename D> class B {
  public:
  ...
  void f(int i) { static_cast<D*>(this)->f(i); }
  protected:
  int i_;
};
class D : public B<D> {
  public:
  void f(int i) { i_ += i; }
};

如果调用基类 B::f() 方法,它将调用传递给实际派生类的方法,就像虚拟函数一样。当然,为了充分利用这种多态,我们必须能够通过基类指针调用基类的方法。如果没有这种能力,我们只是在调用我们已知类型的派生类的方法:

D* d = ...; // Get an object of type D
d->f(5);
B<D>* b = ...; // Also has to be an object of type D
b->f(5);

注意,函数调用看起来与任何带有基类指针的虚拟函数类完全一样。被调用的实际函数,f(),来自派生类,D::f()。然而,有一个显著的区别——派生类D的实际类型必须在编译时已知——基类指针不是B*而是B<D>*,这意味着派生对象是类型D。如果程序员必须知道实际类型,这种多态似乎没有太多意义。但是,这是因为我们还没有完全想清楚编译时多态真正意味着什么。正如虚拟函数的好处在于我们可以调用我们甚至不知道存在的类型的成员函数一样,静态多态也必须具有同样的好处才能有用。

我们如何编写一个必须为未知类型参数编译的函数?当然,使用函数模板:

// Example 01
template <typename D> void apply(B<D>* b, int& i) {
  b->f(++i);
}

这是一个模板函数,可以在任何基类指针上调用,并且它自动推断派生类D的类型。现在,我们可以编写看起来像常规多态代码的东西:

B<D>* b = new D;    // 1
apply(b);         // 2

注意,在第一行,对象必须使用实际类型的知识来构建。这始终是这种情况;对于具有虚拟函数的常规运行时多态也是如此:

void apply(B* b) { ... }
B* b = new D;    // 1
apply(b);        // 2

在两种情况下,在第二行,我们调用了一些只了解基类知识编写的代码。区别在于,在运行时多态中,我们有一个共同的基类和一些操作它的函数。在 CRTP 和静态多态中,有一个共同的基类模板,但没有单个共同的基类(模板不是类型)并且每个操作这个基类模板的函数本身也成为了一个模板。为了使两种多态类型的对称性(而不是等价性!)完整,我们只需要找出另外两个特殊情况:纯虚拟函数和多态析构。让我们从前者开始。

编译时纯虚拟函数

在 CRTP 场景中,纯虚拟函数的等价物是什么?纯虚拟函数必须在所有派生类中实现。声明纯虚拟函数的类,或者继承了一个纯虚拟函数但没有重写的类,是一个抽象类;它可以进一步派生,但不能实例化。

当我们考虑静态多态中纯虚拟函数的等价物时,我们意识到我们的 CRTP 实现存在一个主要漏洞。如果我们忘记在派生类中重写编译时虚拟函数f(),会发生什么?

// Example 02
template <typename D> class B {
  public:
  ...
  void f(int i) { static_cast<D*>(this)->f(i); }
};
class D : public B<D> {
  // no f() here!
};
...
B<D>* b = ...;
b->f(5); // 1

这段代码编译时没有错误或警告——在第一行,我们调用B::f(),它反过来调用D::f()。类D没有声明自己的f()成员函数版本,所以调用的是从基类继承的版本。也就是说,当然是之前已经见过的成员函数B::f(),它再次调用D::f(),实际上是B::f() ...,我们得到了一个无限循环。

这里的问题是没有要求我们在派生类中覆盖成员函数f(),但如果我们不覆盖,程序就会不完整。问题的根源在于我们将接口和实现混合在一起——基类中的公共成员函数声明表明所有派生类都必须有一个void f(int)函数作为它们公共接口的一部分。派生类中相同函数的版本提供了实际实现。我们将在第十四章“模板方法模式和不可虚拟语法的非虚拟方法”中介绍如何分离接口和实现,但到目前为止,只需说如果这些函数有不同的名字,我们的生活就会容易得多:

// Example 03
template <typename D> class B {
  public:
  ...
  void f(int i) { static_cast<D*>(this)->f_impl(i); }
};
class D : public B<D> {
  void f_impl(int i) { i_ += i; }
};
...
B<D>* b = ...;
b->f(5);

现在如果我们忘记实现D::f_impl()会发生什么?代码无法编译,因为类D中既没有这样的成员函数,也没有通过继承。因此,我们已经实现了一个编译时纯虚函数!请注意,虚拟函数实际上是f_impl(),而不是f()

完成这个任务后,我们该如何实现一个常规的虚函数,它有一个默认实现,可以被可选地覆盖?如果我们遵循分离接口和实现的相同模式,我们只需要提供B::f_impl()的默认实现:

// Example 03
template <typename D> class B {
  public:
  ...
  void f(int i) { static_cast<D*>(this)->f_impl(i); }
  void f_impl(int i) {}
};
class D1 : public B<D1> {
  void f_impl(int i) { i_ += i; }
};
class D2 : public B<D2> {
  // No f() here
};
...
B<D1>* b = ...;
b->f(5); // Calls D1::f_impl()
B<D2>* b1 = ...;
b1->f(5); // Calls B::f_impl() by default

我们需要处理的最后一个特殊情况是多态销毁。

析构函数和多态删除

到目前为止,我们故意避免以某种多态方式处理使用 CRTP 实现的删除对象的问题。实际上,如果你回顾并重新阅读了介绍完整代码的示例,例如在“介绍 CRTP”部分中的基准测试组件BM_static,我们要么完全避免了删除对象,要么在栈上构建了一个派生对象。这是因为多态删除带来了额外的复杂性,我们终于准备好处理它了。

首先,让我们注意,在许多情况下,多态删除并不是一个问题。所有对象都是已知其实际类型的情况下创建的。如果构建对象的代码也拥有并最终删除它们,那么关于“被删除对象的类型是什么?”这个问题就永远不会真正被提出。同样,如果对象存储在容器中,它们不是通过基类指针或引用来删除的:

template <typename D> void apply(B<D>& b) {
  ... operate on b ...
}
{
  std::vector<D> v;
  v.push_back(D(...)); // Objects created as D
  ...
  apply(v[0]); // Objects processed as B&
} // Objects deleted as D

在许多情况下,正如前一个示例所示,对象以其实际类型构建和删除,并且在此过程中没有涉及多态,但在这之间对它们进行操作的代码是通用的,编写为针对基类型工作,因此,任何从该基类型派生的类。

但是,如果我们需要通过基类指针实际删除对象呢?这并不容易。首先,对delete运算符的简单调用将做错事:

B<D>* b = new D;
...
delete b;

这段代码可以编译。更糟糕的是,甚至那些通常在类有虚拟函数但没有虚拟析构函数时发出警告的编译器在这种情况下也不会生成任何警告,因为没有虚拟函数,并且编译器不将 CRTP 多态识别为潜在的问题来源。然而,问题在于只调用了基类析构函数B<D>本身;D的析构函数从未被调用!

你可能会倾向于以处理其他编译时虚拟函数相同的方式解决这个问题,通过转换为已知的派生类型并调用派生类的缩进成员函数:

template <typename D> class B {
  public:
  ~B() { static_cast<D*>(this)->~D(); }
};

与常规函数不同,这种多态尝试严重错误,原因有两个——首先,在基类的析构函数中,实际对象不再是派生类型,对其调用派生类的任何成员函数都会导致未定义行为。其次,即使这 somehow 成功了,派生类的析构函数将执行其工作并调用基类的析构函数,这会导致无限循环。

有两种解决这个问题的方法。一个选择是将编译时多态扩展到与任何其他操作相同的删除操作,使用一个函数模板:

template <typename D> void destroy(B<D>* b) {
  delete static_cast<D*>(b);
}

这是明确定义的。delete运算符被调用在实型的指针上,D,并且调用正确的析构函数。然而,你必须注意始终使用这个destroy()函数来删除这些对象,而不是调用delete运算符。

第二种选择是实际上使析构函数成为虚拟的。这确实会带来虚拟函数调用的开销,但仅限于析构函数。它还会使对象大小增加虚拟指针的大小。如果这两个开销来源都不是问题,你可以使用这种混合静态-动态多态,其中所有虚拟函数调用都在编译时绑定,并且没有开销,除了析构函数之外。

CRTP 和访问控制

在实现 CRTP 类时,你必须担心访问权限——你想调用的任何方法都必须是可访问的。要么方法必须是公共的,要么调用者必须具有特殊访问权限。这与调用虚函数的方式略有不同——在调用虚函数时,调用者必须有权访问在调用中命名的成员函数。例如,调用基类函数 B::f() 要求 B::f() 是公共的,或者调用者有权访问非公共成员函数(类 B 的另一个成员函数可以调用 B::f() 即使它是私有的)。然后,如果 B::f() 是虚函数并被派生类 D 覆盖,那么覆盖 D::f() 实际上是在 D::f() 可从原始调用点访问时调用的;例如,D::f() 可以是私有的。

CRTP 多态调用的情形略有不同。所有调用在代码中都是显式的,调用者必须有权访问他们调用的函数。通常这意味着基类必须有权访问派生类的成员函数。考虑以下来自早期部分的示例,但现在带有显式访问控制:

template <typename D> class B {
  public:
  ...
  void f(int i) { static_cast<D*>(this)->f_impl(i); }
  private:
  void f_impl(int i) {}
};
class D : public B<D> {
  private:
  void f_impl(int i) { i_ += i; }
  friend class B<D>;
};

在这里,两个函数,B::f_impl()D::f_impl(),在各自的类中都是私有的。基类对派生类没有特殊访问权限,无法调用其私有成员函数。除非我们想将成员函数 D::f_impl() 从私有改为公共,并允许任何调用者访问它,否则我们必须声明基类为派生类的友元。

反过来操作也有一些好处。让我们创建一个新的派生类 D1,它有一个不同的实现函数 f_impl() 的覆盖:

class D1 : public B<D> {
  private:
  void f_impl(int i) { i_ -= i; }
  friend class B<D1>;
};

这个类有一个细微的错误——它实际上并没有从 B<D1> 派生,而是从旧类 B<D> 派生;在从一个旧模板创建新类时容易犯的错误。如果我们尝试多态地使用这个类,这个错误就会被发现:

B<D1>* b = new D1;

这无法编译,因为 B<D1> 不是 D1 的基类。然而,并非所有 CRTP 的使用都涉及多态调用。无论如何,如果在类 D1 首次声明时就能捕获这个错误会更好。我们可以通过将类 B 变成一种抽象类来实现这一点,仅从静态多态的角度来看。只需将类 B 的构造函数设为私有,并将派生类声明为友元即可:

template <typename D> class B {
  int i_;
  B() : i_(0) {}
  friend D;
  public:
  void f(int i) { static_cast<D*>(this)->f_impl(i); }
  private:
  void f_impl(int i) {}
};

注意友元声明的形式有些不寻常——friend D 而不是 friend class D。这是为模板参数编写友元声明的方式。现在,唯一可以构造 B<D> 类实例的类型是特定派生类 D,它用作模板参数,而错误的代码 class D1 : public B<D> 现在无法编译。

现在我们知道了 CRTP 的工作原理,让我们看看它有什么用途。

CRTP 作为一种委托模式

到目前为止,我们已将 CRTP 作为动态多态的编译时等价物使用,包括通过基指针进行的类似虚函数的调用(当然,是编译时,通过模板函数)。这并不是 CRTP 可以使用的唯一方式。实际上,更常见的情况是函数直接在派生类上调用。这是一个非常基本的不同点——通常,公有继承表示 is-a 关系——派生对象是一种基对象。接口和泛型代码在基类中,而派生类覆盖了特定的实现。当通过基类指针或引用访问 CRTP 对象时,这种关系仍然成立。这种使用 CRTP 的方式有时也被称为 静态接口

当直接使用派生对象时,情况就完全不同了——基类不再是接口,派生类不仅仅是实现。派生类扩展了基类的接口,基类将一些行为委派给派生类。

扩展接口

让我们考虑几个使用 CRTP 将行为从基类委派到派生类的例子。

第一个例子非常简单——对于任何提供 operator+=() 的类,我们希望自动生成 operator+(),它使用前者:

// Example 04
template <typename D> struct plus_base {
  D operator+(const D& rhs) const {
    D tmp = rhs;
    tmp += static_cast<const D&>(*this);
    return tmp;
  }
};
class D : public plus_base<D> {
  int i_;
  public:
  explicit D(int i) : i_(i) {}
  D& operator+=(const D& rhs) {
    i_ += rhs.i_;
    return *this;
  }
};

任何以这种方式从 plus_base 继承的类都会自动获得一个加法运算符,该运算符保证与提供的增量运算符匹配。你们中的一些人可能会指出,我们在这里声明运算符 + 的方式很奇怪。二元运算符不应该是非成员函数吗?的确,它们通常是这样的。标准中没有规定它们必须是,前面的代码在技术上也是有效的。二元运算符如 ==+ 等通常声明为非成员函数的原因与隐式转换有关——如果我们有一个加法操作 x + y,并且预期的 operator+ 是成员函数,它必须是 x 对象的成员函数。不是任何可以隐式转换为 x 类型的对象,而是 x 本身——这是对 x 的成员函数调用。相比之下,y 对象必须隐式转换为那个成员 operator+ 的参数类型,通常与 x 相同。为了恢复对称性并允许在 + 符号的左右两侧进行隐式转换(如果提供了的话),我们必须将 operator+ 声明为非成员函数。通常,这样的函数需要访问类的私有数据成员,就像之前的例子一样,因此它必须被声明为友元。将所有这些放在一起,我们得到了这个替代实现:

// Example 05
template <typename D> struct plus_base {
  friend D operator+(const D& lhs, const D& rhs) {
    D tmp = lhs;
    tmp += rhs;
    return tmp;
  }
};
class D : public plus_base<D> {
  int i_;
  public:
  explicit D(int i) : i_(i) {}
  D& operator+=(const D& rhs) {
    i_ += rhs.i_;
    return *this;
  }
};

与我们之前看到的 CRTP 的使用相比,这里有一个显著的区别——程序中将使用的对象是类型C,它将永远不会通过plus_base<C>的指针来访问。后者并不是任何事物的完整接口,而是一个利用派生类提供的接口的实现。在这里,CRTP 被用作实现技术,而不是设计模式。然而,两者之间的界限并不总是清晰的:一些实现技术非常强大,以至于它们可以改变设计选择。

一个例子是生成的比较和排序操作。在 C++20 中,设计值类型(或任何其他可比较和排序的类型)的接口的推荐选择是只提供两个运算符,operator==()operator<=>()。编译器将生成其余部分。如果你喜欢这种接口设计方法,并想在 C++的早期版本中使用它,你需要一种实现它的方法。CRTP 为我们提供了一个可能的实现。我们需要一个基础类,它将从派生类的operator==()生成operator!=()。它还将生成所有排序运算符;当然,在 C++20 之前我们不能使用operator<=>(),但我们可以使用任何我们同意的成员函数名称,例如cmp()

template <typename D> struct compare_base {
  friend bool operator!=(const D& lhs, const D& rhs) {
    return !(lhs == rhs); }
  friend bool operator<=(const D& lhs, const D& rhs) {
    return lhs.cmp(rhs) <= 0;
  }
  friend bool operator>=(const D& lhs, const D& rhs) {
    return lhs.cmp(rhs) >= 0;
  }
  friend bool operator< (const D& lhs, const D& rhs) {
    return lhs.cmp(rhs) <  0;
  }
  friend bool operator> (const D& lhs, const D& rhs) {
    return lhs.cmp(rhs) >  0;
  }
};
class D : public compare_base<D> {
  int i_;
  public:
  explicit D(int i) : i_(i) {}
  auto cmp(const D& rhs) const {
    return (i_ < rhs.i_) ? -1 : ((i_ > rhs.i_) ? 1 : 0);
  }
  bool operator==(const D& rhs) const {
    return i_ == rhs.i_;
  }
};

在 CRTP 的文献中可以找到许多这样的例子。随着这些例子,你还可以找到关于 C++20 概念是否提供了更好的替代方案的讨论。下一节将解释这是关于什么的。

CRTP 和概念

第一眼看上去,不清楚概念如何取代 CRTP。概念(你可以在第七章,*SFINAE、概念和重载解析管理)都是关于限制接口的,而 CRTP 扩展了接口。

有一些讨论是由那些概念和 CRTP 都可以通过完全不同的方式解决相同问题的案例引发的。回想一下我们使用 CRTP 从operator+=()自动生成operator+()的情况;我们唯一需要做的就是从特殊的基础类模板继承:

// Example 05
template <typename D> struct plus_base {
  friend D operator+(const D& lhs, const D& rhs) { … }
};
class D : public plus_base<D> {
  D& operator+=(const D& rhs) { … }
};

我们的基础类有两个作用。首先,它从operator+=()生成operator+()。其次,它为类提供了一个选择加入这种自动化的机制:要接收生成的operator+(),一个类必须继承自plus_base

第一个问题本身很容易解决,我们只需定义一个全局的operator+()模板:

template <typename T>
T operator+(const T& lhs, const T& rhs) {
  T tmp = lhs;
  tmp += rhs;
  return tmp;
}

这个模板有一个“轻微”的问题:我们为程序中的每一个类型都提供了一个全局的operator+(),无论它是否需要。此外,大多数时候甚至无法编译,因为并非所有类都定义了operator+=()

这就是概念发挥作用的地方:我们可以限制我们新operator+()的应用范围,最终,它只为与我们原本从plus_base继承的类型生成,而不为其他类型生成。

做这件事的一种方法是要求数据模板参数类型 T 至少要有增量操作符:

template <typename T>
requires( requires(T a, T b) { a += b; } )
T operator+(const T& lhs, const T& rhs) { … }

然而,这并不是我们用 CRTP 得到的相同结果。在某些情况下,它可能是一个更好的结果:我们不需要为每个类选择加入自动生成 operator+(),我们只为满足某些限制的每个类做了这件事。但在其他情况下,任何对这些限制的合理描述都会产生过于宽泛的结果,我们必须逐个选择加入我们的类型。使用概念也可以这样做,但使用的技巧并不广为人知。你所需要做的就是定义一个其通用情况为假的(布尔变量就足够了)概念:

template <typename T>
constexpr inline bool gen_plus = false; // General
template <typename T>
requires gen_plus<T>
T operator+(const T& lhs, const T& rhs) { … }

然后,对于需要选择加入的每种类型,我们专门化这个概念:

class D { // No special base
  D& operator+=(const D& rhs) { … }
};
template <>
constexpr inline bool generate_plus<D> = true; // Opt-in

这两种方法都有一些优点:CRTP 使用一个基类,它可以比仅仅是一个操作符定义的包装更复杂;而概念可以在适当的时候结合显式选择加入和一些更一般的限制。然而,这些讨论忽略了一个更重要的区别:CRTP 可以用来通过成员和非成员函数扩展类接口,而概念只能用于非成员函数,包括非成员操作符。当概念和基于 CRTP 的解决方案都适用时,你应该选择最合适的一个(对于像 operator+() 这样的简单函数,概念可能更容易阅读)。此外,你不必等到 C++20 才能使用基于概念的限制:在 第七章SFINAE、概念和重载解析管理 中展示的模拟概念的技巧在这里是绰绰有余的。

当然,我们可以使用与 CRTP 一起的概念,而不是试图替换 CRTP:如果 CRTP 的基类模板对我们想要从中派生的类型有一些要求,我们可以通过概念来强制执行这些要求。在这里使用概念的方式与我们发现的那一章中的方式没有不同。但我们将继续使用 CRTP 以及它还能做什么。

CRTP 作为一种实现技术

正如我们之前指出的,CRTP 通常被用作一种纯实现模式;然而,即使在这个角色中,它也可以影响设计:一些设计选择是可取的但难以实现,如果出现一种好的实现技术,设计选择通常会改变。因此,让我们看看 CRTP 可以解决哪些问题。

CRTP 用于代码复用

让我们从具体实现问题开始:我们有多达多个具有一些共同代码的类。通常,我们会为它们写一个基类。但共同代码并不真正通用:它为所有类做同样的事情,除了它使用的类型。我们需要的是一个通用基类模板。这把我们带到了 CRTP。

一个例子是对象注册表。出于调试目的,可能需要知道当前存在多少个特定类型的对象,甚至可能需要维护这样一个对象的列表。我们肯定不希望将注册机制应用于每个类,因此我们希望将其移动到基类。但是,现在我们遇到了一个问题——如果我们有两个派生类,CD,它们都从同一个基类 B 继承,那么 B 的实例计数将是 CD 的总和。问题不在于基类无法确定派生类的实际类型——如果愿意承担运行时多态的成本,它是可以确定的。问题在于基类只有一个计数器(或者类中编码的任何数量),而派生类的数量是无限的。我们可以实现一个非常复杂、昂贵且不可移植的解决方案,使用 typeid 来确定类名并维护一个名称和计数器的映射。但,我们真正需要的是每个派生类型一个计数器,而唯一的方法是在编译时让基类知道派生类类型。这又把我们带回了 CRTP:

// Example 08
template <typename D> class registry {
  public:
  static size_t count;
  static D* head;
  D* prev;
  D* next;
  protected:
  registry() {
    ++count;
    prev = nullptr;
    next = head;
    head = static_cast<D*>(this);
    if (next) next->prev = head;
  }
  registry(const registry&) {
    ++count;
    prev = nullptr;
    next = head;
    head = static_cast<D*>(this);
    if (next) next->prev = head;
  }
  ~registry() {
    --count;
    if (prev) prev->next = next;
    if (next) next->prev = prev;
    if (head == this) head = next;
  }
};
template <typename D> size_t registry<D>::count(0);
template <typename D> D* registry<D>::head(nullptr);

我们将构造函数和析构函数声明为受保护的,因为我们不希望除了派生类之外创建任何注册对象。同样重要的是不要忘记复制构造函数,否则编译器会生成默认的复制构造函数,它不会增加计数器或更新列表(但析构函数会,所以计数器会变成负数并溢出)。对于每个派生类 D,基类是 registry<D>,这是一个独立的类型,具有自己的静态数据成员,counthead(后者是当前活动对象列表的头部)。任何需要维护活动对象运行时注册的类型现在只需要从 registry 继承:

// Example 08
class C : public registry<C> {
  int i_;
  public:
  C(int i) : i_(i) {}
};

另一个类似的例子,其中基类需要知道派生类的类型并使用它来声明自己的成员,可以在第九章中找到,命名参数、方法链和构建器模式。接下来,我们将看到另一个 CRTP 的例子,这次实现的可用性为特定的设计选择打开了大门。

泛型接口的 CRTP

另一个经常需要将行为委托给派生类的情况是访问问题。在广义上,访问者是指被调用以处理一组数据对象并对每个对象依次执行函数的对象。通常,存在访问者层次结构,其中派生类定制或改变基类行为的一些方面。虽然访问者的最常见实现使用动态多态和虚函数调用,但静态访问者提供了我们之前看到的相同类型的性能优势。访问者通常不是通过多态调用的;你创建你想要的访问者并运行它。然而,基访问者类会调用在编译时可能被调度到派生类的成员函数,如果它们有正确的覆盖。考虑以下用于动物集合的通用访问者:

// Example 09
struct Animal {
  public:
  enum Type { CAT, DOG, RAT };
  Animal(Type t, const char* n) : type(t), name(n) {}
  const Type type;
  const char* const name;
};
template <typename D> class GenericVisitor {
  public:
  template <typename it> void visit(it from, it to) {
    for (it i = from; i != to; ++i) {
      this->visit(*i);
    }
  }
  private:
  D& derived() { return *static_cast<D*>(this); }
  void visit(const Animal& animal) {
    switch (animal.type) {
      case Animal::CAT:
        derived().visit_cat(animal); break;
      case Animal::DOG:
        derived().visit_dog(animal); break;
      case Animal::RAT:
        derived().visit_rat(animal); break;
    }
  }
  void visit_cat(const Animal& animal) {
    cout << "Feed the cat " << animal.name << endl;
  }
  void visit_dog(const Animal& animal) {
    cout << "Wash the dog " << animal.name << endl;
  }
  void visit_rat(const Animal& animal) {
  cout << "Eeek!" << endl;
}
  friend D;
  GenericVisitor() = default;
};

注意,主要访问方法是一个模板成员函数(一个模板中的模板!),它接受任何可以遍历Animal对象序列的迭代器。此外,通过在类底部声明一个私有默认构造函数,我们保护自己免于在派生类中错误地指定其自己的继承类型。现在,我们可以开始创建一些访问者。默认访问者简单地接受通用访问者提供的默认操作:

class DefaultVisitor :
  public GenericVisitor<DefaultVisitor> {
};

我们可以访问任何Animal对象的序列,例如,一个向量:

std::vector<Animal> animals {
  {Animal::CAT, "Fluffy"},
  {Animal::DOG, "Fido"},
  {Animal::RAT, "Stinky"}};
DefaultVisitor().visit(animals.begin(), animals.end());

访问产生了预期的结果:

Feed the cat Fluffy
Wash the dog Fido
Eeek!

但是,我们不必局限于默认操作——我们可以覆盖一个或多个动物类型的访问操作:

class TrainerVisitor :
  public GenericVisitor<TrainerVisitor> {
  friend class GenericVisitor<TrainerVisitor>;
  void visit_dog(const Animal& animal) {
    cout << "Train the dog " << animal.name << endl;
  }
};
class FelineVisitor :
  public GenericVisitor<FelineVisitor> {
  friend class GenericVisitor<FelineVisitor>;
  void visit_cat(const Animal& animal) {
    cout << "Hiss at the cat " << animal.name << endl;
  }
  void visit_dog(const Animal& animal) {
    cout << "Growl at the dog " << animal.name << endl;
  }
  void visit_rat(const Animal& animal) {
    cout << "Eat the rat " << animal.name << endl;
  }
};

当一名狗训练师选择访问我们的动物时,我们使用TrainerVisitor

Feed the cat Fluffy
Train the dog Fido
Eeek!

最后,一只访问猫将有一套它自己的动作:

Hiss at the cat Fluffy
Growl at the dog Fido
Eat the rat Stinky

我们将在第十七章访问者模式和多重分派中学习更多关于不同类型访问者的知识。然而,现在我们将探索 CRTP 与另一个常见模式结合使用的情况。

CRTP 和基于策略的设计

基于策略的设计是众所周知的策略模式的编译时变体;我们有一个专门章节介绍它,第十五章,恰当地命名为基于策略的设计。在这里,我们将专注于使用 CRTP 为派生类提供额外功能。具体来说,我们将泛化 CRTP 基类的使用,以扩展派生类的接口。

到目前为止,我们已使用一个基类来为派生类添加功能:

template <typename D> struct plus_base {…};
class D : public plus_base<D> {…};

然而,如果我们想以多种方式扩展派生类的接口,单个基类会带来不必要的限制。首先,如果我们添加几个成员函数,基类可能会变得相当大。其次,我们可能希望接口设计有更模块化的方法。例如,我们可以有一个基类模板,它为任何派生类添加工厂构建方法:

// Example 10
template <typename D> struct Factory {
  template <typename... Args>
  static D* create(Args&&... args) {
    return new D(std::forward<Args>(args)...);
  }
  static void destroy(D* d) { delete d; }
};

我们甚至可以有多个不同的工厂,它们提供相同的接口但以不同的方式分配内存。我们可以有一个另一个基类模板,它为任何具有流插入运算符的类添加字符串转换功能:

// Example 10
template <typename D> struct Stringify {
  operator std::string() const {
    std::stringstream S;
    S << *static_cast<const D*>(this);
    return S.str();
  }
};

将这两个结合成一个单一的基类是没有意义的。在一个大型系统中,可能会有更多这样的类,每个类都为派生类添加特定的功能,并使用 CRTP 来实现它。但并非每个派生类都需要这些功能中的每一个。有了多个基类可供选择,很容易构建一个具有特定功能集的派生类:

// Example 10
class C1 : public Stringify<C1>, public Factory<C1> {…};

这方法可行,但如果需要实现几个具有非常相似行为(除了 CRTP 基类提供的特性)的派生类,我们就有重复编写几乎完全相同的代码的风险。例如,如果我们还有一个工厂,它在线程局部内存中构建对象以加快并发程序的性能(让我们称它为TLFactory),我们可能不得不编写如下代码:

class C2 : public Stringify<C2>, public TLFactory<C2> {…};

C1C2这两个类除了基类之外完全相同,然而,按照目前的写法,我们仍然需要实现和维护两份相同的代码。如果能编写一个单一代码模板,并根据需要将其不同的基类插入其中,那就更好了。这就是基于策略设计的主要思想;对此有几种不同的方法,你可以在第十五章基于策略的设计中了解它们。现在,让我们专注于在模板中使用 CRTP 基类。由于我们现在需要一个可以接受多个基类类型的类模板,我们将需要使用变长模板。我们需要类似以下的内容:

template <typename… Policies>
class C : public Policies… {};

基于策略的设计有使用这种确切模板的版本;但在这个例子中,如果我们尝试使用FactoryStringify作为策略,它将无法编译。原因是它们不是类型(类),因此不能用作类型名称。它们是模板,因此我们必须将模板C的模板参数声明为模板本身(这被称为模板模板参数)。如果我们首先回忆一下如何声明单个模板模板参数,语法就更容易理解:

template <template <typename> class B> class C;

如果我们想要从这个类模板B的特定实例继承,我们会写成:

template <template <typename> class B>
class C : public B<template argument> {…};

在使用 CRTP 时,模板参数是派生类本身的类型,C<B>

template <template <typename> class B>
class C : public B<C<B>> {…};

将此推广到参数包是直接的:

// Example 11
template <template <typename> class... Policies>
class C : public Policies<C<Policies...>>... {…};

模板参数是一个包(任何数量的模板而不是单个类)。派生类从整个包 Policies… 继承,除了 Policies 是模板,我们需要指定这些模板的实际实例化。包中的每个模板都在派生类上实例化,其类型为 C<Policies…>

如果我们需要额外的模板参数,例如,为了在类 C 中启用使用不同的值类型,我们可以将它们与策略结合:

// Example 11
template <typename T,
          template <typename> class... Policies>
class C : public Policies<C<T, Policies...>>... {
  T t_;
  public:
  explicit C(T t) : t_(t) {}
  const T& get() const { return t_; }
  friend std::ostream&
  operator<<(std::ostream& out, const C c) {
    out << c.t_;
    return out;
  }
};

要使用这个类与特定的策略集,定义一些别名是方便的:

using X = C<int, Factory, Stringify>;

如果我们想使用具有相同 Policies 的几个类,我们也可以定义一个模板别名:

template <typename T> using Y = C<T, Factory, Stringify>;

我们将在 第十五章 的 “基于策略的设计” 中了解更多关于策略的内容。我们将在那里和其他章节中遇到我们刚刚研究的技巧,CRPT ——它是一个灵活且强大的工具。

摘要

我们已经检查了一个相当复杂的设计模式,它结合了 C++ 的两个方面——泛型编程(模板)和面向对象编程(继承)。正如其名,Curiously Recurring Template Pattern 创建了一个循环,其中派生类从基类继承接口和实现,而基类通过模板参数访问派生类的接口。CRTP 有两种主要的使用模式——真正的静态多态,或 静态接口,其中对象主要作为基类型访问,以及扩展接口,或委托,其中直接访问派生类,但实现使用 CRTP 提供共同功能。后者可以从简单添加一到两个方法到从多个构建块或策略组合派生类接口的复杂任务。

下一章介绍了一个习语,它利用了我们刚刚学到的模式。这个习语也改变了我们按参数顺序传递函数参数的标准方式,并允许我们有顺序无关的命名参数。继续阅读以了解如何!

问题

  1. 虚函数调用有多昂贵,为什么?

  2. 为什么类似的函数调用,在编译时解析,没有这样的性能开销?

  3. 你会如何实现编译时多态函数调用?

  4. 你会如何使用 CRTP 来扩展基类的接口?

  5. 在单个派生类中使用多个 CRTP 基类需要什么?

第九章:命名参数、方法链和构建者模式

在本章中,我们将探讨一个解决非常常见的 C++ 问题的方案:参数过多。不,我们不是在谈论 C++ 程序员之间的争论,比如是否在行尾或下一行的开头放置花括号(我们对此问题没有解决方案)。这是关于 C++ 函数参数过多的问题。如果你长时间维护过大型 C++ 系统,你一定见过这种情况——函数开始时声明简单,随着时间的推移,为了支持新功能,常常会添加额外的参数,这些参数通常是默认值。

本章节将涵盖以下主题:

  • 长函数声明有什么问题?

  • 那么,替代方案是什么呢?

  • 使用命名参数习语的缺点是什么?

  • 如何将命名参数习语进行泛化?

技术要求

这里是示例代码的链接:github.com/PacktPublishing/Hands-On-Design-Patterns-with-CPP-Second-Edition/tree/master/Chapter09

这里是 Google Benchmark 库的链接:github.com/google/benchmark(请参阅第四章从简单到微妙,获取安装说明)。

参数的问题

每个在某个时候参与过足够大的 C++ 系统开发的人都必须向函数添加参数。为了避免破坏现有代码,新参数通常会被赋予一个默认值,这通常保留了旧的功能。第一次这样做效果很好,第二次还可以,然后就必须在每次函数调用时开始计算参数。长函数声明也存在其他问题,如果我们想要更好的解决方案,花时间理解这些问题是值得的。我们在分析问题之后,再继续寻找解决方案。

许多参数有什么问题?

不论是代码一开始就设计了很多参数,还是随着时间的推移“有机地”增长,它都是脆弱的,容易受到程序员错误的侵害。主要问题是通常有很多相同类型的参数,它们可能会被错误地计数。考虑设计一个文明建设游戏——当玩家创建一个新城市时,会构建一个相应的对象。玩家可以选择在城市建设哪些设施,游戏会设置可用的资源选项:

class City {
  public:
  enum center_t { KEEP, PALACE, CITADEL };
  City(size_t number_of_buildings,
       size_t number_of_towers,
       size_t guard_strength,
       center_t center,
       bool with_forge,
       bool with_granary,
       bool has_fresh_water,
       bool is_coastal,
       bool has_forest);
  ...
};

看起来我们已经处理了一切。为了开始游戏,让我们给每个玩家一个带有城堡、瞭望塔、两座建筑和一个卫兵公司的城市:

City Capital(2, 1, 1, City::KEEP,
             false, false, false, false);

你能看出错误吗?幸运的是,编译器可以——参数不足。由于编译器不会让我们在这里犯错误,所以这不算什么大问题,我们只需要为has_forest添加参数即可。此外,假设游戏将城市放置在河边,所以现在它有水了:

City Capital(2, 1, 1, City::KEEP,
             false, true, false, false, false);

这很简单……哎呀!现在我们有了河边的城市,但没有淡水(河里到底有什么?)。至少镇民们不会饿肚子,多亏了他们意外获得的免费粮仓。那个错误——将“真实”值传递给了错误的参数——将在调试过程中被发现。此外,这段代码相当冗长,我们可能会发现自己一遍又一遍地输入相同的值。也许游戏默认尝试在河流和森林附近放置城市?那么好吧:

class City {
  public:
  enum center_t { KEEP, PALACE, CITADEL };
  City(size_t number_of_buildings,
       size_t number_of_towers,
       size_t guard_strength,
       enter_t center,
       bool with_forge,
       bool with_granary,
       bool has_fresh_water = true,
       bool is_coastal = false,
       bool has_forest = true);
  ...
};

现在,让我们回到我们第一次尝试创建城市的尝试——现在它编译了,少了一个参数,但我们并没有意识到我们误算了参数。游戏取得了巨大成功,在下一个更新中,我们得到了一个令人兴奋的新建筑——寺庙!当然,我们需要为构造函数添加一个新参数。在with_granary之后,与其他所有建筑一起,在地形特征之前添加它是有意义的。但然后我们必须编辑对City构造函数的每一个调用。更糟糕的是,由于没有寺庙的false对于程序员和编译器来说看起来与没有淡水的false完全一样,所以很容易出错。新参数必须插入正确的位置,在一长串看起来非常相似的价值中。

当然,现有的游戏代码没有寺庙也能工作,所以它们只在新更新的代码中需要。在不必要的情况下不干扰现有代码是有价值的。如果我们把新参数加在最后,并给它一个默认值,那么任何未更改的构造函数调用仍然会创建与之前完全相同的城市:

class City {
  public:
  enum center_t { KEEP, PALACE, CITADEL };
  City(size_t number_of_buildings,
       size_t number_of_towers,
       size_t guard_strength,
       center_t center,
       bool with_forge,
       bool with_granary,
       bool has_fresh_water = true,
       bool is_coastal = false,
       bool has_forest = true,
       bool with_temple = false);
  ...
};

但现在,我们让短期便利主导我们的长期界面设计。参数不再有任何逻辑分组,从长远来看,错误的可能性更大。此外,我们没有完全解决不需要更改的代码更新问题——下一个版本添加了新的地形,沙漠,随之而来的是另一个参数:

class City {
  public:
  enum center_t { KEEP, PALACE, CITADEL };
  City(size_t number_of_buildings,
       size_t number_of_towers,
       size_t guard_strength,
       center_t center,
       bool with_forge,
       bool with_granary,
       bool is_coastal = false,
       bool has_forest = true,
       bool with_temple = false,
       bool is_desert = false);
  ...
};

一旦开始,我们必须为所有在末尾添加的新参数提供默认值。此外,为了在沙漠中创建一个城市,我们还需要指定它是否有寺庙。没有逻辑上的理由说明为什么它必须是这样,但我们受制于界面演变的过程。当你考虑到我们使用的许多类型可以相互转换时,情况变得更糟:

City Capital(2, 1, false, City::KEEP,
             false, true, false, false, false);

这创建了一个没有守卫公司的城市,而不是程序员在将第三个参数设置为false时预期的任何其他东西。甚至enum类型也不能提供完全的保护。你可能已经注意到,所有新的城市通常都是从城堡开始的,所以将其作为默认值也是有意义的:

// Example 01
class City {
  public:
  enum center_t { KEEP, PALACE, CITADEL };
  City(size_t number_of_buildings,
       size_t number_of_towers,
       size_t guard_strength,
       center_t center = KEEP,
       bool with_forge = false,
       bool with_granary = false,
       bool has_fresh_water = true,
       bool is_coastal = false,
       bool has_forest = true,
       bool with_temple = false,
       bool is_desert = false);
  ...
};

现在,我们不必输入那么多参数,甚至可能避免一些错误(如果你没有写参数,你就不能写错顺序)。但是,我们也可以创建新的参数:

City Capital(2, 1, City::CITADEL);

我们刚刚雇佣的两个守卫公司(因为CITADEL的数值是2)将发现自己在这个低级的堡垒(我们本想改变但未改变)的空间非常紧张。C++11 的enum class提供了更好的保护,因为每个都是不同类型,无需转换为整数,但总体问题仍然存在。正如我们所见,将大量值作为单独的参数传递给 C++函数有两个问题。首先,它创建了非常长的声明和函数调用,容易出错。其次,如果我们需要添加一个值或更改参数的类型,需要编辑大量的代码。这两个问题的解决方案在 C++创建之前就已经存在;它来自 C——使用聚合,即结构体——将许多值组合成一个参数。

聚合参数

使用聚合参数,我们创建一个包含所有值的结构体或类,而不是为每个值添加一个参数。我们不必局限于一个聚合;例如,我们的城市可能需要几个结构体,一个用于游戏设置的所有地形相关特性,另一个用于玩家直接控制的所有特性:

struct city_features_t {
  size_t number_of_buildings = 1;
  size_t number_of_towers = 0;
  size_t guard_strength = 0;
  enum center_t { KEEP, PALACE, CITADEL };
  center_t center = KEEP;
  bool with_forge = false;
  bool with_granary = false;
  bool with_temple = false;
};
struct terrain_features_t {
  bool has_fresh_water = true;
  bool is_coastal = false;
  bool has_forest = true;
  bool is_desert = false;
};
class City {
  public:
  City(city_features_t city_features,
       terrain_features_t terrain_features);
  ...
};

这个解决方案有很多优点。首先,可以通过名称显式地分配值,非常明显(并且非常冗长):

city_features_t city_features;
city_features.number_of_buildings = 2;
city_features.center = city_features::KEEP;
...
terrain_features_t terrain_features;
terrain_features.has_fresh_water = true;
...
City Capital(city_features, terrain_features);

看到每个参数的值要容易得多,错误的可能性也小得多(另一种方法,结构体的聚合初始化,只是将问题从一种初始化移到另一种初始化)。如果我们需要添加一个新特性,大多数情况下我们只需向聚合类型之一添加一个新的数据成员。只有实际处理新参数的代码需要更新;所有只是传递参数并转发它们的函数和类都不需要做任何改变。我们甚至可以为聚合类型提供默认值,为所有参数提供默认值,就像我们在上一个例子中所做的那样。

这总的来说是解决具有许多参数的函数问题的优秀解决方案。然而,它有一个缺点:聚合必须显式创建和初始化,一行一行地来。这在许多情况下都很好,特别是当这些类和结构体代表我们将长期保留的状态变量时。但是,当纯粹用作参数容器时,它们会创建不必要的冗长代码,从聚合变量必须有一个名字的事实开始。我们实际上并不需要这个名字,因为我们只用它一次来调用函数,但我们必须想出一个。使用临时变量可能会很有诱惑力:

struct city_features_t {
  size_t number_of_buildings = 1;
  size_t number_of_towers = 0;
  size_t guard_strength = 0;
  enum center_t { KEEP, PALACE, CITADEL };
  center_t center = KEEP;
  bool with_forge = false;
  bool with_granary = false;
  bool with_temple = false;
};
struct terrain_features_t {
  bool has_fresh_water = true;
  bool is_coastal = false;
  bool has_forest = true;
  bool is_desert = false;
};
City Capital({2, 1, 0, KEEP, true, false, false},
             {true, false, false, true});

这可以工作,但它又把我们带回到了起点;一个具有长列表的易于混淆的布尔参数的函数。我们遇到的基本问题是 C++函数具有位置参数,而我们试图找出一种方法来让我们能够通过名称指定参数。聚合对象主要作为副作用解决了这个问题,并且如果整体设计从将一组值收集到一个类中受益,你当然应该这样做。然而,作为一个专门针对命名参数问题的解决方案,没有其他更持久的理由将值组合在一起,它们就不够用了。我们现在将看到如何解决这个问题。

C++中的命名参数

我们已经看到如何将逻辑上相关的值收集到聚合对象中给我们带来一个副作用;我们可以将这些值传递给函数,并通过名称而不是通过长列表中的顺序来访问它们。关键是逻辑上相关;除了它们碰巧在单个函数调用中一起使用之外,没有其他原因将值聚合在一起会创建不必要的对象,我们宁愿不发明这些名称。我们需要一种方法来创建临时聚合,最好是不需要显式的名称或声明。我们有一个解决方案来解决这个问题,C++中已经存在很长时间了;它只需要从不同角度的新视角,这正是我们现在要做的。

方法链式调用

方法链式调用是一种从 C++借用的技术;它起源于Smalltalk。其主要目的是消除不必要的局部变量。你可能已经使用了方法链式调用,尽管你可能没有意识到。考虑以下你可能多次编写过的代码:

// Example 02
int i, j;
std::cout << i << j;

最后一行调用了插入操作符<<两次。第一次是在操作符左侧的对象上调用,即std::cout。第二次调用是在什么对象上?一般来说,操作符语法只是调用一个名为operator<<()的函数的一种方式。通常,这个特定的操作符是一个非成员函数,但std::ostream类也有几个成员函数重载,其中之一是用于int值的。所以,最后一行实际上是这样的:

// Example 02-
std::cout.operator<<(i).operator<<(j);

第二次调用operator<<()是在第一次调用的结果上进行的。等效的 C++代码如下:

// Example 02
auto& out1 = std::cout.operator(i);
out1.operator<<(j);

这就是方法链式调用——对一个方法函数的调用返回下一个方法应该调用的对象。在std::cout的情况下,成员operator<<()返回对对象本身的引用。顺便说一下,非成员operator<<()做的是同样的事情,只是它没有隐含的参数this,而是将流对象作为显式的第一个参数。现在,我们可以使用方法链式调用消除显式命名的参数对象。

方法链式调用和命名参数

正如我们之前看到的,当聚合参数对象不是主要用于持有参数时,它们工作得很好;如果我们需要一个对象来持有系统的状态,并且我们随着时间的推移构建它并长时间保留它,我们也可以将这个对象作为单个参数传递给任何需要这个状态的函数。我们遇到的问题是只为单个函数调用创建聚合对象。另一方面,我们也不喜欢编写带有许多参数的函数。这尤其适用于通常大多数参数都设置为默认值,只有少数参数发生变化的函数。回到我们的游戏,假设每天的游戏时间都通过一个函数调用进行处理。

该函数在每个游戏日被调用一次,以推进城市的一天,并处理游戏可以生成的各种随机事件的后果:

class City {
  ...
  void day(bool flood = false, bool fire = false,
    bool revolt = false, bool exotic_caravan = false,
    bool holy_vision = false, bool festival = false, ... );
  ...
};

在一段时间内可能会发生很多不同的事件,但很少有一天会同时发生多于一个事件。我们默认将所有参数设置为false,但这并没有真正帮助;这些事件没有特定的顺序,如果节日发生,即使它们仍然等于它们的默认值,也必须指定所有之前的参数。

一个聚合对象非常有帮助,但我们需要创建并命名它:

class City {
  ...
  struct DayEvents {
    bool flood = false;
    bool fire = false;
    ...
  };
  void day(DayEvents events);
  ...
};
City capital(...);
City::DayEvents events;
events.fire = true;
capital.day(events);

我们希望只为City::day()的调用创建一个临时的DayEvents对象,但我们需要一种方法来设置其数据成员。这正是方法链发挥作用的地方:

// Example 03
class City {
  ...
  class DayEvents {
    friend City;
    bool flood = false;
    bool fire = false;
    public:
    DayEvents() = default;
    DayEvents& SetFlood() { flood = true; return *this; }
    DayEvents& SetFire() { fire = true; return *this; }
    ...
  };
  void day(DayEvents events);
  ...
};
City capital(...);
capital.day(City::DayEvents().SetFire());

默认构造函数构建一个未命名的临时对象。在这个对象上,我们调用SetFire()方法。它修改对象并返回对该对象的引用。我们将创建并修改后的临时对象传递给day()函数,该函数处理当天的事件,显示城市被火焰更新的图形,播放火灾的声音,并更新城市的状态以反映一些建筑被火灾损坏。

由于每个Set()方法都返回对同一对象的引用,我们可以在方法链中调用多个方法来指定多个事件。当然,Set()方法也可以接受参数;例如,我们可以有一个方法可以设置事件标志,无论是从默认的false变为true,还是从true变为false

DayEvents& SetFire(bool value = true) {
  fire = value;
  return *this;
}

今天是我们城市的集市日,恰好与一个大型节日重合,所以国王除了已经驻扎在城里的两个卫兵公司外,还雇佣了一个额外的卫兵公司:

City capital(...);
capital.day(City::DayEvents().
            SetMarket().
            SetFestival().
            SetGuard(3));

注意,对于所有没有发生的事件,我们无需指定任何内容。我们现在有了真正的命名参数;当我们调用一个函数时,我们可以按任何顺序、按名称传递参数,并且我们不需要明确提及任何希望保留为默认值的参数。这是 C++的命名参数习语。使用命名参数的调用当然比使用位置参数的调用更冗长;每个参数都必须明确写出名称。这正是练习的目的。另一方面,如果我们有一长串不需要更改的默认参数,我们就会处于优势地位。可能有人会问的一个问题是性能——我们有很多额外的函数调用,构造函数,以及每个命名参数的Set()调用,这肯定要花费一些代价。让我们找出它到底花费了多少代价。

命名参数习语的性能

显然,在命名参数调用中发生的事情更多,因为调用了更多的函数。另一方面,函数调用非常简单,如果它们在头文件中定义,并且整个实现对编译器可见,那么编译器没有理由不内联所有的Set()调用并消除不必要的临时变量。通过良好的优化,我们可能期望命名参数习语和显式命名的聚合对象有相似的性能。

测量单个函数调用性能的适当工具是微基准测试。我们使用 Google 微基准测试库来完成这个目的。虽然基准测试通常写在单个文件中,但如果我们希望我们调用的函数是外部的,而不是内联的,我们需要另一个源文件。另一方面,Set()方法应该肯定被内联,因此它们应该在头文件中定义。第二个源文件应包含我们使用命名或位置参数调用的函数的定义。这两个文件在链接时合并:

$CXX named_args.C named_args_extra.C -g -O4 -I. \
  -Wall -Wextra -Werror -pedantic --std=c++14 \
  -I$GBENCH_DIR/include $GBENCH_DIR/lib/libbenchmark.a \
  -lpthread -lrt -lm -o named_args

我们可以将位置参数、命名参数和聚合参数进行比较。结果将取决于参数的类型和数量。例如,对于一个有四个布尔参数的函数,我们可以比较以下调用:

// Example 04
// Positional arguments:
Positional p(true, false, true, false);
// Named arguments idiom:
Named n(Named::Options().SetA(true).SetC(true));
// Aggregate object:
Aggregate::Options options;
options.a = true;
options.c = true;
Aggregate a(options));

基准测试测量的性能将极大地取决于编译器和控制优化的选项。例如,这些数字是在 GCC12 上使用-O3 收集的:

Benchmark                 Time  UserCounters...
BM_positional_const   0.233 ns  items_per_second=138.898G/s
BM_named_const        0.238 ns  items_per_second=134.969G/s
BM_aggregate_const    0.239 ns  items_per_second=135.323G/s

对于编译器能够内联和优化的显式命名的聚合对象,没有明显的性能损失。命名参数和位置参数的表现相似。请注意,函数调用的性能很大程度上取决于程序同时进行的其他操作,因为参数是在寄存器中传递的,而寄存器的可用性受上下文的影响。

在我们的基准测试中,我们使用了编译时常量作为参数值。这并不罕见,特别是对于指定某些选项的参数——在每次调用点,许多选项将是静态的、不变的(在其他代码中调用相同函数的地方,这些值是不同的,但在这行代码中,许多值在编译时就已经固定)。例如,如果我们有一个特殊的代码分支来处理游戏中的自然灾害,普通分支将始终调用我们的日模拟,将洪水、火灾和其他灾害标志设置为false。但是,同样经常的是,参数是在运行时计算的。这如何影响性能?让我们创建另一个基准测试,其中参数值是从向量中检索的,例如:

// Example 04
std::vector<int> v; // Fill v with random values
size_t i = 0;
// ... Benchmark loop ...
const bool a = v[i++];
const bool b = v[i++];
const bool c = v[i++];
const bool d = v[i++];
if (i == v.size()) i = 0; // Assume v.size() % 4 == 0
Positional p(a, b, c, d); // Positional arguments
Named n(Named::Options().
  SetA(a).SetC(b).SetC(c).SetD(d)); // Named arguments
Aggregate::Options options;
options.a = a;
options.b = b;
options.c = c;
options.d = d;
Aggregate a(options)); // Aggregate object

顺便说一下,以这种方式缩短前面的代码是不明智的:

Positional p(v[i++], v[i++], v[i++], v[i++]);

原因是参数评估的顺序是未定义的,所以哪个i++调用首先执行是任意的。如果i0开始,这个调用最终可能调用Positional(v[0], v[1], v[2], v[3])Positional(v[3], v[2], v[1], v[0])或任何其他排列。

在相同的编译器和硬件上,我们现在得到不同的数字:

Benchmark                 Time  UserCounters...
BM_positional_vars     50.8 ns  items_per_second=630.389M/s
BM_named_vars          49.4 ns  items_per_second=647.577M/s
BM_aggregate_vars      45.8 ns  items_per_second=647.349M/s

从结果中我们可以看到,编译器完全消除了未命名临时对象(或命名聚合)的开销,并为将参数传递到函数的这三种方式生成了性能相似的代码。一般来说,编译器优化的结果难以预测。例如,CLANG 产生的结果显著不同(当大多数参数是编译时常量时,命名参数调用更快,但当它们是运行时值时则较慢)。

基准测试并不倾向于任何特定的参数传递机制。我们可以说,命名参数习语的表现不会比显式命名的聚合对象或等效的位置参数差,至少,如果编译器能够消除未命名的临时对象的话。在某些编译器上,如果函数有很多参数,命名参数可能会更快。如果优化没有发生,调用可能会稍微慢一些。另一方面,在许多情况下,函数调用的性能本身并不关键;例如,我们的城市只有在玩家建造时才会构建,游戏中的几次构建。每日事件在游戏日中只处理一次,这可能会占用几秒钟的真实时间,至少这样玩家可以享受与游戏的互动。另一方面,在性能关键代码中反复调用的函数应该尽可能内联,我们也可以期待在这种情况下参数传递的优化会更好。总的来说,我们可以得出结论,除非特定函数调用的性能对程序性能至关重要,否则不应该担心命名参数的开销。对于性能关键的调用,应该根据具体情况测量性能,并且命名参数可能比位置参数更快。

一般方法链

C++ 中方法链的应用不仅限于参数传递(我们已经在流式 I/O 的形式中看到了另一个应用,尽管它隐藏得很好)。在其他上下文中使用时,考虑一些更通用的方法链形式是有帮助的。

方法链与方法级联

“方法级联”这个术语在 C++ 的上下文中并不常见,而且有很好的理由——C++ 并不支持它。方法级联指的是在同一个对象上调用一系列方法。例如,在支持方法级联的 Dart 中,我们可以写出以下代码:

var opt = Options();
opt.SetA()..SetB();

这段代码首先在 opt 对象上调用 SetA(),然后在该同一对象上调用 SetB()。等效的代码是这样的:

var opt = Options();
opt.SetA()
opt.SetB();

但是等等,我们不是刚刚用 C++ 和我们的选项对象做了同样的事情吗?我们确实做了,但我们忽略了一个重要的区别。在方法链中,下一个方法应用于前一个方法的结果。这是 C++ 中的链式调用:

Options opt;
opt.SetA().SetB();

这个链式调用等同于以下代码:

Options opt;
Options& opt1 = opt.SetA();
Options& opt2 = opt1.SetB();

C++ 没有级联语法,但与级联等效的代码会是这样的:

Options opt;
opt.SetA();
opt.SetB();

但这正是我们之前所做的事情,简短的形式也是一样的:

Options opt;
opt.SetA().SetB();

在这种情况下,C++ 的级联之所以成为可能,是因为这些方法返回的是同一对象的引用。我们仍然可以说,等效的代码是这样的:

Options opt;
Options& opt1 = opt.SetA();
Options& opt2 = opt1.SetB();

并且,这在技术上是真的。但是,由于方法的编写方式,我们还有额外的保证,即optopt1opt2都指向同一个对象。方法级联始终可以通过方法链实现,但它限制了接口,因为所有调用都必须返回对this的引用。这种实现技术有时被称为 C++中有些笨拙的名称*this。更通用的链式调用能做什么呢?让我们看看。

通用方法链

如果链式方法不返回对对象的引用,它应该返回一个新的对象。通常,这个对象是同一类型的,或者至少是来自同一类层次结构的类型,如果方法是多态的。例如,让我们考虑一个实现数据集合的类。它有一个使用谓词(一个可调用对象,一个具有operator()返回truefalse的对象)过滤数据的方法。它还有一个对集合进行排序的方法。这些方法中的每一个都会创建一个新的集合对象,并保持原始对象不变。现在,如果我们想过滤集合中的所有有效数据,并且假设我们有一个is_valid谓词对象,我们可以创建一个有效数据的排序集合:

Collection c;
... store data in the collection ...
Collection valid_c = c.filter(is_valid);
Collection sorted_valid_c = valid_c.sort();

可以使用方法链消除中间对象:

Collection c;
...
Collection sorted_valid_c = c.filter(is_valid).sort();

在阅读最后一节之后,应该清楚这是一个方法链的例子,而且比我们之前看到的更通用——每个方法都返回同一类型的对象,但不是同一个对象。在这个例子中,链式调用和级联调用的区别非常明显——级联调用会对原始集合进行过滤和排序(假设我们决定支持这样的操作)。

类层次结构中的方法链

当应用于类层次结构时,方法链会遇到一个特定的问题;假设我们的sort()方法返回一个排序后的数据集合,它是一个不同类型的对象,SortedCollection,这个对象是从Collection类派生出来的。它之所以是派生类,是因为排序后我们可以支持高效的搜索,因此SortedCollection类有一个基类没有的search()方法。我们仍然可以使用方法链,甚至可以在派生类上调用基类的方法,但这样做会中断链:

// Example 05
class SortedCollection;
class Collection {
  public:
  Collection filter();
  // sort() converts Collection to SortedCollection.
  SortedCollection sort();
};
class SortedCollection : public Collection {
  public:
  SortedCollection search();
  SortedCollection median();
};
SortedCollection Collection::sort() {
  SortedCollection sc;
  ... sort the collection ...
  return sc;
}
Collection c;
auto c1 = c.sort().search().filter.median();

在这个例子中,链式调用工作了一段时间:我们能够对一个 Collection 进行排序,搜索结果,并过滤搜索结果。对 sort() 的调用作用于 Collection 并返回一个 SortedCollection。对 search() 的调用需要一个 SortedCollection,所以它按预期工作。对 filter() 的调用需要一个 Collection;该方法可以在派生类(如 SortedCollection)上调用,但返回的结果仍然是一个 Collection。然后链式调用中断:对 median() 的调用需要一个 SortedCollection,我们确实有,但 filter() 有效地将其转换回 Collection。没有办法告诉 median() 该对象实际上是一个 SortedCollection(除了强制类型转换)。

多态或虚函数在这里没有帮助;首先,我们需要在基类中为 search()median() 定义虚函数,尽管我们并不打算在那里支持这些功能,因为只有派生类支持它们。我们不能声明它们为纯虚函数,因为我们使用 Collection 作为具体类,任何具有纯虚函数的类都是抽象类,因此不能实例化此类对象。我们可以使这些函数在运行时终止,但至少我们已经将编程错误的检测——在未排序的集合中进行搜索——从编译时移动到运行时。更糟糕的是,它甚至没有帮助:

class SortedCollection;
class Collection {
  public:
  Collection filter();
  // Converts Collection to SortedCollection
  SortedCollection sort();
  virtual SortedCollection median();
};
class SortedCollection : public Collection {
  public:
  SortedCollection search();
  SortedCollection median() override;
};
SortedCollection Collection::sort() {
  SortedCollection sc;
  ... sort the collection ...
  return sc;
}
SortedCollection Collection::median() {
  cout << "Collection::median called!!!" << endl;
  abort();
  return {};     // Still need to return something
}
Collection c;
auto c1 = c.sort().search().filter().median();

这行不通,因为 Collection::filter 返回的是对象的一个副本,而不是对其的引用。它返回的对象是基类,Collection。如果在一个 SortedCollection 对象上调用它,它会从派生对象中剥离基类部分并返回。如果你认为将 filter() 也设为虚函数,并在派生类中重写它,可以以重写基类中每个函数为代价解决这个问题,那么你还有另一个惊喜——虚函数必须具有相同的返回类型,除了协变返回类型。对基类和派生类的引用是协变返回类型。而类本身,通过值返回的,则不是。

注意,如果我们返回对象引用,这个问题就不会发生。然而,我们只能返回调用对象的引用;如果我们在一个方法函数体中创建一个新对象并返回对其的引用,那么它将是一个指向临时对象的悬垂引用,该临时对象在函数返回时被删除。结果是未定义的行为(程序很可能会崩溃)。另一方面,如果我们总是返回原始对象的引用,我们最初就不能将其类型从基类更改为派生类。

C++ 解决这个问题的方法涉及使用模板和一个奇特的设计模式。事实上,这个词 curious 甚至出现在它的名字中——奇特重复的模板模式。这本书中有一个关于 CRTP 模式的完整章节。该模式在我们的案例中的应用相对直接——基类需要从其函数返回正确的类型,但无法做到,因为它不知道类型是什么。解决方案——将正确的类型作为模板参数传递给基类。当然,基类必须是一个基类模板才能使这起作用:

template <typename T> class Collection {
  public:
  Collection() {}
  T filter(); // "*this" is really a T, not a Collection
  T sort() {
    T sc; // Create new sorted collection
    ...
    return sc;
  }
};
class SortedCollection :
  public Collection<SortedCollection> {
  public:
  SortedCollection search();
  SortedCollection median();
};
Collection<SortedCollection> c;
auto c1 = c.sort().search().filter().median();

这里的链与我们的初始示例类似:在 Collection 上调用 sort() 返回一个 SortedCollection,然后 search() 应用到 SortedCollection 上并返回另一个 SortedCollection,接着调用 filter()。这一次,基类 Collection 知道对象的真正类型,因为 Collection 本身是在派生对象类型上实例化的模板。因此,filter() 在任何集合上工作,但返回与初始集合相同类型的对象——在我们的例子中,两者都是 SortedCollection 对象。最后,median() 需要一个 SortedCollection 并获取它。

这是一个复杂的解决方案。虽然它有效,但其复杂性表明,当对象类型需要在链的中间改变时,应谨慎使用方法链。这有一个很好的理由——改变对象类型与调用一系列方法在本质上不同。这是一个更重大的事件,可能应该明确表示,并且新对象应该有自己的名称。

既然我们已经知道了方法链是什么,让我们看看它还能在哪些地方有用。

构造者模式

让我们几乎回到本章的开头,再次看看我们是如何向 C++ 函数传递命名参数的。我们不是使用带有许多参数的构造函数,而是选择了一个选项对象,其中每个参数都被明确命名:

City GreensDale(City::Options()
  .SetCenter(City::KEEP)
  .SetBuildings(3)
  .SetGuard(1)
  .SetForge()
);

现在,让我们专注于 Options 对象本身,特别是我们构建它的方式。构造函数不会创建一个完成的对象(这只会把问题从 City 构造函数转移到 Options 构造函数)。相反,我们逐步构建对象。这是一个非常通用设计模式——构造者模式的一个特例。

构造者模式的基本

当我们决定一个对象不能仅通过构造函数独立构建成我们认为的完整状态时,就会使用构造者设计模式。相反,我们编写一个辅助类或构造者类来构建这些对象,并将它们交给程序。

你可能会问的第一个问题是“为什么?”——构造函数不是应该做这个工作吗?可能有几个原因。一个非常常见的原因是我们使用一个更通用的对象来表示一些更具体的数据集。例如,我们想要一个对象来存储斐波那契数字或素数,我们决定使用std::vector来存储它们。现在我们遇到了一个问题:向量具有 STL 提供的任何构造函数,但我们需要确保我们的向量中有正确的数字,而且我们不能编写一个新的构造函数。我们可以创建一个只包含素数的特殊类,但最终我们会得到很多类,它们在构造方式不同后,被以非常相似的方式使用。当我们使用向量处理所有这些数字时,这将是完全足够的。或者,我们可以在任何地方使用向量,并在程序需要时将正确的值写入其中。这也不是一个好主意:我们暴露并重复了大量我们希望封装和重用的底层代码(这就是我们为什么想要为每种数字编写一个构造函数的原因)。

解决方案是建造者模式:计算和存储数字的代码封装在一个建造者类中,但建造者创建的对象是一个通用的向量。例如,这里是一个斐波那契数字(以 1, 1 开始,后续每个数字是前两个数字之和的序列)的建造者:

// Example 08
class FibonacciBuilder {
  using V = std::vector<unsigned long>;
  V cache_ { 1, 1, 2, 3, 5 };
  public:
  V operator()(size_t n) {
    while (cache_.size() < n) {     // Cache new numbers
      cache_.push_back(cache_[cache_.size() - 1] +
                       cache_[cache_.size() - 2]);
    }
    return V{cache_.begin(), cache_.begin() + n};
  }
};

假设我们的程序需要为某些算法(运行时值n可能变化的算法)获取前n个斐波那契数字的序列。我们可能需要这些数字多次,有时比之前更大的n值,有时比之前更小的值。我们只需要询问建造者:

FibonacciBuilder b;
auto fib10 = b(10);

我们可以在程序中某个地方保留已知的值,但这会使程序复杂化,需要额外的跟踪工作。将这项工作移到一个仅用于构建斐波那契数字的类中会更好——一个建造者。缓存斐波那契数字值得吗?可能并不真的值得,但请记住,这是一个简洁的例子:如果我们需要,比如说,素数而不是斐波那契数字,重用已知值将非常有价值(但代码会更长)。

使用构建器的另一个常见原因是构建对象的代码可能过于复杂,不适合构造函数。通常,这会表现为如果我们尝试编写一个构造函数,我们必须传递给构造函数的大量参数。我们在本节开头构建CityOptions参数的方式是这种复杂性的一个简单例子,其中Options对象充当其自己的构建器。构建器最有用的特定情况包括构建过程是条件性的,并且构建一个对象所需的数据在数量和类型上根据某些运行时变量而变化。再次强调,我们的City是这种情况的一个简单例子:没有单个City需要每个构造函数参数,但没有Options及其(简单)构建器,我们就必须为它们中的每一个提供一个参数。

我们为 Fibonacci 向量构建器看到的方法是 C++中 Builder 模式的常见变体;它并不非常令人兴奋,但它是有效的。在本章中,我们将看到一些实现 Builder 的替代方法。第一个方法是对我们构建Options的方式进行了泛化。

流畅构建器

我们构建Options对象的方式是通过方法链式调用。每个方法都朝着构建最终对象迈出小小的一步。这种方法的通用名称是流畅接口。虽然它不仅限于设计构建器,但流畅接口在 C++中主要作为构建复杂对象的一种方式而流行。

流畅构建器依赖于方法链式调用:构建器类的每个成员函数都贡献于正在构建的对象的构建,并返回构建器本身,以便工作可以继续。例如,这里有一个Employee类(可能用于某个工作场所数据库):

// Example 09
class Employee {
  std::string prefix_;
  std::string first_name_;
  std::string middle_name_;
  std::string last_name_;
  std::string suffix_;
  friend class EmployeeBuilder;
  Employee() = default;
  public:
  friend std::ostream& operator<<(std::ostream& out,
                                  const Employee& e);
};

我们稍后还会向这个类添加更多数据,但目前已经有了足够的数据成员,使得单个构造函数难以使用(太多相同类型的参数)。我们可以使用一个带有Options对象的构造函数,但,向前看,我们预计在对象构建过程中需要进行一些计算:我们可能需要验证某些数据,员工记录的其他部分可能是条件性的:两个不能同时设置的字段,一个字段默认值依赖于其他字段等。因此,让我们开始为这个类设计一个构建器。

EmployeeBuilder需要首先构建一个Employee对象,然后提供几个链式方法来设置对象的不同字段,最后将构建好的对象传递出去。可能涉及一些错误检查,或者影响多个字段的更复杂操作,但一个基本的构建器看起来像这样:

// Example 09
class EmployeeBuilder {
  Employee e_;
  public:
  EmployeeBuilder& SetPrefix(std::string_view s) {
    e_.prefix_ = s; return *this;
  }
  EmployeeBuilder& SetFirstName(std::string_view s) {
    e_.first_name_ = s ; return *this;
  }
  EmployeeBuilder& SetMiddleName(std::string_view s) {
    e_.middle_name_ = s; return *this;
  }
  EmployeeBuilder& SetLastName(std::string_view s) {
    e_.last_name_ = s; return *this;
  }
  EmployeeBuilder& SetSuffix(std::string_view s) {
    e_.suffix_ = s; return *this;
  }
  operator Employee() {
    assert(!e_.first_name_.empty() &&
           !e_.last_name_.empty());
    return std::move(e_);
  }
};

在这个过程中,我们必须做出几个设计决策。首先,我们决定将构造函数 Employee::Employee() 设置为私有,这样只有像 EmployeeBuilder 这样的友元才能创建这些对象。这确保了部分初始化或无效的 Employee 对象不会出现在程序中:获取这些对象的唯一方式是通过构建器。这通常是更安全的选择,但有时我们需要能够默认构造对象(例如,在容器中使用或用于许多序列化/反序列化实现)。其次,构建器持有正在构建的对象,直到它可以被移动到调用者那里。这是一个常见的方法,但我们必须小心只使用每个构建器对象一次。我们还可以提供一种重新初始化构建器的方法;这通常在构建器需要执行大量计算,其结果被用于构建多个对象时进行。最后,为了构建一个 Employee 对象,我们首先需要构建一个构建器:

Employee Homer = EmployeeBuilder()
  .SetFirstName("Homer")
  .SetMiddleName("J")
  .SetLastName("Simpson")
;

另一种常见的方法是提供一个静态函数 Employee::create() 来构建一个构建器;在这种情况下,构建器的构造函数被设置为私有,并允许友元访问。

如我们在关于 类层次结构中的方法链 的章节中提到的,链式方法不必都返回同一类的引用。如果我们的 Employee 对象具有内部结构,例如家庭地址、工作地点等单独的子记录,我们也可以对构建器采用更结构化的方法。

这里的目标是设计一个接口,使得客户端代码可以看起来像这样:

// Example 09
Employee Homer = EmployeeBuilder()
  .SetFirstName("Homer")
  .SetMiddleName("J")
  .SetLastName("Simpson")
  .Job()
    .SetTitle("Safety Inspector")
    .SetOffice("Sector 7G")
  .Address()
    .SetHouse("742")
    .SetStreet("Evergreen Terrace")
    .SetCity("Springfield")
  .Awards()
    .Add("Remorseless Eating Machine")
;

要做到这一点,我们需要实现一个具有公共基类的构建器层次结构:

// Example 09
class JobBuilder;
class AwardBuilder;
class AbstractBuilder {
  protected:
  Employee& e_;
  public:
  explicit AbstractBuilder(Employee& e) : e_(e) {}
  operator Employee() {
    assert(!e_.first_name_.empty() &&
           !e_.last_name_.empty());
      return std::move(e_);
  }
  JobBuilder Job();
  AddressBuilder Address();
  AwardBuilder Awards();
};

我们仍然从 EmployeeBuilder 开始,它构建 Employee 对象;其余的构建器持有对其的引用,并且通过在 AbstractBuilder 上调用相应的成员函数,我们可以为同一个 Employee 对象切换到不同类型的构建器。注意,虽然 AbstractBuilder 作为所有其他构建器的基类,但没有纯虚函数(或任何其他虚函数):如我们之前所见,运行时多态在方法链中并不特别有用:

class EmployeeBuilder : public AbstractBuilder {
  Employee employee_;
  public:
  EmployeeBuilder() : AbstractBuilder(employee_) {}
  EmployeeBuilder& SetPrefix(std::string_view s){
    e_.prefix_ = s; return *this;
  }
  …
};

要添加工作信息,我们切换到 JobBuilder

// Example 09
class JobBuilder : public AbstractBuilder {
  public:
  explicit JobBuilder(Employee& e) : AbstractBuilder(e) {}
  JobBuilder& SetTitle(std::string_view s) {
    e_.title_ = s; return *this;
  }
  …
  JobBuilder& SetManager(std::string_view s) {
    e_.manager_ = s; return *this;
  }
  JobBuilder& SetManager(const Employee& manager) {
    e_.manager_ = manager.first_name_ + " " +
                  manager.last_name_;
     return *this;
  }
  JobBuilder& CopyFrom(const Employee& other) {
    e_.manager_ = other.manager_;
    …
    return *this;
  }
};
JobBuilder AbstractBuilder::Job() {
  return JobBuilder(e_);
}

一旦我们有了 JobBuilder,它所有的链式方法都返回对自身的引用;当然,JobBuilder 也是一个 AbstractBuilder,因此我们可以在任何时候切换到另一个构建器类型,例如 AddressBuilder。注意,我们可以仅通过 JobBuilder 的前向声明来声明 AbstractBuilder::Job() 方法,但实现必须推迟到类型本身定义之后。

在这个例子中,我们也看到了 Builder 模式的灵活性,这仅使用构造函数很难实现。例如,有两种方式可以定义一个员工的经理:我们可以提供名字,或者使用另一个员工记录。此外,我们可以从另一个员工的记录中复制工作场所信息,并且仍然可以使用Set方法修改不同的字段。

其他如AddressBuilder之类的 Builder 可能类似。但也可能有非常不同的 Builder。例如,一个员工可以有任意数量的奖项:

// Example 09
class Employee {
  … name, job, address, etc …
  std::vector<std::string> awards_;
};

相应的 Builder 需要反映它添加到对象中的信息的性质:

// Example 09
class AwardBuilder : public AbstractBuilder {
  public:
  explicit AwardBuilder(Employee& e) : AbstractBuilder(e)
  {}
  AwardBuilder& Add(std::string_view award) {
    e_.awards_.emplace_back(award); return *this;
  }
};
AwardBuilder AbstractBuilder::Awards() {
  return AwardBuilder(e_);
}

我们可以多次调用AwardBuilder::Add()来构建特定的Employee对象。

这里是我们的 Builder 在起作用。注意,对于不同的员工,我们可以使用不同的方式来提供所需的信息:

Employee Barry = EmployeeBuilder()
  .SetFirstName("Barnabas")
  .SetLastName("Mackleberry")
;

我们可以使用一个员工记录来将经理的名字添加到另一个员工中:

Employee Homer = EmployeeBuilder()
  .SetFirstName("Homer")
  .SetMiddleName("J")
  .SetLastName("Simpson")
  .Job()
    .SetTitle("Safety Inspector")
    .SetOffice("Sector 7G")
    .SetManager(Barry) // Writes "Barnabas Mackleberry"
  .Address()
    .SetHouse("742")
    .SetStreet("Evergreen Terrace")
    .SetCity("Springfield")
  .Awards()
    .Add("Remorseless Eating Machine")
;

我们可以在员工之间复制就业记录:

Employee Lenny = EmployeeBuilder()
  .SetFirstName("Lenford")
  .SetLastName("Leonard")
  .Job()
    .CopyFrom(Homer)
;

一些字段,如姓名和姓氏,是可选的,Builder 在可以访问之前会检查完成后的记录是否完整(参见上面的AbstractBuilder::operator Employee())。其他字段,如名字后缀,也是可选的:

Employee Smithers = EmployeeBuilder()
  .SetFirstName("Waylon")
  .SetLastName("Smithers")
  .SetSuffix("Jr") // Only when needed!
;

流畅的 Builder 模式是 C++中构建具有许多组件的复杂对象的有力模式,尤其是在对象的某些部分是可选的情况下。然而,对于包含大量高度结构化数据的对象,它可能会变得相当冗长。当然,也有其他选择。

隐式 Builder

我们已经看到过一个例子,其中使用了 Builder 模式而没有专门的 Builder 对象:所有用于命名参数传递的Options对象都充当它们自己的 Builder。我们将看到另一个版本,这个版本特别有趣,因为这里没有显式的 Builder 对象。这种设计特别适合构建嵌套层次数据,例如 XML 文件。我们将演示如何使用它来构建一个(非常简化的)HTML 编写器。

在这个设计中,HTML 记录将被相应的类表示:一个用于<P>标签的类,另一个用于<UL>标签,等等。所有这些类都详细说明了共同的基类HTMLElement

class HTMLElement {
  public:
  const std::string name_;
  const std::string text_;
  const std::vector<HTMLElement> children_;
  HTMLElement(std::string_view name, std::string_view text)
    : name_(name), text_(text) {}
  HTMLElement(std::string_view name, std::string_view text,
              std::vector<HTMLElement>&& children)
    : name_(name), text_(text),
      children_(std::move(children)) {}
  friend std::ostream& operator<<(std::ostream& out,
    const HTMLElement& element);
};

当然,HTML 元素还有很多其他内容,但我们必须保持简单。此外,我们的基元素允许无限嵌套:任何元素都可以有一个子元素向量,每个子元素也可以有子元素,依此类推。因此,元素的打印是递归的:

std::ostream& operator<<(std::ostream& out,
                         const HTMLElement& element) {
  out << "<" << element.name_ << ">\n";
  if (!element.text_.empty())
    out << "  " << element.text_ << "\n";
  for (const auto& e : element.children_) out << e;
  out << "</" << element.name_ << ">" << std::endl;
  return out;
}

注意,为了添加子元素,我们必须以std::vector的形式提供它们,然后这些向量会被移动到HTMLElement对象中。右值引用意味着向量参数将是一个临时值或std::move的结果。然而,我们不会自己将子元素添加到向量中:这是特定元素(如<P><UL>等)的派生类的工作。特定的元素类可以在语法不允许时阻止添加子元素,以及强制对 HTML 元素字段的其他限制。

这些特定类看起来是什么样子?简单的类将看起来像这样:

class HTML : public HTMLElement {
  public:
  HTML() : HTMLElement("html", "") {}
  HTML(std::initializer_list<HTMLElement> children) :
    HTMLElement("html", "", children) {};
};

这个HTML类代表<html>标签。像BodyHeadULOL等这样的类也是以完全相同的方式编写的。代表<P>标签的P类类似,但它不允许嵌套对象,因此它只有一个接受文本参数的构造函数。非常重要的一点是,这些类不添加任何数据成员;它们必须初始化基类HTMLElement对象,而不再初始化其他任何内容。如果你再次查看基类,原因应该很明显:我们存储了一个HTMLElement子对象向量。然而,它们被构造了——无论是作为HTML还是作为UL或其他任何东西——现在它们只是HTMLElement对象。任何额外的数据都将丢失。

你可能还会注意到,HTMLElement构造函数的向量参数是从std::initializer_list参数初始化的。这种转换是由编译器隐式地从构造函数参数列表中完成的:

// Example 10
auto doc = HTML{
  Head{
    Title{"Mary Had a Little Lamb"}
  },
  Body{
    P{"Mary Had a Little Lamb"},
    OL{
      LI{"Its fleece was white as snow"},
      LI{"And everywhere that Mary went"},
      LI{"The lamb was sure to go"}
    }
  }
};

这个语句以调用使用两个参数构造HTML对象的方式开始。它们是HeadBody对象,但它们被编译器转换为HTMLElement并放入std::initializer_list<HTMLElement>中。然后这个列表被用来初始化一个向量,这个向量被移动到HTML对象的children_向量中。HeadBody对象本身也有子对象,其中一个(OL)有自己的子对象。

注意,如果你想要额外的构造函数参数,这会变得有点棘手,因为你不能将常规参数与初始化列表元素混合。这个问题在LI类中就出现了。根据我们到目前为止所学的内容,实现这个类的直接方法如下:

//Example 10
class LI : public HTMLElement {
  public:
  explicit LI(std::string_view text) :
    HTMLElement("li", text) {}
  LI(std::string_view text,
     std::initializer_list<HTMLElement> children) :
    HTMLElement("li", text, children) {}
};

不幸的是,你不能用类似这样的方式调用这个构造函数:

//Example 10
LI{"A",
  UL{
    LI{"B"},
    LI{"C"}
  }
}

显然,程序员想要的第一个参数是"A",第二个参数(以及如果有,任何更多的参数)应该放入初始化列表中。但这行不通:通常,为了形成一个初始化列表,我们必须将元素序列放在大括号{…}中。只有当整个参数列表与初始化列表匹配时,这些大括号才能省略。对于不是初始化列表一部分的某些参数,我们必须明确指出:

//Example 10
LI{"A",
  {UL{        // Notice { before UL!
    LI{"B"},
    LI{"C"}
  }}            // And closing } here
}

如果你不想写额外的花括号,你必须稍微改变构造函数:

//Example 11
class LI : public HTMLElement {
  public:
  explicit LI(std::string_view text) :
    HTMLElement("li", text) {}
  template <typename ... Children>
  LI(std::string_view text, const Children&... children) :
    HTMLElement("li", text,
           std::initializer_list<HTMLElement>{children...})
  {}
};

我们不是使用初始化列表参数,而是使用参数包并将其显式转换为初始化列表(然后将其转换为向量)。当然,参数包将接受任意类型和任意数量的参数,而不仅仅是HTMLElement及其派生类,但转换到初始化列表将失败。如果你想遵循任何未失败实例化的模板都不应在其主体中产生编译错误的实践,你必须将参数包中的类型限制为从HTMLElement派生的类。这可以通过使用 C++20 概念轻松完成:

// Example 12
class LI : public HTMLElement {
  public:
  explicit LI(std::string_view text) :
    HTMLElement("li", text) {}
  LI(std::string_view text,
     const std::derived_from<HTMLElement>
     auto& ... children) :
    HTMLElement("li", text,
           std::initializer_list<HTMLElement>{children...})
  {}
};

如果你没有使用 C++20 但仍想限制参数类型,你应该阅读本书的第七章SFINAE、概念和重载解析管理

使用参数包作为构建初始化列表的中间件,我们可以避免额外的花括号,并像这样编写我们的 HTML 文档:

// Examples 11, 12
auto doc = HTML{
  Head{
    Title{"Mary Had a Little Lamb"}
  },
  Body{
    P{"Mary Had a Little Lamb"},
    OL{
      LI{"Its fleece was white as snow"},
      LI{"And everywhere that Mary went"},
      LI{"The lamb was sure to go"}
    },
    UL{
      LI{"It followed her to school one day"},
      LI{"Which was against the rules",
        UL{
          LI{"It made the children laugh and play"},
          LI{"To see a lamb at school"}
        }
      },
      LI{"And so the teacher turned it out"}
    }
  }
};

这确实看起来是构建器模式的应用:尽管最终的构建是通过调用HTMLElement构造函数的单个调用完成的,但该构造函数只是将已经构建的子元素向量移动到其最终位置。实际的构建是按照所有构建器都会做的步骤进行的。但是构建器对象在哪里?没有,不是明确存在的。构建器功能的一部分是由所有派生对象提供的,例如HTMLUL等。它们可能看起来像代表相应的 HTML 结构,但事实并非如此:在构建整个文档之后,我们只有HTMLElement对象。派生对象仅用于构建文档。其余的构建器代码是由编译器在执行参数包、初始化列表和向量之间的所有隐式转换时生成的。顺便说一句,任何半不错的优化编译器都会去掉所有中间副本,并将输入字符串直接复制到最终向量中,这些字符串将存储在那里。

当然,这是一个非常简化的例子,但在任何实际应用中,我们都需要使用 HTML 元素存储更多的数据,并为程序员提供一种初始化这些数据的方法。我们可以将隐式构建方法与流畅接口相结合,为所有HTMLElement对象提供一个简单的方法来添加可选值,例如样式、类型等。

到目前为止,你已经看到了三种不同的构建器设计:有一个“传统”的构建器,它有一个单一的构建器对象;有一个使用方法链的流畅构建器;还有一个使用许多小的构建器辅助对象并大量依赖编译器生成代码的隐式构建器。还有其他的设计,但它们大多是你已经学过的方法的变体和组合。我们对构建器模式的研究已经接近尾声。

摘要

再次,我们看到了 C++从现有语言中基本创建新语言的能力;C++没有命名函数参数,只有位置参数。这是核心语言的一部分。然而,我们能够以合理的方式扩展语言并添加对命名参数的支持,使用方法链技术。我们还探讨了方法链在命名参数习语之外的其它应用。

这些应用之一,流畅构建器,再次是创建新语言的练习:流畅接口的普遍力量在于它可以用来创建特定领域的领域特定语言,以执行某些数据上的指令序列。因此,流畅构建器可以用来允许程序员以特定领域熟悉的步骤序列来描述对象的构建。当然,还有隐式构建器,它(通过适当的缩进)甚至让 C++代码看起来有点像正在构建的 HTML 文档。

下一章介绍了本书中唯一的纯粹面向性能的习语。我们在几个章节中讨论了内存分配的性能成本及其对几个模式实现的影响。下一个习语,局部缓冲区优化,直接攻击问题,通过完全避免内存分配来解决问题。

问题

  1. 为什么具有许多相同或相关类型参数的函数会导致脆弱的代码?

  2. 如何通过聚合参数对象提高代码的可维护性和健壮性?

  3. 命名参数习语是什么,它与聚合参数有何不同?

  4. 方法链和级联有什么区别?

  5. 建造者模式是什么?

  6. 什么是流畅式接口,它在什么情况下被使用?

第十章:本地缓冲区优化

并非所有设计模式都关注于设计类层次结构。对于常见问题,软件设计模式是最通用和可重用的解决方案,而对于使用 C++ 的程序员来说,最常见的问题之一是性能不足。这种糟糕性能的最常见原因是不高效的内存管理。模式是为了解决这些问题而开发的。在本章中,我们将探讨一种特别针对小型、频繁内存分配开销的模式。

本章将涵盖以下主题:

  • 小型内存分配的开销是什么,如何进行测量?

  • 本地缓冲区优化是什么,它如何提高性能,以及如何测量这些改进?

  • 在什么情况下可以有效地使用本地缓冲区优化模式?

  • 使用本地缓冲区优化模式可能有哪些潜在缺点和限制?

技术要求

你需要安装和配置 Google Benchmark 库,详细信息可以在以下链接找到:github.com/google/benchmark(参见 第四章交换 – 从简单到微妙,有关安装说明)。

示例代码可以在以下链接找到:github.com/PacktPublishing/Hands-On-Design-Patterns-with-CPP-Second-Edition/tree/main/Chapter10

小型内存分配的开销

本地缓冲区优化仅仅是优化。它是一个面向性能的模式,因此我们必须牢记性能的第一规则——永远不要对性能做出任何猜测。性能,以及任何优化的影响,都必须进行测量。

内存分配的成本

由于我们正在探索内存分配的开销及其降低方法,我们必须回答的第一个问题是内存分配有多昂贵。毕竟,没有人想优化一个如此快速以至于不需要优化的东西。我们可以使用 Google Benchmark(或任何其他微基准测试,如果你更喜欢)来回答这个问题。测量内存分配成本的最简单基准可能看起来像这样:

void BM_malloc(benchmark::State& state) {
  for (auto _ : state) {
    void* p = malloc(64);
    benchmark::DoNotOptimize(p);
  }
  state.SetItemsProcessed(state.iterations());
}
BENCHMARK(BM_malloc_free);

benchmark::DoNotOptimize 包装器阻止编译器优化掉未使用的变量。唉,这个实验可能不会有一个好结果;微基准测试库需要多次运行测试,通常是数百万次,以积累足够准确的平均运行时间。在基准测试完成之前,机器很可能耗尽内存。修复方法是足够的简单,我们必须释放我们分配的内存:

// Example 01
void BM_malloc_free(benchmark::State& state) {
  const size_t S = state.range(0);
  for (auto _ : state) {
    void* p = malloc(S);
    benchmark::DoNotOptimize(p); free(p);
  }
  state.SetItemsProcessed(state.iterations());
}
BENCHMARK(BM_malloc_free)->Arg(64);

我们必须注意,我们现在测量的是分配和释放的成本,这反映在函数名称的改变上。这种改变并不不合理;任何分配的内存最终都需要释放,因此成本必须在某个时候支付。我们还改变了基准测试,使其由分配大小参数化。如果你运行这个基准测试,你应该得到类似以下的结果:

Benchmark                 Time   Items per second
BM_malloc_free/64        19.2 ns 52.2041M/s

这告诉我们,在这个特定的机器上,分配和释放64字节内存的成本大约是19纳秒,这意味着每秒可以完成 5200 万次分配/释放。如果你对64字节的大小是否以某种方式特别感兴趣,你可以改变基准中参数的尺寸值,或者为一系列尺寸运行基准测试:

void BM_malloc_free(benchmark::State& state) {
  const size_t S = state.range(0);
  for (auto _ : state) {
    void* p = malloc(S);
    benchmark::DoNotOptimize(p); free(p);
  }
  state.SetItemsProcessed(state.iterations());
}
BENCHMARK(BM_malloc_free)->
  RangeMultiplier(2)->Range(32,   256);

你可能还会注意到,到目前为止,我们只测量了程序中第一次内存分配所需的时间,因为我们还没有分配其他任何东西。C++运行时系统可能在程序启动时进行了一些动态分配,但这仍然不是一个非常现实的基准。我们可以通过重新分配一些内存来使测量更加相关:

// Example 02
#define REPEAT2(x) x x
#define REPEAT4(x) REPEAT2(x) REPEAT2(x)
#define REPEAT8(x) REPEAT4(x) REPEAT4(x)
#define REPEAT16(x) REPEAT8(x) REPEAT8(x)
#define REPEAT32(x) REPEAT16(x) REPEAT16(x)
#define REPEAT(x) REPEAT32(x)
void BM_malloc_free(benchmark::State& state) {
  const size_t S = state.range(0);
  const size_t N = state.range(1);
  std::vector<void*> v(N);
  for (size_t i = 0; i < N; ++i) v[i] = malloc(S);
  for (auto _ : state) {
    REPEAT({
      void* p = malloc(S);
      benchmark::DoNotOptimize(p);
      free(p);
    });
  }
  state.SetItemsProcessed(32*state.iterations());
  for (size_t i = 0; i < N; ++i) free(v[i]);
}
BENCHMARK(BM_malloc_free)->
  RangeMultiplier(2)->Ranges({{32, 256}, {1<<15, 1<<15}});

在这里,我们在开始基准测试之前调用malloc N次。通过在重新分配期间改变分配大小,我们可以实现进一步的改进。我们还使用 C 预处理器宏将基准循环的主体复制了32次,以减少循环本身在测量中的开销。基准测试报告的时间现在是进行32次分配和释放所需的时间,这不太方便,但分配率仍然是有效的,因为我们已经考虑了循环展开,并在设置处理项目数量时将迭代次数乘以32(在 Google Benchmark 中,项目是你想要它成为的任何东西,每秒的项目数量在基准测试结束时报告,因此我们声明一次分配/释放为一个项目)。

即使经过所有这些修改和改进,最终结果也将非常接近我们最初的每秒54百万次分配的测量值。这似乎非常快,只有18纳秒。然而,请记住,现代 CPU 可以在这么短的时间内执行数十条指令。由于我们处理的是小分配,因此每个分配的内存片段的处理时间也很小,分配的开销也不可忽视。这当然是对性能的猜测,也是我之前警告过你的,因此我们将通过直接实验来验证这一说法。

然而,首先我想向你展示另一个原因,为什么小内存分配特别低效。到目前为止,我们只探索了在单个线程上内存分配的成本。如今,大多数有性能要求的程序都是并发的,C++支持并发和多线程。让我们看看当我们在多个线程上同时进行内存分配时,成本是如何变化的:

// Example 03
void BM_malloc_free(benchmark::State& state) {
  const size_t S = state.range(0);
  const size_t N = state.range(1);
  std::vector<void*> v(N);
  for (size_t i = 0; i < N; ++i) v[i] = malloc(S);
  for (auto _ : state) {
    REPEAT({
      void* p = malloc(S);
      benchmark::DoNotOptimize(p);
      free(p);
    });
  }
  state.SetItemsProcessed(32*state.iterations());
  for (size_t i = 0; i < N; ++i) free(v[i]);
}
BENCHMARK(BM_malloc_free)->
  RangeMultiplier(2)->Ranges({{32, 256}, {1<<15, 1<<15}})
  ->ThreadRange(1, 2);

结果很大程度上取决于硬件和系统使用的malloc版本。此外,在拥有许多 CPU 的大机器上,你可以有超过两个线程。

尽管如此,整体趋势应该看起来像这样:

Benchmark                          Time   Items per second
BM_malloc_free/32/32768/threads:1  778 ns 41.1468M/s
BM_malloc_free/32/32768/threads:2  657 ns 24.3749M/s
BM_malloc_free/32/32768/threads:4  328 ns 24.3854M/s
BM_malloc_free/32/32768/threads:8  242 ns 16.5146M/s

这相当令人沮丧;当我们从单个线程增加到两个线程(在更大的机器上,类似的增加将会发生,但可能涉及超过两个线程)时,分配的成本增加了几倍。系统内存分配器似乎成为了有效并发的祸害。有更好的分配器可以用来替换默认的malloc()分配器,但它们也有自己的缺点。此外,如果我们的 C++程序不依赖于特定、非标准的系统库替换以获得性能,那就更好了。我们需要一种更好的内存分配方式。让我们来看看。

引入局部缓冲区优化

程序完成特定任务所需做的最少工作就是什么都不做。免费的东西很棒。同样,分配和释放内存最快的方式就是——不做。局部缓冲区优化是一种不劳而获的方式;在这种情况下,不增加额外的计算成本就能获得一些内存。

主要思想

要理解局部缓冲区优化,你必须记住内存分配并不是孤立发生的。通常情况下,如果需要少量内存,分配的内存会被用作某些数据结构的一部分。例如,让我们考虑一个非常简单的字符串:

// Example 04
class simple_string {
  public:
  simple_string() = default;
  explicit simple_string(const char* s) : s_(strdup(s)) {}
  simple_string(const simple_string& s)
    : s_(strdup(s.s_)) {}
  simple_string& operator=(const char* s) {
    free(s_);
    s_ = strdup(s);
    return *this;
  }
  simple_string& operator=(const simple_string& s) {
    if (this == &s) return *this;
    free(s_);
    s_ = strdup(s.s_);
    return *this;
  }
  bool operator==(const simple_string& rhs) const {
    return strcmp(s_, rhs.s_) == 0;
  }
  ~simple_string() { free(s_); }
  private:
  char* s_ = nullptr;
};

字符串通过strdup()调用从malloc()分配内存,并通过调用free()返回它。为了在任何程度上有用,字符串需要更多的成员函数,但就现在而言,这些已经足够探索内存分配的开销了。说到分配,每次字符串被构造、复制或赋值时,都会发生分配。更准确地说,每次字符串被构造时,都会发生额外的分配;字符串对象本身必须被分配到某个地方,这可能是在栈上的局部变量,或者如果字符串是某些动态分配的数据结构的一部分,则是在堆上。此外,还会为字符串数据发生分配,内存总是从malloc()中获取。

这就是局部缓冲区优化的想法——我们为什么不将字符串对象做得更大,以便它可以包含自己的数据呢?这实际上真的是不劳而获;字符串对象的内存无论如何都需要分配,但我们可以得到额外的字符串数据内存,而无需额外成本。当然,字符串可以任意长,所以我们事先不知道需要将字符串对象做得多大才能存储程序可能遇到的任何字符串。即使我们知道,总是分配那么大的对象,即使是对于非常短的字符串,也会造成巨大的内存浪费。

然而,我们可以观察到——字符串越长,处理它所需的时间就越长(复制、搜索、转换或我们需要对其进行的任何操作)。

对于非常长的字符串,分配的成本与处理成本相比将非常小。另一方面,对于短字符串,分配的成本可能会很大。因此,通过在对象本身存储短字符串,而将任何太长无法放入对象的字符串存储在分配的内存中,我们可以获得最大的性能提升。简而言之,这就是局部缓冲区优化,对于字符串来说也被称为短字符串优化;对象(字符串)包含一个特定大小的本地缓冲区,任何适合该缓冲区的字符串都直接存储在对象内部:

// Example 04
class small_string {
  public:
  small_string() = default;
  explicit small_string(const char* s) :
    s_((strlen(s) + 1 < sizeof(buf_)) ? strcpy(buf_, s)
                                      : strdup(s)) {}
  small_string(const small_string& s) :
    s_((s.s_ == s.buf_) ? strcpy(buf_, s.buf_)
                        : strdup(s.s_)) {}
  small_string& operator=(const char* s) {
    if (s_ != buf_) free(s_);
    s_ = (strlen(s) + 1 < sizeof(buf_)) ? strcpy(buf_, s)
                                        : strdup(s);
    return *this;
  }
  small_string& operator=(const small_string& s) {
    if (this == &s) return *this;
    if (s_ != buf_) free(s_);
    s_ = (s.s_ == s.buf_) ? strcpy(buf_, s.buf_)
                          : strdup(s.s_);
    return *this;
  }
  bool operator==(const small_string& rhs) const {
    return strcmp(s_, rhs.s_) == 0;
  }
  ~small_string() {
    if (s_ != buf_) free(s_);
  }
  private:
  char* s_ = nullptr;
  char buf_[16];
};

在前面的代码示例中,缓冲区大小被静态设置为16个字符,包括用于终止字符串的空字符。任何长度超过16的字符串都将从malloc()分配。在分配或销毁字符串对象时,我们必须检查是否进行了分配或使用了内部缓冲区,以便适当地释放字符串使用的内存。

局部缓冲区优化的效果

small_stringsimple_string相比快多少?这当然取决于你需要用它做什么。让我们从仅仅创建和删除字符串开始。为了避免两次输入相同的基准代码,我们可以使用模板基准,如下所示:

// Example 04
template <typename T>
void BM_string_create_short(benchmark::State& state) {
  const char* s = "Simple string";
  for (auto _ : state) {
    REPEAT({
      T S(s);
      benchmark::DoNotOptimize(S);
    })
  }
  state.SetItemsProcessed(32*state.iterations());
}
BENCHMARK_TEMPLATE1(BM_string_create_short, simple_string);
BENCHMARK_TEMPLATE1(BM_string_create_short, small_string);

结果相当令人印象深刻:

Benchmark                                Time Items per sec
BM_string_create_short<simple_string>     835 ns 38.34M/s
BM_string_create_short<small_string>     18.7 ns 1.71658G/s

当我们在多个线程上尝试相同的测试时,情况甚至更好:

Benchmark                                Time Items per sec
BM_create<simple_string>/threads:2        743 ns 21.5644M/s
BM_create<simple_string>/threads:4        435 ns 18.4288M/s
BM_create<small_string>/threads:2        9.34 ns 1.71508G/s
BM_create<small_string>/threads:4        4.77 ns 1.67998G/s

在两个线程上,常规字符串创建稍微快一些,但创建短字符串几乎正好快两倍(在四个线程上再次快两倍)。当然,这几乎是短字符串优化的最佳情况——首先是因为我们做的只是创建和删除字符串,这正是我们优化的部分,其次是因为字符串是局部变量,其内存作为栈帧的一部分分配,因此没有额外的分配成本。

然而,这并不是一个不合理的情况;毕竟,局部变量并不罕见,如果字符串是某个大型数据结构的一部分,那么该结构的分配成本无论如何都必须支付,因此同时分配其他任何东西而无需额外成本实际上是免费的。

尽管如此,我们不太可能只分配字符串然后立即释放它们,因此我们应该考虑其他操作的成本。只要它们保持较短,我们可以期望复制或分配字符串会有类似的改进:

template <typename T>
void BM_string_copy_short(benchmark::State& state) {
  const T s("Simple string");
  for (auto _ : state) {
    REPEAT({
      T S(s);
      benchmark::DoNotOptimize(S);
    })
  }
  state.SetItemsProcessed(32*state.iterations());
}
template <typename T>
void BM_string_assign_short(benchmark::State& state) {
  const T s("Simple string");
  T S;
  for (auto _ : state) {
    REPEAT({ benchmark::DoNotOptimize(S = s); })
  }
  state.SetItemsProcessed(32*state.iterations());
}
BENCHMARK_TEMPLATE1(BM_string_copy_short, simple_string);
BENCHMARK_TEMPLATE1(BM_string_copy_short, small_string);
BENCHMARK_TEMPLATE1(BM_string_assign_short, simple_string);
BENCHMARK_TEMPLATE1(BM_string_assign_short, small_string);

事实上,也观察到了类似的戏剧性的性能提升:

Benchmark                                Time Items per sec
BM_string_copy_short<simple_string>       786 ns 40.725M/s
BM_string_copy_short<small_string>       53.5 ns 598.847M/s
BM_string_assign_short<simple_string>     770 ns 41.5977M/s
BM_string_assign_short<small_string>     46.9 ns 683.182M/s

我们还可能需要至少读取字符串中的数据一次,以比较它们或搜索特定的字符串或字符,或者计算一些派生值。当然,我们并不期望这些操作会有类似的规模改进,因为它们都不涉及任何分配或释放。那么,为什么我们仍然期望有任何改进呢?

事实上,一个简单的字符串比较测试,例如,显示两个字符串版本之间没有差异。为了看到任何好处,我们必须创建许多字符串对象并将它们进行比较:

template <typename T>
void BM_string_compare_short(benchmark::State& state) {
  const size_t N = state.range(0);
  const T s("Simple string");
  std::vector<T> v1, v2;
  ... populate the vectors with strings ...
  for (auto _ : state) {
    for (size_t i = 0; i < N; ++i) {
      benchmark::DoNotOptimize(v1[i] == v2[i]);
    }
  }
  state.SetItemsProcessed(N*state.iterations());
}
BENCHMARK_TEMPLATE1(BM_string_compare_short,
                    simple_string)->Arg(1<<22);
BENCHMARK_TEMPLATE1(BM_string_compare_short,
                    small_string)->Arg(1<<22);

对于N值较小的情况(字符串的总数较少),优化不会带来任何显著的好处。但是,当我们必须处理许多字符串时,使用小字符串优化比较字符串可以大约快两倍:

Benchmark                                Time Items per sec
BM_compare<simple_string>/4194304    30230749 ns 138.855M/s
BM_compare<small_string>/4194304     15062582 ns 278.684M/s

如果没有任何分配,为什么会发生这种情况?这个实验显示了局部缓冲区优化的第二个、非常重要的好处——提高了缓存局部性。在读取字符串数据之前,必须访问字符串对象本身;它包含数据的指针。对于常规字符串,访问字符串字符涉及两次不同的、通常无关的地址的内存访问。如果数据总量很大,那么第二次访问,即访问字符串数据,很可能会错过缓存并等待数据从主内存中传输过来。另一方面,优化的字符串将数据保持得靠近字符串对象,因此一旦字符串本身在缓存中,数据也在缓存中。我们需要足够多的不同字符串来看到这种好处的原因是,当字符串很少时,所有字符串对象及其数据可以永久地驻留在缓存中。只有当字符串的总大小超过缓存大小时,性能好处才会显现出来。现在,让我们更深入地探讨一些额外的优化。

额外的优化

我们实现的small_string类存在一个明显的低效之处——当字符串存储在本地缓冲区时,我们实际上并不需要数据的指针。我们确切地知道数据在哪里,就在本地缓冲区中。我们确实需要以某种方式知道数据是存储在本地缓冲区还是外部分配的内存中,但仅仅为了存储这一点,我们并不需要使用 8 个字节(在 64 位机器上)。当然,我们仍然需要指针来存储较长的字符串,但当我们处理短字符串时,我们可以重复使用那段内存:

// Example 05
class small_string {
  ...
  private:
  union {
    char* s_;
    struct {
      char buf[15];
      char tag;
    } b_;
  };
};

在这里,我们使用最后一个字节作为tag来指示字符串是存储在本地(tag == 0)还是单独的分配中(tag == 1)。请注意,总缓冲区大小仍然是16个字符,15个用于字符串本身,1个用于 tag,如果字符串需要所有16个字节,这个 tag 也会变成尾随的零(这就是为什么我们必须使用tag == 0来指示本地存储,否则会多浪费一个字节)。指针覆盖在字符缓冲区的第一个8个字节上。在这个例子中,我们选择优化字符串占用的总内存;这个字符串仍然有 16 个字符的本地缓冲区,就像之前的版本一样,但对象本身现在只有 16 个字节,而不是 24 个。如果我们愿意保持对象大小不变,我们可以使用更大的缓冲区并本地存储更长的字符串。一般来说,随着字符串变长,小字符串优化的好处会逐渐减少。从本地到远程字符串的最佳转换点取决于特定的应用程序,并且当然必须通过基准测试来确定。

超越字符串的本地缓冲区优化

本地缓冲区优化可以有效地用于比短字符串更多的情况。实际上,任何需要运行时确定大小的动态小分配时,都应该考虑这种优化。在本节中,我们将考虑几个这样的数据结构。

小向量

另一个非常常见的、经常从本地缓冲区优化中受益的数据结构是向量。向量本质上是由指定类型的数据元素组成的动态连续数组(在这个意义上,字符串是字节的向量,尽管空终止符赋予了字符串其特定的特性)。一个基本的向量,如 C++标准库中找到的std::vector,需要两个数据成员,一个数据指针和一个数据大小:

// Example 06
class simple_vector {
  public:
  simple_vector() = default;
  simple_vector(std::initializer_list<int> il) :
    n_(il.size()),
    p_(static_cast<int*>(malloc(sizeof(int)*n_)))
  {
    int* p = p_;
    for (auto x : il) *p++ = x;
  }
  ~simple_vector() { free(p_); }
  size_t size() const { return n_; }
  private:
  size_t n_ = 0;
  int* p_ = nullptr;
};

向量通常是模板,就像标准std::vector一样,但我们将这个例子简化了,以展示一个整数向量(将这个向量类转换为模板留给你作为练习,并且不会以任何方式改变本地缓冲区优化模式的应用)。只要足够小,我们就可以应用小向量优化并将向量数据存储在向量对象的主体中:

// Example 06
class small_vector {
  public:
  small_vector() = default;
  small_vector(std::initializer_list<int> il) :
    n_(il.size()), p_((n_ < sizeof(buf_)/sizeof(buf_[0]))
      ? buf_ : static_cast<int*>(malloc(sizeof(int)*n_)))
  {
    int* p = p_;
    for (auto x : il) *p++ = x;
  }
  ~small_vector() {
    if (p_ != buf_) free(p_);
  }
  private:
  size_t n_ = nullptr;
  int* p_ = nullptr;
  int buf_[16];
};

我们可以用类似字符串的方法进一步优化向量,并将局部缓冲区与指针叠加。我们不能像之前那样使用最后一个字节作为 tag,因为向量的任何元素都可以有任意值,而零值在一般情况下并不特殊。然而,我们无论如何都需要存储向量的大小,因此我们可以随时用它来确定是否使用了局部缓冲区。我们可以进一步利用这样一个事实,即如果使用局部缓冲区优化,向量的大小不可能非常大,所以我们不需要 size_t 类型的字段来存储它:

// Example 07
class small_vector {
  public:
  small_vector() = default;
  small_vector(std::initializer_list<int> il) {
    int* p;
    if (il.size() < sizeof(short_.buf)/
                    sizeof(short_.buf[0])) {
      short_.n = il.size();
      p = short_.buf;
    } else {
      short_.n = UCHAR_MAX;
      long_.n = il.size();
      p = long_.p = static_cast<int*>(
        malloc(sizeof(int)*long_.n));
    }
    for (auto x : il) *p++ = x;
  }
  ~small_vector() {
    if (short_.n == UCHAR_MAX) free(long_.p);
  }
  private:
  union {
    struct {
      int buf[15];
      unsigned char n;
    } short_ = { {}, '\0' };
    struct {
      size_t n;
      int* p;
    } long_;
  };
};

在这里,我们根据是否使用局部缓冲区来存储向量大小,要么在 size_t long_.n 中,要么在 unsigned char short_.n 中。远程缓冲区通过在短大小中存储 UCHAR_MAX(即 255)来表示。由于这个值大于局部缓冲区的大小,这个 tag 是明确的(如果局部缓冲区增加到可以存储超过 255 个元素,那么 short_.n 的类型就需要更改为更长的整数)。

我们可以使用与字符串相同的基准测试来衡量小向量优化的性能提升。根据向量的实际大小,在创建和复制向量时可以期望大约 10 倍的性能提升,如果基准测试在多线程上运行,则提升更多。当然,当它们存储少量动态分配的数据时,其他数据结构也可以以类似的方式优化。这些数据结构的优化在本质上相似,但有一个值得注意的变体我们应该强调。

小队列

我们刚才看到的小向量使用局部缓冲区来存储向量元素的小数组。这是在元素数量经常很少时优化存储可变数量元素的数据结构的标准方式。对于基于队列的数据结构,这种优化有一个特定的版本,其中缓冲区在一端增长,在另一端消耗。如果队列在任何时候只有少数几个元素,则可以使用局部缓冲区来优化队列。这里常用的技术是 buffer[N],因此,当元素被添加到队列的末尾时,我们将达到数组的末尾。到那时,一些元素已经被从队列中取出,所以数组的前几个元素不再使用。当我们到达数组的末尾时,下一个入队的值将进入数组的第一个元素,buffer[0]。数组被当作环形处理,在 buffer[N-1] 元素之后是 buffer[0] 元素(因此这种技术的另一个名字是 环形缓冲区)。

环形缓冲区技术通常用于队列和其他数据结构,在这些数据结构中,数据被多次添加和移除,而在任何给定时间存储的数据总量是有限的。下面是环形缓冲区队列的一种可能的实现:

// Example 08
class small_queue {
  public:
  bool push(int i) {
    if (front_ - tail_ > buf_size_) return false;
    buf_[(++front_) & (buf_size_ - 1)] = i;
    return true;
  }
  int front() const {
    return buf_[tail_ & (buf_size_ - 1)];
  }
  void pop() { ++tail_; }
  size_t size() const { return front_ - tail_; }
  bool empty() const { return front_ == tail_; }
  private:
  static constexpr size_t buf_size_ = 16;
  static_assert((buf_size_ & (buf_size_ - 1)) == 0,
                "Buffer size must be a power of 2");
  int buf_[buf_size_];
  size_t front_ = 0;
  size_t tail_ = 0;
};

在这个例子中,我们只支持局部缓冲区;如果队列必须保留的元素数量超过了缓冲区的大小,push()调用将返回false。我们本可以切换到堆分配的数组,就像我们在Example 07中为small_vector所做的那样。

在这个实现中,我们无限制地增加front_tail_索引,但当这些值用作局部缓冲区的索引时,我们取索引值对缓冲区大小的模。值得注意的是,当处理循环缓冲区时,这种优化非常常见:缓冲区的大小是 2 的幂(由 assert 强制)。这允许我们用更快的位运算来替换一般的(并且较慢的)模运算,例如front_ % buf_size_。我们也不必担心整数溢出:即使我们调用push()pop()超过2⁶⁴次,无符号整数索引值将溢出并回到零,队列仍然可以正常工作。

如预期的那样,具有局部缓冲区优化的队列远远优于一般的队列,例如std::queue<int>(当然,只要优化仍然有效且队列中的元素数量较少,当然是这样):

Benchmark                         Time   items_per_second
BM_queue<std::queue<int>>       472 ns          67.787M/s
BM_queue<small_queue>           100 ns         319.857M/s

循环局部缓冲区可以非常有效地用于许多需要处理大量数据但一次只保留少量元素的情况。可能的应用包括网络和 I/O 缓冲区、在并发程序中交换线程间数据的管道等。

让我们现在看看局部缓冲区优化在常见数据结构之外的用途。

类型擦除和可调用对象

另有一种非常不同的应用类型,其中可以使用局部缓冲优化来非常有效地存储可调用对象,这些对象可以作为函数调用。许多模板类提供了一种使用可调用对象自定义其行为一部分的选项。例如,std::shared_ptr是 C++中的标准共享指针,允许用户指定一个自定义的deleter。这个deleter将使用要删除的对象的地址被调用,因此它是一个具有void*类型一个参数的可调用对象。它可以是一个函数指针、成员函数指针或函数对象(定义了operator()的对象)——任何可以在p指针上调用的类型;也就是说,任何可以在callable(p)函数调用语法中编译的类型都可以使用。然而,deleter不仅仅是一个类型;它是一个对象,并在运行时指定,因此需要存储在一个共享指针可以访问到它的位置。如果deleter是共享指针类型的一部分,我们可以在共享指针对象中声明该类型的数据成员(或者在 C++共享指针的情况下,在其引用对象中声明,该引用对象在所有共享指针副本之间共享)。你可以将其视为局部缓冲优化的简单应用,如下面的智能指针,当指针超出作用域时自动删除对象(就像std::unique_ptr一样):

// Example 09
template <typename T, typename Deleter> class smartptr {
  public:
  smartptr(T* p, Deleter d) : p_(p), d_(d) {}
  ~smartptr() { d_(p_); }
  T* operator->() { return p_; }
  const T* operator->() const { return p_; } private:
  T* p_;
  Deleter d_;
};

然而,我们追求的是更有趣的事情,当我们处理类型擦除对象时,我们可以找到这样一件事。这类对象的细节在专门讨论类型擦除的章节中已经讨论过,但简而言之,它们是可调用不是类型本身的一部分(也就是说,它被从包含对象的类型中擦除)。可调用存储在一个多态对象中,并通过一个虚函数在运行时调用正确类型的对象。多态对象反过来又通过基类指针进行操作。

现在,我们面临一个问题,从某种意义上讲,与前面的小向量类似——我们需要存储一些数据,在我们的例子中是可调用对象,其类型和因此的大小不是静态已知的。一般的解决方案是动态分配这样的对象,并通过基类指针访问它们。在智能指针deleter的情况下,我们可以这样做:

// Example 09
template <typename T> class smartptr_te {
  struct deleter_base {
    virtual void apply(void*) = 0;
    virtual ~deleter_base() {}
  };
  template <typename Deleter>
  struct deleter : public deleter_base {
    deleter(Deleter d) : d_(d) {}
    void apply(void* p) override {
      d_(static_cast<T*>(p));
    }
    Deleter d_;
  };
  public:
  template <typename Deleter>
  smartptr_te(T* p, Deleter d) : p_(p),
    d_(new deleter<Deleter>(d)) {}
  ~smartptr_te() {
    d_->apply(p_);
    delete d_;
  }
  T* operator->() { return p_; }
  const T* operator->() const { return p_; }
  private:
  T* p_;
  deleter_base* d_;
};

注意,Deleter类型不再是智能指针类型的一部分;它已经被擦除。对于相同的T对象类型,所有智能指针都有相同的类型,smartptr_te<T>(在这里,te代表类型擦除)。然而,我们必须为此语法便利付出高昂的代价——每次创建智能指针时,都会进行额外的内存分配。高昂到什么程度?我们必须再次记住性能的第一规则——高昂只是一个猜测,直到通过实验得到证实,如下面的基准测试:

// Example 09
struct deleter {    // Very simple deleter for operator new
  template <typename T> void operator()(T* p) { delete p; }
};
void BM_smartptr(benchmark::State& state) {
  deleter d;
  for (auto _ : state) {
    smartptr<int, deleter> p(new int, d);
  }
  state.SetItemsProcessed(state.iterations());
}
void BM_smartptr_te(benchmark::State& state) {
  deleter d;
  for (auto _ : state) {
    smartptr_te<int> p(new int, d);
  }
  state.SetItemsProcessed(state.iterations());
}
BENCHMARK(BM_smartptr);
BENCHMARK(BM_smartptr_te);
BENCHMARK_MAIN();

对于具有静态定义的删除器的智能指针,我们可以预期每次迭代的成本与之前测量的malloc()free()的成本非常相似:

Benchmark                  Time Items per second
BM_smartptr             21.0 ns 47.5732M/s
BM_smartptr_te          44.2 ns 22.6608M/s

对于类型擦除智能指针,有两个分配而不是一个,因此创建指针对象所需的时间加倍。顺便说一下,我们还可以测量原始指针的性能,它应该与智能指针在测量精度内相同(这实际上是一个针对std::unique_ptr标准的明确设计目标)。

我们可以在这里应用相同的局部缓冲区优化理念,并且它可能比字符串中的效果还要好;毕竟,大多数可调用对象都很小。然而,我们并不能完全依赖这一点,必须处理大于局部缓冲区的可调用对象的情况:

// Example 09
template <typename T> class smartptr_te_lb {
  struct deleter_base {
    virtual void apply(void*) = 0;
    virtual ~deleter_base() {}
  };
  template <typename Deleter>
    struct deleter : public deleter_base {
    deleter(Deleter d) : d_(d) {}
    void apply(void* p) override {
      d_(static_cast<T*>(p));
    }
    Deleter d_;
  };
  public:
  template <typename Deleter>
    smartptr_te_lb(T* p, Deleter d) : p_(p),
      d_((sizeof(Deleter) > sizeof(buf_))
         ? new deleter<Deleter>(d)
         : new (buf_) deleter<Deleter>(d)) {}
  ~smartptr_te_lb() {
    d_->apply(p_);
    if ((void*)(d_) == (void*)(buf_)) {
      d_->~deleter_base();
    } else {
      delete d_;
    }
  }
  T* operator->() { return p_; }
  const T* operator->() const { return p_; }
  private:
  T* p_;
  deleter_base* d_;
  char buf_[16];
};

使用之前的相同基准测试,我们可以测量具有局部缓冲区优化的类型擦除智能指针的性能:

Benchmark                  Time Items per second
BM_smartptr             21.0 ns 47.5732M/s
BM_smartptr_te          44.2 ns 22.6608M/s
BM_smartptr_te_lb       22.3 ns 44.8747M/s

虽然没有类型擦除的智能指针的构建和删除需要 21 纳秒,而有类型擦除的需要 44 纳秒,但优化后的类型擦除共享指针测试在同一台机器上只需要 22 纳秒。轻微的额外开销来自检查deleter是存储在本地还是远程。

标准库中的局部缓冲区优化

我们应该注意,局部缓冲区优化的最后一种应用,为类型擦除对象存储可调用对象,在 C++标准模板库中被广泛使用。例如,std::shared_ptr有一个类型擦除删除器,并且大多数实现都使用局部缓冲区优化;当然,删除器是与引用对象一起存储,而不是与共享指针的每个副本一起存储。另一方面,std::unique_pointer标准根本不进行类型擦除,以避免任何小的开销,或者如果删除器不适合局部缓冲区,可能是一个更大的开销。

C++标准库中的“终极”类型擦除对象std::function通常也使用局部缓冲区来存储小型可调用对象,而不需要额外的分配开销。任何类型的通用容器对象std::any(自 C++17 起)在可能的情况下也通常不进行动态分配。

局部缓冲区优化的详细说明

我们已经看到了局部缓冲区优化的应用;为了简单起见,我们坚持最基本实现。这种简单实现遗漏了几个重要细节,我们现在将突出显示。

首先,我们完全忽略了缓冲区的对齐。我们用来在对象内部预留空间的类型是 char;因此,我们的缓冲区是字节对齐的。大多数数据类型有更高的对齐要求:确切的要求是平台特定的,但大多数内置类型都对其自身大小进行对齐(在 64 位平台如 x86 上,double 是 8 字节对齐的)。对于一些特定于机器的类型,如用于 AVX 指令的打包整数或浮点数组,需要更高的对齐。

对齐很重要:根据处理器和编译器生成的代码,如果访问的数据类型未按要求对齐,可能会导致性能下降或内存访问违规(崩溃)。例如,大多数 AVX 指令需要 16 或 32 字节的对齐,而这些指令的非对齐版本要慢得多。另一个例子是原子操作,如用于互斥锁和其他并发数据结构的操作:如果数据类型没有正确对齐,它们也无法工作(例如,原子 long 必须对齐在 8 字节边界上)。

指定缓冲区的最小对齐要求并不困难,至少如果我们知道我们想在缓冲区中存储的类型。例如,如果我们有一个任意类型 T 的小向量,我们可以简单地写出:

template <typename T>
class small_vector {
  alignas(T) char buffer_[buffer_size_];
  …
};

如果缓冲区用于存储几种类型之一的对象,我们必须使用所有可能类型中的最高对齐。最后,如果要存储的对象类型未知——这是类型擦除实现的典型情况——我们必须选择一个“足够高”的对齐,并在缓冲区中构造特定对象的位置添加编译时检查。

需要记住的第二个重要细节是如何定义缓冲区。通常,它是一个字符(或 std::byte_t)的对齐数组。在前一节中,我们使用 int 数组来表示小整数向量。同样,这里也有一个细节:将缓冲区声明为对象或正确类型的对象数组,当包含缓冲区的对象被销毁时,这些对象将自动被销毁。对于像整数这样的简单可销毁类型,这根本无关紧要——它们的析构函数什么也不做。

通常情况下并非如此,只有当在此位置构造了对象时,才会调用任意析构函数。对于我们的小型向量,这并不总是成立:向量可能为空或包含的对象少于缓冲区能容纳的数量。这可能是最常见的情况:通常,如果我们采用本地缓冲区优化,我们无法确定对象是否在缓冲区中构造。在这种情况下,将缓冲区声明为具有非平凡析构对象的数组将会是一个错误。然而,如果你有保证,在你的特定情况下,缓冲区总是包含一个对象(或多个对象,对于数组),使用相应的类型声明将大大简化析构函数的实现,以及复制/移动操作。

到现在为止,你应该已经注意到,一个典型的本地缓冲区的实现需要大量的模板代码。到处都是 reinterpret_cast 转换,你必须记得添加对齐,还有一些编译时检查你应该始终添加,以确保只有合适的类型存储在缓冲区中,等等。将这些细节组合成一个单一的可重用实现是很好的。不幸的是,正如通常情况下那样,重用性和复杂性之间存在矛盾,所以我们只能满足于几个可重用的通用实现。

如果我们将关于本地缓冲区所学的所有内容综合起来,我们可以得出如下结论:

// Example 10
template<size_t S, size_t A = alignof(void*)>
struct Buffer {
  constexpr static auto size = S, alignment = A;
  alignas(alignment) char space_[size];
  …
};

这里我们有一个任意大小和对齐(两者都是模板参数)的缓冲区。现在我们有了存储对象的空间,我们必须确保我们想要擦除的类型适合这个空间。为此,添加一个 constexpr 验证函数是方便的(它仅在编译时语法检查中使用):

template<size_t S, size_t A = alignof(void*)>
struct Buffer {
  template <typename T> static constexpr bool valid_type()
  {
    return sizeof(T) <= S && (A % alignof(T)) == 0;
  }
  …
};

缓冲区可以通过调用成员函数 as<T>() 来使用,仿佛它包含了一个类型为 T 的对象:

template<size_t S, size_t A = alignof(void*)>
struct Buffer {
  …
  template <typename T> requires(valid_type<T>())
    T* as() noexcept {
    return reinterpret_cast<T*>(&space_);
  }
  template <typename T> requires(valid_type<T>())
    const T* as() const noexcept {
    return const_cast<Buffer*>(this)->as<T>();
  }
};

缓冲区可以构造为空(默认构造)或带有立即构造的对象。在前一种情况下,对象可以在稍后放置。无论哪种方式,我们都验证类型是否适合缓冲区并满足对齐要求(如果 C++20 和概念不可用,可以使用 SFINAE)。默认构造函数是平凡的,但放置构造函数和 emplace() 方法对类型和构造函数参数有约束:

template<size_t S, size_t A = alignof(void*)>
struct Buffer {
  …
  Buffer() = default;
  template <typename T, typename... Args>
    requires(valid_type<T>() &&
             std::constructible_from<T, Args...>)
  Buffer(std::in_place_type_t<T>, Args&& ...args)
    noexcept(std::is_nothrow_constructible_v<T, Args...>)
  {
    ::new (static_cast<void*>(as<T>()))
      T(std::forward<Args>(args)...);
  }
  template<typename T, typename... Args>
    requires(valid_type<T>() &&
             std::constructible_from<T, Args...>)
  T* emplace(Args&& ...args)
    noexcept(std::is_nothrow_constructible_v<T, Args...>)
  {
    return ::new (static_cast<void*>(as<T>()))
      T(std::forward<Args>(args)...);
  }
};

注意,我们确实检查了请求的类型是否可以存储在缓冲区中,但在运行时没有进行检查以确保缓冲区确实包含这样的对象。这种检查可以通过增加额外的空间和运行时计算来实现,并且可能作为调试工具是有意义的。我们对于复制、移动或删除缓冲区没有做任何特殊处理。目前,这个实现适用于简单可复制的和简单可破坏的对象。在这种情况下,当在缓冲区中构造对象时(在构造函数和 emplace() 方法中),我们希望断言这些限制:

template<size_t S, size_t A = alignof(void*)>
struct Buffer {
  template <typename T>
  Buffer(std::in_place_type_t<T>, Args&& ...args) … {
    static_assert(std::is_trivially_destructible_v<T>, "");
    static_assert(std::is_trivially_copyable_v<T>, "");
    …
  }
};

在这种情况下,给类添加一个 swap() 方法也是有意义的:

template<size_t S, size_t A = alignof(void*)>
struct Buffer {
  …
  void swap(Buffer& that) noexcept {
    alignas(alignment) char tmp[size];
    ::memcpy(tmp, this->space_, size);
    ::memcpy(this->space_, that.space_, size);
    ::memcpy(that.space_, tmp, size);
  }
};

另一方面,如果我们使用这个缓冲区来存储单个已知类型的对象,并且该类型不是简单可破坏的,我们就会一直写类似这样的代码:

buffer_.as<T>()->~T();

我们可以通过添加另一个通用的方法来简化客户端代码:

template<size_t S, size_t A = alignof(void*)>
struct Buffer {
  …
  template <typename T> void destroy() {
    this->as<T>()->~T();
  }
};

我们可以添加类似的方法来复制和移动存储在缓冲区中的对象,或者让客户端来处理:

我们的一般本地缓冲区实现适用于所有简单可复制的和可破坏的类型,以及所有已知类型的情况,其中客户端处理存储在缓冲区中的对象的复制和销毁。有一个特殊情况被省略了,但仍然值得考虑:当在类型擦除类中使用本地缓冲区时,存储(擦除)的类型可能需要非简单复制或删除,但客户端无法执行这些操作,因为类型擦除的整个目的就是客户端代码在对象放入缓冲区后不知道擦除的类型。在这种情况下,我们需要在存储类型时捕获该类型,并生成相应的复制、移动和删除操作。换句话说,我们必须将我们的本地缓冲区与我们在第六章中学习的技术结合起来,理解类型擦除。在这种情况下,最合适的类型擦除变体是 vtable —— 我们使用模板生成的函数指针表。vtable 本身是一个包含将执行删除、复制或移动的函数指针的聚合(struct):

// Example 11
template<size_t S, size_t A = alignof(void*)>
struct Buffer {
  …
  struct vtable_t {
    using deleter_t = void(Buffer*);
    using copy_construct_t = void(Buffer*, const Buffer*);
    using move_construct_t = void(Buffer*, Buffer*);
    deleter_t*  deleter_;
    copy_construct_t* copy_construct_;
    move_construct_t* move_construct_;
  };
  const vtable_t* vtable_ = nullptr;
};

我们需要一个类成员 vtable_ 来存储对 vtable 的指针。当然,我们将要指向的对象需要由构造函数或 emplace() 方法创建——这是唯一我们知道实际类型以及如何删除或复制它的时候。但是,我们不会为它进行动态内存分配。相反,我们创建一个静态模板变量,并用指向静态成员函数(也是模板)的指针来初始化它。编译器会为我们在缓冲区中存储的每个类型创建这个静态变量的实例。当然,我们还需要静态模板函数(一个指向静态成员函数的指针与一个常规函数指针相同,而不是成员函数指针)。这些函数由编译器使用存储在缓冲区中的对象的相同类型 T 实例化:

template<size_t S, size_t A = alignof(void*)>
struct Buffer {
  …
  template <typename U, typename T>
  constexpr static vtable_t vtable = {
    U::template deleter<T>,
    U::template copy_construct<T>,
    U::template move_construct<T>
  };
  template <typename T>
    requires(valid_type<T>() &&
    std::is_nothrow_destructible_v<T>)
  static void deleter(Buffer* space) {
    space->as<T>()->~T();
  }
  template <typename T>
    requires(valid_type<T>())
  static void copy_construct(Buffer* to,
                             const Buffer* from)
    noexcept(std::is_nothrow_copy_constructible_v<T>)
  {
    ::new (static_cast<void*>(to->as<T>()))
      T(*from->as<T>());
    to->vtable_ = from->vtable_;
  }
  template <typename T>
    requires(valid_type<T>())
    static void move_construct(Buffer* to, Buffer* from)
    noexcept(std::is_nothrow_move_constructible_v<T>)
  {
    ::new (static_cast<void*>(to->as<T>()))
      T(std::move(*from->as<T>()));
    to->vtable_ = from->vtable_;
  }
};

第六章 理解类型擦除所示,我们首先使用模板静态函数为任何类型 T 生成复制、移动和删除操作。我们将这些函数的指针存储在一个静态模板变量 vtable 的实例中,并将该实例的指针存储在一个(非静态)数据成员 vtable_ 中。后者是我们唯一的成本,从大小上来说(其余的是编译器为存储在缓冲区中的每个类型生成一次的静态变量和函数)。

这个 vtable_ 必须在对象放入缓冲区时初始化,因为这是我们最后一次明确知道存储对象的类型:

// Example 11
template<size_t S, size_t A = alignof(void*)>
struct Buffer {
  template <typename T, typename... Args>
    requires(valid_type<T>() &&
    std::constructible_from<T, Args...>)
  Buffer(std::in_place_type_t<T>, Args&& ...args)
    noexcept(std::is_nothrow_constructible_v<T, Args...>)
    : vtable_(&vtable<Buffer, T>)
  {
    ::new (static_cast<void*>(as<T>()))
      T(std::forward<Args>(args)...);
  }
  template<typename T, typename... Args>
    requires(valid_type<T>() &&
    std::constructible_from<T, Args...>)
  T* emplace(Args&& ...args)
    noexcept(std::is_nothrow_constructible_v<T, Args...>)
  {
    if (this->vtable_) this->vtable_->deleter_(this);
    this->vtable_ = &vtable<Buffer, T>;
    return ::new (static_cast<void*>(as<T>()))
      T(std::forward<Args>(args)...);
  }
  …
};

注意构造函数中 vtable_ 成员的初始化。在 emplace() 方法中,我们还需要删除缓冲区中先前构造的对象,如果有的话。

在类型擦除机制到位后,我们最终可以实现析构函数和复制/移动操作。它们都使用类似的方法——调用 vtable 中的相应函数。以下是复制操作:

// Example 11
template<size_t S, size_t A = alignof(void*)>
struct Buffer {
  …
  Buffer(const Buffer& that) {
    if (that.vtable_)
      that.vtable_->copy_construct_(this, &that);
  }
  Buffer& operator=(const Buffer& that) {
    if (this == &that) return *this;
    if (this->vtable_) this->vtable_->deleter_(this);
    if (that.vtable_)
      that.vtable_->copy_construct_(this, &that);
    else this->vtable_ = nullptr;
    return *this;
  }
};

移动操作类似,只是它们使用 move_construct_ 函数:

template<size_t S, size_t A = alignof(void*)>
struct Buffer {
  …
  Buffer(Buffer&& that) {
    if (that.vtable_)
      that.vtable_->move_construct_(this, &that);
  }
  Buffer& operator=(Buffer&& that) {
    if (this == &that) return *this;
    if (this->vtable_) this->vtable_->deleter_(this);
    if (that.vtable_)
      that.vtable_->move_construct_(this, &that);
    else this->vtable_ = nullptr;
    return *this;
  }
};

注意移动赋值运算符不需要检查自赋值,但这样做也没有错。移动操作最好是 noexcept;不幸的是,我们无法保证这一点,因为我们不知道擦除类型。我们可以做出设计选择,并声明它们为 noexcept。如果我们这样做,我们还可以在编译时断言我们存储在缓冲区中的对象是 noexcept 可移动的。

最后,我们有销毁操作。由于我们允许调用者销毁包含的对象而不销毁缓冲区本身(通过调用 destroy()),我们必须确保对象只被销毁一次:

template<size_t S, size_t A = alignof(void*)>
struct Buffer {
  …
  ~Buffer() noexcept {
    if (this->vtable_) this->vtable_->deleter_(this);
  }
  // Destroy the object stored in the aligned space.
  void destroy() noexcept {
    if (this->vtable_) this->vtable_->deleter_(this);
    this->vtable_ = nullptr;
  }
};

类型擦除的vtable允许我们在运行时重建缓冲区中存储的类型(它嵌入为静态函数(如copy_construct())生成的代码中)。当然,这也有成本;我们已注意到额外的数据成员vtable_,但还有一些由于间接函数调用而产生的运行时成本。我们可以通过使用本地缓冲区的两种实现(带有和没有类型擦除)来存储和复制一些简单的可复制对象来估计它,例如,一个捕获引用的 lambda 表达式:

Benchmark                        Time
BM_lambda_copy_trivial          5.45 ns
BM_lambda_copy_typeerased       4.02 ns

(良好实现的)类型擦除的开销不可忽视,但还算适度。一个额外的优势是,我们可以在运行时验证我们的as<T>()调用是否引用了一个有效的类型,并且对象确实被构造。相对于未经检查的方法的非常便宜的实现,这将增加显著的开销,因此可能应该限制在调试构建中使用。

我们已经看到,本地缓冲区优化对许多不同的数据结构和类性能的显著甚至戏剧性的改进。有了我们刚刚学到的易于使用的通用实现,为什么你不会一直使用这种优化呢?正如任何设计模式一样,我们的探索如果没有提到权衡和缺点,就不算完整。

本地缓冲区优化的缺点

本地缓冲区优化并非没有缺点。最明显的一个缺点是,所有带有本地缓冲区的对象都比没有缓冲区时更大。如果缓冲区中存储的典型数据小于所选的缓冲区大小,那么每个对象都会浪费一些内存,但至少优化是有回报的。更糟糕的是,如果我们选择的缓冲区大小不合适,并且大多数数据实际上比本地缓冲区大,那么数据将存储在远程位置,但每个对象内部仍然会创建本地缓冲区,所有这些内存都浪费了。

在我们愿意浪费的内存量与优化有效的数据大小范围之间存在明显的权衡。本地缓冲区的大小应该根据应用进行仔细选择。

更微妙的问题是这样的——以前存储在对象外部的数据现在存储在对象内部。这有几个后果,除了我们如此关注的性能优势之外。首先,只要数据适合本地缓冲区,每个对象的副本都包含其自己的数据副本。这阻止了像数据引用计数这样的设计;例如,一个写时复制COW)字符串,只要所有字符串副本都保持相同,数据就不会被复制,不能使用小字符串优化。

其次,如果对象本身被移动,则必须移动数据。这与std::vector的情况形成对比,std::vector被移动或交换,本质上就像一个指针——数据指针被移动,但数据本身保持不变。对于std::any内部包含的对象也存在类似的考虑。你可以认为这种担忧是微不足道的;毕竟,局部缓冲区优化主要用于少量数据,移动它们的成本应该与复制指针的成本相当。然而,这里不仅仅是性能问题——移动std::vector(或std::any,无论如何)的实例保证不会抛出异常。然而,在移动任意对象时没有这样的保证。因此,只有当std::any包含的对象是std::is_nothrow_move_constructible时,std::any才能使用局部缓冲区优化。

然而,即使有这样的保证,对于std::vector的情况也不够;标准明确指出,移动或交换向量不会使指向向量任何元素的迭代器失效。显然,这个要求与局部缓冲区优化不相容,因为移动一个小向量会将所有元素重新定位到内存的不同区域。因此,许多高效库提供了一种定制的类似向量的容器,它支持小向量优化,但牺牲了标准迭代器失效的保证。

摘要

我们刚刚介绍了一种旨在提高性能的设计模式。效率是 C++语言的一个重要考虑因素;因此,C++社区开发了模式来解决最常见的低效问题。重复或浪费的内存分配可能是所有问题中最常见的。我们刚刚看到的设计模式——局部缓冲区优化——是一种强大的工具,可以大大减少这种分配。我们已经看到它如何应用于紧凑的数据结构,以及存储小对象,如可调用对象。我们还回顾了使用此模式可能存在的缺点。

在下一章,第十一章ScopeGuard,我们将继续研究更复杂的模式,这些模式解决更广泛的设计问题。我们迄今为止学到的惯用用法通常用于这些模式的实现。

问题

  1. 我们如何衡量一小段代码的性能?

  2. 为什么小而频繁的内存分配对性能尤其不利?

  3. 局部缓冲区优化是什么,它是如何工作的?

  4. 为什么在对象内部分配一个额外的缓冲区实际上是免费的?

  5. 短字符串优化是什么?

  6. 小向量优化是什么?

  7. 为什么局部缓冲区优化对可调用对象特别有效?

  8. 使用局部缓冲区优化时需要考虑哪些权衡?

  9. 何时不应该将对象放入局部缓冲区?

第十一章:ScopeGuard

本章介绍了一种模式,它可以被视为我们之前研究的 RAII 语法的泛化。在其最初形式中,它是一个古老且成熟的 C++模式,然而,它也是从 C++11、C++14 和 C++17 的语言添加中特别受益的模式。我们将见证随着语言变得更加强大,这个模式的演变。ScopeGuard 模式存在于声明式编程(说明你想要发生什么,而不是你想要如何实现)和错误安全程序(特别是异常安全)的交叉点。在我们完全理解 ScopeGuard 之前,我们需要了解一些关于这两个方面的知识。

本章将涵盖以下主题:

  • 我们如何编写错误安全和异常安全的代码?RAII 如何使错误处理更容易?

  • 将可组合性应用于错误处理是什么意思?

  • 为什么 RAII 在错误处理方面不够强大,以及它是如何泛化的?我们如何在 C++中实现声明式错误处理?

技术要求

这里是示例代码:github.com/PacktPublishing/Hands-On-Design-Patterns-with-CPP-Second-Edition/tree/master/Chapter11

你还需要安装和配置 Google Benchmark 库:github.com/google/benchmark(参见第四章交换 – 从简单到微妙,获取安装说明)。

本章对高级 C++特性有相当大的依赖,所以请将 C++参考手册放在附近(en.cppreference.com,除非你想直接查阅标准本身)。

最后,在 Folly 库中可以找到一个非常详尽和完整的 ScopeGuard 实现:github.com/facebook/folly/blob/master/folly/ScopeGuard.h;它包括本书中未涵盖的 C++库编程细节。

错误处理和资源获取即初始化

我们首先回顾错误处理的概念,特别是 C++中编写异常安全代码。资源获取即初始化RAII)是 C++中错误处理的主要方法之一。我们已经为它专门写了一整章,你在这里需要它来理解我们即将要做的事情。让我们首先认识到我们面临的问题。

错误安全和异常安全

在本章的剩余部分,我们将考虑以下问题——假设我们正在实现一个记录数据库。记录存储在磁盘上,但还有一个内存索引,用于快速访问记录。数据库 API 提供了一个将记录插入数据库的方法:

class Record { ... };
class Database {
  public:
  void insert(const Record& r);
  ...
};

如果插入成功,索引和磁盘存储都会更新,并且彼此一致。如果出现问题,则会抛出异常。

虽然数据库的客户端看起来插入是一个单一的事务,但实现必须处理这样一个事实:它是通过多个步骤完成的——我们需要将记录插入索引并写入磁盘。为了便于这样做,数据库包含两个类,每个类负责其类型的存储:

class Database {
  class Storage { ... };    // Disk storage Storage S;
  class Index { ... };    // Memory index Index I;
  public:
  void insert(const Record& r);
  ...
};

insert()函数的实现必须将记录插入存储和索引中,没有其他方法可以绕过这一点:

//Example 01
void Database::insert(const Record& r) {
  S.insert(r);
  I.insert(r);
}

不幸的是,这两种操作都可能失败。让我们首先看看如果存储插入失败会发生什么。假设程序中的所有失败都通过抛出异常来表示。如果存储插入失败,存储保持不变,索引插入根本不会尝试,异常会从Database::insert()函数中传播出去。这正是我们想要的——插入失败,数据库保持不变,并抛出异常。

那么,如果存储插入成功但索引失败会发生什么?这次情况看起来并不太好——磁盘被成功更改,然后索引插入失败,异常传播到Database::insert()的调用者以表示插入失败,但事实是插入并没有完全失败。它也没有完全成功。

数据库被留在一个不一致的状态;磁盘上有一个记录无法从索引中访问。这是处理错误条件失败、异常不安全的代码,这绝对是不行的。

试图改变子操作顺序的冲动尝试并没有帮助:

void Database::insert(const Record& r) {
  I.insert(r);
  S.insert(r);
}

当然,如果索引失败,现在一切看起来都很正常。但如果存储插入抛出异常,我们仍然会遇到同样的问题——现在索引中有一个条目指向了无意义的位置,因为记录从未被写入磁盘。

显然,我们不能简单地忽略IndexStorage抛出的异常;我们必须以某种方式处理它们,以保持数据库的一致性。我们知道如何处理异常;这就是try-catch块的作用:

// Example 02
void Database::insert(const Record& r) {
  S.insert(r);
  try {
    I.insert(r);
  } catch (...) {
    S.undo();
    throw;    // Rethrow
  }
}

再次强调,如果存储失败,我们不需要做任何特殊的事情。如果索引失败,我们必须撤销存储上最后的操作(假设它有执行该操作的 API)。现在数据库再次保持一致性,就像插入从未发生一样。尽管我们捕获了索引抛出的异常,我们仍然需要向调用者发出插入失败的信号,所以我们重新抛出异常。到目前为止,一切顺利。

如果我们选择使用错误代码而不是异常,情况并没有太大的不同;让我们考虑所有insert()函数在成功时返回true,失败时返回false的变体:

bool Database::insert(const Record& r) {
  if (!S.insert(r)) return false;
  if (!I.insert(r)) {
    S.undo();
    return false;
  }
  return true;
}

我们必须检查每个函数的返回值,如果第二个操作失败则撤销第一个动作,并且只有当两个操作都成功时才返回true

到目前为止,一切顺利;我们能够修复最简单的两阶段问题,因此代码是错误安全的。现在,是时候提高复杂性了。假设我们的存储需要在事务结束时进行一些清理,例如,插入的记录只有在调用Storage::finalize()方法后才会处于最终状态(也许这是为了使Storage::undo()能够工作,并且在插入最终化后不能再撤销)。注意undo()finalize()之间的区别;前者只有在想要回滚事务时才必须调用,而后者必须在存储插入成功后调用,无论之后发生什么。

我们的需求通过以下控制流程得到满足:

// Example 02a:
void Database::insert(const Record& r) {
  S.insert(r);
  try {
    I.insert(r);
  } catch (...) {
    S.undo();
    S.finalize();
    throw;
  }
  S.finalize();
}

或者,在返回错误代码的情况下(在本章的其余部分,我们将使用异常作为所有示例,但转换为错误代码并不困难)。

这已经变得很丑陋了,尤其是关于获取清理代码(在我们的情况下,S.finalize())以在每条执行路径上运行的部分。如果我们有一个更复杂的动作序列,这些动作都必须撤销,除非整个操作成功,那么情况只会变得更糟。以下是三个动作的控制流程,每个动作都有自己的回滚和清理:

if (action1() == SUCCESS) {
  if (action2() == SUCCESS) {
    if (action3() == FAIL) {
      rollback2();
      rollback1();
    }
    cleanup2();
  } else {
    rollback1();
  }
  cleanup1();
}

明显的问题是对成功进行的显式测试,无论是作为条件还是作为 try-catch 块。更严重的问题是这种错误处理方式是不可组合的。N+1 个动作的解决方案不是在 N 个动作的代码中添加一些位;不,我们必须深入代码并正确地添加这些部分。但我们已经看到了解决这个问题的 C++惯用法。

资源获取即初始化

RAII 惯用法将资源绑定到对象上。当获取资源时对象被构造,当对象被销毁时资源被删除。在我们的情况下,我们只对后半部分感兴趣,即销毁。RAII 惯用法的好处是,当控制达到作用域的末尾时,必须调用所有局部对象的析构函数,无论发生什么情况(returnthrowbreak等)。由于我们已经与清理作斗争,让我们将清理工作交给一个 RAII 对象:

// Example 02b:
class StorageFinalizer {
  public:
  StorageFinalizer(Storage& S) : S_(S) {}
  ~StorageFinalizer() { S_.finalize(); }
  private:
  Storage& S_;
};
void Database::insert(const Record& r) {
  S.insert(r);
  StorageFinalizer SF(S);
  try {
    I.insert(r);
  } catch (...) {
    S.undo();
    throw;
  }
}

StorageFinalizer对象被构造时,它会绑定到Storage对象并在被销毁时调用finalize()方法。由于没有方法可以不调用其析构函数就退出定义StorageFinalizer对象的范围,所以我们不需要担心控制流,至少对于清理来说是这样;它将会发生。注意,StorageFinalizer在存储插入成功后才会被正确构造;如果第一次插入失败,就没有什么可以最终化的。

这段代码可以工作,但它看起来有些半途而废;我们在函数末尾执行了两个操作,第一个操作(清理或finalize())是自动化的,而第二个操作(回滚或undo())则不是。此外,技术本身仍然不可组合;以下是三个操作的流程控制:

class Cleanup1() {
  ~Cleanup1() { cleanup1(); }
  ...
};
class Cleanup2() {
  ~Cleanup2() { cleanup2(); }
  ...
};
action1();
Cleanup1 c1;
try {
  action2();
  Cleanup2 c2;
  try {
    action3();
  } catch (...) {
    rollback2();
    throw;
  }
} catch (...) {
  rollback1();
}

再次,为了添加另一个操作,我们必须在代码深处添加一个 try-catch 块。另一方面,清理部分本身是完全可以组合的。考虑如果我们不需要执行回滚,之前的代码看起来会是什么样子:

action1();
Cleanup1 c1;
action2();
Cleanup2 c2;

如果我们需要执行另一个操作,我们只需在函数末尾添加两行代码,清理工作将按正确顺序进行。如果我们能够对回滚也做同样的事情,我们就可以万事大吉了。

我们不能简单地将对undo()的调用移动到另一个对象的析构函数中;析构函数总是被调用,但回滚只有在发生错误时才会发生。但我们可以使析构函数有条件地调用回滚:

// Example 03:
class StorageGuard {
  public:
  StorageGuard(Storage& S) : S_(S) {}
  ~StorageGuard() {
    if (!commit_) S_.undo();
  }
  void commit() noexcept { commit_ = true; }
  private:
  Storage& S_;
  bool commit_ = false;
};
void Database::insert(const Record& r) {
  S.insert(r);
  StorageFinalizer SF(S);
  StorageGuard SG(S);
  I.insert(r);
  SG.commit();
}

现在检查代码;如果存储插入失败,将抛出异常且数据库保持不变。如果成功,将构造两个 RAII 对象。第一个对象将在作用域结束时无条件调用S.finalize()。第二个对象将调用S.undo(),除非我们首先通过在StorageGuard对象上调用commit()方法提交更改。除非索引插入失败,否则会发生这种情况,此时将抛出异常,作用域内的其余代码将被跳过,控制直接跳转到作用域的末尾(即关闭的}),在那里调用所有局部对象的析构函数。由于我们在此场景中从未调用commit(),因此StorageGuard仍然处于活动状态并将撤销插入。注意,这里根本没有任何显式的try-catch块:以前在catch子句中执行的操作现在由析构函数完成。当然,异常最终应该被捕获(在伴随本章的所有示例中,异常都在main()中被捕获)。

本地对象的析构函数按反向构造顺序被调用。这很重要;如果我们必须撤销插入,这只能在操作最终化之前完成,因此回滚必须在清理之前发生。因此,我们按正确的顺序构造 RAII 对象——首先,清理(最后执行),然后是回滚保护(如果需要,首先执行)。

代码现在看起来非常好,完全没有 try-catch 块。在某种程度上,它看起来不像常规的 C++。这种编程风格被称为声明式编程;它是一种编程范式,其中程序逻辑通过不明确声明控制流(与 C++中更常见的相反,即命令式编程,其中程序描述了要执行哪些步骤以及它们的顺序,但不一定说明为什么)来表达。有声明式编程语言(主要例子是 SQL),但 C++不是其中之一。尽管如此,C++非常擅长实现允许在 C++之上创建高级语言的构造,因此我们实现了一种声明式错误处理语言。我们的程序现在表示,在记录被插入存储后,有两个待执行的动作——清理和回滚。

如果整个函数执行成功,则回滚将被解除。代码看起来是线性的,没有显式的控制流,换句话说,是声明式的。

虽然很好,但它也远非完美。明显的问题是,我们必须为程序中的每个动作编写一个保护器或最终化器类。不那么明显的问题是,正确编写这些类并不容易,我们到目前为止做得并不特别出色。在查看这里修复的版本之前,花点时间想一下缺少了什么:

class StorageGuard {
  public:
  StorageGuard(Storage& S) : S_(S), commit_(false) {}
  ~StorageGuard() { if (!commit_) S_.undo(); }
  void commit() noexcept { commit_ = true; }
  private:
  Storage& S_;
  bool commit_;
  // Important: really bad things happen if
  // this guard is copied!
  StorageGuard(const StorageGuard&) = delete;
  StorageGuard& operator=(const StorageGuard&) = delete;
};
void Database::insert(const Record& r) {
  S.insert(r);
  StorageFinalizer SF(S);
  StorageGuard SG(S);
  I.insert(r);
  SG.commit();
}

我们需要一个通用框架,让我们能够安排在作用域结束时无条件或条件性地执行任意操作。下一节将介绍提供这种框架的模式,即范围保护。

范围保护模式

在本节中,我们学习如何编写退出时执行的动作 RAII 类,就像我们在上一节中实现的那样,但不需要所有样板代码。这可以在 C++03 中完成,但在 C++14 中得到了很大的改进,然后在 C++17 中再次改进。

范围保护基础

让我们从更困难的问题开始——如何实现一个通用的回滚类,即上一节中StorageGuard的通用版本。与清理类之间的唯一区别是,清理始终处于活动状态,但回滚在操作提交后取消。如果我们有条件回滚版本,我们可以总是去掉条件检查,从而得到清理版本,所以我们现在不必担心这个问题。

在我们的例子中,回滚是一个调用S.undo()方法的操作。为了简化示例,让我们从一个调用常规函数而不是成员函数的回滚开始:

void undo(Storage& S) { S.undo(); }

一旦实施完成,程序应该看起来像这样:

{
  S.insert(r);
  ScopeGuard SG(undo, S);    // Approximate desired syntax
  ...
  SG.commit();            // Disarm the scope guard
}

这段代码以声明式的方式告诉我们,如果插入操作成功,我们将在退出作用域时安排回滚操作。回滚将调用带有参数Sundo()函数,这反过来将撤销插入操作。如果我们到达了函数的末尾,我们将解除保护并禁用回滚调用,这将提交插入操作并使其永久化。

Andrei Alexandrescu 在 2000 年的 Dr. Dobbs 文章中提出了一种更通用且可重用的解决方案(www.drdobbs.com/cpp/generic-change-the-way-you-write-excepti/184403758?)。让我们看看实现并分析它:

// Example 04
class ScopeGuardImplBase {
  public:
  ScopeGuardImplBase() = default;
  void commit() const noexcept { commit_ = true; }
  protected:
  ScopeGuardImplBase(const ScopeGuardImplBase& other) :
    commit_(other.commit_) { other.commit(); }
  ~ScopeGuardImplBase() {}
  mutable bool commit_ = false;
};
template <typename Func, typename Arg>
class ScopeGuardImpl : public ScopeGuardImplBase {
  public:
  ScopeGuardImpl(const Func& func, Arg& arg) :
    func_(func), arg_(arg) {}
  ~ScopeGuardImpl() { if (!commit_) func_(arg_); }
  private:
  const Func& func_;
  Arg& arg_;
};
template <typename Func, typename Arg>
ScopeGuardImpl<Func, Arg>
MakeGuard(const Func& func, Arg& arg) {
  return ScopeGuardImpl<Func, Arg>(func, arg);
}

从顶部开始,我们有所有作用域保护器的基类,ScopeGuardImplBase。基类持有提交标志和操作它的代码;构造函数最初将保护器创建在武装状态,因此延迟操作将在析构函数中发生。对commit()的调用将阻止这种情况发生,并使析构函数不执行任何操作。最后,还有一个拷贝构造函数,它创建一个与原始对象状态相同的新保护器,然后解除原始保护器的武装。这样做是为了防止回滚在两个对象的析构函数中发生两次。对象是可拷贝的但不可赋值。我们在这里使用 C++03 的所有功能,包括禁用的赋值运算符。这个实现本质上就是 C++03;少数 C++11 的变化只是锦上添花(这将在下一节中改变)。

ScopeGuardImplBase 实现的几个细节可能看起来很奇怪,需要详细说明。首先,析构函数不是虚拟的;这不是一个打字错误或错误,这是故意的,就像我们稍后将要看到的那样。其次,commit_标志被声明为mutable。当然,这是为了让它可以通过我们声明的const方法commit()被改变。那么,为什么commit()被声明为const呢?一个原因是我们可以从拷贝构造函数中调用它,将回滚的责任从另一个对象转移到这个对象。从这个意义上说,拷贝构造函数实际上执行了一个移动操作,稍后将被正式声明为这样的操作。const声明的第二个原因稍后就会变得明显(它与非虚拟析构函数有关)。

现在,让我们转向派生类,ScopeGuardImpl。这是一个带有两个类型参数的类模板——第一个是我们将要调用的用于回滚的函数或任何其他可调用对象的类型,第二个是参数的类型。目前,我们的回滚函数仅限于只有一个参数。这个函数在ScopeGuard对象的析构函数中被调用,除非通过调用commit()解除保护。

最后,我们有一个工厂函数模板,MakeGuard。这在 C++中是一个非常常见的惯用法;如果你需要从构造函数参数创建一个类模板的实例,请使用一个模板函数,它可以从参数推导出参数类型和返回值类型(在 C++17 中,类模板也可以这样做,我们稍后会看到)。

这些是如何用来创建一个将为我们调用undo(S)的守卫对象的?如下所示:

void Database::insert(const Record& r) {
  S.insert(r);
  const ScopeGuardImplBase& SG = MakeGuard(undo, S);
  I.insert(r);
  SG.commit();
}

MakeGuard函数推导出undo()函数的类型和参数S的类型,并返回相应类型的ScopeGuard对象。返回是通过值进行的,因此涉及到一个复制(编译器可能会选择省略复制作为优化,但不是必须的)。返回的对象是一个临时变量,它没有名字,并将其绑定到基类引用SG(从派生类到基类的指针和引用的转换是隐式的)。临时变量的生命周期直到创建它的语句的结束分号,正如大家所知。但是,在语句结束后,SG引用指向什么?它必须绑定到某个东西,因为引用不能解绑,它们不像NULL指针。事实是,“每个人”都知道错了,或者说只是大部分正确——通常,临时变量确实会一直活到语句的结束。然而,将临时变量绑定到const引用会将其生命周期延长到与引用本身的生存期一致。换句话说,MakeGuard创建的无名ScopeGuard对象将不会在SG引用超出作用域之前被销毁。在这里,const属性很重要,但不必担心,你不会忘记它;语言不允许将非const引用绑定到临时变量,因此编译器会告诉你。因此,这就解释了commit()方法;它必须是const的,因为我们将在const引用上调用它(因此,commit_标志必须是mutable)。

但是,关于析构函数呢?在作用域结束时,ScopeGuardImplBase类的析构函数将被调用,因为这是超出作用域的引用类型。基类析构函数本身不执行任何操作,执行我们想要的析构函数的是派生类。一个具有虚拟析构函数的多态类会为我们提供正确的服务,但我们没有走这条路。相反,我们利用了 C++标准中关于const引用和临时变量的另一条特殊规则——不仅临时变量的生命周期被延长,而且派生类的析构函数,即实际构造的类,将在作用域结束时被调用。

注意,这条规则仅适用于析构函数;你仍然不能在基类SG引用上调用派生类方法。此外,生命周期扩展仅在临时变量直接绑定到const引用时才有效。如果,例如,我们从第一个引用初始化另一个const引用,则不会生效。这就是为什么我们必须通过值从MakeGuard函数返回ScopeGuard对象;如果我们尝试通过引用返回它,临时变量将绑定到那个引用,而这个引用将在语句结束时消失。第二个引用SG是从第一个引用初始化的,它并没有扩展对象的生命周期。

我们刚才看到的函数实现非常接近原始目标,只是稍微有点冗长(并且提到了ScopeGuardImplBase而不是承诺的ScopeGuard)。不用担心,最后一步仅仅是语法糖:

using ScopeGuard = const ScopeGuardImplBase&;

现在,我们可以这样写:

// Example 04a
void Database::insert(const Record& r) {
  S.insert(r);
  ScopeGuard SG = MakeGuard(undo, S);
  I.insert(r);
  SG.commit();
}

这就是我们迄今为止使用语言工具所能达到的。理想情况下,所需的语法应该是这样的(而且我们并不遥远):(此处省略具体语法示例)

ScopeGuard SG(undo, S);

我们可以利用 C++11 的特性来稍微整理一下我们的ScopeGuard。首先,我们可以正确地禁用赋值运算符。其次,我们可以停止假装我们的复制构造函数除了移动构造函数之外还有什么:

// Example 05
class ScopeGuardImplBase {
  public:
  ScopeGuardImplBase() = default;
  void commit() const noexcept { commit_ = true; }
  protected:
  ScopeGuardImplBase(ScopeGuardImplBase&& other) :
    commit_(other.commit_) { other.commit(); }
  ~ScopeGuardImplBase() {}
  mutable bool commit_ = false;
  private:
  ScopeGuardImplBase& operator=(const ScopeGuardImplBase&)
    = delete;
};
using ScopeGuard = const ScopeGuardImplBase&;
template <typename Func, typename Arg>
class ScopeGuardImpl : public ScopeGuardImplBase {
  public:
  ScopeGuardImpl(const Func& func, Arg& arg) :
    func_(func), arg_(arg) {}
  ~ScopeGuardImpl() { if (!commit_) func_(arg_); }
  ScopeGuardImpl(ScopeGuardImpl&& other) :
    ScopeGuardImplBase(std::move(other)),
    func_(other.func_),
    arg_(other.arg_) {}
  private:
  const Func& func_;
  Arg& arg_;
};
template <typename Func, typename Arg>
ScopeGuardImpl<Func, Arg>
MakeGuard(const Func& func, Arg& arg) {
  return ScopeGuardImpl<Func, Arg>(func, arg);
}

转向 C++14,我们可以进一步简化,并推断出MakeGuard函数的返回类型:

// Example 05a
template <typename Func, typename Arg>
auto MakeGuard(const Func& func, Arg& arg) {
  return ScopeGuardImpl<Func, Arg>(func, arg);
}

我们还必须做出一项让步——我们实际上并不需要undo(S)函数,我们真正想要的是调用S.undo()。这可以通过ScopeGuard的成员函数变体轻松实现。事实上,我们之所以从一开始就没有这样做,只是为了使示例更容易理解;成员函数指针语法并不是 C++中最直接的部分:

// Example 06
template <typename MemFunc, typename Obj>
class ScopeGuardImpl : public ScopeGuardImplBase {
  public:
  ScopeGuardImpl(const MemFunc& memfunc, Obj& obj) :
    memfunc_(memfunc), obj_(obj) {}
  ~ScopeGuardImpl() { if (!commit_) (obj_.*memfunc_)(); }
  ScopeGuardImpl(ScopeGuardImpl&& other) :
    ScopeGuardImplBase(std::move(other)),
    memfunc_(other.memfunc_),
    obj_(other.obj_) {}
  private:
  const MemFunc& memfunc_; Obj& obj_;
};
template <typename MemFunc, typename Obj>
auto MakeGuard(const MemFunc& memfunc, Obj& obj) {// C++14
  return ScopeGuardImpl<MemFunc, Obj>(memfunc, obj);
}

当然,如果在同一个程序中使用了ScopeGuard模板的两个版本,我们必须重命名其中一个。此外,我们的函数守卫只能调用只有一个参数的函数,而我们的成员函数守卫只能调用没有参数的成员函数。在 C++03 中,这个问题通过一种繁琐但可靠的方式得到解决——我们必须为具有零、一、二等参数的函数创建实现版本,例如ScopeGuardImpl0ScopeGuardImp1ScopeGuardImpl2等。然后,我们为具有零、一、二等参数的成员函数创建ScopeObjGuardImpl0ScopeObjGuardImpl1等。如果我们没有创建足够的版本,编译器会告诉我们。所有这些派生类变体都有相同的基类,ScopeGuardtypedef也是如此。

在 C++11 中,我们有变长模板,旨在解决这个确切的问题,但在这里我们不会看到这样的实现。没有必要;我们可以做得更好,正如你即将看到的。

泛型 ScopeGuard

我们现在完全处于 C++11 的领域,你即将看到的任何内容都没有 C++03 的等效物,具有任何实际价值。

到目前为止,我们的ScopeGuard允许我们将任意函数作为任何操作的回滚。就像手工制作的守卫对象一样,作用域守卫是可组合的,并保证异常安全。但到目前为止,我们的实现对我们可以调用来实现回滚的功能有限;它必须是一个函数或成员函数。虽然这似乎涵盖了大部分,我们可能还想调用,例如,两个函数来完成单个回滚。我们当然可以为此编写一个包装函数,但这又让我们回到了单用途手工回滚对象的道路上。

实际上,我们的实现中还存在另一个问题。我们决定通过引用捕获函数参数:

ScopeGuardImpl(const Func& func, Arg& arg);

这通常都有效,除非参数是一个常量或临时变量;那么,我们的代码将无法编译。

C++11 给我们提供了创建任意可调用对象的另一种方法:lambda 表达式。Lambda 实际上是类,但它们的行为像函数,因为它们可以用括号调用。它们可以接受参数,但也可以捕获包含作用域中的任何参数,这通常消除了传递参数给函数调用的需要。我们还可以编写任意代码,并将其打包在 lambda 表达式中。这听起来对作用域守卫来说很理想;我们只需编写一些代码,说“在作用域运行结束时”执行这些代码。

让我们看看 lambda 表达式作用域守卫的样子:

// Example 07
class ScopeGuardBase {
  public:
  ScopeGuardBase() = default;
  void commit() noexcept { commit_ = true; }
  protected:
  ScopeGuardBase(ScopeGuardBase&& other) noexcept :
    commit_(other.commit_) { other.commit(); }
  ~ScopeGuardBase() = default;
  bool commit_ = false;
  private:
  ScopeGuardBase& operator=(const ScopeGuardBase&)
    = delete;
};
template <typename Func>
class ScopeGuard : public ScopeGuardBase {
  public:
  ScopeGuard(Func&& func) : func_(std::move(func)) {}
  ScopeGuard(const Func& func) : func_(func) {}
  ~ScopeGuard() { if (!commit_) func_(); }
  ScopeGuard(ScopeGuard&& other) = default;
  private:
  Func func_;
};
template <typename Func>
ScopeGuard<Func> MakeGuard(Func&& func) {
  return ScopeGuard<Func>(std::forward<Func>(func));
}

基类基本上与之前相同,只是我们不再使用const引用技巧,因此Impl后缀已经消失;你所看到的不再是实现辅助,而是守卫类的本身基础;它包含处理commit_标志的可重用代码。由于我们不使用const引用,我们可以停止假装commit()方法是const的,并从commit_中删除mutable声明。

另一方面,派生类有很大的不同。首先,只有一个类用于所有类型的回滚,参数类型已经消失;相反,我们有一个将要成为 lambda 的功能对象,它将包含它需要的所有参数。析构函数与之前相同(除了缺少对可调用func_的参数),移动构造函数也是如此。但对象的主体构造函数相当不同;可调用对象按值存储,并从const引用或右值引用初始化,编译器会自动选择合适的重载。

MakeGuard函数基本上没有变化,我们不需要两个;我们可以使用完美转发(std::forward)将任何类型的参数转发到ScopeGuard的一个构造函数。

下面是如何使用这个作用域守卫的示例:

void Database::insert(const Record& r) {
  S.insert(r);
  auto SG = MakeGuard([&] { S.undo(); });
  I.insert(r);
  SG.commit();
}

用作MakeGuard参数的标点符号丰富的结构是 lambda 表达式。它创建了一个可调用对象,调用此对象将运行 lambda 体内的代码,在我们的例子中是S.undo()。在 lambda 对象本身中没有声明S变量,因此它必须从包含的作用域中捕获。所有捕获都是通过引用([&])完成的。最后,对象被不带参数地调用;括号可以省略,尽管MakeGuard([&]() { S.undo(); });也是有效的。该函数不返回任何内容,即返回类型是void;不需要显式声明。请注意,到目前为止,我们使用了 C++11 lambda,并没有利用更强大的 C++14 lambda。这通常是 ScopeGuard 的情况,尽管在实践中,您可能会仅为了自动推导的返回类型而使用 C++14,如果其他什么也不做的话。

一直以来,我们有意将常规清理问题放在一边,专注于错误处理和回滚。现在我们有了相当不错的 ScopeGuard,我们可以轻松地解决悬而未决的问题:

// Example 07a
void Database::insert(const Record& r) {
  S.insert(r);
  auto SF = MakeGuard([&] { S.finalize(); });
  auto SG = MakeGuard([&] { S.undo(); });
  I.insert(r);
  SG.commit();
}

如您所见,我们不需要在我们的框架中添加任何特殊的东西来支持清理。我们只需创建另一个我们永远不会解除武装的 ScopeGuard。

我们还应该指出,在 C++17 中,我们不再需要MakeGuard函数,因为编译器可以从构造函数中推导出模板参数:

// Example 07b
void Database::insert(const Record& r) {
  S.insert(r);
  ScopeGuard SF = [&] { S.finalize(); };    // C++17
  ScopeGuard SG = [&] { S.undo(); };
  I.insert(r);
  SG.commit();
}

既然我们在讨论使 ScopeGuard 使用起来更美观的话题,我们应该考虑一些有用的宏。我们可以轻松地为清理守卫编写一个宏,即总是执行的那个。我们希望生成的语法看起来像这样(如果这还不够声明性,我不知道还有什么):

ON_SCOPE_EXIT { S.finalize(); };

实际上,我们可以获取那个非常具体的语法。首先,我们需要为守卫生成一个名称,过去被称为SF,并且我们需要它是一个独一无二的名称。从现代 C++的尖端,我们追溯到几十年前的经典 C 及其预处理器技巧,以生成一个匿名变量的唯一名称:

#define CONCAT2(x, y) x##y
#define CONCAT(x, y) CONCAT2(x, y)
#ifdef __COUNTER__
#define ANON_VAR(x) CONCAT(x, __COUNTER__)
#else
#define ANON_VAR(x) CONCAT(x, __LINE__)
#endif

__CONCAT__宏是在预处理器中将两个标记连接起来的方法(是的,你需要两个,这就是预处理器的工作方式)。第一个标记将是一个用户指定的前缀,第二个是一个独一无二的标记。许多编译器支持一个预处理器变量__COUNTER__,每次使用时都会递增,所以它永远不会相同。然而,它不在标准中。如果__COUNTER__不可用,我们必须使用行号__LINE__作为唯一标识符。当然,只有在我们没有在同一行上放置两个守卫的情况下,它才是唯一的,所以不要这样做。

现在我们有了生成匿名变量名的方法,我们可以实现 ON_SCOPE_EXIT 宏。实现一个将代码作为宏参数传递的宏是微不足道的,但它不会给我们想要的语法;参数必须放在括号内,所以我们最多只能得到 ON_SCOPE_EXIT(S.finalize();)。此外,代码中的逗号会混淆预处理器,因为它将其解释为宏参数之间的分隔符。如果你仔细观察我们请求的语法 ON_SCOPE_EXIT { S.finalize(); };,你会意识到这个宏根本没有任何参数,lambda 表达式的主体只是类型化在无参数宏之后。因此,宏展开在可以跟随开括号的东西上结束。以下是它是如何完成的:

// Example 08
struct ScopeGuardOnExit {};
template <typename Func>
ScopeGuard<Func> operator+(ScopeGuardOnExit, Func&& func) {
  return ScopeGuard<Func>(std::forward<Func>(func));
}
#define ON_SCOPE_EXIT auto ANON_VAR(SCOPE_EXIT_STATE) = \
  ScopeGuardOnExit() + [&]()

宏展开声明了一个以 SCOPE_EXIT_STATE 开头的匿名变量,后面跟着一个唯一的数字,并在不完整的 lambda 表达式 [&]() 上结束,该表达式由花括号中的代码完成。为了不产生前一个 MakeGuard 函数的闭括号(宏无法生成,因为宏在 lambda 体之前展开,所以它不能生成任何代码),我们必须将 MakeGuard 函数(或 C++17 中的 ScopeGuard 构造函数)替换为一个操作符。操作符的选择并不重要;我们使用 +,但也可以使用任何二元操作符。操作符的第一个参数是一个唯一的临时对象,它仅限于之前定义的 operator+() 的重载解析(该对象本身根本不使用,我们只需要它的类型)。operator+() 本身就是 MakeGuard 之前所用的,它推断 lambda 表达式的类型并创建相应的 ScopeGuard 对象。这种技术的唯一缺点是,在 ON_SCOPE_EXIT 语句的末尾需要一个分号,如果你忘记了,编译器将以最隐晦和模糊的方式提醒你。

我们的程序代码现在可以进一步整理:

// Example 08
void Database::insert(const Record& r) {
  S.insert(r);
  ON_SCOPE_EXIT { S.finalize(); };
  auto SG = ScopeGuard([&] { S.undo(); });
  I.insert(r);
  SG.commit();
}

很有诱惑力将同样的技术应用到第二个守卫上。不幸的是,这并不简单;我们必须知道这个变量的名字,以便我们可以调用其上的 commit() 方法。我们可以定义一个类似的宏,它不使用匿名变量,而是使用用户指定的名字:

// Example 08a
#define ON_SCOPE_EXIT_ROLLBACK(NAME) \
  auto NAME = ScopeGuardOnExit() + [&]()

我们可以用它来完成我们代码的转换:

// Example 08a
void Database::insert(const Record& r) {
  S.insert(r);
  ON_SCOPE_EXIT { S.finalize(); };
  ON_SCOPE_EXIT_ROLLBACK(SG){ S.undo(); };
  I.insert(r);
  SG.commit();
}

到目前为止,我们应该重新审视可组合性的问题。对于三个动作,每个都有自己的回滚和清理,我们现在有以下内容:

action1();
ON_SCOPE_EXIT { cleanup1; };
ON_SCOPE_EXIT_ROLLBACK(g2){ rollback1(); };
action2();
ON_SCOPE_EXIT { cleanup2; };
ON_SCOPE_EXIT_ROLLBACK(g4){ rollback2(); };
action3();
g2.commit();
g4.commit();

可以看到,这种模式可以轻易扩展到任意数量的动作。一个细心的读者可能会怀疑他们是否在代码中发现了错误——回滚保护不应该按照反向构造顺序取消吗?这不是一个错误,尽管所有commit()调用的反向顺序也不是错误。原因是commit()不能抛出异常,它被声明为noexcept,而且确实其实现是这样的,不能抛出异常。这对范围保护模式的工作至关重要;如果commit()可以抛出异常,那么就无法保证所有回滚保护都被正确解除。在作用域结束时,一些动作会被回滚,而其他动作则不会,这将使系统处于不一致的状态。

虽然范围保护主要设计用来使编写异常安全代码更容易,但范围保护模式与异常的交互远非简单,我们应该花更多时间来关注它。

范围保护与异常

范围保护模式旨在在退出作用域时自动正确执行各种清理和回滚操作,无论退出原因是什么——正常完成作用域的末尾、提前返回或异常。这使得编写错误安全代码变得容易,尤其是异常安全代码;只要我们在每次动作之后都排好正确的保护程序,正确的清理和错误处理就会自动发生。当然,这是假设范围保护在异常存在的情况下本身运行正确。我们将学习如何确保它确实如此,以及如何使用它来使其余代码错误安全。

什么不能抛出异常

我们已经看到,用于提交动作并解除回滚保护的commit()函数绝对不能抛出异常。幸运的是,这很容易保证,因为这个函数所做的只是设置一个标志。但如果回滚函数也失败了,并抛出了异常怎么办?

// Example 09
void Database::insert(const Record& r) {
  S.insert(r);
  auto SF = MakeGuard([&] { S.finalize(); });
  auto SG = MakeGuard([&] { S.undo(); });
             // What if undo() can throw?
  I.insert(r);    // Let's say this fails
  SG.commit();    // Commit never happens
}            // Control jumps here and undo() throws

简短的回答是没有好办法。一般来说,我们面临一个难题——我们既不能允许动作(在我们的例子中是存储插入)继续进行,也不能撤销它,因为那样也会失败。具体来说,在 C++中,两个异常不能同时传播。因此,析构函数不允许抛出异常;当抛出异常时可能会调用析构函数,如果该析构函数也抛出异常,那么我们现在有两个异常同时传播。如果发生这种情况,程序将立即终止。这与其说是语言的不足,不如说是对一般问题不可解性的反映;我们无法让事情保持原样,但我们也未能成功改变某些东西。已经没有好的选择了。

通常,C++程序有三种处理这种情况的方法。最好的选择是不陷入这个陷阱——如果回滚不能抛出异常,这一切都不会发生。因此,一个编写良好的异常安全程序会竭尽全力提供非抛出回滚和清理。例如,主要动作可以生成新数据并使其就绪,然后只需交换一个指针,就可以简单地将数据提供给调用者,这肯定是一个非抛出操作。回滚只涉及交换指针回原位,可能还需要删除某些东西(正如我们之前所说的,析构函数不应该抛出异常;如果它们这样做,程序的行为是未定义的)。

第二种选择是在回滚中抑制异常。我们尝试撤销操作,但失败了,我们对此无能为力,所以让我们继续前进。这里的危险是程序可能处于未定义的状态,从这一点开始的所有操作都可能是不正确的。然而,这只是一个最坏的情况。在实践中,后果可能不那么严重。例如,对于我们的数据库,我们可能知道如果回滚失败,有一块磁盘空间被记录占用,但无法从索引中访问。调用者将正确地被告知插入失败,但我们浪费了一些磁盘空间。这可能比直接终止程序更好。如果我们希望这样做,我们必须捕获任何可能由 ScopeGuard 操作抛出的异常:

// Example 09a
template <typename Func>
class ScopeGuard : public ScopeGuardBase {
  public:
  ...
  ~ScopeGuard() {
    if (!commit_) try { func_(); } catch (...) {}
  }
  ...
};

catch子句是空的;我们捕获了一切,但什么也没做。这种实现有时被称为屏蔽的 ScopeGuard

最后一种选择是允许程序失败。如果我们只是让两个异常发生,那么这不需要我们做出任何努力就会发生。但我们可以打印一条消息或以其他方式向用户发出即将发生的事情以及原因的信号。如果我们想在程序终止之前插入自己的死亡动作,我们必须编写与之前非常相似的代码:

template <typename Func>
class ScopeGuard : public ScopeGuardBase {
  public:
  ...
  ~ScopeGuard() {
    if (!commit_) try { func_(); } catch (...) {
      std::cout << "Rollback failed" << std::endl;
      throw;    // Rethrow
    }
  }
  ...
};

关键的区别是没有参数的throw;语句。这重新抛出了我们捕获的异常,并允许它继续传播。

最后两个代码片段之间的区别突显了一个我们之前略过的微妙细节,但这个细节在以后会变得很重要。说在 C++中析构函数不应该抛出异常是不准确的。正确的说法是,异常不应该从析构函数中传播出来。只要析构函数也捕获它,它可以抛出任何它想要的东西:

class LivingDangerously {
  public:
  ~LivingDangerously() {
    try {
      if (cleanup() != SUCCESS) throw 0;
       more_cleanup();
    } catch (...) {
      std::cout << "Cleanup failed, proceeding anyway" <<
      std::endl;
      // No rethrow - this is critical!
    }
  }
};

到目前为止,我们主要将异常视为一种麻烦;如果某个地方抛出了异常,程序必须保持一个良好的定义状态,但除此之外,我们没有使用这些异常;我们只是将它们传递下去。另一方面,我们的代码可以与任何类型的错误处理一起工作,无论是异常还是错误代码。如果我们确信错误总是通过异常来表示,并且任何非抛出异常的函数返回都是成功,我们可以利用这一点来自动检测成功或失败,从而允许根据需要发生提交或回滚。

异常驱动的 ScopeGuard

现在,我们将假设如果一个函数没有抛出异常就返回,那么操作已经成功。如果函数抛出异常,显然失败了。现在的目标是取消对commit()的显式调用,而是检测 ScopeGuard 的析构函数是因抛出异常而执行,还是因为函数正常返回。

这个实现有两个部分。第一部分是指定我们希望在何时执行操作。清理守卫必须无论以何种方式退出作用域都要执行。回滚守卫仅在失败的情况下执行。为了完整性,我们还可以有一个仅在函数成功时执行的守卫。第二部分是确定实际上发生了什么。

我们将从第二部分开始。我们的 ScopeGuard 现在需要两个额外的参数,这两个参数将告诉我们是否应该在成功时执行,以及是否应该在失败时执行(两者可以同时启用)。只需要修改 ScopeGuard 的析构函数:

template <typename Func, bool on_success, bool on_failure>
class ScopeGuard {
  public:
  ...
  ~ScopeGuard() {
    if ((on_success && is_success()) ||
        (on_failure && is_failure())) func_();
  }
  ...
};

我们仍然需要弄清楚如何实现伪函数is_success()is_failure()。记住,失败意味着抛出了异常。在 C++中,我们有一个函数可以做到这一点:std::uncaught_exception()。如果当前正在传播异常,它返回 true,否则返回 false。有了这个知识,我们可以实现我们的守卫:

// Example 10
template <typename Func, bool on_success, bool on_failure>
class ScopeGuard {
  public:
  ...
  ~ScopeGuard() {
    if ((on_success && !std::uncaught_exception()) ||
        (on_failure && std::uncaught_exception())) func_();
  }
  ...
};

现在,回到第一部分:ScopeGuard 将在条件正确时执行延迟操作,那么我们如何告诉它正确的条件呢?使用我们之前开发的宏方法,我们可以定义三个版本的守卫——ON_SCOPE_EXIT总是执行,ON_SCOPE_SUCCESS仅在未抛出异常时执行,而ON_SCOPE_FAILURE在抛出异常时执行。后者替换了我们的ON_SCOPE_EXIT_ROLLBACK宏,但现在它也可以使用匿名变量名,因为没有显式调用commit()。这三个宏以非常相似的方式定义,我们只需要三个不同的唯一类型而不是一个ScopeGuardOnExit,这样我们就可以决定调用哪个operator+()

// Example 10
struct ScopeGuardOnExit {};
template <typename Func>
auto operator+(ScopeGuardOnExit, Func&& func) {
  return
    ScopeGuard<Func, true, true>(std::forward<Func>(func));
}
#define ON_SCOPE_EXIT auto ANON_VAR(SCOPE_EXIT_STATE) = \
  ScopeGuardOnExit() + [&]()
struct ScopeGuardOnSuccess {};
template <typename Func>
auto operator+(ScopeGuardOnSuccess, Func&& func) {
  return
   ScopeGuard<Func, true, false>(std::forward<Func>(func));
}
#define ON_SCOPE_SUCCESS auto ANON_VAR(SCOPE_EXIT_STATE) =\
  ScopeGuardOnSuccess() + [&]()
struct ScopeGuardOnFailure {};
template <typename Func>
auto operator+(ScopeGuardOnFailure, Func&& func) {
  return
   ScopeGuard<Func, false, true>(std::forward<Func>(func));
}
#define ON_SCOPE_FAILURE auto ANON_VAR(SCOPE_EXIT_STATE) =\
  ScopeGuardOnFailure() + [&]()

每个 operator+() 的重载都会使用不同的布尔参数构建一个 ScopeGuard 对象,这些参数控制它何时执行以及何时不执行。每个宏通过指定 operator+() 的第一个参数的类型,使用我们为此目的定义的唯一树类型之一来指导 lambda 表达式:ScopeGuardOnExitScopeGuardOnSuccessScopeGuardOnFailure

此实现可以通过简单的甚至相当复杂的测试,看起来似乎可以工作。不幸的是,它有一个致命的缺陷——它不能正确地检测成功或失败。当然,如果我们的 Database::insert() 函数是从正常的控制流中调用的,它可能成功也可能失败,它工作得很好。问题是,我们可能从某个其他对象的析构函数中调用 Database::insert(),而这个对象可能被用于一个抛出异常的作用域中:

class ComplexOperation {
  Database db_;
  public:
  ...
  ~ComplexOperation() {
    try {
      db_.insert(some_record);
    } catch (...) {}    // Shield any exceptions from insert()
  }
};
{
  ComplexOperation OP;
  throw 1;
}    // OP.~ComplexOperation() runs here

现在,db_.insert() 在未捕获异常的存在下运行,因此 std::uncaught_exception() 将返回 true。问题是这并不是我们正在寻找的异常。这个异常并不表明 insert() 失败了,但它将被视为失败,并且数据库插入将被撤销。

我们真正需要知道的是当前正在传播多少个异常。这个说法可能听起来有些奇怪,因为 C++ 不允许同时传播多个异常。然而,我们已经看到这是一个过度简化的说法;第二个异常只要它没有逃离析构函数,就可以很好地传播。同样,如果有嵌套析构函数调用,三个或更多异常也可以传播,我们只需及时捕获它们即可。为了正确解决这个问题,我们需要知道在调用 Database::insert() 函数时正在传播多少个异常。然后,我们可以将其与函数结束时的异常传播数量进行比较,无论我们如何到达那里。如果这些数字相同,insert() 没有抛出任何异常,并且任何现有的异常都不是我们的问题。如果添加了新的异常,insert() 失败了,并且退出处理必须相应地更改。

C++17 允许我们实现这种检测;除了之前已弃用的 std::uncaught_exception()(在 C++20 中被移除),我们现在有一个新的函数,std::uncaught_exceptions(),它返回当前正在传播的异常数量。我们现在可以实现这个 UncaughtExceptionDetector 来检测新的异常:

// Example 10a
class UncaughtExceptionDetector {
  public:
  UncaughtExceptionDetector() :
    count_(std::uncaught_exceptions()) {}
  operator bool() const noexcept {
    return std::uncaught_exceptions() > count_;
  }
  private:
  const int count_;
};

使用这个检测器,我们最终可以实现自动的 ScopeGuard

// Example 10a
template <typename Func, bool on_success, bool on_failure>
class ScopeGuard {
  public:
  ...
  ~ScopeGuard() {
  if ((on_success && !detector_) ||
      (on_failure && detector_)) func_();
  }
  ...
  private:
  UncaughtExceptionDetector detector_;
  ...
};

需要使用 C++17 可能会在使用此技术在受限于较旧语言版本的程序中遇到(希望是短期)障碍。虽然没有其他符合标准、可移植的解决此问题的方法,但大多数现代编译器都有方法来获取未捕获异常计数器。这就是在 GCC 或 Clang(以 __ 开头的名称是 GCC 内部类型和函数)中是如何做的:

// Example 10b
namespace  cxxabiv1 {
  struct cxa_eh_globals;
  extern "C" cxa_eh_globals* cxa_get_globals() noexcept;
}
class UncaughtExceptionDetector {
  public:
  UncaughtExceptionDetector() :
    count_(uncaught_exceptions()) {}
  operator bool() const noexcept {
    return uncaught_exceptions() > count_;
  }
  private:
  const int count_;
  int uncaught_exceptions() const noexcept {
    return *(reinterpret_cast<int*>(
      static_cast<char*>( static_cast<void*>(
        cxxabiv1::cxa_get_globals())) + sizeof(void*)));
  }
};

无论我们使用异常驱动的 ScopeGuard 还是显式命名的 ScopeGuard(可能用于处理错误代码以及异常),我们都已经实现了目标——我们现在可以指定在函数或任何其他作用域结束时必须执行的操作。

在本章末尾,我们将展示另一种可以在网络上找到的 ScopeGuard 实现。这种实现值得考虑,但你也应该意识到它的缺点。

类型擦除的 ScopeGuard

如果你在网上搜索 ScopeGuard 示例,可能会偶然发现一个使用std::function而不是类模板的实现。这个实现本身相当简单:

// Example 11
class ScopeGuard {
  public:
  template <typename Func> ScopeGuard(Func&& func) :
    func_(std::forward<Func>(func)) {}
  ~ScopeGuard() { if (!commit_) func_(); }
  void commit() const noexcept { commit_ = true; }
  ScopeGuard(ScopeGuard&& other) :
    commit_(other.commit_), func_(std::move(other.func_)) {
    other.commit();
  }
  private:
  mutable bool commit_ = false;
  std::function<void()> func_;
  ScopeGuard& operator=(const ScopeGuard&) = delete;
};

注意,这个 ScopeGuard 是一个类,而不是类模板。它有模板构造函数,可以接受与另一个守卫相同的 lambda 表达式或另一个可调用对象。但用于存储该表达式的变量无论可调用对象是什么类型,都具有相同的类型。这种类型是std::function<void()>,它是任何不接受任何参数且不返回任何内容的函数的包装器。如何将任何类型的值存储在某种固定类型的对象中?这就是类型擦除的魔法,我们有一个专门的章节来介绍它(第六章,理解类型擦除)。这个非模板的 ScopeGuard 使得使用它的代码更简单(至少在 C++17 之前),因为没有需要推断的类型:

void Database::insert(const Record& r) {
  S.insert(r);
  ScopeGuard SF([&] { S.finalize(); });
  ScopeGuard SG([&] { S.undo(); });
  I.insert(r);
  SG.commit();
}

然而,这种方法有一个严重的缺点——类型擦除的对象必须进行相当数量的计算才能实现其魔法。至少,它涉及到间接或虚拟函数调用,并且通常还需要分配和释放一些内存。

通过使用第六章的教训,理解类型擦除,我们可以提出一个稍微更有效的类型擦除实现;特别是,我们可以坚持在退出时调用的可调用对象适合守卫的缓冲区:

// Example 11
template <size_t S = 16>
class ScopeGuard : public CommitFlag {
  alignas(8) char space_[S];
  using guard_t = void(*)(void*);
  guard_t guard_ = nullptr;
  template<typename Callable>
  static void invoke(void* callable) {
    (*static_cast<Callable*>(callable))();
  }
  mutable bool commit_ = false;
  public:
  template <typename Callable,
            typename D = std::decay_t<Callable>>
    ScopeGuard(Callable&& callable) :
    guard_(invoke<Callable>) {
    static_assert(sizeof(Callable) <= sizeof(space_));
    ::new(static_cast<void*>(space_))
      D(std::forward<Callable>(callable));
  }
  ScopeGuard(ScopeGuard&& other) = default;
  ~ScopeGuard() { if (!commit_) guard_(space_); }
};

我们可以使用 Google Benchmark 库比较类型擦除的 ScopeGuard 与模板ScopeGuard的运行时成本。结果将取决于我们正在保护的操作:对于长时间计算和昂贵的退出操作,ScopeGuard的运行时差异微不足道。如果作用域内的计算和退出作用域的计算很快,差异将会更加明显:

void BM_nodelete(benchmark::State& state) {
  for (auto _ : state) {
    int* p = nullptr;
    ScopeGuardTypeErased::ScopeGuard SG([&] { delete p; });
    p = rand() < 0 ? new int(42) : nullptr;
  }
  state.SetItemsProcessed(state.iterations());
}

注意,内存永远不会被分配(rand()返回非负随机数),指针p始终是null,因此我们正在基准测试rand()调用以及 ScopeGuard 的开销。为了比较,我们可以显式调用delete,而不使用守卫。结果显示,守卫的模板版本没有可测量的开销,而两种类型擦除的实现都有一些:

Benchmark                              Time
-------------------------------------------
BM_nodelete_explicit                4.48 ns
BM_nodelete_type_erased             6.29 ns
BM_nodelete_type_erased_fast        5.48 ns
BM_nodelete_template                4.50 ns

我们自己的类型擦除版本为每个迭代增加了大约 1 纳秒,而基于std::function的版本则几乎需要两倍的时间。这种类型的基准测试强烈受到编译器优化的影响,并且可能会对代码的微小变化产生非常不同的结果。例如,让我们将代码改为始终构造新对象:

void BM_nodelete(benchmark::State& state) {
  for (auto _ : state) {
    int* p = nullptr;
    ScopeGuardTypeErased::ScopeGuard SG([&] { delete p; });
    p = rand() >= 0 ? new int(42) : nullptr;
  }
  state.SetItemsProcessed(state.iterations());
}

现在我们对循环的每次迭代都调用 operator new,因此相应的删除也必须发生。这次,编译器能够比类型擦除版本更好地优化模板ScopeGuard

Benchmark                              Time
-------------------------------------------
BM_delete_explicit                  4.54 ns
BM_delete_type_erased               13.4 ns
BM_delete_type_erased_fast          12.7 ns
BM_delete_template                  4.56 ns

总体来说,在这里使用类型擦除并没有太大的理由。运行时成本可能微不足道或相当显著,但通常没有什么可以获得的。类型擦除版本的唯一优势是守卫本身总是同一类型。但守卫的类型几乎总是无关紧要:我们创建变量时使用auto或构造函数模板参数推导,我们可能需要的唯一显式操作是对守卫进行解除武装。因此,我们永远不需要编写任何依赖于守卫类型的代码。总的来说,基于模板的 ScopeGuard,无论是否有宏,都是自动释放资源并在作用域结束时执行其他操作的优选模式。

摘要

在本章中,我们详细研究了编写异常安全和错误安全代码的最佳 C++模式之一。ScopeGuard 模式允许我们在作用域完成后执行任意操作,即 C++代码片段。作用域可能是一个函数,循环体,或者只是插入到程序中以管理局部变量生命周期的作用域。执行到最后的操作可能取决于作用域的成功完成,无论定义如何。ScopeGuard 模式在成功或失败通过返回代码或异常指示时同样有效,尽管在后一种情况下我们可以自动检测失败(对于返回代码,程序员必须明确指定哪些返回值表示成功,哪些不表示成功)。我们已经观察到了 ScopeGuard 模式的演变,随着更现代的语言特性的使用。在其最佳形式中,ScopeGuard 提供了一种简单声明式的方式来指定后置条件和延迟操作,如清理或回滚,这种方式对于任何需要提交或撤销的操作数量都是简单可组合的。

下一章将描述另一种非常 C++特定的模式,即 Friends Factory,它是一种工厂模式,只是在程序执行期间制造对象,而不是在编译期间制造函数。

问题

  1. 什么是错误安全或异常安全的程序?

  2. 我们如何使执行多个相关操作的例程成为错误安全的?

  3. RAII 是如何帮助编写错误安全程序的?

  4. ScopeGuard 模式是如何泛化 RAII 惯用的?

  5. 程序如何自动检测函数成功退出和失败的情况?

  6. 类型擦除的 ScopeGuard 有什么优点和缺点?

第十二章:朋友工厂

在本章中,我们将讨论建立友谊。在这里,我们指的是 C++的友元,而不是 C++的朋友(你可以在你当地的 C++用户组中找到他们)。在 C++中,类的友元是函数或其他类,它们被授予对类的特殊访问权限。在这方面,它们与你的朋友并没有太大的不同。但 C++可以根据需要,按需制造朋友!

本章将涵盖以下主题:

  • 友元函数在 C++中是如何工作的,它们做什么?

  • 你应该在什么情况下使用友元函数而不是类成员函数?

  • 如何将友元与模板结合

  • 如何从模板生成友元函数

技术要求

本章的示例代码可以在以下 GitHub 链接中找到:https://github.com/PacktPublishing/Hands-On-Design-Patterns-with-CPP-Second-Edition/tree/master/Chapter12.

C++中的友元

让我们先回顾一下 C++授予类友谊的方式以及这种行动的影响,以及何时以及出于什么原因应该使用友谊(“我的代码直到我在每个地方添加friend才编译”不是一个有效的理由,而是一个表明接口设计不佳的迹象 - 相反,重新设计你的类)。

如何在 C++中授予友谊

友元是 C++的一个概念,它适用于类并影响对类成员的访问(访问publicprivate控制的)。通常,公共成员函数和数据成员对任何人都是可访问的,而私有成员函数仅对类本身的其它成员函数是可访问的。以下代码无法编译,因为数据成员C:x_是私有的:

// Example 01
class C {
  int x_;
  public:
  C(int x) : x_(x) {}
};
C increase(C c, int dx) {
  return C(c.x_ + dx);     // Does not compile
}

解决这个特定问题的最简单方法是将increase()作为一个成员函数,但让我们暂时保留这个版本。另一种选择是放宽访问权限,使C::x_成为公共的。这并不是一个好主意,因为它暴露了x_ - 不仅对increase(),而且对任何想要直接修改C类型对象的代码。我们需要的是使x_increase()公开,而对其他人则不公开。这是通过友元声明来实现的:

// Example 02
class C {
  int x_;
  public:
  C(int x) : x_(x) {}
  friend C increase(C c, int dx);
};
C increase(C c, int dx) {
  return C(c.x_ + dx);    // Now it compiles
}

友元声明所做的只是给指定的函数赋予与类成员函数相同的访问权限。还有一种形式的友元声明,它授予的不是函数的友谊,而是类的友谊;这只是授予该类所有成员函数友谊的一种方式。

友元函数与成员函数的比较

我们确实需要回到这个问题,为什么不直接将increase()作为C类的成员函数呢?在前一节的示例中,实际上并没有这个必要 - increase()显然是C类公共接口的一部分,因为它是C支持的操作之一。它需要特殊的访问权限来完成其工作,因此它应该是一个成员函数。然而,也存在成员函数有局限性或根本不能使用的情况。

让我们考虑 C 类的一个加法运算符——这是使表达式如 c1 + c2 能够编译(如果两个变量都是类型 C)所必需的。加法,或 operator+(),可以声明为一个成员函数:

// Example 03
class C {
  int x_;
  public:
  C(int x) : x_(x) {}
  C operator+(const C& rhs) const {
    return C(x_ + rhs.x_);
  }
};
...
C x(1), y(2);
C z = x + y;

这段代码编译并且完全按照我们的预期工作;看起来并没有什么明显的问题。那是因为到目前为止确实没有。但我们不仅能添加类型为 C 的对象:

// Example 03
C x(1);
C z = x + 2;

这段代码也能编译,并且指向了 C 类声明中的一个微妙细节——我们没有显式地定义 C(int) 构造函数。这个构造函数现在引入了从 intC 的隐式转换,这就是表达式 x + 2 编译的原因——首先,2 被转换成一个临时对象,C(2),使用我们提供的构造函数,然后调用成员函数,x.operator+(const C&)——右侧是我们刚刚创建的临时对象。临时对象在表达式评估后立即被删除。从整数到隐式转换相当广泛,可能是一个疏忽。让我们假设它不是,并且我们确实希望表达式如 x + 2 能够编译。那有什么不满意的呢?再次,到目前为止没有。我们设计中的令人反感之处在于接下来会发生什么:

// Example 03
C x(1);
C z = 2 + x; // Does NOT compile

如果 x + 2 能够编译,你合理地预期 2 + x 也能编译并给出相同的结果(在数学的某些领域加法不是交换的,但让我们在这里坚持算术)。它不能编译的原因是编译器无法从这里访问 C 类中的 operator+(),并且没有其他 operator+() 可用于这些参数。当使用成员函数运算符时,x + y 表达式只是对等价(如果冗长)调用 x.operator+(y) 的语法糖。对于任何其他二元运算符,如乘法或比较,也是如此。

重点是,成员函数运算符在表达式的第一个参数上调用(因此技术上,x + yy + x 并不相同;成员函数在不同的对象上调用,但实现方式使得两者都给出相同的结果)。在我们的情况下,成员函数必须在数字 2 上调用,这是一个整数,根本没有成员函数。那么,表达式 x + 2 是如何编译的呢?实际上非常简单:x + 本身意味着 x.operator+(),而参数是 + 之后的所有内容。在我们的情况下,它是 2。所以,要么 x.operator+(2) 编译,要么不编译,但在任何情况下,对 operator+ 调用的搜索都结束了。我们 C 类中的 int 隐式转换使这个调用编译。那么,为什么编译器不尝试对第一个参数进行转换呢?答案是,它永远不会这样做,因为它没有指导如何转换 - 可能存在无数其他具有 operator+() 成员函数的类型,其中一些可能接受 C 类作为它们的参数,或者 C 可以转换成某种类型。编译器不会尝试探索几乎无限多的这种可能的转换。

如果我们想在表达式中使用加号,其中第一个类型可能是内置类型或任何没有或不能有 operator+() 成员函数的其他类型,那么我们必须使用非成员函数。没问题;我们知道如何编写这些函数:

C operator+(const C& lhs, const C& rhs) {
  return C(lhs.x_ + rhs.x_);
}

但现在我们失去了对私有数据成员 C::x_ 的访问,因此我们的非成员 operator+() 也不能编译。我们在上一节中看到了那个问题的解决方案 - 我们需要将其定义为友元:

// Example 04
class C {
  int x_;
  public:
  C(int x) : x_(x) {}
  friend C operator+(const C& lhs, const C& rhs);
};
C operator+(const C& lhs, const C& rhs) {
  return C(lhs.x_ + rhs.x_);
}
...
C x(1), y(2);
C z1 = x + y;
C z2 = x + 2;
C z3 = 1 + y;

现在,一切编译并按预期工作 - 非成员函数 operator+() 只是一个有两个类型为 const C& 的参数的非成员函数。它的规则与任何其他此类函数相同。

如果我们定义其体 in situ(紧随声明之后,在类内部),我们可以避免重复编写 operator+() 的声明:

// Example 05
class C {
  int x_;
  public:
  C(int x) : x_(x) {}
  friend C operator+(const C& lhs, const C& rhs) {
    return C(lhs.x_ + rhs.x_);
  }
};

后一个例子与前面的例子略有不同,但通常你不会看到差异,所以这只是一个风格问题 - 将函数体移动到对象中会使对象本身更长,但将函数定义在类外则需要更多的输入(以及如果代码发生变化,友元声明和实际函数之间可能存在的差异)。我们将在下一节中解释 in situ 友元声明的复杂性。

无论哪种方式,友元函数实际上是类公共接口的一部分,但出于技术原因,我们在此情况下更倾向于使用非成员函数。甚至有一种情况,非成员函数是唯一的选择。考虑 C++ 输入/输出运算符,例如插入器,或 operator<<(),它们用于将对象写入流(例如,std::cout)。我们希望能够像这样打印 C 类型的对象:

C c1(5);
std::cout << c1;

对于我们的类型 C,没有标准的operator<<(),因此我们必须声明自己的。插入器是一个二元运算符,就像加号一样(它两边都有参数),所以,如果它是一个成员函数,它必须是一个左边的对象上的函数。看看前面的语句——在std::cout << c1表达式中,左边的对象不是我们的对象c1,而是标准输出流std::cout。这就是我们必须添加成员函数的对象,但我们不能——std::cout在 C++标准库头文件中某处被声明,而且没有方法可以扩展其接口,至少不是直接的方式。我们可以声明C类上的成员函数,但这没有帮助——只有左边的对象的成员函数被考虑。唯一的替代方案是非成员函数。第一个参数必须是std::ostream&

// Example 06
class C {
  ...
  friend std::ostream& operator<<(std::ostream& out,
                                  const C& c);
};
std::ostream& operator<<(std::ostream& out, const C& c) {
  out << c.x_;
  return out;
}

这个函数必须被声明为friend,因为它还需要访问C类的私有数据。它也可以就地定义:

// Example 07
class C {
  int x_;
  public:
  C(int x) : x_(x) {}
  friend std::ostream& operator<<(std::ostream& out,
                                  const C& c) {
    out << c.x_;
    return out;
  }
};

按照惯例,返回值是相同的流对象,因此插入器运算符可以被链式调用:

C c1(5), c2(7);
std::cout << c1 << c2;

最后一条语句的解释方式是(std::cout << c1) << c2,这归结为operator<<(operator<<(std::cout, c1), c2)。外层的operator<<()是在内层operator<<()的返回值上调用的,而这个返回值是相同的:std::cout。再次强调,插入器是C类的公共接口的一部分——它使得类型C的对象可打印。然而,它必须是一个非成员函数。

C++中关于friend使用介绍的章节略过了几个偶尔很重要的微妙细节,因此让我们花些时间来阐明它们。

友谊的微妙细节

首先,让我们谈谈声明一个未定义的friend函数(即,没有就地实现)的效果:

// Example 08
class C {
  friend C operator+(const C& lhs, const C& rhs);
  ...
};
C operator+(const C& lhs, const C& rhs) { … }

顺便说一下,你将friend声明放在类的公共部分还是私有部分,这完全无关紧要。但关于friend声明本身:我们授予了哪个函数访问权限?这是我们程序中第一次提到具有此签名的operator+()(其定义必须在类C本身声明之后出现)。结果是friend语句起到了双重作用:它还充当了函数的前置声明。

当然,没有规则阻止我们自行提前声明相同的函数:

// Example 09
class C;
C operator+(const C& lhs, const C& rhs);
class C {
  friend C operator+(const C& lhs, const C& rhs);
  ...
};
C operator+(const C& lhs, const C& rhs) { … }

没有必要为了使用friend而单独使用前置声明,但如果我们想在程序中更早地使用operator+(),可能出于其他原因就需要这样做。

注意,如果friend提前声明与函数定义不匹配,或者friend语句与提前声明不匹配,编译器不会警告你。如果friend语句中的函数签名与实际函数不同,你将授予某个其他函数友谊,而这个函数是提前声明的但未在任何地方定义。你很可能在编译实际函数时遇到语法错误,因为现在它没有对类的特殊访问权限,无法访问其私有成员。但错误信息不会提及friend语句与函数定义之间的不匹配。你只需要知道,如果你授予了一个函数友谊,而编译器没有看到它,那么friend语句中的函数签名与函数定义中的签名之间存在差异。

当然,如果friend语句不仅声明了函数,还定义了它,那么它根本不是作为一个提前声明来执行的。但在这个情况下,还有一个细微之处,即新函数是在哪个作用域中定义的?考虑一下,如果你在类内部声明了一个静态函数,那么这个函数存在于类的自身作用域中。如果我们有一个类C和一个静态函数f(),这个函数在类外部的正确名称是C::f

class C {
  static void f(const C& c);
  ...
};
C c;
C::f(c);    // Must be called as C::f() not f()

很容易看出,这种情况并不适用于friend函数:

class C {
  friend void f(const C& c);
  ...
};
C c;
C::f(c);    // Does not compile – not a member

考虑到我们已经看到,没有定义的friend语句提前声明了一个在类外部定义的函数。因此,如果friend声明在包含类的(在我们的例子中是全局作用域,但可能是一个namespace)作用域中提前声明了一个函数,那么具有就地定义的friend语句必须在相同的作用域中定义一个函数,即它将函数注入到类的外部作用域。对吗?是的,但并不完全是这样。

在实践中,你几乎不可能注意到“并不完全”的部分,一切都会表现得好像函数只是简单地注入到包含的作用域中。需要相当复杂的例子来演示实际发生的情况:

// Example 10
class C {
  static int n_;
  int x_;
  public:
  C(int x) : x_(x) {}
  friend int f(int i) { return i + C::n_; }
  friend int g(const C& c) { return c.x_ + C::n_; }
};
int C::n_ = 42;
...
C c(1);
f(0);        // Does not comppile - no ADL
g(c);        // Compiles fine

在这里,我们有两个friend函数,f()g(),它们都在声明点定义。函数g()表现得就像它在全局作用域(或者如果我们使用了namespace,则是包含类C的作用域)中声明一样。但是,在同一作用域中对f()的调用无法编译,错误信息将是“函数f在此作用域中未声明”或类似的内容。编译器错误信息的措辞差异很大,但错误的本质是这样的:对f()的调用没有找到要调用的函数。f()g()函数之间的唯一区别是它们的参数;这最终证明是关键。

要理解这一点,我们必须知道当您编写像f(0)这样的函数调用时,编译器是如何查找函数名的。首先,这是一个非成员函数,因此编译器只查找那些(它也可以是一个函数对象——一个具有operator()的类,但这对现在来说并不重要,因为我们没有这样的类)。其次,编译器搜索当前作用域,即调用发生的作用域,以及所有包含的作用域,如嵌套函数体、类和命名空间,一直到全局作用域。但这还不是结束:编译器还会查看函数的参数,并搜索这些参数类型声明的作用域(或作用域)。这一步被称为依赖参数查找ADL),也称为科宁查找,以纪念安德鲁·科宁(他否认发明了它)。在完成所有这些查找后,编译器在其找到的所有具有匹配名称的函数上执行重载解析(即,没有给任何特定作用域赋予优先级)。

那么,这与friend函数有什么关系呢?只有这一点:根据标准,由friend语句定义的函数被注入到包含类的范围内,但只能通过依赖参数的查找来找到。

这解释了我们刚才看到的行为:函数f()g()都被注入到全局作用域,因为这是包含类C的作用域。函数g()有一个类型为const C&的参数,因此它通过 ADL 在包含类C的作用域中被找到。函数f()有一个类型为int的参数,而内置类型被认为是在任何作用域中声明的,它们“就是如此。”由于无法执行 ADL,并且作为friend定义的函数仅通过 ADL 找到,因此函数f()根本无法找到。

注意,这种情况非常脆弱。例如,如果我们提前声明了同一个函数,它可以在声明的作用域中找到,而不需要 ADL:

// Example 11
int f(int i);    // Forward declaration
class C {
  ...
  friend int f(int i) { return i + C::n_; }
};
f(0);        // No problem

如果friend语句只声明了函数,稍后定义:

// Example 12
class C {
  ...
  friend int f(int i);    // Forward declaration
};
int f(int i) { return i + C::n_; }
f(0);        // No problem

为什么我们不那么经常将其视为问题?因为大多数情况下,在类内部声明的friend函数至少有一个与类本身类型相关的参数(如指针或引用)。我们之前看到的operator+()operator<<()都属于这一类。毕竟,声明函数为friend的唯一原因是为了使其能够访问类的私有成员,但如果它不操作类类型的对象,则不需要这种访问。作为一个非成员函数,它如何通过其参数之外的方式访问这样的对象?当然,有方法,但在实践中这种情况很少发生。

当程序定义自己的operator new时,还会发生另一个微妙且可能危险的案例。这并不违法,类特定的内存分配操作符通常是必要的。但声明一个并不那么简单。自定义operator new的两种常见用途:第一个是操作符为正在分配的类定义,通常在类内部定义。这些被称为类特定操作符,它们不是我们现在感兴趣的主题。我们需要解释第二种常见情况,即自定义operator new被定义为使用特定的分配器类分配内存。这通常是这样做:

// Example 13
class Alloc {
  void* alloc(size_t s);    // Allocates memory
  void dealloc(void* p);     // Releases memory
  friend void* operator new (size_t s, Alloc* a);
  friend void operator delete(void* p, Alloc* a);
};
void* operator new (size_t s, Alloc* a) {
  return a->alloc(s);
}
void operator delete(void* p, Alloc* a) {
  a->dealloc(p);
}
class C { ... };
Alloc a;
C* c = new (&a) C;

有几个细节需要注意:首先,我们的分配器类Allocoperator new提供了一个重载:每个operator new的第一个参数是强制性的,必须是分配的大小(编译器会填充这个值)。第二个参数(以及如果需要的话,第三个等)是任意的;在我们的情况下,它是将提供此分配内存的分配器类的指针。operator new本身是在全局作用域中的一个函数,并且它被声明为类Alloc的朋友。如果你想知道如何调用我们也声明的匹配的operator delete,答案是你不能;这个操作符仅由编译器本身在operator new的分配成功但新对象的构造函数抛出异常的情况下使用。编译器将使用与operator new相同的参数调用operator delete。但当你想要删除这个对象并且它的生命周期结束时,这并不是你删除对象的方式:无法向delete表达式添加额外的参数,所以你必须自己调用析构函数,然后显式地将内存返回给分配器。

这完全符合预期。编译器寻找对operator new(size_t, Alloc*)调用的最佳匹配,并如预期地在全局作用域中找到我们的自定义operator new

现在,你可能会决定将操作符的主体移动到friend语句中,以节省一些输入并避免friend声明和实际的operator new定义不同步的可能性。这只需要进行微小的更改:

// Example 14
class Alloc {
  void* alloc(size_t s);    // Allocates memory
  void dealloc(void* p);     // Releases memory
  friend void* operator new (size_t s, Alloc* a) {
    return a->alloc(s);
  }
  friend void operator delete(void* p, Alloc* a) {
    a->dealloc(p);
  }
};
class C { ... };
Alloc a;
C* c = new (&a) C;

这个程序几乎肯定能编译。在某些编译器上它将正常工作,在其他编译器上则会产生可怕的内存损坏。不幸的是,那些其他编译器是正确的。这是正在发生的事情:new 表达式(这是对语法“new … some-type”的标准名称)在查找匹配的 operator new 时有特殊的规则。具体来说,查找是在正在构造的类的范围(在我们的例子中是类 C)和全局范围内进行的(这些规则在标准的 [expr.new] 节中定义)。请注意,没有在 operator new 自身的参数范围内进行查找,即没有参数依赖查找。由于由 friend 语句定义的函数只能通过参数依赖查找找到,所以它根本找不到。但程序是如何编译的呢?这是因为 operator new 的另一个重载,所谓的 placement new。这个重载的形式是:

void* new(size_t size, void* addr) { return addr; }

它在标准头文件 <new> 中声明,该头文件被许多其他头文件包含,因此你的程序很可能已经包含它,即使你没有明确这样做。

placement new 的意图是在之前分配的内存中构造一个对象(我们在之前关于类型擦除的章节中使用了这种方法来在类内部预留空间构造对象)。但它也是我们的 operator new(size_t, Alloc*) 调用的可能匹配,因为 Alloc* 可以隐式转换为 void*。我们自己的重载不需要这种转换,会是一个更好的匹配,但不幸的是,当在原地定义时,它不会被查找。结果是类型 C 的对象在分配器对象本身已经占用的内存中构造,在这个过程中破坏了后者对象。

您可以使用我们的示例来测试您的编译器:当在类外部定义时,自定义的 operator new 应该被调用,并且程序应该按预期工作。但是当由 friend 语句定义时,只能找到 placement new(一些编译器还会发出关于覆盖已构造对象的警告)。

到目前为止,我们的类只是普通类,不是模板,我们的非成员函数声明的朋友只是普通非模板函数。现在,让我们考虑如果类成为模板,需要做哪些改变(如果有的话)。

朋友和模板

C++ 中的类和函数都可以是模板,我们可以有几种不同的组合——一个类模板可以授予非模板函数朋友权限,如果其参数类型不依赖于模板参数;这不是一个特别有趣的情况,当然也不能解决我们现在正在处理的问题。当需要操作模板参数类型时,正确地选择朋友变得更加困难。

模板类的朋友

让我们先让我们的 C 类成为模板:

template <typename T> class C {
  T x_;
  public:
  C(T x) : x_(x) {}
};

我们仍然想要添加 C 类型的对象并将它们打印出来。我们已经考虑了为什么前者用非成员函数完成更好,以及后者不能以任何其他方式完成的原因。这些原因对类模板同样有效。

没问题——我们可以声明与我们的模板类一起使用的模板函数,并执行之前章节中非模板函数所做的工作。让我们从 operator+() 开始:

template <typename T>
C<T> operator+(const C<T>& lhs, const C<T>& rhs) {
  return C<T>(lhs.x_ + rhs.x_);
}

这是我们之前看到过的相同函数,只是变成了一个模板,可以接受类模板 C 的任何实例化。请注意,我们在这个模板上参数化了类型 T,即 C 的模板参数。当然,我们可以简单地声明以下内容:

template <typename C>
C operator+(const C& lhs, const C& rhs) { // NEVER do this!
  return C<T>(lhs.x_ + rhs.x_);
}

然而,这引入了一个——不亚于全局作用域——声称接受任何类型两个参数的 operator+()。当然,它实际上只处理具有 x_ 数据成员的类型。那么,当我们有一个也是可添加的模板类 D,但它有一个 y_ 数据成员而不是 x_ 数据成员时,我们将怎么办呢?

模板的早期版本至少限制在类模板 C 的所有可能实例化上。当然,它也面临着我们第一次尝试非成员函数时遇到的问题——它无法访问私有数据成员 C<T>::x_。没问题——毕竟,本章是关于朋友的。但朋友是针对什么的?整个类模板 C 将有一个朋友声明,只为所有 T 类型,并且它必须适用于模板函数 operator+() 的每个实例化。看起来我们必须授予整个函数模板朋友权限:

// Example 15
template <typename T> class C {
  T x_;
  public:
  C(T x) : x_(x) {}
  template <typename U>
  friend C<U> operator+(const C<U>& lhs, const C<U>& rhs);
};
template <typename T>
C<T> operator+(const C<T>& lhs, const C<T>& rhs) {
  return C<T>(lhs.x_ + rhs.x_);
}

注意正确的语法——关键字 friend 出现在模板及其参数之后,但在函数的返回类型之前。此外,请注意,我们必须重命名嵌套朋友声明的模板参数——T 标识符已经被用于类模板参数。同样,我们可以在函数定义中将模板参数 T 重命名,但不必这样做——就像在函数声明和定义中一样,参数只是一个名称;它只在每个声明中有意义——同一函数的两个声明可以使用不同的名称来表示相同的参数。我们可以做的替代方案是将函数体内联,放入类中:

// Example 16
template <typename T> class C {
  int x_;
  public:
  C(int x) : x_(x) {}
  template <typename U>
  friend C<U> operator+(const C<U>& lhs, const C<U>& rhs) {
    return C<U>(lhs.x_ + rhs.x_);
  }
};

你可能会指出,我们在模板类 C 的封装上打开了一个相当大的漏洞——通过将 C<T> 的任何实例化与整个模板函数视为友元,例如,我们使 operator+(const C&<double>, const C&<double>) 的实例化成为 C<int> 的友元。这显然是不必要的,尽管这种做法可能不会立即显现出危害(一个展示实际危害的例子将会相当复杂,因为这是必要的)。但这个问题忽略了我们的设计中一个更严重的问题,这个问题在我们开始使用它来添加内容时变得明显。它在一开始是可行的:

C<int> x(1), y(2);
C<int> z = x + y; // So far so good...

但仅到此为止:

C<int> x(1), y(2);
C<int> z1 = x + 2; // This does not compile!
C<int> z2 = 1 + 2; // Neither does this!

但这难道不是使用非成员函数的原因吗?我们的隐式转换怎么了?这曾经是可行的!答案在细节中——它曾经可行,但对于非模板函数 operator+(),模板函数的转换规则非常不同。确切的技术细节可以通过标准获得,但需要极大的勤奋和努力,但这是关键——在考虑非成员、非模板函数时,编译器会寻找所有具有给定名称(在我们的例子中是 operator+)的函数,然后检查它们是否接受正确的参数数量(可能考虑默认参数),然后检查对于每个这样的函数,对于它的每个参数,是否存在从提供的参数到指定参数类型的转换(关于哪些转换被考虑的规则相当复杂,但让我们说,用户提供的隐式转换和内置转换,如非 constconst,都被考虑)。如果这个过程只产生一个函数,那么就调用该函数(否则编译器要么选择 最佳 覆载,要么抱怨有多个候选者且调用是模糊的)。

对于模板函数,这个过程将再次产生几乎无限多的候选者——每个具有 operator+() 名称的模板函数都必须在所有已知类型上实例化,以检查是否足够多的类型转换可用以使其工作。相反,尝试了一个更简单的过程——除了前一段中描述的所有非模板函数(在我们的例子中,没有)之外,编译器还考虑了具有给定名称(再次是 operator+)的模板函数的实例化,以及所有参数类型与函数调用位置上的函数参数类型相等的类型(允许所谓的平凡转换,例如添加 const)。

在我们的例子中,x + 2 表达式中的参数类型分别是 C<int>int。编译器会寻找一个接受这种类型两个参数的模板函数 operator+ 的实例化,而用户提供的转换不被考虑。当然,这样的函数不存在,因此对 operator+() 的调用无法解析。

问题的根源在于我们真的希望用户提供的转换能够被编译器自动使用,但只要我们试图实例化一个模板函数,这种情况就不会发生。我们可以声明一个非模板函数 operator+(const C<int>&, const C<int>&),但使用 C 模板类,我们必须为 C 类可能实例化的每个 T 类型声明一个。

模板友元工厂

我们需要的是为每个用于实例化类模板 CT 类型自动生成一个非模板函数。当然,我们无法提前生成所有这些函数——理论上,可能有几乎无限数量的 T 类型可以与模板类 C 一起使用。幸运的是,我们不需要为这些类型中的每一个生成 operator+(),我们只需要为实际在程序中使用此模板的类型生成它们。

按需生成友元

我们即将看到的模式是一个非常古老的模式,由 John Barton 和 Lee Nackman 在 1994 年为了完全不同的目的引入——他们用它来绕过当时编译器存在的一些限制。发明者提出了 受限模板扩展 这个名字,但从未被广泛使用。多年以后,Dan Sacks 提出了 友元工厂 这个名字,但这个模式有时也简单地被称为 Barton-Nackman 技巧

这种模式看起来非常简单,与本章前面编写的代码非常相似:

// Example 17
template <typename T> class C {
  int x_;
  public:
  C(int x) : x_(x) {}
  friend C operator+(const C& lhs, const C& rhs) {
    return C(lhs.x_ + rhs.x_);
  }
};

我们正在利用一个非常特定的 C++ 功能,因此代码必须精确编写。非模板友元函数定义在类模板内部。此函数必须定义为内联的;它不能先声明为友元然后稍后定义,除非是显式模板实例化——我们可以在类内部声明友元函数,然后定义 operator+<const C<int>>&, const C<int>&),这对于 C<int> 是有效的,但对于 C<double> 则无效(因为我们不知道调用者以后可能会实例化哪些类型,这并不很有用)。它可能具有 T 类型的参数,模板参数,C<T> 类型(在类模板内部可以简单地称为 C),以及任何其他固定或仅依赖于模板参数的类型,但它本身不能是模板。每个 C 类模板的实例化,无论模板参数类型的组合如何,都生成一个具有指定名称的非模板、非成员函数。请注意,生成的函数是非模板函数;它们是常规函数,并且通常的转换规则适用于它们。我们现在回到了非模板的 operator+(),所有转换都完全按照我们想要的方式进行:

C<int> x(1), y(2);
C<int> z1 = x + y; // This works
C<int> z2 = x + 2; // and this too
C<int> z3 = 1 + 2; // so does this

这就是全部的模式。有几个细节我们必须注意。首先,关键字 friend 不能省略。一个类通常不能生成非成员函数,除非声明为友元。即使函数不需要访问任何私有数据,为了从类模板的实例化中自动生成非模板函数,这些函数必须被声明为友元(可以通过类似的方式生成静态非成员函数,但二进制运算符不能是静态函数——标准明确禁止)。其次,生成的函数放置在包含类的范围内,但必须通过参数依赖查找来找到,正如我们在本章前面学到的。例如,让我们为我们的 C 模板类定义插入操作符,但在这样做之前,将整个类包裹在一个命名空间中:

// Example 18
namespace NS {
template <typename T> class C {
  int x_;
  public:
  C(int x) : x_(x) {}
  friend C operator+(const C& lhs, const C& rhs) {
    return C(lhs.x_ + rhs.x_);
  }
  friend std::ostream&
  operator<<(std::ostream& out, const C& c) {
    out << c.x_;
    return out;
  }
};
} // namespace NS

现在我们可以添加并打印 C 类型的对象:

NS::C<int> x(1), y(2);
std::cout << (x + y) << std::endl;

注意,尽管 C 类模板现在位于命名空间 NS 中,并且必须这样使用(NS::C<int>),我们并不需要做任何特殊的事情来调用 operator+()operator<<()。这并不意味着它们是在全局作用域中生成的。不,它们仍然在 NS 命名空间中,但我们看到的是参数依赖查找正在起作用——例如,当寻找名为 operator+() 的函数时,编译器会考虑当前作用域(即全局作用域,且没有)以及函数参数定义的作用域。在我们的例子中,operator+() 至少有一个参数是 NS::C<int> 类型,这会自动将 NS 命名空间中声明的所有函数都纳入考虑。友元工厂在其包含类模板的作用域中生成其函数,这当然是 NS 命名空间。因此,查找找到了定义,+<< 操作符的解析正是我们期望的方式。请放心,这是设计好的,绝非偶然;参数查找规则被精心调整以产生这个期望和预期的结果。

很容易证明,尽管友元函数是在包含类的(在我们的例子中是命名空间 NS)作用域中生成的,但它们只能通过参数依赖查找来找到。一个直接尝试不使用参数依赖查找来查找函数的尝试将会失败:

auto p = &NS::C<int>::operator+; // Does not compile

友元工厂模式与我们之前研究过的模式之一也有联系。

友元工厂和奇特重复的模板模式

友元工厂是一种模式,它从每个类模板的实例化中合成一个非模板、非成员函数——每次模板在新的类型上实例化时,都会生成一个新的函数。对于其参数,这个函数可以使用在该类模板实例化中可以声明的任何类型。通常,这是该类本身,但它可以是模板所知的任何类型。

以这种方式,友元工厂可以与operator!=()一起使用,可以通过operator==()实现:

// Example 19
template <typename D> class B {
  public:
  friend bool operator!=(const D& lhs, const D& rhs) {
    return !(lhs == rhs);
  }
};
template <typename T> class C : public B<C<T>> {
  T x_;
  public:
  C(T x) : x_(x) {}
  friend bool operator==(const C& lhs, const C& rhs) {
    return lhs.x_ == rhs.x_;
  }
};

在这里,派生类C使用友元工厂模式,直接从类模板的实例化中生成一个非模板函数用于二进制operator==()。它还从基类B继承,这会触发对该模板的实例化,进而为已经生成operator==()的每个类型生成一个非模板函数operator!=()

CRTP 的第二个用途是将成员函数转换为非成员函数。例如,二进制operator+()有时是用operator+=()实现的,而operator+=()始终是一个成员函数(它作用于其第一个操作数)。为了实现二进制operator+(),必须有人负责转换到该对象类型,然后才能调用operator+=()。这些转换是由使用友元工厂生成的通用 CRTP 基类生成的二进制运算符提供的。同样,如果我们建立我们的类有一个print()成员函数的约定,插入运算符也可以生成:

// Example 20
template <typename D> class B {
  public:
  friend D operator+(const D& lhs, const D& rhs) {
    D res(lhs);
    res += rhs; // Convert += to +
    return res;
  }
  friend std::ostream&
  operator<<(std::ostream& out, const D& d) {
    d.print(out);
    return out;
  }
};
template <typename T> class C : public B<C<T>> {
  T x_;
  public:
  C(T x) : x_(x) {}
  C operator+=(const C& incr) {
    x_ += incr.x_;
    return *this;
  }
  void print(std::ostream& out) const {
    out << x_;
  }
};

以这种方式,CRTP 可以用来添加样板接口,同时将实现委托给派生类。毕竟,它是一个静态(编译时)委托模式。

摘要

在本章中,我们了解了一种非常 C++特定的模式,最初作为早期有缺陷的 C++编译器的解决方案而引入,但几年后找到了新的用途。友元工厂用于从类模板的实例化中生成非模板函数。作为非模板函数,这些生成的友元在参数转换方面比模板函数具有更灵活的规则。我们还学习了参数依赖查找、类型转换和友元工厂如何协同工作,通过一个远非直观的过程,提供了一种看起来非常自然的结果。

下一章将描述一种完全不同类型的工厂——一种基于经典工厂模式的 C++模式,它解决语言中的一种不对称性——所有成员函数,甚至是析构函数,都可以是虚拟的,除了构造函数。

问题

  1. 声明一个函数为友元有什么效果?

  2. 授予函数友元权限与函数模板的友元权限有什么区别?

  3. 为什么二进制运算符通常实现为非成员函数?

  4. 为什么插入运算符总是实现为非成员函数?

  5. 模板函数和非模板函数的参数转换之间主要区别是什么?

  6. 我们如何使模板实例化的过程同时生成一个独特的非模板、非成员函数?

第十三章:虚构造函数和工厂

在 C++中,任何类的任何成员函数,包括其析构函数,都可以被声明为虚函数——唯一的例外是构造函数。没有虚函数,在编译时就可以知道调用成员函数的对象的确切类型。因此,构造的对象类型在编译时总是已知的,在构造函数调用时已知。尽管如此,我们经常需要构造在运行时才知道类型的对象。本章描述了几个相关的模式和惯用法,以各种方式解决这个设计问题,包括工厂模式。

本章将涵盖以下主题:

  • 为什么无法使构造函数成为虚函数

  • 如何使用工厂模式来延迟构造对象类型的选择,直到编译时

  • 使用 C++惯用法来多态地构造和复制对象

技术要求

本章的示例代码可以在以下 GitHub 链接中找到:github.com/PacktPublishing/Hands-On-Design-Patterns-with-CPP_Second_Edition/tree/master/Chapter13.

为什么构造函数不能是虚函数

我们已经理解了多态是如何工作的——当通过基类指针或引用调用虚函数时,该指针或引用用于访问类中的 v 指针。v 指针用于识别对象的真正类型,即对象创建时所用的类型。这可能就是基类本身,或者是任何派生类之一。实际上调用的是该对象上的成员函数。那么,为什么构造函数不能这样做呢?让我们来调查一下。

对象何时获得其类型?

很容易理解为什么我们之前描述的过程不能用于创建虚构造函数。首先,从先前过程的描述中可以明显看出——作为其中的一部分,我们识别了对象创建时所用的类型。这只能在对象构造之后发生——在构造之前,我们还没有这种类型的对象,只有一些未初始化的内存。另一种看待方式是——在虚函数被调度到正确的类型之前,需要查找 v 指针。谁将正确的值放入 v 指针中?考虑到 v 指针唯一地标识了对象的类型,它只能在构造过程中初始化。这意味着它在此之前没有被初始化。但如果它没有被初始化,就不能用它来调度虚函数调用。因此,我们再次意识到构造函数不能是虚函数。

对于层次结构中的派生类,确定类型的流程更加复杂。我们可以尝试观察对象在构造过程中的类型。最简单的方法是使用 typeid 操作符,它返回有关对象类型的详细信息,包括类型的名称:

// Example 01
#include <iostream>
#include <typeinfo>
using std::cout;
using std::endl;
template <typename T>
auto type(T&& t) { return typeid(t).name(); }
class A {
  public:
  A() { cout << "A::A(): " << type(*this) << endl; }
  virtual
  ~A() { cout << "A::~A(): " << type(*this) << endl; }
};
class B : public A {
  public:
  B() { cout << "B::B(): " << type(*this) << endl; }
  ~B() { cout << "B::~B(): " << type(*this) << endl; }
};
class C : public B {
  public:
  C() { cout << "C::C(): " << type(*this) << endl; }
  ~C() { cout << "C::~C(): " << type(*this) << endl; }
};
int main() {
  C c;
}

运行这个程序产生以下结果:

A::A(): 1A
B::B(): 1B
C::C(): 1C
C::~C(): 1C
B::~B(): 1B
A::~A(): 1A

std::typeinfo::name() 调用返回的类型名称是所谓的名称混淆类型名称——这是编译器用来识别类型的内部名称,而不是像 class A 这样的可读名称。如果你想了解未混淆的类型,你可以使用像 GCC 中的 c++filt 程序这样的去混淆器:

$ c++filt -t 1A
A

我们也可以编写一个小的 C++ 函数来去混淆类型名称,但实现方式因编译器而异(没有可移植版本)。例如,这是为 GCC 编写的代码:

// Example 2
#include <cxxabi.h>
template <typename T> auto type(T&& p) {
  int r;
  std::string name;
  char* mangled_name =
    abi::__cxa_demangle(typeid(p).name(), 0, 0, &r);
  name += mangled_name;
  ::free(mangled_name);
  return name;
}

注意,去混淆函数返回一个 C 字符串(一个 char* 指针),必须由调用者显式释放。现在程序打印去混淆后的名称,如 ABC。这足以满足我们的需求,但在某些情况下,你可能会注意到类型并没有按预期打印出来:

class A {
  public:
  void f() const { cout << type(*this) << endl; }
};
...
C c;
c.f();

如果我们调用函数 f(),其类型报告为 C,而不是我们可能预期的 const C(对象在 const 成员函数内部是 const 的)。这是因为 typeid 操作符移除了 constvolatile 限定符以及类型中的任何引用。要打印这些,你必须自己找出它们:

// Example 03
template <typename T> auto type(T&& p) {
  std::string name;
  using TT = std::remove_reference_t<T>;
  if (std::is_const<TT>::value) name += "const ";
  if (std::is_volatile<TT>::value) name += "volatile ";
  int r;
  name += abi::__cxa_demangle(typeid(p).name(), 0, 0, &r);
  return name;
}

无论你选择如何打印类型,在这些示例中构造了多少个对象?源代码只说了一个,类型为 Cc 对象:

int main() {
  C c;
}

运行时输出显示三个,即每种类型的一个。两个答案都是正确的——当类型为 C 的对象被构造时,必须首先构造基类 A,因此会调用其构造函数。然后,构造中间基类 B,之后才会构造 C。析构函数的执行顺序是相反的。由 typeid 操作符报告的对象构造函数或析构函数中的类型与正在运行构造函数或析构函数的对象的类型相同。

看起来,类型,如虚拟指针所示,在构造过程中正在改变!当然,这是假设 typeid 操作符返回的是动态类型,即虚拟指针指示的类型,而不是在编译时可以确定的静态类型。标准指出,这确实是情况。这意味着,如果我们从每个构造函数中调用相同的虚拟方法,我们实际上会调用这个方法的三种不同的重写吗?这很容易找到答案:

// Example 04
class A {
  public:
  A() { whoami(); }
  virtual ~A() { whoami(); }
  virtual void whoami() const {
    std::cout << "A::whoami" << std::endl;
  }
};
class B : public A {
  public:
  B() { whoami(); }
  ~B() { whoami(); }
  void whoami() const override {
    std::cout << "B::whoami" << std::endl;
  }
};
class C : public B {
  public:
  C() { whoami(); }
  ~C() { whoami(); }
  void whoami() const override {
    std::cout << "C::whoami" << std::endl;
  }
};
int main() {
  C c;
  c.whoami();
}

现在,我们将创建一个类型为C的对象,并在创建之后调用whoami()来确认它——对象的动态类型是C。这是从构造过程的开始就是正确的;我们要求编译器构造一个类型为C的对象,但在构造过程中对象的动态类型发生了变化:

A::whoami
B::whoami
C::whoami
C::whoami
C::whoami
B::whoami
A::whoami

很明显,随着对象构造的进行,虚拟指针值已经改变。一开始,它将对象类型识别为A,即使最终类型是C。这是否因为我们是在栈上创建了对象?如果对象是在堆上创建的,会有所不同吗?我们可以很容易地找到答案:

C* c = new C;
c->whoami();
delete c;

运行修改后的程序会产生与原始程序完全相同的结果。

另一个原因是因为构造函数不能是虚拟的,或者更普遍地说,为什么正在构造的对象的类型必须在构造点在编译时已知,是因为编译器必须知道为对象分配多少内存。内存量由类型的大小决定,即由sizeof运算符。sizeof(C)的结果是一个编译时常量,因此为新对象分配的内存量始终在编译时已知。这无论是我们在栈上还是堆上创建对象都是正确的。

核心问题是这样的——如果程序创建了一个T类型的对象,那么在代码的某个地方会有一个对T::T构造函数的显式调用。之后,我们可以在程序的其余部分隐藏T类型,例如,通过通过基类指针访问对象,或者通过擦除类型(参见第六章理解类型擦除)。但是,代码中必须至少有一个对T类型的显式提及,那就是在构造的时候。

一方面,我们现在有一个非常合理的解释,说明了为什么构造对象永远不能是多态的。另一方面,这并没有解决可能需要构造一个在编译时类型未知的设计挑战。考虑设计一个游戏——玩家可以为他们的团队招募或召唤任意数量的冒险者,并建立定居点和城市。为每种生物种类和每种建筑类型拥有一个单独的类似乎是合理的,但当我们有一个冒险者加入团队或一座建筑被建立时,我们必须构造这些类型之一的对象,直到玩家选择它,游戏才能知道要构造哪个对象。

如同软件中的常规做法,解决方案涉及添加另一个间接层。

工厂模式

我们面临的问题,即如何在运行时决定创建特定类型的对象,显然是一个非常常见的设计问题。设计模式正是针对这类问题的解决方案,而且对于这个问题也有一个模式——它被称为工厂模式。工厂模式是一种创建型模式,它为几个相关的问题提供了解决方案——如何将创建哪个对象的决策委托给派生类,如何使用单独的工厂方法创建对象,等等。我们将逐一回顾工厂模式的这些变体,从基本的工厂方法开始。

工厂方法的基本原理

在其最简单的形式中,工厂方法构建一个在运行时指定的类型的对象:

class Base { ... };
class Derived : public Base { ... };
Base* p = ClassFactory(type_identifier, ... arguments );

我们如何在运行时识别要创建的对象?我们需要为工厂可以创建的每种类型提供一个运行时标识符。在最简单的情况下,这些类型的列表在编译时是已知的。

考虑一个游戏设计,玩家可以从菜单中选择要构建的建筑类型。程序有一个可以构建的建筑列表,每个建筑由一个对象表示,并为每个对象分配一个标识符:

// Example 05
enum Buildings {
  FARM, FORGE, MILL, GUARDHOUSE, KEEP, CASTLE
};
class Building {
  public:
  virtual ~Building() {}
};
class Farm : public Building { ... };
class Forge : public Building { ... };

当玩家选择建筑类型时,游戏程序也会选择相应的标识符值。现在,程序可以使用工厂方法构建建筑:

Building* new_farm = MakeBuilding(FARM);

注意,工厂函数接受类型标识符参数并返回基类的指针。返回的对象应该具有与类型标识符相对应的类型。工厂是如何实现的?记住上一节的结论——在程序的某个地方,每个对象都必须显式地使用其真实类型进行构造。工厂模式并不取消这一要求;它只是隐藏了构造发生的地方:

// Example 05
Building* MakeBuilding(Buildings building_type) {
  switch (building_type) {
    case FARM: return new Farm;
    case FORGE: return new Forge;
    ...
  }
}

类型标识符与对象类型之间的对应关系编码在工厂内部的switch语句中。由于只有一个工厂方法,并且其类型在编译时声明,因此返回类型必须对所有由工厂构建的类型相同。在最简单的情况下,它是基类指针,尽管如果你遵循本书中描述的现代内存所有权习惯用法第三章内存和所有权,那么工厂应该返回对基类的唯一指针,std::unique_ptr<Building>

// Example 06:
class Building {
  public:
  enum Type {FARM, FORGE, ...};
  virtual ~Building() {}
  auto MakeBuilding(Type building_type);
};
auto Building::MakeBuilding(Type building_type) {
  using result_t = std::unique_ptr<Building>;
  switch (building_type) {
    case FARM: return result_t{new Farm};
    case FORGE: return result_t{new Forge};
    ...
  }
}

在极少数需要共享所有权的场合,可以通过将对象从唯一指针移动到共享指针std::shared_ptr<Building>来创建共享所有权(但这是由调用者做出的决定,而不是工厂本身)。

我们在这里做出的另一个设计选择(独立于使用拥有指针)是将类型标识符和工厂函数移动到基类中。这对于封装和保持所有相关代码和类型更接近是有用的。

这就是工厂方法的基本形式。有许多变体使其更适合特定问题。我们将在下面回顾其中的一些变体。

工厂方法的澄清

注意,“工厂方法”这个术语的使用存在一些歧义。在本章中,我们用它来描述基于某些运行时信息创建不同类型对象的函数。还有一个与之不相关的、有时以相同名称引入的设计模式:这个模式不是构建不同的类,而是以不同的方式构建相同的类。以下是一个简短的例子:假设我们有一个类来表示平面上的一个点。这个点由其坐标 xy 描述:

class Point {
  double x_ {};
  double y_ {};
  public:
  Point(double x, double y) : x_(x), y_(y) {}
};

到目前为止,一切顺利。但同一个点可以用极坐标,例如,来描述。因为这些是描述同一个点的两种方式,我们不需要一个单独的类,但我们可能想要一个新的构造函数,它可以从指定的极坐标创建笛卡尔点:

class Point() {
  ...
  Point(double r, double angle);
};

但这行不通:新的构造函数和从 xy 来的原始构造函数都接受完全相同的参数,因此重载解析无法确定你指的是哪一个。一个解决方案是使用不同单位测量的量(在我们的例子中是长度和角度)使用不同的类型。但它们必须是真正不同的类型,而不仅仅是别名。有时,这样的单位模板库正是你所需要的,但如果你坚持使用双精度浮点数,你需要其他方法来根据调用者的意图调用不同的构造函数,而不仅仅是根据参数。

处理这个问题的方法之一是切换到工厂构建。我们不会使用构造函数,而是将所有 Point 对象都使用静态工厂方法来构建。请注意,在使用这种方法时,构造函数本身通常是私有的:

// Example 07
class Point {
  double x_ {};
  double y_ {};
  Point(double x, double y) : x_(x), y_(y) {}
  public:
  static Point new_cartesian(double x, double y) {
    return Point(x, y);
  }
  static Point new_polar(double r, double phi) {
    return Point(r*std::cos(phi), r*std::sin(phi));
  }
};
Point p1(Point::new_cartesian(3, 4));
Point p2(Point::new_polar(5, 0.927295));

这种设计是可行的,但在现代 C++ 中,更受欢迎的替代方案是使用多个构造函数,并通过唯一定义的类型标签来区分它们:

// Example 08
class Point {
  double x_ {};
  double y_ {};
  public:
  struct cartesian_t {} static constexpr cartesian {};
  Point(cartesian_t, double x, double y) : x_(x), y_(y) {}
  struct polar_t {} static constexpr polar {};
  Point(polar_t, double r, double phi) :
    Point(cartesian, r*std::cos(phi), r*std::sin(phi)) {}
};
Point p1(Point::cartesian, 3, 4);
Point p2(Point::polar, 5, 0.927295);

在这个例子中,我们创建了两个独特的类型,Point::polar_tPoint::cartesian_t,以及相应的变量,并使用它们作为标签来指定我们想要的构建类型。构造函数的重载不再模糊,因为每个都有一个独特的第一参数类型。委托构造函数使这种方法更具吸引力。

虽然使用静态函数以不同方式构建相同类型的对象有时被称为工厂方法,但它也可以被视为建造者模式的一个变体(特别是当我们使用具有类似方法的单独建造者类而不是静态方法时)。无论如何,更现代的模式——使用标签——可以替代这两种模式。在明确了术语之后,让我们回到基于运行时信息构建不同类型对象的原问题。

工厂方法的论据

在我们的简单示例中,构造函数没有接受任何参数。如果不同类型的构造函数有不同的参数,向构造函数传递参数会带来一些问题——毕竟,MakeBuilding() 函数必须用一些特定的参数声明。一个看起来很直接的选择是将工厂做成可变模板,并将参数简单地转发给每个构造函数。直接的实现可能看起来像这样:

// Example 09
template <typename... Args>
auto Building::MakeBuilding(Type type, Args&&... args) {
  using result_t = std::unique_ptr<Building>;
  switch (type) {
    case FARM: return
      result_t{new Farm(std::forward<Args>(args)...)};
    case FORGE: return
      result_t{new Forge(std::forward<Args>(args)...)};
    ...
  }
}

这段代码可能甚至会在一段时间内编译,但迟早你会遇到以下错误。让我们给我们要构建的两个类提供一些构造函数参数:

// Example 09
class Farm : public Building {
  public:
  explicit Farm(double size);
};
class Forge : public Building {
  public:
  static constexpr size_t weaponsmith = 0x1;
  static constexpr size_t welder = 0x2;
  static constexpr size_t farrier = 0x4;
  Forge(size_t staff, size_t services);
};
std::unique_ptr<Building> forge =
  Building::MakeBuilding(Building::FORGE, 2,
    Forge::weaponsmith | Forge::welder | Forge::farrier);

Forge 类使用位掩码作为标志来标记在锻造处提供哪些服务(处理少量非排他性选项的一个简单且有效的方法)。例如,如果 (services & Forge::farrier)true,那么在锻造处工作的两位工匠中的一位可以为马钉蹄铁。简单、优雅,但……无法编译。

编译器错误将提到没有匹配的构造函数可用于从两个整数构造 Farm 类。但我们并不是试图构造一个 Farm!这个错误迟早会困扰到每个人。问题是,在编译时,我们无法确定我们不是试图构造一个 Farm:这是一个运行时决策。函数 MakeBuilding() 必须编译,这意味着其整个实现必须编译,包括以 case FARM 开头的行。你第一个想法可能是用 if constexpr 替换 switch 语句,但这不会起作用,因为我们用来选择要构建哪个类的条件不是 constexpr,而是一个运行时值——这正是工厂模式的意义所在。

尝试使用为 Forge 准备的参数来构造一个 Farm 是一个错误。然而,这是一个运行时错误,并且只能在运行时检测到。这仍然让我们面临如何使永远不会运行的代码有效的问题。问题是,农场没有我们可以用于所有错误参数的构造函数(但希望永远不会),最简单的解决方案是提供一个:

// Example 09
class Farm : public Building {
  public:
  explicit Farm(...) { abort(); }
  ...
};

我们必须对我们可能用工厂构造的所有类型都做同样的事情。可变参数函数构造器是“最后的手段”重载——它仅在没有任何其他重载与参数匹配时才会被选中。因为它匹配任何参数,所以编译错误将消失,如果程序中出现问题,将被运行时错误所取代。为什么不简单地将这个构造函数添加到基类中呢?我们可以这样做,但基类构造函数在没有 using 语句的情况下在派生类中是不可见的,所以我们仍然必须为每个派生类添加一些内容。

只为了让每个类能够与工厂创建模式一起使用而必须修改每个类,这确实是一个缺点,尤其是新的构造函数可以在任何地方使用,而不仅仅是工厂函数中(不幸的是,这会产生不良后果)。像往常一样,通过引入一个重载模板,我们可以通过引入一个重载模板来解决这个问题,以构建我们的对象:

// Example 10
template <typename T, typename... Args>
auto new_T(Args&&... args) ->
  decltype(T(std::forward<Args>(args)...))* {
  return new T(std::forward<Args>(args)...);
}
template <typename T>
T* new_T(...) { abort(); return nullptr; }
template <typename... Args>
auto Building::MakeBuilding(Type type, Args&&... args) {
  using result_t = std::unique_ptr<Building>;
  switch (type) {
    case FARM: return
      result_t{new_T<Farm>(std::forward<Args>(args)...)};
    case FORGE: return
      result_t{new_T<Forge>(std::forward<Args>(args)...)};
    ...
  }
}

好消息是,现在我们不需要修改任何类:任何带有正确参数的工厂调用都会编译并转发到正确的构造函数,而任何尝试使用错误参数创建对象的操作都会导致运行时错误。坏消息是,任何尝试使用错误参数创建对象的操作都会导致运行时错误。这包括我们从未计划运行的死代码(例如,使用Forge的参数创建Farm),也包括我们在调用工厂时可能犯的任何错误。

一旦你开始实现可变参数模板解决方案,它可能看起来就不那么有吸引力了,有一个更简单的选择:创建一个参数对象,其层次结构与我们要创建的对象的层次结构相匹配。让我们假设,在我们的游戏中,玩家可以为要构建的每个建筑选择升级。用户界面当然必须提供特定于建筑的选项,用户选择的结果存储在特定于建筑的对象中:

// Example 11
struct BuildingSpec {
  virtual Building::Type type() const = 0;
};
struct FarmSpec : public BuildingSpec {
  Building::Type type() const override {
    return Building::FARM;
  }
  bool with_pasture;
  int number_of_stalls;
};
struct ForgeSpec : public BuildingSpec {
  Building::Type type() const override {
    return Building::FORGE;
  }
  bool magic_forge;
  int number_of_apprentices;
};

注意,我们在参数对象中包含了类型标识符,没有理由用两个必须始终正确匹配的参数调用工厂方法;这只会增加出错的可能性。这样,我们就可以保证在每个工厂调用中类型标识符和参数是一致的:

// Example 11
auto Building::MakeBuilding(const BuildingSpec& spec) {
  using result_t = std::unique_ptr<Building>;
  switch (spec.type()) {
    case FARM: return result_t{
      new Farm(static_cast<const FarmSpec&>(spec))};
    case FORGE: return result_t{
      new Forge(static_cast<const ForgeSpec&>(spec))};
    ...
  }
}

注意,工厂模式通常与我们在第九章中看到的命名参数模式配合得很好,命名参数、方法链和构建器模式,以避免需要指定长的参数列表。规范对象本身就成了我们可以用来指定命名参数的选项对象:

// Example 11
class FarmSpec {
  ...
  bool with_pasture {};
  int number_of_stalls {};
  FarmSpec() = default;
  FarmSpec& SetPasture(bool with_pasture) {
    this->with_pasture = with_pasture;
    return *this;
  }
  FarmSpec& SetStalls(int number_of_stalls) {
    this->number_of_stalls = number_of_stalls;
    return *this;
  }
};
struct ForgeSpec : public BuildingSpec {
  ...
  bool magic_forge {};
  int number_of_apprentices {};
  ForgeSpec() = default;
  ForgeSpec& SetMagic(bool magic_forge) {
    this->magic_forge = magic_forge;
    return *this;
  }
  ForgeSpec& SetApprentices(int number_of_apprentices) {
    this->number_of_apprentices = number_of_apprentices;
    return *this;
  }
};
...
std::unique_ptr<Building> farm =
  Building::MakeBuilding(FarmSpec()
                         .SetPasture(true)
                         .SetStalls(2));
std::unique_ptr<Building> forge =
  Building::MakeBuilding(ForgeSpec()
                         .SetMagic(false)
                         .SetApprentices(4));

这种技术可以与以下章节中展示的其他工厂变体结合使用,这样我们就可以在构造函数需要时传递参数。

动态类型注册

到目前为止,我们假设类型的完整列表在编译时已知,并且可以编码在类型标识符对应表中(在我们的例子中通过 switch 语句实现)。在程序的全局范围内,这个要求是不可避免的:因为每个构造函数调用都必须在某个地方显式编写,因此可以构造的类型列表在编译时是已知的。然而,我们的解决方案比这更受限制——我们有一个硬编码在工厂方法中的所有类型的列表。没有添加到工厂中,就不能创建额外的派生类。有时,这种限制并不像看起来那么糟糕——例如,游戏中的建筑列表可能不会经常改变,即使它改变了,也必须有一个完整的列表手动更新,以便正确生成菜单,图片和声音出现在正确的位置等等。

尽管如此,分层设计的优点之一是,可以在不修改任何操作该层次结构的代码的情况下,稍后添加派生类。新的虚拟函数只需插入到现有的控制流程中,并提供必要的定制行为。我们可以为工厂构造函数实现同样的想法。

首先,每个派生类都必须负责构建自身。这是必要的,因为我们已经了解到,显式调用构造函数必须在某个地方编写。如果它不在通用代码中,它就必须是创建新派生类时添加的代码的一部分。例如,我们可以有一个静态工厂函数:

class Forge : public Building {
  public:
  static Building* MakeBuilding() { return new Forge; }
};

其次,类型的列表必须在运行时可扩展,而不是在编译时固定。我们仍然可以使用enum,但每次添加新的派生类时,它都必须更新。或者,我们可以在运行时为每个派生类分配一个整数标识符,确保标识符是唯一的。无论如何,我们需要一个将这些标识符映射到工厂函数的映射,而且它不能是一个在编译时固定的switch语句或其他任何东西。这个映射必须是可扩展的。我们可以使用std::map来实现这一点,但如果类型标识符是整数,我们也可以使用一个按类型标识符索引的函数指针的std::vector

class Building;
using BuildingFactory = Building*(*)();
std::vector<BuildingFactory> building_registry;

现在,为了注册一个新的类型,我们只需生成一个新的标识符,并将相应的工厂函数添加到向量中:

size_t building_type_count = 0;
void RegisterBuilding(BuildingFactory factory) {
  building_registry.push_back(factory));
  ++building_type_count;
}

这种注册机制可以封装在基类本身中:

// Example 12
class Building {
  static size_t building_type_count;
  using BuildingFactory = Building* (*)();
  static std::vector<BuildingFactory> registry;
  public:
  static size_t (BuildingFactory factory) {
    registry.push_back(factory);
    return building_type_count++;
  }
  static auto MakeBuilding(size_t building_type) {
    BuildingFactory RegisterBuilding factory =
        registry[building_type];
    return std::unique_ptr<Building>(factory());
  }
};
std::vector<Building::BuildingFactory> Building::registry;
size_t Building::building_type_count = 0;

这个基类具有工厂函数表和已注册派生类型的计数作为静态数据成员。它还具有两个静态函数:一个用于注册新类型,另一个用于构造类中注册的一个类型的对象。请注意,注册函数返回与工厂函数关联的类型标识符。我们很快就会用到这个。

现在,我们只需要将每个新的建筑类型添加到注册表中。这是分两步完成的——首先,我们需要为每个建筑类添加一个注册方法,如下所示:

class Forge : public Building {
  public:
  static void Register() {
    RegisterBuilding(Forge::MakeBuilding);
  }
};

第二,我们需要确保在游戏开始之前调用所有Register()方法,并确保我们知道每个建筑类型的正确标识符。这就是RegisterBuilding()函数返回的值变得重要的地方,因为我们将它作为类型标识符存储在类内部:

// Example 12
class Forge : public Building {
  public:
  static void Register() {
    RegisterBuilding(Forge::MakeBuilding);
  }
  static const size_t type_tag;
};
const size_t Forge::type_tag =
  RegisterBuilding(Forge::MakeBuilding);

注册发生在静态变量的初始化过程中,在main()开始之前。

工厂函数不必是静态成员函数:任何可以通过函数指针调用的东西都可以工作。例如,我们可以使用没有捕获的 lambda:

// Example 12
class Farm : public Building {
  public:
  static const size_t type_tag;
};
const size_t Farm::type_tag =
  RegisterBuilding([]()->Building* { return new Farm; });

我们必须显式指定返回类型,因为函数指针类型被定义为没有参数且返回Building*的函数,而 lambda 被推断为返回Farm*的函数,除非我们转换返回值或指定返回类型。

现在,调用Building::MakeBuilding(tag)将构造一个与标识符tag注册的类型对象。标签的值——类型标识符——作为每个类的静态成员存储,因此我们不必知道它,也不会出错:

std::unique_ptr<Building> farm =
  Building::MakeBuilding(Farm::type_tag);
std::unique_ptr<Building> forge =
  Building::MakeBuilding(Forge::type_tag);

在我们的解决方案中,标识符值与类型之间的对应关系直到运行时才知道——在我们运行程序之前,我们无法说出哪个建筑物的 ID 是 5。通常,我们不需要知道这一点,因为正确的值会自动存储在每个类中。

注意,这个实现与编译器为真正的虚函数生成的代码非常相似——虚函数调用是通过存储在表中并通过唯一标识符(虚指针)访问的函数指针完成的。主要区别是唯一标识符是与每个类型关联的静态数据成员。尽管如此,这几乎就是一个虚拟构造函数

这种动态类型注册模式有许多变体。在某些情况下,显式指定类型标识符比在程序启动时生成它们更好。特别是像“农场”和“锻造厂”这样的可读名称可能很有用。在这种情况下,我们可以将工厂函数指针存储在以字符串为索引的std::map容器中(std::map<std::string, BuildingFactory>)。

另一个修改是允许更通用的可调用对象作为工厂函数。我们可以通过使用std::function而不是函数指针来泛化BuildingFactory类型:

using BuildingFactory = std::function<Building*()>;

我们仍然可以将静态工厂方法注册并用作派生类的工厂,但我们也可以使用 lambda 和自定义仿函数:

// Example 13
class Forge : public Building {
  public:
  static const size_t type_tag;
};
class ForgeFactory {
  public:
  Building* operator()() const { return new Forge; }
};
const size_t Forge::type_tag =
  RegisterBuilding(ForgeFactory{});

这些动态工厂的实现,无论是使用函数指针还是更通用的 std::function,都与我们在第六章,“理解类型擦除”中探讨的类型擦除模式非常相似。要构建的对象的具体类型嵌入在函数或函数对象的代码中,其声明没有提及这些类型。这允许我们将这些函数存储在单个函数表或映射中。同样,第六章,“理解类型擦除”中的其他类型擦除实现也可以使用。

为了简化,我们没有为我们的工厂方法使用任何参数。然而,我们在上一节中探讨了传递参数的选项。可变模板与函数指针(我们必须提前声明工厂函数的签名)配合得不好,因此传递参数的最可能模式将是参数规范对象:

// Example 14
struct BuildingSpec {};
class Building {
  ...
  using BuildingFactory =
    Building* (*)(const BuildingSpec&);
  static auto MakeBuilding(size_t building_type,
                           const BuildingSpec& spec) {
    BuildingFactory factory = registry[building_type];
    return std::unique_ptr<Building>(factory(spec));
  }
};
struct FarmSpec : public BuildingSpec {
  bool with_pasture {};
  int number_of_stalls {};
  FarmSpec() = default;
  FarmSpec& SetPasture(bool with_pasture) {
    this->with_pasture = with_pasture;
    return *this;
  }
  FarmSpec& SetStalls(int number_of_stalls) {
    this->number_of_stalls = number_of_stalls;
    return *this;
  }
};
class Farm : public Building {
  public:
  explicit Farm(const FarmSpec& spec);
  ...
};
const size_t Farm::type_tag = RegisterBuilding(
  [](const BuildingSpec& spec)->Building* {
    return new Farm(static_cast<const FarmSpec&>(spec));
  });
struct ForgeSpec : public BuildingSpec { ... };
class Forge : public Building { ... };
std::unique_ptr<Building> farm =
  Building::MakeBuilding(FarmSpec()
                         .SetPasture(true)
                         .SetStalls(2));
std::unique_ptr<Building> forge =
  Building::MakeBuilding(ForgeSpec()
                         .SetMagic(false)
                         .SetApprentices(4));

在我们迄今为止的所有工厂构造函数中,关于构建哪个对象的决策是由程序的外部输入驱动的,并且构建是通过相同的工厂方法完成的(可能使用对派生类的委托)。现在我们将看到工厂的不同变体,它用于解决一个稍微不同的场景。

多态工厂

考虑一个稍微不同的问题——想象在我们的游戏中,每个建筑都生产某种单位,并且单位的类型与建筑的类型唯一相关联。城堡招募骑士,巫师塔训练法师,蜘蛛山产生巨型蜘蛛。现在,我们的通用代码不仅构建在运行时选择的建筑类型,还创建新的单位,其类型在编译时也不为人知。我们已经有建筑工厂。我们可以以类似的方式实现单位工厂,其中每个建筑都有一个与其关联的唯一单位标识符。但这个设计将单位与建筑之间的对应关系暴露给了程序的其他部分,这实际上并不是必要的——每个建筑都知道如何构建正确的单位;程序的其他部分没有必要也知道它。

这个设计挑战需要一种稍微不同的工厂——工厂方法决定创建一个单位,但确切是哪个单位则由建筑来决定。这是模板模式的应用,结合了工厂模式——整体设计是工厂,但单位类型由派生类定制:

// Example 15
class Unit {};
class Knight : public Unit { ... };
class Mage : public Unit { ... };
class Spider : public Unit { ... };
class Building {
  public:
  virtual Unit* MakeUnit() const = 0;
};
class Castle : public Building {
  public:
  Knight* MakeUnit() const { return new Knight; }
};
class Tower : public Building {
  public:
  Mage* MakeUnit() const { return new Mage; }
};
class Mound : public Building {
  public:
  Spider* MakeUnit() const { return new Spider; }
};

每座建筑都有一个用于创建相应单位的工厂,我们可以通过基类 Building 访问这些工厂方法:

std::vector<std::unique_ptr<Building>> buildings;
std::vector<std::unique_ptr<Unit>> units;
for (const auto& b : buildings) {
  units.emplace_back(b->MakeUnit());
}

使用多态并通过基类中的虚拟函数(通常是纯虚拟函数)访问的工厂被称为抽象工厂模式。

本例中没有展示建筑本身的工厂方法——单元工厂可以与我们所学的任何建筑工厂实现共存(伴随本章的源代码示例使用的是示例 12 中的建筑工厂)。从建筑构建单位的通用代码只写一次,当添加新的建筑和单位派生类时不需要更改。

注意,所有 MakeUnit() 函数的返回类型都不同。尽管如此,它们都是同一虚拟 Building::MakeUnit() 函数的重写。这些被称为协变返回类型——重写方法的返回类型可能是被重写方法返回类型的派生类。在我们的例子中,返回类型与类类型相匹配,但通常这并不是必需的。任何基类和派生类都可以用作协变类型,即使它们来自不同的层次结构。然而,只有这样的类型才能是协变的,除此之外的例外,重写的返回类型必须与基类虚拟函数相匹配。

当我们尝试使工厂返回除原始指针之外的内容时,协变返回类型的严格规则会带来一些问题。例如,假设我们想要返回 std::unique_ptr 而不是原始指针。但是,与 Unit*Knight* 不同,std::unique_ptr<Unit>std::unique_ptr<Knight> 不是协变类型,不能用作虚拟方法和其重写的返回类型。

我们将在下一节考虑这个解决方案以及与工厂方法相关的几个其他特定于 C++ 的问题。

C++ 中的工厂类似模式

在 C++ 中,用于解决特定设计需求和约束的基本工厂模式的变体有很多。在本节中,我们将考虑其中的一些。这绝对不是 C++ 中工厂类似模式的独家列表,但理解这些变体应该为读者准备将他们从本书中学到的技术结合起来,以解决与对象工厂相关的各种设计挑战。

多态复制

到目前为止,我们考虑了对象构造函数的替代方案——要么是默认构造函数,要么是带有参数的构造函数之一。然而,可以将类似的模式应用于复制构造函数——我们有一个对象,我们想要复制它。

这在许多方面是一个类似的问题——我们有一个通过基类指针访问的对象,我们想要调用它的复制构造函数。由于我们之前讨论的原因,包括编译器需要知道分配多少内存在内的原因,实际的构造函数调用必须在静态确定的类型上完成。然而,将我们带到特定构造函数调用的控制流可以在运行时确定,这再次需要应用工厂模式。

我们将使用的工厂方法来实现多态复制与上一节中的 Unit 工厂示例有些相似——实际的构建必须由每个派生类来完成,派生类知道要构建哪种类型的对象。基类实现了控制流,决定了将构建某个人的副本,并且派生类定制了构建部分:

// Example 16
class Base {
  public:
  virtual Base* clone() const = 0;
};
class Derived : public Base {
  public:
  Derived* clone() const override {
    return new Derived(*this);
  }
};
Base* b0 = ... get an object somewhere ...
Base* b1 = b->clone();

我们可以使用 typeid 操作符(可能结合本章前面使用过的解名函数)来验证指针 b1 确实指向一个 Derived 对象。

我们刚刚通过继承实现了多态复制。在第六章 理解类型擦除中,我们看到了另一种在构建时已知类型但后来丢失(或擦除)的对象的复制方法。这两种方法在本质上并没有不同:在实现类型擦除复制时,我们自行构建了一个虚表。在本章中,我们让编译器为我们完成这项工作。在任何特定情况下,首选的实现方式主要取决于代码周围的其它内容。

注意,我们又使用了协变返回类型,因此我们被限制为只能返回原始指针。假设我们想返回唯一指针。由于只有基类和派生类的原始指针被认为是协变的,我们必须始终返回基类唯一指针:

class Base {
  public:
  virtual std::unique_ptr<Base> clone() const = 0;
};
class Derived : public Base {
  public:
  std::unique_ptr<Base> clone() const override {
    return std::unique_ptr<Base>(new Derived(*this));
  }
};
std::unique_ptr<Base> b(... make an object ...);
std::unique_ptr<Base> b1 = b->clone();

在许多情况下,这并不是一个重大的限制。然而,有时它可能导致不必要的转换和强制类型转换。如果返回精确类型的智能指针很重要,我们将考虑这个模式的另一个版本。

CRTP 工厂和返回类型

从派生类的工厂复制构造函数中返回 std::unique_ptr<Derived> 的唯一方法是将基类的虚拟 clone() 方法返回相同的类型。但这是不可能的,至少如果我们有多个派生类——对于每个派生类,我们需要 Base::clone() 的返回类型是该类。但只有一个 Base::clone()!或者有吗?幸运的是,在 C++ 中,我们有一种简单的方法可以将一个变成多个——那就是模板。如果我们模板化基类,我们就可以使每个派生类的基类返回正确的类型。但要做到这一点,我们需要基类以某种方式知道将从它派生出的类的类型。当然,也有一个模式,在 C++ 中被称为“奇特重复模板模式”,我们在第八章 奇特重复模板模式中讨论过。现在,我们可以结合 CRTP 和工厂模式:

// Example 18
template <typename Derived> class Base {
  public:
  virtual std::unique_ptr<Derived> clone() const = 0;
};
class Derived : public Base<Derived> {
  public:
  std::unique_ptr<Derived> clone() const override {
    return std::unique_ptr<Derived>(new Derived(*this));
  }
};
std::unique_ptr<Derived> b0(new Derived);
std::unique_ptr<Derived> b1 = b0->clone();

auto 返回类型使得编写这样的代码显著减少了冗余。在这本书中,我们通常不使用它们来明确指出哪个函数返回什么。

Base类的模板参数是从它派生的一个类,因此得名。如果你愿意,甚至可以使用静态断言来强制这种限制:

template <typename Derived> class Base {
  public:
  virtual std::unique_ptr<Derived> clone() const = 0;
  Base() {
    static_assert(std::is_base_of_v<Base, Derived>;
  }
};

我们必须将静态断言隐藏在类构造函数中的原因是在类本身的声明中,Derived类型是不完整的。

注意,由于Base类现在知道派生类的类型,我们甚至不需要clone()方法为虚函数:

// Example 19
template <typename Derived> class Base {
  public:
  std::unique_ptr<Derived> clone() const {
    return std::unique_ptr<Derived>(
      new Derived(*static_cast<const Derived*>(this)));
  }
};
class Derived : public Base<Derived> { ... };

这种方法存在一些显著的缺点,至少就我们迄今为止的实现方式而言。首先,我们必须将基类做成模板,这意味着我们不再有一个通用的指针类型可以在我们的通用代码中使用(或者我们必须更广泛地使用模板)。其次,这种方法只有在没有更多类从Derived类派生时才有效,因为基类的类型不跟踪第二次派生——只有实例化了Base模板的那个派生。总的来说,除了在必须返回确切类型而不是基类型的情况下非常重要的一些特定情况外,这种方法不推荐使用。

另一方面,这种实现有一些吸引人的特性,我们可能希望保留。具体来说,我们消除了clone()函数的多个副本,每个派生类一个,并得到了一个模板来为我们自动生成它们。在下一节中,我们将向您展示如何保留 CRTP 实现的有用特性,即使我们必须放弃通过模板技巧将协变返回类型的概念扩展到智能指针。

CRTP 用于工厂实现

到目前为止,我们已经多次提到,虽然 CRTP 有时被用作设计工具,但它同样可能被用作实现技术。现在我们将专注于使用 CRTP 来避免在每个派生类中编写clone()函数。这不仅仅是为了减少打字——代码写得越多——特别是那些被复制和修改的非常相似的代码——你犯错误的可能性就越大。我们已经看到如何使用 CRTP 自动为每个派生类生成一个clone()版本。我们只是不想为了这样做而放弃通用的(非模板)基类。如果我们把克隆委托给只处理那个的特殊基类,我们实际上并不需要这样做:

// Example 20
class Base {
  public:
  virtual Base* clone() const = 0;
};
template <typename Derived> class Cloner : public Base {
  public:
  Base* clone() const {
    return new Derived(*static_cast<const Derived*>(this));
  }
};
class Derived : public Cloner<Derived> {
  ...
};
Base* b0(new Derived);
Base* b1 = b0->clone();

在这里,为了简单起见,我们回到了返回原始指针,尽管我们也可以返回std::unique_ptr<Base>。我们无法返回Derived*,因为在解析Cloner模板时,并不知道Derived总是从Base派生。

这种设计使我们能够从Base派生出任意数量的类,通过Cloner间接实现,而且再也不需要编写另一个clone()函数。它仍然存在一个限制,即如果我们从Derived派生另一个类,它将无法正确复制。在许多设计中,这并不是一个问题——明智的自利应该引导你避免深层层次结构,并使所有类成为两种类型之一:永远不会实例化的抽象基类,以及从这些基类之一派生出来的具体类,但永远不会从另一个具体类派生。

工厂和 Builder

到目前为止,我们主要使用的是工厂函数,或者更一般地说,是像 lambda 这样的函数对象。在实践中,我们同样可能需要一个工厂类。这通常是因为构建对象所需的运行时信息比仅仅类型标识符和一些参数更复杂。这也是我们可能选择使用 Builder 模式来创建对象的原因,因此工厂类也可以被视为一个具有工厂方法的构建类,用于创建具体对象。我们在本章前面看到的 Unit 工厂就是一个这样的模式示例:Building 类及其所有派生类充当单元对象的构建器(而且建筑对象本身是由另一个工厂创建的,这又是一个证明,即即使是简单的代码也很难仅用一种模式来简化)。然而,在这种情况下,我们使用工厂类有一个特殊的原因:每个派生建筑类都构建自己的单元对象。

让我们现在考虑使用工厂类的一个更常见的场景:决定构建哪个类以及如何构建的运行时数据的整体复杂性,以及我们需要执行此操作的非平凡代码量。虽然我们可以使用工厂函数和一些全局对象来处理所有这些,但这将是一个糟糕的设计,缺乏凝聚力和封装。这将容易出错且难以维护。将所有相关代码和数据封装到一个类或少数相关类中会更好。

对于这个例子,我们将解决一个非常常见(但仍然具有挑战性)的序列化/反序列化问题。在我们的情况下,我们有一些从相同基类派生的对象。我们希望通过将它们写入文件来实现序列化,然后从该文件中恢复对象。在本章的最后一个例子中,我们将结合我们学到的几种方法来设计和实现工厂。

我们将从基类开始。基类将利用我们之前学到的动态类型注册表。此外,它将声明一个纯虚Serialize()函数,每个派生类都需要实现以将自身序列化到文件中:

// Example 21
class SerializerBase {
  static size_t type_count;
  using Factory = SerializerBase* (*)(std::istream& s);
  static std::vector<Factory> registry;
  protected:
  virtual void Serialize(std::ostream& s) const = 0;
  public:
  virtual ~SerializerBase() {}
  static size_t RegisterType(Factory factory) {
    registry.push_back(factory);
    return type_count++;
  }
  static auto Deserialize(size_t type, std::istream& s) {
    Factory factory = registry[type];
    return std::unique_ptr<SerializerBase>(factory(s));
  }
};
std::vector<SerializerBase::Factory>
  SerializerBase::registry;
size_t SerializerBase::type_count = 0;

任何派生类都需要实现Serialize()函数以及注册反序列化函数:

// Example 21
class Derived1 : public SerializerBase {
  int i_;
  public:
  Derived1(int i) : i_(i) {...}
  void Serialize(std::ostream& s) const override {
    s << type_tag << " " << i_ << std::endl;
  }
  static const size_t type_tag;
};
const size_t Derived1::type_tag =
  RegisterType([](std::istream& s)->SerializerBase* {
    int i; s >> i; return new Derived1(i); });

只有派生类本身才有关于其状态的信息,为了重新构成对象必须保存什么,以及如何保存。在我们的例子中,序列化总是在Serialize()函数中完成的,而反序列化是在我们向类型注册表中注册的 lambda 中完成的。不用说,这两者必须相互一致。有一些基于模板的技巧可以确保这种一致性,但它们与我们正在研究的工厂构建无关。

我们已经处理了序列化部分——我们只需要在任何一个对象上调用 Serialize 即可:

std::ostream S ... – construct the stream as needed
Derived1 d(42);
d.Serialize(S);

反序列化本身并不特别困难(大部分工作由派生类完成),但其中足够的样板代码足以证明工厂类的必要性。工厂对象将读取整个文件并反序列化(重新创建)其中记录的所有对象。当然,这些对象有许多可能的用途。由于我们正在构建在编译时类型未知的对象,我们必须通过基类指针来访问它们。例如,我们可以将它们存储在唯一指针的容器中:

// Example 21
class DeserializerFactory {
  std::istream& s_;
  public:
  explicit DeserializerFactory(std::istream& s) : s_(s) {}
  template <typename It>
  void Deserialize(It iter) {
    while (true) {
      size_t type;
      s_ >> type;
      if (s_.eof()) return;
      iter = SerializerBase::Deserialize(type, s_);
    }
  }
};

这个工厂逐行读取整个文件。首先,它只读取类型标识符(每个对象在序列化时都必须写入)。基于这个标识符,它将剩余的反序列化过程调度到为相应类型注册的正确函数。工厂使用插入迭代器(如后插入迭代器)将所有反序列化的对象存储在容器中:

// Example 21
std::vector<std::unique_ptr<SerializerBase>> v;
DeserializerFactory F(S);
F.Deserialize(std::back_inserter(v));

使用这种方法,我们可以处理任何从 SerializerBase 派生的类,只要我们能想出一个将其写入文件并恢复的方法。我们可以处理更复杂的状态和具有多个参数的构造函数:

// Example 21
class Derived2 : public SerializerBase {
  double x_, y_;
  public:
  Derived2(double x, double y) : x_(x), y_(y) {...}
  void Serialize(std::ostream& s) const override {
    s << type_tag << " " << x_ << " " << y_ << std::endl;
  }
  static const size_t type_tag;
};
const size_t Derived2::type_tag =
  RegisterType([](std::istream& s)->SerializerBase* {
    double x, y; s >> x >> y;
    return new Derived2(x, y);
});

只要我们知道如何再次构造一个特定的对象,我们同样可以轻松地处理具有多个构造函数的类:

// Example 21
class Derived3 : public SerializerBase {
  bool integer_;
  int i_ {};
  double x_ {};
  public:
  Derived3(int i) : integer_(true), i_(i) {...}
  Derived3(double x) : integer_(false), x_(x) {...}
  void Serialize(std::ostream& s) const override {
    s << type_tag << " " << integer_ << " ";
    if (integer_) s << i_; else s << x_;
    s << std::endl;
  }
  static const size_t type_tag;
};
const size_t Derived3::type_tag =
  RegisterType([](std::istream& s)->SerializerBase* {
    bool integer; s >> integer;
    if (integer) {
      int i; s >> i; return new Derived3(i);
    } else {
      double x; s >> x; return new Derived3(x);
  }
});

C++中有许多工厂模式的变体。如果你理解了本章的解释并跟随了示例,这些替代方案对你来说应该不会构成特别的挑战。

摘要

在本章中,我们学习了为什么不能使构造函数成为虚拟的,以及当我们真的需要一个虚拟构造函数时应该做什么。我们学习了如何通过使用工厂模式和其变体之一来构建和复制在运行时类型已知的对象。我们还探讨了几个工厂构造函数的实现,它们在代码组织方式和将行为委派给系统不同组件的方式上有所不同,并比较了它们的优缺点。我们还看到了多个设计模式如何相互作用。

虽然,在 C++中,构造函数必须使用要构造的对象的真实类型来调用——总是这样——但这并不意味着应用程序代码必须指定完整的类型。工厂模式允许我们编写代码,通过使用与类型关联的标识符间接指定类型(创建第三种类型的对象),或关联的对象类型(创建与这种建筑类型相匹配的单位),甚至相同的类型(给我一个这个的副本,无论它是什么)。

在下一章中,我们将学习的设计模式是模板方法模式,这是经典面向对象模式之一,在 C++中,它对我们设计类层次结构有额外的含义。

问题

  1. 为什么 C++不允许虚拟构造函数?

  2. 工厂模式是什么?

  3. 你如何使用 Factory 模式来实现虚拟构造函数的效果?

  4. 你如何实现虚拟拷贝构造函数的效果?

  5. 你如何一起使用模板和 Factory 模式?

  6. 你如何一起使用 Builder 和 Factory 模式?

第十四章:模板方法模式和伪虚函数

模板方法是经典的四人帮设计模式之一,或者更正式地说,是 Erich Gamma、Richard Helm、Ralph Johnson 和 John Vlissides 在《设计模式 - 可复用面向对象软件元素》一书中描述的 24 个模式之一。它是一种行为设计模式,意味着它描述了不同对象之间通信的方式。作为面向对象的语言,C++当然完全支持模板方法模式,尽管本章将阐明一些特定于 C++的实现细节。

本章将涵盖以下主题:

  • 模板方法模式是什么,它解决了什么问题?

  • 什么是非虚接口?

  • 你应该默认将虚函数设置为公有、私有还是保护?

  • 你是否应该始终在多态类中将析构函数设置为虚的和公有的?

技术要求

本章的示例代码可以在以下 GitHub 链接中找到:github.com/PacktPublishing/Hands-On-Design-Patterns-with-CPP-Second-Edition/tree/master/Chapter14.

模板方法模式

模板方法模式是实现一个算法的常见方式,其整体结构是预先确定的,但实现的一些细节需要定制。如果你正在考虑一个解决方案,类似于这样——首先,我们做X,然后Y,然后Z,但我们如何做Y取决于我们处理的数据——你正在考虑模板方法。作为一个允许程序行为动态变化的模式,模板方法在某种程度上类似于策略模式。关键区别在于,策略模式在运行时改变整个算法,而模板方法允许我们定制算法的特定部分。本节处理后者,而我们有专门的第十六章基于策略的设计,专门用于前者。

C++中的模板方法

模板方法模式在任何面向对象的语言中都可以轻松实现。C++实现使用继承和虚函数。请注意,这与泛型编程中的 C++模板无关。这里的模板是算法的骨架实现:

// Example 01
class Base {
  public:
  bool TheAlgorithm() {
    if (!Step1()) return false; // Step 1 failed
    Step2();
    return true;
  }
};

这里的模板是算法的结构——所有实现都必须首先执行步骤 1,这可能失败。如果发生这种情况,整个算法被认为是失败的,不再进行任何操作。如果步骤 1成功,我们必须执行步骤 2。按照设计,步骤 2不能失败,一旦步骤 2完成,整体算法计算被认为是成功的。

注意到TheAlgorithm()方法是公开的但不是虚拟的——任何从Base派生的类都有它作为其接口的一部分,但不能覆盖它的行为。派生类可以覆盖的是在算法模板限制内的步骤 1步骤 2的实现——步骤 1可能失败,必须通过返回false来表示失败,而步骤 2可能不会失败:

// Example 01
class Base {
  public:
  ...
  virtual bool Step1() { return true };
  virtual void Step2() = 0;
};
class Derived1 : public Base {
  public:
  void Step2() override { ... do the work ... }
};
class Derived2 : public Base {
  public:
  bool Step1() override { ... check preconditions ... }
  void Step2() override { ... do the work ... }
};

在前面的例子中,覆盖可能失败的步骤 1是可选的,默认实现很简单;它什么都不做,永远不会失败。步骤 2必须由每个派生类实现——没有默认实现,并且它被声明为一个纯虚函数。

你可以看到整体的控制流程——框架——保持不变,但它有占位符用于可定制的选项,可能由框架本身提供默认值。这种流程被称为控制反转。在传统的控制流程中,它的具体实现决定了计算的流程和操作的顺序,并调用库函数或其他低级函数来实现必要的通用算法。在模板方法中,是框架在自定义代码中调用特定的实现。

模板方法的应用

使用模板方法有许多原因。一般来说,它用于控制可以和不可以被子类化的内容——与通用多态覆盖相反,在通用多态覆盖中,整个虚拟函数可以被替换,这里的基类决定了可以和不可以被覆盖的内容。模板方法的另一个常见用途是避免代码重复,在这种情况下,你可以这样得出使用模板方法的结论。假设你从一个常规的多态开始——一个虚拟函数——并覆盖它。例如,让我们考虑这个玩具设计,为一个游戏的回合制战斗系统:

// Example 02
class Character {
  public:
  virtual void CombatTurn() = 0;
  protected:
  int health_;
};
class Swordsman : public Character {
  bool wielded_sword_;
  public:
  void CombatTurn() override {
    if (health_ < 5) { // Critically injured
      Flee();
      return;
    }
    if (!wielded_sword_) {
      Wield();
      return; // Wielding takes a full turn
    }
    Attack();
  }
};
class Wizard : public Character {
  int mana_;
  bool scroll_ready_;
  public:
  void CombatTurn() override {
    if (health_ < 2 ||
        mana_ == 0) { // Critically injured or out of mana
      Flee();
      return;
    }
    if (!scroll_ready_) {
      ReadScroll();
      return; // Reading takes a full turn
    }
    CastSpell();
  }
};

注意这个代码是多么的重复——所有角色可能在它们的回合被迫退出战斗,然后他们必须进行一个回合来为战斗做准备,只有在这种情况下,如果他们准备好了并且足够强大,他们才能使用他们的攻击能力。如果你看到这个模式反复出现,这是一个强烈的提示,可能需要调用模板方法。使用模板方法,战斗回合的整体顺序是固定的,但每个角色如何前进到下一步以及到达那里后他们做什么仍然是角色特定的:

// Example 03
class Character {
  public:
  void CombatTurn() {
    if (MustFlee()) {
      Flee();
      return;
    }
    if (!Ready()) {
      GetReady();
      return; // Getting ready takes a full turn
    }
    CombatAction();
  }
  virtual bool MustFlee() const = 0;
  virtual bool Ready() const = 0;
  virtual void GetReady() = 0;
  virtual void CombatAction() = 0;
  protected:
  int health_;
};

现在每个派生类只需实现这个类独有的代码部分:

// Example 03
class Swordsman : public Character {
  bool wielded_sword_;
  public:
  bool MustFlee() const override { return health_ < 5; }
  bool Ready() const override { return wielded_sword_; }
  void GetReady()override { Wield(); }
  void CombatAction()override { Attack(); }
};
class Wizard : public Character {
  int mana_;
  bool scroll_ready_;
  public:
  bool MustFlee() const override { return health_ < 2 ||
                                          mana_ == 0; }
  bool Ready() const override { return scroll_ready_; }
  void GetReady() override { ReadScroll(); }
  void CombatAction() override { CastSpell(); }
};

注意这段代码的重复性明显减少。尽管模板方法的优势不仅仅在于外观上的美观。假设在游戏的下一个版本中,我们增加了治疗药水,并且在回合开始时,每个角色都可以喝上一瓶药水。现在,想象一下需要遍历每一个派生类并添加类似 if (health_ < ... some class-specific value ... && potion_count_ > 0) ... 的代码。如果设计已经使用了模板方法,那么药水饮用的逻辑只需要编写一次,不同的类实现它们使用药水的特定条件,以及饮用药水的后果。然而,在你读完这一章之前,不要急于实施这个解决方案,因为这并不是你能编写的最好的 C++代码。

预条件和后置条件以及动作

模板方法的另一个常见用途是处理预条件和后置条件或动作。在类层次结构中,预条件和后置条件通常验证在执行过程中,接口提供的抽象设计不变量没有被任何特定的实现违反。这种验证自然符合模板方法的设计:

// Example 04
class Base {
  public:
  void VerifiedAction() {
    assert(StateIsValid());
    ActionImpl();
    assert(StateIsValid());
  }
  virtual void ActionImpl() = 0;
};
class Derived : public Base {
  public:
  void ActionImpl() override { ... real implementation ...}
};

不变量是对象在客户端可访问时必须满足的要求,即在任何成员函数被调用之前或返回之后。成员函数本身通常需要暂时破坏不变量,但它们必须在将控制权返回给调用者之前恢复类的正确状态。让我们假设我们前面例子中的类跟踪执行了多少个动作。每个动作在开始时注册,完成时再次注册,这两个计数必须相同:一旦一个动作被启动,它必须完成,然后才能将控制权返回给调用者。当然,在 ActionImpl() 成员函数内部,这个不变量被违反了,因为动作正在进行中:

// Example 04
class Base {
  bool StateIsValid() const {
    return actions_started_ == actions_completed_;
  }
  protected:
  size_t actions_started_ = 0;
  size_t actions_completed_ = 0;
  public:
  void VerifiedAction() {
    assert(StateIsValid());
    ActionImpl();
    assert(StateIsValid());
  }
  virtual void ActionImpl() = 0;
};
class Derived : public Base {
  public:
  void ActionImpl() override {
    ++actions_started_;
    ... perform the action ...
    ++actions_completed_;
  }
};

当然,任何实际的预条件和后置条件的实现都必须考虑几个额外的因素。首先,一些成员函数可能有额外的不变量,即它们只能在对象处于受限制状态时调用。这样的函数将具有特定的前置条件进行测试。其次,我们没有考虑动作由于错误而中止的可能性(这可能涉及抛出异常)。一个精心设计的错误处理实现必须保证在错误发生后类的不变量没有被违反。在我们的例子中,一个失败的动作可能完全被忽略(在这种情况下,我们需要减少已启动动作的计数)或者我们的不变量可能需要更复杂:所有已启动的动作最终都必须完成或失败,我们需要计算两者:

// Example 05
class Base {
  bool StateIsValid() const {
    return actions_started_ ==
      actions_completed_ + actions_failed_;
  }
  protected:
  size_t actions_started_ = 0;
  size_t actions_completed_ = 0;
  size_t actions_failed_ = 0;
  ...
};
class Derived : public Base {
  public:
  void ActionImpl() override {
    ++actions_started_;
    try {
      ... perform the action – may throw ...
      ++actions_completed_;
    } catch (...) {
      ++actions_failed_;
    }
  }
};

在实际的程序中,你必须确保失败的交易不仅被正确计数,而且也要得到正确的处理(通常,它必须被撤销)。我们已经在第五章 全面审视 RAII第十一章 ScopeGuard中进行了详细讨论。最后,在并发程序中,一个对象在成员函数执行期间无法被观察的事实不再成立,类不变性的整个主题变得更加复杂,并且与线程安全保证交织在一起。

当然,在软件设计中,一个人的不变性是另一个人的定制点。有时,主要代码保持不变,但发生的事情取决于具体的应用。在这种情况下,我们可能不会验证任何不变性,而是执行初始和最终操作:

// Example 06
class FileWriter {
  public:
  void Write(const char* data) {
    Preamble(data);
    ... write data to a file ...
    Postscript(data);
  }
  virtual void Preamble(const char* data) {}
  virtual void Postscript(const char* data) {}
};
class LoggingFileWriter : public FileWriter {
  public:
  using FileWriter::FileWriter;
  void Preamble(const char* data) override {
    std::cout << "Writing " << data << " to the file" <<
      std::endl;
  }
  void Postscript (const char*) override {
    std::cout << "Writing done" << std::endl;
  }
};

当然,没有理由将前置条件和后置条件与打开和关闭操作结合在一起——基类可以在主要实现前后有多个“标准”成员函数调用。

虽然这段代码完成了任务,但它仍然存在一些我们将要揭露的缺陷。

非虚接口

动态可定制算法部分的实现通常使用虚函数来完成。对于一般的模板方法模式,这不是必需的,但在 C++中,我们很少需要其他方式。现在,我们将专门关注使用虚函数并改进我们所学的知识。

虚函数和访问

让我们从一个问题开始——虚函数应该是公共的还是私有的?教科书中的面向对象设计风格使用公共虚函数,所以我们经常不加思考地使它们成为公共的。在模板方法中,这种做法需要重新评估——公共函数是类接口的一部分。在我们的情况下,类接口包括整个算法,以及我们在基类中设置的框架。这个函数应该是公共的,但它也是非虚的。算法某些部分的定制实现从未打算直接由类层次结构的客户端调用。它们只在一个地方使用——在非虚公共函数中,它们替换了我们放在算法模板中的占位符。

这个想法可能看起来微不足道,但它对许多程序员来说却是一个惊喜。我多次被问到这个问题——C++甚至允许虚函数不是公共的吗?事实上,语言本身对虚函数的访问没有限制;它们可以是私有的、受保护的或公共的,就像任何其他类的成员函数一样。这可能需要一些时间来理解;也许一个例子会有所帮助:

// Example 07
class Base {
  public:
  void method1() { method2(); method3(); }
  virtual void method2() { ... }
  private:
  virtual void method3() { ... }
};
class Derived : public Base {
  private:
  void method2() override { ... }
  void method3() override { ... }
};

在这里,Derived::method2()Derived::method3() 都是私有的。基类甚至可以调用其派生类的私有方法吗?答案是,它不必这样做——Base::method1() 只调用它自己的成员函数(分别是公共和私有);调用同一类的私有成员函数没有问题。但如果实际类类型是 Derived,则在运行时会调用 method2() 的虚拟重写。这两个决定,“我是否可以调用” method2() 和 “哪个” method2(),发生在完全不同的时间——前者发生在包含 Base 类的模块编译时(而 Derived 类可能甚至还没有被编写),而后者发生在程序执行时(在那个点上,“私有”或“公共”这些词没有任何意义)。此外,请注意,正如前例中的 method3() 所示,虚拟函数及其重写可以有不同的访问权限。再次强调,编译时调用的函数(在我们的例子中是 Base::method3())必须在调用点可访问;最终在运行时执行的覆盖函数不必如此(然而,如果我们直接在类外部调用 Derived::method3(),我们就会尝试调用该类的私有方法)。

// Example 07
Derived* d = new Derived;
Base* b = d;
b->method2();    // OK, calls Derived::method2()
d->method2();    // Does not compile – private function

避免公共虚拟函数的另一个更根本的原因是,公共方法构成了类接口的一部分。虚拟函数的重写是对实现的定制。一个公共虚拟函数本质上同时执行这两个任务。同一个实体执行了两个非常不同的功能,这些功能不应该耦合在一起——声明公共接口和提供替代实现。这些功能各自有不同的约束——只要层次不变量保持不变,实现可以被以任何方式更改。但是,接口实际上不能通过虚拟函数来改变(除了返回协变类型,但这实际上并没有改变接口)。所有公共虚拟函数所做的只是重申,是的,公共接口仍然看起来像基类所声明的。这种两种非常不同的角色的混合需要更好的关注点分离。模板方法模式是对该设计问题的回答,在 C++中,它以非虚拟接口(NVI)的形式出现。

C++中的 NVI 习语

公共虚拟函数的两个角色之间的紧张关系,以及由这些函数创建的不必要的定制点暴露,导致我们产生了将实现特定的虚拟函数设为私有的想法。Herb Sutter 在他的文章《虚拟性》(www.gotw.ca/publications/mill18.htm)中建议,大多数,如果不是所有,虚拟函数都应该设为私有。

对于模板方法,将虚拟函数从公共部分移动到私有部分不会带来任何后果(除了看到私有虚拟函数时的初始震惊,如果你从未意识到 C++允许它们):

// Example 08 (NVI version of example 01)
class Base {
  public:
  bool TheAlgorithm() {
    if (!Step1()) return false; // Step 1 failed
    Step2();
    return true;
  }
  private:
  virtual bool Step1() { return true };
  virtual void Step2() = 0;
};
class Derived1 : public Base {
  void Step2() override { ... do the work ... }
};
class Derived2 : public Base {
  bool Step1() override { ... check preconditions ... }
  void Step2() override { ... do the work ... }
};

这个设计很好地将接口和实现分离开来——客户端接口始终是运行整个算法的一个调用。算法实现部分的可变性并没有在接口中得到体现。因此,仅通过公共接口访问这个类层次结构且不需要扩展层次结构(编写更多派生类)的用户,对这样的实现细节并不知情。为了了解这在实践中是如何工作的,你可以将本章中的每一个示例从公共虚拟函数转换为 NVI;我们将只做其中一个,即示例 06,其余的留给读者作为练习。

// Example 09 (NVI version of example 06)
class FileWriter {
  virtual void Preamble(const char* data) {}
  virtual void Postscript(const char* data) {}
  public:
  void Write(const char* data) {
    Preamble(data);
    ... write data to a file ...
    Postscript(data);
  }
};
class LoggingFileWriter : public FileWriter {
  using FileWriter::FileWriter;
  void Preamble(const char* data) override {
    std::cout << "Writing " << data << " to the file" <<
      std::endl;
  }
  void Postscript (const char*) override {
    std::cout << "Writing done" << std::endl;
  }
};

NVI(Non-Virtual Interface)将接口的完全控制权交给了基类。派生类只能自定义这个接口的实现。基类可以确定并验证不变性,强加实现的总体结构,并指定哪些部分可以、必须和不能被自定义。NVI 还明确地将接口与实现分离。派生类的实现者不需要担心无意中将实现的一部分暴露给调用者——仅实现私有的方法只能被基类调用。

注意,派生类如LoggingFileWriter仍然可以声明自己的非虚拟函数Write。这在 C++中被称为“阴影”:在派生类中引入的名称会阴影(或使不可访问)所有具有相同名称的函数,这些函数原本会从基类继承而来。这会导致基类和派生类的接口发生分歧,这是一种非常不好的做法。不幸的是,基类实现者没有好的方法来防止有意阴影。有时,当打算作为虚拟覆盖的函数以略有不同的参数声明时,会发生意外阴影;如果所有覆盖都使用override关键字,则可以避免这种情况。

到目前为止,我们已经将所有自定义实现的虚函数设置为私有。然而,这并不是 NVI(Non-Virtual Interface)的主要观点——这个惯用表达式以及更一般的模板方法,关注的是使公共接口非虚。由此延伸,实现特定的覆盖不应是公共的,因为它们不是接口的一部分。但这并不意味着它们应该是私有的。这就留下了受保护的。那么,为算法提供自定义的虚函数应该是私有的还是受保护的?模板方法允许两者——层次结构的客户端不能直接调用任何一个,因此算法的框架不受影响。答案取决于派生类是否可能需要调用基类提供的实现。以下是一个后者的例子,考虑一个可以序列化并通过套接字发送到远程机器的类层次结构:

// Example 10
class Base {
  public:
  void Send() { // Template Method used here
    ... open connection ...
    SendData(); // Customization point
    ... close connection ...
  }
  protected:
  virtual void SendData() { ... send base class data ... }
  private:
  ... data ...
};
class Derived : public Base {
  protected:
  void SendData() {
    Base::SendData();
    ... send derived class data ...
  }
};

在这里,框架由公共非虚方法Base::Send()提供,它处理连接协议,并在适当的时候通过网络发送数据。当然,它只能发送基类知道的数据。这就是为什么SendData是一个自定义点并且被设置为虚函数。派生类当然必须发送自己的数据,但仍然需要有人发送基类的数据,因此派生类调用基类中的受保护虚函数。

如果这个例子看起来好像缺少了什么,那是有充分理由的。虽然我们提供了发送数据的一般模板以及每个类处理其自身数据的一个自定义点,但还有一个应该由用户可配置的行为方面:如何发送数据。这是一个展示模板方法模式和策略模式(有时是隐晦的)之间差异的好地方。

模板方法 vs 策略

虽然本章不是关于策略模式,但它有时会与模板方法混淆,所以我们现在将澄清两者的区别。我们可以使用上一节的例子来做这件事。

我们已经使用模板方法为Base::Send()中“发送”操作的执行提供了一个整体模板。操作有三个步骤:打开连接、发送数据和关闭连接。发送数据是依赖于对象实际类型的步骤(它实际上是哪个派生类),因此它被明确指定为自定义点。模板的其余部分是固定的。

然而,我们需要另一种类型的自定义:在一般情况下,Base类不是定义如何打开和关闭连接的正确地方。派生类也不是:相同的对象可以通过不同类型的连接(套接字、文件、共享内存等)发送。这就是我们可以使用策略模式来定义通信策略的地方。策略由一个单独的类提供:

// Example 11
class CommunicationStrategy {
  public:
  virtual void Open() = 0;
  virtual void Close() = 0;
  virtual void Send(int v) = 0;
  virtual void Send(long v) = 0;
  virtual void Send(double v) = 0;
  ... Send other types ...
};

模板函数不能是虚函数,这不是很令人沮丧吗?对于这个问题的更好解决方案,你必须等到 第十五章基于策略的设计。无论如何,现在我们有了通信策略,我们可以用它来参数化 Send() 操作模板:

// Example 11
class Base {
  public:
  void Send(CommunicationStrategy* comm) {
    comm->Open();
    SendData(comm);
    comm->Close();
  }
  protected:
  virtual void SendData(CommunicationStrategy* comm) {
    comm->Send(i_);
    ... send all data ...
  }
  private:
  int i_;
  ... other data members ...
};

注意,发送数据的模板基本上没有改变,但我们委托了具体步骤的实现给另一个类——策略。这是关键的区别:策略模式允许我们选择(通常在运行时)特定操作应使用哪种实现。公共接口是固定的,但整个实现完全取决于特定的策略。模板方法模式强制执行整体实现流程以及公共接口。只有算法的具体步骤可以定制。

第二个区别在于定制的位置:Base::Send() 以两种方式进行了定制。对模板的定制是在派生类中完成的;策略的实现由 Base 层次之外的类提供。

正如我们在本节开头所指出的,有很好的理由将所有虚成员函数默认设置为私有(或保护),这不仅仅适用于模板方法模式的应用。然而,有一个特定的成员函数——析构函数——值得单独考虑,因为析构函数的规则有所不同。

关于析构函数的说明

对 NVI 的整个讨论是对一个简单指南的详细阐述——使虚函数私有(或保护),并通过非虚基类函数呈现公共接口。这听起来不错,直到它与另一个众所周知的指南正面冲突——如果一个类至少有一个虚函数,那么它的析构函数也必须是虚函数。由于这两个存在冲突,需要一些澄清。

使析构函数为虚函数的原因是,如果对象以多态方式被删除——例如,通过基类指针删除派生类对象——则析构函数必须是虚函数;否则,只有类的基部分将被析构(通常的结果是类的切片,部分删除,尽管标准只是简单地声明结果是不确定的)。因此,如果对象是通过基类指针删除的,析构函数必须是虚函数;没有其他选择。但这只是唯一的原因。如果对象总是以正确的派生类型被删除,那么这个原因就不适用。这种情况并不少见:例如,如果派生类对象存储在容器中,它们将按其真实类型被删除。

容器必须知道为对象分配多少内存,因此它不能存储基类和派生类的混合对象,或者将它们作为基类对象删除(请注意,指向基类对象的指针容器是另一种完全不同的结构,通常是为了我们可以以多态方式存储和删除对象而专门创建的)。

现在,如果派生类必须以自身类型被删除,其析构函数不需要是虚函数。然而,如果有人在实际对象为派生类类型时调用基类的析构函数,仍然会发生不好的事情。为了安全地防止这种情况发生,我们可以将非虚基类析构函数声明为受保护的,而不是公共的。当然,如果基类不是抽象的,并且周围有基类和派生类的对象,那么两个析构函数都必须是公共的,更安全的选项是将它们声明为虚函数(可以实施运行时检查来验证基类析构函数没有被用来销毁派生类对象)。

顺便说一下,如果你只需要在基类中编写析构函数来实现多态删除(通过基类指针进行删除),编写virtual ~Base() = default;是完全可接受的——析构函数可以同时是virtualdefault

我们还必须警告读者不要尝试为类析构函数使用模板方法或非虚接口习惯用法。可能会诱使人们做类似这样的事情:

// Example 12
class Base {
  public:
  ~Base() { // Non-virtual interface!
    std::cout << "Deleting now" << std::endl;
    clear(); // Employing Template Method here
    std::cout << "Deleting done" << std::endl;
  }
  protected:
  virtual void clear() { ... } // Customizable part
};
class Derived : public Base {
  private:
  void clear() override {
    ...
    Base::clear();
  }
};

然而,这不会起作用(如果基类有一个纯虚的Base::clear()而不是默认实现,它将以相当壮观的方式失败)。原因在于,在基类析构函数Base::~Base()内部,对象的实际、真实和真正类型不再是Derived。它是Base。没错——当Derived::~Derived()析构函数完成其工作并将控制权传递给基类析构函数时,对象的动态类型变为Base

唯一其他以这种方式工作的类成员是构造函数——只要基类构造函数正在运行,对象的类型就是Base,然后当派生类构造函数开始运行时,类型变为Derived。对于所有其他成员函数,对象的类型始终是其创建时的类型。如果对象是以Derived类型创建的,那么这就是其类型,即使调用了基类的方法。那么,如果在先前的例子中,Base::clear()是纯虚函数,会发生什么?它仍然会被调用!结果取决于编译器;大多数编译器将生成代码来终止程序,并带有一些诊断信息,指出调用了纯虚函数

非虚接口的缺点

在使用 NVI(非虚拟接口)方面,并没有太多缺点。这就是为什么总是将虚函数设为私有,并使用 NVI 来调用它们的指南被广泛接受。然而,在决定模板方法是否是正确的设计模式时,你必须注意一些考虑因素。使用模板模式可能会导致脆弱的层次结构。此外,使用模板模式可以解决的问题和那些更适合使用策略模式或 C++中的策略的问题之间存在一些重叠。我们将在本节中回顾这两个考虑因素。

可组合性

考虑一下LoggingFileWriter的早期设计。现在,假设我们还想有一个CountingFileWriter,它可以计算写入文件中的字符数:

class CountingFileWriter : public FileWriter {
  size_t count_ = 0;
  void Preamble(const char* data) {
    count_ += strlen(data);
  }
};

这很简单。但是,没有理由计数文件写入器不能也进行日志记录。我们如何实现CountingLoggingFileWriter?没问题,我们有技术——将私有虚函数改为受保护的,并从派生类中调用基类版本:

class CountingLoggingFileWriter : public LoggingFileWriter {
  size_t count_ = 0;
  void Preamble(const char* data) {
    count_ += strlen(data);
    LoggingFileWriter::Preamble(data);
  }
};

或者它应该是从CountingFileWriter继承的LoggingCountingFileWriter?请注意,无论哪种方式,都会有一些代码重复——在我们的例子中,计数代码同时存在于CountingLoggingFileWriterCountingFileWriter中。随着我们添加更多变体,这种重复只会变得更糟。如果你需要可组合的自定义化,模板方法根本就不是正确的模式。为此,你应该阅读第十五章基于策略的设计

脆弱基类问题

脆弱基类问题不仅限于模板方法,在一定程度上,它是所有面向对象语言固有的。问题出现在对基类的更改破坏了派生类。为了了解这是如何发生的,特别是当使用非虚拟接口时,让我们回到文件写入器并添加一次性写入多个字符串的能力:

class FileWriter {
  public:
  void Write(const char* data) {
    Preamble(data);
    ... write data to a file ...
    Postscript(data);
  }
  void Write(std::vector<const char*> huge_data) {
    Preamble(huge_data);
    for (auto data: huge_data) {
      ... write data to file ...
    }
    Postscript(huge_data);
  }
  private:
  virtual void Preamble(std::vector<const char*> data) {}
  virtual void Postscript(std::vector<const char*> data) {}
  virtual void Preamble(const char* data) {}
  virtual void Postscript(const char* data) {}
};

计数写入器会随着更改而保持最新:

class CountingFileWriter : public FileWriter {
  size_t count_ = 0;
  void Preamble(std::vector<const char*> huge_data) {
    for (auto data: huge_data) count_ += strlen(data);
  }
  void Preamble(const char* data) {
    count_ += strlen(data);
  }
};

到目前为止,一切顺利。后来,一个有良好意图的程序员注意到基类存在一些代码重复,并决定对其进行重构:

class FileWriter {
  public:
  void Write(const char* data) { ... no changes here ... }
  void Write(std::vector<const char*> huge_data) {
    Preamble(huge_data);
    for (auto data: huge_data) Write(data); // Code reuse!
    Postscript(huge_data);
  }
  private:
  ... no changes here ...
};

现在,派生类被破坏了——当写入字符串向量时,会调用Write的两个版本的计数自定义化,数据大小被计算了两次。请注意,我们不是在谈论更基本的脆弱性,即如果基类方法的签名发生变化,派生类中的重写方法可能会停止重写:这种脆弱性在很大程度上可以通过在第一章继承和多态简介中推荐使用override关键字来避免。

尽管只要使用继承,就没有解决脆弱基类问题的通用方法,但使用模板方法时避免该问题的指南是直接的——当更改基类和算法结构或框架时,避免更改被调用的定制点。具体来说,不要跳过已经调用的任何定制选项,也不要向已经存在的选项中添加新的调用(只要默认实现是合理的,添加新的定制点是允许的)。如果无法避免这种更改,您需要审查每个派生类,以确定它是否依赖于现在已删除或替换的实现覆盖,以及这种更改的后果。

关于模板定制点的注意事项

这个简短的章节并不是模板方法的缺点,而是一个关于 C++ 中某个较为晦涩角落的警告。许多最初作为运行时行为(面向对象模式)开发的设计模式,在 C++ 泛型编程中找到了它们的编译时对应物。那么,编译时模板方法是否存在呢?

当然,有一个明显的例子:我们可以有一个函数或类模板,它接受函数参数,或者更普遍地说,接受可调用参数,用于算法的某个固定步骤。标准库中有许多例子,例如 std::find_if

std::vector<int> v = ... some data ...
auto it = std::find_if(v.begin(), v.end(),
                       [](int i) { return i & 1; });
if (it != v.end()) { ... } // even value found

std::find_if 的算法是已知的,无法更改,除非它在检查特定值是否满足调用者的谓词这一步。

如果我们想对类层次结构做同样的事情,我们可以使用成员函数指针(尽管通过 lambda 表达式调用成员函数更容易),但除了使用虚拟函数及其覆盖之外,没有其他方法可以说“在具有不同名称的类上调用具有相同名称的成员函数”。在泛型编程中没有与之等效的方法。

不幸的是,有一种情况,模板可能会意外地被定制,通常会产生意料之外的结果。考虑以下示例:

// Example 15
void f() { ... }
template <typename T> struct A {
  void f() const { ... }
};
template <typename T> struct B : public A<T> {
  void h() { f(); }
};
B<int> b;
b.h();

B<T>::h() 内部调用的是哪个函数 f()?在符合标准的编译器中,应该是独立函数,即 ::f(),而不是基类的成员函数!这可能会让人感到惊讶:如果 AB 都是非模板类,那么就会调用基类的方法 A::f()。这种行为源于 C++ 解析模板的复杂性(如果你想了解更多关于这个话题的信息,可以搜索“两阶段模板解析”或“两阶段名称查找”,但这个问题远远超出了本书的主题)。

如果全局函数 f() 本来就不存在,会发生什么?那么我们不得不调用基类中的那个,不是吗?

// Example 15
// No f() here!
template <typename T> struct A {
  void f() const { ... }
};
template <typename T> struct B : public A<T> {
  void h() { f(); } // Should not compile!
};
B<int> b;
b.h();

如果你尝试了这段代码并且它调用了A<T>::f(),那么你有一个有缺陷的编译器:标准规定这根本不应该编译!但如果你想要调用你自己的基类的成员函数,你应该怎么做?答案是简单但如果你没有编写很多模板代码可能会看起来奇怪:

// Example 15
template <typename T> struct A {
  void f() const { ... }
};
template <typename T> struct B : public A<T> {
  void h() { this->f(); } // Definitely A::f()
};
B<int> b;
b.h();

没错,你必须显式地调用this->f()来确保你正在调用一个成员函数。如果你这样做,无论是否声明了全局的f(),都会调用A<T>::f()。顺便说一句,如果你打算调用全局函数,明确这样做的方式是::f(),或者如果函数在命名空间NS中,则是NS::f()

编译器无法找到在基类中明显存在的成员函数的编译错误是 C++中较为令人困惑的错误之一;如果编译器没有报告这个错误,而是“按预期”编译了代码,那就更糟糕了:如果后来有人添加了一个具有相同名称的全局函数(或者它在另一个你包含的头文件中声明),编译器将无警告地切换到那个函数。一般准则是在类模板中对成员函数调用使用this->进行限定。

总体而言,模板方法是少数几个在 C++中仍然纯粹面向对象的模式之一:我们看到的std::find_if(以及许多其他模板)的模板形式通常属于我们将在下一章研究的基于策略的设计的一般范畴。

摘要

在本章中,我们回顾了经典面向对象设计模式之一,即模板方法,以及它如何应用于 C++程序。这个模式在 C++中以及任何其他面向对象的语言中都适用,但 C++也有自己风格的模板方法——非虚接口习语。这种设计模式的优势导致了一个相当广泛的准则——将所有虚函数设为私有或保护。然而,关于多态,要注意析构函数的具体细节。以下是访问(公共与私有)虚函数的一般准则:

  1. 更倾向于使用模板方法设计模式将接口设为非虚

  2. 更倾向于将虚函数设为私有

  3. 只有当派生类需要调用虚拟函数的基类实现时,才将虚拟函数设为保护。

  4. 基类析构函数应该是公共和虚的(如果对象通过基类指针被删除)或者保护和非虚的(如果直接删除派生对象)。

我们在本章中已经通过阐明它与模板方法模式的区别来提到了策略模式。策略也是 C++中一个流行的模式,特别是它的泛型编程等价物。这将是下一章的主题。

问题

  1. 什么是行为设计模式?

  2. 模板方法模式是什么?

  3. 为什么模板方法被认为是行为模式?

  4. 控制反转是什么,它如何应用于模板方法?

  5. 非虚接口是什么?

  6. 为什么建议在 C++ 中将所有虚函数设置为私有?

  7. 应该在何时将虚函数设置为保护?

  8. 为什么模板方法不能用于析构函数?

  9. 什么是脆弱基类问题,以及我们在使用模板方法时如何避免它?

第四部分:高级 C++ 设计模式

本部分继续描述和详细解释 C++ 设计模式,并转向更高级的模式。其中一些模式使用了 C++ 语言的先进特性。其他模式代表了复杂的概念,并解决了更困难的设计问题。还有一些模式实现了非常开放的设计,其中解决方案的一部分可以分解为一种普遍接受的模式,但整个系统必须在非常广泛的范围内可定制。

本部分包含以下章节:

  • 第十五章基于策略的设计

  • 第十六章适配器和装饰器

  • 第十七章访问者模式和多重分派

  • 第十八章并发模式

第十五章:基于策略的设计

基于策略的设计是 C++中最著名的模式之一。自从 1998 年引入标准模板库以来,很少有新的想法比基于策略的设计对 C++程序设计方式的影响更大。

基于策略的设计完全是关于灵活性、可扩展性和定制性。这是一种设计软件的方法,可以使软件能够进化,并能够适应不断变化的需求,其中一些需求在最初设计构思时甚至无法预见。一个设计良好的基于策略的系统可以在结构层面上多年保持不变,并在不妥协的情况下满足不断变化的需求和新要求。

不幸的是,这也是构建能够做所有这些事情(如果有人能弄清楚它是如何工作的)的软件的方法。本章的目标是教会你设计和理解前一种类型的系统,同时避免导致后一种类型灾难的过度行为。

本章将涵盖以下主题:

  • 策略模式和基于策略的设计

  • C++中的编译时策略

  • 基于策略的类的实现

  • 策略的使用指南

技术要求

本章的示例代码可以在以下 GitHub 链接中找到:github.com/PacktPublishing/Hands-On-Design-Patterns-with-CPP_Second_Edition/tree/master/Chapter15

策略模式和基于策略的设计

经典的策略模式是一种行为设计模式,它允许在运行时选择特定行为的具体算法,通常是从预定义的算法族中选择。这种模式也被称为策略模式;其名称早于其在 C++泛型编程中的应用。策略模式的目标是允许设计有更大的灵活性。

注意

在经典的对象导向策略模式中,关于使用哪个具体算法的决定被推迟到运行时。

就像许多经典模式一样,C++中的泛型编程在编译时算法选择上采用相同的方法 - 它允许通过从一系列相关、兼容的算法中选择来对系统行为的特定方面进行编译时定制。我们现在将学习如何在 C++中实现具有策略的类的基础知识,然后继续研究更复杂和多样化的基于策略设计的方法。

基于策略设计的原理

当我们设计一个执行某些操作的系统,但具体操作的实施是不确定的、多样的或系统实施后可能发生变化时,应该考虑使用策略模式——换句话说,当我们知道系统必须做什么(what the system must do),但不知道如何做(how)时。同样,编译时策略(或策略)是实现一个具有特定功能(what)的类的方法,但实现该功能的方式不止一种(how)。

在本章中,我们将设计一个智能指针类来展示如何使用策略。除了策略之外,智能指针还有许多其他必需和可选的功能,我们不会涵盖所有这些功能——对于智能指针的完整实现,你将被指引到诸如 C++标准智能指针(unique_ptrshared_ptr)、Boost 智能指针或 Loki 智能指针(loki-lib.sourceforge.net/)等示例。本章中介绍的材料将帮助你理解这些库的实现者所做的选择,以及如何设计自己的基于策略的类。

一个智能指针的最小初始实现可能看起来像这样:

// Example 01
template <typename T>
  T* p_;
  class SmartPtr {
  public:
  explicit SmartPtr(T* p = nullptr) : p_(p) {}
  ~SmartPtr() {
    delete p_;
  }
  T* operator->() { return p_; }
  const T* operator->() const { return p_; }
  T& operator*() { return *p_; }
  const T& operator*() const { return *p_; }
  SmartPtr(const SmartPtr&) = delete;
  SmartPtr& operator=(const SmartPtr&) = delete;
  SmartPtr(SmartPtr&& that) :
    p_(std::exchange(that.p_, nullptr)) {}
  SmartPtr& operator=(SmartPtr&& that) {
    delete p_;
    p_ = std::exchange(that.p_, nullptr);
  }
};

此指针有一个从相同类型的原始指针构造函数和通常的(对于指针)操作符,即*->。这里最有趣的部分是析构函数——当指针被销毁时,它将自动删除对象(在删除之前不需要检查指针的null值;operator delete需要接受一个空指针并执行无操作)。因此,这个智能指针的预期使用方式如下:

// Example 01
Class C { ... };
{
  SmartPtr<C> p(new C);
  ... use p ...
} // Object *p is deleted automatically

这是一个 RAII 类的基本示例。RAII 对象——在我们的例子中是智能指针——拥有资源(已构造的对象)并在拥有对象本身被删除时释放(删除)它。在第五章“全面审视 RAII”中详细考虑的常见应用,重点是确保在程序退出此作用域时删除在作用域内构造的对象,无论后者是如何完成的(例如,如果在代码的中间某处抛出异常,RAII 析构函数将保证对象被销毁)。

智能指针的两个更多成员函数被提及,不是它们的实现,而是它们的缺失——指针被设计为不可复制的,因为它的拷贝构造函数和赋值运算符都被禁用了。这个有时被忽视的细节对于任何 RAII 类至关重要——由于指针的析构函数会删除所拥有的对象,因此绝对不应该有两个智能指针指向并尝试删除同一个对象。另一方面,移动指针是一个有效的操作:它将所有权从旧指针转移到新指针。移动构造函数对于工厂函数的工作是必要的(至少在 C++17 之前)。

我们这里所拥有的指针是功能性的,但其实现是受限的。特别是,它只能拥有和删除使用标准 operator new 构造的对象,并且只能是一个对象。虽然它可以捕获从自定义 operator new 获得的指针或指向元素数组的指针,但它并不能正确地删除这样的对象。

我们可以为在用户定义的堆上创建的对象实现不同的智能指针,为在客户端管理的内存中创建的对象实现另一个智能指针,等等,为每种类型的对象构造及其相应的删除方式实现一个。这些指针的大部分代码都会重复——它们都是指针,整个指针-like API 将必须复制到每个类中。我们可以观察到,所有这些不同的类在本质上都是同一类——对于问题“这是什么类型?”的回答总是相同的——它是一个 智能指针

唯一的区别在于删除的实现方式。这种在行为的一个特定方面有差异但意图相同的情况表明了使用策略模式。我们可以实现一个更通用的智能指针,其中处理对象删除的细节被委托给任何数量的删除策略之一:

// Example 02
template <typename T, typename DeletionPolicy>
class SmartPtr {
  T* p_;
  DeletionPolicy deletion_policy_;
  public:
  explicit SmartPtr(
    T* p = nullptr,
    const DeletionPolicy& del_policy = DeletionPolicy()) :
    p_(p), deletion_policy_(del_policy)
  {}
  ~SmartPtr() {
    deletion_policy_(p_);
  }
  T* operator->() { return p_; }
  const T* operator->() const { return p_; }
  T& operator*() { return *p_; }
  const T& operator*() const { return *p_; }
  SmartPtr(const SmartPtr&) = delete;
  SmartPtr& operator=(const SmartPtr&) = delete;
  SmartPtr(SmartPtr&& that) :
    p_(std::exchange(that.p_, nullptr)),
    deletion_policy_(std::move(deletion_policy_))
 {}
  SmartPtr& operator=(SmartPtr&& that) {
    deletion_policy_(p_);
    p_ = std::exchange(that.p_, nullptr);
    deletion_policy_ = std::move(deletion_policy_);
  }
};

删除策略是一个额外的模板参数,并且将删除策略类型的对象传递给智能指针的构造函数(默认情况下,这样的对象是默认构造的)。删除策略对象存储在智能指针中,并在其析构函数中使用它来删除指针所指向的对象。

在实现基于策略的类的复制和移动构造函数时必须小心:很容易忘记策略也需要移动或复制到新对象中。在我们的例子中,复制被禁用,但移动操作是支持的。它们必须移动的不仅仅是指针本身,还有策略对象。我们像处理任何其他类一样做这件事:通过移动对象(移动指针更复杂,因为它们是内置类型,但所有类都假定能够正确处理自己的移动操作或删除它们)。在赋值运算符中,记住指针当前拥有的对象必须由相应的,即旧的策略删除;只有在这种情况下,我们才将策略从赋值运算符的右侧移动过来。

对于删除策略类型的要求只有一个,那就是它应该是可调用的——策略被调用,就像一个带有一个参数的函数,以及指向必须删除的对象的指针。例如,我们原始指针在对象上调用 operator delete 的行为可以用以下删除策略来复制:

// Example 02
template <typename T>
struct DeleteByOperator {
  void operator()(T* p) const {
    delete p;
  }
};

要使用此策略,我们必须在构造智能指针时指定其类型,并且可以选择性地将此类型的对象传递给构造函数,尽管在这种情况下,默认构造的对象将工作得很好:

class C { ... };
SmartPtr<C, DeleteByOperator<C>> p(new C(42));

在 C++17 中,构造模板参数推导(CTAD) 通常可以推导出模板参数:

class C { ... };
SmartPtr p(new C(42));

如果删除策略与对象类型不匹配,将报告无效调用 operator() 的语法错误。这通常是不希望的:错误消息并不特别友好,并且通常,对策略的要求必须从模板中策略的使用推断出来(我们的策略只有一个要求,但这是我们第一个也是最简单的策略)。为具有策略的类编写的好做法是明确并在一个地方验证和记录策略的所有要求。在 C++20 中,可以使用概念来完成此操作:

// Example 03
template <typename T, typename F> concept Callable1 =
  requires(F f, T* p) { { f(p) } -> std::same_as<void>; };
template <typename T, typename DeletionPolicy>
requires Callable1<T, DeletionPolicy>
class SmartPtr {
  ...
};

在 C++20 之前,我们可以通过编译时断言来实现相同的结果:

// Example 04
template <typename T, typename DeletionPolicy>
requires Callable1<T, DeletionPolicy>
class SmartPtr {
  ...
  static_assert(std::is_same<
    void, decltype(deletion_policy_(p_))>::value, "");
};

即使在 C++20 中,你也可能更喜欢 assert 错误消息。这两个选项都实现了相同的目标:它们验证策略满足所有要求,并在代码的一个地方以可读的方式表达这些要求。是否在要求中包含“可移动”取决于你:严格来说,策略只需要是可移动的,如果你需要移动智能指针本身。允许非可移动策略并在需要时才要求移动操作是合理的。

对于以不同方式分配的对象,需要其他删除策略。例如,如果一个对象是在用户提供的包含 allocate()deallocate() 成员函数的堆对象上创建的,分别用于分配和释放内存,我们可以使用以下堆删除策略:

// Example 02
template <typename T> struct DeleteHeap {
  explicit DeleteHeap(Heap& heap) : heap_(heap) {}
  void operator()(T* p) const {
    p->~T();
    heap_.deallocate(p);
  }
  private:
  Heap& heap_;
};

另一方面,如果一个对象是在由调用者单独管理的内存中构造的,那么只需要调用对象的析构函数:

// Example 02
template <typename T> struct DeleteDestructorOnly {
  void operator()(T* p) const {
    p->~T();
  }
};

我们之前提到,由于策略被用作可调用的实体,deletion_policy_(p_),它可以任何可以像函数一样调用的类型。这包括实际的函数:

// Example 02
using delete_int_t = void (*)(int*);
void delete_int(int* p) { delete p; }
SmartPtr<int, delete_int_t> p(new int(42), delete_int);

模板实例化也是一个函数,可以以相同的方式使用:

template <typename T> void delete_T(T* p) { delete p; }
SmartPtr<int, delete_int_t> p(new int(42), delete_T<int>);

在所有可能的删除策略中,其中一个通常是最常用的。在大多数程序中,它很可能会是默认的operator delete函数的删除。如果是这样,避免每次使用时都指定这个策略,并使其成为默认策略是有意义的:

// Example 02
template <typename T,
          typename DeletionPolicy = DeleteByOperator<T>>
class SmartPtr {
  ...
};

现在,我们的基于策略的智能指针可以像原始版本一样使用,只需删除一个选项:

SmartPtr<C> p(new C(42));

在这里,第二个模板参数被保留为其默认值,DeleteByOperator<C>,并将此类型的默认构造对象传递给构造函数作为默认的第二个参数。

在这一点上,我必须警告你,在实现这样的基于策略的类时可能会犯的一个微妙错误。请注意,策略对象是通过const引用在智能指针的构造函数中被捕获的:

explicit SmartPtr(T* p = nullptr,
  const DeletionPolicy& del_policy = DeletionPolicy());

这里的const引用很重要,因为非const引用不能绑定到临时对象(我们将在本节稍后考虑右值引用)。然而,策略是通过值存储在对象本身中的,因此必须制作策略对象的副本:

template <typename T,
          typename DeletionPolicy = DeleteByOperator<T>>
class SmartPtr {
  T* p_;
  DeletionPolicy deletion_policy_;
  ...
};

可能会诱使人们避免复制,并在智能指针中通过引用捕获策略:

// Example 05
template <typename T,
          typename DeletionPolicy = DeleteByOperator<T>>
class SmartPtr {
  T* p_;
  const DeletionPolicy& deletion_policy_;
  ...
};

在某些情况下,这甚至可以工作,例如:

Heap h;
DeleteHeap<C> del_h(h);
SmartPtr<C, DeleteHeap<C>> p(new (&heap) C, del_h);

然而,这对于默认创建智能指针或以临时策略对象初始化的任何其他智能指针都不会起作用:

SmartPtr<C> p(new C, DeleteByOperator<C>());

这段代码可以编译。不幸的是,它是错误的——临时DeleteByOperator<C>对象在调用SmartPtr构造函数之前被构造,但在语句结束时被销毁。SmartPtr对象内部的引用留成了悬垂引用。乍一看,这不应该让人感到惊讶——当然,临时对象不会比它被创建的语句活得久——它最晚在语句的闭合分号处被删除。一个对语言细节更熟悉的读者可能会问——标准不是特别扩展了绑定到常量引用的临时对象的生存期吗? 确实如此;例如:

{
  const C& c = C();
  ... c is not dangling! ...
} // the temporary is deleted here

在这个代码片段中,临时对象C()在句子的末尾没有被删除,而是在它所绑定引用的生命周期结束时才被删除。那么,为什么同样的技巧没有在我们的删除策略对象上起作用呢?答案是,它某种程度上是起作用的 - 当构造函数的参数被评估并绑定到const引用参数时创建的临时对象,在其引用的生命周期内没有被销毁,这就是构造函数调用的持续时间。实际上,它本来就不会被销毁 - 在函数参数评估过程中创建的所有临时对象都在包含函数调用的句子的末尾被删除,即关闭分号处。在我们的情况下,函数是对象的构造函数,因此临时对象的生命周期跨越了整个构造函数调用。然而,它并不扩展到对象的生命周期 - 对象的const引用成员不是绑定到临时对象,而是绑定到构造函数参数,而构造函数参数本身也是一个const引用。

生命周期扩展只能使用一次 - 将引用绑定到临时对象会延长其生命周期。另一个绑定到第一个引用的引用不会做任何事情,如果对象被销毁,它可能会留下悬挂引用(GCC 和 CLANG 的地址清理器ASAN)有助于找到这样的错误)。因此,如果策略对象需要作为智能指针的数据成员存储,它必须被复制。

通常,策略对象很小,复制它们是微不足道的。然而,有时策略对象可能具有非平凡的内部状态,复制起来代价高昂。你也可以想象一个不可复制的策略对象。在这些情况下,将参数对象移动到数据成员对象中可能是有意义的。如果我们声明一个类似于移动构造函数的重载,这很容易做到:

// Example 06
template <typename T,
          typename DeletionPolicy = DeleteByOperator<T>>
class SmartPtr {
  T* p_;
  DeletionPolicy deletion_policy_;
  public:
  explicit SmartPtr(T* p = nullptr,
      DeletionPolicy&& del_policy = DeletionPolicy())
    : p_(p), deletion_policy_(std::move(del_policy))
  {}
  ...
};

正如我们所说的,策略对象通常很小,所以复制它们很少成为问题。如果你确实需要两个构造函数,请确保只有一个有默认参数,这样调用无参数或无策略参数的构造函数就不会产生歧义。

我们现在有一个已经实现一次的智能指针类,但其删除实现可以在编译时通过指定删除策略进行定制。我们甚至可以添加一个在类设计时不存在的新删除策略,只要它符合相同的调用接口,它就会正常工作。接下来,我们将考虑实现策略对象的不同方法。

策略的实现

在上一节中,我们学习了如何实现最简单的策略对象。只要策略符合接口约定,它可以是任何类型,并且作为数据成员存储在类中。策略对象最常见的是通过模板生成的;然而,它也可以是一个特定于特定指针类型的常规非模板对象,甚至是一个函数。策略的使用仅限于特定的行为方面,例如智能指针拥有的对象的删除。

有几种方法可以实现和使用此类策略。首先,让我们回顾一下具有删除策略的智能指针的声明:

template <typename T,
          typename DeletionPolicy = DeleteByOperator<T>>
class SmartPtr { ... };

接下来,让我们看看我们如何构造一个智能指针对象:

class C { ... };
SmartPtr<C, DeleteByOperator<C>> p(
  new C(42), DeleteByOperator<C>());

这种设计的缺点立即显现出来——类型C在对象p的定义中提到了四次——它必须在所有四个地方保持一致,否则代码将无法编译。C++17 允许我们稍微简化定义:

SmartPtr p(new C, DeleteByOperator<C>());

在这里,构造函数用于从构造函数参数推导出class模板的参数,方式类似于函数模板。仍然有两个关于类型C的提及必须保持一致。

一种适用于无状态策略以及内部状态不依赖于主模板类型(在我们的例子中,是SmartPtr模板的类型T)的策略对象实现方法是,将策略本身做成非模板对象,但给它一个模板成员函数。例如,DeleteByOperator策略是无状态的(该对象没有数据成员)并且可以不使用类模板来实现:

// Example 07
struct DeleteByOperator {
  template <typename T> void operator()(T* p) const {
    delete p;
  }
};

这是一个非模板对象,因此不需要类型参数。成员函数模板在需要删除的对象类型上实例化——类型由编译器推导。由于策略对象类型始终相同,我们不必担心在创建智能指针对象时指定一致的类型:

// Example 07
SmartPtr<C, DeleteByOperator> p(
  new C, DeleteByOperator());             // Before C++17
SmartPtr p(new C, DeleteByOperator());     // C++17

此对象可以直接用于我们的智能指针,无需对SmartPtr模板进行任何修改,尽管我们可能想要更改默认模板参数:

template <typename T,
          typename DeletionPolicy = DeleteByOperator>
class SmartPtr { ... };

更复杂的策略,如堆删除策略,仍然可以使用这种方法实现:

struct DeleteHeap {
  explicit DeleteHeap(SmallHeap& heap) : heap_(heap) {}
  template <typename T> void operator()(T* p) const {
    p->~T();
    heap_.deallocate(p);
  }
  private:
  Heap& heap_;
};

此策略有一个内部状态——对堆的引用——但在此策略对象中,除了operator()成员函数外,没有任何内容依赖于我们需要删除的对象的类型T。因此,策略不需要通过对象类型进行参数化。

由于主模板SmartPtr在将我们的策略从类模板转换为具有模板成员函数的非模板类时无需更改,因此我们没有理由不能使用相同类中的两种类型策略。实际上,前一小节中的任何模板类策略仍然有效,因此我们可以将一些删除策略实现为类,而将其他策略实现为类模板。后者在策略具有依赖于智能指针对象类型的成员数据类型时很有用。

如果策略作为类模板实现,我们必须指定正确的类型来实例化策略,以便与每个特定的基于策略的类一起使用。在许多情况下,这是一个非常重复的过程 - 相同的类型用于参数化主模板及其策略。如果我们使用整个模板而不是其特定的实例作为策略,我们可以让编译器为我们完成这项工作:

// Example 08
template <typename T,
          template <typename> class DeletionPolicy =
                                    DeleteByOperator>
class SmartPtr {
  public:
  explicit SmartPtr(T* p = nullptr,
    const DeletionPolicy<T>& del_policy =
                             DeletionPolicy<T>())
  : p_(p), deletion_policy_(deletion_policy)
  {}
  ~SmartPtr() {
    deletion_policy_(p_);
  }
  ...
};

注意第二个模板参数的语法 - template <typename> class DeletionPolicy。这被称为模板模板参数 - 模板的参数本身也是一个模板。在 C++14 及之前版本中,class关键字是必要的;在 C++17 中,它可以被typename替换。要使用此参数,我们需要用某种类型实例化它;在我们的例子中,它是主模板类型参数T。这确保了在主要智能指针模板及其策略中的对象类型的一致性,尽管构造函数的参数仍然必须用正确的类型构造:

SmartPtr<C, DeleteByOperator> p(
  new C, DeleteByOperator<C>());

再次,在 C++17 中,类模板参数可以由构造函数推导;这也适用于模板模板参数:

SmartPtr p(new C, DeleteByOperator<C>());

当类型是从模板实例化时,模板模板参数似乎是一个吸引人的替代方案,为什么我们总是不使用它们呢?首先,正如你所见,它们在灵活性方面略逊于模板类参数:当策略是一个与类本身具有相同第一个参数的模板时,它们在常见情况下可以节省输入,但在任何其他情况下都不起作用(策略可能是一个非模板或需要多个参数的模板)。另一个问题是,按照目前的写法,模板模板参数有一个显著的限制 - 模板参数的数量必须与指定完全匹配,包括默认参数。换句话说,假设我有以下模板:

template <typename T, typename Heap = MyHeap> class DeleteHeap { ... };

此模板不能用作先前智能指针的参数 - 它有两个模板参数,而我们在SmartPtr的声明中只指定了一个(具有默认值的参数仍然是一个参数)。这个限制很容易解决:我们只需要将模板模板参数定义为变长模板:

// Example 09
template <typename T,
          template <typename...> class DeletionPolicy =
                                    DeleteByOperator>
class SmartPtr {
  ...
};

现在,删除策略模板可以有任何数量的类型参数,只要它们有默认值(DeletionPolicy<T>是我们用于SmartPtr的,它必须能编译)。相比之下,我们可以使用DeleteHeap模板的一个实例来为智能指针提供DeletionPolicy作为类型参数,而不是模板模板参数——我们只需要一个类,DeleteHeap<int, MyHeap>就能做得很好。

到目前为止,我们总是将策略对象捕获为基于策略的类的数据成员。将类集成到更大的类中的这种做法被称为组合。还有其他方法可以让主模板访问策略提供的定制行为算法,我们将在下一部分考虑。

策略对象的使用

到目前为止,我们的所有示例都将策略对象存储为类的数据成员。这通常是存储策略的首选方式,但它有一个显著的缺点——数据成员总是有非零大小。考虑我们的具有某种删除策略的智能指针:

template <typename T> struct DeleteByOperator {
  void operator()(T* p) const {
    delete p;
  }
};
template <typename T,
          typename DeletionPolicy = DeleteByOperator<T>>
class SmartPtr {
  T* p_;
  DeletionPolicy deletion_policy_;
  ...
};

注意,策略对象没有数据成员。然而,对象的大小不是零,而是 1 个字节(我们可以通过打印sizeof(DeleteByOperator<int>)的值来验证这一点)。这是必要的,因为 C++程序中的每个对象都必须有一个唯一的地址:

DeleteByOperator<int> d1;     // &d1 = ....
DeleteByOperator<long> d2; // &d2 must be != &d1

当两个对象在内存中连续布局时,它们地址之间的差异是第一个对象的大小(如果需要,加上填充)。为了防止d1d2对象位于相同的地址,标准规定它们的大小至少为 1 个字节。

当作为另一个类的数据成员使用时,一个对象将占用至少与其大小相等的空间,在我们的例子中,是 1 个字节。假设指针占用 8 个字节,因此整个对象长度为 9 个字节。但是,对象的大小也必须填充到满足对齐要求的最接近的值——如果指针的地址需要对齐到 8 个字节,对象可以是 8 个字节或 16 个字节,但不能介于两者之间。因此,向类中添加一个空的策略对象最终将其大小从 8 个字节增加到 16 个字节。这纯粹是内存的浪费,通常是不希望的,尤其是对于大量创建的对象,如指针。无法说服编译器创建零大小的数据成员;标准禁止这样做。但是,策略还可以以另一种方式使用,而不产生开销。

组合的替代方法是继承——我们可以将策略作为主类的基类:

// Example 10
template <typename T,
          typename DeletionPolicy = DeleteByOperator<T>>
class SmartPtr : private DeletionPolicy {
  T* p_;
  public:
  explicit SmartPtr(T* p = nullptr,
    DeletionPolicy&& deletion_policy = DeletionPolicy())
  : DeletionPolicy(std::move(deletion_policy)), p_(p)
  {}
  ~SmartPtr() {
    DeletionPolicy::operator()(p_);
  }
  ...
};

这种方法依赖于特定的优化——如果一个基类为空(没有非静态数据成员),它可以完全从派生类的布局中优化掉。这被称为SmartPtr类的大小仅取决于其数据成员的必要大小——在我们的例子中,是 8 个字节。

当使用继承策略时,必须在公共继承或私有继承之间做出选择。通常,策略用于为行为的一个特定方面提供实现。这种实现继承通过私有继承来表示。在某些情况下,策略可能用于更改类的公共接口;在这种情况下,应使用公共继承。对于删除策略,我们没有更改类的接口 - 智能指针在其生命周期结束时始终删除对象;唯一的问题是怎样做。因此,删除策略应使用私有继承。

虽然使用 operator delete 的删除策略是无状态的,但某些策略具有必须从构造函数中给出的对象中保留的数据成员。因此,通常,基类策略应通过复制或移动到基类中从构造函数参数初始化,类似于我们初始化数据成员的方式。基类总是在派生类的数据成员之前在成员初始化列表中初始化。最后,可以使用 base_type::function_name() 语法来调用基类的成员函数;在我们的情况下,DeletionPolicy::operator()(p_)

继承或组合是将策略类集成到主类中的两种选择。通常,应首选组合,除非有使用继承的理由。我们已经看到了这样一个理由 - 空基类优化。如果我们想影响类的公共接口,继承也是一个必要的选项。

我们智能指针目前缺少一些在大多数智能指针实现中常见的重要功能。其中一个功能是释放指针的能力,即防止对象自动销毁。在某些情况下,如果对象通过其他方式销毁,或者如果需要延长对象的生存期并将其所有权传递给另一个拥有资源的对象,这可能很有用。我们可以轻松地将此功能添加到我们的智能指针中:

template <typename T,
          typename DeletionPolicy>
class SmartPtr : private DeletionPolicy {
  T* p_;
  public:
  ~SmartPtr() {
    if (p) DeletionPolicy::operator()(p_);
  }
  void release() { p_ = nullptr; }
  ...
};

现在,我们可以在我们的智能指针上调用 p.release(),析构函数将不会执行任何操作。我们可以将释放功能硬编码到指针中,但有时你可能希望强制执行与指针中相同的删除操作,而不进行释放。这需要使释放功能成为可选的,由另一个策略控制。我们可以添加一个 ReleasePolicy 模板参数来控制 release() 成员函数是否存在,但它应该做什么呢?当然,我们可以将 SmartPtr::release() 的实现移动到策略中:

// Example 11
template <typename T> struct WithRelease {
  void release(T*& p) { p = nullptr; }
};

现在,SmartPtr 的实现只需要调用 ReleasePolicy::release(p_) 来将 release() 的适当处理委托给策略。但如果我们不希望支持释放功能,应该怎么处理呢?我们的无释放策略可以简单地什么都不做,但这会误导用户——用户期望如果调用了 release(),对象就不会被销毁。我们可以在运行时断言并终止程序。这会将程序员逻辑错误——尝试释放一个无释放智能指针——转换为运行时错误。最好的方式是,如果不需要,SmartPtr 类根本就不应该有 release() 成员函数。这样,错误的代码就无法编译。实现这一点的唯一方法是将策略注入到主要模板的公共接口中。这可以通过公共继承来完成:

template <typename T,
          typename DeletionPolicy,
          typename ReleasePolicy>
class SmartPtr : private DeletionPolicy,
                 public ReleasePolicy {
  ...
};

现在,如果释放策略有一个名为 release() 的公共成员函数,那么 SmartPtr 类也有。

这解决了接口问题。现在,只剩下实现的小问题。release() 成员函数现在已经移动到策略类中,但它必须操作父类的数据成员 p_。一种方法是在构造过程中从派生类传递这个指针的引用到基策略类。这是一个丑陋的实现——它浪费了 8 个字节的内存来存储一个几乎“就在那里”的数据成员的引用,这个数据成员存储在派生类中,紧挨着基类本身。一个更好的方法是从基类转换到正确的派生类。当然,为了使这可行,基类需要知道正确的派生类是什么。这个问题的解决方案是我们在本书中研究的奇特重复模板模式CRTP):策略应该是一个模板(因此我们需要一个模板模板参数),它在派生类类型上实例化。

这样,SmartPtr 类既是释放策略的派生类,也是它的模板参数:

// Example 11
template <typename T,
          typename DeletionPolicy = DeleteByOperator<T>,
          template <typename...> class ReleasePolicy =
                                       WithRelease>
class SmartPtr : private DeletionPolicy,
                 public ReleasePolicy<SmartPtr<T,
                          DeletionPolicy, ReleasePolicy>>
{ ... };

注意,ReleasePolicy 模板被特化为 SmartPtr 模板的实际实例化,包括所有策略,以及 ReleasePolicy 本身。

现在,释放策略知道派生类的类型,并且可以将其自身转换成那个类型。这个情况总是安全的,因为正确的派生类在构造时得到了保证:

// Example 11
template <typename P> struct WithRelease {
  void release() { static_cast<P*>(this)->p_ = nullptr; }
};

模板参数 P 将被替换为智能指针的类型。一旦智能指针公开继承自释放策略,策略的公共成员函数 release() 就会被继承并成为智能指针公共接口的一部分。

关于释放策略实现的最后一个细节与访问有关。正如我们迄今为止所写的,数据成员p_SmartPtr类中是私有的,并且其基类不能直接访问它。解决这个问题的方法是声明相应的基类为派生类的友元:

// Example 11
template <typename T,
          typename DeletionPolicy = DeleteByOperator<T>,
          template <typename...> class ReleasePolicy =
                                       WithRelease>
class SmartPtr : private DeletionPolicy,
  public ReleasePolicy<SmartPtr<T, DeletionPolicy,
                                   ReleasePolicy>>
{
  friend class ReleasePolicy<SmartPtr>;
  T* p_;
  ...
};

注意,在SmartPtr类的主体内部,我们不需要重复所有模板参数。简写SmartPtr指的是当前实例化的模板。这并不扩展到类声明中开括号之前的部分,因此当我们指定策略作为基类时,我们必须重复模板参数。

无释放策略的编写同样简单:

// Example 11
template <typename P> struct NoRelease {};

这里没有release()函数,所以尝试使用此策略调用智能指针上的release()将无法编译。这解决了我们提出的只在有调用意义时才需要release()公共成员函数的要求。基于策略的设计是一个复杂的模式,很少只限于一种做事的方式。还有另一种实现相同目标的方法,我们将在本章后面的部分,即在使用策略控制 公共接口的节中,对其进行研究。

政策对象有时还可以以另一种方式使用。这仅适用于任何策略版本都没有内部状态的情况,这是设计上的要求。例如,我们的删除策略有时是无状态的,但引用调用者堆的那个不是,所以这是一个不一定是无状态的策略。释放策略始终可以被认为是无状态的;我们没有理由向其中添加数据成员,但它被限制通过公共继承来使用,因为它的主要效果是注入一个新的公共成员函数。

让我们考虑另一个我们可能想要定制的方面——调试或日志记录。出于调试目的,当对象被智能指针拥有或被删除时打印信息可能很方便。我们可以在智能指针上添加一个调试策略来支持这一点。调试策略只需做一件事,那就是在智能指针构造或销毁时打印一些信息。如果我们将指针的值传递给打印函数,它不需要访问智能指针。因此,我们可以在调试策略中将打印函数声明为静态的,并且根本不需要在智能指针类中存储此策略:

// Example 12
template <typename T,
          typename DeletionPolicy,
          typename DebugPolicy = NoDebug>
class SmartPtr : private DeletionPolicy {
  T* p_;
  public:
  explicit SmartPtr(T* p = nullptr,
    DeletionPolicy&& deletion_policy = DeletionPolicy())
  : DeletionPolicy(std::move(deletion_policy)), p_(p) {
    DebugPolicy::constructed(p_);
  }
  ~SmartPtr() {
    DebugPolicy::deleted(p_);
    DeletionPolicy::operator()(p_);
  }
  ...
};

为了简单起见,我们省略了释放策略,但多个策略很容易组合。调试策略实现很简单:

// Example 12
struct Debug {
  template <typename T>
  static void constructed(const T* p) {
    std::cout << "Constructed SmartPtr for object " <<
      static_cast<const void*>(p) << std::endl;
  }
  template <typename T>
  static void deleted(const T* p) {
    std::cout << "Destroyed SmartPtr for object " <<
      static_cast<const void*>(p) << std::endl;
  }
};

我们选择将策略实现为一个具有模板静态成员函数的非模板类。或者,我们也可以将其实现为一个模板,参数化对象类型T。策略的无调试版本,即默认版本,甚至更简单。它必须定义相同的函数,但它们什么都不做:

// Example 12
struct NoDebug {
  template <typename T>
    static void constructed(const T* p) {}
  template <typename T> static void deleted(const T* p) {}
};

我们可以期待编译器在调用位置内联空模板函数,并优化整个调用,因为不需要生成任何代码。

注意,通过选择这种政策的实现方式,我们做出了一些限制性的设计决策——所有版本的调试政策都必须是无状态的。如果我们需要,比如,在调试政策中存储自定义输出流而不是默认的std::cout,我们可能会后悔这个决定。但即使在这种情况下,也只有智能指针类的实现需要改变——客户端代码将继续工作而无需任何更改。

我们已经考虑了三种将策略对象纳入基于策略类的方法——通过组合、通过继承(公开或私有),以及仅通过编译时结合,在这种情况下,策略对象在运行时不需要存储在主要对象中。我们现在将转向基于策略设计的更高级技术。

高级策略设计

我们在上一节中介绍的技术构成了基于策略设计的基石——策略可以是类、模板实例化或模板(由模板模板参数使用)。策略类可以在编译时组合、继承或静态使用。如果一个策略需要知道主要基于策略的类的类型,可以使用 CRTP。其余的都是在同一主题上的变体,以及巧妙地结合几种技术以实现新的功能。我们现在将考虑这些更高级的技术。

构造函数政策

政策可以用来定制实现几乎任何方面,以及改变类接口。然而,当我们尝试使用政策定制类构造函数时,会出现一些独特的挑战。

例如,让我们考虑我们当前智能指针的另一个限制。到目前为止,智能指针拥有的对象总是在智能指针被删除时删除。如果智能指针支持释放,那么我们可以调用release()成员函数,并完全负责对象的删除。但我们如何确保这种删除呢?最可能的方式是,我们将让另一个智能指针拥有它:

SmartPtr<C> p1(new C);
SmartPtr<C> p2(&*p1); // Now two pointers own one object
p1.release();

这种方法冗长且容易出错——我们暂时让两个指针拥有同一个对象。如果此时发生任何导致两个指针都被删除的情况,我们将两次销毁同一个对象。我们还必须记住始终只释放这些指针中的一个。我们应该从更高的角度看待这个问题——我们试图将对象的拥有权从第一个智能指针传递到另一个。

做这件事更好的方法是移动第一个指针到第二个:

SmartPtr<C> p1(new C);
SmartPtr<C> p2(std::move(p1));

现在,第一个指针保留在移动前的状态,我们可以定义它(唯一的要求是析构函数调用必须是有效的)。我们选择将其定义为不拥有任何对象的指针,即处于释放状态的指针。第二个指针接收对象的所有权,并将适时删除它。

为了支持这个功能,我们必须实现移动构造函数。然而,可能存在某些情况下我们希望阻止所有权的转移。因此,我们可能希望同时拥有可移动和不可移动的指针。这需要另一种策略来控制是否支持移动:

template <typename T,
  typename DeletionPolicy = DeleteByOperator<T>,
  typename MovePolicy = MoveForbidden
>
class SmartPtr ...;

为了简化,我们已回退到仅使用另一项政策——删除政策。我们考虑的其他政策可以与新的MovePolicy一起添加。删除政策可以通过我们已学到的任何一种方式实现。由于它可能从空基优化中受益,我们将继续使用基于继承的实现方式。移动策略可以通过几种不同的方式实现,但继承可能是最简单的方法:

// Example 13
template <typename T,
  typename DeletionPolicy = DeleteByOperator<T>,
  typename MovePolicy = MoveForbidden>
class SmartPtr : private DeletionPolicy,
                 private MovePolicy {
  T* p_;
  public:
  explicit SmartPtr(T* p = nullptr,
    DeletionPolicy&& deletion_policy = DeletionPolicy())
    : DeletionPolicy(std::move(deletion_policy)),
      MovePolicy(), p_(p) {}
  … SmartPtr code unchanged …
  SmartPtr(SmartPtr&& that) :
    DeletionPolicy(std::move(that)),
    MovePolicy(std::move(that)),
    p_(std::exchange(that.p_, nullptr)) {}
  SmartPtr(const SmartPtr&) = delete;
};

通过使用私有继承将两种策略集成,我们现在有一个具有多个基类的派生对象。在 C++的基于策略的设计中,这种多重继承相当常见,不应让你感到惊讶。这种技术有时被称为混入,因为派生类的实现是从基类提供的部分中混合而成的。在 C++中,混入这个术语也用来指代与 CRTP 相关的一种完全不同的继承方案,因此这个术语的使用常常造成混淆(在大多数面向对象的语言中,混入明确地指代我们在这里看到的多重继承应用)。

我们智能指针类的新特性是移动构造函数。移动构造函数在SmartPtr类中无条件存在。然而,它的实现要求所有基类都是可移动的。这为我们提供了一种通过不可移动的移动策略来禁用移动支持的方法:

// Example 13
struct MoveForbidden {
  MoveForbidden() = default;
  MoveForbidden(MoveForbidden&&) = delete;
  MoveForbidden(const MoveForbidden&) = delete;
  MoveForbidden& operator=(MoveForbidden&&) = delete;
  MoveForbidden& operator=(const MoveForbidden&) = delete;
};

可移动策略要简单得多:

// Example 13
struct MoveAllowed {
};

我们现在可以构造一个可移动指针和一个不可移动指针:

class C { ... };
SmartPtr<C, DeleteByOperator<C>, MoveAllowed> p = ...;
auto p1(std::move(p)); // OK
SmartPtr<C, DeleteByOperator<C>, MoveForbidden> q = ...;
auto q1(std::move(q)); // Does not compile

尝试移动一个不可移动指针无法编译,因为其中一个基类MoveForbidden是不可移动的(没有移动构造函数)。请注意,在先前的例子中,移动前的指针p可以安全地删除,但不能以任何其他方式使用。特别是,它不能被解引用。

当我们处理可移动指针时,提供移动赋值运算符也是有意义的:

// Example 13
template <typename T,
  typename DeletionPolicy = DeleteByOperator<T>,
  typename MovePolicy = MoveForbidden>
class SmartPtr : private DeletionPolicy,
                 private MovePolicy {
  T* p_;
  public:
  explicit SmartPtr(T* p = nullptr,
    DeletionPolicy&& deletion_policy = DeletionPolicy())
    : DeletionPolicy(std::move(deletion_policy)),
      MovePolicy(), p_(p) {}
  … SmartPtr code unchanged …
  SmartPtr& operator=(SmartPtr&& that) {
    if (this == &that) return *this;
    DeletionPolicy::operator()(p_);
    p_ = std::exchange(that.p_, nullptr);
    DeletionPolicy::operator=(std::move(that));
    MovePolicy::operator=(std::move(that));
    return *this;
  }
  SmartPtr& operator=(const SmartPtr&) = delete;
};

注意自我赋值的检查。与必须对自我赋值不执行任何操作的复制赋值不同,移动赋值受标准的约束较少。唯一确定的要求是自我移动应始终使对象处于一个良好定义的状态(已移动的状态是一个这样的状态)。不执行任何操作的自我移动不是必需的,但也是有效的。还要注意基类是如何进行移动赋值的——最简单的方法是直接调用每个基类的移动赋值运算符。没有必要将派生类that转换为每个基类型——这是一个隐式执行的转换。我们绝对不能忘记将已移动的指针设置为nullptr,否则,这些指针拥有的对象将被删除两次。

为了简单起见,我们忽略了之前引入的所有策略。这没问题——不是所有的设计都需要通过策略来控制所有内容,而且无论如何,组合多个策略都是非常直接的。然而,这是一个指出不同策略有时相关的好机会——例如,如果我们同时使用释放策略和移动策略,使用可移动的移动策略强烈暗示该对象必须支持释放(已释放的指针类似于已移动的指针)。如果需要,我们可以使用模板元编程来强制策略之间的这种依赖关系。

注意,一个需要禁用或启用构造函数的策略并不一定必须用作基类 - 移动赋值或构造函数也会移动所有数据成员,因此,一个不可移动的数据成员同样可以禁用移动操作。在这里使用继承的更重要原因是空基类优化:如果我们把一个MovePolicy数据成员引入我们的类中,它会在 64 位机器上将对象大小从 8 字节增加到 16 字节。

我们考虑过使我们的指针可移动。但复制呢?到目前为止,我们明确禁止了复制——在我们的智能指针中,从一开始就删除了复制构造函数和复制赋值运算符。这到目前为止是有意义的——我们不希望有两个智能指针拥有同一个对象并删除它两次。但还有一种所有权的类型,复制操作是完美的——这就是引用计数共享指针所实现的所有权。这种类型的指针允许复制指针,现在两个指针都平等地拥有指向的对象。维护一个引用计数来统计程序中指向同一对象的指针数量。当拥有特定对象的最后一个指针被删除时,该对象本身也会被删除,因为没有更多的引用指向它。

实现引用计数的共享指针有几种方法,但让我们从类及其策略的设计开始。我们仍然需要一个删除策略,并且让一个策略控制移动和复制操作是有意义的。为了简单起见,我们再次限制自己只探索当前正在探索的策略:

// Example 14
template <typename T,
  typename DeletionPolicy = DeleteByOperator<T>,
  typename CopyMovePolicy = NoMoveNoCopy
>
class SmartPtr : private DeletionPolicy,
                 public CopyMovePolicy {
  T* p_;
  public:
  explicit SmartPtr(T* p = nullptr,
    DeletionPolicy&& deletion_policy = DeletionPolicy())
    : DeletionPolicy(std::move(deletion_policy)), p_(p)
  {}
  SmartPtr(SmartPtr&& other) :
    DeletionPolicy(std::move(other)),
    CopyMovePolicy(std::move(other)),
    p_(std::exchange(that.p_, nullptr)) {}
  SmartPtr(const SmartPtr& other) :
    DeletionPolicy(other),
    CopyMovePolicy(other),
    p_(other.p_) {}
  ~SmartPtr() {
    if (CopyMovePolicy::must_delete())
      DeletionPolicy::operator()(p_);
  }
};

复制操作不再无条件删除。提供了复制和移动构造函数(为了简洁,省略了两个赋值运算符,但应按照之前的方式实现)。

智能指针析构函数中对象的删除不再是无条件的 - 在引用计数的指针的情况下,复制策略维护引用计数并知道对于特定对象只有一个智能指针副本时。

智能指针类本身提供了策略类的需求。无移动、无复制策略必须禁止所有复制和移动操作:

// Example 14
class NoMoveNoCopy {
  protected:
  NoMoveNoCopy() = default;
  NoMoveNoCopy(NoMoveNoCopy&&) = delete;
  NoMoveNoCopy(const NoMoveNoCopy&) = delete;
  NoMoveNoCopy& operator=(NoMoveNoCopy&&) = delete;
  NoMoveNoCopy& operator=(const NoMoveNoCopy&) = delete;
  constexpr bool must_delete() const { return true; }
};

此外,不可复制的智能指针在其析构函数中始终删除它所拥有的对象,因此must_delete()成员函数应始终返回true。请注意,此函数必须由所有复制策略实现,即使它是微不足道的,否则智能指针类将无法编译。然而,我们可以完全期待编译器优化调用并无条件调用析构函数,当使用此策略时。

仅移动策略与之前我们使用的可移动策略类似,但现在我们必须明确启用移动操作并禁用复制操作:

// Example 14
class MoveNoCopy {
  protected:
  MoveNoCopy() = default;
  MoveNoCopy(MoveNoCopy&&) = default;
  MoveNoCopy(const MoveNoCopy&) = delete;
  MoveNoCopy& operator=(MoveNoCopy&&) = default;
  MoveNoCopy& operator=(const MoveNoCopy&) = delete;
  constexpr bool must_delete() const { return true; }
};

再次强调,删除是无条件的(如果对象被移动,智能指针对象内的指针可以是空的,但这并不阻止我们对其调用operator delete)。此策略允许移动构造函数和移动赋值运算符编译;SmartPtr类为这些操作提供了正确的实现,不需要策略的额外支持。

基于引用计数的复制策略要复杂得多。在这里,我们必须决定共享指针的实现。最简单的实现是在单独的内存分配中分配引用计数器,该分配由复制策略管理。让我们从一个不允许移动操作的引用计数复制策略开始:

// Example 14
class NoMoveCopyRefCounted {
  size_t* count_;
  protected:
  NoMoveCopyRefCounted() : count_(new size_t(1)) {}
  NoMoveCopyRefCounted(const NoMoveCopyRefCounted& other) :
    count_(other.count_)
  {
    ++(*count_);
  }
  NoMoveCopyRefCounted(NoMoveCopyRefCounted&&) = delete;
  ~NoMoveCopyRefCounted() {
    --(*count_);
    if (*count_ == 0) {
      delete count_;
    }
  }
  bool must_delete() const { return *count_ == 1; }
};

当具有这种复制策略的智能指针被构造时,会分配并初始化一个新的引用计数器,其值为一(我们有一个智能指针指向特定的对象——我们现在正在构造的那个对象)。当智能指针被复制时,包括复制策略在内的所有基类也会被复制。这个策略的复制构造函数只是简单地增加引用计数。当智能指针被删除时,引用计数会减少。最后一个被删除的智能指针也会删除计数器本身。复制策略还控制指向的对象何时被删除——它发生在引用计数达到一的时候,这意味着我们即将删除指向该对象的最后一个指针。当然,确保在调用must_delete()函数之前不删除计数器非常重要。这可以通过基类的析构函数在派生类的析构函数之后运行来保证——最后一个智能指针的派生类将看到计数器的值为一,并将删除对象;然后,复制策略的析构函数将再次减少计数器,看到它降到零,并删除计数器本身。

使用这种策略,我们可以实现对象共享所有权:

SmartPtr<C, DeleteByOperator<C>, NoMoveCopyRefCounted> p1{new C};
auto p2(p1);

现在,我们有两个指向同一对象的指针,引用计数为两个。当最后一个指针被删除时,对象被删除,前提是在此之前没有创建更多副本。智能指针是可复制的,但不可移动:

SmartPtr<C, DeleteByOperator<C>, NoMoveCopyRefCounted> p1{new C};
auto p2(std::move(p1)); // Does not compile

通常情况下,一旦支持引用计数复制,可能就没有理由禁止移动操作,除非它们根本不需要(在这种情况下,无移动实现可以稍微高效一些)。为了支持移动操作,我们必须考虑引用计数策略的移动后状态——显然,当它被删除时,它不能减少引用计数,因为移动后的指针不再拥有该对象。最简单的方法是将指针重置到引用计数器,这样它就不再可以从复制策略中访问,但此时复制策略必须支持空计数指针的特殊情况:

// Example 15
class MoveCopyRefCounted {
  size_t* count_;
  protected:
  MoveCopyRefCounted() : count_(new size_t(1)) {}
  MoveCopyRefCounted(const MoveCopyRefCounted& other) :
    count_(other.count_)
  {
    if (count_) ++(*count_);
  }
  ~MoveCopyRefCounted() {
    if (!count_) return;
    --(*count_);
    if (*count_ == 0) {
      delete count_;
    }
  }
  MoveCopyRefCounted(MoveCopyRefCounted&& other) :
    count_(std::exchange(other.count_, nullptr)) {}
  bool must_delete() const {
    return count_ && *count_ == 1;
  }
};

最后,引用计数复制策略还必须支持赋值操作。这些操作与复制或移动构造函数的实现方式类似(但请注意,在将新值赋给策略之前,必须先使用左侧策略删除左侧对象)。

正如你所见,一些政策实施可能相当复杂,它们的交互甚至更为复杂。幸运的是,基于策略的设计特别适合编写可测试的对象。这种基于策略的设计应用非常重要,值得特别提及。

测试策略

现在,我们将向读者展示如何使用基于策略的设计来编写更好的测试。特别是,可以通过替换策略的特殊测试版本来使代码更容易通过单元测试进行测试。这可以通过用常规版本替换策略的特殊测试版本来实现。让我们通过之前小节中引用计数策略的例子来演示这一点。

该策略的主要挑战当然是维护正确的引用计数。我们可以轻松地开发一些测试,这些测试应该能够测试引用计数的所有边界情况:

// Test 1: only one pointer
{
  SmartPtr<C, ... policies ...> p(new C);
} // C should be deleted here
// Test 2: one copy
{
  SmartPtr<C, ... policies ...> p(new C);
  {
    auto p1(p); // Reference count should be 2
  } // C should not be deleted here
} // C should be deleted here

实际上测试所有这些代码是否按预期工作是很困难的。我们知道引用计数应该是多少,但我们没有检查它实际是多少的方法(将公共函数count()添加到智能指针中可以解决这个问题,但这只是困难中的一小部分)。我们知道对象应该在何时被删除,但很难验证它实际上是否被删除了。如果我们删除对象两次,我们可能会遇到崩溃,但这并不确定。如果对象根本没有被删除,那就更难捕捉到这种情况。一个清理器可以找到这样的问题,至少如果我们使用标准的内存管理,但它们并不在所有环境中都可用,并且除非测试被设计为与清理器一起运行,否则可能会产生非常嘈杂的输出。

幸运的是,我们可以使用策略来让我们的测试能够窥视对象的内部工作原理。例如,如果我们没有在我们的引用计数策略中实现公共的count()方法,我们可以为引用计数策略创建一个可测试的包装器:

class NoMoveCopyRefCounted {
  protected:
  size_t* count_;
  ...
};
class NoMoveCopyRefCountedTest :
  public NoMoveCopyRefCounted {
  public:
  using NoMoveCopyRefCounted::NoMoveCopyRefCounted;
  size_t count() const { return *count_; }
};

注意,我们必须将主复制策略中的count_数据成员从私有改为保护。我们也可以将测试策略声明为友元,但那样的话,我们就必须为每个新的测试策略都这样做。现在,我们实际上可以实施我们的测试:

// Test 1: only one pointer
{
  SmartPtr<C, ... NoMoveCopyRefCountedTest> p(new C);
  assert(p.count() == 1);
} // C should be deleted here
// Test 2: one copy
{
  SmartPtr<C, ... NoMoveCopyRefCountedTest> p(new C);
  {
  auto p1(p); // Reference count should be 2
    assert(p.count() == 2);
    assert(p1.count() == 2);
    assert(&*p == &*p1);
  } // C should not be deleted here
  assert(p.count == 1);
} // C should be deleted here

同样,我们可以创建一个可测量的删除策略,检查对象是否将被删除,或者记录在某个外部日志对象中,表明它实际上已被删除,并测试删除是否已正确记录。我们需要对我们的智能指针实现进行测量,以便调用调试或测试策略:

// Example 16:
template <... template parameters ...,
          typename DebugPolicy = NoDebug>
class SmartPtr : ... base policies ... {
  T* p_;
  public:
  explicit SmartPtr(T* p = nullptr,
    DeletionPolicy&& deletion_policy = DeletionPolicy()) :
    DeletionPolicy(std::move(deletion_policy)), p_(p)
  {
    DebugPolicy::construct(this, p);
  }
  ~SmartPtr() {
    DebugPolicy::destroy(this, p_,
                         CopyMovePolicy::must_delete());
  if (CopyMovePolicy::must_delete())
    DeletionPolicy::operator()(p_);
  }
  ...
};

调试和生产的(非调试)策略都必须包含类中引用的所有方法,但非调试策略的空方法将被内联并优化为无。

// Example 16
struct NoDebug {
  template <typename P, typename T>
  static void construct(const P* ptr, const T* p) {}
  template <typename P, typename T>
  static void destroy(const P* ptr, const T* p,
                      bool must_delete) {}
  ... other events ...
};

调试策略各不相同,基本的策略只是记录所有可调试的事件:

// Example 16
struct Debug {
  template <typename P, typename T>
  static void construct(const P* ptr, const T* p) {
    std::cout << "Constructed SmartPtr at " << ptr <<
      ", object " << static_cast<const void*>(p) <<
      std::endl;
  }
  template <typename P, typename T>
  static void destroy(const P* ptr, const T* p,
                      bool must_delete) {
    std::cout << "Destroyed SmartPtr at " << ptr <<
      ", object " << static_cast<const void*>(p) <<
      (must_delete ? " is" : " is not") << " deleted" <<
      std::endl;
  }
};

更复杂的策略可以验证对象的内部状态是否符合要求,并且类的不变性是否得到维护。

到现在为止,读者可能已经注意到基于策略的对象声明可能相当长:

SmartPtr<C, DeleteByOperator<T>, MoveNoCopy,
         WithRelease, Debug> p( ... );

这是基于策略设计中最常见的观察问题之一,我们应该考虑一些减轻这种问题的方法。

策略适配器和别名

可能最明显的缺点是基于策略的设计中我们必须声明具体对象的方式 - 特别是,必须每次都重复的长策略列表。明智地使用默认参数有助于简化最常用的案例。例如,让我们看看以下的长声明:

SmartPtr<C, DeleteByOperator<T>, MoveNoCopy,
         WithRelease, NoDebug>
p( ... );

有时,这可以简化为以下内容:

SmartPtr<C> p( ... );

如果默认值代表了一个可移动的非调试指针最常见的使用情况,并且使用了operator delete,那么这可以做到。然而,如果我们不打算使用这些策略,添加它们又有什么意义呢?一个经过深思熟虑的策略参数顺序有助于使更常见的策略组合更短。例如,如果最常见的变体是删除策略,那么可以声明一个新的指针,它具有不同的删除策略和默认剩余策略,而不需要重复我们不需要更改的策略:

SmartPtr<C, DeleteHeap<T>> p( ... );

这仍然留下了不常用策略的问题。此外,策略通常在添加额外功能后作为设计的一部分添加。这些策略几乎总是添加到参数列表的末尾。否则,需要重写声明基于策略类的所有代码,以重新排序其参数。然而,这些后来添加的策略并不一定是不常用的,这种设计演变可能导致许多策略参数必须明确写出,即使是在它们的默认值上,以便可以更改其中一个尾随参数。

虽然在传统基于策略的设计框架内没有通用的解决方案,但在实践中,通常只有少数常用策略组,然后是一些频繁的变化。例如,我们的大多数智能指针可能使用operator delete并支持移动和释放,但我们经常需要在调试和非调试版本之间交替。这可以通过创建适配器来实现,这些适配器将具有许多策略的类转换为一个新的接口,该接口仅暴露我们经常想要更改的策略,并将其他策略固定在其常用值上。任何大型设计都可能需要多个这样的适配器,因为常用的策略集可能不同。

编写此类适配器的最简单方法是使用using别名:

// Example 17
template <typename T, typename DebugPolicy = NoDebug>
using SmartPtrAdapter =
  SmartPtr<T, DeleteByOperator<T>, MoveNoCopy,
              WithRelease, DebugPolicy>;

另一个选择是使用继承:

// Example 18
template <typename T, typename DebugPolicy = NoDebug>
class SmartPtrAdapter : public SmartPtr<T,
  DeleteByOperator<T>, MoveNoCopy,
  WithRelease, DebugPolicy>
{...};

这创建了一个派生类模板,它固定了基类模板的一些参数,而将其他参数保持为参数化。基类的整个公共接口被继承,但需要特别注意基类的构造函数。默认情况下,它们不会被继承,因此新派生的类将具有默认的编译器生成的构造函数。这可能不是我们想要的,因此我们必须将基类的构造函数(以及可能的赋值运算符)引入派生类:

// Example 18
template <typename T, typename DebugPolicy = NoDebug>
class SmartPtrAdapter : public SmartPtr<T,
  DeleteByOperator<T>, MoveNoCopy,
  WithRelease, DebugPolicy>
{
  using base_t = SmartPtr<T, DeleteByOperator<T>,
    MoveNoCopy, WithRelease, DebugPolicy>;
  using base_t::SmartPtr;
  using base_t::operator=;
};

using别名无疑更容易编写和维护,但如果需要同时适配一些成员函数、嵌套类型等,派生类适配器则提供了更多的灵活性。

当我们需要一个具有预设策略的智能指针,但需要快速更改调试策略时,我们现在可以使用新的适配器。

SmartPtrAdapter<C, Debug> p1{new C); // Debug pointer
SmartPtrAdapter<C> p2{new C); // Non-debug pointer

正如我们一开始所说的,策略最常见的应用是选择类行为某个方面的特定实现。有时,这种实现上的变化也会反映在类的公共接口上——某些操作可能只适用于某些实现,而不适用于其他实现,确保与实现不兼容的操作不被调用的最佳方式是简单地不提供它。

现在,让我们重新审视使用策略选择性地启用公共接口部分的问题。

使用策略来控制公共接口

我们之前曾使用策略以两种方式控制公共接口:首先,通过从策略继承,我们能够注入一个公共成员函数。这种方法相当灵活且强大,但有两个缺点——首先,一旦我们公开继承自策略,我们就无法控制要注入的接口——策略的每个公共成员函数都成为派生类接口的一部分。其次,要以此方式实现任何有用的功能,我们必须让策略类将自己转换为派生类,然后它必须能够访问所有数据成员以及可能的其他策略。我们尝试的第二种方法依赖于构造函数的特定属性——要复制或移动一个类,我们必须复制或移动其所有基类或数据成员;如果其中之一是不可复制的或不可移动的,整个构造函数将无法编译。不幸的是,它通常以一个相当不明显的语法错误而失败——没有找到这个对象的复制构造函数。我们可以将这种技术扩展到其他成员函数,例如赋值运算符,但它会变得复杂。

现在,我们将学习一种更直接的方式来操作基于策略的类的公共接口。首先,让我们区分条件性地禁用现有成员函数和添加新成员函数。前者是合理的且通常安全:如果某个特定实现不支持接口提供的某些操作,那么它们从一开始就不应该被提供。后者是危险的,因为它允许对类的公共接口进行任意和不受控制的扩展。因此,我们将专注于提供基于策略的类的所有可能预期用途的接口,然后在某些策略选择下,禁用该接口的部分功能。

C++语言中已经存在一种机制来有选择性地启用和禁用成员函数。在 C++20 之前,这个机制通常通过概念(如果可用)或std::enable_if来实现,但其背后的基础是我们已经在第七章中学习过的 SFINAE 惯用法,即SFINAE、概念和重载解析管理。在 C++20 中,更强大的概念可以在许多情况下取代std::enable_if

为了说明如何使用 SFINAE 让策略有选择性地启用成员函数,我们将重新实现控制公共release()成员函数的策略。我们已经在本章中通过从可能提供或不提供release()成员函数的ReleasePolicy继承来实现过一次;如果提供了,就必须使用 CRTP 来实现它。现在,我们将使用 C++20 的概念来完成同样的工作。

正如我们刚才所说的,依赖于 SFINAE 和概念的策略不能向类的接口添加任何新的成员函数;它只能禁用其中的一些。因此,第一步是将release()函数添加到SmartPtr类本身:

// Example 19
template <typename T,
          typename DeletionPolicy = DeleteByOperator<T>,
          typename ReleasePolicy = NoRelease>
class SmartPtr : private DeletionPolicy {
  T* p_;
  public:
  void release() { p_ = nullptr; }
  ...
};

目前,它始终处于启用状态,因此我们需要使用ReleasePolicy的某个属性来有条件地启用它:

// Example 19
struct WithRelease {
  static constexpr bool enabled = true;
};
struct NoRelease {
  static constexpr bool enabled = false;
};

现在,我们需要使用约束有条件地启用release()成员函数:

// Example 19
template <...> class SmartPtr ... {
  ...
  void release() requires ReleasePolicy::enabled {
    p_ = nullptr;
  }
};

在 C++20 中,我们需要的就这些。请注意,我们不需要从ReleasePolicy继承,因为其中除了一个常量值之外没有其他内容。同样地,我们也不需要移动或复制这个策略。

在 C++20 和概念出现之前,我们必须使用std::enable_if来启用或禁用特定的成员函数——一般来说,表达式std::enable_if<value, type>如果valuetrue(它必须是一个编译时,或constexpr,布尔值)将会编译并产生指定的type。如果valuefalse,类型替换将失败(不会产生任何类型结果)。这个模板元函数的正确用途是在 SFINAE 上下文中,类型替换的失败不会导致编译错误,而只是禁用导致失败的函数(更准确地说,是从重载解析集中移除它)。

策略本身根本不需要改变:SFINAE 和约束都需要一个constexpr bool值。改变的是用来禁用成员函数的表达式。简单地写成如下形式是有诱惑力的:

template <...> class SmartPtr ... {
  ...
  std::enable_if_t<ReleasePolicy::enabled> release() {
    p_ = nullptr;
  }
};

不幸的是,这行不通:对于 NoRelease 策略,即使我们不尝试调用 release(),代码也无法编译。原因是 SFINAE 只在模板参数替换时才起作用(release() 函数必须是模板,而且,更重要的是,潜在的替换失败必须发生在模板参数替换过程中。我们不需要任何模板参数来声明 release(),但我们必须引入一个虚拟参数来使用 SFINAE:

// Example 20
template <...> class SmartPtr ... {
  ...
  template<typename U = T>
  std::enable_if_t<sizeof(U) != 0 &&
                   ReleasePolicy::enabled> release() {
    p_ = nullptr;
  }
};

当我们在 第七章**,SFINAE、概念和重载解析管理 中描述“概念工具”时,我们看到了这样的“假模板”——在 C++20 之前模仿概念的一种方法。现在我们有一个模板类型参数;它永远不会被使用,并且始终设置为默认值,这并不会改变任何事情。返回类型中的条件表达式使用这个模板参数(尽管表达式依赖于参数的部分永远不会失败)。因此,我们现在处于 SFINAE 规则之内。

现在我们有了选择性地禁用成员函数的方法,我们可以重新审视条件启用构造函数,看看我们如何启用和禁用构造函数。

在 C++20 中,答案是“完全相同的方式。”我们需要一个具有 constexpr 布尔值和 restrict 约束的策略来禁用任何构造函数:

// Example 21
struct MoveForbidden {
  static constexpr bool enabled = false;
};
struct MoveAllowed {
  static constexpr bool enabled = true;
};

我们可以使用这个策略来约束任何成员函数,包括构造函数:

// Example 21
template <typename T,
          typename DeletionPolicy = DeleteByOperator<T>,
          typename MovePolicy = MoveForbidden>
class SmartPtr : private DeletionPolicy {
  public:
  SmartPtr(SmartPtr&& other)
    requires MovePolicy::enabled :
    DeletionPolicy(std::move(other)),
    p_(std::exchange(other.p_, nullptr)) {}
  ...
};

在 C++20 之前,我们必须使用 SFINAE。这里的复杂性在于构造函数没有返回类型,我们必须在其他地方隐藏 SFINAE 测试。此外,我们再次必须使构造函数成为模板。我们还可以再次使用虚拟模板参数:

// Example 22
template <typename T,
          typename DeletionPolicy = DeleteByOperator<T>,
          typename MovePolicy = MoveForbidden>
class SmartPtr : private DeletionPolicy {
  public:
  template <typename U = T,
    std::enable_if_t<sizeof(U) != 0 && MovePolicy::enabled,
                     bool> = true>
  SmartPtr(SmartPtr&& other) :
    DeletionPolicy(std::move(other)),
    p_(std::exchange(other.p_, nullptr)) {}
  ...
};

如果你使用 第七章* 中的概念工具,SFINAE、概念和重载解析管理,代码将看起来更简单、更直接,尽管仍然需要一个虚拟模板参数:

// Example 22
template <typename T,
          typename DeletionPolicy = DeleteByOperator<T>,
          typename MovePolicy = MoveForbidden>
class SmartPtr : private DeletionPolicy {
  public:
  template <typename U = T,
    REQUIRES(sizeof(U) != 0 && MovePolicy::enabled)>
  SmartPtr(SmartPtr&& other) :
    DeletionPolicy(std::move(other)),
    p_(std::exchange(other.p_, nullptr)) {}
  ...
};

现在我们有了完全通用的方法来启用或禁用特定的成员函数,包括构造函数,读者可能会想知道,引入早期方法的意义何在?首先,为了简单起见——enable_if 表达式必须在正确的上下文中使用,如果稍有错误,生成的编译器错误并不美观。另一方面,一个不可复制的基类使整个派生类不可复制的概念非常基础,并且每次都有效。这种技术甚至可以在 C++03 中使用,那时 SFINAE 的限制更多,而且更难正确实现。

此外,我们已经看到,有时策略需要向类中添加成员变量而不是(或除了)成员函数。我们的引用计数指针是一个完美的例子:如果某个策略提供了引用计数,它也必须包含计数。成员变量不能使用约束进行限制,因此它们必须来自基策略类。

另一个至少要知道如何通过策略注入公有成员函数的理由是,有时enable_if替代方案要求在主类模板中声明所有可能的函数集,然后可以选择性地禁用其中一些。有时,这个函数集是自相矛盾的,不能同时存在。一个例子是一组转换运算符。目前,我们的智能指针不能转换回原始指针。我们可以启用这些转换并要求它们是显式的,或者允许隐式转换:

void f(C*);
SmartPtr<C> p(...);
f((C*)(p));     // Explicit conversion
f(p);         // Implicit conversion

转换运算符的定义如下:

template <typename T, ...>
class SmartPtr ... {
  T* p_;
  public:
  explicit operator T*() { return p_; } // Explicit
  operator T*() { return p_; }          // Implicit
  ...
};

我们已经决定不希望这些运算符无条件地存在;相反,我们希望它们由原始转换策略控制。让我们从上次用于启用成员函数的策略的相同方法开始:

// Example 23
struct NoRaw {
  static constexpr bool implicit_conv = false;
  static constexpr bool explicit_conv = false;
};
struct ExplicitRaw {
  static constexpr bool implicit_conv = false;
  static constexpr bool explicit_conv = true;
};
struct ImplicitRaw {
  static constexpr bool implicit_conv = true;
  static constexpr bool explicit_conv = false;
};

再次,我们将首先编写 C++20 代码,在那里我们可以使用约束来限制显式和隐式运算符:

// Example 23
template <typename T, ..., typename ConversionPolicy>
class SmartPtr : ... {
  T* p_;
  public:
  explicit operator T*()
    requires ConversionPolicy::explicit_conv
    { return p_; }
  operator T*()
    requires ConversionPolicy::implicit_conv
    { return p_; }
  explicit operator const T*()
    requires ConversionPolicy::explicit_conv const
    { return p_; }
  operator const T*()
    requires ConversionPolicy::implicit_conv const
    { return p_; }
};

为了完整性,我们还提供了转换到const原始指针的转换。请注意,在 C++20 中,使用条件显式指定符(另一个 C++20 特性)提供这些运算符有更简单的方法:

// Example 24
template <typename T, ..., typename ConversionPolicy>
class SmartPtr : ... {
  T* p_;
  public:
  explicit (ConversionPolicy::explicit_conv)
  operator T*()
    requires (ConversionPolicy::explicit_conv ||
              ConversionPolicy::implicit_conv)
    { return p_; }
  explicit (ConversionPolicy::explicit_conv)
  operator const T*()
    requires (ConversionPolicy::explicit_conv const ||
              ConversionPolicy::implicit_conv const)
    { return p_; }
};

在 C++20 之前,我们可以尝试使用std::enable_if和 SFINAE 来启用这些运算符之一,再次基于转换策略。问题是,即使后来禁用,我们也不能声明到同一类型的隐式和显式转换。这些运算符一开始就不能在同一个重载集中:

// Example 25 – does not compile!
template <typename T, ..., typename ConversionPolicy>
class SmartPtr : ... {
  T* p_;
  public:
  template <typename U = T,
            REQUIRES(ConversionPolicy::explicit_conv)>
  explicit operator T*() { return p_; }
  template <typename U = T,
            REQUIRES(ConversionPolicy::implicit_conv)>
  operator T*() { return p_; }
  ...
};

如果我们想在智能指针类中选择这些运算符之一,我们必须让它们由基类策略生成。由于策略需要了解智能指针类型,我们必须再次使用 CRTP。以下是一组策略来控制从智能指针到原始指针的转换:

// Example 26
template <typename P, typename T> struct NoRaw {
};
template <typename P, typename T> struct ExplicitRaw {
  explicit operator T*() {
    return static_cast<P*>(this)->p_;
  }
  explicit operator const T*() const {
    return static_cast<const P*>(this)->p_;
  }
};
template <typename P, typename T> struct ImplicitRaw {
  operator T*() {
    return static_cast<P*>(this)->p_;
  }
  operator const T*() const {
    return static_cast<const P*>(this)->p_;
  }
};

这些策略将所需的公有成员函数运算符添加到派生类中。由于它们是模板,需要用派生类类型实例化,因此转换策略是一个模板模板参数,其使用遵循 CRTP:

// Example 26
template <typename T, ... other policies ...
          template <typename, typename>
          class ConversionPolicy = ExplicitRaw>
class SmartPtr : ... other base policies ...,
  public ConversionPolicy<SmartPtr<... paramerers ...>, T>
{
  T* p_;
  template<typename, typename>
  friend class ConversionPolicy;
  public:
  ...
};

再次注意模板模板参数的使用:模板参数ConversionPolicy不是一个类型,而是一个模板。当我们从该策略的一个实例继承时,我们必须写出我们SmartPtr类的完整类型,包括所有模板参数。我们将转换策略做成一个接受两个参数的模板(第二个参数是对象类型T)。我们也可以从第一个模板参数(智能指针类型)推导出类型T,这主要是一个风格问题。

选定的转换策略将它的公共接口(如果有),添加到派生类的接口中。一个策略添加了一组显式的转换操作符,而另一个则提供了隐式转换。就像在早期的 CRTP 示例中一样,基类需要访问派生类的私有数据成员。我们可以授予整个模板(及其所有实例化)友情权限,或者更具体地,授予用作每个智能指针基类的特定实例化:

friend class ConversionPolicy<
  SmartPtr<T, ... parameters ..., ConversionPolicy>, T>;

我们已经学习了多种实现新策略的方法。有时,挑战在于重用我们已有的策略。下一节将展示一种实现方法。

重新绑定策略

如我们所见,策略列表可能会变得相当长。通常,我们只想更改一条策略,并创建一个与另一个类似但略有不同的类。至少有两种方法可以做到这一点。

第一种方法非常通用,但有些冗长。第一步是将模板参数作为别名暴露在主模板中。无论如何,这是一个好习惯——如果没有这样的别名,在编译时很难找出模板参数是什么,以防我们需要在模板外使用它。例如,我们有一个智能指针,我们想知道删除策略是什么。到目前为止,最简单的方法是借助智能指针类本身的一些帮助:

template <typename T,
          typename DeletionPolicy = DeleteByOperator<T>,
          typename CopyMovePolicy = NoMoveNoCopy,
          template <typename, typename>
            class ConversionPolicy = ExplicitRaw>
class SmartPtr : ... base policies ... {
  T* p_;
  public:
  using value_type = T;
  using deletion_policy_t = DeletionPolicy;
  using copy_move_policy_t = CopyMovePolicy;
  template <typename P, typename T1>
  using conversion_policy_t = ConversionPolicy<P, T1>;
  ...
};

注意,我们在这里使用了两种不同类型的别名——对于像DeletionPolicy这样的常规模板参数,我们可以使用using别名。对于模板模板参数,我们必须使用模板别名,有时称为模板typedef——为了使用另一个智能指针重复相同的策略,我们需要知道模板本身,而不是模板实例化,例如ConversionPolicy<SmartPtr, T>。现在,如果我们需要创建另一个具有一些相同策略的智能指针,我们可以简单地查询原始对象的策略:

// Example 27
SmartPtr<int,
  DeleteByOperator<int>, MoveNoCopy, ImplicitRaw>
  p1(new int(42));
using ptr_t = decltype(p1); // The exact type of p1
SmartPtr<ptr_t::value_type,
  ptr_t::deletion_policy_t, ptr_t::copy_move_policy_t,
  ptr_t::conversion_policy_t> p2;
SmartPtr<double,
  ptr_t::deletion_policy_t, ptr_t::copy_move_policy_t,
  ptr_t::conversion_policy_t> p3;

现在,p2p1具有完全相同的类型。当然,还有更简单的方法可以做到这一点。但关键是,我们可以更改列表中的任何一种类型,保留其余的,并得到一个像p1一样的指针,除了一个变化。例如,指针p2具有相同的策略,但指向一个double

后者实际上是一个非常常见的案例,并且有一种方法可以在保持其余参数不变的情况下,简化模板到不同类型的重新绑定。为此,主模板及其所有策略都需要支持这种重新绑定:

// Example 27
template <typename T> struct DeleteByOperator {
  void operator()(T* p) const { delete p; }
  template <typename U>
    using rebind_type = DeleteByOperator<U>;
};
template <typename T,
          typename DeletionPolicy = DeleteByOperator<T>,
          typename CopyMovePolicy = NoMoveNoCopy,
          template <typename, typename>
            class ConversionPolicy = ExplicitRaw>
class SmartPtr : private DeletionPolicy,
  public CopyMovePolicy,
  public ConversionPolicy<SmartPtr<T, DeletionPolicy,
    CopyMovePolicy, ConversionPolicy>, T> {
  T* p_;
  public:
  ...
  template <typename U>
  using rebind = SmartPtr<U,
    typename DeletionPolicy::template rebind<U>,
    CopyMovePolicy, ConversionPolicy>;
};

rebind 别名定义了一个只有一个参数的新模板——我们可以更改的类型。其余的参数来自主模板本身。其中一些参数也是依赖于主类型 T 的类型,并且自身也需要重新绑定(在我们的例子中,是删除策略)。通过选择不重新绑定复制/移动策略,我们强加了一个要求,即这些策略中没有任何一个依赖于主类型,否则这个策略也需要重新绑定。最后,模板转换策略不需要重新绑定——我们在这里可以访问整个模板,因此它将使用新的主类型实例化。现在,我们可以使用重新绑定机制来创建一个类似的指针类型:

SmartPtr<int,
  DeleteByOperator<int>, MoveNoCopy, ImplicitRaw>
p(new int(42));
using dptr_t = decltype(p)::rebind<double>;
dptr_t q(new double(4.2));

如果我们直接访问智能指针类型,我们可以用它来进行重新绑定(例如,在模板上下文中)。否则,我们可以使用 decltype() 从此类变量的类型中获取类型。指针 qp 具有相同的策略,但指向一个 double,并且根据类型依赖的策略(如删除策略)进行了相应的更新。

我们已经介绍了策略可以实施和用于定制基于策略类的主要方式。现在是时候回顾我们所学的,并就基于策略设计的使用提出一些一般性指南。

建议和指南

基于策略的设计允许在创建精细可定制的类时具有非凡的灵活性。有时,这种灵活性和力量反而会成为良好设计的敌人。在本节中,我们将回顾基于策略设计的优点和缺点,并提出一些一般性建议。

基于策略设计的优点

基于策略设计的主要优点是设计的灵活性和可扩展性。从高层次来看,这些是策略模式提供的相同好处,只是在编译时实现。基于策略的设计允许程序员在编译时为系统执行的每个特定任务或操作选择多个算法之一。由于算法的唯一约束是绑定它们的接口对整个系统的要求,因此通过编写新策略来扩展系统也是同样可能的。

在高层次上,基于策略的设计允许软件系统由组件构建。在高层次上,这几乎不是一个新颖的想法,当然也不限于基于策略的设计。基于策略设计的重点是使用组件来定义行为和实现单个类。策略和回调之间有一些相似之处——两者都允许在特定事件发生时执行用户指定的操作。然而,策略比回调更通用——回调是一个函数,而策略是整个类,具有多个函数,可能还有非平凡的内部状态。

这些通用概念转化为设计的一套独特优势,主要围绕灵活性和可扩展性。由于系统的整体结构和其高级组件由高级设计确定,因此策略允许在原始设计强加的约束内进行各种低级定制。策略可以扩展类接口(添加公共成员函数),实现或扩展类的状态(添加数据成员),并指定实现(添加私有成员函数)。原始设计在设定类的整体结构和它们之间的交互时,实际上授权每个策略拥有这些角色中的一个或多个。

结果是一个可扩展的系统,可以修改以应对不断变化的需求,甚至包括在系统设计时未预见或未知的那些需求。整体架构保持稳定,而可能策略的选择及其接口的约束提供了一种系统化、规范化的方式来修改和扩展软件。

基于策略设计的缺点

考虑到基于策略的设计的第一个问题,是我们已经遇到的问题——具有特定策略集的基于策略类的声明非常冗长,尤其是如果列表末尾的策略需要更改的话。考虑一下声明一个智能指针,其中包含我们在本章中实现的所有策略,合并在一起:

SmartPtr<int, DeleteByOperator<int>, NoMoveNoCopy, ExplicitRaw, WithoutArrow, NoDebug> p;

这只是针对智能指针的——一个接口相对简单且功能有限的类。尽管不太可能有人需要具有所有这些定制化可能性的单个指针,但基于策略的类往往有很多策略。这个问题可能最为明显,但实际上并不是最糟糕的。模板别名有助于为特定应用实际使用的少量策略组合提供简洁的名称。在模板上下文中,用作函数参数的智能指针类型会被推导出来,无需显式指定。在常规代码中,可以使用auto来节省大量输入,并使代码更加健壮——当必须保持一致性的复杂类型声明被替换为自动生成这些一致类型的方式时,由于在两个不同位置输入略有不同而导致的错误就会消失(一般来说,如果有一个方法可以使编译器生成正确构造的代码,就使用它)。

更为显著,尽管不太明显的问题是,所有这些具有不同策略的策略类型实际上都是不同的类型。两个指向相同对象类型但具有不同删除策略的智能指针是不同类型。两个在其他方面相同但具有不同复制策略的智能指针也是不同类型。这为什么会成为问题呢?考虑一个被调用来处理通过智能指针传递给函数的对象的函数。这个函数不会复制智能指针,因此复制策略应该无关紧要——它永远不会被使用。然而,参数类型应该是什么?没有一种类型可以容纳所有智能指针,即使是功能非常相似的智能指针。

这里有几个可能的解决方案。最直接的一个是将所有使用策略类型的函数都做成模板。这确实简化了编码,并减少了代码重复(至少是源代码的重复),但它也有其自身的缺点——由于每个函数都有多个副本,机器代码会变得更大,并且所有模板代码都必须放在头文件中。

另一个选择是擦除策略类型。我们在第六章中看到了类型擦除技术,理解类型擦除。类型擦除解决了存在许多相似类型的问题——我们可以使所有智能指针,无论其策略如何,都具有相同的类型(当然,仅限于策略决定实现而不是公共接口)。然而,这代价很高。

模板的一般缺点,尤其是基于策略的设计,在于模板提供了一个零开销的抽象——我们可以用方便的高级抽象和概念来表示我们的程序,但编译器会移除所有这些,内联所有模板,并生成必要的最小代码。类型擦除不仅否定了这一优势,而且产生了相反的效果——它增加了非常高的内存分配和间接函数调用的开销。

最后一个选择是避免使用基于策略的类型,至少对于某些操作来说是这样。有时,这种选择会带来一些额外的成本——例如,一个需要操作对象但不删除或拥有它的函数应该通过引用而不是智能指针来获取对象(参见第三章内存和所有权)。除了清楚地表达函数不会拥有对象的事实外,这还巧妙地解决了参数应该是什么类型的问题——引用与来自哪个智能指针无关,都是相同的类型。然而,这是一个有限的方法——大多数情况下,我们确实需要操作整个基于策略的对象,这些对象通常比简单的指针复杂得多(例如,自定义容器通常使用策略实现)。

最后一个缺点是基于策略的类型的一般复杂性,尽管这样的说法应该谨慎对待——重要的是,与什么相比的复杂性?基于策略的设计通常用于解决复杂的设计问题,其中一系列类似类型服务于相同的基本目的(什么),但以略有不同的方式(如何)。这导致我们对策略使用的建议。

基于策略的设计指南

基于策略的设计指南总结为管理复杂性和确保结果合理——设计的灵活性和结果的优雅性应该证明实现复杂性和使用的合理性。

由于大多数复杂性都来自策略数量的增加,这是大多数指南的重点。一些策略最终将非常不同的类型组合在一起,这些类型恰好有类似的实现。这种基于策略的类型的目的是减少代码重复。虽然这是一个值得追求的目标,但这通常不足以将众多不同的策略选项暴露给类型的最终用户。如果两种不同的类型或类型家族恰好有类似的实现,那么这种实现可以被分解出来。设计的私有、隐藏、仅实现部分可以使用策略,如果这使实现更容易的话。

然而,这些隐藏的策略不应该由客户端选择——客户端应该指定在应用程序中有意义的类型和定制可见行为的策略。从这些类型和策略中,实现可以根据需要派生额外的类型。这与调用一个通用函数来执行操作没有什么不同,比如从几个不同且无关的算法中找到序列中的最小元素。通用代码没有被重复,也没有暴露给用户。

因此,何时应该将基于策略的类型分解成两个或更多部分?一个很好的方法是问,具有特定策略集的主要类型是否有描述它的良好特定名称。例如,不可复制的拥有指针,无论是否可移动,是唯一指针——在任何给定时间,每个对象只有一个这样的指针。这适用于任何删除或转换策略。

另一方面,引用计数的指针是共享指针,再次,可以搭配其他任何策略。这表明我们的“终结所有智能指针的智能指针”可能最好分成两个——一个不可复制的唯一指针和一个可复制的共享指针。我们仍然可以获得一些代码重用,因为删除策略,例如,对于这两种指针类型都是通用的,不需要实现两次。这确实是 C++标准所做的选择。《std::unique_ptr》只有一个策略,即删除策略。《std::shared_ptr》也有相同的策略,可以使用相同的策略对象,但它进行了类型擦除,因此指向特定对象的共享指针都是同一类型。

但是,其他策略又是如何呢?在这里,我们来到了第二个指导原则——限制类使用的策略应该由它们试图防止的错误可能造成的成本来证明其合理性。例如,我们真的需要非移动策略吗?一方面,如果对象的拥有权绝对不能转让,这可以防止编程错误。另一方面,在许多情况下,程序员会简单地更改代码以使用可移动指针。此外,我们被迫使用可移动指针从工厂函数中按值返回它们。然而,不可复制的策略通常是有理由的,并且应该是默认设置。例如,有很好的理由使大多数容器默认不可复制:复制大量数据几乎总是糟糕编码的结果,通常是在向函数传递参数时。

同样,虽然可能希望防止隐式转换为原始指针作为基本的编码纪律,但总有一种方法可以将智能指针显式地转换为原始指针——如果不是其他的话,&*p应该始终有效。再次强调,精心限制的接口的好处可能不足以证明添加此策略的合理性。然而,它为一系列可以用来创建更复杂、更有用的策略的技术提供了一个很好的紧凑学习示例,因此我们花费在学习这个策略如何工作上的时间是完全有理由的。

当一个影响公共接口的策略是合理的,我们必须在基于约束的策略和基于 CRTP 的策略之间做出选择,前者限制现有的成员函数,后者添加它们。一般来说,依赖于约束的设计是首选的,即使在 C++20 之前,我们也必须使用“伪概念”。然而,这种方法不能用来向类中添加成员变量,只能添加成员函数。

另一种看待正确策略集和策略应该分成哪些单独群体的问题的方法是回到基于策略设计的根本优势——不同策略表达的行为的可组合性。如果我们有一个具有四个不同策略的类,每个策略都可以有四种不同的实现,那么这就是 256 个不同的类版本。当然,我们不太可能需要所有 256 个。但关键是,在我们实现类的时候,我们不知道我们将来实际上需要哪个版本。我们可以猜测并只实现最有可能的几个。如果我们错了,这将导致大量的代码重复和粘贴。使用基于策略的设计,我们有潜力实现任何行为组合,而实际上并不需要一开始就明确地写出所有这些行为。

现在我们已经了解了基于策略设计的这种优势,我们可以利用它来评估一组特定的策略——它们是否需要可组合的?我们是否需要以不同的方式将它们结合起来?如果某些策略总是以特定的组合或群体出现,这就需要从主要用户指定的策略中自动推导出这些策略。另一方面,一组可以任意组合的相对独立的策略可能是一组很好的策略。

解决基于策略的设计的一些弱点的一种方法是通过不同的手段尝试实现相同的目标。没有替代品可以完全替代策略提供的全部功能——策略模式的存在是有原因的。然而,有一些替代模式提供了一些表面的相似性,并且可以用来解决基于策略的设计所解决的问题。当我们讨论装饰器时,我们将在第十六章中看到这样一个替代方案,适配器和装饰器。它并不像策略那样通用,但当它起作用时,它可以提供策略的所有优势,特别是可组合性,而不存在一些问题。

摘要

在本章中,我们广泛研究了策略模式(也称为策略模式)在 C++泛型编程中的应用。这两种方法的结合产生了 C++程序员武器库中最强大的工具之一——基于策略的类设计。这种方法通过允许我们从许多构建块或策略中组合类的行为,为每个策略负责行为的一个特定方面,提供了极大的灵活性。

我们已经学习了不同的实现策略的方法——这些可以是模板,具有模板成员函数的类,具有静态函数的类,甚至是具有常量值的类。我们可以通过组合、继承或直接访问静态成员来使用策略的方式同样多种多样。策略参数可以是类型或模板,每种都有其自身的优势和局限性。

基于策略的设计这样的强大工具也容易被误用或在不恰当的情况下应用。这种情况通常源于软件逐渐向更复杂的方向发展。为了减轻这种不幸,我们提供了一套指南和建议,这些指南和建议侧重于基于策略的设计为程序员提供的核心优势,并建议了最大化这种优势的技术和约束。

在下一章中,我们将考虑一种更有限的设计模式,有时可以用来模仿基于策略的方法,而不存在其缺点。本章专门介绍装饰器模式以及更通用的适配器模式。两者都是 C++的魔法技巧——它们使一个对象看起来像它不是的东西。

问题

  1. 策略模式是什么?

  2. 策略模式是如何使用 C++泛型编程在编译时实现的?

  3. 可以用作策略的类型有哪些?

  4. 如何将策略集成到主模板中?

  5. 我应该使用具有公共成员函数的策略还是具有约束变量的策略?

  6. 基于策略的设计的主要缺点是什么?

第十六章:适配器和装饰者

本章探讨了面向对象编程(OOP)中的两个经典模式——适配器模式和装饰者模式。这些模式只是 Erich Gamma、Richard Helm、Ralph Johnson 和 John Vlissides 在《设计模式——可重用面向对象软件元素》一书中介绍的二十三个原始设计模式中的两个。作为一个面向对象的语言,C++可以利用这些模式,就像任何其他语言一样。但是,正如通常情况那样,泛型编程为经典模式带来了某些优势、变化,以及随之而来的新挑战。

本章节涵盖了以下主题:

  • 适配器和装饰者模式是什么?

  • 两者之间的区别是什么?

  • 这些模式可以解决哪些设计问题?

  • 这些模式如何在 C++中使用?

  • 泛型编程如何帮助设计适配器和装饰者?

  • 其他不同模式如何提供类似问题的替代解决方案?

技术要求

本章的示例代码可以在以下 GitHub 链接中找到:github.com/PacktPublishing/Hands-On-Design-Patterns-with-CPP-Second-Edition/tree/master/Chapter16

装饰者模式

我们将从这个研究开始,先定义这两个经典模式。正如我们将看到的,在纸上,模式以及它们之间的区别非常清晰。然后,C++介入,通过允许介于两者之间的设计解决方案来模糊界限。尽管如此,这些简单案例的清晰性仍然是有帮助的,即使在我们增加复杂性时它可能会变得混乱。让我们从清晰的地方开始。

装饰者模式也是一种结构型模式;它允许向对象添加行为。经典的装饰者模式扩展了由一个类执行的操作的行为。它通过添加新的行为来装饰这个类,并创建了一个新装饰类型的对象。装饰者实现了原始类的接口,并将请求从其接口转发到那个类,但它还执行了在转发请求之前和之后的一些额外操作——这些就是装饰。这种装饰者有时被称为“类包装器”。

基本装饰者模式

我们将从一个尽可能接近经典定义的 C++装饰者模式示例开始。为此示例,我们将设想设计一个设定在中世纪时期的幻想游戏(真实生活,只是有龙和精灵等等)。当然,没有战斗的中世纪时代是什么样的?因此,在我们的游戏中,玩家可以选择适合他/她的单位,并在被召唤时进行战斗。以下是基本的Unit类——至少是战斗相关的部分:

// Example 01
class Unit {
  public:
  Unit(double strength, double armor) :
    strength_(strength), armor_(armor) {}
  virtual bool hit(Unit& target) {
    return attack() > target.defense();
  }
  virtual double attack() = 0;
  virtual double defense() = 0;
  protected:
  double strength_;
  double armor_;
};

单位有strength属性,它决定了其攻击力,以及armor属性,它提供防御。攻击和防御的实际值由派生类——具体的单位——计算,但战斗机制本身就在这里——如果攻击力强于防御力,单位就成功地击中了目标(当然,这是一个非常简化的游戏方法,但我们想使示例尽可能简洁)。

现在,游戏中实际有哪些单位?人类军队的支柱是英勇的Knight。这个单位拥有坚固的盔甲和锋利的剑,使它在攻击和防御上都获得加成:

// Example 01
class Knight : public Unit {
  public:
  using Unit::Unit;
  double attack() { return strength_ + sword_bonus_; }
  double defense() { return armor_ + plate_bonus_; }
  protected:
  static constexpr double sword_bonus_ = 2;
  static constexpr double plate_bonus_ = 3;
};

与骑士战斗的是粗鲁的巨魔。巨魔挥舞着简单的木棍,穿着破旧的皮革,这两者都不是很好的战争工具,给它们带来了一些战斗上的惩罚:

// Example 01
class Ogre : public Unit {
  public:
  using Unit::Unit;
  double attack() { return strength_ + club_penalty_; }
  double defense() { return armor_ + leather_penalty_; }
  protected:
  static constexpr double club_penalty_ = -1;
  static constexpr double leather_penalty_ = -1;
};

另一方面,巨魔一开始就非常强壮:

Knight k(10, 5);
Ogre o(12, 2);
k.hit(o); // Yes!

在这里,骑士凭借他的攻击加成和敌人的薄弱盔甲,成功地击中了巨魔。但游戏远未结束。随着单位的战斗,幸存者获得经验,最终成为老兵。老兵单位仍然是同一种单位,但它获得了攻击和防御加成,反映了它的战斗经验。在这里,我们不想改变任何类接口,但想修改attack()defense()函数的行为。这就是装饰者模式的工作,以下是对VeteranUnit装饰者的经典实现:

// Example 01
class VeteranUnit : public Unit {
  public:
  VeteranUnit(Unit& unit,
              double strength_bonus,
              double armor_bonus) :
    Unit(strength_bonus, armor_bonus), unit_(unit) {}
  double attack() { return unit_.attack() + strength_; }
  double defense() { return unit_.defense() + armor_; }
  private:
  Unit& unit_;
};

注意,这个类直接从Unit类继承,所以在类层次结构中,它位于具体单位类(如KnightOgre)的旁边。我们仍然有原始单位,它被装饰并成为老兵——VeteranUnit装饰者包含对其的引用。它的使用方式是装饰一个单位,然后继续使用装饰过的单位,但它不会删除原始单位:

// Example 01
Knight k(10, 5);
Ogre o(12, 2);
VeteranUnit vk(k, 7, 2);
VeteranUnit vo(o, 1, 9);
vk.hit(vo); // Another hit!

在这里,我们两个老对手都达到了他们的第一个军衔等级,胜利再次属于骑士。但经验是最好的老师,我们的巨魔又提升了一个等级,并且,随着它,附有巨大防御加成的魔法符文盔甲:

VeteranUnit vvo(vo, 1, 9);
vk.hit(vvo); // Miss!

注意,在这个设计中,我们可以装饰一个装饰过的对象!这是故意的,并且随着单位等级的提升,加成会叠加。这次,经验丰富的战士的防御力证明对骑士来说太过强大。

正如我们之前提到的,这是一个经典的装饰者模式,直接来自教科书。它在 C++中工作,但有一些限制。第一个限制相当明显,即使我们一旦拥有装饰过的单位,原始单位也必须保留,并且这些对象的生命周期必须仔细管理。对于这样的实际问题,有实际的解决方案,但本书的重点是结合设计模式和泛型编程,以及这种配对创造的新设计可能性。因此,我们的创新之路将带我们走向其他地方。

第二个问题在 C++中更为普遍。最好通过一个例子来说明。游戏的设计师为Knight单位添加了特殊能力——它可以向前冲锋攻击敌人,获得短期的攻击加成。这个加成只对下一次攻击有效,但在激烈的战斗中,这也许正是所需要的:

// Example 02
class Knight : public Unit {
  public:
  Knight(double strength, double armor) :
  Unit(strength, armor), charge_bonus_(0) {}
  double attack() {
    double res = strength_ + sword_bonus_ + charge_bonus_;
    charge_bonus_ = 0;
    return res;
  }
  double defense() { return armor_ + plate_bonus_; }
  void charge() { charge_bonus_ = 1; }
  protected:
  double charge_bonus_;
  static constexpr double sword_bonus_ = 2;
  static constexpr double plate_bonus_ = 3;
};

充能加成是通过调用charge()成员函数激活的,持续一次攻击,然后重置。当玩家激活充能时,游戏执行以下代码:

Knight k(10, 5);
Ogre o(12, 2);
k.charge();
k.hit(o);

当然,我们期望老兵骑士也能向前冲锋,但在这里我们遇到了问题——我们的代码无法编译:

VeteranUnit vk(k, 7, 2);
vk.charge(); // Does not compile!

问题的根源在于charge()方法是Knight类接口的一部分,而VeteranUnit装饰器是从Unit类派生出来的。我们可以将charge()函数移动到基类Unit中,但这是一种糟糕的设计——Ogre也是从Unit派生出来的,而哥布林不能冲锋,因此它们不应该有这样的接口(这违反了公共继承的is-a原则)。

这是一个固存在于我们实现装饰器对象的方式中的问题——KnightVeteranUnit都从同一个基类Unit派生,但它们对彼此一无所知。有一些丑陋的解决方案,但这是一种基本的 C++限制;它处理交叉转换(在相同层次结构的另一分支中转换类型)不佳。但语言一手拿走,另一手又给予——我们有许多更好的工具来处理这个问题,我们将在下一章学习这些工具。

C++风格的装饰器

在实现 C++中的经典装饰器时,我们遇到了两个问题——首先,装饰对象没有接管原始对象的所有权,因此两者都必须保留(如果装饰需要稍后移除,这可能不是问题,而是特性之一,这也是装饰器模式以这种方式实现的原因之一)。另一个问题是,装饰后的Knight根本不是Knight,而是Unit。如果装饰器本身是从被装饰的类派生出来的,我们就可以解决第二个问题。这意味着VeteranUnit类没有固定的基类——基类应该是被装饰的任何类。这种描述与Curiously Recurring Template PatternCRTP)完全吻合(这种 C++习语在本书的第八章中已有描述,即《Curiously Recurring Template Pattern》)。要应用 CRTP,我们需要将装饰器做成模板,并从模板参数继承:

// Example 03
template <typename U>
class VeteranUnit : public U {
  public:
  VeteranUnit(U&& unit,
              double strength_bonus,
              double armor_bonus) :
    U(unit), strength_bonus_(strength_bonus),
    armor_bonus_(armor_bonus) {}
  double attack() { return U::attack() + strength_bonus_; }
  double defense() { return U::defense() + armor_bonus_; }
  private:
  double strength_bonus_;
  double armor_bonus_;
};

现在,要将一个单位提升为老兵状态,我们必须将其转换为混凝土unit类的装饰版本:

// Example 03
Knight k(10, 5);
Ogre o(12, 2);
k.hit(o); // Hit!
VeteranUnit<Knight> vk(std::move(k), 7, 2);
VeteranUnit<Ogre> vo(std::move(o), 1, 9);
vk.hit(vo); // Hit!
VeteranUnit<VeteranUnit<Ogre>> vvo(std::move(vo), 1, 9);
vk.hit(vvo); // Miss...
vk.charge(); // Compiles now, vk is a Knight too
vk.hit(vvo); // Hit with the charge bonus!

这是我们之前章节结尾处看到的相同场景,但现在它使用了模板装饰器。注意其中的差异。首先,VeteranUnit是一个从像KnightOgre这样的具体单位派生出来的类。因此,它能够访问基类的接口:例如,一个老兵骑士VeteranUnit<Knight>也是一个Knight,并且拥有从Knight继承来的成员函数charge()。其次,装饰过的单位明确地接管了原始单位的所有权——为了创建一个老兵单位,我们必须将原始单位移动到其中(老兵单位的基类是通过原始单位移动构造的)。原始对象被留在未指定的移动后状态,对这个对象唯一安全的操作是调用析构函数。请注意,至少对于单位类的简单实现,move操作只是一个复制,所以原始对象仍然是可用的,但你不应依赖于它——对移动后状态做出假设是一个即将发生的错误。

值得指出的是,我们对于VeteranUnit构造函数的声明强制并要求这种所有权的转移。如果我们试图在不从原始单位移动的情况下构建一个老兵单位,它将无法编译:

VeteranUnit<Knight> vk(k, 7, 2); // Does not compile

通过只提供一个接受右值引用的构造函数,即Unit&&,我们要求调用者同意所有权的转移。

到目前为止,为了演示目的,我们已经在栈上作为局部变量创建了所有单位对象。在任何非平凡程序中,这都不会起作用——我们需要这些对象在创建它们的函数完成之后还能存在。我们可以集成装饰器对象和内存所有权机制,并确保在创建装饰版本之后删除移动后的原始单位。

假设在整个程序中通过唯一指针(在任何给定时间每个对象都有一个明确的拥有者)来管理所有权。以下是实现方法。首先,为我们需要使用的指针声明别名是方便的:

using Unit_ptr = std::unique_ptr<Unit>;
using Knight_ptr = std::unique_ptr<Knight>;

虽然任何单位都可以被Unit_ptr指针拥有,但我们不能通过它调用特定的单位成员函数,例如charge(),因此我们可能还需要指向具体类的指针。正如我们接下来将要看到的,我们需要在这些指针之间移动对象。从派生类指针移动到基类指针是很容易的:

Knight_ptr k(new Knight(10, 5));
Unit_ptr u(std::move(k)); // Now k is null

反方向的操作要困难一些;std::move不会隐式地工作,就像我们不能在没有显式转换的情况下从Unit*转换为Knight*一样。我们需要一个移动转换

// Example 04
template <typename To, typename From>
std::unique_ptr<To> move_cast(std::unique_ptr<From>& p) {
 return std::unique_ptr<To>(static_cast<To*>(p.release()));
}

在这里,我们使用static_cast将类型转换为派生类,如果假设的关系(即基对象确实是预期的派生对象)是正确的,那么这将是有效的,否则结果是不确定的。如果我们想,我们可以在运行时测试这个假设,使用dynamic_cast代替。这里是一个进行测试的版本,但仅当断言被启用时(我们可以抛出一个异常而不是断言):

// Example 04
template <typename To, typename From>
std::unique_ptr<To> move_cast(std::unique_ptr<From>& p) {
#ifndef NDEBUG
 auto p1 =
   std::unique_ptr<To>(dynamic_cast<To*>(p.release()));
 assert(p1);
 return p1;
#else
 return std::unique_ptr<To>(static_cast<To*>(p.release()));
#endif
}

如果所有对象都将由唯一指针的实例拥有,那么VeteranUnit装饰器必须在构造函数中接受一个指针并将对象从这个指针中移动出来:

// Example 04
template <typename U> class VeteranUnit : public U {
  public:
  template <typename P>
  VeteranUnit(P&& p,
              double strength_bonus,
              double armor_bonus) :
    U(std::move(*move_cast<U>(p))),
    strength_bonus_(strength_bonus),
    armor_bonus_(armor_bonus) {}
  double attack() { return U::attack() + strength_bonus_; }
  double defense() { return U::defense() + armor_bonus_; }
  private:
  double strength_bonus_;
  double armor_bonus_;
};

这里棘手的部分在于VeteranUnit<U>的基类U的初始化——我们必须将单元从唯一指针移动到基类,然后将其移动到派生类的移动构造函数中(没有简单地将对象从一个唯一指针移动到另一个唯一指针的方法;我们需要将其包装到派生类中)。我们还要确保不泄漏任何内存。原始的唯一指针被释放,因此它的析构函数将不会做任何事情,但我们的move_cast返回一个新的唯一指针,现在它拥有相同的对象。这个唯一指针是一个临时变量,将在新对象初始化结束时被删除,但在我们使用它的对象来构造一个新的派生对象(即VeteranUnit)之前不会删除。单元对象的移动初始化本身在我们的情况下与复制相比没有节省任何时间,但这是一个良好的实践,以防更重的单元对象提供了一个优化的移动构造函数。

下面是如何在管理资源(在我们的例子中是单元)的程序中使用这个新装饰器的示例:

// Example 04
Knight_ptr k(new Knight(10, 5));
  // Knight_ptr so we can call charge()
Unit_ptr o(new Ogre(12, 2));
  // Could be Orge_ptr if we needed one
Knight_ptr vk(new VeteranUnit<Knight>(k, 7, 2));
Unit_ptr vo(new VeteranUnit<Ogre>(o, 1, 9));
Unit_ptr vvo(new VeteranUnit<VeteranUnit<Ogre>>(vo, 1, 9));
vk->hit(*vvo); // Miss
vk->charge(); // Works because vk is Knight_ptr
vk->hit(*vvo); // Hit

注意,我们没有重新定义hit()函数——它仍然通过引用接受一个单元对象。这是正确的,因为这个函数不拥有对象——它只是对其操作。没有必要向其中传递拥有指针——这会暗示所有权的转移。

注意,严格来说,这个例子和上一个例子之间几乎没有区别——被移动的单元无论如何都不应该被访问。从实际的角度来看,存在显著的差异——被移动的指针不再拥有对象。它的值是空值,因此,在对象被提升之后尝试对其原始单元进行操作将很快变得明显(程序将解引用空指针并崩溃)。

正如我们所见,我们可以装饰已经装饰过的类,因为装饰器的作用是累积的。同样,我们可以将两个不同的装饰器应用于同一个类。每个装饰器都为该类添加了特定的新行为。在我们的游戏引擎中,我们可以打印每次攻击的结果,无论是否命中。但如果结果不符合预期,我们不知道原因。为了调试,打印攻击和防御值可能很有用。我们不想对所有单位都这样做,但对我们感兴趣的代码部分,我们可以使用一个调试装饰器,该装饰器为单位添加新行为以打印计算的中间结果。

DebugDecorator使用与之前装饰器相同的设计理念——它是一个类模板,生成一个从要装饰的对象派生的类。它的attack()defense()虚拟函数将调用转发到基类并打印结果:

// Example 05
template <typename U> class DebugDecorator : public U {
  public:
  using U::U;
  template <typename P> DebugDecorator(P&& p) :
    U(std::move(*move_cast<U>(p))) {}
  double attack() {
    double res = U::attack();
    cout << "Attack: " << res << endl;
    return res;
  }
  double defense() {
    double res = U::defense();
    cout << "Defense: " << res << endl;
    return res;
  }
};

在这个例子中,我们省略了动态内存分配,并依赖于移动对象本身来实现所有权的转移。我们没有理由不能同时拥有可堆叠的装饰器和唯一指针:

// Example 06
template <typename U> class VeteranUnit : public U {
  ...
};
template <typename U> class DebugDecorator : public U {
  using U::U;
  public:
  template <typename P>
  DebugDecorator(std::unique_ptr<P>& p) :
    U(std::move(*move_cast<U>(p))) {}
  double attack() override {
    double res = U::attack();
    cout << "Attack: " << res << endl;
    return res;
  }
  double defense() override {
    double res = U::defense();
    cout << "Defense: " << res << endl;
    return res;
  }
  using ptr = std::unique_ptr<DebugDecorator>;
  template <typename... Args>
  static ptr construct(Args&&... args) { return
    ptr{new DebugDecorator(std::forward<Args>(args)...)};
  }
};

在实现装饰器时,你应该小心不要意外地以意想不到的方式改变基类的行为。例如,考虑DebugDecorator的这种可能实现:

template <typename U> class DebugDecorator : public U {
  double attack() {
    cout << "Attack: " << U::attack() << endl;
    return U::attack();
  }
};

这里有一个微妙的错误——装饰后的对象,除了预期的打印输出之外,还隐藏了对原始行为的一个变化——它在基类上调用attack()两次。如果两次调用attack()返回不同的值,打印的值可能是不正确的,而且任何一次性攻击加成,如骑士冲锋,也将被取消。

DebugDecorator为它装饰的每个成员函数添加了非常相似的行为。C++有一套丰富的工具,旨在专门提高代码重用和减少重复。让我们看看我们是否能做得更好,并提出一个更可重用、更通用的装饰器。

多态装饰器和它们的局限性

一些装饰器非常特定于它们所修改的类,其行为是精确针对的。而另一些则非常通用,至少在原则上如此。例如,一个记录函数调用并打印返回值的调试装饰器,如果能够正确实现,可以与任何函数一起使用。

使用 C++14 或更高版本的variadic模板、参数包和完美前向,这样的实现相当直接:

// Example 07
template <typename Callable> class DebugDecorator {
  public:
  template <typename F>
  DebugDecorator(F&& f, const char* s) :
    c_(std::forward<F>(f)), s_(s) {}
  template <typename ... Args>
  auto operator()(Args&& ... args) const {
    cout << "Invoking " << s_ << endl;
    auto res = c_(std::forward<Args>(args) ...);
    cout << "Result: " << res << endl;
    return res;
  }
  private:
  Callable c_;
  const std::string s_;
};

这个装饰器可以围绕任何可调用对象或函数(任何可以用()语法调用的东西)包装,无论有多少个参数。它打印自定义字符串和调用结果。然而,写出可调用类型通常是棘手的——最好让编译器为我们完成这项工作,使用模板参数推导:

// Example 07
template <typename Callable>
  auto decorate_debug(Callable&& c, const char* s) {
  return DebugDecorator<Callable>(
    std::forward<Callable>(c), s);
}

这个模板函数推断出Callable的类型,并用调试包装器对其进行装饰。现在我们可以将其应用于任何函数或对象。下面是一个装饰后的函数示例:

// Example 07
int g(int i, int j) { return i - j; } // Some function
auto g1 = decorate_debug(g, "g()"); // Decorated function
g1(5, 2); // Prints "Invoking g()" and "Result: 3"

我们还可以装饰一个可调用对象:

// Example 07
struct S {
  double operator()() const {
    return double(rand() + 1)/double(rand() + 1);
  }
};
S s; // Callable
auto s1 =
  decorate_debug(s, "rand/rand"); // Decorated callable
s1(); s1(); // Prints the result, twice

注意,我们的装饰器不会接管可调用对象的所有权(如果我们想这样做,可以写成那样)。

我们甚至可以装饰一个 lambda 表达式,这实际上只是一个隐式类型的可调用对象。这个例子中的 lambda 定义了一个具有两个整数参数的可调用对象:

// Example 07
auto f2 = decorate_debug(
  [](int i, int j) { return i + j; }, "i+j");
f2(5, 3); // Prints "Invoking i+j" and "Result: 8"

在我们的例子中,我们决定在装饰器类构造函数和辅助函数中都将可调用对象进行转发。通常,可调用对象是通过值传递的,并且假设它们易于复制。在所有情况下,装饰器存储其数据成员中的可调用对象的副本都很重要。如果你通过引用捕获它,那么一个微妙的错误正在等待发生:

template <typename Callable> class DebugDecorator {
  public:
  DebugDecorator(const Callable& c, const char* s) :
    c_(c), s_(s) {}
  ...
  private:
  const Callable& c_;
  const std::string s_;
};

装饰一个函数很可能会正常工作,但装饰一个 lambda 表达式可能会失败(尽管不一定立即显现)。const Callable& c_ 成员将被绑定到一个临时的 lambda 对象上:

auto f2 = decorate_debug(
  [](int i, int j) { return i + j; }, "i+j");

这个对象的生存期在语句末尾的分号处结束,任何随后的f2使用都会访问一个悬垂引用(地址检查工具可以帮助检测此类错误)。

我们的装饰器有一些局限性。首先,当我们尝试装饰一个不返回任何内容的功能时,它就不够用了,比如下面的 lambda 表达式,它增加其参数但不返回任何内容:

auto incr = decorate_debug([](int& x) { ++x; }, "++x");
int i;
incr(i); // Does not compile

问题出在DebugDecorator内部auto res = ...行的void res表达式。这是有道理的,因为我们不能声明void类型的变量。这个问题可以使用 C++17 中的if constexpr来解决:

// Example 08
template <typename Callable> class DebugDecorator {
  public:
  ...
  template <typename... Args>
  auto operator()(Args&&... args) const {
    cout << "Invoking " << s_ << endl;
    using r_t = decltype(c_(std::forward<Args>(args)...));
    if constexpr (!std::is_same_v<res_t, void>) {
      auto res = c_(std::forward<Args>(args)...);
      cout << "Result: " << res << endl;
      return res;
    } else {
      c_(std::forward<Args>(args)...);
    }
  }
    private:
    Callable c_;
    const std::string s_;
};

在 C++17 之前,if constexpr 最常用的替代方法是函数重载(第一个参数是std::true_typestd::false_type,这取决于由相应函数提供的 if constexpr 的分支):

// Example 08a
template <typename Callable> class DebugDecorator {
  public:
  ...
  template <typename... Args>
  auto operator()(Args&&... args) const {
    cout << "Invoking " << s_ << endl;
    using r_t = decltype(c_(std::forward<Args>(args)...));
    return this->call_impl(std::is_same<res_t, void>{},
                           std::forward<Args>(args)...);
    }
    private:
    Callable c_;
    const std::string s_;
    template <typename... Args>
    auto call_impl(std::false_type, Args&&... args) const {
      auto res = c_(std::forward<Args>(args)...);
      cout << "Result: " << res << endl;
      return res;
    }
    template <typename... Args>
    void call_impl(std::true_type, Args&&... args) const {
      c_(std::forward<Args>(args)...);
  }
};

第二个局限性是,我们的装饰器的auto返回类型推断得并不完全准确——例如,如果一个函数返回double&,则装饰后的函数将只返回double。最后,包装成员函数调用是可能的,但需要稍微不同的语法。

现在,C++的模板机制非常强大,有方法可以使我们的泛型装饰器更加通用。这些方法也使其更加复杂。这样的代码应该放在库中,比如标准库,但在大多数实际应用中,调试装饰器不值得付出这样的努力。

另一个限制是,装饰器越通用,它能做的就越少。就目前而言,我们能够采取的、对任何函数或成员函数(甚至在我们的装饰器中产生一个良好的调试信息可能需要使用编译器扩展,见示例 09)有意义的操作非常有限。我们可以添加一些调试打印,只要它定义了流输出操作符,就可以打印结果。我们可以在多线程程序中锁定互斥锁来保护非线程安全的函数调用。可能还有一些更通用的操作。但总的来说,不要被追求最通用代码本身所迷惑。

无论我们是否有某种通用的还是非常具体的装饰器,我们通常都需要向对象添加多个行为。我们已经看到了一个这样的例子。现在,让我们更系统地回顾一下应用多个装饰器的问题。

可组合的装饰器

我们希望在这里拥有的装饰器属性有一个名字,叫做可组合性。如果行为可以被分别应用于同一个对象,则它们是可组合的:在我们的情况下,如果我们有两个装饰器,AB。因此,A(B(object))应该应用两种行为。可组合性的替代方案是显式创建组合行为:如果没有可组合性,我们需要编写一个新的装饰器,AB。由于为几个装饰器的任何组合编写新代码都是不可能的,即使装饰器的数量相对较少,可组合性是一个非常重要的属性。

幸运的是,使用我们的装饰器方法,可组合性并不难实现。我们在早期的游戏设计中使用的 CRTP 装饰器自然是可以组合的:

template <typename U> class VeteranUnit : public U { ... };
template <typename U> class DebugDecorator : public U { ...
};
Unit_ptr o(new DebugDecorator<Ogre>(12, 2));
Unit_ptr vo(new DebugDecorator<VeteranUnit<Ogre>>(o, 1, 9));

每个装饰器都继承自它装饰的对象,因此保留了其接口,除了添加的行为。请注意,装饰器的顺序很重要,因为新的行为是在装饰调用之前或之后添加的。DebugDecorator应用于它装饰的对象,并为其提供调试功能,因此VeteranUnit<DebugDecorator<Ogre>>对象会调试对象的基础部分(Ogre),这同样很有用。

我们的(某种程度上)通用装饰器也可以组合使用。我们已经有了一个可以与许多不同的可调用对象一起工作的调试装饰器,并且我们提到了可能需要使用互斥锁来保护这些调用。现在我们可以以类似的方式(以及类似的限制)实现这样的锁定装饰器,就像多态调试装饰器一样:

// Example 10
template <typename Callable> class LockDecorator {
  public:
  template <typename F>
  LockDecorator(F&& f, std::mutex& m) :
    c_(std::forward<F>(f)), m_(m) {}
  template <typename ... Args>
  auto operator()(Args&& ... args) const {
    std::lock_guard<std::mutex> l(m_);
    return c_(std::forward<Args>(args) ...);
  }
  private:
  Callable c_;
  std::mutex& m_;
};
template <typename Callable>
auto decorate_lock(Callable&& c, std::mutex& m) {
  return
    LockDecorator<Callable>(std::forward<Callable>(c), m);
}

再次强调,我们将使用decorate_lock()辅助函数来委托给编译器解决可调用对象的正确类型这一繁琐的工作。现在我们可以使用互斥锁来保护一个非线程安全的函数调用:

std::mutex m;
auto safe_f = decorate_lock([](int x) {
  return unsafe_f(x); }, m
);

如果我们想要通过互斥锁来保护一个函数,并在调用时进行调试打印,我们不需要编写一个新的锁定调试装饰器,而是可以按顺序应用这两个装饰器:

auto safe_f = decorate_debug(
  decorate_lock(
    [](int x) { return unsafe_f(x); },
    m
  ),
  "f(x)");

这个例子展示了可组合性的好处——我们不需要为每种行为组合编写特殊的装饰器(想想如果它们不可组合,你将需要为五种不同主要装饰器的任何组合编写多少装饰器!)。

这种可组合性在我们的装饰器中很容易实现,因为它们保留了原始对象(至少是我们感兴趣的部分)的接口——行为改变了,但接口没有改变。当一个装饰器被用作另一个装饰器的原始对象时,保留的接口再次被保留,依此类推。

保留接口是装饰器模式的一个基本特征。它也是其最严重的限制之一。我们的锁定装饰器并不像乍看之下那么有用(所以当你需要使代码线程安全时,不要在代码中到处强行添加锁)。正如我们接下来将看到的,无论实现多么好,并不是每个接口都可以被做成线程安全的。这就是我们不仅要修改行为,还要改变接口的时候。这是适配器模式的工作。

适配器模式

我们在上一个部分结束时提出了装饰器模式具有来自保留装饰后接口的特定优势,并且这些优势有时会变成限制。适配器模式是一个更通用的模式,可以在这种情况下使用。

适配器模式被定义得非常广泛——它是一种结构型模式,允许一个类的接口被用作另一个不同的接口。它允许一个现有的类在期望不同接口的代码中使用,而不需要修改原始类。这样的适配器有时被称为类包装器,因为它们包装在类周围并呈现不同的接口。你可能还记得,装饰器有时也被称为类包装器,原因大致相同。

然而,适配器模式是一个非常通用、广泛的模式。它可以用来实现几个其他更具体定义的模式——特别是装饰器模式。装饰器模式更容易理解,所以我们首先处理了它。现在,我们将转向一般情况。

基本适配器模式

让我们继续上一个部分的最后一个例子——锁定装饰器。它在一个锁下调用任何函数,因此没有其他由相同互斥锁保护的函数可以在任何其他线程上同时被调用。在某些情况下,这足以使整个代码线程安全。通常,这并不够。

为了演示这一点,我们将实现一个线程安全的队列对象。队列是一个中等复杂的数据结构,即使没有线程安全,但幸运的是,我们不需要从头开始——C++标准库中有std::queue。我们可以按照先进先出的顺序将对象推入队列并从队列中取出对象,但只能在一个线程上——例如,同时从两个不同的线程向同一个队列推送两个对象是不安全的。但我们有一个解决方案——我们可以将锁定队列作为基本队列的装饰器来实现。由于我们这里不关心空基优化(std::queue不是一个空类)并且我们必须转发每个成员函数调用,所以我们不需要继承,而可以使用组合。我们的装饰器将包含队列和锁。包装push()方法很容易。std::queue中有两个版本的push()方法——一个移动对象,一个复制对象。我们应该用锁保护这两个版本:

// Example 11
template <typename T> class locking_queue {
  using mutex = std::mutex;
  using lock_guard = std::lock_guard<mutex>;
  using value_type = typename std::queue<T>::value_type;
  void push(const value_type& value) {
    lock_guard l(m_);
    q_.push(value);
  }
  void push(value_type&& value) {
    lock_guard l(m_);
    q_.push(value);
  }
  private:
  std::queue<T> q_;
  mutex m_;
};

现在,让我们把注意力转向从队列中获取元素。标准队列有三个相关的成员函数——首先,是front(),它允许我们访问队列的前端元素,但不从队列中移除它。然后是pop(),它移除前端元素但不返回任何内容(它不提供对前端元素的访问——它只是移除它)。这两个函数在队列为空时不应该被调用——没有错误检查,但结果是不确定的。

最后,是第三个函数empty();如果队列不为空,它返回false,然后我们可以调用front()pop()。如果我们用锁定来装饰它们,我们就能写出如下代码:

locking_queue<int> q;
q.push(5);
... sometime later in the program ...
if (!q.empty()) {
  int i = q.front();
  q.pop();
}

每个函数本身都是线程安全的。但它们的整体组合并不是。理解这一点非常重要。首先,我们调用q.empty()。假设它返回false,这意味着我们知道队列中至少有一个元素。接下来,我们在下一行通过调用q.front()来访问它,它返回5。但这只是程序中许多线程中的一个。另一个线程同时正在执行相同的代码(这正是练习的目的)。那个线程也调用了q.empty(),并且也得到了false——正如我们刚才说的,队列中有一个元素,而我们还没有做任何删除操作。第二个线程也调用了q.front(),并且也得到了5。这已经是一个问题——两个线程都试图从队列中取出一个元素,但取到的却是同一个。但问题更严重——我们的第一个线程现在调用了q.pop()并从队列中移除了5。现在队列是空的,但第二个线程并不知道这一点——它之前调用了q.empty()。因此,第二个线程现在也调用了q.pop(),这次是在一个空队列上。在这种情况下,最好的情况是程序会立即崩溃。

我们刚刚看到了一个一般问题的具体案例——一系列操作,每个操作都是线程安全的,但作为一个整体不是线程安全的。实际上,这个锁定队列完全无用,无法用它来编写线程安全的代码。我们需要的是一个单一的线程安全函数,它在一个锁下执行整个事务,作为一个不可中断的操作(这种事务被称为std::queue接口不提供这样的事务性 API)。

因此,现在我们需要一个新的模式——一个将类的现有接口转换为不同接口所需的新模式。这不能通过装饰器模式来完成,但这正是适配器模式解决的问题。既然我们已经同意需要一个不同的接口,我们只需决定它应该是什么。我们新的单个pop()成员函数应该完成所有这些——如果队列不为空,它应该从队列中移除第一个元素并返回它,通过复制或移动,给调用者。如果队列为空,它应该根本不改变队列的状态,但以某种方式通知调用者队列是空的。一种方法是通过返回两个值来实现——元素本身(如果有的话)和一个布尔值,告诉我们队列是否为空。以下是锁定队列的pop()部分,现在它是一个适配器,而不是装饰器:

// Example 11
template <typename T> class locking_queue {
  ... the push() is unchanged ...
  bool pop(value_type& value) {
    lock_guard l(m_);
    if (q_.empty()) return false;
    value = std::move(q_.front());
    q_.pop();
    return true;
  }
  private:
  std::queue<T> q_;
  mutex m_;
};

注意,我们不需要改变push()——单个函数调用已经完成了我们需要的所有操作,因此这部分接口只是通过我们的适配器一对一地转发。这个版本的pop()在从队列中移除元素时返回true,否则返回false。如果返回true,则将元素保存到提供的参数中,但如果返回false,则参数保持不变。如果元素类型T是可移动赋值的,则使用移动而不是复制。

当然,这并不是这种原子pop()的唯一可能接口。另一种方式是返回一个包含元素和布尔值的对。一个显著的区别是,现在没有方法可以保留元素不变——它是返回值,它总是必须有一些东西。自然的方式是,如果队列中没有元素,就默认构造该元素(这暗示了对元素类型T的限制——它必须是可以默认构造的)。

在 C++17 中,更好的选择是返回一个std::optional

// Example 12
template <typename T> class locking_queue {
  ... the push() is unchanged ...
  std::optional<value_type> pop() {
    lock_guard l(m_);
    if (q_.empty()) return std::nullopt;
    value_type value = std::move(q_.front());
    q_.pop();
    return { value };
  }
};

根据需要此队列的应用代码,可能有一种接口更可取,因此也有其他设计它的方法。在所有情况下,我们最终都会有两个成员函数,push()pop(),它们都受到相同的互斥锁的保护。现在,任何数量的线程可以同时执行这些操作的任何组合,并且行为是明确的。这意味着locking_queue对象是线程安全的。

将对象从其当前接口转换为特定应用所需的接口,而不需要重写对象本身,这就是适配器模式的目的和用途。可能需要转换各种接口,因此存在许多不同类型的适配器。我们将在下一节中了解其中的一些。

函数适配器

我们刚刚看到了一个类适配器,它改变了类的接口。另一种接口是函数(成员函数或非成员函数)。一个函数有一定的参数,但我们可能想要用不同的参数集调用它。这需要一个适配器。这种适配器的一个常见应用被称为 currying(或 currying 多个)函数的参数。这意味着我们有一个多个参数的函数,我们固定其中一个参数的值,因此我们不必在每次调用时指定它。一个例子是,如果我们有 f(int i, int j),但我们需要 g(i),这相当于 f(i, 5),只是不需要每次都输入 5

这里有一个更有趣的例子,我们将实际逐步实现一个适配器。std::sort 函数接受一个迭代器范围(要排序的序列),但它也可以用三个参数调用——第三个是比较对象(默认情况下使用 std::less,它反过来会在排序的对象上调用 operator<())。

现在,我们想要做的是其他事情——我们想要模糊地比较浮点数,带有容差——如果两个数 xy 足够接近,那么我们不认为一个比另一个小。只有当 x 远远小于 y 时,我们才想要强制执行排序顺序,即 xy 之前。

下面是我们的比较函数对象(一个可调用对象):

// Example 13
struct much_less {
  template <typename T>
  bool operator()(T x, T y) {
    return x < y && std::abs(x - y) > tolerance);
  }
  static constexpr double tolerance = 0.2;
};

这个比较对象可以与标准排序一起使用:

std::vector<double> v;
std::sort(v.begin(), v.end(), much_less());

然而,如果我们经常需要这种排序,我们可能想要 curry 最后一个参数,并为自己创建一个只有两个参数的适配器,即迭代器和隐含的排序函数。下面是一个这样的适配器——它非常简单:

// Example 13
template<typename RandomIt>
  void sort_much_less(RandomIt first, RandomIt last) {
  std::sort(first, last, much_less());
}

现在,我们可以用两个参数调用排序函数:

// Example 13
std::vector<double> v;
sort_much_less(v.begin(), v.end());

现在,如果我们经常以这种方式调用排序来对整个容器进行排序,我们可能想要再次更改接口并创建另一个适配器:

// Example 14
template<typename Container> void sort_much_less(Container&
   c) {
std::sort(c.begin(), c.end(), much_less());
}

在 C++20 中,std::sort 和其他 STL 函数有接受范围的变体;它们是容器适配器的一般化。现在,我们程序中的代码看起来更加简单:

// Example 14
std::vector<double> v;
sort_much_less(v);

需要指出的是,C++14 提供了编写这种简单适配器的替代方案,通常应优先选择;我们可以使用 lambda 表达式,如下所示:

// Example 15
auto sort_much_less = [](auto first, auto last) {
  return std::sort(first, last, much_less());
};

当然,比较函数 much_less() 本身就是一个可调用对象,因此它也可以是一个 lambda 表达式:

// Example 15a
auto sort_much_less = [](auto first, auto last) {
  return std::sort(first, last,
    [](auto x, auto y) {
      static constexpr double tolerance = 0.2;
      return x < y && std::abs(x - y) > tolerance;
    }); };

容器适配器的编写同样简单:

// Example 16
auto sort_much_less = [](auto& container) {
  return std::sort(container.begin(), container.end(),
                   much_less());
};

注意,你无法在同一个程序中以相同的名称同时拥有这两个(lambda 表达式不能以这种方式重载;实际上,它们根本不是函数,而是对象(你可以从 lambda 中创建一个重载集,如第二章**,类和函数模板所示)。

回到用一些固定或绑定的常量值调用函数的问题,我们应该说这是一个如此常见的需求,以至于 C++标准库提供了一个用于此目的的标准可定制适配器,即std::bind。以下是一个示例,展示了它的用法:

// Example 17
using namespace std::placeholders; // For _1, _2 etc
int f3(int i, int j, int k) { return i + j + k; }
auto f2 = std::bind(f3, _1, _2, 42);
auto f1 = std::bind(f3, 5, _1, 7);
f2(2, 6);     // Returns 50
f1(3);     // Returns 15

这种标准适配器有其自己的迷你语言 - std::bind的第一个参数是要绑定的函数,其余的是它的参数,按顺序排列。应该绑定的参数被指定的值替换。应该保持自由的参数被占位符_1_2等替换(不一定按此顺序;也就是说,我们也可以改变参数的顺序)。返回值是不指定的类型,必须使用auto捕获。我们唯一知道的是返回值可以像函数一样调用,具有与占位符一样多的参数。它也可以在任何期望可调用的上下文中用作函数,例如,在另一个std::bind中:

// Example 17
...
auto f1 = std::bind(f3, 5, _1, 7);
auto f0 = std::bind(f1, 3);
f1(3);    // Returns 15
f0();         // Also returns 15

然而,这些对象是可调用的,而不是函数,你会发现如果你尝试将它们中的一个分配给函数指针:

// Example 17
int (*p3)(int, int, int) = f3;    // OK
int (*p1)(int) = f1;            // Does not compile

相反,如果 lambda 没有捕获,则可以将其转换为函数指针:

auto l1 = [](int i) { return f3(5, i, 7); }
int (*p1)(int) = l1;            // OK

尽管std::bind很有用,但它并没有使我们摆脱学习如何编写自己的函数适配器的需求 - 它最大的局限性是std::bind不能绑定模板函数。我们无法编写以下内容:

auto sort_much_less = std::bind(std::sort, _1, _2, much_less()); // No!

这将无法编译。在模板内部,我们可以绑定它的特定实例化,但至少在我们的排序示例中,这实际上并没有给我们带来任何好处:

template<typename RandomIt>
void sort_much_less(RandomIt first, RandomIt last) {
  auto f = std::bind(std::sort<RandomIt, much_less>,
                     _1, _2, much_less());
  f(first, last, much_less());
}

如我们在本节开头提到的,装饰器可以被视为适配器模式的一个特例。有时,这种区别并不在于模式的具体应用,而在于我们选择如何看待它。

适配器或装饰器

到目前为止,我们描述装饰器为一种我们用来增强现有接口的模式,而适配器则是用来转换(适配)接口,以便与期望不同接口的代码集成。这种区别并不总是清晰的。

例如,让我们考虑一个简单的类,它将系统调用std::time的结果适配为可打印的日期格式(std::chrono提供了这种功能,但它是一个易于理解的例子)。函数std::time返回一个std::time_t类型的值,它是一个整数,包含自过去某个标准时刻以来的秒数,这个时刻被称为“纪元开始”。另一个系统函数localtime将这个值转换为包含日期元素的 struct:年、月和日(以及小时、分钟等)。通常的日历计算相当复杂(这也是为什么std::chrono不像我们希望的那样简单),但就目前而言,让我们假设系统库做了正确的事情,我们只需要以正确的格式打印日期。例如,以下是如何以美国格式打印当前日期的方法:

const std::time_t now = std::time(nullptr);
const tm local_tm = *localtime(&now);
cout << local_tm.tm_mon + 1 << "/" <<
        local_tm.tm_mday << "/" <<
        local_tm.tm_year + 1900;

我们想要创建一个适配器,将秒数转换为特定格式的日期,并允许我们打印它;我们需要为美国格式(月份在前)、欧洲格式(日期在前)和 ISO 格式(年份在前)分别创建适配器。

适配器的实现相当直接:

// Example 18
class USA_Date {
  public:
  explicit USA_Date(std::time_t t) : t_(t) {}
  friend std::ostream& operator<<(std::ostream& out,
                                  const USA_Date& d) {
    const tm local_tm = *localtime(&d.t_);
    out << local_tm.tm_mon + 1 << "/" <<
           local_tm.tm_mday << "/" <<
           local_tm.tm_year + 1900;
    return out;
  }
  private:
  const std::time_t t_;
};

其他两种日期格式在打印字段顺序上相似,除了我们打印字段的顺序。事实上,它们如此相似,我们可能想要重构代码以避免编写三个几乎相同的类。最简单的方法是使用模板,并将字段顺序编码在“格式代码”中,该代码指定我们打印日期(字段 0)、月份(字段 1)和年份(字段 2)的顺序。例如,“格式”210 表示年份,然后是月份,然后是日期——ISO 日期格式。格式代码可以是一个整数模板参数:

// Example 19
template <size_t F> class Date {
  public:
  explicit Date(std::time_t t) : t_(t) {}
  friend std::ostream& operator<<(std::ostream& out,
                                  const Date& d) {
    const tm local_tm = *localtime(&d.t_);
    const int t[3] = { local_tm.tm_mday,
                       local_tm.tm_mon + 1,
                       local_tm.tm_year + 1900 };
    constexpr size_t i1 = F/100;
    constexpr size_t i2 = (F - i1*100)/10;
    constexpr size_t i3 = F - i1*100 - i2*10;
    static_assert(i1 >= 0 && i1 <= 2 && ..., "Bad format");
    out << t[i1] << "/" << t[i2] << "/" << t[i3];
    return out;
  }
  private:
  const std::time_t t_;
};
using USA_Date = Date<102>;
using European_Date = Date<12>;
using ISO_Date = Date<210>;

我们的小包装器将一个类型(一个整数)适配为在代码中使用,该代码期望以特定格式提供日期。或者它是否用operator<<()装饰了整数?最好的答案是……无论哪个对你思考特定问题更有帮助。重要的是要记住,最初用模式语言说话的目的:我们这样做是为了有一个紧凑且普遍理解的方式来描述我们的软件问题和我们选择的解决方案。当多个模式似乎产生类似的结果时,你选择描述让你能够关注对你最重要的方面。

到目前为止,我们只考虑了转换运行时接口的适配器,这些接口是我们程序执行时调用的接口。然而,C++也有编译时接口——我们在上一章考虑的一个主要例子是基于策略的设计。这些接口并不总是恰好符合我们的需求,因此我们必须学会编写编译时适配器。

编译时适配器

第十六章,“适配器和装饰器”中,我们学习了政策,它们是类的构建块——它们让程序员可以为特定的行为定制实现。作为一个例子,我们可以实现这个基于政策的智能指针,它可以自动删除它拥有的对象。政策是删除的特定实现:

// Chapter 15, Example 08
template <typename T,
          template <typename> class DeletionPolicy =
                                    DeleteByOperator>
class SmartPtr {
  public:
  explicit SmartPtr(T* p = nullptr,
    const DeletionPolicy<T>& del_policy =
                             DeletionPolicy<T>())
  : p_(p), deletion_policy_(deletion_policy)
  {}
  ~SmartPtr() {
    deletion_policy_(p_);
  }
  ... pointer interface ...
  private:
  T* p_;
  DeletionPolicy<T> deletion_policy_;
};

注意,删除策略本身也是一个模板——这是一个模板参数。默认的删除策略是使用operator delete

template <typename T> struct DeleteByOperator {
  void operator()(T* p) const {
    delete p;
  }
};

然而,对于在用户指定的堆上分配的对象,我们需要一个不同的删除策略,该策略将内存返回到那个堆:

template <typename T> struct DeleteHeap {
  explicit DeleteHeap(MyHeap& heap) : heap_(heap) {}
  void operator()(T* p) const {
    p->~T();
    heap_.deallocate(p);
  }
  private:
  MyHeap& heap_;
};

然后,我们必须创建一个政策对象来与指针一起使用:

MyHeap H;
SmartPtr<int, DeleteHeap<int>> p(new int, H);

这项政策并不非常灵活,然而——它只能处理一种类型的堆——MyHeap。如果我们把堆类型设为第二个模板参数,就可以使政策更通用。只要堆有deallocate()成员函数来返回内存给它,我们就可以使用任何与这个政策兼容的堆类:

// Example 20
template <typename T, typename Heap> struct DeleteHeap {
  explicit DeleteHeap(Heap& heap) : heap_(heap) {}
  void operator()(T* p) const {
    p->~T();
    heap_.deallocate(p);
  }
  private:
  Heap& heap_;
};

当然,如果我们有一个使用不同名称的成员函数的堆类,我们可以使用类适配器使该类也能与我们的政策一起工作。但我们有一个更大的问题——我们的政策与我们的智能指针不兼容。以下代码无法编译:

SmartPtr<int, DeletelHeap> p; // Does not compile

原因再次是接口不匹配,但现在它是一种不同类型的接口——template <typename T, template <typename> class DeletionPolicy> class SmartPtr {}; 模板期望第二个参数是一个只有一个类型参数的模板。相反,我们有DeleteHeap模板,它有两个类型参数。这就像尝试调用一个只有一个参数但使用两个参数的函数——这是行不通的。我们需要一个适配器来将我们的双参数模板转换为单参数模板,并且我们必须将第二个参数固定为特定的堆类型(如果我们有多个堆类型,我们不需要重写策略,我们只需要编写几个适配器)。我们可以使用继承来创建这个适配器,DeleteMyHeap(并记得将基类的构造函数带入派生适配器类的范围):

// Example 20
template <typename T>
struct DeleteMyHeap : public DeleteHeap<T, MyHeap> {
  using DeleteHeap<T, MyHeap>::DeleteHeap;
};

我们也可以用模板别名来做同样的事情:

// Example 21
template <typename T>
using DeleteMyHeap = DeleteHeap<T, MyHeap>;

这个第一个版本显然要长得多。然而,我们必须学习两种编写模板适配器的方法,因为模板别名有一个主要限制。为了说明这一点,让我们考虑另一个需要适配器的例子。我们将从实现任何 STL 兼容序列容器的流插入操作符开始,这些容器的元素定义了这样的操作符。它是一个简单的函数模板:

// Example 22
template <template <typename> class Container, typename T>
std::ostream& operator<<(std::ostream& out,
                         const Container<T>& c) {
  bool first = true;
  for (auto x : c) {
  if (!first) out << ", ";
    first = false;
    out << x;
  }
  return out;
}

这个template函数有两个类型参数,容器类型和元素类型。容器本身是一个带有单个类型参数的模板。编译器从第二个函数参数(在任何operator<<()中的第一个参数总是流)推导出容器类型和元素类型。我们可以在一个简单的容器上测试我们的插入操作符:

// Example 22
template <typename T> class Buffer {
  public:
  explicit Buffer(size_t N) : N_(N), buffer_(new T[N_]) {}
  ~Buffer() { delete [] buffer_; }
  T* begin() const { return buffer_; }
  T* end() const { return buffer_ + N_; }
  ...
  private:
  const size_t N_;
  T* const buffer_;
};
Buffer<int> buffer(10);
... fill the buffer ...
cout << buffer; // Prints all elements of the buffer

但这只是一个玩具容器,并不很有用。我们真正想要的是打印真实容器的元素,例如std::vector

std::vector<int> v;
... add some values to v ...
cout << v;

不幸的是,这段代码无法编译。原因是std::vector实际上不是一个只有一个类型参数的模板,尽管我们这样使用它。它有两个参数 - 第二个是分配器类型。这个分配器有一个默认值,这就是为什么我们可以写std::vector<int>并且它能编译。但是,即使有这个默认参数,这仍然是一个有两个参数的模板,而我们的流插入操作符被声明为只接受只有一个参数的容器模板。我们可以通过编写适配器来解决这个问题(大多数 STL 容器实际上都是与默认分配器一起使用的)。编写这个适配器最简单的方法是使用别名:

template <typename T> using vector1 = std::vector<T>;
vector1<int> v;
...
cout << v; // Does not compile either

不幸的是,这同样无法编译,现在我们可以展示我们之前提到的模板别名限制 - 模板别名不用于模板参数类型推导。当编译器试图确定使用coutv作为参数调用operator<<()的模板参数类型时,模板别名vector1是“不可见”的。在这种情况下,我们必须使用一个派生类适配器:

// Example 22
template <typename T>
struct vector1 : public std::vector<T> {
  using std::vector<T>::vector;
};
vector1<int> v;
...
cout << v;

顺便说一下,如果你注意到了前面的章节,你可能已经意识到我们已经遇到了模板模板参数额外参数的问题,并且通过将这些参数声明为变长模板参数来解决它:

// Example 23
template <typename T,
  template <typename, typename...> class Container,
  typename... Args>
std::ostream& operator<<(std::ostream& out,
                         const Container<T, Args...>& c) {
  ...
}

现在我们可以让operator<<()打印任何容器,所以我们不再需要担心适配器,对吧?并不完全是这样:我们仍然无法打印的容器之一是std::array,它是一个只有一个类型和一个非类型参数的类模板。我们可以声明一个重载来处理这种情况:

// Example 23
template <typename T,
  template <typename, size_t> class Container, size_t N>
std::ostream& operator<<(std::ostream& out,
                         const Container<T, N>& c) {
  ...
}

但我们可能还有另一种类型的容器,它不适合这两种模板(无论是必须这样做还是因为它只是旧代码的一部分,该代码以不同的方式编写)。然后,我们再次必须使用适配器。

我们现在已经看到了如何实现装饰器来增强类和函数接口以实现所需的行为,以及当现有接口不适合特定应用时如何创建适配器。装饰器,甚至更不用说适配器,都是非常通用和灵活的模式,可以用来解决许多问题。毫不奇怪,通常一个问题可以用多种方式解决,因此有选择使用模式的余地。在下一节中,我们将看到这样一个案例。

适配器与策略

适配器和策略(或策略)模式是一些更通用的模式,C++ 为这些模式增加了泛型编程能力。这往往扩展了它们的可用性,有时也模糊了模式之间的界限。模式本身定义得非常明确 - 策略提供自定义实现,而适配器则改变接口并向现有接口添加功能(后者是装饰器方面,但正如我们所见,大多数装饰器都是作为适配器实现的)。我们还在上一章中看到,C++ 扩展了基于策略的设计能力;特别是,C++ 中的策略可以添加或删除接口的部分,以及控制实现。因此,虽然模式不同,但它们在可以用于的问题类型上存在显著的重叠。当问题在广义上可以适用于两种方法时,比较这两种方法是有教育意义的。对于这个练习,我们将考虑设计自定义值类型的问题。

简而言之,值类型是一种主要像 int 那样行为的类型。通常,这些类型是数字。虽然我们有一组内置类型用于这些,但我们可能想要操作有理数、复数、张量、矩阵或与它们关联有单位的数字(米、克等等)。这些值类型支持一系列操作,如算术运算、比较、赋值和复制。根据值所代表的内容,我们可能只需要这些操作的一小部分 - 例如,我们可能需要支持矩阵的加法和乘法,但不允许除法,并且在大多数情况下,比较矩阵以任何非等性可能都没有意义。同样,我们可能不希望允许米和克的相加。

更普遍地说,人们通常希望有一个具有有限接口的数值类型 - 如果我们不希望允许表示此类数值的操作编译,我们会希望这样。这样,一个包含无效操作的程序就根本无法编写。为了通用,我们的设计必须允许我们逐步构建接口。例如,我们可能希望一个可以用于等性比较、有序(定义了小于操作)和可加的值,但没有乘法或除法。这似乎是一个非常适合装饰器(或更普遍地,适配器)模式的问题:装饰器可以添加比较运算符或加法运算符等行为。另一方面,创建一个通过插入正确的策略来配置功能集的类型正是策略模式的目的。

适配器解决方案

让我们先来考察适配器解决方案。我们将从一个基本的价值类型开始,它在接口中几乎不支持任何功能,然后我们可以逐一添加所需的功能。

这里是我们的初始 Value 类模板:

// Example 24
template <typename T> class Value {
  public:
  using basic_type = T;
  using value_type = Value;
  explicit Value() : val_(T()) {}
  explicit Value(T v) : val_(v) {}
  Value(const Value&) = default;
  Value& operator=(const Value&) = default;
  Value& operator=(basic_type rhs) {
    val_ = rhs;
    return *this;
  }
  protected:
  T val_ {};
};

Value 的值是可复制和可赋值的,无论是从底层类型如 int 还是另一个 Value。如果我们想拥有不可复制的值,我们也可以将这些功能移动到适配器中,但你会发现,在阅读完本章的其余部分后,这个改动很容易实现。

为了方便起见,我们还将使我们的 Value 可打印(在任何真实情况下,你可能会希望这是一个单独且可配置的功能,但这使得示例更简单,而没有去掉任何重要内容)。

// Example 24
template <typename T> class Value {
  public:
  friend std::ostream& operator<<(std::ostream& out,
                                  Value x) {
    out << x.val_;
    return out;
  }
  friend std::istream& operator>>(std::istream& in,
                                  Value& x) {
    in >> x.val_;
    return in;
  }
  ...
};

我们使用 友元工厂,这在 第十二章友元工厂 中有描述,来生成这些函数。到目前为止,我们能够对 Value 做的只是初始化它,也许将它赋值给另一个值,或者打印它:

// Example 24
using V = Value<int>;
V i, j(5), k(3);
i = j;
std::cout << i;     // Prints 5

对于这个类,我们别无他法——没有用于相等或不等的比较,也没有算术运算。然而,我们可以创建一个适配器来添加比较接口:

// Example 24
template <typename V> class Comparable : public V {
  public:
  using V::V;
  using V::operator=;
  using value_type = typename V::value_type;
  using basic_type = typename value_type::basic_type;
  Comparable(value_type v) : V(v) {}
  friend bool operator==(Comparable lhs, Comparable rhs) {
    return lhs.val_ == rhs.val_;
  }
  friend bool operator==(Comparable lhs, basic_type rhs) {
    return lhs.val_ == rhs;
  }
  friend bool operator==(basic_type lhs, Comparable rhs) {
    return lhs == rhs.val_;
  }
  ... same for the operator!= ...
};

这是一个类适配器——它从它增强的类中继承而来,因此继承了所有接口并添加了一些更多——完整的比较运算符集。请注意,在处理值类型时,通常使用值传递而不是引用传递(将引用传递给 const 也没有错,一些编译器可能会将两种版本都优化到相同的结果)。

我们熟悉这些适配器的使用方式:

using V = Comparable<Value<int>>;
V i(3), j(5);
i == j; // False
i == 3; // True
5 == j; // Also true

那是其中一个功能。关于更多功能呢?没问题——Ordered 适配器可以非常相似地编写,只是它提供了 <<=>>= 运算符(或者在 C++20 中,是 <=> 运算符):

// Example 24
template <typename V> class Ordered : public V {
  public:
  using V::V;
  using V::operator=;
  using value_type = typename V::value_type;
  using basic_type = typename value_type::basic_type;
  Ordered(value_type v) : V(v) {}
  friend bool operator<(Ordered lhs, Ordered rhs) {
    return lhs.val_ < rhs.val_;
  }
  friend bool operator<(basic_type lhs, Ordered rhs) {
    return lhs < rhs.val_;
  }
  friend bool operator<(Ordered lhs, basic_type rhs) {
    return lhs.val_ < rhs;
  }
  ... same for the other operators ...
};

我们可以将这两个适配器结合起来——正如我们所说的,它们是可组合的,并且可以在任何顺序下工作:

using V = Ordered<Comparable<Value<int>>>;
// Or Comparable<Ordered<...>
V i(3), j(5);
i == j; // False
i <= 3; // True

一些操作或功能需要更多的工作。如果我们的值类型是数值类型,例如 Value<int>,我们可能希望有一些算术运算,比如加法和乘法。这里有一个允许加法和减法的装饰器:

// Example 24
template <typename V> class Addable : public V {
  public:
  using V::V;
  using V::operator=;
  using value_type = typename V::value_type;
  using basic_type = typename value_type::basic_type;
  Addable(value_type v) : V(v) {}
  friend Addable operator+(Addable lhs, Addable rhs) {
    return Addable(lhs.val_ + rhs.val_);
  }
  friend Addable operator+(Addable lhs, basic_type rhs) {
    return Addable(lhs.val_ + rhs);
  }
  friend Addable operator+(basic_type lhs, Addable rhs) {
    return Addable(lhs + rhs.val_);
  }
  ... same for the operator- ...
};

装饰器使用起来非常简单:

using V = Addable<Value<int>>;
V i(5), j(3), k(7);
k = i + j; // 8

我们还可以将 Addable 与其他装饰器结合使用:

using V = Addable<Ordered<Value<int>>>;
V i(5), j(3), k(7);
if (k - 1 < i + j) { ... yes it is ... }

但我们有一个问题,到目前为止,这个问题只是因为好运才被隐藏起来。我们本来也可以这样写:

using V = Ordered<Addable<Value<int>>>;
V i(5), j(3), k(7);
if (k - 1 < i + j) { ... }

这个例子与上一个例子之间不应该有任何区别。相反,我们得到了一个编译错误:最后一行没有有效的operator<可以使用。这里的问题是i + j表达式使用了来自Addable适配器的operator+(),而这个操作符返回的是类型为Addable<Value<int>>的对象。比较操作符期望的是类型Ordered<Addable<Value<int>>>,并且不会接受“部分”类型(从基类到派生类的隐式转换不存在)。令人不满意的解决方案是要求Addable始终是顶层装饰器。这不仅感觉不正确,而且也没有带我们走得很远:我们接下来想要的装饰器是Multipliable,它也会遇到同样的问题。当某物既是Addable又是Multipliable时,我们不能让两者都位于顶层。

注意,我们比较操作符返回bool时没有遇到任何问题,但一旦我们必须返回装饰后的类型本身,这正是operator+()所做的,组合性就会崩溃。为了解决这个问题,每个返回装饰后类型的操作符都必须返回原始(最外层)类型。例如,如果我们的值类型是Ordered<Addable<Value<int>>>,两个值相加的结果应该具有相同的类型。当然,问题是operator+()是由Addable装饰器提供的,它只知道Addable及其基类。我们需要在层次结构中添加一个中间类(Addable<...>),以返回其派生类型(Ordered<Addable<...>>)的对象。这是一个非常常见的设计问题,并且有一个模式:Curiously Recurring Template Pattern,或 CRTP(参见同名的第八章**,Curiously Recurring Template Pattern)。将此模式应用于我们的装饰器需要一些递归思考。我们将介绍两个主要思想,然后我们只需通过一个相当大的代码示例。

首先,每个装饰器都将有两个模板参数。第一个与之前相同:它是链中的下一个装饰器,或者在链的末尾是Value<int>(当然,这个模式不仅限于int,但我们通过在整个示例中保持相同的基类型来简化示例)。第二个参数将是最外层类型;我们将称之为“最终值类型”。因此,我们所有的装饰器都将这样声明:

template <typename V, typename FV> class Ordered : ...

但在我们的代码中,我们仍然想要写

using V = Ordered<Addable<Value<int>>>;

这意味着我们需要为第二个模板参数提供一个默认值。这个值可以是我们在装饰器中其他地方不会使用的任何类型;void将非常合适。我们还需要为这个默认类型提供一个部分模板特化,因为如果最终值类型没有明确指定,我们必须以某种方式确定它:

template <typename V, typename FV = void> class Ordered;
template <typename V> class Ordered<V, void>;

现在,我们将逐步分析我们的“嵌套”类型 Ordered<Addable<Value< int>>>。在最外层,我们可以将其视为 Ordered<T>,其中 TAddable<Value<int>>。由于我们没有指定 Ordered 模板的第二个类型参数 FV,我们将得到默认值 void,并且模板实例化 Ordered<T> 将使用 Ordered 模板的局部特化。即使我们没有指定“最终值类型” FV,我们也知道那是什么:它就是 Ordered<T> 本身。

现在我们需要确定要继承的基类。由于每个装饰器都从它装饰的类型继承,它应该是 T,即 Addable<U>(其中 UValue<int>)。但这不会起作用:我们需要将正确的最终值类型传递给 Addable。因此,我们应该从 Addable<U, FV> 继承,其中 FV 是最终值类型 Ordered<T>。不幸的是,我们没有在代码中写出 Addable<U, FV>:我们有 Addable<U>。我们需要做的是以某种方式找出由相同的模板 Addable 但具有不同的第二个类型参数(Ordered<T> 而不是默认的 void)生成的类型。

这是在 C++ 模板中非常常见的问题,并且有一个同样常见的解决方案:模板重新绑定。我们所有的装饰器模板都需要定义以下模板别名:

template <typename V, typename FV = void>
class Ordered : public ... some base class ... {
  public:
  template <typename FV1> using rebind = Ordered<V, FV1>;
};

现在,给定类型 T,它是装饰器模板之一的一个实例化,我们可以找出由相同的模板但具有不同的第二个模板参数 FV 产生的类型:它是 T::template rebind<FV>。这就是我们的 Ordered<V> 需要继承以传递正确的最终值类型给下一个装饰器的:

// Example 25
template <typename V, typename FV = void>
class Ordered : public V::template rebind<FV> { ... };

这个类模板表明,给定类型 Ordered<T, FV>,我们将从重新绑定到相同最终值类型 FV 的类型 T 继承,并忽略 T 的第二个模板参数。这个例外是最外层的类型,其中模板参数 FVvoid,但我们知道最终的值类型应该是什么,因此我们可以重新绑定到那个类型:

// Example 25
template <typename V> class Ordered<V, void> :
  public V::template rebind<Ordered<V>> { ... };

注意语法,使用关键字 template:一些编译器将接受 V:: rebind<Ordered<V>>,但这是不正确的,标准要求这种确切的语法。

现在我们可以把所有东西放在一起。在装饰器链中间的通用情况下,我们必须将最终值类型传递给基类:

// Example 25
template <typename V, typename FV = void>
class Ordered : public V::template rebind<FV> {
  using base_t = typename V::template rebind<FV>;
  public:
  using base_t::base_t;
  using base_t::operator=;
  template <typename FV1> using rebind = Ordered<V, FV1>;
  using value_type = typename base_t::value_type;
  using basic_type = typename value_type::basic_type;
  explicit Ordered(value_type v) : base_t(v) {}
  friend bool operator<(FV lhs, FV rhs) {
    return lhs.val_ < rhs.val_;
  }
  ... the rest of the operators ...
};

为了方便起见,引入了类型别名 base_t,这使得编写使用语句变得更容易。请注意,在依赖于模板参数的任何类型之前,我们需要使用 typename 关键字;我们不需要这个关键字来指定基类,因为基类始终是一个类型,所以编写 typename 将是多余的。

最外层类型的特殊情况,其中最终值类型未指定并默认为 void,非常相似:

// Example 25
template <typename V> class Ordered<V, void>
  : public V::template rebind<Ordered<V>> {
  using base_t = typename V::template rebind<Ordered>;
  public:
  using base_t::base_t;
  using base_t::operator=;
  template <typename FV1> using rebind = Ordered<V, FV1>;
  using value_type = typename base_t::value_type;
  using basic_type = typename value_type::basic_type;
  explicit Ordered(value_type v) : base_t(v) {}
  friend bool operator<(Ordered lhs, Ordered rhs) {
    return lhs.val_ < rhs.val_;
  }
  ... the rest of the operators ...
};

特化与一般情况有两种不同之处。除了基类之外,操作符的参数不能是 FV 类型,因为它代表 void。相反,我们必须使用由模板生成的类的类型,在模板定义内部可以简单地称为 Ordered(当在类中使用时,模板的名称指的是特定的实例化 - 你不需要重复模板参数)。

对于那些操作符返回值的装饰器,我们需要确保始终使用正确的最终值类型来指定返回类型。在一般情况下,这是第二个模板参数 FV

// Example 25
template <typename V, typename FV = void> class Addable :
  public V::template rebind<FV> {
  friend FV operator+(FV lhs, FV rhs) {
    return FV(lhs.val_ + rhs.val_);
  }
  ...
};

在最外层装饰器的特化中,最终值类型是装饰器本身:

// Example 25
template <typename V> class Addable<V, void> :
  public V::template rebind<FV> {
  friend Addable operator+(Addable lhs,Addable rhs) {
    return Addable(lhs.val_ + rhs.val_);
  }
  ...
};

我们必须将此技术应用于每个装饰器模板。现在我们可以以任何顺序组合装饰器,并使用任何可用操作的子集定义值类型:

// Example 25
using V = Comparable<Ordered<Addable<Value<int>>>>;
// Addable<Ordered<Comparable<Value<int>>>> also OK
V i, j(5), k(3);
i = j; j = 1;
i == j;         // OK – Comparable
i > j;        // OK – Ordered
i + j == 7 – k;    // OK – Comparable and Addable
i*j;             // Not Multipliable – does not compile

到目前为止,我们所有的装饰器都向类中添加了成员或非成员操作符。我们也可以添加成员函数甚至构造函数。后者在需要添加转换时很有用。例如,我们可以添加一个从底层类型(如所写,Value<T> 不能隐式地从 T 构造)的隐式转换。转换装饰器遵循所有其他装饰器的相同模式,但添加了一个隐式转换构造函数:

// Example 25
template <typename V, typename FV = void>
class ImplicitFrom : public V::template rebind<FV> {
  ...
  explicit ImplicitFrom(value_type v) : base_t(v) {}
  ImplicitFrom(basic_type rhs) : base_t(rhs) {}
};
template <typename V> class ImplicitFrom<V, void> :
  public V::template rebind<ImplicitFrom<V>> {
  ...
  explicit ImplicitFrom(value_type v) : base_t(v) {}
  ImplicitFrom(basic_type rhs) : base_t(rhs) {}
};

现在我们可以使用隐式转换到我们的值类型,例如,在调用函数时:

using V = ImplicitFrom<Ordered<Addable<Value<int>>>>;
void f(V v);
f(3);

如果你想要一个到底层类型的隐式转换,你可以使用一个非常相似的适配器,但不是构造函数,而是添加了转换操作符:

// Example 25
template <typename V, typename FV = void>
class ImplicitTo : public V::template rebind<FV> {
  ...
  explicit ImplicitTo(value_type v) : base_t(v) {}
  operator basic_type(){ return this->val_; }
  operator const basic_type() const { return this->val_; }
};
template <typename V> class ImplicitTo<V, void> :
  public V::template rebind<ImplicitTo<V>> {
  ...
  explicit ImplicitTo(value_type v) : base_t(v) {}
  operator basic_type(){ return this->val_; }
  operator const basic_type() const { return this->val_; }
};

这允许我们进行相反方向的转换:

using V = ImplicitTo<Ordered<Addable<Value<int>>>>;
void f(int i);
V i(3);
f(i);

这种设计完成了工作,没有特别的问题,除了编写适配器的复杂性:CRTP 的递归应用往往会让你陷入无限递归,直到你习惯了这种类型的模板适配器的思考方式。另一种选择是策略基于的值类型。

策略解决方案

我们现在将研究一种与我们在第十五章**,基于策略的设计相比略有不同的形式。它并不像后者那样通用,但当它起作用时,它可以提供策略的所有优势,特别是可组合性,而没有一些问题。问题仍然是相同的:创建一个具有我们可以控制的操作集的自定义值类型。这个问题可以通过标准的基于策略的方法来解决:

template <typename T, typename AdditionPolicy,
                      typename ComparisonPolicy,
                      typename OrderPolicy,
                      typename AssignmentPolicy, ... >
class Value { ... };

这种实现遇到了基于策略设计的所有缺点——策略列表很长,所有策略都必须明确列出,而且没有好的默认值;策略是位置相关的,因此类型声明需要仔细计算逗号的数量,并且随着新策略的添加,策略的任何有意义顺序都消失了。请注意,我们没有提到不同策略集创建不同类型的问题——在这种情况下,这并不是一个缺点,而是设计意图。如果我们想要一个支持加法且类似但不支持加法的类型,这些必须是不相同的类型。

理想情况下,我们只想列出我们想要我们的值拥有的策略——我想有一个基于整数的值类型,支持加法、乘法和赋值,但没有其他功能。毕竟,我们使用适配器模式做到了这一点,所以我们不会满足于任何更少的东西。实际上,有一种方法可以实现这一点。

首先,让我们思考一下这样的策略可能是什么样子。例如,允许加法的策略应该将 operator+() 注入类的公共接口(也许还可以注入 operator+=())。使值可赋值的策略应该注入 operator=()。我们已经看到了足够多的此类策略,知道它们是如何实现的——它们必须是基类,公开继承,并且需要知道派生类的类型并将其转换为该类型,因此它们必须使用 CRTP:

template <
  typename T,    // The base type (like int)
  typename V>    // The derived class
struct Incrementable {
  V operator++() {
    V& v = static_cast<V&>(*this);
    ++v.val_;     // The value inside the derived class
    return v;
  }
};

现在,我们需要考虑这些策略在主模板中的使用。首先,我们希望支持任何顺序的未知数量策略。这让我想起了变长模板。然而,为了使用 CRTP,模板参数必须是模板本身。然后,我们希望从每个这些模板的实例化中继承,无论有多少个。我们需要的是一个具有模板模板参数包的变长模板:

// Example 26
template <typename T,
          template <typename, typename> class ... Policies>
class Value :
  public Policies<T, Value<T, Policies ... >> ...
{ ... };

前面的声明引入了一个名为 Value 的类模板,它至少有一个参数是类型,加上零个或多个模板策略,这些策略本身有两个类型参数(在 C++17 中,我们也可以用 typename ... Policies 代替 class ... Policies)。Value 类使用类型 T 和自身实例化这些模板,并从它们中公开继承。

Value 类模板应该包含我们希望所有值类型共有的接口。其余的将来自策略。让我们使值默认可复制、可赋值和可打印:

// Example 26
template <typename T,
          template <typename, typename> class ... Policies>
class Value :
  public Policies<T, Value<T, Policies ... >> ...
{
  public:
  using base_type = T;
  explicit Value() = default;
  explicit Value(T v) : val_(v) {}
  Value(const Value& rhs) : val_(rhs.val_) {}
  Value& operator=(Value rhs) {
    val_ = rhs.val_;
    return *this;
  }
  Value& operator=(T rhs) { val_ = rhs; return *this; }
  friend std::ostream&
  operator<<(std::ostream& out, Value x) {
    out << x.val_; return out;
  }
  friend std::istream&
    operator>>(std::istream& in, Value& x) {
    in >> x.val_; return in;
  }
  private:
  T val_ {};
};

再次,我们使用来自第十二章**,友元工厂友元因子来生成流操作符。

在我们能够尽情实现所有策略之前,还有一个障碍需要克服。val_值在Value类中是私有的,我们喜欢这种方式。然而,策略需要访问和修改它。在过去,我们通过将需要此类访问的每个策略都变成友元来解决此问题。这次,我们甚至不知道可能存在的策略名称。在处理完参数包扩展的声明作为一组基类之后,读者可能会合理地期望我们变魔术般地宣布与整个参数包的友谊。不幸的是,标准并没有提供这样的方法。我们能提出的最佳解决方案是提供一组只有策略可以调用的访问器函数,但没有好的方法来强制执行这一点(例如,一个名为policy_accessor_do_not_call()的名称可能有助于暗示用户代码应远离它,但程序员的创造力是无限的,这样的提示并不总是得到普遍尊重):

// Example 26
template <typename T,
          template <typename, typename> class ... Policies>
class Value :
  public Policies<T, Value<T, Policies ... >> ...
{
  public:
  ...
  T get() const { return val_; }
  T& get() { return val_; }
  private:
  T val_ {};
};

要创建一个具有受限操作集的值类型,我们必须使用我们想要的策略列表实例化此模板,没有其他内容:

// Example 26
using V = Value<int, Addable, Incrementable>;
V v1(0), v2(1);
v1++; // Incrementable - OK
V v3(v1 + v2); // Addable - OK
v3 *= 2; // No multiplication policies - won't compile

我们可以实现的策略的数量和类型主要受当前需求(或想象力)的限制,但以下是一些示例,展示了如何向类添加不同类型的操作。

首先,我们可以实现前面提到的Incrementable策略,它提供了两个++运算符,后缀和前缀:

// Example 26
template <typename T, typename V> struct Incrementable {
  V operator++() {
    V& v = static_cast<V&>(*this);
    ++(v.get());
    return v;
  }
  V operator++(int) {
    V& v = static_cast<V&>(*this);
    return V(v.get()++);
  }
};

我们可以为--运算符创建一个单独的Decrementable策略,或者如果对我们的类型有意义,可以有一个策略同时处理两者。此外,如果我们想以除了 1 以外的值进行增量,那么我们还需要+=运算符:

// Example 26
template <typename T, typename V> struct Incrementable {
  V& operator+=(V val) {
    V& v = static_cast<V&>(*this);
    v.get() += val.get();
    return v;
  }
  V& operator+=(T val) {
    V& v = static_cast<V&>(*this);
    v.get() += val;
    return v;
  }
};

上述策略提供了operator+=()的两个版本 - 一个接受相同Value类型的增量,另一个接受基础类型T。这并不是一个要求,我们可以根据需要实现其他类型的值增量。我们甚至可以有多种增量策略版本,只要只使用其中一种(编译器会告诉我们是否引入了不兼容的重载)。

我们可以用类似的方式添加*=/=运算符。添加如比较运算符或加法和乘法这样的二元运算符略有不同 - 这些运算符必须是非成员函数,以便允许对第一个参数进行类型转换。同样,友元工厂模式在这里很有用。让我们从比较运算符开始:

// Example 26
template <typename T, typename V> struct ComparableSelf {
  friend bool operator==(V lhs, V rhs) {
    return lhs.get() == rhs.get();
  }
  friend bool operator!=(V lhs, V rhs) {
    return lhs.get() != rhs.get();
  }
};

当实例化时,此模板生成两个非成员非模板函数,即特定Value类变量的比较运算符,即实例化的那个。我们可能还希望允许与基础类型(如int)进行比较:

template <typename T, typename V> struct ComparableValue {
  friend bool operator==(V lhs, T rhs) {
    return lhs.get() == rhs;
  }
  friend bool operator==(T lhs, V rhs) {
    return lhs == rhs.get();
  }
  friend bool operator!=(V lhs, T rhs) {
    return lhs.get() != rhs;
  }
  friend bool operator!=(T lhs, V rhs) {
    return lhs != rhs.get();
  }
};

更多的时候,我们可能同时想要这两种类型的比较。我们可以简单地将它们都放入同一个策略中,不必担心分离它们,或者我们可以从已有的两个策略中创建一个组合策略:

// Example 26
template <typename T, typename V>
struct Comparable : public ComparableSelf<T, V>,
                    public ComparableValue<T, V> {};

在上一节中,我们从一开始就将所有比较组合在一个适配器中。这里,我们使用一种稍微不同的方法,只是为了说明使用策略或适配器(两种解决方案都提供相同的选项)控制类接口的不同选项。加法和乘法运算符是通过类似策略创建的。它们也是非模板非成员函数的朋友。唯一的区别是返回值类型——它们返回对象本身,例如:

// Example 26
template <typename T, typename V> struct Addable {
  friend V operator+(V lhs, V rhs) {
    return V(lhs.get() + rhs.get());
  }
  friend V operator+(V lhs, T rhs) {
    return V(lhs.get() + rhs);
  }
  friend V operator+(T lhs, V rhs) {
    return V(lhs + rhs.get());
  }
};

如您所见,我们在编写适配器时遇到的返回“最终值类型”的问题在这里不存在:传递给每个策略的派生类本身就是值类型。

向基本类型转换的显式或隐式转换运算符可以同样轻松地添加:

// Example 26
template <typename T, typename V>
struct ExplicitConvertible {
  explicit operator T() {
    return static_cast<V*>(this)->get();
  }
  explicit operator const T() const {
    return static_cast<const V*>(this)->get();
  }
};

这种方法乍一看似乎解决了传统基于策略的类型的大部分缺点。策略的顺序并不重要——我们只需指定我们想要的策略,不必担心其他的——有什么不喜欢的呢?然而,有两个基本限制。首先,基于策略的类不能通过名称引用任何策略。不再有DeletionPolicyAdditionPolicy的位置。没有约定强制的策略接口,例如删除策略必须是可调用的。将策略绑定到单个类型的整个过程是隐式的;它只是接口的叠加。

因此,我们使用这些策略所能做的事情有限——我们可以注入公共成员函数和非成员函数——甚至可以添加私有数据成员——但我们不能为由主要基于策略的类确定和限制的行为方面提供实现。因此,这不是策略模式的实现——我们随意组合接口,以及实现,而不是定制特定的算法(这就是为什么我们将这种替代基于策略的设计模式的演示推迟到本章)。

第二个,与之紧密相关的限制是,没有默认策略。缺失的策略就是缺失。它们的位置上什么也没有。默认行为总是没有任何行为。在传统的基于策略的设计中,每个策略槽都必须被填充。如果有合理的默认值,可以指定它,然后除非用户覆盖它(例如,默认删除策略使用operator delete),否则这就是策略。如果没有默认值,编译器不会让我们省略策略——我们必须为模板提供一个参数。

这些限制的后果比你最初想象的要深远。例如,可能会诱使你使用我们在第十五章**,基于策略的设计中看到的enable_if技术,而不是通过基类注入公共成员函数。然后,我们可以有一个默认行为,如果没有其他选项,则启用。但在这里行不通。我们当然可以创建一个针对enable_if使用的策略:

template <typename T, typename V> struct Addable {
  constexpr bool adding_enabled = true;
};

但使用它是没有可能的——我们无法使用AdditionPolicy::adding_enabled,因为没有AdditionPolicy——所有策略槽位都是未命名的。另一种选择是使用Value::adding_enabled——加法策略是Value的基类,因此,其所有数据成员在Value类中都是可见的。唯一的问题是这不起作用——在编译器评估此表达式(在定义Value类型作为 CRTP 策略的模板参数)时,Value是一个不完整类型,我们无法访问其数据成员。如果我们知道策略名称,我们可以评估policy_name::adding_enabled。但正是这种知识,我们为了不指定策略的完整列表而放弃了。

虽然严格来说,这不是策略模式的应用,但当策略主要用于控制一组支持的运算时,我们刚刚学到的基于策略的设计的替代方案可能很有吸引力。在讨论基于策略的设计指南时,我们提到,仅仅为了提供受限接口的额外安全性而使用策略槽位通常是不值得的。对于这种情况,这种替代方法应该被记住。

总体来看,我们可以看到这两种模式都有其优点和缺点:适配器依赖于更复杂的 CRTP 形式,而我们刚刚看到的“无槽位”策略要求我们做出妥协(我们必须使用类似我们的get()方法之类的机制将值暴露给策略)。

这就是我们作为软件工程师必须解决的问题的本质——一旦问题变得足够复杂,它就可以被解决,通常需要使用多种设计,每种方法都有其自身的优点和局限性。我们无法比较用于创建两种非常不同的设计以解决相同需求的每种模式,至少在有限大小的书中是不可能的。通过展示和分析这些示例,我们希望为读者提供理解和洞察,这将有助于评估类似复杂和多样的设计选项,以解决现实生活中的问题。

摘要

我们研究了两种最常用的模式——不仅限于 C++,而且在软件设计的一般领域。适配器模式提供了一种解决广泛设计挑战的方法。这些挑战只有一个最一般的共同属性——给定一个类、一个函数或一个提供特定功能的软件组件,我们必须解决一个特定问题,并为一个不同、相关的问题构建解决方案。在许多方面,装饰器模式是适配器模式的一个子集,它限制于通过添加新行为来增强类或函数的现有接口。

我们已经看到,适配器和装饰器执行的接口转换和修改可以应用于程序生命周期的每个阶段的接口——尽管最常见的用途是修改运行时接口,以便类可以在不同的上下文中使用,但也有编译时适配器用于泛型代码,允许我们将类用作构建块或更大、更复杂类的组件。

适配器模式可以应用于许多非常不同的设计挑战。这些挑战的多样性和模式的普遍性通常意味着可能存在另一种解决方案。这些替代方案通常采用完全不同的方法——一个完全不同的设计模式——但最终提供类似的行为。区别在于设计选择带来的权衡、附加条件和限制,以及以不同方式扩展解决方案的可能性。为此,本章提供了对两种非常不同的设计方法进行比较,包括对两种选项的优缺点评估。

接下来的,倒数第二个章节,介绍了一个庞大、复杂且具有多个相互作用的组件的模式——这是一个适合作为我们的压轴大戏——访问者模式。

问题

  1. 什么是适配器模式?

  2. 装饰器模式是什么,它与适配器模式有何不同?

  3. 在 C++ 中,经典的面向对象(OOP)装饰器模式通常不推荐使用。为什么?

  4. 在什么情况下,C++ 类装饰器应该使用继承或组合?

  5. 在什么情况下,C++ 类适配器应该使用继承或组合?

  6. C++ 提供了一个通用的函数适配器用于柯里化函数参数,std::bind。它的局限性是什么?

  7. C++11 提供了模板别名,可以用作适配器。它们的局限性是什么?

  8. 适配器和策略模式都可以用来添加或修改类的公共接口。给出一些选择其中一个而不是另一个的理由。

第十七章:访问者模式与多态

访问者模式是另一种经典面向对象设计模式,它是 Erich Gamma、Richard Helm、Ralph Johnson 和 John Vlissides 在《设计模式——可重用面向对象软件元素》一书中介绍的 23 个模式之一。在面向对象编程的黄金时代,它是较为流行的模式之一,因为它可以使大型类层次结构更易于维护。近年来,由于大型复杂层次结构变得不那么常见,访问者模式在 C++中的使用有所下降,因为实现访问者模式相对复杂。泛型编程——特别是 C++11 和 C++14 中添加的语言特性——使得实现和维护访问者类变得更加容易,而旧模式的新应用也重新点燃了对它的部分兴趣。

本章将涵盖以下主题:

  • 访问者模式

  • C++中访问者模式的实现

  • 使用泛型编程简化访问者类

  • 使用访问者处理组合对象

  • 编译时访问者和反射

技术要求

本章的示例代码可以在以下 GitHub 链接中找到:github.com/PacktPublishing/Hands-On-Design-Patterns-with-CPP-Second-Edition/tree/main/Chapter17

访问者模式

访问者模式因其复杂性而与其他经典面向对象模式脱颖而出。一方面,访问者模式的基本结构相当复杂,涉及许多必须协同工作的类,以形成该模式。另一方面,即使是访问者模式的描述也很复杂——有几种非常不同的方式来描述同一个模式。许多模式可以应用于多种类型的问题,但访问者模式超越了这一点——有几种描述其功能的语言,讨论看似无关的问题,总体上没有共同之处。然而,它们都描述了同一个模式。让我们首先考察访问者模式的众多面貌,然后继续探讨其实现。

什么是访问者模式?

访问者模式是一种将算法与对象结构分离的模式,这是该算法的数据。使用访问者模式,我们可以在不修改类本身的情况下向类层次结构添加新的操作。访问者模式的使用遵循软件设计的开/闭原则 - 一个类(或另一个代码单元,如模块)应该对修改封闭;一旦类向其客户端提供了一个接口,客户端就会依赖于这个接口及其提供的功能。这个接口应该保持稳定;不应该需要修改类来维护软件并继续其开发。同时,一个类应该对扩展开放 - 可以添加新功能以满足新的需求。与所有非常通用的原则一样,可以找到一个反例,其中严格应用规则比违反规则更糟。同样,与所有通用原则一样,其价值不在于成为每个情况的绝对规则,而在于一个默认规则,一个在没有充分理由不遵循的情况下应该遵循的指南;现实是,大多数日常工作的不特殊,如果遵循这个原则,结果会更好。

从这个角度来看,访问者模式允许我们在不修改类的情况下向类或整个类层次结构添加功能。当处理公共 API 时,这个特性尤其有用 - API 的用户可以扩展它以添加额外的操作,而无需修改源代码。

描述访问者模式的一个非常不同、更技术的方法是说它实现了双分派。这需要一些解释。让我们从常规的虚函数调用开始:

class Base {
  virtual void f() = 0;
};
class Derived1 : public Base {
  void f() override;
};
class Derived2 : public Base {
  void f() override;
};

如果我们通过指向b基类的指针调用b->f()虚函数,调用将根据对象的实际类型分发到Derived1::f()Derived2::f()。这是单分派 - 实际调用的函数由一个单一因素决定,即对象类型。

现在让我们假设函数f()还接受一个指向基类的指针作为参数:

class Base {
  virtual void f(Base* p) = 0;
};
class Derived1 : public Base {
  void f(Base* p) override;
};
class Derived2 : public Base {
  void f(Base* p) override;
};

*p对象的实际类型也是派生类之一。现在,b->f(p)调用可以有四种不同的版本;*b*p对象可以是两种派生类型中的任何一种。在每种情况下都希望实现不同的行为是合理的。这将实现双分派 - 最终运行的代码由两个独立因素决定。虚函数不提供直接实现双分派的方法,但访问者模式正是如此。

以这种方式呈现时,并不明显地看出双分派访问者模式与操作添加访问者模式有什么关系。然而,它们实际上是同一个模式,这两个要求实际上是相同的。这里有另一种看待它的方法,可能有助于理解——如果我们想向层次结构中的所有类添加一个操作,那么这相当于添加一个虚函数,因此我们有一个因素控制着每个调用的最终处理,即对象类型。但是,如果我们能够有效地添加虚函数,我们可以添加多个——每个操作一个。操作类型是控制分派的第二个因素,类似于我们之前示例中的函数参数。因此,操作添加访问者能够提供双分派。或者,如果我们有实现双分派的方法,我们可以做访问者模式所能做的——为每个我们想要支持的运算添加一个虚拟函数。

现在我们已经知道了访问者模式的作用,合理的疑问是,为什么我们要这样做?双分派有什么用?当我们可以直接添加一个真正的虚函数时,为什么我们还想另一种方式给一个类添加一个虚拟函数的替代品?不考虑公共 API 不可用源代码的情况,为什么我们想要在外部添加一个操作而不是在每一个类中实现它?考虑序列化/反序列化问题。序列化是一个将对象转换为可以存储或传输的格式的操作(例如,写入文件)。反序列化是逆操作——它从其序列化和存储的图像中构建一个新的对象。为了以简单、面向对象的方式支持序列化和反序列化,层次结构中的每个类都需要两个方法,每个操作一个。但如果存在多种存储对象的方式呢?例如,我们可能需要将对象写入内存缓冲区,以便在网络中传输并在另一台机器上反序列化。或者,我们可能需要将对象保存到磁盘,或者我们可能需要将容器中的所有对象转换为 JSON 等标记格式。直接的方法会要求我们为每种序列化机制给每个对象添加序列化和反序列化方法。如果需要新的不同的序列化方法,我们必须遍历整个类层次结构并添加对其的支持。

另一种选择是在一个单独的函数中实现整个序列化/反序列化操作,该函数可以处理所有类。生成的代码是一个循环,它遍历所有对象,并在其中包含一个大的决策树。代码必须查询每个对象并确定其类型,例如,使用动态类型转换。当向层次结构中添加新类时,必须更新所有序列化和反序列化实现以处理新对象。

这两种实现对于大型层次结构来说都难以维护。访问者模式提供了一个解决方案 - 它允许我们在类外部实现一个新操作 - 在我们的情况下,是序列化 - 而不修改它们,但也没有循环中巨大的决策树的缺点(注意,访问者模式不是序列化问题的唯一解决方案;C++还提供了其他可能的方法,但我们在本章中专注于访问者模式)。

正如我们一开始所说的,访问者模式是一个复杂的模式,具有复杂的描述。我们可以通过研究具体示例来最好地处理这个困难的模式,从下一节中的非常简单的示例开始。

C++中的基本访问者

真正理解访问者模式如何操作的唯一方法是通过一个示例来工作。让我们从一个非常简单的示例开始。首先,我们需要一个类层次结构:

// Example 01
class Pet {
  public:
  virtual ~Pet() {}
  Pet(std::string_view color) : color_(color) {}
  const std::string& color() const { return color_; }
  private:
  const std::string color_;
};
class Cat : public Pet {
  public:
  Cat(std::string_view color) : Pet(color) {}
};
class Dog : public Pet {
  public:
  Dog(std::string_view color) : Pet(color) {}
};

在这个层次结构中,我们有Pet基类和几个派生类,用于不同的宠物动物。现在我们想要给我们的类添加一些操作,比如“喂宠物”或“和宠物玩耍”。实现取决于宠物的类型,所以如果直接添加到每个类中,这些将必须是虚拟函数。对于这样一个简单的类层次结构来说,这不是问题,但我们预计未来需要维护一个更大的系统,其中修改层次结构中的每个类将非常昂贵且耗时。我们需要一种更好的方法,我们首先创建一个新的类,PetVisitor,它将被应用于每个Pet对象(访问它)并执行我们需要的操作。首先,我们需要声明这个类:

// Example 01
class Cat;
class Dog;
class PetVisitor {
  public:
  virtual void visit(Cat* c) = 0;
  virtual void visit(Dog* d) = 0;
};

我们必须提前声明Pet层次结构类,因为PetVisitor必须在具体的Pet类之前声明。现在我们需要使Pet层次结构可访问,这意味着我们确实需要修改它,但只需修改一次,无论我们以后想添加多少操作。我们需要为每个可访问的类添加一个虚拟函数以接受访问者模式:

// Example 01
class Pet {
  public:
  virtual void accept(PetVisitor& v) = 0;
  ...
};
class Cat : public Pet {
  public:
  void accept(PetVisitor& v) override { v.visit(this); }
  ...
};
class Dog : public Pet {
  public:
  void accept(PetVisitor& v) override { v.visit(this); }
  ...
};

现在我们的Pet层次结构是可访问的,并且我们有一个抽象的PetVisitor类。一切准备就绪,为我们的类实现新的操作(注意,到目前为止我们所做的一切都不依赖于我们将要添加的操作;我们已经创建了必须实现一次的访问基础设施)。操作是通过实现从PetVisitor派生的具体访问者类来添加的:

// Example 01
class FeedingVisitor : public PetVisitor {
  public:
  void visit(Cat* c) override {
    std::cout << "Feed tuna to the " << c->color()
              << " cat" << std::endl;
  }
  void visit(Dog* d) override {
    std::cout << "Feed steak to the " << d->color()
              << " dog" << std::endl;
  }
};
class PlayingVisitor : public PetVisitor {
  public:
  void visit(Cat* c) override {
    std::cout << "Play with a feather with the "
              << c->color() << " cat" << std::endl;
  }
  void visit(Dog* d) override {
    std::cout << "Play fetch with the " << d->color()
              << " dog" << std::endl;
  }
};

假设访问者基础设施已经集成到我们的类层次结构中,我们可以通过实现一个派生访问者类来实施新的操作,并覆盖所有visit()的虚拟函数。要从我们的类层次结构中的对象上调用新的操作,我们需要创建一个访问者并访问该对象:

// Example 01
Cat c("orange");
FeedingVisitor fv;
c.accept(fv); // Feed tuna to the orange cat

在调用访问者的最新示例中,有一个重要的方面过于简单——在调用访问者的那一刻,我们知道我们正在访问的对象的确切类型。为了使示例更真实,我们必须以多态的方式访问对象:

// Example 02
std::unique_ptr<Pet> p(new Cat("orange"));
...
FeedingVisitor fv;
p->accept(fv);

在编译时,我们并不知道p所指向的对象的实际类型;在访问者被接受的那一刻,p可能来自不同的来源。虽然不太常见,访问者也可以被多态地使用:

// Example 03
std::unique_ptr<Pet> p(new Cat("orange"));
std::unique_ptr<PetVisitor> v(new FeedingVisitor);
...
p->accept(*v);

当以这种方式编写时,代码突出了访问者模式的二分派特性——对accept()的调用最终会根据两个因素分派到特定的visit()函数——一个是可访问对象*p的类型,另一个是访问者*v的类型。如果我们想强调访问者模式的这一特性,我们可以使用辅助函数来调用访问者:

// Example 03
void dispatch(Pet& p, PetVisitor& v) { p.accept(v); }
std::unique_ptr<Pet> p = ...;
std::unique_ptr<PetVisitor> v = ...;
dispatch(*p, *v); // Double dispatch

现在我们有了经典面向对象访问者模式在 C++中的最简单示例。尽管它很简单,但它包含了所有必要的组件;对于大型现实生活类层次结构和多个访问者操作,代码会更多,但并没有新的代码类型,只是更多我们已经做过的东西。这个示例展示了访问者模式的两个方面;一方面,如果我们关注软件的功能,现在有了访问者基础设施,我们可以添加新的操作而无需对类本身进行任何更改。另一方面,如果我们只看操作调用的方式,即accept()调用,我们已经实现了双分派。

我们可以立即看到访问者模式的吸引力,我们可以添加任意数量的新操作,而无需修改层次结构中的每个类。如果向Pet层次结构中添加了一个新类,不可能忘记处理它——如果我们对访问者不做任何操作,新类上的accept()调用将无法编译,因为没有相应的visit()函数可以调用。一旦我们向PetVisitor基类添加了新的visit()重载,我们也必须将其添加到所有派生类中;否则,编译器会告诉我们有一个没有重载的纯虚函数。后者也是访问者模式的主要缺点之一——如果向层次结构中添加了一个新类,所有访问者都必须更新,无论新类实际上是否需要支持这些操作。因此,有时建议只在相对稳定的层次结构上使用访问者,这些层次结构不经常添加新类。还有一个替代的访问者实现,它在某种程度上减轻了这个问题;我们将在本章后面看到它。

本节中的示例非常简单——我们的新操作不接收任何参数也不返回任何结果。我们现在将考虑这些限制是否重要,以及它们如何被消除。

访问者泛化和限制

在上一节中,我们的第一个访问者使我们能够有效地为层次结构中的每个类添加一个虚函数。这个虚函数没有参数也没有返回值。前者很容易扩展;我们的visit()函数为什么不能有参数,完全没有理由。让我们通过允许我们的宠物拥有小猫和小狗来扩展我们的类层次结构。仅使用访问者模式无法完成这个扩展——我们需要添加不仅新的操作,还有新的数据成员。访问者模式可以用于前者,但后者需要代码更改。如果我们有先见之明提供适当的策略,基于策略的设计可以让我们将这个更改因式分解为现有策略的新实现。我们确实在本书中有一个关于第十五章基于策略的设计的独立章节,所以在这里我们将避免混合多个模式,只是添加新的数据成员:

// Example 04
class Pet {
  public:
  ..
  void add_child(Pet* p) { children_.push_back(p); }
  virtual void accept(PetVisitor& v, Pet* p = nullptr) = 0;
  private:
  std::vector<Pet*> children_;
};

每个父Pet对象跟踪其子对象(请注意,容器是一个指针向量,而不是唯一指针向量,因此对象不拥有其子对象,只是可以访问它们)。我们还添加了新的add_child()成员函数来向向量中添加对象。我们本来可以用访问者来做这件事,但这个函数是非虚的,所以我们必须只将它添加到基类中,而不是每个派生类中——访问者在这里是不必要的。accept()函数已被修改,增加了一个额外的参数,这个参数也必须添加到所有派生类中,它只是简单地转发到visit()函数:

// Example 04
class Cat : public Pet {
  public:
  Cat(std::string_view color) : Pet(color) {}
  void accept(PetVisitor& v, Pet* p = nullptr) override {
    v.visit(this, p);
  }
};
class Dog : public Pet {
  public:
  Dog(std::string_view color) : Pet(color) {}
  void accept(PetVisitor& v, Pet* p = nullptr) override {
    v.visit(this, p);
  }
};

visit() 函数也必须修改以接受额外的参数,即使对于不需要它的访问者也是如此。因此,更改 accept() 函数的参数是一个昂贵的全局操作,如果可能的话,不应该经常进行,甚至根本不应该进行。请注意,层次结构中相同虚拟函数的所有覆盖版本已经必须具有相同的参数。访问者模式将这种限制扩展到使用相同的基本访问者对象添加的所有操作。对于这个问题的一个常见解决方案是使用聚合(将多个参数组合在一起的类或结构)来传递参数。visit() 函数被声明为接受对基本聚合类指针的引用,而每个访问者都接收对派生类的指针,该派生类可能具有额外的字段,并且根据需要使用它们。

现在,我们的额外参数将通过虚拟函数调用的链路传递给访问者,在那里我们可以利用它。让我们创建一个记录宠物出生并作为子对象添加新宠物对象到其父对象中的访问者:

// Example 04
class BirthVisitor : public PetVisitor {
  public:
  void visit(Cat* c, Pet* p) override {
    assert(dynamic_cast<Cat*>(p));
    c->add_child(p);
  }
  void visit(Dog* d, Pet* p) override {
    assert(dynamic_cast<Dog*>(p));
    d->add_child(p);
  }
};

注意,如果我们想确保我们的家族树中没有生物学上的不可能性,验证必须在运行时进行——在编译时,我们不知道多态对象的实际类型。新的访问者与上一节中的访问者一样容易使用:

Pet* parent; // A cat
BirthVisitor bv;
Pet* child(new Cat("calico"));
parent->accept(bv, child);

一旦我们建立了亲子关系,我们可能想检查我们的宠物家族。这是我们想要添加的另一个操作,需要另一个访问者:

// Example 04
class FamilyTreeVisitor : public PetVisitor {
  public:
  void visit(Cat* c, Pet*) override {
    std::cout << "Kittens: ";
    for (auto k : c->children_) {
      std::cout << k->color() << " ";
    }
    std::cout << std::endl;
  }
  void visit(Dog* d, Pet*) override {
    std::cout << "Puppies: ";
    for (auto p : d->children_) {
      std::cout << p->color() << " ";
    }
    std::cout << std::endl;
  }
};

然而,我们遇到了一个小问题,因为按照目前的编写方式,代码将无法编译。原因是 FamilyTreeVisitor 类试图访问 Pet::children_ 私有数据成员。这是访问者模式的一个弱点——从我们的角度来看,访问者向类添加新操作,就像虚拟函数一样,但从编译器的角度来看,它们是完全独立的类,根本不像 Pet 类的成员函数,也没有特殊访问权限。访问者模式的通常应用需要放松封装,有两种方式——我们可以允许对数据进行公共访问(直接或通过访问器成员函数)或声明访问者类为友元(这确实需要更改源代码)。在我们的例子中,我们将遵循第二条路线:

class Pet {
  ...
  friend class FamilyTreeVisitor;
};

现在家族树访问者按预期工作:

Pet* parent; // A cat
...
amilyTreeVisitor tv;
parent->accept(tv); // Prints kitten colors

BirthVisitor 不同,FamilyTreeVisitor 不需要额外的参数。

现在我们有了使用参数执行操作的访客。那么返回值怎么办呢?技术上讲,visit()accept() 函数没有必须返回 void 的要求。它们可以返回任何其他类型。然而,它们必须返回相同类型的限制通常使得这种能力变得无用。虚函数可以有协变返回类型,其中基类虚函数返回某个类的对象,而派生类覆盖返回的对象是从该类派生出来的,但即使是这也通常过于限制。还有一个更简单、更有效的解决方案——每个访客对象的 visit() 函数可以完全访问该对象的数据成员。我们没有理由不能在访客类本身中存储返回值并在稍后访问它。这对于最常见的使用情况非常合适,即每个访客添加不同的操作,并且可能具有唯一的返回类型,但操作本身对于层次结构中的所有类通常具有相同的返回类型。例如,我们可以让我们的 FamilyTreeVisitor 计算子代总数并通过访客对象返回该值:

// Example 05
class FamilyTreeVisitor : public PetVisitor {
  public:
  FamilyTreeVisitor() : child_count_(0) {}
  void reset() { child_count_ = 0; }
  size_t child_count() const { return child_count_; }
  void visit(Cat* c, Pet*) override {
    visit_impl(c, "Kittens: ");
  }
  void visit(Dog* d, Pet*) override {
    visit_impl(d, "Puppies: ");
  }
  private:
  template <typename T>
  void visit_impl(T* t, const char* s) {
    std::cout << s;
    for (auto p : t->children_) {
      std::cout << p->color() << " ";
        ++child_count_;
      }
      std::cout << std::endl;
  }
  size_t child_count_;
};
FamilyTreeVisitor tv;
parent->accept(tv);
std::cout << tv.child_count() << " kittens total"
          << std::endl;

这种方法在多线程程序中会带来一些限制——访客现在不是线程安全的,因为多个线程不能使用同一个访客对象来访问不同的宠物对象。最常见的解决方案是每个线程使用一个访客对象,通常是在调用访客的函数栈上创建的一个局部变量。如果这不可能,还有更复杂的选项可以给访客提供一个线程(线程局部)状态,但分析这些选项超出了本书的范围。另一方面,有时我们想在多次访问中累积结果,在这种情况下,将结果存储在访客对象中的先前技术可以完美工作。此外,请注意,相同的解决方案也可以用来将参数传递到访客操作中,而不是将它们添加到 visit() 函数中;我们可以在访客对象内部存储参数,然后我们就不需要任何特殊的东西来从访客那里访问它们。当参数在每次调用访客时都不变,但可能从一个访客对象到另一个访客对象有所变化时,这种技术特别有效。

让我们暂时回顾一下FamilyTreeVisitor的实现。请注意,它遍历父对象的子对象,并依次对每个对象调用相同的操作。然而,它并没有处理子对象的子对象——我们的家谱树只有一代。访问包含其他对象的对象的这个问题非常普遍,并且相当常见。我们本章开头提到的动机示例,序列化问题,完美地展示了这种需求——每个复杂对象都是通过逐个序列化其组件来序列化的,然后它们依次以相同的方式序列化,直到我们到达内置类型,如intdouble,我们知道如何读写这些类型。下一节将更全面地处理访问复杂对象的问题。

访问复杂对象

在最后一节中,我们看到了访问者模式如何允许我们向现有层次结构中添加新操作。在一个示例中,我们访问了一个包含其他对象指针的复杂对象。访问者以有限的方式遍历这些指针。我们现在将考虑访问由其他对象组成或包含其他对象的对象的普遍问题,并在最后演示一个有效的序列化/反序列化解决方案。

访问组合对象

访问复杂对象的一般思想非常直接——在访问对象本身时,我们通常不知道如何处理每个组件或包含对象的详细信息。但是,有一种东西可以做到这一点——针对该对象类型的访问者被专门编写来处理该类,而不会处理其他任何东西。这个观察表明,正确处理组件对象的方法是简单地访问每个对象,并将问题委托给其他人(这是一种在编程和其他方面都普遍有效的技术)。

让我们先以一个简单的容器类为例来演示这个想法,比如Shelter类,它可以包含任意数量的宠物对象,代表等待领养的宠物:

// Example 06
class Shelter {
  public:
  void add(Pet* p) {
    pets_.emplace_back(p);
  }
  void accept(PetVisitor& v) {
    for (auto& p : pets_) {
      p->accept(v);
    }
  }
  private:
  std::vector<std::unique_ptr<Pet>> pets_;
};

这个类本质上是一个适配器,用于使宠物对象的向量可访问(我们已经在同名的章节中详细讨论了适配器模式)。请注意,这个类的对象确实拥有它们包含的宠物对象——当Shelter对象被销毁时,向量中的所有Pet对象也会被销毁。任何包含唯一指针的容器都是一个拥有其包含对象的容器;这就是如何在std::vector等容器中存储多态对象的方式(对于非多态对象,我们可以存储对象本身,但这种情况不适用,因为从Pet派生的对象属于不同的类型。)

与我们当前问题相关的代码当然是Shelter::accept(),它决定了Shelter对象是如何被访问的。正如你所看到的,我们没有在Shelter对象本身上调用访问者。相反,我们将访问委托给每个包含的对象。由于我们的访问者已经编写好了来处理宠物对象,所以不需要做更多的事情。当ShelterFeedingVisitor等访问者访问时,庇护所中的每只宠物都会被喂食,我们不需要编写任何特殊的代码来实现这一点。

复合对象的访问以类似的方式进行 - 如果一个对象由几个较小的对象组成,我们必须访问这些对象中的每一个。让我们考虑一个代表一个家庭及其两只宠物(狗和猫)的对象(在下面的代码中,照顾宠物的家庭成员没有被包括在内,但我们假设他们也在那里):

// Example 07
class Family {
  public:
  Family(const char* cat_color, const char* dog_color) :
  cat_(cat_color), dog_(dog_color) {}
  void accept(PetVisitor& v) {
    cat_.accept(v);
    dog_.accept(v);
  }
  private: // Other family members not shown for brevity
  Cat cat_;
  Dog dog_;
};

再次,使用来自PetVisitor层次结构的访问者访问家庭被委托,以便每个Pet对象都被访问,访问者已经拥有了处理这些对象所需的一切(当然,Family对象也可以接受其他类型的访问者,我们将不得不为它们编写单独的accept()方法)。

现在,最后,我们拥有了处理任意对象序列化和反序列化问题所需的所有部件。下一个小节将展示如何使用访问者模式来完成这项工作。

使用访问者进行序列化和反序列化

该问题本身在上一节中已详细描述 - 对于序列化,每个对象都需要被转换为一系列位,这些位需要被存储、复制或发送。动作的第一部分取决于对象(每个对象的转换方式不同),但第二部分取决于序列化的具体应用(保存到磁盘与通过网络发送不同)。实现取决于两个因素,因此需要双重分派,这正是访问者模式提供的。此外,如果我们有方法可以序列化某些对象然后反序列化它(从位序列中重建对象),那么当这个对象包含在其他对象中时,我们应该使用相同的方法。

为了演示使用访问者模式进行类层次结构的序列化和反序列化,我们需要一个比我们迄今为止使用的玩具示例更复杂的层次结构。让我们考虑这个二维几何对象的层次结构:

// Example 08
class Geometry {
  public:
  virtual ~Geometry() {}
};
class Point : public Geometry {
  public:
  Point() = default;
  Point(double x, double y) : x_(x), y_(y) {}
  private:
  double x_ {};
  double y_ {};
};
class Circle : public Geometry {
  public:
  Circle() = default;
  Circle(Point c, double r) : c_(c), r_(r) {}
  private:
  Point c_;
  double r_ {};
};
class Line : public Geometry {
  public:
  Line() = default;
  Line(Point p1, Point p2) : p1_(p1), p2_(p2) {}
  private:
  Point p1_;
  Point p2_;
};

所有对象都从抽象的Geometry基类派生,但更复杂的对象包含一个或多个更简单的对象;例如,Line由两个Point对象定义。请注意,最终,我们所有的对象都是由double数字组成的,因此将序列化为一系列数字。关键是知道哪个double代表哪个对象的哪个字段;我们需要这个来正确地恢复原始对象。

要使用访问者模式序列化这些对象,我们遵循与上一节相同的流程。首先,我们需要声明基访问者类:

// Example 08
class Visitor {
public:
  virtual void visit(double& x) = 0;
  virtual void visit(Point& p) = 0;
  virtual void visit(Circle& c) = 0;
  virtual void visit(Line& l) = 0;
};

这里还有一个额外的细节 - 我们也可以访问double值;每个访问者都需要适当地处理它们(写入它们、读取它们等)。访问任何几何对象最终都会导致访问它所组成的数字。

我们的基本Geometry类及其所有派生类都需要接受这个访问者:

// Example 08
class Geometry {
  public:
  virtual ~Geometry() {}
  virtual void accept(Visitor& v) = 0;
};

当然,我们无法向double添加一个accept()成员函数,但我们将不必这样做。派生类的accept()成员函数,每个都由一个或多个数字和其他类组成,会按顺序访问每个数据成员:

// Example 08
void Point::accept(Visitor& v) {
  v.visit(x_); // double
  v.visit(y_); // double
}
void Circle::accept(Visitor& v) {
  v.visit(c_); // Point
  v.visit(r_); // double
}
void Point::accept(Visitor& v) {
  v.visit(p1_); // Point
  v.visit(p2_); // Point
}

具体的访问者类,所有都是基Visitor类的派生,负责序列化和反序列化的具体机制。对象分解成其部分的顺序,一直到底层的数字,由每个对象控制,但访问者决定了如何处理这些数字。例如,我们可以使用格式化输入输出将所有对象序列化成一个字符串(类似于我们将数字打印到cout时得到的结果):

// Example 08
class StringSerializeVisitor : public Visitor {
public:
  void visit(double& x) override { S << x << " "; }
  void visit(Point& p) override { p.accept(*this); }
  void visit(Circle& c) override { c.accept(*this); }
  void visit(Line& l) override { l.accept(*this); }
  std::string str() const { return S.str(); }
  private:
  std::stringstream S;
};

字符串会在stringstream中累积,直到所有必要的对象都被序列化:

// Example 08
Line l(...);
Circle c(...);
StringSerializeVisitor serializer;
serializer.visit(l);
serializer.visit(c);
std::string s(serializer.str());

现在我们已经将对象打印到字符串s中,我们可以从这个字符串中恢复它们,也许是在不同的机器上(如果我们安排将字符串发送到那里)。首先,我们需要反序列化的访问者:

// Example 08
class StringDeserializeVisitor : public Visitor {
  public:
  StringDeserializeVisitor(const std::string& s) {
    S.str(s);
  }
  void visit(double& x) override { S >> x; }
  void visit(Point& p) override { p.accept(*this); }
  void visit(Circle& c) override { c.accept(*this); }
  void visit(Line& l) override { l.accept(*this); }
  private:
  std::stringstream S;
};

这个访问者从字符串中读取数字,并将它们保存为被访问对象提供的变量中。成功反序列化的关键是按照保存时的顺序读取数字 - 例如,如果我们首先写入一个点的 XY 坐标,我们应该从读取的前两个数字构建一个点,并将它们用作 XY 坐标。如果第一个写入的点是一条线的终点,我们应该使用构建的点作为新线的终点。访问者模式的美妙之处在于,执行实际读取和写入的函数不需要做任何特殊的事情来保持这个顺序 - 顺序由每个对象确定,并且对所有访问者保证是相同的(对象不会区分特定的访问者,甚至不知道它是什么类型的访问者)。我们唯一需要做的就是按照序列化时的顺序访问对象:

// Example 08
Line l1;
Circle c1;
// s is the string from a serializer
StringDeserializeVisitor deserializer(s);
deserializer.visit(l1); // Restored Line l
deserializer.visit(c1); // Restored Circle c

到目前为止,我们已经知道了哪些对象被序列化以及它们的顺序。因此,我们可以以相同的顺序反序列化相同的对象。更一般的情况是,在反序列化过程中我们不知道期望哪些对象——对象存储在一个可访问的容器中,类似于早期示例中的 Shelter,它必须确保对象以相同的顺序进行序列化和反序列化。例如,考虑这个类,它存储一个表示为两个其他几何体交集的几何体:

// Example 09
class Intersection : public Geometry {
  public:
  Intersection() = default;
  Intersection(Geometry* g1, Geometry* g2) :
    g1_(g1), g2_(g2) {}
  void accept(Visitor& v) override {
    g1_->accept(v);
    g2_->accept(v);
  }
  private:
  std::unique_ptr<Geometry> g1_;
  std::unique_ptr<Geometry> g2_;
};

此对象的序列化很简单——我们按顺序序列化几何体,通过将这些细节委托给这些对象来实现。我们不能直接调用 v.visit(),因为我们不知道 *g1_*g2_ 几何体的类型,但我们可以让这些对象根据适当的情况分派调用。但是,按照目前的写法,反序列化将失败——几何指针是 null,还没有分配任何对象,我们也不知道应该分配哪种类型的对象。某种方式,我们首先需要在序列化流中编码对象的类型,然后根据这些编码的类型构建它们。还有另一种模式为这个问题提供了标准的解决方案,那就是工厂模式(在构建复杂系统时,通常需要使用多个设计模式)。

有几种方法可以实现这一点,但它们都归结为将类型转换为数字并将这些数字序列化。在我们的情况下,当我们声明基类 Visitor 时,我们必须知道完整的几何类型列表,这样我们才能同时定义所有这些类型的枚举:

// Example 09
class Geometry {
  public:
  enum type_tag {POINT = 100, CIRCLE, LINE, INTERSECTION};
  virtual type_tag tag() const = 0;
};
class Visitor {
  public:
  static Geometry* make_geometry(Geometry::type_tag tag);
  virtual void visit(Geometry::type_tag& tag) = 0;
  ...
};

enum type_tag 不一定需要在 Geometry 类内部定义,或者 make_geometry 工厂构造函数必须是 Visitor 类的静态成员函数。它们也可以在任何类外部声明,但返回每个派生几何类型正确标记的虚拟 tag() 方法需要按照所示方式声明。必须在每个派生 Geometry 类中定义 tag() 重写,例如,Point 类:

// Example 09
class Point : public Geometry {
  public:
  ...
  type_tag tag() const override { return POINT; }
};

其他派生类也需要进行类似的修改。

然后,我们需要定义工厂构造函数:

// Example 09
Geometry* Visitor::make_geometry(Geometry::type_tag tag) {
  switch (tag) {
    case Geometry::POINT: return new Point;
    case Geometry::CIRCLE: return new Circle;
    case Geometry::LINE: return new Line;
    case Geometry::INTERSECTION: return new Intersection;
  }
}

此工厂函数根据指定的类型标记构建正确的派生对象。剩下的只是让 Intersection 对象序列化和反序列化构成交集的两个几何体的标记:

// Example 09
class Intersection : public Geometry {
  public:
  void accept(Visitor& v) override {
    Geometry::type_tag tag;
    if (g1_) tag = g1_->tag();
    v.visit(tag);
    if (!g1_) g1_.reset(Visitor::make_geometry(tag));
    g1_->accept(v);
    if (g2_) tag = g2_->tag();
    v.visit(tag);
    if (!g2_) g2_.reset(Visitor::make_geometry(tag));
    g2_->accept(v);
  }
  ...
};

首先,标记被发送到访问者。序列化访问者应该将标记与数据的其他部分一起写入:

// Example 09
class StringSerializeVisitor : public Visitor {
  public:
  void visit(Geometry::type_tag& tag) override {
    S << size_t(tag) << " ";
  }
  ...
};

反序列化访问者必须读取标记(实际上,它读取一个 size_t 数字并将其转换为标记):

// Example 09
class StringDeserializeVisitor : public Visitor {
  public:
  void visit(Geometry::type_tag& tag) override {
    size_t t;
    S >> t;
    tag = Geometry::type_tag(t);
  }
  ...
};

一旦反序列化访问者恢复了标签,Intersection对象可以调用工厂构造函数来构建正确的几何对象。现在我们可以从这个流中反序列化这个对象,我们的Intersection就被恢复成了与序列化时完全相同的副本。请注意,还有其他方法来封装访问标签和调用工厂构造函数;最佳解决方案取决于系统中不同对象的角色——例如,反序列化访问者可能会根据标签而不是拥有这些几何形状的复合对象来构建对象。然而,需要发生的事件序列仍然是相同的。

到目前为止,我们一直在学习经典的面向对象访问者模式。在我们看到经典模式在 C++中的特定变化之前,我们应该了解另一种类型的访问者,它解决了访问者模式中的一些不便之处。

无环访问者

如我们所见,访问者模式到目前为止已经做到了我们想要它做的事情。它将算法的实现与作为算法数据的目标对象分离,并允许我们根据两个运行时因素来选择正确的实现——具体的对象类型和我们要执行的具体操作,这两个因素都从它们各自的类层次结构中选择。然而,这里有一个问题——我们想要减少复杂性并简化代码维护,我们确实做到了,但现在我们必须维护两个并行类层次结构,即可访问对象和访问者,以及两者之间的依赖关系是非平凡的。这些依赖关系中最糟糕的部分是它们形成了一个循环——访问者对象依赖于可访问对象的类型(对于每个可访问类型都有一个visit()方法的重载),而基础可访问类型依赖于基础访问者类型。这个依赖关系的前半部分是最糟糕的。每次向层次结构中添加新对象时,每个访问者都必须更新。后半部分对程序员的工作量不大,因为可以在任何时候添加新的访问者,而不需要任何其他更改——这就是访问者模式的核心所在。但仍然存在基础可访问类及其所有派生类对基础访问者类的编译时依赖。访问者的大部分接口和实现都是稳定的,除了一个情况——添加新的可访问类。因此,这个循环在操作中看起来是这样的——向可访问对象的层次结构中添加了一个新类。访问者类需要更新以包含新类型。由于基础访问者类已更改,基础可访问类及其所有依赖于它的代码行都必须重新编译,包括不使用新可访问类的代码,只使用旧的。即使尽可能使用前向声明也无法帮助——如果添加了新的可访问类,所有旧的都必须重新编译。

传统访问者模式的附加问题是必须处理对象类型和访问者类型的所有可能组合。通常情况下,有些组合是没有意义的,某些对象永远不会被某些类型的访问者访问。但我们不能利用这一点,因为每个组合都必须有一个定义好的动作(动作可能非常简单,但仍然,每个访问者类都必须定义完整的visit()成员函数集)。

无环访问者模式是访问者模式的一种变体,它专门设计用来打破依赖循环并允许部分访问。无环访问者模式的基础可访问类与常规访问者模式相同:

// Example 10
class Pet {
  public:
  virtual ~Pet() {}
  virtual void accept(PetVisitor& v) = 0;
  ...
};

然而,相似之处到此为止。基访问者类没有为每个可访问对象提供visit()重载。事实上,它根本没有任何visit()成员函数:

// Example 10
class PetVisitor {
  public:
  virtual ~PetVisitor() {}
};

那么,谁来进行访问呢?对于原始层次结构中的每个派生类,我们也声明相应的访问者类,这就是visit()函数所在的地方:

// Example 10
class Cat;
class CatVisitor {
  public:
  virtual void visit(Cat* c) = 0;
};
class Cat : public Pet {
  public:
  Cat(std::string_view color) : Pet(color) {}
  void accept(PetVisitor& v) override {
    if (CatVisitor* cv = dynamic_cast<CatVisitor*>(&v)) {
      cv->visit(this);
    } else { // Handle error
      assert(false);
    }
  }
};

注意,每个访问者只能访问它被设计为访问的类——CatVisitor只访问Cat对象,DogVisitor只访问Dog对象,等等。魔法在于新的accept()函数——当一个类被要求接受一个访问者时,它首先使用dynamic_cast来检查这是否是正确的访问者类型。如果是,一切顺利,访问者被接受。如果不是,我们就有问题,必须处理错误(错误处理的精确机制取决于应用程序;例如,可以抛出异常)。因此,具体的访问者类必须从公共的PetVisitor基类以及如CatVisitor之类的特定基类派生:

// Example 10
class FeedingVisitor : public PetVisitor,
                       public CatVisitor,
                       public DogVisitor {
  public:
  void visit(Cat* c) override {
    std::cout << "Feed tuna to the " << c->color()
              << " cat" << std::endl;
  }
  void visit(Dog* d) override {
    std::cout << "Feed steak to the " << d->color()
              << " dog" << std::endl;
  }
};

每个具体的访问者类都从公共访问者基类派生,并且从每个必须由该访问者处理的类型的每个特定类型的访问者基类(例如CatVisitorDogVisitor等)派生。另一方面,如果这个访问者没有被设计为访问层次结构中的某些类,我们可以简单地省略相应的访问者基类,然后我们也就不需要实现虚拟函数的重载了:

// Example 10
class BathingVisitor : public PetVisitor,
                       public DogVisitor
                       { // But no CatVisitor
  public:
  void visit(Dog* d) override {
    std::cout << "Wash the " << d->color()
              << " dog" << std::endl;
  }
  // No visit(Cat*) here!
};

无环访问者模式的调用方式与常规访问者模式完全相同:

// Example 10
std::unique_ptr<Pet> c(new Cat("orange"));
std::unique_ptr<Pet> d(new Dog("brown"));
FeedingVisitor fv;
c->accept(fv);
d->accept(fv);
BathingVisitor bv;
//c->accept(bv); // Error
d->accept(bv);

如果我们尝试访问特定访问者不支持的对象,错误就会被检测到。因此,我们已经解决了部分访问的问题。那么依赖循环怎么办?这也得到了妥善处理——公共的PetVisitor基类不需要列出可访问对象的完整层次结构,具体的可访问类只依赖于它们各自的类访问者,而不是任何其他类型的访问者。因此,当向层次结构中添加另一个可访问对象时,现有的对象不需要重新编译。

Acyclic Visitor 模式看起来如此之好,以至于人们不禁要问,为什么不总是使用它而不是常规的 Visitor 模式呢? 有几个原因。首先,Acyclic Visitor 模式使用dynamic_cast从一个基类转换到另一个基类(有时称为交叉转换)。这个操作通常比虚函数调用更昂贵,所以 Acyclic Visitor 模式比替代方案更慢。此外,Acyclic Visitor 模式要求为每个可访问类提供一个 Visitor 类,因此类数增加了一倍,并且它使用了多个继承和许多基类。第二个问题对于大多数现代编译器来说不是什么大问题,但许多程序员发现处理多重继承很困难。第一个问题——动态转换的运行时成本——是否是问题取决于应用程序,但这是你需要注意的事情。另一方面,当可访问对象层次结构频繁变化或整个代码库重新编译的成本很高时,Acyclic Visitor 模式确实很出色。

你可能已经注意到 Acyclic Visitor 模式的一个问题——它有很多样板代码。对于每个可访问类,必须复制几行代码。实际上,常规的 Visitor 模式也面临着同样的问题,即实现任何一种 Visitor 都涉及到大量的重复输入。但是 C++有一套特殊的工具来用代码复用来替换代码重复:这正是泛型编程的目的。我们将在下一节中看到 Visitor 模式是如何适应现代 C++的。

现代 C++中的 Visitor

正如我们刚才看到的,Visitor 模式促进了关注点的分离;例如,序列化的顺序和序列化的机制被独立出来,每个都由一个单独的类负责。该模式还通过将执行给定任务的代码收集到一个地方来简化代码维护。Visitor 模式不促进的是没有重复的代码复用。但那是现代 C++之前的面向对象的 Visitor 模式。让我们看看我们可以如何利用 C++的泛型能力,从常规的 Visitor 模式开始。

泛型 Visitor

我们将尝试减少 Visitor 模式实现中的样板代码。让我们从accept()成员函数开始,它必须复制到每个可访问类中;它总是看起来一样:

class Cat : public Pet {
  void accept(PetVisitor& v) override { v.visit(this); }
};

这个函数不能移动到基类,因为我们需要调用具有实际类型的访问者,而不是基类型——visit()接受Cat*Dog*等,但不接受Pet*。如果我们引入一个中间的模板基类,我们可以得到一个模板来为我们生成这个函数:

// Example 11
class Pet { // Same as before
  public:
  virtual ~Pet() {}
  Pet(std::string_view color) : color_(color) {}
  const std::string& color() const { return color_; }
  virtual void accept(PetVisitor& v) = 0;
  private:
  std::string color_;
};
template <typename Derived>
class Visitable : public Pet {
  public:
  using Pet::Pet;
  void accept(PetVisitor& v) override {
    v.visit(static_cast<Derived*>(this));
  }
};

模板由派生类参数化。在这方面,它与指向正确派生类指针的 this 指针类似。现在我们只需要从模板的正确实例化中派生每个宠物类,我们就会自动获得 accept() 函数:

// Example 11
class Cat : public Visitable<Cat> {
  using Visitable<Cat>::Visitable;
};
class Dog : public Visitable<Dog> {
  using Visitable<Dog>::Visitable;
};

这样就处理了样板代码的一半——派生可访问对象内部的代码。现在只剩下另一半:访问者类内部的代码,在那里我们必须为每个可访问类重复相同的声明。我们对特定访问者无能为力;毕竟,那里才是真正的工作所在,而且,假设我们需要为不同的可访问类做不同的事情(否则为什么要使用双重分派呢?)

然而,如果我们引入这个通用访问者模板,我们可以简化基访问者类的声明:

// Example 12
template <typename ... Types> class Visitor;
template <typename T> class Visitor<T> {
  public:
  virtual void visit(T* t) = 0;
};
template <typename T, typename ... Types>
class Visitor<T, Types ...> : public Visitor<Types ...> {
  public:
  using Visitor<Types ...>::visit;
  virtual void visit(T* t) = 0;
};

注意,我们只需要实现这个模板一次:不是为每个类层次结构实现一次,而是永远实现一次(或者至少直到我们需要更改 visit() 函数的签名,例如,添加参数)。这是一个好的通用库类。一旦我们有了它,声明特定类层次结构的访问者基类就变得如此简单,以至于感觉有些平淡无奇:

// Example 12

注意到 class 关键字有些不寻常的语法——它将模板参数列表与前置声明结合起来,相当于以下内容:

class Cat;
class Dog;
using PetVisitor = Visitor<Cat, Dog>;

通用访问者基类是如何工作的?它使用变长模板来捕获任意数量的类型参数,但主要模板只声明了,没有定义。其余的是特化。首先,我们有一个只有一个类型参数的特殊情况。我们为该类型声明了纯 visit() 虚拟成员函数。然后我们有一个针对多个类型参数的特化,其中第一个参数是显式的,其余的都在参数包中。我们为显式指定的类型生成 visit() 函数,并从具有一个较少参数的相同变长模板的实例化中继承其余的。实例化是递归的,直到我们只剩下一个类型参数,然后使用第一个特化。

这段通用且可重用的代码有一个限制:它不能处理深层层次结构。回想一下,每个可访问的类都派生自一个共同的基类:

template <typename Derived>
class Visitable : public Pet {...};
class Cat : public Visitable<Cat> {...};

如果我们要从 Cat 派生另一个类,它也必须从 Visitable 派生:

class SiameseCat : public Cat,
                   public Visitable<SiameseCat> {...};

我们不能仅仅从Cat派生出SiameseCat,因为它是提供每个派生类accept()方法的Visitable模板。但我们也不能像之前尝试的那样使用双重继承,因为现在SiameseCat类从Pet基类和Visitable基类继承两次:一次是通过Cat基类,一次是通过Visitable基类。如果你仍然想使用模板生成accept()方法,唯一的解决方案是将层次结构分开,使得每个可访问类(如Cat)都从Visitable继承,并从具有所有“猫特定”功能(除了访问支持)的相应基类CatBase继承。这会使层次结构中的类数量加倍,这是一个主要的缺点。

现在我们有了由模板生成的样板访问者代码,我们也可以使其定义具体的访问者更加简单。

Lambda 访问者

定义具体访问者的大部分工作是为每个可访问对象必须发生的实际工作编写代码。在特定的访问者类中并没有很多样板代码。但有时我们可能不想声明这个类本身。想想 lambda 表达式——任何可以用 lambda 表达式完成的事情也可以用显式声明的可调用类完成,因为 lambda 是(匿名)可调用类。尽管如此,我们发现 lambda 表达式对于编写一次性可调用对象非常有用。同样,我们可能想要编写一个没有显式命名的访问者——一个 lambda 访问者。我们希望它看起来像这样:

auto v(lambda_visitor<PetVisitor>(
  [](Cat* c) { std::cout << "Let the " << c->color()
                         << " cat out" << std::endl;
  },
  [](Dog* d) { std::cout << "Take the " << d->color()
                         << " dog for a walk" << std::endl;
  }
));
pet->accept(v);

有两个问题需要解决——如何创建一个处理类型列表及其相应对象(在我们的例子中,是可访问类型和相应的 lambda)的类,以及如何使用 lambda 表达式生成一组重载函数。

前一个问题将需要我们递归地在参数包上实例化一个模板,每次剥掉一个参数。后一个问题与 lambda 表达式的重载集类似,这在类模板章节中已经描述过。我们可以使用那一章中的重载集,但我们可以使用我们需要的递归模板实例化来直接构建函数的重载集。

在这个实现中,我们将面临一个新的挑战——我们必须处理不止一个类型列表。第一个列表包含所有可访问类型;在我们的例子中,是CatDog。第二个列表包含 lambda 表达式的类型,每个可访问类型一个。我们还没有看到带有两个参数包的变长模板,而且有很好的理由——不能简单地声明template<typename... A, typename... B>,因为编译器不知道第一个列表在哪里结束,第二个在哪里开始。技巧是将一个或两个类型列表隐藏在其他模板中。在我们的例子中,我们已经有了一个Visitor模板,它在可访问类型的列表上实例化:

using PetVisitor = Visitor<class Cat, class Dog>;

我们可以从Visitor模板中提取这个列表,并将每个类型与其 lambda 表达式匹配。用于同步处理两个参数包的部分特化语法很棘手,所以我们将分步骤进行。首先,我们需要声明我们的LambdaVisitor类的一般模板:

// Example 13
template <typename Base, typename...>
class LambdaVisitor;

注意,这里只有一个通用参数包,加上访问者的基类(在我们的情况下,它将是PetVisitor)。这个模板必须被声明,但它永远不会被使用——我们将为每个需要处理的案例提供一个特化。第一个特化用于只有一个可访问类型和一个相应的 lambda 表达式时:

// Example 13
template <typename Base, typename T1, typename F1>
class LambdaVisitor<Base, Visitor<T1>, F1> :
  private F1, public Base
{
  public:
  LambdaVisitor(F1&& f1) : F1(std::move(f1)) {}
  LambdaVisitor(const F1& f1) : F1(f1) {}
  using Base::visit;
  void visit(T1* t) override { return F1::operator()(t); }
};

这个专业,除了处理我们只有一个可访问类型的情况外,还用作每条递归模板实例化链中的最后一个实例化。由于它始终是LambdaVisitor实例化递归层次结构中的第一个基类,因此它是唯一一个直接继承自基Visitor类(如PetVisitor)的类。请注意,即使只有一个T1可访问类型,我们也使用Visitor模板作为其包装器。这是为了准备我们将要处理的一般情况,即我们将有一个长度未知的类型列表。两个构造函数将f1 lambda 表达式存储在LambdaVisitor类内部,如果可能,使用移动而不是复制。最后,visit(T1*)虚拟函数覆盖简单地转发调用到 lambda 表达式。乍一看,可能看起来从F1公开继承并同意使用函数调用语法(换句话说,将所有对visit()的调用重命名为对operator()的调用)会更简单。但这行不通;我们需要间接引用,因为 lambda 表达式的operator()实例本身不能是虚拟函数覆盖。顺便说一句,这里的override关键字在检测模板未从正确的基类继承或虚拟函数声明不完全匹配的代码中的错误时非常有价值。

任何数量可访问类型和 lambda 表达式的一般情况由这个部分特化处理,它明确处理两个列表中的第一个类型,然后递归实例化自身以处理其余的列表:

// Example 13
template <typename Base,
          typename T1, typename... T,
          typename F1, typename... F>
class LambdaVisitor<Base, Visitor<T1, T...>, F1, F...> :
  private F1,
  public LambdaVisitor<Base, Visitor<T ...>, F ...>
{
  public:
  LambdaVisitor(F1&& f1, F&& ... f) :
    F1(std::move(f1)),
    LambdaVisitor<Base, Visitor<T...>, F...>(
      std::forward<F>(f)...)
  {}
  LambdaVisitor(const F1& f1, F&& ... f) :
    F1(f1),
    LambdaVisitor<Base, Visitor<T...>, F...>(
      std::forward<F>(f) ...)
  {}
  using LambdaVisitor<Base, Visitor<T ...>, F ...>::visit;
  void visit(T1* t) override { return F1::operator()(t); }
};

再次,我们有两个构造函数,将第一个 lambda 表达式存储在类中,并将其余的转发到下一个实例化。在递归的每一步都会生成一个虚拟函数覆盖,始终针对剩余的可访问类列表中的第一个类型。然后,该类型从列表中删除,并以相同的方式继续处理,直到我们达到最后一个实例化,即单个可访问类型的实例化。

由于无法显式命名 lambda 表达式的类型,因此我们也不能显式声明 lambda 访问者的类型。相反,lambda 表达式的类型必须通过模板参数推导来推断,因此我们需要一个接受多个 lambda 表达式参数并从所有这些参数中构建LambdaVisitor对象的lambda_visitor()模板函数:

// Example 13
template <typename Base, typename ... F>
auto lambda_visitor(F&& ... f) {
  return LambdaVisitor<Base, Base, F...>(
    std::forward<F>(f) ...);
}

在 C++17 中,可以使用推导指南实现相同的功能。现在我们有一个存储任意数量 lambda 表达式并将每个 lambda 表达式绑定到相应的visit()重写的类,我们可以像编写 lambda 表达式一样轻松地编写 lambda 访问者:

// Example 13
void walk(Pet& p) {
  auto v(lambda_visitor<PetVisitor>(
  [](Cat* c){std::cout << "Let the " << c->color()
                         << " cat out" << std::endl;},
  [](Dog* d){std::cout << "Take the " << d->color()
                       << " dog for a walk" << std::endl;}
  ));
  p.accept(v);
}

注意,由于我们在继承相应 lambda 表达式的同一类中声明了visit()函数,因此lambda_visitor()函数参数列表中 lambda 表达式的顺序必须与PetVisitor定义中类型列表中类的顺序相匹配。如果需要,可以通过增加一些实现复杂性的代价来移除这种限制。

在 C++中处理类型列表的另一种常见方法是将它们存储在std::tuple中:例如,我们可以使用std::tuple<Cat, Dog>来表示由两种类型组成的列表。同样,整个参数包也可以存储在元组中:

// Example 14
template <typename Base, typename F1, typename... F>
class LambdaVisitor<Base, std::tuple<F1, F...>> :
  public F1, public LambdaVisitor<Base, std::tuple<F...>>;

您可以将示例 13 和 14 进行比较,以了解如何使用std::tuple来存储类型列表。

我们已经看到了如何将访问者代码的常见片段转换为可重用的模板,以及这如何反过来让我们创建 lambda 访问者。但我们没有忘记在本章中学到的另一种访问者实现,即非循环访问者模式。让我们看看它如何也能从现代 C++语言特性中受益。

泛型非循环访问者

非循环访问者模式不需要具有所有可访问类型列表的基类。然而,它也有自己的样板代码。首先,每个可访问类型都需要一个accept()成员函数,并且它比原始访问者模式中的类似函数有更多的代码:

// Example 10
class Cat : public Pet {
  public:
  void accept(PetVisitor& v) override {
    if (CatVisitor* cv = dynamic_cast<CatVisitor*>(&v)) {
      cv->visit(this);
    } else { // Handle error
      assert(false);
    }
  }
};

假设错误处理是统一的,这个函数会针对不同的访问者类型重复使用,每个访问者类型对应其可访问类型(例如这里的CatVisitor)。然后还有每个类型的访问者类本身,例如:

class CatVisitor {
  public:
  virtual void visit(Cat* c) = 0;
};

再次,这段代码被粘贴到程序的所有地方,只有细微的修改。让我们将这种容易出错的代码复制粘贴转换为易于维护的可重用代码。

我们首先需要创建一些基础设施。非循环访问者模式以其所有访问者的公共基类为基础构建其层次结构,如下所示:

class PetVisitor {
  public:
  virtual ~PetVisitor() {}
};

注意,这里没有针对Pet层次结构的具体内容。通过更好的命名,这个类可以作为任何访问者层次结构的基类:

// Example 15
class VisitorBase {
  public:
  virtual ~VisitorBase() {}
};

我们还需要一个模板来生成所有这些针对可访问类型的特定Visitor基类,以替换几乎相同的CatVisitorDogVisitor等。由于这些类所需的所有内容仅仅是纯虚visit()方法的声明,我们可以通过可访问类型来参数化模板:

// Example 15
template <typename Visitable> class Visitor {
  public:
  virtual void visit(Visitable* p) = 0;
};

对于任何类层次结构,其基可访问类现在使用共同的VisitorBase基类接受访问者:

// Example 15
class Pet {
  ...
  virtual void accept(VisitorBase& v) = 0;
};

我们不再直接从Pet派生每个可访问类并粘贴accept()方法的副本,而是引入一个中间模板基类,它可以生成具有正确类型的此方法:

// Example 15
template <typename Visitable>
class PetVisitable : public Pet {
  public:
  using Pet::Pet;
  void accept(VisitorBase& v) override {
    if (Visitor<Visitable>* pv =
        dynamic_cast<Visitor<Visitable>*>(&v)) {
      pv->visit(static_cast<Visitable*>(this));
    } else { // Handle error
      assert(false);
    }
 }
};

这是我们需要编写的accept()函数的唯一副本,它包含了我们应用程序处理访问者不被基类接受的情况的首选错误处理实现(回想一下,循环访问者允许部分访问,其中某些访问者和可访问类型的组合不受支持)。就像常规访问者一样,中间的 CRTP 基类使得使用深度层次结构变得困难。

具体的可访问类通过中间的PetVisitable基类间接继承自共同的Pet基类,该基类还为他们提供了可访问接口。PetVisitable模板的参数是派生类本身(再次,我们看到 CRTP 的作用):

// Example 15
class Cat : public PetVisitable<Cat> {
  using PetVisitable<Cat>::PetVisitable;
};
class Dog : public PetVisitable<Dog> {
  using PetVisitable<Dog>::PetVisitable;
};

当然,对于所有派生类,使用相同的基类构造函数并不是强制性的,因为每个类都可以根据需要定义自定义构造函数。

剩下的唯一事情是实现访问者类。回想一下,在循环访问者模式中,特定的访问者从共同的访问者基类继承,并且每个代表受支持的可访问类型的访问者类。这不会改变,但现在我们有了按需生成这些访问者类的方法:

// Example 15
class FeedingVisitor : public VisitorBase,
                       public Visitor<Cat>,
                       public Visitor<Dog>
{
  public:
  void visit(Cat* c) override {
    std::cout << "Feed tuna to the " << c->color()
              << " cat" << std::endl;
  }
  void visit(Dog* d) override {
    std::cout << "Feed steak to the " << d->color()
              << " dog" << std::endl;
  }
};

让我们回顾一下我们所做的工作——访问者类的并行层次结构不再需要显式地指定类型;相反,它们按需生成。重复的accept()函数减少到单个PetVisitable类模板。尽管如此,我们仍然需要为每个新的可访问类层次结构编写这个模板。我们也可以将其泛化,并为所有层次结构创建一个可重用的模板,该模板通过基可访问类进行参数化:

// Example 16
template <typename Base, typename Visitable>
class VisitableBase : public Base {
  public:
  using Base::Base;
  void accept(VisitorBase& vb) override {
    if (Visitor<Visitable>* v = 
        dynamic_cast<Visitor<Visitable>*>(&vb)) {
      v->visit(static_cast<Visitable*>(this));
    } else { // Handle error
      assert(false);
    }
  }
};

现在,对于每个可访问类层次结构,我们只需要创建一个模板别名:

// Example 16
template <typename Visitable>
using PetVisitable = VisitableBase<Pet, Visitable>;

我们可以进一步简化,允许程序员将可访问类的列表指定为类型列表,而不是像之前那样从Visitor<Cat>Visitor<Dog>等继承。这需要一个变长模板来存储类型列表。实现与之前看到的LambdaVisitor实例类似:

// Example 17
template <typename ... V> struct Visitors;
template <typename V1>
struct Visitors<V1> : public Visitor<V1> {};
template <typename V1, typename ... V>
struct Visitors<V1, V ...> : public Visitor<V1>,
                             public Visitors<V ...> {};

我们可以使用这个包装模板来缩短特定访问者的声明:

// Example 17
class FeedingVisitor :
  public VisitorBase, public Visitors<Cat, Dog>
{
  ...
};

如果需要,我们甚至可以将VisitorBase隐藏在为单个类型参数定义的Visitors模板的定义中。

我们现在已经看到了经典面向对象的访问者模式及其可重用的实现,这些实现是由 C++的泛型编程工具实现的。在早期章节中,我们看到了一些模式可以完全在编译时应用。现在让我们考虑是否也可以用访问者模式做到这一点。

编译时访问者

在本节中,我们将分析在编译时使用访问者模式的可行性,类似于应用策略模式导致基于策略的设计。

首先,当在模板上下文中使用时,访问者模式的多个分派方面变得非常简单:

template <typename T1, typename T2> auto f(T1 t1, T2 t2);

模板函数可以轻松地为T1T2类型的任何组合运行不同的算法。与使用虚函数实现的运行时多态不同,根据两个或更多类型的不同调用分发并不需要额外的成本(当然,除了编写处理所有组合所需的代码之外)。基于这个观察,我们可以在编译时轻松地模仿经典的访问者模式:

// Example 18
class Pet {
  std::string color_;
  public:
  Pet(std::string_view color) : color_(color) {}
  const std::string& color() const { return color_; }
  template <typename Visitable, typename Visitor>
  static void accept(Visitable& p, Visitor& v) {
    v.visit(p);
  }
};

accept()函数现在是一个模板和静态成员函数 - 第一个参数的实际类型,即从Pet类派生的可访问对象,将在编译时推断出来。具体的可访问类以通常的方式从基类派生:

// Example 18
class Cat : public Pet {
  public:
  using Pet::Pet;
};
class Dog : public Pet {
  public:
  using Pet::Pet;
};

访问者不需要从公共基类派生,因为我们现在在编译时解析类型:

// Example 18
class FeedingVisitor {
  public:
  void visit(Cat& c) {
    std::cout << "Feed tuna to the " << c.color()
              << " cat" << std::endl;
  }
  void visit(Dog& d) {
    std::cout << "Feed steak to the " << d.color()
              << " dog" << std::endl;
  }
};

可访问的类可以接受任何具有正确接口的访问者,即对层次结构中所有类都有visit()重载:

// Example 18
Cat c("orange");
Dog d("brown");
FeedingVisitor fv;
Pet::accept(c, fv);
Pet::accept(d, fv);

当然,任何接受访问者参数并需要支持多个访问者的函数也必须是一个模板(仅仅有一个公共基类已经不再足够,它只能帮助在运行时确定实际对象类型)。

编译时访问者解决了经典访问者相同的问题,它允许我们有效地向类添加新成员函数,而无需编辑类定义。然而,它看起来比运行时版本要无趣得多。

当我们将访问者模式与组合模式结合使用时,会出现更多有趣的可能。我们在讨论复杂对象的访问问题时已经这样做过一次,尤其是在序列化问题的背景下。这之所以特别有趣,是因为它与 C++中缺失的少数几个“重要特性”之一——反射——有关。在编程中,反射是指程序检查和内省其自身源代码的能力,然后根据这种内省生成新的行为。一些编程语言,如 Delphi 或 Python,具有原生的反射能力,但 C++没有。反射对于解决许多问题很有用:例如,如果我们能够使编译器遍历对象的所有数据成员并递归地序列化每个成员,直到我们达到内置类型,那么序列化问题就可以轻松解决。我们可以使用编译时访问者模式实现类似的功能。

再次,我们将考虑几何对象的层次结构。由于现在所有事情都在编译时发生,我们对类的多态性质不感兴趣(如果需要运行时操作,它们仍然可以使用虚拟函数;我们只是不会在本节中编写或查看它们)。例如,这是Point类:

// Example 19
class Point {
  public:
  Point() = default;
  Point(double x, double y) : x_(x), y_(y) {}
  template <typename This, typename Visitor>
  static void accept(This& t, Visitor& v) {
    v.visit(t.x_);
    v.visit(t.y_);
  }
  private:
  double x_ {};
  double y_ {};
};

访问是通过accept()函数提供的,就像之前一样,但现在它是特定于类的。我们只有一个模板参数This的原因是为了方便地支持 const 和非 const 操作:This可以是Pointconst Point。任何访问这个类的访问者都会被发送去访问定义点的两个值,x_y_。访问者必须具有适当的接口,具体来说,就是接受double参数的visit()成员函数。像大多数 C++模板库一样,包括Line类:

// Example 19
class Line {
  public:
  Line() = default;
  Line(Point p1, Point p2) : p1_(p1), p2_(p2) {}
  template <typename This, typename Visitor>
  static void accept(This& t, Visitor& v) {
    v.visit(t.p1_);
    v.visit(t.p2_);
  }
  private:
  Point p1_;
  Point p2_;
};

Line类由两个点组成。在编译时,访问者被引导访问每个点。这就是Line类的参与结束;Point类将决定如何被访问(正如我们刚才看到的,它也将工作委托给另一个访问者)。由于我们不再使用运行时多态,现在可以容纳不同类型几何形状的容器类现在必须使用模板:

// Example 19
template <typename G1, typename G2>
class Intersection {
  public:
  Intersection() = default;
  Intersection(G1 g1, G2 g2) : g1_(g1), g2_(g2) {}
  template <typename This, typename Visitor>
  static void accept(This& t, Visitor& v) {
    v.visit(t.g1_);
    v.visit(t.g2_);
  }
  private:
  G1 g1_;
  G2 g2_;
};

现在我们有了可访问的类型。我们可以使用具有此接口的不同类型的访问者,而不仅仅是序列化访问者。然而,我们现在专注于序列化。之前,我们看到了一个将对象转换为 ASCII 字符串的访问者。现在让我们将对象序列化为二进制数据,连续的位流。序列化访问者可以访问一定大小的缓冲区,并将对象写入该缓冲区,每次写入一个double值:

// Example 19
class BinarySerializeVisitor {
  public:
  BinarySerializeVisitor(char* buffer, size_t size) :
    buf_(buffer), size_(size) {}
  void visit(double x) {
    if (size_ < sizeof(x))
      throw std::runtime_error("Buffer overflow");
    memcpy(buf_, &x, sizeof(x));
    buf_ += sizeof(x);
    size_ -= sizeof(x);
  }
  template <typename T> void visit(const T& t) {
    T::accept(t, *this);
  }
  private:
  char* buf_;
  size_t size_;
};

反序列化访问者从缓冲区读取内存并将其复制到它恢复的对象的数据成员中:

// Example 19
class BinaryDeserializeVisitor {
  public:
  BinaryDeserializeVisitor(const char* buffer, size_t size)
    : buf_(buffer), size_(size) {}
  void visit(double& x) {
    if (size_ < sizeof(x))
      throw std::runtime_error("Buffer overflow");
    memcpy(&x, buf_, sizeof(x));
    buf_ += sizeof(x);
    size_ -= sizeof(x);
  }
  template <typename T> void visit(T& t) {
    T::accept(t, *this);
  }
  private:
  const char* buf_;
  size_t size_;
};

两个访问者都通过将它们复制到缓冲区并从缓冲区复制来直接处理内置类型,同时让更复杂的类型决定如何处理对象。在这两种情况下,如果超出缓冲区大小,访问者都会抛出异常。现在我们可以使用我们的访问者,例如,将对象通过套接字发送到另一台机器:

// Example 19
// On the sender machine:
Line l = ...;
Circle c = ...;
Intersection<Circle, Circle> x = ...;
char buffer[1024];
BinarySerializeVisitor serializer(buffer, sizeof(buffer));
serializer.visit(l);
serializer.visit(c);
serializer.visit(x);
... send the buffer to the receiver ...
// On the receiver machine:
Line l;
Circle c;
Intersection<Circle, Circle> x;
BinaryDeserializeVisitor deserializer(buffer, 
  sizeof(buffer));
deserializer.visit(l);
deserializer.visit(c);
deserializer.visit(x);

虽然没有语言支持,我们无法实现通用的反射,但我们可以以有限的方式让类反映其内容,例如这种复合访问模式。我们还可以考虑这个主题的一些变体。

首先,通常的做法是将只有一个重要成员函数的对象使其可调用;换句话说,不是调用成员函数,而是使用函数调用语法来调用对象本身。这个约定规定visit()成员函数应该被命名为operator()

// Example 20
class BinarySerializeVisitor {
  public:
  void operator()(double x);
  template <typename T> void operator()(const T& t);
  ...
};

可访问的类现在像函数一样调用访问者:

// Example 20
class Point {
  public:
  static void accept(This& t, Visitor& v) {
    v(t.x_);
    v(t.y_);
  }
  ...
};

实现包装函数以在多个对象上调用访问者可能也很方便:

// Example 20
SomeVisitor v;
Object1 x; Object2 y; ...
visitation(v, x, y, z);

这可以通过变长模板轻松实现:

// Example 20
template <typename V, typename T>
void visitation(V& v, T& t) {
  v(t);
}
template <typename V, typename T, typename... U>
void visitation(V& v, T& t, U&... u) {
  v(t);
  visitation(v, u ...);
}

在 C++17 中,我们有折叠表达式,不需要递归模板:

// Example 20
template <typename V, typename T, typename... U>
void visitation(V& v, U&... u) {
  (v(u), ...);
}

在 C++14 中,我们可以使用基于std::initializer_list的技巧来模拟折叠表达式:

template <typename V, typename T, typename... U>
void visitation(V& v, U&... u) {
  using fold = int[];
  (void)fold { 0, (v(u), 0)... };
}

这可以工作,但它不太可能因为清晰度或可维护性而获奖。

编译时访问者通常更容易实现,因为我们不需要做任何巧妙的事情来获得多态,因为模板已经提供了这种功能。我们只需要想出有趣的应用模式,比如我们刚刚探索的序列化/反序列化问题。

C++17 中的访问者

C++17 通过在标准库中添加std::variant引入了我们对访问者模式使用方式的重大变化。std::variant模板本质上是一个“智能联合体:”std::variant<T1, T2, T3>union { T1 v1; T2 v2; T3 v3; }类似,因为它们都可以存储指定类型中的一个值,并且一次只能存储一个值。关键区别在于,变体对象知道它包含哪种类型,而联合体则要求程序员完全负责读取与之前写入相同的类型。将联合体作为与初始化时不同的类型访问是不确定的操作:

union { int i; double d; std::string s; } u;
u.i = 0;
++u.i;               // OK
std::cout << u.d;     // Undefined behavior

相反,std::variant提供了一种安全的方式在相同的内存中存储不同类型的值。在运行时很容易检查当前存储在变体中的是哪种备选类型,如果以错误类型访问变体,则会抛出异常:

std::variant<int, double, std::string> v;
std::get<int>(v) = 0;     // Initialized as int
std::cout << v.index();     // 0 is the index of int
++std::get<0>(v);     // OK, int is 0th type
std::get<1>(v);          // throws std::bad_variant_access

在许多方面,std::variant 提供了类似于基于继承的运行时多态的能力:两者都允许我们编写代码,其中相同的变量名在运行时可以引用不同类型的对象。两个主要区别是:首先,std::variant 不要求所有类型都来自同一个层次结构(它们甚至不必是类),其次,变体对象只能存储其声明中列出的类型之一,而基类指针可以指向任何派生类。换句话说,向层次结构添加新类型通常不需要重新编译使用基类的代码,而向变体添加新类型则需要更改变体对象的类型,因此所有引用此对象的代码都必须重新编译。

在本节中,我们将重点关注 std::variant 的访问使用。这种能力是由名为 std::visit 的函数提供的,它接受一个可调用对象和一个变体:

std::variant<int, double, std::string> v;
struct Print {
  void operator()(int i) { std::cout << i; }
  void operator()(double d) { std::cout << d; }
  void operator()(const std::string& s) { std::cout << s; }
} print;
std::visit(print, v);

要与 std::visit 一起使用,可调用对象必须为变体中可以存储的每种类型声明一个 operator()(否则调用将无法编译)。当然,如果实现相似,我们可以在函数对象或 lambda 中使用模板 operator()

std::variant<int, double, std::string> v;
std::visit([](const auto& x) { std::cout << x;}, v);

我们现在将使用 std::variantstd::visit 重新实现我们的宠物访客。首先,Pet 类型不再是层次结构的基类,而是变体,包含所有可能的类型替代项:

// Example 21
using Pet = 
  std::variant<class Cat, class Dog, class Lorikeet>;

类型本身不需要任何访问机制。我们仍然可以使用继承来重用常见的实现代码,但不需要类型属于单个层次结构:

// Example 21
class PetBase {
  public:
  PetBase(std::string_view color) : color_(color) {}
  const std::string& color() const { return color_; }
  private:
  const std::string color_;
};
class Cat : private PetBase {
  public:
  using PetBase::PetBase;
  using PetBase::color;
};
class Dog : private PetBase {
  ... similar to Cat ...
};
class Lorikeet {
  public:
  Lorikeet(std::string_view body, std::string_view head) :
    body_(body), head_(head) {}
  std::string color() const {
    return body_ + " and " + head_;
  }
  private:
  const std::string body_;
  const std::string head_;
};

现在我们需要实现一些访问者。访问者只是可调用对象,可以用变体中可能存储的任何替代类型来调用:

// Example 21
class FeedingVisitor {
  public:
  void operator()(const Cat& c) {
    std::cout << "Feed tuna to the " << c.color()
              << " cat" << std::endl;
  }
  void operator()(const Dog& d) {
    std::cout << "Feed steak to the " << d.color()
              << " dog" << std::endl;
  }
  void operator()(const Lorikeet& l) {
    std::cout << "Feed grain to the " << l.color()
              << " bird" << std::endl;
  }
};

要将访问者应用于变体,我们调用 std::visit

// Example 21
Pet p = Cat("orange");
FeedingVisitor v;
std::visit(v, p);

变体 p 可以包含我们在定义 Pet 类型时列出的任何类型(在这个例子中,它是一个 Cat)。然后我们调用 std::visit,产生的动作既取决于访问者本身,也取决于当前存储在变体中的类型。结果看起来很像虚拟函数调用,因此我们可以说 std::visit 允许我们向一组类型添加新的多态函数(由于这些类型不必是类,所以称它们为“虚拟函数”可能会有误导性)。

每当我们看到一个具有用户定义的 operator() 的可调用对象时,我们必须在考虑 lambdas。然而,与 std::visit 一起使用 lambdas 并不简单:我们需要对象能够以变体中可以存储的任何类型进行调用,而 lambda 只有一个 operator()。第一个选项是将该操作符做成模板(多态 lambda)并处理所有可能的类型:

// Example 22
#define SAME(v, T) \
  std::is_same_v<std::decay_t<decltype(v)>, T>
auto fv = [](const auto& p) {
  if constexpr (SAME(p, Cat)) {
    std::cout << "Feed tuna to the " << p.color()
              << " cat" << std::endl; }
  else if constexpr (SAME(p, Dog)) {
    std::cout << "Feed steak to the " << p.color()
              << " dog" << std::endl; }
  else if constexpr (SAME(p, Lorikeet)) {
    std::cout << "Feed grain to the " << p.color()
              << " bird" << std::endl; }
  else abort();
};

在这里,lambda 可以用任何类型的参数调用,并在 lambda 体内部,我们使用if constexpr来处理可以存储在变体中的所有类型。这种方法的缺点是我们不再有编译时验证,即所有可能的类型都被访问者处理。然而,另一方面,如果代码现在没有处理所有类型,代码仍然可以编译,并且只要访问者没有被调用带有我们没有定义操作的类型,程序将正常工作。以这种方式,这个版本类似于无环访问者,而之前的实现类似于常规访问者。

还可以使用 lambda 和我们在第一章中看到的创建重载集的技术来实现熟悉的重载operator()集:

// Example 22
template <typename... T> struct overloaded : T... {
  using T::operator()...;
};
template <typename... T>
overloaded( T...)->overloaded<T...>;
auto pv = overloaded {
  [](const Cat& c) {
    std::cout << "Play with feather with the " << c.color()
              << " cat" << std::endl; },
  [](const Dog& d) {
    std::cout << "Play fetch with the " << d.color()
              << " dog" << std::endl; },
  [](const Lorikeet& l) {
    std::cout << "Teach words to the " << l.color()
              << " bird" << std::endl; }
};

这个访问者是一个继承自所有 lambda 的类,并暴露它们的operator(),从而创建了一组重载。它就像我们明确写出每个operator()的访问者一样使用:

// Example 22
Pet l = Lorikeet("yellow", "green");
std::visit(pv, l);

到目前为止,我们还没有充分利用std::visit的潜力:它可以与任意数量的变体参数一起调用。这允许我们执行依赖于超过两个运行时条件的操作:

// Example 23
using Pet = std::variant<class Cat, class Dog>;
Pet c1 = Cat("orange");
Pet c2 = Cat("black");
Pet d = Dog("brown");
CareVisitor cv;
std::visit(cv, c1, c2);      // Two cats
std::visit(cv, c1, d);     // Cat and dog

访问者必须以处理每个变体中可以存储的所有类型可能组合的方式编写:

class CareVisitor {
  public:
  void operator()(const Cat& c1, const Cat& c2) {
    std::cout << "Let the " << c1.color() << " and the "
              << c2.color() << " cats play" << std::endl; }
  void operator()(const Dog& d, const Cat& c) {
    std::cout << "Keep the " << d.color()
              << " dog safe from the vicious " << c.color()
              << " cat" << std::endl; }
  void operator()(const Cat& c, const Dog& d) {
    (*this)(d, c);
  }
  void operator()(const Dog& d1, const Dog& d2) {
    std::cout << "Take the " << d1.color() << " and the "
              << d2.color() << " dogs for a walk"
              << std::endl; }
};

在实践中,唯一可行的方式来编写适用于所有可能类型组合的可调用函数是使用模板operator(),这仅在访问者操作可以用通用方式编写时才有效。尽管如此,std::visit能够进行多重分派的能力是一个潜在的有用特性,它超越了常规访问者模式的双分派能力。

摘要

在本章中,我们学习了访问者模式及其在 C++中的不同实现方式。经典的面向对象访问者模式允许我们在不更改类源代码的情况下,有效地向整个类层次结构添加一个新的虚拟函数。层次结构必须可访问,但在此之后,可以添加任意数量的操作,并且它们的实现与对象本身保持分离。在经典访问者模式的实现中,包含被访问层次结构的源代码不需要更改,但在添加新类到层次结构时,需要重新编译。无环访问者模式解决了这个问题,但代价是额外的动态转换。另一方面,无环访问者模式还支持部分访问 - 忽略一些访问者/可访问组合 - 而经典访问者模式要求所有组合至少被声明。

对于所有访问者变体,可扩展性的权衡是需要弱化封装,并且经常授予外部访问者类访问应该为私有数据成员的权限。

访问者模式通常与其他设计模式结合使用,特别是组合模式,以创建复杂的可访问对象。组合对象将访问委托给其包含的对象。这种组合模式特别有用,当对象必须分解为其最小的构建块时;例如,用于序列化。

经典的访问者模式在运行时实现双重分派 - 在执行过程中,程序根据两个因素选择要运行的代码,即访问者和可访问对象类型。该模式也可以在编译时类似地使用,它提供有限的反射能力。

在 C++17 中,可以使用std::visit将访问者模式扩展到未绑定到公共层次结构中的类型,甚至实现多重分派。

本章关于访问者模式,原本是这本书的结尾,这本书致力于 C++惯用和设计模式。但是,就像新星一样,新模式的诞生永远不会停止 - 新的前沿和新思想带来了新的挑战要解决,新的解决方案要发明,它们会不断发展和演变,直到编程社区集体达成共识,我们可以自信地说,这通常是处理那个问题的好方法。我们将详细阐述每种新方法的优点,考虑其缺点,并给它起一个名字,这样我们就可以简洁地引用关于该问题、其解决方案及其注意事项的全部知识。有了这个,一个新的模式就进入了我们的设计工具集和编程词汇。为了说明这个过程,在下一章和最后一章中,我们收集了一些出现以解决特定于并发程序的问题的模式。

问题

  1. 访问者模式是什么?

  2. 访问者模式解决了什么问题?

  3. 双重分派是什么?

  4. 无环访问者模式的优势是什么?

  5. 访问者模式如何帮助实现序列化?

第十八章:并发模式

上一章专门介绍了一组用于并发程序的模式。并发与 C++之间有一种相当复杂的关系。一方面,C++是一种以性能为导向的语言,并发几乎总是被用来提高性能,因此两者是自然匹配的。当然,C++从语言最早的时候就开始被用来开发并发程序了。另一方面,对于经常被用来编写并发程序的语言来说,C++在直接解决并发编程需求的结构和特性方面却出奇地缺乏。这些需求主要是由广泛的社区开发的库以及通常针对特定应用的解决方案来满足的。在本章中,我们将回顾在并发程序开发中遇到的常见问题以及多年经验中出现的解决方案;这些共同构成了设计模式的两面。

本章涵盖了以下主题:

  • C++中并发支持的现状如何?

  • 并发的挑战主要有哪些?

  • 数据同步的挑战以及 C++工具如何应对这些挑战

  • 并发的设计是怎样的?

  • C++中管理并发工作负载的常见模式有哪些?

技术要求

本章的示例代码可以在 GitHub 上找到,链接如下:github.com/PacktPublishing/Hands-On-Design-Patterns-with-CPP-Second-Edition/tree/master/Chapter18。此外,对并发的一般知识和 C++中的并发支持也是先决条件。

C++与并发

并发的概念是在 C++11 中引入到语言中的,但并发程序在那时之前就已经用 C++编写了。本章的目的不是介绍并发,甚至也不是介绍 C++中的并发。这个主题在文献中已经得到了很好的覆盖(在本书出版时,一本既全面又更新的作品是 Anthony Williams 的《C++并发实战》)。此外,虽然并发几乎总是用来提高性能,但在这里我们不会直接讨论性能和优化问题;对于这些问题,你可以参考我的书《编写高效程序的技艺》。我们将专注于并发软件设计中出现的问题。

在开发并发程序时,从广义上讲,我们会遇到三种类型的挑战。首先,如何在多个线程同时操作同一数据的情况下确保程序的正确性?其次,如何通过多线程执行程序的工作来提高整体性能?最后,如何设计软件,使其能够让我们对其进行分析、理解其功能以及维护它,同时还要考虑到并发的复杂性。

第一组挑战主要与数据共享和同步相关。我们将首先检查相关的模式:程序首先必须是正确的,在一个崩溃或产生不可信结果的程序中实现高性能是毫无用处的。

同步模式

同步模式有一个总的目标:确保多个线程共享的数据的正确操作。这些模式对于绝大多数并发程序至关重要。唯一不需要同步的程序是那些执行几个完全独立的任务且不涉及任何公共数据(除了可能读取共享的不可变输入)并产生单独结果的程序。对于其他所有程序,都需要管理一些共享状态,这使我们面临可怕的数据竞争的风险。正式来说,C++标准指出,如果没有适当的同步来保证每个线程的独占访问,则对同一对象(同一内存位置)的并发访问会导致未定义行为。更精确地说,如果至少有一个线程可以修改共享数据,则行为是未定义的:如果数据从未被任何线程更改,则不可能发生数据竞争。有一些设计模式利用了这个漏洞,但让我们从最广为人知的同步模式开始。当你听到避免数据竞争时,首先想到的是什么?

互斥锁和锁定模式

如果有一个用于编写并发程序的工具,那就是互斥锁。互斥锁用于保证多个线程访问共享数据时的独占访问:

std::mutex m;
MyData data;
...
// On several threads:
m.lock();
transmogrify(data);
m.unlock();

数据修改操作 transmogrify() 必须保证对共享数据的独占访问:在任何给定时间只能有一个线程执行此操作。程序员使用 lock()unlock()

使用互斥锁足以确保对共享数据的正确访问,但这几乎不是一个好的设计。第一个问题是它容易出错:如果 transmogrify() 抛出异常,或者如果程序员添加了对返回值的检查并提前退出关键部分,最终的 unlock() 永远不会执行,互斥锁将永远锁定,从而阻止其他任何线程访问数据。

这个挑战可以通过对我们在 第五章 中已经看到的非常通用的 C++模式的一种特定应用来轻松解决,即 全面审视 RAII。我们需要的只是一个用于锁定和解锁互斥锁的对象,而 C++标准库已经提供了一个,std::lock_guard

// Example 01
std::mutex m;
int i = 0;
void add() {
  std::lock_guard<std::mutex> l(m);
  ++i;
}
...
std::thread t1(add);
std::thread t2(add);
t1.join();
t2.join();
std::cout << i << std::endl;

函数add()修改共享变量i,因此需要独占访问;这是通过使用互斥量m来实现的。请注意,如果你在没有互斥量的情况下运行此示例,你仍然可能会得到正确的结果,因为其中一个线程会在另一个线程之前执行。有时程序会失败,而更多的时候则不会。这并不意味着它是正确的,只是使得调试变得困难。你可以通过使用--sanitize=address来启用它,并使用add()中的互斥量(示例 02),用 TSAN 编译,运行程序,你将看到以下内容:

WARNING: ThreadSanitizer: data race
...
Location is global 'i' of size 4 at <address>

显示了更多信息,以帮助您确定哪些线程存在数据竞争以及对于哪个变量。这是一种比等待程序失败更可靠的测试数据竞争的方法。

在 C++17 中,使用std::lock_guard稍微简单一些,因为编译器会从构造函数中推断模板参数:

// Example 03
std::lock_guard l(m);

在 C++20 中,我们可以使用std::jthread来代替显式调用join()

// Example 03
{
  std::jthread t1(add);
  std::jthread t2(add);
}
std::cout << i << std::endl;

注意,在使用计算结果之前必须小心地销毁线程,因为析构函数现在会连接线程并等待计算完成。否则,将存在另一个数据竞争:主线程在i值被增加时正在读取它的值(TSAN 也会发现这个竞争)。

RAII 的使用确保每次锁定互斥量时都会解锁,但这并不能避免在使用互斥量时可能发生的其他错误。最常见的一个是忘记最初使用互斥量。同步保证仅适用于每个线程都使用相同的机制来确保对数据的独占访问。如果甚至有一个线程没有使用互斥量,即使只是读取数据,那么整个程序都是不正确的。

开发了一种模式来防止对共享数据的未同步访问。这个模式通常被称为“互斥量保护”或“互斥量保护”,它有两个关键元素:首先,需要保护的数据和用于此目的的互斥量被组合在同一个对象中。其次,设计确保对数据的每次访问都由互斥量保护。以下是一个基本的互斥量保护类模板:

// Example 04
template <typename T> class MutexGuarded {
  std::mutex m_;
  T data_ {};
  public:
  MutexGuarded() = default;
  template <typename... Args>
  explicit MutexGuarded(Args&&... args) :
    data_(std::forward<Args>(args)...) {}
  template <typename F> decltype(auto) operator()(F f) {
    std::lock_guard<std::mutex> l(m_);
    return f(data_);
  }
};

如您所见,此模板将互斥量和它所保护的数据结合起来,并只提供一种访问数据的方式:通过使用任意可调用对象调用MutexGuarded对象。这确保了所有数据访问都是同步的:

// Example 04
MutexGuarded<int> i_guarded(0);
void add() {
  i_guarded([](int& i) { ++i; });
}
...
// On many threads:
std::thread t1(add);
std::thread t2(add);
t1.join();
t2.join();
i_guarded([](int i) { std::cout << i << std::endl; });

这些是正确和可靠使用互斥锁模式的最基本的版本。在实践中,需求通常更为复杂,因此解决方案也是如此:有比std::mutex更高效的锁(例如,用于保护短计算的自旋锁,你可以在我的书《编写高效程序的艺术》中找到),还有更复杂的锁,如共享锁和独占锁,用于高效的读写访问。此外,我们通常需要同时操作多个共享对象,这导致了安全锁定多个互斥锁的问题。许多这些问题都是通过我们刚刚看到的模式的更复杂变体来解决的。有些需要完全不同的数据访问同步方法,我们将在本节后面看到这些方法。最后,一些数据访问挑战在整体系统设计的更高层次上解决更好;这一点也将在本章中展示。

让我们接下来回顾一下超越常用互斥锁的数据共享的不同方法。

不共享是最好的共享

虽然使用互斥锁来保护共享数据看起来并不复杂,但实际上,数据竞争是任何并发程序中最常见的错误。虽然声称你不能访问你不共享的数据以避免数据竞争可能看似一个无用的真理,但事实上,不共享是共享的一个经常被忽视的替代方案。换句话说,通常可以重新设计程序以避免共享某些变量,或者将共享数据的访问限制在代码的较小部分。

这个想法是设计模式的基础,这种模式容易解释但往往难以应用,因为它需要跳出思维定势——线程特定数据模式。它也被称为“线程局部数据”,但这个名字容易与 C++的thread_local关键字混淆。为了说明这个想法,我们考虑以下示例:我们需要计算可能在多个线程同时发生的事件(在这个演示中,计数的内容并不重要)。我们需要整个程序中这些事件的累计计数,所以直接的方法是有一个共享计数,当线程检测到事件时(在演示中,我们计算能被 10 整除的随机数):

// Example 05
MutexGuarded<size_t> count;
void events(unsigned int s) {
  for (size_t i = 1; i != 100; ++i) {
    if ((rand_r(&s) % 10) == 0) { // Event!
      count([](size_t& i) { ++i; });
    }
  }
}

这是一个直接的设计;它不是最好的。注意,虽然每个线程都在计数事件,但它不需要知道其他线程计数了多少事件。这并不与我们的实现中每个线程需要知道当前计数值以便正确增加它的这一事实相混淆。这种区别很微妙但很重要,并暗示了一个替代方案:每个线程可以使用线程特定的计数来计数它自己的事件,每个线程一个。这些计数都不正确,但只要我们能在需要正确的总事件计数时将所有计数相加,那就没关系了。这里有几个可能的设计。我们可以为事件使用局部计数,并在线程退出之前更新一次共享计数:

// Example 06
MutexGuarded<size_t> count;
void events(unsigned int s) {
  size_t n = 0;
  for (size_t i = 1; i != 100; ++i) {
    if ((rand_r(&s) % 10) == 0) { // Event!
      ++n;
    }
  }
  if (n > 0) count(n { i += n; });
}

在一个或多个线程正在执行其函数时声明的任何局部(栈分配)变量都是特定于每个线程的:每个线程的栈上都有一个唯一的变量副本,并且当它们都引用相同的名称 n 时,每个线程访问它自己的变量。

我们也可以给每个线程提供一个唯一的计数变量来增加,并在所有计数线程完成后在主线程中将它们相加:

// Example 07
void events(unsigned int s, size_t& n) {
  for (size_t i = 1; i != 100; ++i) {
    if ((rand_r(&s) % 10) == 0) ++n;
  }
}

在多个线程上调用这个计数函数时,我们必须采取一些预防措施。显然,我们应该给每个线程自己的计数变量 n。这还不够:由于称为“伪共享”的硬件相关效应,我们还必须确保线程特定的计数在内存中不是相邻的(有关伪共享的详细描述可以在我的书 《编写高效程序的艺术》 中找到):

// Example 07
alignas(64) size_t n1 = 0;
alignas(64) size_t n2 = 0;
std::thread t1(events, 1, std::ref(n1));
std::thread t2(events, 2, std::ref(n2));
t1.join();
t2.join();
size_t count = n1 + n2;

alignas 属性确保每个计数变量有 64 字节的对齐,从而确保 n1n2 的地址之间至少有 64 字节的区别(64 是大多数现代 CPU(包括 X86 和 ARM)的缓存行大小)。注意,对于 std::thread 来调用使用引用参数的函数,需要 std::ref 包装器。

前一个例子将共享数据访问的需求减少到每个线程一次,而最后一个例子则完全没有共享数据;首选的解决方案取决于总计数值何时需要。

最后一个例子可以从一个稍微不同的角度来审视;稍微重写它会有所帮助:

// Example 08
struct {
  alignas(64) size_t n1 = 0;
  alignas(64) size_t n2 = 0;
} counts;
std::thread t1(events, 1, std::ref(counts.n1));
std::thread t2(events, 2, std::ref(counts.n2));
t1.join();
t2.join();
size_t count = counts.n1 + counts.n2;

这并没有改变任何实质性的东西,但我们可以将线程特定的计数视为同一数据结构的一部分,而不是为每个线程创建的独立变量。这种思维方式引导我们到达线程特定数据模式的另一个变体:有时,多个线程必须操作相同的数据,但可能可以将数据分区,并给每个线程分配它自己的子集来工作。

在下一个示例中,我们需要对向量中的每个元素进行 clamp 操作(如果一个元素超过最大值,它将被替换为这个值,因此结果总是在零和最大值之间)。计算是通过以下模板算法实现的:

// Example 09
template <typename IT, typename T>
void clamp(IT from, IT to, T value) {
  for (IT it = from; it != to; ++it) {
    if (*it > value) *it = value;
  }
}

一个生产质量的实现将确保迭代器参数满足迭代器要求,并且最大值可以与迭代器值类型进行比较,但我们为了简洁起见省略了所有这些(我们有一个关于概念和其他限制模板方法的整个章节)。

clamp() 函数可以在任何序列上调用,有时我们可能会幸运地有可以独立在多个线程上处理的不同且无关的数据结构。但是,为了继续这个例子,让我们假设我们只有一个需要 clamp 的向量。然而,情况并非全无希望,因为我们可以在没有数据竞争风险的情况下,在多个线程上处理它的非重叠部分:

// Example 09
std::vector<int> data = ... data ...;
std::thread t1([&](){
  clamp(data.begin(), data.begin() + data.size()/2, 42);
});
std::thread t2([&](){
  clamp(data.begin() + data.size()/2, data.end(), 42);
});
...
t1.join();
t2.join();

尽管我们程序中的数据结构在两个线程之间是共享的,并且两个线程都对其进行修改,但这个程序是正确的:对于每个向量元素,只有一个线程可以修改它。但是,向量对象本身呢?它不是在所有线程之间共享的吗?

我们已经强调,有一种情况允许数据共享而不需要任何同步:只要没有其他线程正在修改它,任意数量的线程都可以读取相同的变量。我们的例子就利用了这一点:所有线程都读取向量的大小和其他向量对象的成员变量,但没有线程修改它们。

线程特定数据模式的运用必须经过仔细思考,通常需要很好地理解数据结构。我们必须绝对确信没有任何线程试图修改它们共享的变量,例如向量对象本身的成员变量的大小和指针。例如,如果某个线程可以调整向量的大小,即使没有两个线程访问相同的元素,这也会成为数据竞争:向量的大小是一个变量,它被一个或多个线程修改,但没有加锁。

在本小节中,我们想要描述的最后一种模式适用于多个线程需要修改整个数据集(因此不能分区)但不需要看到其他线程所做的修改的情况。通常,这种情况发生在修改作为某些结果计算的一部分时,但修改后的数据本身不是最终结果。在这种情况下,有时最好的方法是为每个线程创建一个线程特定的数据副本。这种模式在副本是“一次性”对象时效果最好:每个线程都需要修改其副本,但修改的结果不需要提交回原始数据结构。

在下面的示例中,我们使用一个算法来计算向量中唯一元素的数量,该算法在原地排序向量:

// Example 10
void count_unique(std::vector<int> data, size_t& count) {
  std::sort(data.begin(), data.end());
  count = std::unique(data.begin(),
                      data.end()) - data.begin();
}

此外,当我们只需要计数满足某个谓词的元素时,我们首先删除所有其他元素(std::erase_if 是 C++20 的新增功能,但在 C++ 的早期版本中也很容易实现):

// Example 10
void count_unique_even(std::vector<int> data, size_t& count) {
  std::erase_if(data, [](int i) { return i & 1; });
  std::sort(data.begin(), data.end());
  count = std::unique(data.begin(),
                      data.end()) - data.begin();
}

这两种操作都是对向量的破坏性操作,但它们只是达到目的的手段:一旦我们得到了计数,就可以丢弃修改后的向量。在多个线程上同时计算我们的计数的最简单、通常也是最高效的方法是创建向量的线程特定副本。实际上,我们已经这样做了:这两个计数函数都是通过值传递向量参数,因此会创建一个副本。通常,这会是一个错误,但在这个情况下,这是故意的,并且允许这两个函数同时操作同一个向量:

// Example 10
std::vector<int> data = ...;
size_t unique_count = 0;
size_t unique_even_count = 0;
{
  std::jthread t1(count_unique, data,
                  std::ref(unique_count));
  std::jthread t2(count_unique_even, data,
                  std::ref(unique_even_count));
}

当然,仍然存在对原始数据的并发访问,并且没有使用锁:两个线程都需要创建它们的线程特定副本。然而,这属于只读并发访问的例外情况,是安全的。

从原则上讲,尽可能避免数据共享,在必要时使用互斥锁,就足以在任何程序中安排无竞争的数据访问。然而,这可能不是实现这一目标的最有效方式,而良好的性能几乎总是并发的目标。现在,我们将考虑几种其他用于并发访问共享数据的模式,当适用时,可以提供更好的性能。我们将从比互斥锁更高级的同步原语开始,这些原语专门设计用来允许线程高效地等待某些事件。

等待模式

等待是并发程序中经常遇到的问题,它有多种形式。我们已经看到了一个:互斥锁。确实,如果有两个线程试图同时进入临界区,其中一个将不得不等待。但在这里,等待并不是目标,只是对临界区独占访问的不幸副作用。还有其他情况下,等待是主要目标。例如,我们可能有等待某些事件发生的线程。这可能是一个用户界面线程等待输入(性能要求很低)或等待网络套接字的线程(性能要求适中)或甚至是一个高性能线程,如线程池中的计算线程等待执行任务(性能要求极高)。不出所料,这些场景有不同的实现,但基本上有两种方法:轮询和通知。我们将首先探讨通知。

等待通知的基本模式是条件模式。它通常由一个条件变量和一个互斥锁组成。一个或多个线程被阻塞等待条件变量。在这段时间里,还有一个线程锁定互斥锁(从而保证独占访问)并执行其他线程等待完成的工作。一旦工作完成,完成工作的线程必须释放互斥锁(这样其他线程就可以访问包含此工作结果的共享数据)并通知等待的线程它们可以继续。

例如,在一个线程池中,等待的线程是等待将任务添加到池中的池工作线程。由于池任务队列是一个共享资源,一个线程需要独占访问来推送或弹出任务。向队列添加一个或多个任务的线程必须在执行此操作时持有互斥锁,然后通知工作线程有任务要执行。

现在我们来看一个只有两个线程的非常基本的示例,说明通知模式。首先,我们有主线程,它启动一个工作线程,然后等待它产生一些结果:

// Example 11
std::mutex m;
std::condition_variable cv;
size_t n = 0;               // Zero until work is done
// Main thread
void main_thread() {
  std::unique_lock l(m);
  std::thread t(produce);     // Start the worker
  cv.wait(l, []{ return n != 0; });
  ... producer thread is done, we have the lock ...
}

在这种情况下,锁定是由std::unique_lock提供的,这是一个围绕互斥锁包装的对象,具有类似互斥锁的接口,具有lock()unlock()成员函数。互斥锁在构造函数中被锁定,并在我们开始等待条件时几乎立即由wait()函数解锁。当接收到通知时,wait()在返回控制权给调用者之前再次锁定互斥锁。

许多等待和条件的实现都存在所谓的虚假唤醒问题:即使没有通知,等待也可以被中断。这就是为什么我们还要检查结果是否准备好,在我们的例子中,通过检查结果计数n:如果它仍然是零,则没有结果,主线程被错误唤醒,我们可以回到等待状态(注意,等待线程必须在wait()返回之前获取互斥锁,因此它必须等待工作线程释放这个互斥锁)。

工作线程在访问共享数据之前必须锁定相同的互斥锁,然后在通知主线程工作已完成之前解锁它:

// Example 11
// Worker thread
void produce() {
  {
    std::lock_guard l(m);
    ... compute results ...
    n = ... result count ...
  } // Mutex unlocked
  cv.notify_one();          // Waiting thread notified
}

在工作线程活跃的整个时间内没有必要一直持有互斥锁:它的唯一目的是保护共享数据,例如我们例子中的结果计数n

两个同步原语std::conditional_variablestd::unique_lock是标准 C++工具,用于实现带有条件的等待模式。就像互斥锁一样,有许多变体。

通知的替代方法是轮询。在这个模式中,等待的线程反复检查是否满足某些条件。在 C++20 中,我们可以使用std::atomic_flag实现一个简单的轮询等待示例,它本质上是一个原子布尔变量(在 C++20 之前,我们可以用std::atomic<bool>做同样的事情):

// Example 12
std::atomic_flag flag;
// Worker thread:
void produce() {
  ... produce the results ...
  flag.test_and_set(std::memory_order_release);
}
// Waiting thread:
void main_thread() {
  flag.clear();
  std::thread t(produce);
  while (!flag.test(std::memory_order_acquire)) {} // Wait
  ... results are ready ...
}

原子操作,例如 test_and_set(),利用内存屏障:一种全局同步标志,确保在设置(释放)该标志之前对内存所做的所有更改都对在标志测试(获取)之后执行的任何其他线程上的任何操作可见。关于这些屏障还有很多内容,但这些都超出了本书的范围,可以在许多关于并发和效率的书中找到。

与上一个示例相比,最重要的区别是等待线程在示例 12中的显式轮询循环。如果等待时间很长,这会非常低效,因为等待线程在整个等待过程中都在忙于计算(从内存中读取)。任何实际实现都会在等待循环中引入一些睡眠,但这样做也会付出代价:等待线程不会在工作者线程设置标志后立即唤醒,而必须先完成睡眠。这些效率问题超出了本书的范围;在这里,我们想展示这些模式的整体结构和组件。

轮询和等待之间的界限并不总是清晰的。例如,据我们所知,wait() 可能是通过定期轮询条件变量的某些内部状态来实现的。实际上,我们刚才看到的相同的原子标志可以用来等待通知:

// Example 13
std::atomic_flag flag;
// Worker thread:
void produce() {
  ... produce the results ...
  flag.test_and_set(std::memory_order_release);
  flag.notify_one();
}
// Waiting thread:
void main_thread() {
  flag.clear();
  std::thread t(produce);
  flag.wait(true, std::memory_order_acquire); // Wait
  while (!flag.test(std::memory_order_acquire)) {}
  ... results are ready ...
}

wait() 的调用需要一个相应的 notify_one()(或如果有多个线程在等待该标志,则为 notify_all())调用。它的实现几乎肯定比我们的简单轮询循环更高效。在接收到通知并结束等待后,我们检查标志以确保它确实被设置了。标准说这不是必要的,std::atomic_flag::wait() 不会遭受虚假唤醒,但 GCC 和 Clang 中的 TSAN 都不同意(这可能是 TSAN 中的假阳性或标准库实现中的错误)。

有许多其他情况下需要等待,而我们需要等待的条件各不相同。另一个常见的需求是等待一定数量的事件发生。例如,我们可能有几个线程在生成结果,我们可能需要它们在主线程继续之前完成它们的工作份额。这是通过在屏障或闩锁上等待来实现的。在 C++20 之前,我们需要自己实现这些同步原语或使用第三方库,但在 C++20 中它们已成为标准:

// Example 14
// Worker threads
void produce(std::latch& latch) {
  ... do the work ...
  latch.count_down();     // One more thread is done
}
void main_thread() {
  constexpr size_t nthread = 4;
  std::jthread t[nthread];
  std::latch latch(nthread); // Wait for 4 count_down()
  for (size_t i = 0; i != nthread; ++i) {
    t[i] = std::jthread(std::ref(latch));
  }
  latch.wait();   // Wait for producers to finish
  ... results are ready ...
}

闩锁初始化为等待事件的数量。当完成这么多 count_down() 调用后,它将解锁。

等待有许多其他应用,但几乎所有的等待模式都可以大致分为本节中我们看到的一种类别(特定实现可能会对特定情况下的性能产生显著影响,因此你更有可能看到这些同步构造的特定应用版本,而不是非标准容器或其他基本数据结构)。

现在我们将看到几个非常专业且非常高效的同步模式的例子。它们并不适用于所有情况,但当它们符合需求时,它们通常提供最佳性能。

无锁同步模式

大多数情况下,安全访问共享数据依赖于互斥锁。C++还支持另一种同步并发线程的类型:原子操作。同样,详细的解释超出了本书的范围,本节需要一些关于原子的先验知识。

基本思想是这样的:一些数据类型(通常是整数)有特殊的硬件指令,允许一些简单的操作,如读取、写入或增加值以原子方式执行,在单个事件中完成。在原子操作期间,其他线程根本无法访问原子变量,因此如果一个线程执行原子操作,所有其他线程都可以看到操作前后相同的变量,但不能在操作过程中看到。例如,增加是一个读取-修改-写入操作,但原子增加是一个特殊的硬件事务,一旦读取开始,其他线程就无法访问变量,直到写入完成。这些原子操作通常伴随着内存屏障;我们已经在程序中使用了它们,以确保不仅仅是原子操作,所有其他操作都是同步的,并且没有数据竞争。

原子操作最简单但有用的应用是计数。我们经常需要在程序中计数,在并发程序中,我们可能需要计数在多个线程上可能发生的一些事件。如果我们只对所有线程完成后的事件总数感兴趣,这最好由我们之前看到的“非共享”或线程特定的计数器来处理。但如果有所有线程都需要知道当前计数的情况呢?我们总是可以使用互斥锁,但使用互斥锁来保护一个整数的简单增加是非常低效的。C++为我们提供了一种更好的方法,即原子计数器:

// Example 15
std::atomic<size_t> count;
void thread_work() {
  size_t current_count = 0;
  if (... counted even ...) {
    current_count =
      count.fetch_add(1, std::memory_order_relaxed);
  }
}

在这个例子中只有一个共享变量,即count本身。由于我们没有其他共享数据,所以我们不需要内存屏障(“宽松”内存顺序意味着对其他数据的访问顺序没有要求)。fetch_add()操作是一个原子增加操作,它将count增加 1 并返回count的旧值。

原子计数也可以用来让多个线程在无需加锁的情况下对同一数据结构进行操作:为此,我们需要确保只有一个线程在处理数据结构中的每个元素。以这种方式使用时,该模式通常被称为原子索引。在下一个示例中,我们有一个所有线程共享的数据数组:

// Example 16
static constexpr size_t N = 1024;
struct Data { ... };
Data data[N] {};

我们还有一个原子索引,由所有需要将工作结果存储在数组中的线程使用。为了安全地这样做,每个线程都会增加一个原子索引,并使用预增量值作为数组的索引。由于没有两个原子增量操作会产生相同的值,因此每个线程都会得到自己的数组元素来处理:

// Example 16
std::atomic<size_t> index(0);
// Many producer threads
void produce(size_t& n) {
  while (... more work … ) {
    const size_t s =
      index.fetch_add(1, std::memory_order_relaxed);
    if (s >= N) return;     // No more space
    data[s] = ... results ...
  }
}

每个线程可以初始化尽可能多的数组元素,并在它(以及所有其他线程)填满整个数组时停止。主线程必须等待所有工作完成才能访问任何结果。仅使用原子索引是无法做到这一点的,因为它是当线程开始处理特定数组元素时增加的,而不是当线程完成工作后。我们必须使用其他同步机制来使主线程等待直到所有工作完成,例如一个闩锁,或者在简单情况下,将生产者线程连接起来:

// Example 16
void main_thread() {
  constexpr size_t nthread = 5;
  std::thread t[nthread];
  for (size_t i = 0; i != nthread; ++i) {
    t[i] = std::thread(produce);
  }
  // Wait for producers. to finish.
  for (size_t i = 0; i != nthread; ++i) {
    t[i].join();
  }
  ... all work is done, data is ready ...
}

当我们不依赖于计数值来访问已生成的结果时,原子计数是好的。在上一个示例中,生产者线程不需要访问其他线程计算出的数组元素,主线程在访问结果之前等待所有线程完成。通常情况下并非如此,我们需要在数据生成过程中访问数据。这就是内存屏障发挥作用的地方。

依赖于内存屏障的最简单但出奇强大的无锁模式被称为发布协议。当有一个线程正在生产一些数据,当它准备好时,这些数据将被提供给一个或多个其他线程时,这种模式适用。该模式看起来是这样的:

// Example 17
std::atomic<Data*> data;
void produce() {
  Data* p = new Data;
  ... complete *p object ...
  data.store(p, std::memory_order_release);
}
void consume() {
  Data* p = nullptr;
  while (!(p = data.load(std::memory_order_acquire))) {}
  ... safe to use *p ...
}

共享变量是一个指向数据的原子指针。它通常被称为“根指针”,因为数据本身可能是一个复杂的包含多个指针连接其部分的数据结构。这种模式的关键要求是只能通过根指针访问整个数据结构。

生产者线程构建它需要生产的所有数据。它使用一个线程特定的指针,通常是一个局部变量,来访问数据。由于根指针没有指向它,并且生产者线程的局部指针没有与其他线程共享,因此其他线程还看不到这些数据。

最后,当数据完成时,生产者将数据指针原子地存储在共享的根指针中。通常说生产者原子地发布数据,因此这种模式被称为发布协议。

消费者必须等待数据被发布:只要根指针为空,他们就没有事情可做。他们等待根指针变为非空(等待不必使用轮询,也可以使用通知机制)。一旦数据被发布,消费者线程可以通过根指针访问它。因为没有其他同步机制,一旦数据被发布,就没有线程可以修改数据(数据可能包含互斥锁或其他机制,允许其部分安全地修改)。

原子变量本身不足以保证这种模式没有数据竞争:所有线程不仅访问原子指针,还访问它指向的内存。这就是为什么我们需要特定的内存屏障:当发布数据时,生产者使用释放屏障不仅原子地初始化指针,还确保在指针上的原子写操作之前所做的所有内存修改都对读取指针新值的任何人可见。消费者使用获取屏障确保在读取指针新值之后对共享数据的任何操作都观察到数据发布时共享数据的最新状态。换句话说,如果你读取指针的值然后解引用它,你通常不知道你是否会得到指针指向的数据的最新值。但如果你使用获取屏障读取指针(并且指针是用释放屏障写入的),那么你可以确信你会读取(获取)最后写入(发布)的数据。释放和获取屏障共同保证消费者看到的共享数据与生产者在发布根指针中数据地址时的所见完全一致。

同样的模式可以用来发布线程间共享的更大数据结构的完成元素。例如,我们可以有一个生产者线程发布它用结果初始化了多少个数组元素:

// Example 18
constexpr size_t N = ...;
Data data[N];     // Shared, not locked
std::atomic<size_t> size;
void produce() {
  for (size_t n = 0; n != N; ++n) {
    data[n] = ... results ...
    size.store(n, std::memory_order_release);
  }
}
void consume() {
  size_t n = 0;
  do {
    n = size.load(std::memory_order_acquire);
    ... n elements are safe to access ...
  } while (n < N - 1);
}

这个想法与前面的例子完全相同,只是我们使用数组中的索引而不是指针。在两种情况下,我们都有一个计算并发布数据的生产者线程,以及一个或多个等待数据发布的消费者线程。如果我们需要多个生产者,我们必须使用其他同步机制来确保它们不会在相同的数据上工作,例如我们刚刚看到的原子索引。

在具有多个生产者和消费者线程的程序中,我们通常必须结合使用几种同步模式。在下一个例子中,我们有一个大型的共享数据结构,它组织成一个指向各个元素的指针数组。几个生产者线程用结果填充这个数据结构;我们将使用原子索引来确保每个元素只由一个生产者处理:

// Example 19
static constexpr size_t N = 1024;
struct Data { ... };
std::atomic<Data*> data[N] {};
std::atomic<size_t> size(0);     // Atomic index
void produce() {
  Data* p = new Data;
  ... compute *p ...
  const size_t s =
    size.fetch_add(1, std::memory_order_relaxed);
  data[s].store(p, std::memory_order_release);
}

我们的生成器计算结果,然后获取当前索引值,同时增加索引,以便下一个生成器不能获得相同的索引值。因此,数组槽data[s]是唯一为这个生成器线程保留的。这足以避免生产者之间的共享冲突,但消费者不能使用相同的索引来知道数组中已经有多少元素:索引在相应的数组元素初始化之前增加。对于消费者,我们使用发布协议:每个数组元素都是一个原子指针,直到数据发布之前保持为 null。消费者必须在可以访问数据之前等待指针变为非 null:

// Example 19
void consumer() {
  for (size_t i = 0; i != N; ++i) {
    const Data* p =
      data[i].load(std::memory_order_acquire);
    if (!p) break; // No more data
    ... *p is safe to access ...
  }
}

在这个例子中,消费者一旦找到不准备好的数据元素就会停止。我们可以继续扫描数组:一些后续的元素可能已经准备好了,因为它们被另一个生产者线程填充。如果我们这样做,我们必须以某种方式记住回来处理我们遗漏的元素。正确的方法当然取决于我们需要解决的问题。

无锁编程的文献非常丰富,充满了(通常是)非常复杂的例子。我们展示的并发模式只是更复杂数据结构和数据同步协议的基本构建块。

在下一节中,我们将看到一些适用于此类数据结构或甚至整个程序及其主要组件设计的更高层次的模式。

并发设计模式和指南

设计和实现并发软件是困难的。即使是控制对共享数据访问的基本模式,如我们在上一节中看到的,也是复杂的,充满了微妙的细节。未能注意到这些细节通常会导致难以调试的数据竞争。为了简化编写并发程序的任务,编程社区提出了几项指南。所有这些指南都源于早期的灾难性经验,因此请认真对待这些指南。这些指南的核心是线程安全保证的概念。

线程安全保证

虽然这并不是一个模式,但这是一个范围更广的概念,并且是任何并发软件的关键设计原则之一。任何并发程序中的每个类、函数、模块或组件都应该指定它提供的线程安全保证,以及它对其使用的组件所要求的保证。

通常,一个软件组件可以提供三个级别的线程安全保证:

  • 强线程安全保证:任何数量的线程都可以无限制地访问此组件,而不会遇到未定义的行为。对于一个函数,这意味着任何数量的线程都可以同时调用此函数(可能对参数有一些限制)。对于一个类,这意味着任何数量的线程都可以并发调用此类的成员函数。对于一个更大的组件,任何数量的线程都可以操作其接口(同样,可能有一些限制)。这样的组件、类和数据结构有时被称为线程安全。

  • (const 成员函数)。在任何时候,只有一个线程可以修改组件的状态,锁定或其他确保这种独占访问的方式是调用者的责任。这样的组件、类和数据结构有时被称为线程兼容,因为你可以使用适当的同步机制从它们构建并发程序。所有 STL 容器都提供这种级别的保证。

  • 无线程安全保证:此类组件根本不能用于并发程序,有时被称为线程敌对。这些类和函数通常具有隐藏的全局状态,无法以线程安全的方式访问。

通过设计每个组件以提供一定的线程安全保证,我们可以将使整个程序线程安全这一难以处理的问题分解为一系列设计挑战,其中更复杂的组件利用简单组件提供的保证。这一过程的核心是事务接口的概念。

事务接口设计

事务接口设计理念非常简单:每个组件都应该有一个接口,使得每个操作都是一个原子事务。从程序其他部分的角度来看,操作要么尚未发生,要么已经完成。在操作期间,没有其他线程可以观察到组件的状态。这可以通过互斥锁或其他适合需求的同步方案来实现——特定的实现可以影响性能,但只要接口保证了事务处理,它就不是正确性的关键。

这个指南对于设计并发程序的数据结构非常有用。在这里,它如此重要,以至于普遍认为,不能设计一个不提供事务接口(至少不是一个有用的数据结构)的线程安全数据结构。例如,我们可以考虑一个队列。C++ 标准库提供了一个 std::queue 模板。与其他任何 STL 容器一样,它提供弱保证:只要没有线程调用任何非 const 方法,任意数量的线程都可以调用队列的 const 方法。或者,任何单个线程都可以调用一个非 const 方法。为了确保后者,我们必须使用外部互斥锁锁定对队列的所有访问。如果我们想追求这种方法,我们应该将队列和互斥锁组合在一个新的类中:

template <typename T> class ts_queue {
  std::mutex m_;
  std::queue<T> q_;
  public:
  ts_queue() = default;
  ...
};

要将另一个元素推送到队列中,我们需要锁定互斥锁,因为 push() 成员函数会修改队列:

template <typename T> class ts_queue {
  public:
  void push(const T& t) {
    std::lock_guard l(m_);
    q_.push(t);
  }
};

这完全符合我们的预期:任意数量的线程都可以调用 push(),每个元素将正好被添加到队列中一次(如果同时发生多个调用,顺序将是任意的,但这并发性的本质)。我们已经成功提供了强线程安全保证!

不幸的是,这种成功将是短暂的。让我们看看从队列中弹出元素需要什么。有一个成员函数 pop() 可以从队列中移除元素,因此我们可以用相同的互斥锁保护它:

template <typename T> class ts_queue {
  public:
  void pop() {
    std::lock_guard l(m_);
    q_.pop();
  }
};

注意到这个函数不返回任何内容:它移除队列中最旧的元素并销毁它,但这并不是我们需要找出该元素是什么(或曾经是什么)的原因。为此,我们需要使用返回对最旧元素引用但不修改队列的 front() 函数。它是一个 const 成员函数,因此我们只需要在同时调用任何非 const 函数时锁定它;我们现在将忽略这种优化可能性,并始终锁定这个调用:

template <typename T> class ts_queue {
  public:
  T& front() const {
    std::lock_guard l(m_);
    return q_.front();
  }
};

如果我们从多个线程调用 front() 而不调用任何其他函数,这种实现是次优的,但并不错误。

我们忽略了一个特殊情况:如果队列为空,你不应该调用 pop()front() – 根据标准,这样做会导致未定义的行为。你如何知道从队列中弹出元素是否安全?你可以检查队列是否为空。这是一个另一个 const 成员函数,我们再次将过度保护它并锁定对它的每个调用:

template <typename T> class ts_queue {
  public:
  bool empty() const {
    std::lock_guard l(m_);
    return q_.empty();
  }
};

现在,底层 std::queue 的每个成员函数都由互斥锁保护。我们可以从任意数量的线程中调用它们,并保证在任何时候只有一个线程可以访问队列。技术上,我们已经实现了强保证。不幸的是,这并不非常实用。

为了理解原因,让我们考虑从队列中移除一个元素的过程:

ts_queue<int> q;
int i = 0;
if (!q.empty()) {
  i = q.front();
  q.pop();
}

在单线程上这工作得很好,但我们并不需要互斥锁来处理它。当我们有两个线程时,一个线程将新元素推入队列,另一个线程从队列中取出元素,它仍然(大多数情况下)可以正常工作。让我们考虑当两个线程都试图各自弹出队列中的一个元素时会发生什么。首先,它们都调用了empty()。假设队列不为空,并且两次调用都返回true。然后,它们都调用了front()。由于两个线程都没有调用过pop(),它们都得到了相同的队首元素。这不是我们想要的结果,如果我们希望每个线程都能从队列中弹出一个元素的话。最后,两个线程都调用了pop(),队列中移除了两个元素。其中有一个元素我们从未见过,也永远不会再见到,所以我们丢失了一些已经入队的资料。

但这并不是唯一可能出错的方式。如果队列中只有一个元素会发生什么?两次对empty()的调用仍然返回true——一个只有一个元素的队列不是空的。两次对front()的调用仍然返回(相同的)队首元素。第一次对pop()的调用成功,但第二次调用是未定义的行为,因为队列现在为空了。也有可能一个线程在另一个线程调用front()之前调用pop(),但在调用empty()之后。在这种情况下,第二次对front()的调用也是未定义的。

我们有一个既安全又毫无用处的数据结构。显然,线程安全保证是不够的。我们还需要一个不会暴露我们于未定义行为的接口,而实现这一点的唯一方法是在单个临界区中执行弹出操作的所有三个步骤(empty()front()pop()),即在调用之间不释放互斥锁。除非我们希望调用者提供自己的互斥锁,否则实现这一点的唯一方法就是改变我们的ts_queue类的接口:

// Example 20
template <typename T> class ts_queue {
  std::queue<T> q_;
  std::mutex m_;
  public:
  ts_queue() = default;
  template <typename U> void push(U&& u) {
    std::lock_guard l(m_);
    q_.push(std::forward<U>(u));
  }
  std::optional<T> pop() {
    std::lock_guard l(m_);
    if (q_.empty()) return {};
    std::optional<T> res(std::move(q_.front()));
    q_.pop();
    return res;
 }
};

push()函数与之前相同(我们使参数类型更加灵活,但这与线程安全无关)。我们不需要改变推送操作的原因是它已经是事务性的:在操作结束时,队列比操作开始时多了一个元素,队列的其他状态保持不变。我们只是通过互斥锁来保护它,使其原子化(没有其他正确使用相同互斥锁的线程可以观察到我们的队列在其过渡的非不变状态)。

pop()操作是事务性接口看起来非常不同的地方。为了提供一个有意义的线程安全性保证,我们必须提供一个返回前端元素给调用者并从队列中原子性地移除它的操作:其他线程不应该能够看到相同的前端元素,因此,我们必须使用相同的互斥锁锁定原始队列上的front()pop()。我们还必须考虑队列可能为空且我们没有前端元素可以返回给调用者的可能性。在这种情况下我们返回什么?如果我们决定通过值返回前端元素,我们就必须默认构造这个值(或者返回一些其他商定的值,表示“没有元素”)。在 C++17 中,更好的方法是返回一个包含前端元素的std::optional,如果有的话。

现在pop()push()都是原子性和事务性的:我们可以从任意多的线程中调用这两个方法,结果总是良好定义的。

你可能会想知道为什么std::queue一开始没有提供这种事务性接口。首先,STL 是在线程进入标准之前设计的。但另一个非常重要的原因是,队列接口受到了提供异常安全性的需求的影响。异常安全性是保证在抛出异常时对象保持良好定义状态的保证。在这里,原始的队列接口做得很好:empty()只是返回大小,不会抛出异常,front()返回前端元素的引用,也不会抛出异常,最后pop()调用前端元素的析构函数,这通常也不会抛出异常。当然,当访问前端元素时,调用者的代码可能会抛出异常(例如,如果调用者需要将前端元素复制到另一个对象中),但预期调用者会处理这种情况。无论如何,队列本身保持在一个良好定义的状态。

我们的线程安全队列,然而,存在一个异常安全性问题:将队列的前端元素复制以返回给调用者的代码现在位于pop()函数内部。如果在构造局部std::optional变量res期间抛出异常,我们可能没问题。然而,如果在将结果返回给调用者时抛出异常(这可能通过移动或复制发生),那么pop()操作已经完成,因此我们将丢失刚刚从队列中弹出的元素。

在线程安全和异常安全之间的这种紧张关系通常是不可避免的,在设计用于并发程序的线程安全数据结构时必须考虑。无论如何,必须重申,设计线程安全数据结构或更大的模块的唯一方法是要确保每个接口调用都是一个完整的交易:任何条件定义的步骤必须与确保满足这些条件所需的操作一起打包成一个单一的交易调用。然后,整个调用应该由互斥锁或其他确保无竞争的独占访问的方式保护。

设计线程安全数据结构通常非常困难,尤其是如果我们想要良好的性能(如果我们不追求并发,那么并行的意义何在呢?)。这就是为什么利用任何使用限制或特殊要求来限制这些数据结构的使用方式非常重要。在下一节中,我们将看到这种限制的一个常见案例。

具有访问限制的数据结构

设计线程安全数据结构如此困难,以至于我们应该寻找任何机会简化要求和实现。如果你现在不需要任何场景,想想如果你不支持该场景,你是否可以使你的代码更简单。一个明显的例子是,一个由单个线程构建的数据结构(不需要线程安全保证),然后变为不可变,并被许多作为读者的线程访问(一个弱保证就足够了)。例如,任何 STL 容器都可以以这种方式运行。我们仍然需要确保在容器被填充数据时,没有读者可以访问容器,但这可以通过一个屏障或条件轻松完成。这是一个非常有用但相当简单的情况。我们还能利用哪些其他限制?

在本节中,我们考虑一个相当常见且允许使用更简单数据结构的特定用例。具体来说,我们考察了当某个数据结构仅由两个线程访问的情况。一个线程是生产者,它向数据结构添加数据。另一个线程是消费者,它从数据结构中移除数据。两个线程都会修改数据结构,但方式不同。这种情况相当常见,并且往往允许非常专业且非常高效的数据结构实现。这或许值得被认可为并发设计的设计模式,并且它已经有一个普遍认可的名字:“单生产者单消费者数据结构。”

在本节中,我们将看到一个单生产者单消费者队列的例子。这是一个经常与一个生产者和一个消费者线程一起使用的数据结构,但我们在这里探讨的思想也可以用来设计其他数据结构。这个队列的主要区别特征将是它是无锁的:它根本不包含互斥锁,因此我们可以期望从它那里获得更高的性能。

队列是基于一个固定大小的数组构建的,因此,与普通队列不同,它不能无限增长(这是用于简化无锁数据结构的一个常见限制):

// Example 21
template <typename T, size_t N> class ts_queue {
  T buffer_[N];
  std::atomic<size_t> back_{0};
  std::atomic<size_t> front_{N - 1};
  ...
};

在我们的例子中,我们默认构造数组中的元素。如果这不可取,我们也可以使用一个正确对齐的未初始化缓冲区。所有对队列的访问都由两个原子变量back_front_决定。前者是我们将新元素推入队列时将要写入的数组元素的索引。后者是我们需要从队列中弹出元素时将要读取的数组元素的索引。范围[front_, back_]内的所有数组元素都填充了队列上的当前元素。请注意,这个范围可以绕过缓冲区的末尾:在使用了buffer_[N-1]元素之后,队列并没有用完空间,而是从buffer_[0]重新开始。这被称为循环缓冲区

我们如何使用这些索引来管理队列?让我们从推入操作开始:

// Example 21
template <typename T, size_t N> class ts_queue {
  public:
  template <typename U> bool push(U&& u) {
    const size_t front =
      front_.load(std::memory_order_acquire);
    size_t back = back_.load(std::memory_order_relaxed);
    if (back == front) return false;
    buffer_[back] = std::forward<U>(u);
    back_.store((back + 1) % N, std::memory_order_release);
    return true;
  }
};

当然,我们需要读取back_的当前值:这是我们即将写入的数组元素的索引。我们只支持一个生产者,只有生产者线程可以增加back_,因此在这里我们不需要特别的预防措施。然而,我们需要小心避免覆盖队列中已经存在的任何元素。为此,我们必须检查front_的当前值(我们可以在读取back_之前或之后读取它,这没有关系)。如果我们即将覆盖的buffer_[back]元素也是队首元素,那么队列已满,push()操作将失败(请注意,对于这个问题还有另一种常用的解决方案:如果队列已满,最老的元素将被静默覆盖并丢失)。在存储新元素后,我们原子性地增加back_的值,以通知消费者这个槽位现在可以读取。因为我们正在发布这个内存位置,所以我们必须使用释放屏障。此外,请注意模运算:在达到数组元素N-1之后,我们将循环回到元素 0。

接下来,让我们看看pop()操作:

// Example 21
template <typename T, size_t N> class ts_queue {
  public:
  std::optional<T> pop() {
    const size_t back =
      back_.load(std::memory_order_acquire);
    const size_t front =
     (front_.load(std::memory_order_relaxed) + 1) % N;
    if (front == back) return {};
    std::optional<T> res(std::move(buffer_[front]));
    front_.store(front, std::memory_order_release);
    return res;
  }
};

再次强调,我们需要读取front_back_两个部分:front_是我们即将读取的元素的索引,只有消费者可以前进这个索引。另一方面,back_是确保我们实际上有一个元素可以读取所必需的:如果前一个和后一个索引相同,那么队列就是空的;再次强调,我们使用std::optional来返回可能不存在的一个值。在读取back_时,我们必须使用获取屏障来确保我们看到生产者线程写入数组中的元素值。最后,我们前进front_以确保我们不会再次读取相同的元素,并使这个数组槽位对生产者线程可用。

这里有几个必须指出的微妙细节。读取back_front_不是在一个单一的事务中完成的(它不是原子的)。特别是,如果生产者首先读取front_,那么当它读取back_并与两个值进行比较时,消费者可能已经前进front_了。这并不会使我们的数据结构出错。最坏的情况是,生产者可能会报告队列已满,而实际上它已经不再满了。我们可以原子地读取这两个值,但这只会降低性能,调用者仍然需要处理队列已满的情况。同样,当pop()报告队列为空时,到调用完成时它可能已经不再为空了。这些是并发不可避免的复杂性:每个操作都反映了数据在某个时间点的状态。当调用者获取返回值并分析它时,数据可能已经发生了变化。

另一个值得注意的细节是对队列元素生命周期的谨慎管理。我们在数组中默认构造所有元素,因此在push()期间从调用者将数据传输到队列的正确方式是通过复制或移动赋值(std::forward可以完成这两者)。另一方面,一旦pop()将值返回给调用者,我们就不再需要那个值了,所以这里的正确操作是移动,首先移动到optional中,然后移动到调用者的返回值对象中。请注意,移动一个对象与销毁它不同;实际上,被移动的数组元素直到队列本身被销毁时才被销毁。如果一个数组元素被重用,它会被复制或移动赋值一个新的值,而赋值是可以在移动后的对象上安全执行的三种操作之一(第三种是析构函数,我们最终也会调用它)。

单生产者单消费者模式是一种常见的模式,它允许程序员极大地简化他们的并发数据结构。还有其他模式,你可以在专门针对并发数据结构的书籍和论文中找到它们。所有这些模式最终都是为了帮助你编写在多线程访问时能够正确且高效执行的数据结构。然而,我们必须继续前进,最终解决使用这些线程来完成一些有用工作的问题。

并发执行模式

我们必须学习的下一个并发模式组是执行模式。这些模式用于组织在多个线程上完成的计算。你会发现,就像我们之前看到的同步模式一样,所有这些都是低级模式:大多数实际问题的解决方案必须将这些模式组合成更大、更复杂的结构。这并不是因为 C++不适合这样的更大设计;实际上,情况正好相反:在 C++中有许多方法来实现,例如线程池,对于每个具体的应用,都有一个在性能和功能方面都理想的版本。这就是为什么很难将这些更完整的解决方案描述为模式:虽然它们解决的问题很常见,但解决方案有很大的不同。但所有这些设计都有许多挑战需要解决,而解决这些挑战的解决方案通常反复使用相同的工具,因此我们至少可以将这些更基本的挑战和它们的常见解决方案描述为设计模式。

活动对象

我们将要看到的第一个并发执行模式是活动对象。活动对象通常封装要执行的代码、执行所需的数据以及执行代码所需的控制流。这种控制流可能只是一个对象启动并连接的单独线程。在大多数情况下,我们不会为每个任务启动一个新的线程,所以活动对象会有某种方式在其多线程执行器(如线程池)上运行其代码。从调用者的角度来看,活动对象是一个调用者构建的对象,用数据初始化,然后告诉对象执行自己,执行是异步发生的。

基本的活动对象看起来像这样:

// Example 22
class Job {
  ... data ...
  std::thread t_;
  bool done_ {};
  public:
  Job(... args ...) { ... initialize data ... }
  void operator()() {
    t_ = std::thread([this](){ ... computations ... }
  );
  }
  void wait() {
    if (done_) return;
    t_.join();
    done_ = true;
  }
  ~Job() { wait(); }
  auto get() { this->wait(); return ... results ...; }
};
Job j(... args ...);
j();     // Execute code on a thread
... do other work ...
std::cout << j.get();  // Wait for results and print them

在这里展示的最简单的情况下,活动对象包含一个用于异步执行代码的线程。在大多数实际情况下,你会使用一个执行器,它在它管理的线程之一上调度工作,但这使我们陷入了实现特定的细节。执行开始于调用operator()时;我们也可以通过在构造函数中调用operator()来使对象在构造时立即执行。在某个时刻,我们必须等待结果。如果我们使用一个单独的线程,我们可以在那时连接线程(并且要注意,如果调用者再次调用wait(),不要尝试两次连接它)。如果对象代表的是一个线程池或某些其他执行器中的任务,而不是线程,我们会进行必要的清理工作。

正如你所见,一旦我们确定了一种特定的异步执行代码的方式,编写具有不同数据和代码的活动对象就变得相当重复。没有人像我们刚才那样编写活动对象,我们总是使用某种通用的可重用框架。实现此类框架有两种一般方法。第一种使用继承:基类执行样板工作,而派生类包含独特的任务特定数据和代码。继续使用我们简单的活动对象方法,我们可以将基类编写如下:

// Example 23
class Job {
  std::thread t_;
  bool done_ {};
  virtual void operator()() = 0;
  public:
  void wait() {
    if (done_) return;
    t_.join();
    done_ = true;
  }
  void run() {
    t_ = std::thread([this](){ (*this)(); });
  }
  virtual ~Job() { wait(); }
};

基础对象 Job 包含实现异步控制流所需的一切:用于仅一次连接线程的线程和状态标志。它还定义了通过调用非虚函数 run() 来执行代码的方式。在线程上执行的代码必须由派生对象通过重载 operator() 提供。请注意,只有 run() 是公开的,而 operator() 则不是:这就是非虚语法的实际应用(我们在 第十四章模板方法模式和 非虚语法)中看到了它)。

派生对象是针对特定问题的,但通常看起来是这样的:

// Example 23
class TheJob final : public Job {
  ... data ...
  void operator()() override { ... work ... }
  public:
  TheJob(... args ...) {
    ... initialize data ...
    this->run();
  }
  auto get() { this->wait(); return ... results ...; }
};

这里的唯一细微差别是在派生对象构造函数结束时对 run() 的调用。这不是必需的(我们可以稍后自己执行活动对象)但如果我们想让构造函数运行它,就必须在派生类中完成。如果我们从基类构造函数开始启动线程和异步执行,那么我们将在线程上的执行(operator())和派生类构造函数中继续的其余初始化之间产生竞争。出于同样的原因,从构造函数开始执行的活动对象不应再次派生;我们通过使对象成为最终类来确保这一点。

使用我们的活动对象非常简单:我们创建它,对象开始在后台(在单独的线程)执行代码,当我们需要结果时,我们请求它(这可能涉及等待):

// Example 23
TheJob j1(... args ...);
TheJob j2(... args ...);
... do other stuff ...
std::cout << "Results: " << j1.get() << " " << j2.get();

如果你曾经在任何 C++ 并发代码中编写过代码,那么你肯定已经使用过活动对象了:std::thread 是一个活动对象,它让我们能够在单独的线程上执行任意代码。对于 C++,有并发库,其中线程是一个基础对象,所有具体的线程都从中派生出来。但 C++ 标准线程并没有选择这种方法。它遵循实现可重用活动对象的第二种方式:类型擦除。如果你需要熟悉它,请重新阅读 第六章理解类型擦除。尽管 std::thread 本身就是一个类型擦除的活动对象,但我们还是将实现自己的,以展示设计(标准库代码相当难以阅读)。这次,没有基类。框架由一个单一类提供:

// Example 24
class Job {
  bool done_ {};
  std::function<void()> f_;
  std::thread t_;
  public:
  template <typename F> explicit Job(F&& f) :
    f_(f), t_(f_) {}
  void wait() {
     if (done_) return;
     t_.join();
     done_ = true;
  }
  ~Job() { wait(); }
};

要实现类型擦除的可调用对象,我们使用std::function(我们也可以使用第六章理解类型擦除中更有效的实现之一,或者按照相同的方法自己实现类型擦除)。调用者提供的要在线程上执行的代码来自构造函数参数中的可调用对象f。请注意,类成员的顺序非常重要:异步执行是在线程t_初始化后立即开始的,因此其他数据成员,特别是可调用对象f_,必须在那时之前初始化。

要使用这种风格的主动对象,我们需要提供一个可调用对象。它可以是 lambda 表达式或命名对象,例如:

// Example 24
class TheJob {
  ... data ...
  public:
  TheJob(... args ...) { ... initialize data ... }
  void operator()() { // Callable!
    ... do the work ...
  }
};
Job j(TheJob(... args ...));
j.wait();

注意,在这个设计中,没有简单的方法可以直接访问可调用对象TheJob的数据成员,除非它被创建为一个命名对象。因此,结果通常是通过构造函数传入的引用参数返回的(这与我们使用std::thread的方式相同):

// Example 24
class TheJob {
  ... data ...
  double& res_; // Result
  public:
  TheJob(double& res, ... args ...) : res_(res) {
    ... initialize data ...
  }
  void operator()() { // Callable!
    ... do the work ...
    res_ = ... result ...
  }
};
double res = 0;
Job j(TheJob(res, ... args ...));
j.wait();
std::cout << res;

主动对象可以在每个并发 C++程序中找到,但它们的一些用法是常见和专门的,因此被认可为它们自己的并发设计模式。我们现在将看到这些模式中的几个。

反应器对象模式

反应器模式通常用于事件处理或响应服务请求。它解决了一个特定问题,即我们有多条针对某些操作的请求,这些请求由多个线程发出;然而,这些操作的本质是,至少部分操作必须在某个线程上执行或进行同步。反应器对象是处理这些请求的对象:它接受来自多个线程的请求并执行它们。

这里是一个反应器的例子,它可以接受请求以使用调用者提供的输入执行特定的计算,并将结果存储起来。请求可以来自任意数量的线程。每个请求都会在结果数组中分配一个槽位——这就是必须在所有线程之间同步的部分。在槽位分配后,我们可以并发地进行计算。为了实现这个反应器,我们将使用原子索引为每个请求分配唯一的数组槽位:

// Example 25
class Reactor {
  static constexpr size_t N = 1024;
  Data data_[N] {};
  std::atomic<size_t> size_{0};
  public:
  bool operator()(... args ...) {
    const size_t s =
      size_.fetch_add(1, std::memory_order_acq_rel);
    if (s >= N) return false;  // Array is full
    data_[s] = ... result ...;
    return true;
  }
  void print_results() { ... }
};

operator()的调用是线程安全的:任意数量的线程可以同时调用此操作符,并且每个调用都会将计算结果添加到下一个数组槽位,而不会覆盖其他调用产生的任何数据。要从对象中检索结果,我们可以等待所有请求完成,或者实现另一种同步机制,例如发布协议,以使对operator()print_results()的调用在彼此之间是线程安全的。

注意,通常,反应器对象异步处理请求:它有一个单独的线程来执行计算,并且有一个队列来将所有请求通道到一个线程。我们可以通过结合我们之前看到的几个模式来构建这样的反应器,例如,我们可以向基本反应器添加一个线程安全的队列来得到一个异步反应器(我们很快将看到一个这样的设计示例)。

到目前为止,我们专注于启动和执行工作,然后等待工作完成。下一个模式专注于处理异步任务的完成。

Proactor 对象模式

Proactor 模式用于通过一个或多个线程的请求来执行异步任务,通常是长时间运行的任务。这听起来很像反应器,但区别在于任务完成时发生的情况:在反应器的情况下,我们只需要等待工作完成(等待可以是阻塞的或非阻塞的,但在所有情况下,调用者都启动了完成检查)。Proactor 对象将每个任务与一个回调关联,当任务完成时,回调异步执行。反应器和 Proactor 是处理并发任务完成的同步和异步解决方案。

Proactor 对象通常有一个任务队列,用于异步执行任务,或者使用另一个执行器来调度这些任务。每个任务都提交一个回调,通常是可调用的。当任务完成时执行回调;通常,执行任务的同一线程也会调用回调。由于回调总是异步的,如果它需要修改任何共享数据(例如,任何由提交任务到 Proactor 的线程访问的数据),必须以线程安全的方式访问。

这里是一个使用上一节中线程安全队列的 Proactor 对象的示例。在这个例子中,每个任务接受一个整数作为输入并计算一个 double 结果:

// Example 26
class Proactor {
  using callback_t = std::function<void(size_t, double)>;
  struct op_task {
    size_t n;
    callback_t f;
  };
  std::atomic<bool> done_{false}; // Must come before t_
  ts_queue<op_task> q_;           // Must come before t_
  std::thread t_;
  public:
  Proactor() : t_([this]() {
    while (true) {
      auto task = q_.pop();
      if (!task) {                // Queue is empty
        if (done_.load(std::memory_order_relaxed)) {
          return;                 // Work is done
        }
        continue;                 // Wait for more work
      }
      ... do the work ...
      double x = ... result ...
      task->f(n, x);
    } // while (true)
  }) {}
  template <typename F>
  void operator()(size_t n, F&& f) {
    q_.push(op_task{n, std::forward<F>(f)});
  }
  ~Proactor() {
    done_.store(true, std::memory_order_relaxed);
    t_.join();
  }
};

队列存储工作请求,这些请求由输入和可调用对象组成;任何数量的线程都可以调用 operator() 将请求添加到队列中。一个更通用的 Proactor 可能会接受一个可调用对象作为工作请求,而不是将计算编码到具体的 Proactor 对象中。Proactor 按顺序在一个线程上执行所有请求。当请求的计算完成时,线程调用回调并将结果传递给它。这就是我们可能使用这样的 Proactor 对象的方式:

// Example 26
Proactor p;
for (size_t n : ... all inputs ...) {
  p(n, [](double x) { std::cout << x << std::endl; });
}

注意,我们的 Proactor 在一个线程上执行所有回调,主线程不做任何输出。否则,我们就必须用互斥锁来保护 std::cout

Proactor 模式用于执行异步事件,并在这些事件发生时执行附加操作(回调)。在本节中我们探索的最后一个模式不执行任何操作,而是用于对外部事件做出反应。

监视器模式

当我们需要观察或监控某些条件并对某些事件做出响应时,使用监视器模式。通常,监视器在自己的线程上运行,该线程大部分时间处于休眠或等待状态。线程可以通过通知或简单地通过时间的流逝而被唤醒。一旦唤醒,监视器对象将检查它被分配观察的系统状态。如果满足指定的条件,它可能会采取某些行动,然后线程返回等待状态。

我们将看到一种使用超时的监视器实现;具有条件变量的监视器可以使用相同的方法实现,但使用本章前面看到的等待通知模式。

首先,我们需要一些可以监控的东西。让我们假设我们有几个生产者线程执行一些计算并将结果存储在一个数组中,使用原子索引:

// Example 27
static constexpr size_t N = 1UL << 16;
struct Data {... data ... };
Data data[N] {};
std::atomic<size_t> index(0);
void produce(std::atomic<size_t>& count) {
  for (size_t n = 0; ; ++n) {
    const size_t s =
      index.fetch_add(1, std::memory_order_acq_rel);
    if (s >= N) return;
    const int niter = 1 << (8 + data[s].n);
    data[s] = ... result ...
    count.store(n + 1, std::memory_order_relaxed);
  }
}

我们的生产者还将线程计算的结果计数存储在传递给它的count变量中。以下是启动生产者线程的方式:

// Example 27
std::thread t[nthread];
std::atomic<size_t> work_count[nthread] = {};
for (size_t i = 0; i != nthread; ++i) {
  t[i] = std::thread(produce, std::ref(work_count[i]));
}

每个线程都有一个结果计数,因此每个生产者都有自己的计数来递增。那么,为什么我们使计数原子化呢?因为计数也是我们将要监控的内容:我们的监视器线程将定期报告完成的工作量。因此,每个工作计数被两个线程访问,即生产者和监视器,我们需要使用原子操作或互斥锁来避免数据竞争。

监视器将是一个独立的线程,它时不时地醒来,读取结果计数的值,并报告工作进度:

// Example 27
std::atomic<bool> done {false};
std::thread monitor([&]() {
  auto print = [&]() { ... print work_count[] ... };
  std::cout << "work counts:" << std::endl;
  while (!done.load(std::memory_order_relaxed)) {
    std::this_thread::sleep_for(
      std::chrono::duration<double, std::milli>(500));
    print();
  }
  print();
});

监视器可以在生产者线程之前启动,或者在我们需要监控工作进度时启动,它将报告每个生产者线程计算的结果数量,例如:

work counts:
1096 1083 957 1046 1116 -> 5298/65536
2286 2332 2135 2242 2335 -> 11330/65536
...
13153 13061 13154 12979 13189 -> 65536/65536
13153 13061 13154 12979 13189 -> 65536/65536

在这里,我们使用了五个线程来计算总共 64K 个结果,监视器报告了每个线程的计数和总结果计数。要关闭监视器,我们需要设置done标志并加入监视器线程:

// Example 27
done.store(true, std::memory_order_relaxed);
monitor.join();

监视器模式的另一种常见变体是,我们不是等待计时器,而是等待一个条件。这种监视器是基本监视器和我们在本章前面看到的等待通知模式的组合。

并发编程社区已经提出了许多其他用于解决与并发相关常见问题的模式;大多数这些模式都可以用于 C++程序,但它们并不特定于 C++。有一些 C++特有的功能,如原子变量,影响了我们实现和使用这些模式的方式。本章的示例应该足以指导你将任何其他并发模式适应到 C++中。

执行模式的描述基本上完成了对 C++并发模式的简要研究。在你翻到最后一页之前,我想向你展示一种完全不同类型的并发模式,这种模式刚刚进入 C++。

C++中的协程模式

协程是 C++ 中非常新的特性:它们是在 C++20 中引入的,目前的状态是构建库和框架的基础,而不是你应该直接在应用程序代码中使用的特性。这是一个复杂的特性,包含许多细微的细节,需要整整一章的篇幅来解释它(我的书《编写高效程序的艺术》中就有这样的章节)。简而言之,协程是可以暂停和恢复自己的函数。它们不能被强制暂停,协程会继续执行直到它自己暂停。它们用于实现所谓的协作多任务,在这种多任务中,多个执行流自愿地将控制权交给彼此,而不是被操作系统强制抢占。

本章中我们看到的每一个执行模式,以及更多,都可以使用协程来实现。然而,现在还过早地说这将成为 C++ 中协程的常见用法,所以我们不能确定“生产者协程”是否会成为一种模式。不过,协程的一个应用正在成为 C++ 中的一个新模式:协程生成器。

当我们想要将通常使用复杂循环完成的某些计算重写为迭代器时,这种模式就会发挥作用。例如,假设我们有一个三维数组,我们想要遍历它的所有元素并对它们进行一些计算。这用循环来做很容易:

size_t*** a; // 3D array
for (size_t i = 0; i < N1; ++i) {
  for (size_t j = 0; j < N2; ++j) {
    for (size_t k = 0; k < N3; ++k) {
      ... do work with a[i][j][k] ...
    }
  }
}

但以这种方式编写可重用代码是困难的:如果我们需要自定义对每个数组元素执行的工作,我们必须修改内部循环。如果我们有一个遍历整个三维数组的迭代器会容易得多。不幸的是,为了实现这个迭代器,我们必须将循环颠倒过来:首先,我们将 k 增加直到它达到 N3;然后,我们将 j 增加 1 并回到增加 k,依此类推。结果是代码非常复杂,许多程序员不得不依靠手指计数来避免一次性错误:

// Example 28
class Iterator {
  const size_t N1, N2, N3;
  size_t*** const a;
  size_t i = 0, j = 0, k = 0;
  bool done = false;
  public:
  Iterator(size_t*** a, size_t N1, size_t N2, size_t N3) :
    N1(N1), N2(N2), N3(N3), a(a) {}
  bool next(size_t& x) {
    if (done) return false;
    x = a[i][j][k];
    if (++k == N3) {
      k = 0;
      if (++j == N2) {
        j = 0;
        if (++i == N1) return (done = true);
      }
    }
    return true;
  }
};

我们甚至走了一个捷径,给我们的迭代器提供了一个非标准的接口:

// Example 28
Iterator it(a, N1, N2, N3);
size_t val;
while (it.next(val)) {
  ... val is the current array element ...
}

如果我们想要符合 STL 迭代器接口,实现过程会更加复杂。

这种问题,比如我们的嵌套循环在执行过程中必须暂停,以便调用者可以执行一些任意代码并恢复暂停的函数,非常适合协程。确实,一个产生与我们的迭代器相同序列的协程看起来非常简单和自然:

// Example 28
generator<size_t>
coro(size_t*** a, size_t N1, size_t N2, size_t N3) {
  for (size_t i = 0; i < N1; ++i) {
    for (size_t j = 0; j < N2; ++j) {
      for (size_t k = 0; k < N3; ++k) {
        co_yield a[i][j][k];
      }
    }
  }
}

就这样;我们有一个函数,它接受遍历 3D 数组所需的参数,一个常规的嵌套循环,并且我们对每个元素执行一些操作。秘密在于最内层的行,其中“一些操作”发生:C++20 关键字 co_yield 暂停协程并返回值 a[i][j][k] 给调用者。它与 return 操作符非常相似,但 co_yield 并不会永久退出协程:调用者可以恢复协程,并且执行从 co_yield 之后的下一行继续。

使用这种协程的方法也很直接:

// Example 28
auto gen = coro(a, N1, N2, N3);
while (true) {
  const size_t val = gen();
  if (!gen) break;
  ... val is the current array element ...
}

协程的魔法发生在由协程返回的生成器对象内部。其实现方式远非简单,如果你想要自己编写,你必须成为 C++ 协程的专家(通过阅读另一本书或文章来实现)。你可以在 示例 28 中找到一个非常简单的实现,并且,在协程的良好参考手册的帮助下,你可以逐行理解其内部工作原理。幸运的是,如果你只想编写像之前展示的那样简单的代码,你实际上并不需要学习协程的细节:有几个开源库提供了如生成器(接口略有不同)之类的实用类型,并且,在 C++23 中,std::generator 将被添加到标准库中。

虽然使用循环和 co_yield 编写协程确实比迭代器的复杂倒置循环更容易,但这种便利的代价是什么?显然,你必须要么编写一个生成器,要么在库中找到一个生成器,但一旦完成,协程还有其他缺点吗?一般来说,协程比常规函数涉及更多的工作,但生成的代码的性能很大程度上取决于编译器,并且可能会因为代码看似微不足道的更改而变化(就像任何编译器优化一样)。协程仍然相当新颖,编译器还没有为它们提供全面的优化。话虽如此,协程的性能可以与手工编写的迭代器相媲美。对于我们的 示例 28,当前(在撰写本文时)的 Clang 17 版本给出了以下结果:

Iterator time: 9.20286e-10 s/iteration
Generator time: 6.39555e-10 s/iteration

另一方面,GCC 13 给了迭代器一个优势:

Iterator time: 6.46543e-10 s/iteration
Generator time: 1.99748e-09 s/iteration

我们可以期待编译器在将来能更好地优化协程。

当我们想要生成的值序列没有预先限制,并且我们只想在需要时生成新元素(懒生成器)时,协程生成器的另一种变体是有用的。同样,协程的优势在于从循环内部向调用者返回结果的简单性。

这里是一个简单的随机数生成器,实现为一个协程:

// Example 29
generator<size_t> coro(size_t i) {
  while (true) {
    constexpr size_t m = 1234567890, k = 987654321;
    for (size_t j = 0; j != 11; ++j) {
      if (1) i = (i + k) % m; else ++i;
    }
    co_yield i;
  }
}

这个协程永远不会结束:它会挂起自己以返回下一个伪随机数i,每次它被恢复时,执行都会跳回无限循环。再次强调,生成器是一个相当复杂的对象,有很多样板代码,你最好从库中获取(或者等待 C++23)。但一旦完成,生成器的使用就非常简单:

// Example 29
auto gen = coro(42);
size_t random_number = gen();

每次调用gen(),你都会得到一个新的随机数(由于我们实现了一个最古老和最简单的伪随机数生成器,所以质量相当差,所以请将此示例仅用于说明)。生成器可以调用任意多次;当它最终被销毁时,协程也会被销毁。

在未来几年中,我们可能会看到更多利用协程的设计模式。目前,生成器是唯一确立的模式,而且刚刚确立,因此,在本书的 C++设计模式最后一章中,加入我们模式工具箱的最新补充是合适的。

摘要

在本章中,我们探讨了开发并发软件的常见 C++解决方案。与之前我们研究过的所有内容相比,这是一个非常不同类型的问题。我们在这里的主要关注点是正确性,特别是通过避免数据竞争,以及性能。同步模式是控制对共享数据访问的标准方式,以避免未定义的行为。执行模式是线程调度器和异步执行器的基本构建块。最后,并发设计的高级模式和指南是我们,程序员,在试图思考所有可能同时发生的事情之前、之后或同时发生的事情时保持理智的方法。

问题

  1. 什么是并发?

  2. C++如何支持并发?

  3. 什么是同步设计模式?

  4. 什么是执行设计模式?

  5. 对于并发程序的设计和架构,应该遵循哪些总体指南?

  6. 什么是事务接口?

评估

第一章,继承和多态简介

  1. 对象和类是 C++程序的基本构建块。通过将数据和算法(代码)组合成一个单一单元,C++程序表示了它所模拟的系统组件,以及它们的交互。

  2. 公共继承表示对象之间的is-a关系——派生类的对象可以像基类对象一样使用。这种关系意味着基类的接口,包括其不变性和限制,也是派生类的一个有效接口。与公共继承不同,私有继承没有关于接口的说明。它表达的是has-ais implemented in terms of关系。派生类重用了基类提供的实现。在大多数情况下,可以通过组合实现相同的效果。在可能的情况下应优先选择组合;然而,空基类优化和(较少见)虚方法覆盖是使用私有继承的有效理由。

  3. C++中的多态对象是一个其行为取决于其类型且在编译时(至少在请求行为的相关点)类型未知的对象。如果一个对象被引用为基类对象,并且它确实是其派生类的类型,那么它可以表现出派生类的行为。在 C++中,多态行为是通过虚函数实现的。

  4. 动态转换在运行时验证转换的目标类型是否有效:它必须是对象的实际类型(对象创建时使用的类型)或其基类型之一。正是检查对象的所有可能基类型的这部分使得动态转换变得昂贵。

第二章,类和函数模板

  1. 模板不是一种类型;它是一个为具有相似结构的许多不同类型提供服务的工厂。模板是用泛型类型编写的;用具体类型替换这些泛型类型将生成从模板生成的类型。

  2. 存在类模板、函数模板和变量模板。每种模板生成相应的实体——函数模板生成函数,类模板生成类(类型),变量模板生成变量。

  3. 模板可以有类型参数和非类型参数。类型参数是类型。非类型参数可以是整型或枚举值或模板(在变长模板的情况下,占位符也是非类型参数)。

  4. 模板实例化是由模板生成的代码。通常,实例化是隐式的;使用模板会强制其实例化。没有使用的情况下也可以进行显式实例化;它生成可以在以后使用的类型或函数。模板的显式特化是一种所有泛型类型都已指定的特化;它不是实例化,并且直到模板被使用之前不会生成任何代码。它只是为这些特定类型生成代码的另一种方法。

  5. 通常,参数包是通过递归来迭代的。编译器通常会内联由这种递归生成的代码,因此递归仅在编译期间存在(以及程序员阅读代码的头部)。在 C++17(以及很少见的 C++14)中,可以在不递归的情况下操作整个包。

  6. Lambda 表达式本质上是一种声明可以像函数一样调用的局部类的方法。它们用于有效地将代码片段存储在变量中(或者更确切地说,将代码与变量关联起来),以便稍后调用此代码。

  7. 概念对模板参数施加限制。这可以用来避免替换类型和实例化会导致模板体中发生错误的模板。在更复杂的情况下,概念可以用来消除多个模板重载之间的歧义。

第三章,内存和所有权

  1. 清晰的内存所有权,以及由此扩展的资源所有权,是良好设计的关键属性之一。有了清晰的拥有权,资源在需要时一定会被创建并可供使用,在使用期间得到维护,在不再需要时释放/清理。

  2. 资源泄露,包括内存泄露;悬垂句柄(资源句柄,如指针、引用或迭代器,指向不存在的资源);多次尝试释放同一资源;多次尝试构造同一资源。

  3. 非所有权、独占所有权、共享所有权,以及不同类型所有权的转换和所有权转移。

  4. 对于通过拥有指针处理的所有权,所有权无关的函数和类应通过原始指针和引用来引用对象。如果对象由丰富指针或容器拥有,问题会变得更加复杂。如果丰富指针中包含的额外数据不需要,或者访问容器中的单个元素,原始指针和引用就足够了。否则,理想情况下,我们会使用相应的非拥有引用对象,如std::string_view或范围库中的视图之一。如果没有可用的,可能不得不通过引用传递拥有对象本身。

  5. 独占内存所有权更容易理解,并且可以更好地跟随程序的流程控制。它也更有效率。

  6. 最好是通过在栈上分配对象或作为拥有类的数据成员(包括容器类)来分配对象。如果需要引用语义或某些移动语义,应使用唯一指针。对于条件构造的对象,std::optional是一个很好的解决方案。

  7. 共享所有权应通过共享指针,如std::shared_ptr来表示。

  8. 在大型系统中共享所有权难以管理,并且可能不必要地延迟资源的释放。与独占所有权相比,它还有相当大的性能开销。在线程安全的并发程序中维护共享所有权需要非常谨慎的实现。

  9. std::string_viewstd::span和来自std::ranges的视图等视图本质上是非拥有丰富指针。一个字符串视图到字符串就像原始指针到唯一指针:一个不拥有对象,包含与相应拥有对象相同的信息。

第四章,从简单到微妙——交换

  1. 交换函数交换两个对象的状态。在交换调用之后,对象应该保持不变,除了它们被访问的名称之外。

  2. 交换通常用于提供提交或回滚语义的程序中;首先创建一个临时结果副本,然后只有在没有检测到错误的情况下才将其交换到最终目的地。

  3. 使用交换来提供提交或回滚语义假设交换操作本身不能抛出异常或以其他方式失败,并留下未定义状态的对象。

  4. 应始终提供一个非成员交换函数,以确保非成员交换的调用能够正确执行。也可以提供一个成员交换函数,原因有两个——首先,这是唯一一种可以交换临时对象的方法,其次,交换实现通常需要访问类的私有数据成员。如果两者都提供,则非成员函数应该调用两个参数中的一个的成员交换函数。

  5. 所有 STL 容器和一些其他标准库类都提供了一个成员函数swap()。此外,非成员std::swap()函数模板为所有 STL 类型提供了标准重载。

  6. std::qualifier禁用了依赖参数的查找,并强制调用默认的std::swap模板实例化,即使该类已实现了一个自定义的交换函数。为了避免这个问题,建议也提供一个std::swap模板的显式实例化。

第五章,全面审视 RAII

  1. 内存是最常见的资源,但任何对象都可以是资源。程序操作的所有虚拟或物理量都是资源。

  2. 资源不应丢失(泄漏)。如果资源通过句柄访问,例如指针或 ID,则该句柄不应悬空(指不存在的资源)。当不再需要资源时,应按获取它们的方式释放资源。

  3. 资源获取即初始化是一个惯用语;它是 C++中资源管理的主要方法,其中每个资源都由一个对象拥有,在构造函数中获取,并在该对象的析构函数中释放。

  4. RAII 对象应始终在栈上创建或作为另一个对象的数据成员。当程序的流程离开包含 RAII 对象的范围或包含 RAII 对象的大对象被删除时,RAII 对象的析构函数将被执行。这无论控制流如何离开范围都会发生。

  5. 如果每个资源都由 RAII 对象拥有,并且 RAII 对象不提供原始句柄(或者用户小心地不克隆原始句柄),则句柄只能从 RAII 对象中获取,只要该对象保持存在,资源就不会被释放。

  6. 最常用的是std::unique_ptr用于内存管理;std::lock_guard用于管理互斥锁。

  7. 通常,RAII 对象必须是不可复制的。移动 RAII 对象会转移资源的所有权;经典的 RAII 模式不支持这一点,因此大多数 RAII 对象应该是不可移动的(区分std::unique_ptrconst std::unique_ptr)。

  8. RAII 在处理释放失败时存在困难,因为异常不能从析构函数中传播,因此没有很好的方法将失败报告给调用者。因此,未能释放资源通常会导致未定义的行为(C++标准有时也采取这种方法)。

第六章,理解类型擦除

  1. 类型擦除是一种编程技术,程序本身不显示对它使用的某些类型的显式依赖。当用于将抽象行为与特定实现分离时,它是一种强大的设计工具。

  2. 实现涉及一个多态对象和虚函数调用,或者一个专门为擦除类型实现并通过函数指针调用的函数。通常,这会与泛型编程结合使用,以构建这样的多态对象或从模板自动生成函数,并确保具体化的类型始终与构造期间提供的类型相同。

  3. 程序可以编写成避免明确提及大多数类型。类型是通过模板函数推导出来的,并声明为auto或模板推导的 typedef 类型。然而,被auto隐藏的对象的实际类型仍然取决于对象操作的所有类型(例如,指针的删除器类型)。擦除的类型根本不被对象类型捕获。换句话说,如果你能让编译器告诉你这个特定的auto代表什么,所有类型都会明确地在那里。但如果类型被擦除,即使是最详细的包含对象的声明也无法揭示它(例如std::shared_ptr<int>——这是整个类型,删除器类型不在其中)。

  4. 类型是通过为该类型生成的函数来具体化的:虽然其签名(参数)不依赖于擦除类型,但函数体则依赖于它。通常,第一步是将其中一个参数从void*等通用指针转换为擦除类型的指针。

  5. 与直接调用相同的可调用对象相比,类型擦除总是会产生一些开销:总会有额外的间接引用及其相关的指针。几乎所有实现都使用运行时多态(虚函数或动态类型转换)或函数指针的等效虚拟表,这增加了时间和内存(虚拟指针)。最大的开销通常来自额外的内存分配,这是为了存储编译时不知道大小的对象。如果可以最小化这种分配,并将额外的内存局部化到对象中,运行时的总开销可能相当小(内存开销仍然存在,并且通常通过这种优化而增加)。

第七章,SFINAE、概念和重载解析管理

  1. 对于每个函数调用,它是从调用位置(其可访问性可能受命名空间、嵌套作用域等因素影响)可访问的所有具有指定名称的函数的集合。

  2. 这是根据参数及其类型选择重载集中哪个函数将被调用的过程。

  3. 对于模板函数和成员函数(以及 C++17 中的类构造函数),类型推导从函数参数的类型推导出模板参数的类型。对于每个参数,可能可以从多个参数中推导出类型。在这种情况下,这种推导的结果必须相同,否则类型推导将失败。一旦推导出模板参数的类型,就将具体类型替换到所有参数、返回类型和默认参数中的模板参数。这是一个类型替换。

  4. 如前所述的类型替换可能导致无效的类型,例如,对于没有成员函数的类型,成员函数指针就是一个无效的类型。这种替换失败不会生成编译错误;相反,失败的函数重载将从重载集中移除。

  5. 这仅适用于函数声明(返回类型、参数类型和默认值)。重载解析所选函数体中的替换失败是硬错误。

  6. 如果每个重载返回不同的类型,这些类型可以在编译时进行检查。这些类型必须有一些方法来区分它们,例如,不同的大小或嵌入常量的不同值。对于constexpr函数,我们还可以检查返回值(在这种情况下,函数需要函数体)。

  7. 它被谨慎地使用。通过故意造成替换失败,我们可以将重载解析引导到特定的重载。通常,除非失败,否则首选期望的重载;否则,变长参数重载仍然存在并被选择,这表明我们想要测试的表达式是无效的。通过使用它们的返回类型区分重载,我们可以生成一个编译时(constexpr)常量,该常量可用于条件编译。

  8. C++20 的约束提供了更自然、更容易理解的语法。当被调用的函数不符合要求时,它们还导致更清晰的错误消息。此外,与 SFINAE 不同,约束不仅限于函数模板的替换参数。

  9. 标准不仅定义了语言中的概念和约束,还提供了一种思考模板限制的方法。虽然存在广泛的基于 SFINAE 的技术,但如果将 SFINAE 的使用限制为几种类似概念使用的方法,代码将更容易阅读和维护。

第八章,奇特重复的模板模式

  1. 虽然在绝对数值上并不非常昂贵(最多只有几纳秒),但虚函数调用比非虚函数调用要贵上几倍,并且可能比内联函数调用慢一个数量级或更多。这种开销来自于间接引用:虚函数总是通过函数指针来调用,而实际的函数在编译时是未知的,因此不能内联。

  2. 如果编译器知道将要调用的确切函数,它可以消除间接引用并可能将函数内联。

  3. 正如通过基类指针进行运行时多态调用一样,静态多态调用也必须通过基类的指针或引用来进行。在 CRTP 和静态多态的情况下,基类型实际上是基类模板生成的整个类型集合,每个派生类一个。为了进行多态调用,我们必须使用一个函数模板,它可以实例化在任何这些基类型上。

  4. 当直接调用派生类时,CRTP 的使用与编译时等价的虚函数有很大不同。它成为一种实现技术,为多个派生类提供公共功能,每个派生类都扩展并定制了基类模板的接口。

  5. 严格来说,使用多个 CRTP 基类不需要任何新的东西:派生类可以继承自几个这样的基类型,每个都是 CRTP 基类模板的一个实例化。然而,将这些基类与每个正确的模板参数(派生类本身)一起列出变得繁琐。将派生类声明为具有模板模板参数的变长模板,并从整个参数包中继承,更容易且更不容易出错。

第九章,命名参数、方法链和构造者模式

  1. 容易误计参数数量,更改错误的参数,或者使用错误类型的参数,该参数恰好转换为参数类型。此外,添加新参数需要更改所有必须传递这些参数的功能签名。

  2. 聚合内的参数值具有显式名称。添加新值不需要更改功能签名。为不同参数组制作的类具有不同的类型,并且不能意外混合。

  3. 命名参数习语允许使用临时聚合对象。我们不是通过名称更改每个数据成员,而是编写一个方法来设置每个参数的值。所有这些方法都返回对对象的引用,并且可以在一个语句中连在一起。

  4. 方法级联将多个方法应用于同一对象。在方法链中,通常,每个方法返回一个新的对象,下一个方法应用于它。通常,方法链用于级联方法。在这种情况下,所有链式方法都返回对原始对象的引用。

  5. 构造者模式是一种设计模式,它使用一个单独的构造者对象来构建复杂对象。当构造函数不足以构建对象或难以使用来构建对象在其期望的完全构建状态时,会使用它。当正在构建的对象的构造函数无法修改(用于特定目的的通用对象)时,当所需的构造函数会有许多类似参数且在构建过程复杂时难以使用,或者当构建过程计算成本高昂但某些结果可以重用时,可能需要构造者。

  6. 流式接口是一种使用方法链来呈现可以在对象上执行的多条指令、命令或操作的接口。特别是,在 C++中,流式构造者用于将复杂对象的构建分解成多个较小的步骤。其中一些步骤可以是条件性的或依赖于其他数据。

第十章,本地缓冲区优化

  1. 微基准测试可以测量代码小片段的独立性能。为了在程序上下文中测量相同片段的性能,我们必须使用分析器。

  2. 处理少量数据通常涉及相应的小量计算,因此非常快。内存分配增加了一个常数开销,与数据大小不成比例。当处理时间短时,相对影响更大。此外,内存分配可能使用全局锁或以其他方式序列化多个线程。

  3. 本地缓冲区优化用对象本身的一部分缓冲区替换外部内存分配。这避免了额外的内存分配成本和开销。

  4. 无论是否发生任何次要分配,对象都必须被构造,并且为其分配内存。这种分配有一定的成本——如果对象在堆上分配,成本更高,如果是栈变量,成本更低——但必须在对象可以使用之前支付这种成本。局部缓冲区优化增加了对象的大小,因此也增加了原始分配的大小,但通常这不会显著影响分配的成本。

  5. 短字符串优化涉及将字符串字符存储在字符串对象内部的局部缓冲区中,直到字符串的某个特定长度。

  6. 小向量优化涉及将向量内容的一小部分存储在向量对象内部的局部缓冲区中。

第十一章,ScopeGuard

  1. 错误安全程序即使在遇到错误的情况下也能保持一个良好定义的状态(一组不变量)。异常安全性是一种特定的错误安全性;它假设错误是通过抛出表达式来表示的。当抛出(允许的)表达式时,程序不得进入一个未定义的状态。异常安全程序可能需要某些操作不抛出异常。

  2. 如果必须在多个动作之间保持一致的状态,而这些动作中任何一个都可能失败,那么如果后续的动作失败,则必须撤销先前的动作。这通常要求动作在事务成功到达终点之前不完整地提交。最终的提交操作不能失败(例如,抛出异常),否则无法保证错误安全性。回滚操作也不能失败。

  3. RAII 类确保当程序离开作用域(如函数)时,总是执行某个特定的动作。使用 RAII,关闭动作不能被跳过或绕过,即使函数通过提前返回或抛出异常提前退出作用域。

  4. 经典的 RAII(Resource Acquisition Is Initialization)需要为每个动作创建一个特殊的类。ScopeGuard 能够自动从一个任意的代码片段(至少,如果支持 lambda 表达式的话)生成一个 RAII 类。

  5. 如果状态是通过错误代码返回的,那么就不能这样做。如果程序中的所有错误都通过异常来表示,并且任何函数的返回都是成功,那么我们可以在运行时检测到是否抛出了异常。复杂的是,受保护的运算可能本身就在由另一个异常引起的栈展开过程中进行。当保护类必须决定操作是成功还是失败时,该异常正在传播,但它的存在并不表示受保护的运算失败(它可能表示其他地方出了问题)。健壮的异常检测必须跟踪受保护作用域的开始和结束时传播了多少个异常,这只有在 C++17(或使用编译器扩展)中才可能实现。

  6. ScopeGuard 类通常是模板实例化。这意味着 ScopeGuard 的具体类型对程序员来说是未知的,或者至少难以明确指定。ScopeGuard 依赖于生命周期扩展和模板参数推导来管理这种复杂性。类型擦除的 ScopeGuard 是一个具体类型;它不依赖于它所包含的代码。缺点是类型擦除需要运行时多态性,并且大多数情况下需要内存分配。

第十二章,友元工厂

  1. 非成员友元函数具有与成员函数相同的对类成员的访问权限。

  2. 将友元授予模板会使该模板的每个实例化都成为友元;这包括相同模板的实例化,但具有不同、无关的类型。

  3. 作为成员函数实现的二元运算符始终在运算符的左侧操作数上调用,不允许对该对象进行转换。允许对右侧操作数进行转换,根据成员运算符的参数类型。这导致表达式如x + 22 + x之间存在不对称,因为后者不能由成员函数处理,因为2的类型(int)没有任何转换。

  4. 插入器的第一个操作数始终是流,而不是要打印的对象。因此,成员函数必须在该流上,这是标准库的一部分;用户不能扩展它以包括用户定义的类型。

  5. 虽然细节很复杂,但主要区别在于,在调用非模板函数时,会考虑用户定义的转换(隐式构造函数和转换运算符),但对于模板函数,参数类型必须与参数类型(几乎)完全匹配,不允许用户定义的转换。

  6. 在类模板中定义一个就地友元函数(定义紧随声明之后)会导致该模板的每个实例化都在包含的作用域中生成一个具有给定名称和参数类型的非模板、非成员函数。

第十三章,虚构造函数和工厂

  1. 有几个原因,但最简单的原因是内存必须以sizeof(T)的数量分配,其中T是实际的对象类型,而sizeof()运算符是constexpr(编译时常量)。

  2. 工厂模式是一种创建型模式,它解决了在不需要显式指定对象类型的情况下创建对象的问题。

  3. 虽然 C++ 中必须在构造点指定实际类型,但工厂模式允许我们将构造点与程序必须决定构建什么对象以及使用某种替代标识符(如数字、值或另一种类型)来识别类型的点分开。

  4. 虚拟拷贝构造函数是一种特殊的工厂,其中要构建的对象由我们已有的另一个对象的类型来识别。一个典型的实现涉及在每个派生类中重写的虚拟 clone() 方法。

  5. 模板模式描述了一种设计,其中整体控制流程由基类决定,而派生类在预定义的某些点上提供定制。在我们的情况下,整体控制流程是工厂构建,定制点是构建对象的行为(内存分配和构造函数调用)。

  6. 建造者模式在需要(或更方便)将构建对象的工作委托给另一个类而不是在构造函数中完成完整初始化时使用。一个根据某些运行时信息使用工厂方法构建不同类型对象的对象也是一个建造者。除了工厂本身之外,这样的建造者或工厂类通常还有其他运行时数据,用于构建对象,并且必须存储在另一个对象中——在我们的例子中,是工厂对象,它也是建造者对象。

第十四章,模板方法模式和非虚拟习语

  1. 行为模式描述了一种通过使用特定方法在不同对象之间进行通信来解决常见问题的方法。

  2. 模板方法模式是一种标准的实现算法的方式,该算法有一个严格的 骨架,或整体控制流程,但允许一个或多个定制点来处理特定类型的问题。

  3. 模板方法允许子类(派生类型)实现其他通用算法的特定行为。这个模式的关键是基类和派生类型之间的交互方式。

  4. 更常见的分层设计方法是将低级代码提供 构建块,高级代码通过特定的控制流程将它们组合起来构建特定的算法。在模板模式中,高级代码不决定整体算法,也不控制整体流程。低级代码控制算法并决定何时调用高级代码来调整执行的特定方面。

  5. 它是一种模式,其中类层次结构的公共接口由基类的非虚拟公共方法实现,而派生类只包含虚拟私有方法(以及实现它们所需的任何必要数据和非虚拟方法)。

  6. 公共虚拟函数执行两个独立的任务——它提供接口(因为它公开)并修改实现。更好的关注点分离是仅使用虚拟函数来自定义实现,并使用基类的非虚拟函数来指定公共接口。

  7. 一旦采用了 NVI(Non-Virtual Interface),通常可以将虚拟函数设置为私有。一个例外是当派生类需要调用基类的虚拟函数以委托部分实现时。在这种情况下,该函数应设置为受保护的。

  8. 析构函数是按照嵌套顺序调用的,从最派生的类开始。当派生类的析构函数完成时,它会调用基类的析构函数。到那时,派生类包含的额外信息已经被销毁,只剩下基类部分。如果基类的析构函数调用一个虚拟函数,它必须被调度到基类(因为那时派生类已经不存在了)。基类的析构函数没有方法调用派生类的虚拟函数。

  9. 当对基类的更改无意中破坏了派生类时,就会显现出脆弱基类问题。虽然这不是模板方法的特有现象,但它可能影响所有面向对象的设计,包括基于模板模式的设计。在最简单的例子中,如果以改变调用以自定义算法行为所调用的虚拟函数名称的方式更改基类中的非虚拟公共函数,那么所有现有的派生类都将被破坏,因为它们当前的定制,通过具有旧名称的虚拟函数实现,将突然停止工作。为了避免这个问题,不应更改现有的定制点。

第十五章,基于策略的设计

  1. 策略模式是一种行为模式,它允许用户通过从提供的一组替代算法中选择一个实现该行为的算法,或者通过提供一个新的实现,来自定义类的一定行为方面。

  2. 虽然传统的面向对象策略在运行时适用,但 C++通过一种称为基于策略设计的技术将泛型编程与策略模式相结合。在这种方法中,主要类模板将某些行为方面委托给用户指定的策略类型。

  3. 通常,对策略类型几乎没有限制,尽管类型声明的特定方式和使用方式按照惯例施加了某些限制。例如,如果策略作为一个函数被调用,则可以使用任何可调用类型。另一方面,如果调用策略的特定成员函数,策略必然是一个类,并提供所需的成员函数。模板策略也可以使用,但必须与指定的模板参数数量完全匹配。

  4. 主要有两种方式:组合和继承。组合通常应该被优先考虑;然而,实践中许多策略是空类,没有数据成员,并且可以从空基类优化中受益。除非策略还必须修改主类的公共接口,否则应该优先考虑私有继承。需要操作基于策略的主类本身的策略通常必须使用 CRTP。在其他情况下,当策略对象本身不依赖于主模板构建中使用的类型时,策略行为可以通过静态成员函数公开。

  5. 通常情况下,只包含常量并用于约束公共接口的策略更容易编写和维护。然而,在某些情况下,通过基类策略注入公共成员函数是首选的:当我们还需要向类中添加成员变量时,或者当完整的公共函数集难以维护或可能导致冲突时。

  6. 主要的缺点是复杂性,以各种形式表现出来。具有不同策略的策略类型通常是不同的类型(唯一的替代方案,类型擦除,通常具有禁止的运行时开销)。这可能会迫使代码的大部分内容也要模板化。长列表的策略难以维护和正确使用。因此,应避免创建不必要的或难以证明合理的策略。有时,具有两个足够不相关的策略集的类型最好分成两个单独的类型。

第十六章,适配器和装饰器

  1. 适配器是一个非常通用的模式,它修改了一个类或函数(或在 C++中的模板)的接口,使其可以在需要不同接口但具有相似底层行为的上下文中使用。

  2. 装饰器模式是一个更窄的模式;它通过添加或删除行为来修改现有接口,但不会将接口转换为完全不同的接口。

  3. 在经典的面向对象实现中,被装饰的类和装饰器类都继承自一个公共基类。这有两个限制;最重要的是,被装饰的对象保留了被装饰类的多态行为,但不能保留在具体(派生)装饰类中添加的接口,而这个接口在基类中不存在。第二个限制是装饰器特定于特定的层次结构。我们可以使用 C++的泛型编程工具来消除这两个限制。

  4. 通常,装饰器尽可能地保留被装饰类的接口。任何行为未被修改的函数都保持不变。因此,公共继承被广泛使用。如果一个装饰器必须显式地将大多数调用转发到被装饰的类,那么继承方面就不那么重要了,可以使用组合或私有继承。

  5. 与装饰器不同,适配器通常与原始类的接口非常不同。在这种情况下,通常更倾向于使用组合。例外的是编译时适配器,它们修改模板参数,但本质上仍然是相同的类模板(类似于模板别名)。这些适配器必须使用公有继承。

  6. 主要限制是它不能应用于模板函数。它也不能用来用包含这些参数的表达式替换函数参数。

  7. 模板别名在函数模板实例化时永远不会被考虑。适配器和策略模式都可以用来添加或修改类的公共接口。

  8. 适配器很容易堆叠(组合)以逐步构建一个复杂的接口。未启用的功能根本不需要任何特殊处理;如果未使用相应的适配器,则该功能将不会启用。传统的策略模式需要为每个模式预定槽位。除了最后一个显式指定的默认参数之后的所有策略,即使是默认策略,也必须显式指定。另一方面,堆栈中间的适配器无法访问对象的最终类型,这使实现变得复杂。基于策略的类总是最终类型,使用 CRTP,这种类型可以传播到需要它的策略中。

第十七章,访问者模式与多分派

  1. 访问者模式提供了一种将算法的实现与它们操作的对象的实现分离的方法;换句话说,这是一种在不修改类的情况下通过编写新成员函数向类添加操作的方法。

  2. 访问者模式允许我们扩展类层次结构的功能。它可以在类源代码不可用或修改此类修改难以维护时使用。

  3. 双重分派是基于两个因素来调度函数调用(选择要运行的算法)的过程。双重分派可以通过使用访问者模式(虚拟函数提供单分派)在运行时实现,或者通过使用模板或编译时访问者在编译时实现。

  4. 经典的访问者类层次结构与可访问类层次结构之间存在循环依赖。虽然当添加新的访问者时,可访问类不需要被编辑,但当访问者层次结构发生变化时,它们需要重新编译。后者必须在每次添加新的可访问类时发生,因此产生依赖循环。无环访问者通过使用交叉转换和多继承来打破这个循环。

  5. 接受访问者进入由更小对象组成的对象的一种自然方式是逐个访问这些对象。这种通过递归实现的模式最终会访问对象中包含的每个内置数据成员,并且以固定的、预定的顺序进行。因此,该模式自然映射到序列化和反序列化的需求,我们必须将对象解构为内置类型的集合,然后恢复它。

第十八章,并发模式

  1. 并发是程序的一种属性,允许多个任务同时执行或在时间上部分重叠。通常,通过使用多个线程来实现并发。

  2. C++11 为编写并发程序提供了基本支持:线程、互斥锁和条件变量。C++14 和 C++17 添加了几个便利类和工具,但 C++ 并发功能的主要新增是在 C++20:在这里,我们有了几个新的同步原语以及协程的引入。

  3. 同步模式是解决访问共享数据基本问题的常见解决方案。通常,它们提供了安排对由多个线程修改或由某些线程在修改时访问的数据的独占访问的方法。

  4. 执行模式是使用一个或多个线程来安排某些计算异步执行的标准方式。这些模式提供了启动某些代码执行并接收执行结果的方法,而调用者本身并不负责执行(程序中的其他实体承担这一职责)。

  5. 设计并发的最重要的指导原则是模块化;当具体应用于并发时,这意味着从满足其在并发程序中行为一定限制的组件构建并发软件。这些限制中最重要的是线程安全保证:通常,从允许广泛线程安全操作的组件构建并发软件要容易得多。

  6. 为了在并发程序中发挥作用,任何数据结构或组件都必须提供事务接口。一个操作如果是执行一个定义明确的完整计算并将系统置于一个定义明确的状态,则称为事务。识别哪些操作是事务以及哪些不是的一个简化方法是:如果一个并发程序在锁定状态下执行每个操作,但操作之间没有顺序保证,那么系统的状态是否可以保证是定义明确的?如果不行,那么一些操作应该作为一个序列执行,而不释放锁。这个序列就是一个事务;序列中的操作本身不是事务。不应该由调用者负责安排这些操作的顺序。相反,数据结构或组件应该提供接口,以便将整个事务作为一个单一操作执行。

posted @ 2025-10-07 17:57  绝不原创的飞龙  阅读(2)  评论(0)    收藏  举报