C---模版元编程-全-

C++ 模版元编程(全)

原文:zh.annas-archive.org/md5/fa44116af1a433afa47ff628987a2494

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

C++编程语言是世界上使用最广泛的编程语言之一,并且已经如此几十年了。它的成功并不仅仅是因为它提供的性能,也许是因为它的易用性,尽管许多人会对此提出异议,但可能是因为它的多功能性。C++是一种通用、多范式的编程语言,它结合了过程式、函数式和泛型编程。

泛型编程是一种编写代码的范例,其中函数和类等实体是以后来指定的类型编写的。这些泛型实体仅在需要为特定类型(作为参数指定)时实例化。在 C++中,这些泛型实体被称为模板。

元编程是一种编程技术,使用模板(以及 C++中的 constexpr 函数)在编译时生成代码,然后将生成的代码与源代码的其余部分合并以编译最终程序。元编程意味着至少输入或输出是一个类型。

C++中的模板以其“相当糟糕”而闻名,正如 C++核心指南(由 Bjarne Stroustrup 和 Herb Sutter 维护的关于应该做什么和不应该做什么的文档)中所述。然而,它们使得泛型库成为可能,例如 C++标准库,这是 C++开发者经常使用的。无论您是编写自己的模板还是仅使用他人编写的模板(例如标准容器或算法),模板很可能构成了您日常代码的一部分。

本书旨在全面了解 C++中所有可用的模板(从其基本语法到 C++20 中的概念)。这将是本书前两部分的焦点。第三部分和最后一部分将帮助您将新获得的知识应用于使用模板进行元编程。

本书面向谁?

本书面向希望了解模板元编程的初学者到中级 C++开发者,以及希望掌握与模板和 C++20 相关的新特性以及各种惯用和模式的先进 C++开发者。要开始阅读本书,需要具备基本的 C++编码经验。

本书涵盖的内容

第一章模板简介,介绍了 C++中模板元编程的概念,包括几个简单示例,以及讨论了为什么我们需要模板以及使用模板的优缺点。

第二章模板基础,探讨了 C++中所有形式的模板:函数模板、类模板、变量模板和别名模板。对于这些中的每一个,我们讨论了它们的语法以及它们如何工作的细节。此外,还讨论了模板实例化和特殊化的关键概念。

第三章可变参数模板,完全致力于可变参数模板,这些模板具有可变数量的模板参数。我们详细讨论了可变函数模板、可变类模板、可变别名模板和可变变量模板,参数包及其展开方式,以及帮助我们简化可变模板编写的折叠表达式。

第四章高级模板概念,将一系列高级模板概念如依赖名称和名称查找、模板参数推导、模板递归、完美转发、泛型和模板 lambda 等分组。通过理解这些主题,读者将能够极大地扩展他们可以阅读或编写的模板的多样性。

第五章类型特性和条件编译,致力于类型特性。读者将了解类型特性、标准库提供的特性以及如何使用它们来解决不同的问题。

第六章概念和约束,介绍了 C++20 中用于定义模板参数要求的新的概念和约束机制。你将了解指定约束的各种方法。此外,我们还提供了 C++20 标准概念库内容的概述。

第七章模式和惯用法,探讨了将到目前为止学到的知识应用于实现各种模式的一系列不相关的先进主题。我们探讨了静态多态、类型擦除、标签分派等概念,以及诸如好奇的递归模板模式、表达式模板、混入和类型列表等模式。

第八章范围和算法,致力于理解容器、迭代器和算法,这些是标准模板库的核心组件。在这里,你将学习如何为它编写一个泛型容器和迭代器类型,以及一个通用算法。

第九章范围库,探讨了新的 C++20 范围库及其关键特性,如范围、范围适配器和约束算法。这些特性使我们能够编写更简单的代码来处理范围。此外,你还将学习如何编写自己的范围适配器。

附录 是一个简短的结语,提供了本书的总结。

作业答案 包含了所有章节的所有问题的答案。

为了充分利用本书

要开始学习本书,您需要对 C++ 编程语言有一些基本了解。您需要了解语法以及关于类、函数、运算符、函数重载、继承、虚函数等方面的基础知识。然而,不需要模板知识,因为本书将从零开始教授您所有内容。

本书中的所有代码示例都是跨平台的。这意味着您可以使用任何编译器来构建和运行它们。然而,尽管许多代码片段与 C++11 编译器兼容,但也有片段需要 C++17 或 C++20 兼容的编译器。因此,我们建议您使用支持 C++20 的编译器版本,以便运行所有示例。本书中的示例已使用 MSVC 19.30Visual Studio 2022)、GCC 12.1/13Clang 13/14 进行测试。如果您机器上没有这样的 C++20 兼容编译器,您可以在网上尝试一个。我们推荐以下之一:

书中将多次提及 C++ Insights 在线工具,用于分析编译器生成的代码。

如果您想检查编译器对不同版本 C++ 标准的支持情况,请参考页面 en.cppreference.com/w/cpp/compiler_support

如果您使用的是本书的电子版,我们建议您亲自输入代码或从本书的 GitHub 仓库(下一节中提供链接)获取代码。这样做将有助于避免与代码复制粘贴相关的任何潜在错误。

提及和进一步阅读

在本书中,我们多次提到了 C++ 标准。这份文件由 国际标准化组织 版权所有。官方 C++ 标准文档可以从这里购买:www.iso.org/standard/79358.html。然而,C++ 标准的多个草案以及生成它们的源代码在 GitHub 上免费提供,网址为 github.com/cplusplus/draft。您可以在 isocpp.org/std/the-standard 找到有关 C++ 标准的更多信息。

对于 C++ 开发者来说,一个极好的在线资源是 C++ Reference 网站,网址为 en.cppreference.com/。该网站提供了直接从 C++ 标准派生的详尽文档。本书中多次引用了 C++ Reference 的内容。C++ Reference 的内容受 CC-BY-SA 许可协议保护,en.cppreference.com/w/Cppreference:Copyright/CC-BY-SA

每章末尾,您将找到一个名为进一步阅读的部分。本部分包含用作参考文献的阅读列表,并推荐用于深化您对所呈现主题的了解。

下载示例代码文件

您可以从 GitHub 下载本书的示例代码文件github.com/PacktPublishing/Template-Metaprogramming-with-CPP。如果代码有更新,它将在 GitHub 仓库中更新。

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

下载彩色图像

我们还提供了一份包含本书中使用的截图和图表彩色图像的 PDF 文件。您可以从这里下载:packt.link/Un8j5

使用的约定

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

文本中的代码:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“可以通过将init设置为依赖名称来修复此问题。”

代码块应如下设置:

template <typename T>
struct parser : base_parser<T>
{
   void parse()
   {
      this->init(); // OK
      std::cout << "parse\n";
   }
};

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

fatal error: recursive template instantiation exceeded maximum
depth of 1024
use -ftemplate-depth=N to increase recursive template
instantiation depth

粗体:表示新术语、重要单词或您在屏幕上看到的单词。例如,菜单或对话框中的单词以粗体显示。以下是一个示例:“容量为 8,大小为 0,都指向索引0。”

小贴士或重要注意事项

看起来像这样。

联系我们

欢迎读者反馈。

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

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

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

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

分享您的想法

一旦您阅读了《使用 C++的模板元编程》,我们很乐意听听您的想法!请点击此处直接进入此书的亚马逊评论页面并分享您的反馈。

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

第一部分:核心模板概念

在本部分中,你将从模板的简介开始,了解其优势。然后,你将学习编写函数模板、类模板、变量模板和别名模板的语法。你将探索模板实例化和模板特化的概念,并学习如何编写具有可变参数数量的模板。

本部分包含以下章节:

  • 第一章模板简介

  • 第二章模板基础

  • 第三章可变参数模板

第一章:第一章:模板简介

作为一名 C++ 开发者,你应该至少熟悉,如果不是精通模板元编程,通常简称为模板。模板元编程是一种编程技术,它使用模板作为编译器生成代码的蓝图,并帮助开发者避免编写重复的代码。尽管通用库大量使用模板,但 C++ 语言中模板的语法和内部工作原理可能会让人望而却步。甚至由 C++ 语言的创造者 Bjarne Stroustrup 和 C++ 标准化委员会主席 Herb Sutter 编辑的 C++ Core Guidelines 也称模板为“相当糟糕”。

这本书旨在阐明 C++ 语言这一领域,并帮助你成为模板元编程的大师。

在本章中,我们将讨论以下主题:

  • 理解模板的需求

  • 编写你的第一个模板

  • 理解模板术语

  • 模板简史

  • 模板的优势与劣势

学习如何使用模板的第一步是理解它们实际上解决了什么问题。让我们从这一点开始。

理解模板的需求

每个语言特性都是为了帮助开发者在使用该语言时解决遇到的问题或任务。模板的目的是帮助我们避免编写只略有不同的重复代码。

为了举例说明,让我们以经典的 max 函数为例。这样的函数接受两个数值参数,并返回两个中的较大值。我们可以轻松地实现如下:

int max(int const a, int const b)
{
   return a > b ? a : b;
}

这效果相当不错,但正如你所见,它只适用于 int 类型的值(或可转换为 int 的类型)。如果我们需要相同的功能,但参数类型为 double 呢?那么,我们可以为 double 类型重载这个函数(创建一个具有相同名称但参数数量或类型不同的函数):

double max(double const a, double const b)
{
   return a > b ? a : b;
}

然而,intdouble 并不是唯一的数值类型。还有 charshortlonglong 以及它们的无符号版本,unsigned charunsigned shortunsigned longunsigned long。还有 floatlong double 这两种类型。还有其他类型,例如 int8_tint16_tint32_tint64_t。还可能有其他可以比较的类型,例如 bigintMatrixpoint2d 以及任何重载了 operator> 的用户定义类型。一个通用库如何为所有这些类型提供一个通用的函数,比如 max 呢?它可以重载所有内置类型的函数,也许还可以重载其他库类型,但不能为任何用户定义类型重载。

使用不同参数重载函数的替代方法是使用void*来传递不同类型的参数。请记住,这是一个不好的做法,以下示例仅作为一个在没有模板的世界中可能的选择。然而,为了讨论的目的,我们可以设计一个排序函数,该函数将对任何可能类型的元素数组执行快速排序算法,这些类型提供了严格的弱排序。快速排序算法的详细信息可以在网上找到,例如在维基百科上en.wikipedia.org/wiki/Quicksort

快速排序算法需要比较和交换任意两个元素。然而,由于我们不知道它们的类型,实现不能直接这样做。解决方案是依赖于回调函数,这些函数作为参数传递,并在必要时被调用。一个可能的实现如下:

using swap_fn = void(*)(void*, int const, int const);
using compare_fn = bool(*)(void*, int const, int const);
int partition(void* arr, int const low, int const high, 
              compare_fn fcomp, swap_fn fswap)
{
   int i = low - 1;
   for (int j = low; j <= high - 1; j++)
   {
      if (fcomp(arr, j, high))
      {
         i++;
         fswap(arr, i, j);
      }
   }
   fswap(arr, i + 1, high);
   return i + 1;
}
void quicksort(void* arr, int const low, int const high, 
               compare_fn fcomp, swap_fn fswap)
{
   if (low < high)
   {
      int const pi = partition(arr, low, high, fcomp, 
         fswap);
      quicksort(arr, low, pi - 1, fcomp, fswap);
      quicksort(arr, pi + 1, high, fcomp, fswap);
   }
}

为了调用quicksort函数,我们需要为每种类型提供这些比较和交换函数的实现,我们将这些类型作为数组传递给函数。以下是int类型的实现:

void swap_int(void* arr, int const i, int const j)
{
   int* iarr = (int*)arr;
   int t = iarr[i];
   iarr[i] = iarr[j];
   iarr[j] = t;
}
bool less_int(void* arr, int const i, int const j)
{
   int* iarr = (int*)arr;
   return iarr[i] <= iarr[j];
}

在所有这些定义完成后,我们可以编写如下代码来对整数数组进行排序:

int main()
{
   int arr[] = { 13, 1, 8, 3, 5, 2, 1 };
   int n = sizeof(arr) / sizeof(arr[0]);
   quicksort(arr, 0, n - 1, less_int, swap_int);
}

这些示例主要关注函数,但相同的问题也适用于类。假设你想编写一个类,该类模拟一个具有可变大小且在内存中连续存储元素的数值集合。你可以提供以下实现(这里只展示了声明部分)来存储整数:

struct int_vector
{
   int_vector();
   size_t size() const;
   size_t capacity() const;
   bool empty() const;
   void clear();
   void resize(size_t const size);
   void push_back(int value);
   void pop_back();
   int at(size_t const index) const;
   int operator[](size_t const index) const;
private:
   int* data_;
   size_t size_;
   size_t capacity_;
};

看起来一切都很不错,但当你需要存储double类型、std::string类型或任何用户定义类型的值时,你必须编写相同的代码,每次只更改元素的类型。这并不是人们想要做的事情,因为它是一项重复性的工作,而且当需要更改某些内容(例如添加新功能或修复错误)时,你需要在多个地方应用相同的更改。

最后,当你需要定义变量时,可能会遇到类似的问题,尽管这种情况不太常见。让我们考虑一个持有换行符的变量的情况。你可以这样声明它:

constexpr char NewLine = '\n';

如果你需要相同的常量,但用于不同的编码,例如宽字符串字面量、UTF-8 等,你可以有多个变量,具有不同的名称,如下例所示:

constexpr wchar_t NewLineW = L'\n';
constexpr char8_t NewLineU8 = u8'\n';
constexpr char16_t NewLineU16 = u'\n';
constexpr char32_t NewLineU32 = U'\n';

模板是一种技术,允许开发者编写蓝图,使编译器能够为我们生成所有这些重复的代码。在下一节中,我们将看到如何将前面的代码片段转换为 C++模板。

编写你的第一个模板

现在是时候看看在 C++语言中如何编写模板了。在本节中,我们将从三个简单的示例开始,每个示例对应前面展示的代码片段。

之前讨论的max函数的模板版本看起来如下:

template <typename T>
T max(T const a, T const b)
{
   return a > b ? a : b;
}

你会注意到,类型名(如intdouble)已被替换为T(代表类型)。T被称为template<typename T>typename<class T>。请记住,T是一个参数,因此它可以有任何一个名字。我们将在下一章中了解更多关于模板参数的内容。

到目前为止,你放在源代码中的这个模板只是一个蓝图。编译器将根据其使用情况生成代码。更确切地说,它将为模板使用的每个类型实例化一个函数重载。以下是一个示例:

struct foo{};
int main()
{   
   foo f1, f2;
   max(1, 2);     // OK, compares ints
   max(1.0, 2.0); // OK, compares doubles
   max(f1, f2);   // Error, operator> not overloaded for 
                  // foo
}

在这个代码片段中,我们首先用两个整数调用max函数,这是可以的,因为operator>对于int类型是可用的。这将生成一个重载int max(int const a, int const b)。其次,我们用两个双精度浮点数调用max函数,这同样是可以的,因为operator>对于双精度浮点数也是可用的。因此,编译器将生成另一个重载double max(double const a, double const b)。然而,对max的第三次调用将生成编译器错误,因为foo类型没有重载operator>

在这里不深入太多细节的情况下,应该提到调用max函数的完整语法如下:

max<int>(1, 2);
max<double>(1.0, 2.0);
max<foo>(f1, f2);

编译器能够推导出模板参数的类型,因此没有必要写出它。然而,在某些情况下,这是不可能的;在这些情况下,你需要显式指定类型,使用这种语法。

上一节中涉及函数的第二个例子是处理void*参数的quicksort()实现。这个实现可以很容易地转换成模板版本,只需做很少的修改。以下是一个示例:

template <typename T>
void swap(T* a, T* b)
{
   T t = *a;
   *a = *b;
   *b = t;
}
template <typename T>
int partition(T arr[], int const low, int const high)
{
   T pivot = arr[high];
   int i = (low - 1);
   for (int j = low; j <= high - 1; j++)
   {
      if (arr[j] < pivot)
      {
         i++;
         swap(&arr[i], &arr[j]);
      }
   }
   swap(&arr[i + 1], &arr[high]);
   return i + 1;
}
template <typename T>
void quicksort(T arr[], int const low, int const high)
{
   if (low < high)
   {
      int const pi = partition(arr, low, high);
      quicksort(arr, low, pi - 1);
      quicksort(arr, pi + 1, high);
   }
}

quicksort函数模板的使用与之前所见非常相似,只是不需要传递回调函数的指针:

int main()
{
   int arr[] = { 13, 1, 8, 3, 5, 2, 1 };
   int n = sizeof(arr) / sizeof(arr[0]);
   quicksort(arr, 0, n - 1);
}

在上一节中,我们讨论的第三个例子是vector类。它的模板版本如下所示:

template <typename T>
struct vector
{
   vector();
   size_t size() const;
   size_t capacity() const;
   bool empty() const;
   void clear();
   void resize(size_t const size);
   void push_back(T value);
   void pop_back();
   T at(size_t const index) const;
   T operator[](size_t const index) const;
private:
   T* data_;
   size_t size_;
   size_t capacity_;
};

max函数的情况一样,变化很小。在类的上方一行有模板声明,元素类型int已被类型模板参数T所取代。这个实现可以这样使用:

int main()
{   
   vector<int> v;
   v.push_back(1);
   v.push_back(2);
}

这里需要注意的一点是,在声明变量v时,我们必须指定其元素类型,在我们的代码片段中是int,因为否则编译器无法推断它们的类型。在 C++17 中,这种情况是可能的,这个主题被称为类模板参数推导,将在第四章 高级模板概念中讨论。

第四个也是最后一个例子是关于当只有类型不同时声明多个变量。我们可以用模板替换所有这些变量,如下面的代码片段所示:

template<typename T>
constexpr T NewLine = T('\n');

此模板可以按以下方式使用:

int main()
{
   std::wstring test = L"demo";
   test += NewLine<wchar_t>;
   std::wcout << test;
}

本节中的示例表明,无论模板代表函数、类还是变量,其声明和使用语法都是相同的。这引导我们进入下一节,我们将讨论模板的类型和模板术语。

理解模板术语

到目前为止,在本章中,我们使用了通用术语模板。然而,有四个不同的术语描述了我们所编写的模板类型:

  • 之前见过的 max 模板。

  • classstructunion 关键字)。一个例子是我们之前章节中编写的 vector 类。

  • 之前章节中的 NewLine 模板。

  • 别名模板是用于模板化类型别名的术语。我们将在下一章中看到别名模板的示例。

模板可以用一个或多个参数进行参数化(在我们迄今为止的示例中,有一个单个参数)。这些被称为模板参数,可以分为三类:

  • template<typename T>,其中参数代表在模板使用时指定的类型。

  • template<size_t N>template<auto n>,其中每个参数都必须有一个结构化类型,这包括整数类型、浮点类型(对于 C++20)、指针类型、枚举类型、左值引用类型以及其他类型。

  • template<typename K, typename V, template<typename> typename C>,其中参数的类型是另一个模板。

可以通过提供替代实现来专门化模板。这些实现可以依赖于模板参数的特性。专门化的目的是为了实现优化或减少代码膨胀。有两种专门化的形式:

  • 部分专门化:这是只为部分模板参数提供的替代实现。

  • (显式)完全专门化:这是当所有模板参数都提供时模板的专门化。

编译器从模板生成代码的过程称为 vector<int>,编译器在 T 出现的每个地方都替换了 int 类型。

模板实例化可以有两种形式:

  • vector<int>vector<double>,它将为 intdouble 类型实例化 vector 类模板,而不会更多。

  • 显式实例化:这是一种明确告诉编译器要创建哪些模板实例化的方法,即使这些实例化在您的代码中没有被明确使用。这在创建库文件时很有用,因为未实例化的模板不会被放入对象文件中。它们还有助于减少编译时间和对象大小,我们将在稍后看到。

本节中提到的所有术语和主题将在本书的其他章节中详细说明。本节旨在作为模板术语的简要参考指南。但请记住,还有许多与模板相关的其他术语将在适当的时候介绍。

模板的历史简述

模板元编程是 C++的泛型编程实现。这种范式最早在 20 世纪 70 年代被探索,而第一个支持它的主要语言是 20 世纪 80 年代上半叶的 Ada 和 Eiffel。David Musser 和 Alexander Stepanov 在 1989 年的一篇名为《泛型编程》的论文中定义了泛型编程,如下所述:

泛型编程围绕着从具体、高效的算法抽象出通用算法的想法,这些通用算法可以与不同的数据表示结合,以产生各种有用的软件。

这定义了一种编程范式,其中算法是根据稍后指定的类型定义的,并根据其使用进行实例化。

模板不是 Bjarne Stroustrup 开发的最初C with Classes语言的组成部分。Stroustrup 描述 C++中模板的第一篇论文出现在 1986 年,即他的书《C++编程语言,第一版》出版后一年。模板在 1990 年成为 C++语言的一部分,在 ANSI 和 ISO C++标准化委员会成立之前。

在 20 世纪 90 年代初,Alexander Stepanov、David Musser 和 Meng Lee 在 C++中对各种泛型概念进行了实验。这导致了标准模板库STL)的第一个实现。当 ANSI/ISO 委员会在 1994 年了解到这个库时,它迅速将其添加到草案规范中。STL 与 C++语言一起在 1998 年标准化,这被称为 C++98。

C++标准的较新版本,统称为现代 C++,引入了各种对模板元编程的改进。以下表格简要列出它们:

![Table 1.1

![img/B18367_Table_1.1.jpg]

表 1.1

所有这些特性,以及模板元编程的其他方面,将是本书的唯一主题,将在以下章节中详细介绍。现在,让我们看看使用模板的优势和劣势是什么。

模板的优势和劣势

在开始使用模板之前,了解使用模板的好处以及它们可能带来的劣势是很重要的。

让我们先指出其优势:

  • 模板帮助我们避免编写重复的代码。

  • 模板促进了通用库的创建,这些库提供算法和类型,例如标准 C++库(有时错误地称为 STL),它可以在许多应用中使用,无论它们的类型如何。

  • 模板的使用可以导致代码更少且更好。例如,使用标准库中的算法可以帮助编写更少的代码,这些代码可能更容易理解和维护,并且由于这些算法的开发和测试所付出的努力,可能更健壮。

当谈到劣势时,以下几点值得提及:

  • 虽然语法被认为是复杂且繁琐的,但只要稍加练习,这实际上不应该在模板的开发和使用中构成真正的障碍。

  • 与模板代码相关的编译器错误通常很长且难以理解,这使得确定其原因是极其困难的。较新的 C++编译器在这些类型的错误简化方面取得了进展,尽管它们通常仍然是一个重要问题。C++20 标准中包含的概念被看作是尝试之一,旨在为编译错误提供更好的诊断。

  • 由于它们完全在头文件中实现,因此它们会增加编译时间。每当对模板进行更改时,包含该头文件的所有翻译单元都必须重新编译。

  • 模板库以一组一个或多个头文件的形式提供,这些头文件必须与使用它们的代码一起编译。

  • 从模板在头文件中的实现中产生的另一个缺点是缺乏信息隐藏。整个模板代码都可在头文件中供任何人阅读。库开发者经常求助于使用诸如detaildetails之类的命名空间来包含库内部应使用且不应直接被库使用者调用的代码。

  • 由于未使用的代码不会被编译器实例化,因此它们可能更难验证。因此,在编写单元测试时,必须确保良好的代码覆盖率。这对于库尤其如此。

尽管缺点列表可能看起来更长,但使用模板并不是一件坏事或需要避免的事情。相反,模板是 C++语言的一个强大功能。模板并不总是被正确理解,有时会被误用或过度使用。然而,模板的明智使用无疑具有优势。本书将尝试提供对模板及其使用的更好理解。

摘要

本章介绍了 C++编程语言中模板的概念。

我们首先学习了那些解决方案是使用模板的问题。然后,我们通过函数模板、类模板和变量模板的简单示例来了解模板的外观。我们介绍了模板的基本术语,这些内容将在接下来的章节中进一步讨论。在章节的末尾,我们简要回顾了 C++编程语言中模板的历史。我们以讨论使用模板的优缺点结束本章。所有这些主题都将帮助我们更好地理解下一章的内容。

在下一章中,我们将探讨 C++中模板的基础知识。

问题

  1. 我们为什么需要模板?它们提供了哪些优势?

  2. 如何调用模板函数?对于模板类又是如何?

  3. 存在多少种模板参数类型,它们是什么?

  4. 什么是部分专业化?什么是完全专业化?

  5. 使用模板的主要缺点是什么?

进一步阅读

第二章:第二章:模板基础

在上一章中,我们简要介绍了模板。它们是什么,它们如何有帮助,使用模板的优缺点,以及一些函数和类模板的例子。在本章中,我们将详细探讨这个领域,并查看模板参数、实例化、特化、别名等方面。本章的主要学习内容包括以下内容:

  • 如何定义函数模板、类模板、变量模板和别名模板

  • 存在哪些类型的模板参数?

  • 什么是模板实例化?

  • 什么是模板特化?

  • 如何使用泛型 lambda 和 lambda 模板

到本章结束时,你将熟悉 C++ 中模板的核心基础,能够理解大量模板代码,并能够自己编写模板。

为了开始本章,我们将探讨定义和使用函数模板的细节。

定义函数模板

函数模板的定义方式与常规函数类似,只是函数声明前面是关键字 template,后面跟着一个用尖括号括起来的模板参数列表。以下是一个简单的函数模板示例:

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

这个函数有两个参数,称为 ab,它们都是相同的 T 类型。这个类型列在模板参数列表中,由关键字 typenameclass(在本例和整本书中使用了前者)引入。这个函数所做的只是将两个参数相加并返回这个操作的结果,这个结果应该具有相同的 T 类型。

函数模板只是创建实际函数的蓝图,并且只存在于源代码中。除非在源代码中显式调用,否则函数模板将不会出现在编译后的可执行文件中。然而,当编译器遇到对函数模板的调用并且能够将提供的参数及其类型与函数模板的参数匹配时,它将根据模板和用于调用的参数生成一个实际函数。为了理解这一点,让我们看看一些例子:

auto a = add(42, 21);

在这个片段中,我们使用两个 int 参数 4221 调用 add 函数。编译器能够从提供的参数类型中推断出模板参数 T,因此不需要显式提供它。然而,以下两种调用也是可能的,并且实际上与前面的一种相同:

auto a = add<int>(42, 21);
auto a = add<>(42, 21);

从这个调用中,编译器将生成以下函数(请注意,实际代码可能因编译器的不同而有所不同):

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

然而,如果我们改变调用形式,我们明确为模板参数 T 提供了 short 类型的参数:

auto b = add<short>(42, 21);

在这种情况下,编译器将生成这个函数的另一个实例,用 short 代替 int。这个新的实例将如下所示:

short add(const short a, const int b)
{
  return static_cast<short>(a + b);
}

如果两个参数的类型不明确,编译器将无法自动推断它们。以下调用就是这种情况:

auto d = add(41.0, 21);

在这个例子中,41.0是一个double类型,而21是一个int类型。add函数模板有两个相同类型的参数,因此编译器无法将其与提供的参数匹配,并将引发错误。为了避免这种情况,假设你希望它为double类型实例化,你必须显式指定类型,如下面的代码片段所示:

auto d = add<double>(41.0, 21);

只要两个参数具有相同的类型,并且+运算符对于参数类型是可用的,你就可以以前面显示的方式调用函数模板add。然而,如果+运算符不可用,那么即使模板参数被正确解析,编译器也无法生成实例化。这如下面的代码片段所示:

class foo
{
   int value;
public:
   explicit foo(int const i):value(i)
   { }
   explicit operator int() const { return value; }
};
auto f = add(foo(42), foo(41));

在这种情况下,编译器将发出错误,指出找不到类型foo的二进制+运算符。当然,不同的编译器会有不同的实际消息,所有错误都是如此。为了能够为类型foo的参数调用add,你必须为此类型重载+运算符。一个可能的实现如下:

foo operator+(foo const a, foo const b)
{
  return foo((int)a + (int)b);
}

我们迄今为止看到的所有示例都表示了只有一个模板参数的模板。然而,一个模板可以有任意数量的参数,甚至可以有可变数量的参数。这个后者的主题将在第三章中讨论,可变参数模板。下一个函数是一个有两个类型模板参数的函数模板:

template <typename Input, typename Predicate>
int count_if(Input start, Input end, Predicate p)
{
   int total = 0;
   for (Input i = start; i != end; i++)
   {
      if (p(*i))
         total++;
   }
   return total;
}

此函数接受两个输入迭代器,分别指向范围的开头和结尾,以及一个谓词,并返回范围内匹配谓词的元素数量。这个函数,至少在概念上,与标准库中<algorithm>头文件中的std::count_if通用函数非常相似,你应该始终优先使用标准算法而不是手工实现的算法。然而,为了本主题的目的,这个函数是一个很好的例子,可以帮助你理解模板是如何工作的。

我们可以使用count_if函数如下:

int main()
{
   int arr[]{ 1,1,2,3,5,8,11 };
   int odds = count_if(
                 std::begin(arr), std::end(arr), 
                 [](int const n) { return n % 2 == 1; });
   std::cout << odds << '\n';
}

再次强调,没有必要显式指定类型模板参数的参数(输入迭代器的类型和一元谓词的类型),因为编译器能够从调用中推断它们。

虽然还有更多关于函数模板的知识需要学习,但本节提供了关于如何使用它们的介绍。现在让我们学习定义类模板的基础知识。

定义类模板

类模板的声明方式与类声明非常相似,使用 template 关键字和模板参数列表在类声明之前。我们已经在引言章节中看到了第一个例子。下面的代码片段展示了名为 wrapper 的类模板。它有一个单一的模板参数,称为 T,用作数据成员、参数和函数返回类型的类型:

template <typename T>
class wrapper
{
public:
   wrapper(T const v): value(v)
   { }
   T const& get() const { return value; }
private:
   T value;
};

只要类模板在您的源代码中任何地方都没有使用,编译器就不会从它生成代码。为了实现这一点,类模板必须被实例化,并且所有参数都必须正确地与参数匹配,无论是用户显式地匹配,还是编译器隐式地匹配。下面展示了实例化此类模板的例子:

wrapper a(42);           // wraps an int
wrapper<int> b(42);      // wraps an int
wrapper<short> c(42);    // wraps a short
wrapper<double> d(42.0); // wraps a double
wrapper e("42");         // wraps a char const *

由于一个名为 wrapper<int>wrapper<char const*> 的特性,这个片段中 ae 的定义仅在 C++17 及以后版本中有效。

类模板可以在不定义的情况下声明,并在允许不完整类型的环境中使用,例如函数的声明,如下所示:

template <typename T>
class wrapper;
void use_foo(wrapper<int>* ptr);

然而,类模板必须在模板实例化的点定义;否则,编译器将生成错误。以下代码片段展示了这一点:

template <typename T>
class wrapper;                       // OK
void use_wrapper(wrapper<int>* ptr); // OK
int main()
{
   wrapper<int> a(42);            // error, incomplete type
   use_wrapper(&a);
}
template <typename T>
class wrapper
{
   // template definition
};
void use_wrapper(wrapper<int>* ptr)
{
   std::cout << ptr->get() << '\n';
}

在声明 use_wrapper 函数时,类模板 wrapper 只被声明,而没有定义。然而,在这个上下文中允许不完整类型,这使得在这一点上使用 wrapper<T> 是可以的。然而,在 main 函数中,我们正在实例化 wrapper 类模板的对象。这将生成编译器错误,因为在这个点上类模板的定义必须是可用的。为了修复这个特定的例子,我们必须将 main 函数的定义移动到末尾,在 wrapperuse_wrapper 定义之后。

在这个例子中,类模板是使用 class 关键字定义的。然而,在 C++ 中,使用 classstruct 关键字声明类之间几乎没有区别:

  • 使用 struct 时,默认成员访问权限是公共的,而使用 class 则是私有的。

  • 使用 struct 时,基类继承的默认访问修饰符是公共的,而使用 class 则是私有的。

您可以使用 struct 关键字定义类模板,就像我们在这里使用 class 关键字一样。使用 structclass 关键字定义的类之间的差异也适用于使用 structclass 关键字定义的类模板。

不论类是否是模板,都可能包含成员函数模板。这些定义的方式将在下一节中讨论。

定义成员函数模板

到目前为止,我们已经学习了关于函数模板和类模板的知识。也可以定义成员函数模板,无论是在非模板类中还是在类模板中。在本节中,我们将学习如何做到这一点。为了理解差异,让我们从以下示例开始:

template <typename T>
class composition
{
public:
   T add(T const a, T const b)
   {
      return a + b;
   }
};

composition 类是一个类模板。它有一个名为 add 的单一成员函数,该函数使用类型参数 T。这个类可以这样使用:

composition<int> c;
c.add(41, 21);

我们首先需要实例化 composition 类的对象。请注意,我们必须显式指定类型参数 T 的参数,因为编译器无法自行推断出来(没有上下文可以从中推断)。当我们调用 add 函数时,我们只需提供参数。它们的类型,由之前解析为 intT 类型模板参数表示,已经已知。例如 c.add<int>(42, 21) 这样的调用将触发编译器错误。add 函数不是一个函数模板,而是一个类模板 composition 的成员函数。

在下一个示例中,composition 类略有变化,但意义重大。让我们首先看看定义:

class composition
{
public:
   template <typename T>
   T add(T const a, T const b)
   {
      return a + b;
   }
};

这次,composition 是一个非模板类。然而,add 函数是一个函数模板。因此,要调用此函数,我们必须执行以下操作:

composition c;
c.add<int>(41, 21);

对于 T 类型模板参数的 int 类型显式指定是多余的,因为编译器可以从调用参数中自行推断出来。然而,这里展示了这样做有助于更好地理解这两种实现之间的差异。

除了这两种情况之外,我们还可以有类模板的成员函数模板。在这种情况下,成员函数模板的模板参数必须与类模板的模板参数不同;否则,编译器将生成错误。让我们回到 wrapper 类模板示例,并按如下方式修改它:

template <typename T>
class wrapper
{
public:
   wrapper(T const v) :value(v)
   {}
   T const& get() const { return value; }
   template <typename U>
   U as() const
   {
      return static_cast<U>(value);
   }
private:
   T value;
};

如您所见,这个实现增加了一个成员,一个名为 as 的函数。这是一个函数模板,有一个名为 U 的类型模板参数。这个函数用于将包装值从类型 T 转换为类型 U,并将其返回给调用者。我们可以如下使用这个实现:

wrapper<double> a(42.0);
auto d = a.get();       // double
auto n = a.as<int>();   // int

在实例化 wrapper 类(double)时指定了模板参数的参数 - 虽然在 C++17 中这是多余的,并且在调用 as 函数(int)以执行转换时。

在我们继续其他主题之前,例如实例化、特化和其他形式的模板,包括变量和别名之前,重要的是我们要花时间更多地了解模板参数。这将使下一节的主题更加清晰。

理解模板参数

到目前为止,本书中我们已经看到了多个具有一个或多个参数的模板示例。在所有这些示例中,参数代表在实例化时提供的类型,无论是用户明确提供的,还是编译器在可以推断它们时隐式提供的。这类参数被称为类型模板参数。然而,模板也可以有非类型模板参数模板模板参数。在以下章节中,我们将探讨所有这些参数。

类型模板参数

如前所述,这些是在模板实例化过程中作为参数提供的类型参数。它们通过typenameclass关键字引入。使用这两个关键字没有区别。类型模板参数可以有一个默认值,这是一个类型。这可以通过与指定函数参数默认值相同的方式指定。以下是一些示例:

template <typename T>
class wrapper { /* ... */ };
template <typename T = int>
class wrapper { /* ... */ };

类型模板参数的名称可以省略,这在转发声明中可能很有用:

template <typename>
class wrapper;
template <typename = int>
class wrapper;

C++11 引入了可变模板,这些模板具有可变数量的参数。接受零个或多个参数的模板参数称为参数包类型模板参数包具有以下形式:

template <typename... T>
class wrapper { /* ... */ };

可变模板将在第三章,“可变模板”中讨论。因此,我们在此不会深入讨论这类参数的细节。

C++20 引入了typenameclass关键字。以下是一些示例,包括具有默认值的概念和受约束的类型模板参数包:

template <WrappableType T>
class wrapper { /* ... */ };
template <WrappableType T = int>
class wrapper { /* ... */ };
template <WrappableType... T>
class wrapper { /* ... */ };

概念和约束在第第六章“概念和约束”中讨论。我们将在那一章中了解更多关于这类参数的信息。现在,让我们看看第二种模板参数,非类型模板参数。

非类型模板参数

模板参数不总是必须代表类型。它们也可以是编译时表达式,例如常量、具有外部链接的函数或对象的地址,或静态类成员的地址。使用编译时表达式提供的参数称为非类型模板参数。这类参数只能具有结构化类型。以下是一些结构化类型:

  • 整数类型

  • 浮点类型,自 C++20 起

  • 枚举

  • 指针类型(指向对象或函数)

  • 成员类型指针(指向成员对象或成员函数)

  • 左值引用类型(指向对象或函数)

  • 符合以下要求的字面类类型:

    • 所有基类都是公开且不可变的。

    • 所有非静态数据成员都是公开且不可变的。

    • 所有基类和非静态数据成员的类型也是结构化类型或其数组。

这些类型的 cv-限定形式也可以用于非类型模板参数。非类型模板参数可以以不同的方式指定。可能的形式在以下片段中显示:

template <int V>
class foo { /*...*/ };
template <int V = 42>
class foo { /*...*/ };
template <int... V>
class foo { /*...*/ };

在所有这些例子中,非类型模板参数的类型是 int。第一个和第二个例子是相似的,除了第二个例子使用了默认值。第三个例子显著不同,因为参数实际上是一个参数包。这将在下一章中讨论。

为了更好地理解非类型模板参数,让我们看看以下示例,其中我们草拟了一个固定大小的数组类,称为 buffer

template <typename T, size_t S>
class buffer
{
   T data_[S];
public:
   constexpr T const * data() const { return data_; }
   constexpr T& operator[](size_t const index)
   {
      return data_[index];
   }
   constexpr T const & operator[](size_t const index) const
   {
      return data_[index];
   }
};

这个 buffer 类包含一个内部数组,该数组有 S 个元素,类型为 T。因此,S 需要是一个编译时值。这个类可以如下实例化:

buffer<int, 10> b1;
buffer<int, 2*5> b2;

这两个定义是等价的,b1b2 都是两个包含 10 个整数的缓冲区。此外,它们是同一类型,因为 2*5 和 10 是两个在编译时评估为相同值的表达式。你可以通过以下语句轻松检查这一点:

static_assert(std::is_same_v<decltype(b1), decltype(b2)>);

这种情况不再适用,因为 b3 对象的类型声明如下:

buffer<int, 3*5> b3;

在这个例子中,b3 是一个包含 15 个整数的 buffer,这与上一个例子中包含 10 个整数的 buffer 类型不同。从概念上讲,编译器会生成以下代码:

template <typename T, size_t S>
class buffer
{
   T data_[S];
public:
   constexpr T* data() const { return data_; }
   constexpr T& operator[](size_t const index)
   {
      return data_[index];
   }
   constexpr T const & operator[](size_t const index) const
   {
      return data_[index];
   }
};

这是主模板的代码,但接下来还将展示几个特化示例:

template<>
class buffer<int, 10>
{
  int data_[10];
public: 
  constexpr int * data() const;
  constexpr int & operator[](const size_t index); 
  constexpr const int & operator[](
    const size_t index) const;
};
template<>
class buffer<int, 15>
{
  int data_[15]; 
public: 
  constexpr int * data() const;
  constexpr int & operator[](const size_t index);
  constexpr const int & operator[](
    const size_t index) const;
};

在这个代码示例中看到的特化概念将在本章的 理解模板特化 部分中进一步详细说明。目前,你应该注意到两种不同的 buffer 类型。再次强调,可以通过以下语句验证 b1b3 的类型不同:

static_assert(!std::is_same_v<decltype(b1), decltype(b3)>);

在实践中,使用结构化类型(如整数、浮点数或枚举类型)的情况比其他情况更为常见。理解它们的使用和找到有用的示例可能更容易。然而,也存在使用指针或引用的场景。在以下示例中,我们将检查函数参数指针的使用。让我们先看看代码:

struct device
{
   virtual void output() = 0;
   virtual ~device() {}
};
template <void (*action)()>
struct smart_device : device
{
   void output() override
   {
      (*action)();
   }
};

在这个片段中,device 是一个具有纯虚函数 output(以及虚析构函数)的基类。这是 smart_device 类模板的基类,该类模板通过调用函数指针来实现 output 虚函数。这个函数指针传递了一个参数给类模板的非类型模板参数。以下示例展示了它的用法:

void say_hello_in_english()
{
   std::cout << "Hello, world!\n";
}
void say_hello_in_spanish()
{
   std::cout << "Hola mundo!\n";
}
auto w1 =
   std::make_unique<smart_device<&say_hello_in_english>>();
w1->output();
auto w2 =
   std::make_unique<smart_device<&say_hello_in_spanish>>();
w2->output();

在这里,w1w2是两个unique_ptr对象。尽管表面上它们指向相同类型的对象,但这并不正确,因为smart_device<&say_hello_in_english>smart_device<&say_hello_in_spanish>是不同的类型,因为它们使用不同的函数指针值实例化。这可以通过以下语句轻松检查:

static_assert(!std::is_same_v<decltype(w1), decltype(w2)>);

相反,如果我们将auto指定符改为std::unique_ptr<device>,如下面的代码片段所示,那么w1w2就是基类 device 的智能指针,因此它们具有相同的类型:

std::unique_ptr<device> w1 = 
   std::make_unique<smart_device<&say_hello_in_english>>();
w1->output();
std::unique_ptr<device> w2 = 
   std::make_unique<smart_device<&say_hello_in_spanish>>();
w2->output();
static_assert(std::is_same_v<decltype(w1), decltype(w2)>);

虽然这个例子使用了函数指针,但也可以构思一个类似的使用成员函数指针的例子。前一个例子可以转换为以下形式(仍然使用相同的基类 device):

template <typename Command, void (Command::*action)()>
struct smart_device : device
{
   smart_device(Command& command) : cmd(command) {}
   void output() override
   {
      (cmd.*action)();
   }
private:
   Command& cmd;
};
struct hello_command
{
   void say_hello_in_english()
   {
      std::cout << "Hello, world!\n";
   }
   void say_hello_in_spanish()
   {
      std::cout << "Hola mundo!\n";
   }
};

这些类可以如下使用:

hello_command cmd;
auto w1 = std::make_unique<
   smart_device<hello_command, 
      &hello_command::say_hello_in_english>>(cmd);
w1->output();
auto w2 = std::make_unique<
   smart_device<hello_command, 
      &hello_command::say_hello_in_spanish>>(cmd);
w2->output();

在 C++17 中,引入了一种新的指定非类型模板参数的形式,使用auto指定符(包括auto*auto&形式)或decltype(auto)代替类型的名称。这允许编译器从提供的表达式推断参数的类型。如果推断的类型不允许作为非类型模板参数,编译器将生成错误。让我们看一个例子:

template <auto x>
struct foo
{ /* … */ };

这个类模板可以如下使用:

foo<42>   f1;  // foo<int>
foo<42.0> f2;  // foo<double> in C++20, error for older 
               // versions
foo<"42"> f3;  // error

在第一个例子中,对于f1,编译器推断参数的类型为int。在第二个例子中,对于f2,编译器推断的类型为double。然而,这只适用于 C++20。在标准的前版本中,这一行会产生错误,因为 C++20 之前不允许将浮点类型作为非类型模板参数的参数。然而,最后一行产生错误,因为"42"是一个字符串字面量,而字符串字面量不能用作非类型模板参数的参数。

然而,在 C++20 中,可以通过将字面量字符串包裹在结构字面量类中来绕过最后一个例子。这个类将字符串字面量的字符存储在固定长度的数组中。以下代码片段展示了这一点:

template<size_t N>
struct string_literal
{
   constexpr string_literal(const char(&str)[N])
   {
      std::copy_n(str, N, value);
   }
   char value[N];
};

然而,前面展示的foo类模板需要修改,以显式使用string_literal而不是auto指定符:

template <string_literal x>
struct foo
{
};

在此基础上,前面展示的foo<"42"> f;声明在 C++20 中将无错误编译。

auto指定符也可以与非类型模板参数包一起使用。在这种情况下,每个模板参数的类型都是独立推断的。模板参数的类型不需要相同。以下代码片段展示了这一点:

template<auto... x>
struct foo
{ /* ... */ };
foo<42, 42.0, false, 'x'> f;

在这个例子中,编译器推断模板参数的类型分别为intdoubleboolchar

第三种也是最后一种模板参数类别是模板模板参数。我们将在下一节中探讨它们。

模板模板参数

尽管这个名字听起来可能有点奇怪,但它指的是一类模板参数,这些参数本身也是模板。这些参数可以像类型模板参数一样指定,带有或没有名称,带有或没有默认值,以及带有或没有名称的参数包。截至 C++17,可以使用关键字 classtypename 来引入模板模板参数。在此版本之前,只能使用 class 关键字。

为了展示模板模板参数的使用,让我们首先考虑以下两个类模板:

template <typename T>
class simple_wrapper
{
public:
   T value;
};
template <typename T>
class fancy_wrapper
{
public:
   fancy_wrapper(T const v) :value(v)
   {
   }
   T const& get() const { return value; }
   template <typename U>
   U as() const
   {
      return static_cast<U>(value);
   }
private:
   T value;
};

simple_wrapper 类是一个非常简单的类模板,它持有类型模板参数 T 的值。另一方面,fancy_wrapper 是一个更复杂的包装实现,它隐藏了包装的值并公开了数据访问的成员函数。接下来,我们实现一个名为 wrapping_pair 的类模板,它包含两个包装类型的值。这可以是 simpler_wrapperfancy_wrapper 或其他类似的东西:

template <typename T, typename U, 
          template<typename> typename W = fancy_wrapper>
class wrapping_pair
{
public:
   wrapping_pair(T const a, U const b) :
      item1(a), item2(b)
   {
   }
   W<T> item1;
   W<U> item2;
};   

wrapping_pair 类模板有三个参数。前两个是类型模板参数,分别命名为 TU。第三个参数是模板模板参数,称为 W,它有一个默认值,即 fancy_wrapper 类型。我们可以像以下代码片段所示使用这个类模板:

wrapping_pair<int, double> p1(42, 42.0);
std::cout << p1.item1.get() << ' '
          << p1.item2.get() << '\n';
wrapping_pair<int, double, simple_wrapper> p2(42, 42.0);
std::cout << p2.item1.value << ' '
          << p2.item2.value << '\n';

在这个例子中,p1 是一个包含两个值(一个 int 和一个 double,每个都包装在一个 fancy_wrapper 对象中)的 wrapping_pair 对象。这不是显式指定的,而是模板模板参数的默认值。另一方面,p2 也是一个 wrapping_pair 对象,也包含一个 int 和一个 double,但这些被一个 simple_wrapper 对象包装,现在在模板实例化中显式指定。

在这个例子中,我们看到了模板参数的默认模板参数的使用。这个主题将在下一节中详细探讨。

默认模板参数

默认模板参数的指定方式与默认函数参数类似,在等号后面的参数列表中。以下规则适用于默认模板参数:

  • 它们可以与任何类型的模板参数一起使用,除了参数包。

  • 如果在类模板、变量模板或类型别名中为模板参数指定了一个默认值,那么所有后续的模板参数也必须有一个默认值。例外是最后一个参数,如果它是模板参数包。

  • 如果在函数模板中为模板参数指定了一个默认值,那么后续的模板参数不受限制,也必须有一个默认值。

  • 在函数模板中,参数包之后可以跟有更多类型参数,前提是它们有默认参数或编译器可以从函数参数中推断出它们的值。

  • 它们不允许在友元类模板的声明中使用。

  • 它们只允许在友元函数模板的声明中使用,如果该声明也是一个定义,并且在同一翻译单元中没有其他函数声明。

  • 它们不允许在函数模板或成员函数模板的显式特化的声明或定义中使用。

以下代码片段展示了使用默认模板参数的示例:

template <typename T = int>
class foo { /*...*/ };
template <typename T = int, typename U = double>
class bar { /*...*/ };

如前所述,在声明类模板时,带有默认参数的模板参数不能后面跟着没有默认参数的参数,但这种限制不适用于函数模板。这将在下一个代码片段中展示:

template <typename T = int, typename U>
class bar { };   // error
template <typename T = int, typename U>
void func() {}   // OK

一个模板可以有多个声明(但只有一个定义)。所有声明和定义中的默认模板参数被合并(与默认函数参数合并的方式相同)。让我们通过一个例子来了解它是如何工作的:

template <typename T, typename U = double>
struct foo;
template <typename T = int, typename U>
struct foo;
template <typename T, typename U>
struct foo
{
   T a;
   U b;
};

这在语义上等同于以下定义:

template <typename T = int, typename U = double>
struct foo
{
   T a;
   U b;
};

然而,这些具有不同默认模板参数的多个声明不能以任何顺序提供。前面提到的规则仍然适用。因此,第一个参数具有默认参数而后续参数没有的类模板声明是不合法的:

template <typename T = int, typename U>
struct foo;  // error, U does not have a default argument
template <typename T, typename U = double>
struct foo;

默认模板参数的另一个限制是,在同一个作用域内不能为同一个模板参数指定多个默认值。因此,下一个示例将产生错误:

template <typename T = int>
struct foo;
template <typename T = int> // error redefinition
                            // of default parameter
struct foo {};

当默认模板参数使用类中的名称时,成员访问限制是在声明时检查的,而不是在模板实例化时:

template <typename T>
struct foo
{
protected:
   using value_type = T;
};
template <typename T, typename U = typename T::value_type>
struct bar
{
   using value_type = U;
};
bar<foo<int>> x;

当定义x变量时,bar 类模板被实例化,但foo::value_type类型别名是受保护的,因此不能在foo之外使用。结果是bar类模板声明时出现编译错误。

通过这些说明,我们结束了模板参数这一主题。在下一节中,我们将探讨模板实例化,这是从模板定义和一组模板参数创建函数、类或变量新定义的过程。

理解模板实例化

如前所述,模板只是编译器在遇到它们的使用时创建实际代码的蓝图。从模板声明创建函数、类或变量定义的行为称为模板实例化。这可以是显式的,即当你告诉编译器何时生成定义时,或者隐式的,即编译器根据需要生成新的定义。我们将在下一节中详细探讨这两种形式。

隐式实例化

当编译器根据模板的使用生成定义,并且没有显式实例化时,会发生隐式实例化。隐式实例化的模板定义在模板所在的同一命名空间中。然而,编译器从模板创建定义的方式可能不同。这将在以下示例中看到。让我们考虑以下代码:

template <typename T>
struct foo
{
  void f() {}
};
int main()
{
  foo<int> x;
}

这里,我们有一个名为 foo 的类模板,它有一个成员函数 f。在 main 中,我们定义了一个 foo<int> 类型的变量,但没有使用它的任何成员。因为编译器遇到了对 foo 的这种使用,它会隐式地为 int 类型定义 foo 的一个特化。如果你使用在 Clang 上运行的 cppinsights.io,你会看到以下代码:

template<>
struct foo<int>
{
  inline void f();
};

因为我们的代码中没有调用函数 f,它只被声明而没有定义。如果我们向 main 中添加一个 f 调用,特化将如下改变:

template<>
struct foo<int>
{
  inline void f() { }
};

然而,如果我们添加一个名为 g 的额外函数,其实现包含一个错误,我们将根据不同的编译器获得不同的行为:

template <typename T>
struct foo
{
  void f() {}
  void g() {int a = "42";}
};
int main()
{
  foo<int> x;
  x.f();
}

g 函数体中存在一个错误(你也可以使用 static_assert(false) 语句作为替代)。这段代码在 VC++ 中编译没有任何问题,但在 Clang 和 GCC 中会失败。这是因为 VC++ 忽略了模板中未使用的部分,只要代码在语法上是正确的,但其他编译器在继续模板实例化之前会进行语义验证。

对于函数模板,当用户代码在需要其定义存在的上下文中引用一个函数时,会发生隐式实例化。对于类模板,当用户代码在需要完整类型或类型的完整性影响代码的上下文中引用模板时,会发生隐式实例化。此类上下文的典型例子是在构造此类类型的对象时。然而,在声明类模板的指针时并非如此。为了理解它是如何工作的,让我们考虑以下示例:

template <typename T>
struct foo
{
  void f() {}
  void g() {}
};
int main()
{
  foo<int>* p;
  foo<int> x;
  foo<double>* q;
}

在这个片段中,我们使用了之前示例中的相同 foo 类模板,并声明了几个变量:p 是指向 foo<int> 的指针,x 是一个 foo<int>q 是指向 foo<double> 的指针。由于 x 的声明,编译器此时只需要实例化 foo<int>。现在,让我们考虑一些成员函数 fg 的调用,如下所示:

int main()
{
  foo<int>* p;
  foo<int> x;
  foo<double>* q;
  x.f();
  q->g();
}

随着这些更改,编译器需要实例化以下内容:

  • 当声明 x 变量时 foo<int>

  • 当发生 x.f() 调用时 foo<int>::f()

  • 当发生 q->g() 调用时 foo<double>foo<double>::g()

另一方面,当声明 p 指针时,编译器不需要实例化 foo<int>,当声明 q 指针时,也不需要实例化 foo<double>。然而,当类模板特化涉及指针转换时,编译器确实需要隐式实例化。以下示例展示了这一点:

template <typename T>
struct control
{};
template <typename T>
struct button : public control<T>
{};
void show(button<int>* ptr)
{
   control<int>* c = ptr;
}

show 函数中,发生 button<int>*control<int>* 之间的转换。因此,在这个点上,编译器必须实例化 button<int>

当一个类模板包含静态成员时,这些成员在编译器隐式实例化类模板时不会隐式实例化,但只有在编译器需要它们的定义时才会实例化。另一方面,每个类模板的特化都有自己的静态成员副本,如下面的代码片段所示:

template <typename T>
struct foo
{
   static T data;
};
template <typename T> T foo<T>::data = 0;
int main()
{
   foo<int> a;
   foo<double> b;
   foo<double> c;
   std::cout << a.data << '\n'; // 0
   std::cout << b.data << '\n'; // 0
   std::cout << c.data << '\n'; // 0
   b.data = 42;
   std::cout << a.data << '\n'; // 0
   std::cout << b.data << '\n'; // 42
   std::cout << c.data << '\n'; // 42
}

类模板 foo 有一个名为 data 的静态成员变量,它在 foo 的定义之后初始化。在 main 函数中,我们将变量 a 声明为 foo<int> 的对象,而 bc 则是 foo<double> 的对象。最初,它们的所有成员字段 data 都初始化为 0。然而,变量 bc 共享同一份数据副本。因此,在执行 b.data = 42 赋值操作后,a.data 仍然是 0,但 b.datac.data 都是 42

在了解了隐式实例化的工作原理之后,现在是时候继续前进,了解模板实例化的另一种形式,即显式实例化。

显式实例化

作为用户,你可以明确告诉编译器实例化一个类模板或函数模板。这被称为显式实例化,并且有两种形式:显式实例化定义显式实例化声明。我们将按此顺序讨论它们。

显式实例化定义

显式实例化定义可能出现在程序中的任何位置,但必须在引用的模板定义之后。显式模板实例化定义的语法有以下形式:

  • 类模板的语法如下:

    template class-key template-name <argument-list>
    
  • 函数模板的语法如下:

    template return-type name<argument-list>(parameter-list);
    template return-type name(parameter-list);
    

如您所见,在所有情况下,显式实例化定义都是以 template 关键字开始的,但不跟任何参数列表。对于类模板,class-key 可以是 classstructunion 关键字之一。对于类和函数模板,具有给定参数列表的显式实例化定义在整个程序中只能出现一次。

我们将通过一些示例来了解这是如何工作的。以下是第一个示例:

namespace ns
{
   template <typename T>
   struct wrapper
   {
      T value;
   };
   template struct wrapper<int>;       // [1]
}
template struct ns::wrapper<double>;   // [2]
int main() {}

在这个片段中,wrapper<T>是在ns命名空间中定义的类模板。代码中标记为[1][2]的语句都代表显式实例化定义,分别对应wrapper<int>wrapper<double>。显式实例化定义只能出现在它所引用的模板所在的同一命名空间中(如[1]所示),或者它必须是完全限定的(如[2]所示)。我们可以为函数模板编写类似的显式模板定义:

namespace ns
{
   template <typename T>
   T add(T const a, T const b)
   {
      return a + b;
   }
   template int add(int, int);           // [1]
}
template double ns::add(double, double); // [2]
int main() { }

第二个示例与第一个示例有惊人的相似之处。[1][2]都代表add<int>()add<double>()的显式模板定义。

如果显式实例化定义不在与模板相同的命名空间中,则名称必须完全限定。使用using语句不会使名称在当前命名空间中可见。以下示例展示了这一点:

namespace ns
{
   template <typename T>
   struct wrapper { T value; };
}
using namespace ns;
template struct wrapper<double>;   // error

在这个示例的最后一行中,会生成一个编译错误,因为wrapper是一个未知名称,必须用命名空间名称限定,如ns::wrapper

当类成员用作返回类型或参数类型时,在显式实例化定义中会忽略成员访问指定。以下代码片段展示了示例:

template <typename T>
class foo
{
   struct bar {};
   T f(bar const arg)
   {
      return {};
   }
};
template int foo<int>::f(foo<int>::bar);

X<T>::bar类和foo<T>::f()函数都是foo<T>类的私有成员,但它们可以在最后一行显示的显式实例化定义中使用。

在了解了显式实例化定义及其工作原理之后,出现的问题是何时它是有用的。你为什么要告诉编译器从模板中生成实例化?答案是这有助于分发库、减少构建时间和可执行文件大小。如果你正在构建一个你想以.lib文件形式分发的库,并且该库使用模板,那么没有实例化的模板定义不会被放入库中。但这会导致每次使用库时用户代码的构建时间增加。通过强制在库中实例化模板,那些定义被放入对象文件和你要分发的.lib文件中。因此,你的用户代码只需要链接到库文件中可用的那些函数。这就是 Microsoft MSVC CRT 库为所有流、区域设置和字符串类所做的事情。libstdc++库对字符串类和其他类也做了同样的事情。

模板实例化可能引发的问题是你可能会得到多个定义,每个翻译单元一个。如果包含模板的相同头文件被包含在多个翻译单元(.cpp文件)中,并且使用了相同的模板实例化(例如,从我们之前的示例中的wrapper<int>),那么这些实例化的相同副本将被放入每个翻译单元中。这会导致对象大小增加。可以通过显式实例化声明来解决此问题,我们将在下一节中探讨。

显式实例化声明

显式实例化声明(自 C++11 起可用)是你可以告诉编译器模板实例化的定义位于不同的翻译单元中,并且不应生成新定义的方法。其语法与显式实例化定义相同,只是在声明前使用了 extern 关键字:

  • 类模板的语法如下:

    extern template class-key template-name <argument-list>
    
  • 函数模板的语法如下:

    extern template return-type name<argument-list>(parameter-list);
    extern template return-type name(parameter-list);
    

如果你提供了一个显式的实例化声明,但在程序的任何翻译单元中都没有实例化定义,那么结果将是编译器警告和链接错误。技术是在一个源文件中声明显式的模板实例化,在剩余的源文件中声明显式的模板声明。这将减少编译时间和目标文件大小。

让我们看看以下示例:

// wrapper.h
template <typename T>
struct wrapper
{
   T data;
}; 
extern template wrapper<int>;   // [1]
// source1.cpp
#include "wrapper.h"
#include <iostream>
template wrapper<int>;          // [2]
void f()
{
   ext::wrapper<int> a{ 42 };
   std::cout << a.data << '\n';
}
// source2.cpp
#include "wrapper.h"
#include <iostream>
void g()
{
   wrapper<int> a{ 100 };
   std::cout << a.data << '\n';
}
// main.cpp
#include "wrapper.h"
int main()
{
   wrapper<int> a{ 0 };
}

在这个示例中,我们可以看到以下内容:

  • wrapper.h 头文件包含一个名为 wrapper<T> 的类模板。在标记为 [1] 的行中有一个对 wrapper<int> 的显式实例化声明,告诉编译器在编译包含此头文件的源文件(翻译单元)时不要为这个实例化生成定义。

  • source1.cpp 文件包含了 wrapper.h,在标记为 [2] 的行中包含了对 wrapper<int> 的显式实例化定义。这是整个程序中这个实例化的唯一定义。

  • 源文件 source2.cppmain.cpp 都使用了 wrapper<int>,但没有任何显式实例化定义或声明。这是因为当头文件包含在每个这些文件中时,wrapper.h 中的显式声明是可见的。

或者,可以将显式实例化声明从头文件中移除,但然后它必须添加到包含该头文件的每个源文件中,这很可能会被遗忘。

当你进行显式模板声明时,请记住,定义在类体内部的类成员函数始终被认为是内联的,因此它总是会实例化。因此,你只能使用 extern 关键字来定义类体之外的成员函数。

现在我们已经了解了模板实例化是什么,我们将继续讨论另一个重要主题,模板特化,这是从模板实例化中创建的定义,用于处理特定的模板参数集。

理解模板特化

模板特化是从模板实例化创建的定义。正在特化的模板称为主模板。你可以为给定的一组模板参数提供一个显式特化的定义,从而覆盖编译器会生成的隐式代码。这是支持诸如类型特性和条件编译等特性的技术,这些是我们在 第五章类型特性和条件编译 中将要探讨的元编程概念。

模板特化有两种形式:显式(完整)特化部分特化。我们将在以下章节中详细探讨这两个方面。

显式特化

显式特化(也称为完整特化)发生在你为具有完整模板参数集的模板实例提供定义时。以下内容可以完全特化:

  • 函数模板

  • 类模板

  • 变量模板(自 C++14 起可用)

  • 类模板的成员函数、类和枚举

  • 类或类模板的成员函数模板和类模板

  • 类模板的静态数据成员

让我们从以下示例开始:

template <typename T>
struct is_floating_point
{
   constexpr static bool value = false;
};
template <>
struct is_floating_point<float>
{
   constexpr static bool value = true;
};
template <>
struct is_floating_point<double>
{
   constexpr static bool value = true;
};
template <>
struct is_floating_point<long double>
{
   constexpr static bool value = true;
};

在此代码片段中,is_floating_point 是主模板。它包含一个名为 valueconstexpr 静态布尔数据成员,其初始值为 false。然后,我们有三个针对 floatdoublelong double 类型的完整特化。这些新定义改变了 value 使用 true 而不是 false 初始化的方式。因此,我们可以使用此模板编写如下代码:

std::cout << is_floating_point<int>::value         << '\n';
std::cout << is_floating_point<float>::value       << '\n';
std::cout << is_floating_point<double>::value      << '\n';
std::cout << is_floating_point<long double>::value << '\n';
std::cout << is_floating_point<std::string>::value << '\n';

第一行和最后一行打印 0(对于 false);其他行打印 1(对于 true)。这个示例演示了 type 特性是如何工作的。实际上,标准库在 <type_traits> 头文件中定义了一个名为 is_floating_point 的类模板,位于 std 命名空间中。我们将在 第五章类型特性和条件编译 中了解更多关于这个主题的内容。

正如你在示例中看到的,静态类成员可以被完全特化。然而,每个特化都有自己的静态成员副本,以下示例进行了演示:

template <typename T>
struct foo
{
   static T value;
};
template <typename T> T foo<T>::value = 0;
template <> int foo<int>::value = 42;
foo<double> a, b;  // a.value=0, b.value=0
foo<int> c;        // c.value=42
a.value = 100;     // a.value=100, b.value=100, c.value=42

在这里,foo<T> 是一个具有单个静态成员的类模板,该成员称为 value。对于主模板,它被初始化为 0,而对于 int 特化,它被初始化为 42。在声明变量 abc 之后,a.value0b.value 也是 0,而 c.value42。然而,在将值 100 赋给 a.value 之后,b.value 也变成了 100,而 c.value 保持为 42

显式特化必须出现在主模板声明之后。它不需要在显式特化之前提供主模板的定义。因此,以下代码是有效的:

template <typename T>
struct is_floating_point;
template <>
struct is_floating_point<float>
{
   constexpr static bool value = true;
};
template <typename T>
struct is_floating_point
{
   constexpr static bool value = false;
};

模板特化也可以只声明而不定义。这样的模板特化可以像任何其他不完整类型一样使用。您可以在以下示例中看到这一点:

template <typename>
struct foo {};    // primary template
template <>
struct foo<int>;  // explicit specialization declaration
foo<double> a; // OK
foo<int>* b;   // OK
foo<int> c;    // error, foo<int> incomplete type

在这个例子中,foo<T> 是一个主模板,对于它存在一个针对 int 类型的显式特化的声明。这使得可以使用 foo<double>foo<int>*(支持声明指向部分类型的指针)。然而,在声明 c 变量时,完整的类型 foo<int> 不可用,因为缺少对 int 的完整特化的定义。这会生成编译器错误。

当特化一个函数模板时,如果编译器能够从函数参数的类型中推断出一个模板参数,那么这个模板参数是可选的。以下示例展示了这一点:

template <typename T>
struct foo {};
template <typename T>
void func(foo<T>) 
{
   std::cout << "primary template\n";
}
template<>
void func(foo<int>) 
{
   std::cout << "int specialization\n";
}

func 函数模板的 int 完整特化的语法应该是 template<> func<int>(foo<int>)。然而,编译器能够从函数参数的类型中推断出 T 实际表示的类型。因此,在定义特化时我们不必指定它。

另一方面,函数模板和成员函数模板的声明或定义不允许包含默认函数参数。因此,在以下示例中,编译器将发出错误:

template <typename T>
void func(T a)
{
   std::cout << "primary template\n";
}
template <>
void func(int a = 0) // error: default argument not allowed
{
   std::cout << "int specialization\n";
}

在所有这些示例中,模板只有一个模板参数。然而,在实际应用中,许多模板有多个参数。显式特化需要一个包含完整参数集的定义。以下代码片段展示了这一点:

template <typename T, typename U>
void func(T a, U b)
{
   std::cout << "primary template\n";
}
template <>
void func(int a, int b)
{
   std::cout << "int-int specialization\n";
}
template <>
void func(int a, double b)
{
   std::cout << "int-double specialization\n";
}
func(1, 2);      // int-int specialization
func(1, 2.0);    // int-double specialization
func(1.0, 2.0);  // primary template

在这些内容的基础上,我们可以继续前进,研究部分特化,它基本上是显式(完整)特化的泛化。

部分特化

当你对一个主模板进行部分特化,但只指定了一些模板参数时,就会发生部分特化。这意味着部分特化既有模板参数列表(紧随模板关键字之后)和模板参数列表(紧随模板名称之后)。然而,只有类才能进行部分特化。

让我们通过以下示例来了解它是如何工作的:

template <typename T, int S>
struct collection
{
   void operator()()
   { std::cout << "primary template\n"; }
};
template <typename T>
struct collection<T, 10>
{
   void operator()()
   { std::cout << "partial specialization <T, 10>\n"; }
};
template <int S>
struct collection<int, S>
{ 
   void operator()()
   { std::cout << "partial specialization <int, S>\n"; }
};
template <typename T, int S>
struct collection<T*, S>
{ 
   void operator()()
   { std::cout << "partial specialization <T*, S>\n"; }
};

我们有一个名为 collection 的主模板,它有两个模板参数(一个类型模板参数和一个非类型模板参数),并且我们有三个部分特化,如下所示:

  • 非类型模板参数 S 的值为 10 的特化

  • int 类型的特化

  • 指针类型 T* 的特化

这些模板可以按照以下代码片段所示使用:

collection<char, 42> a;  // primary template
collection<int,  42> b;  // partial specialization <int, S>
collection<char, 10> c;  // partial specialization <T, 10>
collection<int*, 20> d;  // partial specialization <T*, S>

如注释中所述,a 从主模板实例化,bint 的部分特化(collection<int, S>)实例化,c10 的部分特化(collection<T, 10>)实例化,而 d 从指针的部分特化(collection<T*, S>)实例化。然而,由于它们是模糊的,编译器无法选择使用哪个模板实例化,因此某些组合是不可能的。以下是一些示例:

collection<int,   10> e; // error: collection<T,10> or 
                         //        collection<int,S>
collection<char*, 10> f; // error: collection<T,10> or 
                         //        collection<T*,S>

在第一种情况下,collection<T, 10>collection<int, S> 的部分特化都与类型 collection<int, 10> 匹配,而在第二种情况下,可以是 collection<T, 10>collection<T*, S>

在定义主模板的特化时,你需要记住以下几点:

  • 部分特化的模板参数列表中的参数不能有默认值。

  • 模板参数列表暗示了模板参数列表中参数的顺序,这仅在部分特化中才有特征。部分特化的模板参数列表不能与模板参数列表暗示的列表相同。

  • 在模板参数列表中,你只能使用非类型模板参数的标识符。在此上下文中不允许使用表达式。以下示例展示了这一点:

    template <int A, int B> struct foo {};
    template <int A> struct foo<A, A> {};     // OK
    template <int A> struct foo<A, A + 1> {}; // error
    

当一个类模板有部分特化时,编译器必须决定从哪个特化生成最佳匹配的定义。为此,它将模板特化的模板参数与主模板和部分特化的模板参数列表进行匹配。根据此匹配过程的结果,编译器执行以下操作:

  • 如果没有找到匹配项,则从主模板生成一个定义。

  • 如果找到一个单独的部分特化,则从该特化生成一个定义。

  • 如果找到多个部分特化,则从最特化的部分特化生成一个定义,但前提是它是唯一的。否则,编译器会生成一个错误(如我们之前所见)。如果模板 A 接受的类型是模板 B 接受的子集,但反之则不然,则认为模板 A 比模板 B 更特化。

然而,部分特化不是通过名称查找来找到的,只有在通过名称查找找到主模板时才会考虑。

要了解部分特化的有用性,让我们看看一个现实世界的例子。

在这个例子中,我们想要创建一个函数,以优雅的方式格式化数组的内 容并将其输出到流中。格式化后的数组内容应看起来像 [1,2,3,4,5]。然而,对于 char 元素数组,元素之间不应用逗号分隔,而应显示为方括号内的字符串,例如 [demo]。为此,我们将考虑使用 std::array 类。以下实现使用分隔符格式化数组的内容:

template <typename T, size_t S>
std::ostream& pretty_print(std::ostream& os, 
                           std::array<T, S> const& arr)
{
   os << '[';
   if (S > 0)
   {
      size_t i = 0;
      for (; i < S - 1; ++i)
         os << arr[i] << ',';
      os << arr[S-1];
   }
   os << ']';
   return os;
}
std::array<int, 9> arr {1, 1, 2, 3, 5, 8, 13, 21};
pretty_print(std::cout, arr);  // [1,1,2,3,5,8,13,21]
std::array<char, 9> str;
std::strcpy(str.data(), "template");
pretty_print(std::cout, str);  // [t,e,m,p,l,a,t,e]

在这个片段中,pretty_print 是一个有两个模板参数的函数模板,与 std::array 类的模板参数相匹配。当用 arr 数组作为参数调用时,它打印 [1,1,2,3,5,8,13,21]。当用 str 数组作为参数调用时,它打印 [t,e,m,p,l,a,t,e]。然而,我们的意图是在后一种情况下打印 [template]。为此,我们需要另一个实现,它专门针对 char 类型:

template <size_t S>
std::ostream& pretty_print(std::ostream& os, 
                           std::array<char, S> const& arr)
{
   os << '[';
   for (auto const& e : arr)
      os << e;
   os << ']';
   return os;
}
std::array<char, 9> str;
std::strcpy(str.data(), "template");
pretty_print(std::cout, str);  // [template]

在这个第二个实现中,pretty_print 是一个只有一个模板参数的函数模板,这个模板参数是一个非类型模板参数,表示数组的尺寸。类型模板参数被显式指定为 char,在 std::array<char, S> 中。这次,使用 str 数组调用 pretty_print[template] 打印到控制台。

这里关键要理解的是,不是 pretty_print 函数模板被部分特化,而是 std::array 类模板。函数模板不能被特化,而我们这里有的是重载函数。然而,std::array<char,S>std::array<T, S> 主类模板的一个特化。

本章中我们看到的所有示例要么是函数模板,要么是类模板。然而,变量也可以是模板,这将是下一节的主题。

定义变量模板

变量模板是在 C++14 中引入的,允许我们在命名空间作用域或类作用域中定义模板变量,在这种情况下,它们代表一组全局变量或静态数据成员。

变量模板在命名空间作用域中声明,如下面的代码片段所示。这是一个典型的例子,你可以在文献中找到,但我们可以用它来阐述变量模板的好处:

template<class T>
constexpr T PI = T(3.1415926535897932385L);

语法类似于声明变量(或数据成员),但结合了声明模板的语法。

产生的问题是变量模板实际上是如何有帮助的。为了回答这个问题,让我们构建一个示例来展示这个观点。假设我们想要编写一个函数模板,给定一个球体的半径,返回其体积。球体的体积是 4πr³ / 3。因此,一个可能的实现如下:

constexpr double PI = 3.1415926535897932385L;
template <typename T>
T sphere_volume(T const r)
{
   return 4 * PI * r * r * r / 3;
}

在这个例子中,PI被定义为double类型的编译时常量。如果我们使用float等类型作为类型模板参数T,这将生成编译器警告:

float v1 = sphere_volume(42.0f); // warning
double v2 = sphere_volume(42.0); // OK

解决这个问题的潜在方法是将PI作为模板类的静态数据成员,其类型由类型模板参数确定。这种实现可以如下所示:

template <typename T>
struct PI
{
   static const T value;
};
template <typename T> 
const T PI<T>::value = T(3.1415926535897932385L);
template <typename T>
T sphere_volume(T const r)
{
   return 4 * PI<T>::value * r * r * r / 3;
}

这方法是可行的,尽管使用PI<T>::value并不理想。如果能简单地写PI<T>会更好。这正是本节开头展示的变量模板PI允许我们做到的。下面是完整的解决方案:

template<class T>
constexpr T PI = T(3.1415926535897932385L);
template <typename T>
T sphere_volume(T const r)
{
   return 4 * PI<T> * r * r * r / 3;
}

下一个示例展示了另一种可能的用法,并演示了变量模板的显式特化:

template<typename T> 
constexpr T SEPARATOR = '\n';
template<> 
constexpr wchar_t SEPARATOR<wchar_t> = L'\n';
template <typename T>
std::basic_ostream<T>& show_parts(
   std::basic_ostream<T>& s, 
   std::basic_string_view<T> const& str)
{
   using size_type = 
      typename std::basic_string_view<T>::size_type;
   size_type start = 0;
   size_type end;
   do
   {
      end = str.find(SEPARATOR<T>, start);
      s << '[' << str.substr(start, end - start) << ']' 
        << SEPARATOR<T>;
      start = end+1;
   } while (end != std::string::npos);
   return s;
}
show_parts<char>(std::cout, "one\ntwo\nthree");
show_parts<wchar_t>(std::wcout, L"one line");

在这个例子中,我们有一个名为show_parts的函数模板,它处理一个输入字符串,在分割由分隔符分隔的部分之后。分隔符是一个在(全局)命名空间作用域中定义的变量模板,并显式特化为wchar_t类型。

如前所述,变量模板可以是类的成员。在这种情况下,它们代表静态数据成员,需要使用static关键字进行声明。以下示例演示了这一点:

struct math_constants
{
   template<class T>
   static constexpr T PI = T(3.1415926535897932385L);
};
template <typename T>
T sphere_volume(T const r)
{
   return 4 * math_constants::PI<T> *r * r * r / 3;
}

你可以在类中声明一个变量模板,然后在其外部提供其定义。请注意,在这种情况下,变量模板必须使用static const声明,而不是static constexpr,因为后者需要在类内初始化:

struct math_constants
{
   template<class T>
   static const T PI;
};
template<class T>
const T math_constants::PI = T(3.1415926535897932385L);

变量模板用于简化类型特性的使用。显式特化部分包含了一个名为is_floating_point的类型特性的示例。这里再次是主要模板:

template <typename T>
struct is_floating_point
{
   constexpr static bool value = false;
};

有几个显式特化,这里不再列出。然而,这个type特性可以如下使用:

std::cout << is_floating_point<float>::value << '\n';

使用is_floating_point<float>::value相当繁琐,但可以通过以下定义的变量模板来避免:

template <typename T>
inline constexpr bool is_floating_point_v = 
   is_floating_point<T>::value;

这个is_floating_point_v变量模板有助于编写更简单、更易于阅读的代码。以下片段是我更倾向于使用,而不是使用::value的冗长变体的形式:

std::cout << is_floating_point_v<float> << '\n';

标准库定义了一系列以_v后缀结尾的变量模板,用于::value,就像我们的例子一样(例如std::is_floating_point_vstd::is_same_v)。我们将在第五章中更详细地讨论这个主题,类型特性与条件编译

变量模板的实例化方式类似于函数模板和类模板。这可以通过显式实例化或显式特化来实现,或者由编译器隐式地完成。当变量模板在需要存在变量定义的上下文中使用时,或者变量需要用于表达式的常量评估时,编译器会生成一个定义。

然后,我们转向别名模板的主题,它允许我们为类模板定义别名。

定义别名模板

在 C++ 中,可以使用 typedef 声明或 using 声明(后者是在 C++11 中引入的)。以下是一些使用 typedef 的示例:

typedef int index_t;
typedef std::vector<
           std::pair<int, std::string>> NameValueList;
typedef int (*fn_ptr)(int, char);
template <typename T>
struct foo
{
   typedef T value_type;
};

在这个例子中,index_tint 的别名,NameValueListstd::vector<std::pair<int, std::string>> 的别名,而 fn_ptr 是返回 int 并有两个 intchar 类型的参数的函数指针类型的别名。最后,foo::value_type 是类型模板 T 的别名。

自 C++11 以来,这些类型别名可以通过以下形式的 using 声明 来创建:

using index_t = int;
using NameValueList = 
   std::vector<std::pair<int, std::string>>;
using fn_ptr = int(*)(int, char);
template <typename T>
struct foo
{
   using value_type = T;
};

现在,使用声明比 typedef 声明更受欢迎,因为它们更容易使用,也更易于阅读(从左到右)。然而,它们比 typedef 有一个重要的优势,即允许我们为模板创建别名。别名模板 是一个名称,它不仅指向一个类型,而且指向一系列类型。记住,模板不是一个类、函数或变量,而是一个蓝图,它允许创建一系列类型、函数或变量。

要了解别名模板是如何工作的,让我们考虑以下示例:

template <typename T>
using customer_addresses_t = 
   std::map<int, std::vector<T>>;            // [1]
struct delivery_address_t {};
struct invoice_address_t {};
using customer_delivery_addresses_t =
   customer_addresses_t<delivery_address_t>; // [2]
using customer_invoice_addresses_t =
   customer_addresses_t<invoice_address_t>;  // [3]

在第 [1] 行的声明中引入了别名模板 customer_addresses_t。它是一个映射类型的别名,其键类型为 int,值类型为 std::vector<T>。由于 std::vector<T> 不是一个类型,而是一系列类型,因此 customer_addresses_t<T> 定义了一系列类型。在第 [2][3] 行的 using 声明中,从上述类型系列中引入了两个类型别名,customer_delivery_addresses_tcustomer_invoice_addresses_t

别名模板可以出现在命名空间或类作用域中,就像任何模板声明一样。另一方面,它们既不能完全也不能部分特化。然而,有方法可以克服这种限制。一种解决方案是创建一个具有类型别名成员的类模板并特化该类。然后可以创建一个引用类型别名成员的别名模板。让我们通过以下示例来演示这一点。

虽然以下代码不是有效的 C++ 代码,但它代表了我想要实现的目标,如果别名模板的特化是可能的:

template <typename T, size_t S>
using list_t = std::vector<T>;
template <typename T>
using list_t<T, 1> = T;

在这个例子中,如果集合的大小大于 1,则 list_tstd::vector<T> 的别名模板。然而,如果只有一个元素,则 list_t 应该是类型模板参数 T 的别名。实际上实现这一点的示例如下:

template <typename T, size_t S>
struct list
{
   using type = std::vector<T>;
};
template <typename T>
struct list<T, 1>
{
   using type = T;
};
template <typename T, size_t S>
using list_t = typename list<T, S>::type;

在这个例子中,list<T,S> 是一个具有名为 T 的成员类型别名的类模板。在主模板中,这是一个 std::vector<T> 的别名。在部分特化 list<T,1> 中,它是 T 的别名。然后,list_t 被定义为 list<T, S>::type 的别名模板。以下断言证明了这一机制是有效的:

static_assert(std::is_same_v<list_t<int, 1>, int>);
static_assert(std::is_same_v<list_t<int, 2>, std::vector<int>>);

在我们结束本章之前,还有一个需要解决的问题:泛型 lambda 及其 C++20 改进,lambda 模板。

探索泛型 lambda 和 lambda 模板

Lambda 表达式,正式称为lambda 表达式,是在需要的地方简化定义函数对象的一种方法。这通常包括传递给算法的谓词或比较函数。尽管我们不会一般性地讨论 lambda 表达式,但让我们看看以下示例:

int arr[] = { 1,6,3,8,4,2,9 };
std::sort(
   std::begin(arr), std::end(arr),
   [](int const a, int const b) {return a > b; });
int pivot = 5;
auto count = std::count_if(
   std::begin(arr), std::end(arr),
   pivot {return a > pivot; });

Lambda 表达式是语法糖,是一种简化定义匿名函数对象的方法。当遇到 lambda 表达式时,编译器会生成一个具有函数调用操作符的类。对于前面的例子,它们可能看起来如下:

struct __lambda_1
{
   inline bool operator()(const int a, const int b) const
   {
      return a > b;
   }
};
struct __lambda_2
{
   __lambda_2(int & _pivot) : pivot{_pivot}
   {} 
   inline bool operator()(const int a) const
   {
      return a > pivot;
   }
private: 
   int pivot;
};

这里选择的名字是任意的,每个编译器都会生成不同的名字。此外,实现细节可能不同,这里看到的是编译器应该生成的最基本的内容。注意,第一个 lambda 和第二个 lambda 之间的区别在于后者包含通过值捕获的状态。

Lambda 表达式,在 C++11 中引入,在标准后来的版本中收到了几个更新。其中有两个特别值得注意,将在本章中讨论:

  • 使用auto指定符而不是显式指定类型。这会将生成的函数对象转换为一个具有模板函数调用操作符的对象。

  • 模板 lambda,在 C++20 中引入,允许我们使用模板语法显式指定模板化函数调用操作符的形状。

为了理解这些之间的区别以及泛型和模板 lambda 如何有帮助,让我们探索以下示例:

auto l1 = [](int a) {return a + a; };  // C++11, regular 
                                       // lambda
auto l2 = [](auto a) {return a + a; }; // C++14, generic 
                                       // lambda
auto l3 = []<typename T>(T a) 
          { return a + a; };   // C++20, template lambda
auto v1 = l1(42);                      // OK
auto v2 = l1(42.0);                    // warning
auto v3 = l1(std::string{ "42" });     // error
auto v5 = l2(42);                      // OK
auto v6 = l2(42.0);                    // OK
auto v7 = l2(std::string{"42"});       // OK
auto v8 = l3(42);                      // OK
auto v9 = l3(42.0);                    // OK
auto v10 = l3(std::string{ "42" });    // OK

这里,我们有三个不同的 lambda:l1是一个常规 lambda,l2是一个泛型 lambda,因为至少有一个参数是用auto指定符定义的,而l3是一个模板 lambda,使用模板语法定义,但没有使用template关键字。

我们可以用一个整数来调用l1;我们也可以用double来调用它,但这次编译器将产生一个关于可能数据丢失的警告。然而,尝试用字符串参数调用它将产生编译错误,因为std::string不能转换为int。另一方面,l2是一个泛型 lambda。编译器将为其调用的所有参数类型实例化它的特化,在这个例子中是intdoublestd::string。以下代码片段显示了生成的函数对象可能的样子,至少在概念上是这样:

struct __lambda_3
{
   template<typename T1>
   inline auto operator()(T1 a) const
   {
     return a + a;
   }
   template<>
   inline int operator()(int a) const
   {
     return a + a;
   }
   template<>
   inline double operator()(double a) const
   {
     return a + a;
   }
   template<>
   inline std::string operator()(std::string a) const
   {
     return std::operator+(a, a);
   }
};

你可以在这里看到函数调用操作符的主要模板,以及我们提到的三个特殊化。不出所料,编译器将为第三个 lambda 表达式l3生成相同的代码,这是一个仅在 C++20 中可用的模板 lambda。由此产生的问题是泛型 lambda 和 lambda 模板有何不同?为了回答这个问题,让我们稍微修改一下之前的例子:

auto l1 = [](int a, int b) {return a + b; };
auto l2 = [](auto a, auto b) {return a + b; };
auto l3 = []<typename T, typename U>(T a, U b) 
          { return a + b; };
auto v1 = l1(42, 1);                    // OK
auto v2 = l1(42.0, 1.0);                // warning
auto v3 = l1(std::string{ "42" }, '1'); // error
auto v4 = l2(42, 1);                    // OK
auto v5 = l2(42.0, 1);                  // OK
auto v6 = l2(std::string{ "42" }, '1'); // OK
auto v7 = l2(std::string{ "42" }, std::string{ "1" }); // OK 
auto v8 = l3(42, 1);                    // OK
auto v9 = l3(42.0, 1);                  // OK
auto v10 = l3(std::string{ "42" }, '1'); // OK
auto v11 = l3(std::string{ "42" }, std::string{ "42" }); // OK 

新的 lambda 表达式接受两个参数。再次,我们可以用两个整数或一个int和一个double调用l1(尽管这又会产生警告),但我们不能用字符串和char调用它。然而,我们可以使用泛型 lambda l2和 lambda 模板 l3做所有这些。编译器生成的代码对于l2l3是相同的,从语义上看如下所示:

struct __lambda_4
{
   template<typename T1, typename T2>
   inline auto operator()(T1 a, T2 b) const
   {
     return a + b;
   }
   template<>
   inline int operator()(int a, int b) const
   {
     return a + b;
   }
   template<>
   inline double operator()(double a, int b) const
   {
     return a + static_cast<double>(b);
   }
   template<>
   inline std::string operator()(std::string a, 
                                 char b) const
   {
     return std::operator+(a, b);
   }
   template<>
   inline std::string operator()(std::string a, 
                                 std::string b) const
   {
     return std::operator+(a, b);
   }
};

在这个片段中,我们看到函数调用操作符的主要模板,以及几个完整的显式特殊化:对于两个int值,对于doubleint,对于字符串和char,以及对于两个字符串对象。但如果我们想限制泛型 lambda l2的使用,使其仅限于相同类型的参数呢?这是不可能的。编译器无法推断我们的意图,因此,它将为参数列表中每个auto指定符的出现生成不同的类型模板参数。然而,C++20 中的 lambda 模板确实允许我们指定函数调用操作符的形式。看看下面的例子:

auto l5 = []<typename T>(T a, T b) { return a + b; };
auto v1 = l5(42, 1);        // OK
auto v2 = l5(42, 1.0);      // error
auto v4 = l5(42.0, 1.0);    // OK
auto v5 = l5(42, false);    // error
auto v6 = l5(std::string{ "42" }, std::string{ "1" }); // OK 
auto v6 = l5(std::string{ "42" }, '1'); // error               

使用任何两种不同类型的两个参数调用 lambda 模板是不可能的,即使它们可以隐式转换,例如从intdouble。编译器将生成一个错误。在调用模板 lambda 时,无法显式提供模板参数,例如在l5<double>(42, 1.0)中。这也会生成编译器错误。

decltype类型指定符允许我们告诉编译器从表达式推导类型。这个主题在第四章高级模板概念中详细讨论。然而,在 C++14 中,我们可以在泛型 lambda 中使用它来声明上一个泛型 lambda 表达式中第二个参数的类型与第一个参数相同。更确切地说,这看起来如下所示:

auto l4 = [](auto a, decltype(a) b) {return a + b; };

然而,这暗示了第二个参数b的类型必须可以转换为第一个参数a的类型。这允许我们编写以下调用:

auto v1 = l4(42.0, 1);                  // OK
auto v2 = l4(42, 1.0);                  // warning
auto v3 = l4(std::string{ "42" }, '1'); // error

第一次调用编译没有任何问题,因为int可以隐式转换为double。第二次调用编译时会有警告,因为从double转换为int可能会丢失数据。然而,第三次调用会生成错误,因为char不能隐式转换为std::string。尽管l4 lambda 比之前看到的泛型 lambda l2有所改进,但它仍然不能完全限制不同类型参数的调用。这只有通过前面展示的 lambda 模板才能实现。

下一个片段展示了 lambda 模板的另一个示例。这个 lambda 有一个单一参数,一个std::array。然而,数组的元素类型和数组的大小被指定为 lambda 模板的模板参数:

auto l = []<typename T, size_t N>(
            std::array<T, N> const& arr) 
{ 
   return std::accumulate(arr.begin(), arr.end(), 
                          static_cast<T>(0));
};
auto v1 = l(1);                           // error
auto v2 = l(std::array<int, 3>{1, 2, 3}); // OK

尝试使用除std::array对象以外的任何东西调用这个 lambda 会产生编译器错误。编译器生成的函数对象可能看起来如下:

struct __lambda_5
{
   template<typename T, size_t N>
   inline auto operator()(
      const std::array<T, N> & arr) const
   {
     return std::accumulate(arr.begin(), arr.end(), 
                            static_cast<T>(0));
   }
   template<>
   inline int operator()(
      const std::array<int, 3> & arr) const
   {
     return std::accumulate(arr.begin(), arr.end(), 
                            static_cast<int>(0));
   }
};

与常规 lambda 相比,泛型 lambda 的一个有趣的好处是关于递归 lambda。Lambda 没有名字;它们是无名的,因此你不能直接递归调用它们。相反,你必须定义一个std::function对象,将 lambda 表达式赋值给它,并在捕获列表中通过引用捕获它。以下是一个递归 lambda 的示例,它计算一个数的阶乘:

std::function<int(int)> factorial;
factorial = &factorial {
   if (n < 2) return 1;
      else return n * factorial(n - 1);
};
factorial(5);

这可以使用泛型 lambda 简化。它们不需要std::function及其捕获。一个递归泛型 lambda 可以如下实现:

auto factorial = [](auto f, int const n) {
   if (n < 2) return 1;
   else return n * f(f, n - 1);
};
factorial(factorial, 5);

如果理解这一点有困难,编译器生成的代码应该能帮助你弄清楚:

struct __lambda_6
{
   template<class T1>
   inline auto operator()(T1 f, const int n) const
   {
     if(n < 2) return 1;
     else return n * f(f, n - 1);
   }
   template<>
   inline int operator()(__lambda_6 f, const int n) const
   {
     if(n < 2) return 1;
     else return n * f.operator()(__lambda_6(f), n - 1);
   }
};
__lambda_6 factorial = __lambda_6{};
factorial(factorial, 5);

一个通用的 lambda 是一个具有模板函数调用操作符的函数对象。第一个参数,使用auto指定,可以是任何东西,包括 lambda 本身。因此,编译器将为生成的类的类型提供一个完整的显式特化调用操作符。

Lambda 表达式帮助我们避免在需要将函数对象作为参数传递给其他函数时编写显式代码。相反,编译器为我们生成这些代码。C++14 中引入的泛型 lambda 帮助我们避免为不同类型编写相同的 lambda。C++20 的 lambda 模板允许我们使用模板语法和语义指定生成的调用操作符的形式。

摘要

本章是对 C++模板核心特性的概述。我们学习了如何定义类模板、函数模板、变量模板和别名模板。在学习模板参数之后,我们详细研究了模板实例化和模板特化。我们还学习了泛型 lambda 和 lambda 模板以及它们与常规 lambda 相比的优势。通过完成本章,你现在熟悉了模板基础知识,这应该允许你理解大量模板代码,并自己编写模板。

在下一章中,我们将探讨另一个重要主题,即具有可变数量参数的模板,称为变长模板。

问题

  1. 哪些类型的类别可以用于非类型模板参数?

  2. 默认模板参数不允许在哪些地方使用?

  3. 显式实例化声明是什么,它与显式实例化定义在语法上有什么区别?

  4. 什么是别名模板?

  5. 什么是模板 lambda?

进一步阅读

第三章:第三章:变长模板

变长模板是一个具有可变数量参数的模板。这是 C++11 中引入的特性。它结合了泛型代码和具有可变数量参数的函数,这是从 C 语言继承来的特性。尽管语法和一些细节可能看起来有些繁琐,但变长模板帮助我们以前无法通过编译时评估和类型安全的方式编写具有可变数量参数的函数模板或具有可变数量数据成员的类模板。

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

  • 理解变长模板的需求

  • 变长函数模板

  • 参数包

  • 变长类模板

  • 折叠表达式

  • 变长别名模板

  • 变长变量模板

到本章结束时,你将很好地理解如何编写变长模板以及它们是如何工作的。

然而,我们将首先尝试理解为什么具有可变数量参数的模板是有帮助的。

理解变长模板的需求

最著名的 C 和 C++ 函数之一是 printf,它将格式化输出写入 stdout 标准输出流。实际上,I/O 库中有一系列用于写入格式化输出的函数,包括 fprintf(写入文件流)、sprintfsnprintf(写入字符缓冲区)。这些函数之所以相似,是因为它们接受一个定义输出格式的字符串和可变数量的参数。然而,语言为我们提供了编写具有可变数量参数的函数的手段。以下是一个接受一个或多个参数并返回最小值的函数示例:

#include<stdarg.h>
int min(int count, ...)
{
   va_list args;
   va_start(args, count);
   int val = va_arg(args, int);
   for (int i = 1; i < count; i++)
   {
      int n = va_arg(args, int);
      if (n < val)
         val = n;
   }
   va_end(args);
   return val;
}
int main()
{
   std::cout << "min(42, 7)=" << min(2, 42, 7) << '\n';
   std::cout << "min(1,5,3,-4,9)=" << 
                 min(5, 1, 5, 3, -4, 
              9) << '\n';
}

此实现特定于 int 类型的值。然而,可以编写一个类似的函数模板。这种转换需要最小的更改,结果如下:

template <typename T>
T min(int count, ...)
{
   va_list args;
   va_start(args, count);
   T val = va_arg(args, T);
   for (int i = 1; i < count; i++)
   {
      T n = va_arg(args, T);
      if (n < val)
         val = n;
   }
   va_end(args);
   return val;
}
int main()
{
   std::cout << "min(42.0, 7.5)="
             << min<double>(2, 42.0, 7.5) << '\n';
   std::cout << "min(1,5,3,-4,9)=" 
             << min<int>(5, 1, 5, 3, -4, 9) << '\n';
}

编写这样的代码,无论是泛型还是非泛型,都有几个重要的缺点:

  • 它需要使用几个宏:va_list(为其他宏提供所需的信息)、va_start(开始迭代参数)、va_arg(提供访问下一个参数的方法)和 va_end(停止迭代参数)。

  • 评估发生在运行时,尽管传递给函数的参数数量和类型在编译时是已知的。

  • 以这种方式实现的变长函数不是类型安全的。va_ 宏执行低内存操作,并且在运行时 va_arg 中进行类型转换。这可能导致运行时异常。

  • 这些可变参数函数需要以某种方式指定可变参数的数量。在早期 min 函数的实现中,有一个参数表示参数的数量。类似于 printf 的函数从格式化字符串中获取一个格式化字符串,从而确定期望的参数数量。例如,printf 函数会评估并忽略额外的参数(如果提供的参数多于格式化字符串中指定的数量),但如果提供的参数少于格式化字符串中指定的数量,则具有未定义的行为。

除了所有这些之外,在 C++11 之前,只有函数可以是可变的。然而,有一些类也可以从能够有可变数量的数据成员中受益。典型的例子是 tuple 类,它表示一组固定大小的异构值集合,以及 variant,它是一个类型安全的联合体。

可变模板有助于解决所有这些问题。它们在编译时评估,是类型安全的,不需要宏,不需要显式指定参数数量,并且我们可以编写可变函数模板和可变类模板。此外,我们还有可变变量模板和可变别名模板。

在下一节中,我们将开始探讨可变参数模板函数。

可变参数模板函数

可变参数模板函数是具有可变数量参数的模板函数。它们借用省略号(...)的使用来指定参数包,其语法可能因性质不同而不同。

为了理解可变参数模板函数的基本原理,让我们从一个重写之前 min 函数的例子开始:

template <typename T>
T min(T a, T b)
{
   return a < b ? a : b;
}
template <typename T, typename... Args>
T min(T a, Args... args)
{
   return min(a, min(args...));
}
int main()
{
   std::cout << "min(42.0, 7.5)=" << min(42.0, 7.5) 
             << '\n';
   std::cout << "min(1,5,3,-4,9)=" << min(1, 5, 3, -4, 9)
             << '\n';
}

这里有两个 min 函数的重载。第一个是一个有两个参数的函数模板,返回两个参数中最小的一个。第二个是一个具有可变数量参数的函数模板,它递归地调用自身,并扩展参数包。尽管可变参数模板函数的实现看起来像使用了某种编译时递归机制(在这种情况下,两个参数的重载作为结束情况),但实际上,它们只是依赖于重载函数,这些函数是从模板和提供的参数集合实例化的。

在可变参数模板函数的实现中,省略号(...)被用于三个不同的地方,具有不同的含义,如我们的示例所示:

  • 在模板参数列表中指定一组参数,例如 typename... Args。这被称为模板参数包。模板参数包可以用于类型模板、非类型模板和模板模板参数。

  • 在函数参数列表中指定一组参数,例如 Args... args。这被称为函数参数包

  • 在函数体中展开一个包,如args…min(args…)调用中看到的那样。这被称为参数包展开。这种展开的结果是一个由逗号分隔的零个或多个值(或表达式)的列表。这个主题将在下一节中更详细地介绍。

从调用min(1, 5, 3, -4, 9)开始,编译器实例化了一组具有 5、4、3 和 2 个参数的重载函数。从概念上讲,它等同于以下一组重载函数:

int min(int a, int b)
{
   return a < b ? a : b;
}
int min(int a, int b, int c)
{
   return min(a, min(b, c));
}
int min(int a, int b, int c, int d)
{
   return min(a, min(b, min(c, d)));
}
int min(int a, int b, int c, int d, int e)
{
   return min(a, min(b, min(c, min(d, e))));
}

因此,min(1, 5, 3, -4, 9)展开为min(1, min(5, min(3, min(-4, 9))))。这可能会引发关于变长模板性能的问题。然而,在实践中,编译器会执行大量的优化,例如尽可能地进行内联。结果是,当启用优化时,实际上将不会有函数调用。您可以使用在线资源,例如min是具有前面所示实现的变长函数模板):

int main()
{    
   std::cout << min(1, 5, 3, -4, 9);
}

使用 GCC 11.2 编译器,带有-O标志进行优化编译,会产生以下汇编代码:

sub     rsp, 8
mov     esi, -4
mov     edi, OFFSET FLAT:_ZSt4cout
call    std::basic_ostream<char, std::char_traits<char>>
           ::operator<<(int)
mov     eax, 0
add     rsp, 8
ret

您不需要是汇编语言专家就能理解这里发生的事情。对min(1, 5, 3, -4, 9)的调用是在编译时评估的,结果-4直接加载到 ESI 寄存器。在这个特定的情况下,没有运行时调用或计算,因为所有内容都是在编译时已知的。当然,这并不一定总是如此。

以下代码片段展示了min函数模板的一个调用,由于它的参数仅在运行时才知道,因此无法在编译时评估:

int main()
{    
    int a, b, c, d, e;
    std::cin >> a >> b >> c >> d >> e;
    std::cout << min(a, b, c, d, e);
}

这次,生成的汇编代码如下(这里只展示了调用min函数的代码):

mov     esi, DWORD PTR [rsp+12]
mov     eax, DWORD PTR [rsp+16]
cmp     esi, eax
cmovg   esi, eax
mov     eax, DWORD PTR [rsp+20]
cmp     esi, eax
cmovg   esi, eax
mov     eax, DWORD PTR [rsp+24]
cmp     esi, eax
cmovg   esi, eax
mov     eax, DWORD PTR [rsp+28]
cmp     esi, eax
cmovg   esi, eax
mov     edi, OFFSET FLAT:_ZSt4cout
call    std::basic_ostream<char, std::char_traits<char>> 
             ::operator<<(int)

从这个列表中我们可以看到,编译器已经内联了所有对min重载的调用。这里只有一系列将值加载到寄存器中的指令、比较寄存器值以及基于比较结果的跳转,但没有函数调用。

当禁用优化时,函数调用仍然会发生。我们可以通过使用编译器特定的宏来追踪在调用min函数期间发生的这些调用。GCC 和 Clang 提供了一个名为__PRETTY_FUNCTION__的宏,其中包含函数的签名和名称。同样,Visual C++提供了一个名为__FUNCSIG__的宏,它执行相同的功能。这些可以在函数体内部使用来打印其名称和签名。我们可以如下使用它们:

template <typename T>
T min(T a, T b)
{
#if defined(__clang__) || defined(__GNUC__) || defined(__GNUG__)
   std::cout << __PRETTY_FUNCTION__ << "\n";
#elif defined(_MSC_VER)
   std::cout << __FUNCSIG__ << "\n";
#endif
   return a < b ? a : b;
}
template <typename T, typename... Args>
T min(T a, Args... args)
{
#if defined(__clang__) || defined(__GNUC__) || defined(__GNUG__)
   std::cout << __PRETTY_FUNCTION__ << "\n";
#elif defined(_MSC_VER)
   std::cout << __FUNCSIG__ << "\n";
#endif
   return min(a, min(args...));
}
int main()
{
   min(1, 5, 3, -4, 9);
}

当使用 Clang 编译此程序时,执行结果如下:

T min(T, Args...) [T = int, Args = <int, int, int, int>]
T min(T, Args...) [T = int, Args = <int, int, int>]
T min(T, Args...) [T = int, Args = <int, int>]
T min(T, T) [T = int]
T min(T, T) [T = int]
T min(T, T) [T = int]
T min(T, T) [T = int]

另一方面,当使用 Visual C++编译时,输出如下:

int __cdecl min<int,int,int,int,int>(int,int,int,int,int)
int __cdecl min<int,int,int,int>(int,int,int,int)
int __cdecl min<int,int,int>(int,int,int)
int __cdecl min<int>(int,int)
int __cdecl min<int>(int,int)
int __cdecl min<int>(int,int)
int __cdecl min<int>(int,int)

尽管签名格式在 Clang/GCC 和 VC++之间有显著差异,但它们都显示了相同的情况:首先调用具有五个参数的重载函数,然后是四个参数的函数,然后是三个参数的函数,最后有四个调用具有两个参数的重载函数(这标志着展开的结束)。

理解参数包的展开是理解变长模板的关键。因此,我们将在下一节详细探讨这个主题。

参数包

模板或函数参数包可以接受零个、一个或多个参数。标准没有指定参数数量的上限,但在实践中,编译器可能有一些限制。标准所做的是推荐这些限制的最小值,但不要求对这些限制有任何遵守。这些限制如下:

  • 对于函数参数包,最大参数数量取决于函数调用参数的限制,建议至少为 256。

  • 对于模板参数包,最大参数数量取决于模板参数的限制,建议至少为 1,024。

参数包中的参数数量可以在编译时使用sizeof...运算符检索。此运算符返回std::size_t类型的constexpr值。让我们通过几个示例来看看它是如何工作的。

在第一个示例中,sizeof...运算符用于通过constexpr if语句帮助实现变长函数模板sum的递归模式结束。如果参数包中的参数数量为零(意味着函数只有一个参数),那么我们正在处理最后一个参数,所以我们只需返回值。否则,我们将第一个参数添加到剩余参数的总和中。实现如下:

template <typename T, typename... Args>
T sum(T a, Args... args)
{
   if constexpr (sizeof...(args) == 0)
      return a;
   else
      return a + sum(args...);
}

这与以下经典方法在语义上是等价的,但另一方面更简洁,用于变长函数模板的实现:

template <typename T>
T sum(T a)
{
   return a;
}
template <typename T, typename... Args>
T sum(T a, Args... args)
{
   return a + sum(args...);
}

注意到sizeof…(args)(函数参数包)和sizeof…(Args)(模板参数包)返回相同的值。另一方面,sizeof…(args)sizeof(args)...不是同一回事。前者是应用于参数包argssizeof运算符。后者是在sizeof运算符上展开的参数包args。这两个都在以下示例中展示:

template<typename... Ts>
constexpr auto get_type_sizes()
{
   return std::array<std::size_t, 
                     sizeof...(Ts)>{sizeof(Ts)...};
}
auto sizes = get_type_sizes<short, int, long, long long>();

在这个片段中,sizeof…(Ts)在编译时评估为4,而sizeof(Ts)...展开为以下逗号分隔的参数包:sizeof(short), sizeof(int), sizeof(long), sizeof(long long)。从概念上讲,前面的函数模板get_type_sizes等同于以下具有四个模板参数的函数模板:

template<typename T1, typename T2, 
         typename T3, typename T4>
constexpr auto get_type_sizes()
{
   return std::array<std::size_t, 4> {
      sizeof(T1), sizeof(T2), sizeof(T3), sizeof(T4)
   };
}

通常,参数包是函数或模板的尾随参数。然而,如果编译器可以推断出参数,那么参数包后面可以跟有其他参数,包括更多的参数包。让我们考虑以下示例:

template <typename... Ts, typename... Us>
constexpr auto multipacks(Ts... args1, Us... args2)
{
   std::cout << sizeof...(args1) << ','
             << sizeof...(args2) << '\n';
}

这个函数应该接受两组可能不同类型的元素,并对它们进行一些操作。它可以像以下示例那样调用:

multipacks<int>(1, 2, 3, 4, 5, 6);
                 // 1,5
multipacks<int, int, int>(1, 2, 3, 4, 5, 6);
                // 3,3
multipacks<int, int, int, int>(1, 2, 3, 4, 5, 6);
               // 4,2
multipacks<int, int, int, int, int, int>(1, 2, 3, 4, 5, 6); 
               // 6,0

对于第一次调用,args1包在函数调用时指定(如multipacks<int>所示),包含1,而args2被推断出包含2, 3, 4, 5, 6。同样,对于第二次调用,两个包将具有相同数量的参数,更确切地说,是1, 2, 33, 4, 6。对于最后一次调用,第一个包包含所有元素,而第二个包为空。在这些所有示例中,所有元素都是int类型。然而,在以下示例中,两个包包含不同类型的元素:

multipacks<int, int>(1, 2, 4.0, 5.0, 6.0);         // 2,3
multipacks<int, int, int>(1, 2, 3, 4.0, 5.0, 6.0); // 3,3

对于第一次调用,args1包将包含整数1, 2,而args2将被推断出包含双精度值4.0, 5.0, 6.0。同样,对于第二次调用,args1包将是1, 2, 3,而args2将包含4.0, 5.0, 6.0

然而,如果我们稍微修改函数模板multipacks,要求包的大小相等,那么只有之前显示的一些调用仍然可能。这将在以下示例中显示:

template <typename... Ts, typename... Us>
constexpr auto multipacks(Ts... args1, Us... args2)
{
   static_assert(
      sizeof...(args1) == sizeof...(args2),
      "Packs must be of equal sizes.");
}
multipacks<int>(1, 2, 3, 4, 5, 6);                   // error
multipacks<int, int, int>(1, 2, 3, 4, 5, 6);         // OK
multipacks<int, int, int, int>(1, 2, 3, 4, 5, 6);    // error
multipacks<int, int, int, int, int, int>(1, 2, 3, 4, 5, 6); 
                                                     // error
multipacks<int, int>(1, 2, 4.0, 5.0, 6.0);           // error
multipacks<int, int, int>(1, 2, 3, 4.0, 5.0, 6.0);   // OK

在这个代码片段中,只有第二个和第六个调用是有效的。在这两种情况下,两个推断出的包各有三个元素。在其他所有情况下,如前一个示例所示,包的大小不同,static_assert语句将在编译时生成错误。

多个参数包不仅限于变长函数模板。它们也可以用于部分特化的变长类模板,前提是编译器可以推断出模板参数。为了举例说明,我们将考虑一个表示函数指针对的类模板的情况。实现应该允许存储任何函数的指针。为此,我们定义了一个主模板,称为func_pair,以及一个具有四个模板参数的部分特化:

  • 第一个函数返回类型的类型模板参数

  • 第一个函数的参数类型的模板参数包

  • 第二个函数返回类型的第二个类型模板参数

  • 第二个函数的参数类型的第二个模板参数包

func_pair类模板在下一列表中显示:

template<typename, typename>
struct func_pair;
template<typename R1, typename... A1, 
         typename R2, typename... A2>
struct func_pair<R1(A1...), R2(A2...)>
{
   std::function<R1(A1...)> f;
   std::function<R2(A2...)> g;
};

为了演示这个类模板的使用,让我们也考虑以下两个函数:

bool twice_as(int a, int b)
{
   return a >= b*2;
}
double sum_and_div(int a, int b, double c)
{
   return (a + b) / c;
}

我们可以实例化func_pair类模板,并使用它来调用以下代码片段中所示的两个函数:

func_pair<bool(int, int), double(int, int, double)> funcs{
   twice_as, sum_and_div };
funcs.f(42, 12);
funcs.g(42, 12, 10.0);

参数包可以在各种上下文中展开,这将使下一节的主题。

理解参数包展开

参数包可以出现在多种上下文中。它们的展开形式可能取决于此上下文。以下列出了可能的上下文及其示例:

  • 模板参数列表:这是在指定模板参数时使用的:

    template <typename... T>
    struct outer
    {
       template <T... args>
       struct inner {};
    };
    outer<int, double, char[5]> a;
    
  • 模板参数列表:这是在指定模板参数时使用的:

    template <typename... T>
    struct tag {};
    template <typename T, typename U, typename ... Args>
    void tagger()
    {
       tag<T, U, Args...> t1;
       tag<T, Args..., U> t2;
       tag<Args..., T, U> t3;
       tag<U, T, Args...> t4;
    }
    
  • 函数参数列表:这是在指定函数模板的参数时使用的:

    template <typename... Args>
    void make_it(Args... args)
    {
    }
    make_it(42);
    make_it(42, 'a');
    
  • 函数参数列表:当扩展包出现在函数调用括号内时,省略号左侧的最大表达式或花括号初始化列表是展开的模式:

    template <typename T>
    T step_it(T value)
    {
       return value+1;
    }
    template <typename... T>
    int sum(T... args)
    {
       return (... + args);
    }
    template <typename... T>
    void do_sums(T... args)
    {
       auto s1 = sum(args...);
       // sum(1, 2, 3, 4)
       auto s2 = sum(42, args...);
       // sum(42, 1, 2, 3, 4)
       auto s3 = sum(step_it(args)...); 
       // sum(step_it(1), step_it(2),... step_it(4))
    }
    do_sums(1, 2, 3, 4);
    
  • 括号初始化器:当扩展包出现在直接初始化器、函数样式转换、成员初始化器、new 表达式和其他类似上下文的括号内时,规则与函数参数列表的上下文相同:

    template <typename... T>
    struct sum_wrapper
    {
       sum_wrapper(T... args)
       {
          value = (... + args);
       }
       std::common_type_t<T...> value;
    };
    template <typename... T>
    void parenthesized(T... args)
    {
       std::array<std::common_type_t<T...>, 
                  sizeof...(T)> arr {args...};
       // std::array<int, 4> {1, 2, 3, 4}
       sum_wrapper sw1(args...);
       // value = 1 + 2 + 3 + 4
       sum_wrapper sw2(++args...);
       // value = 2 + 3 + 4 + 5
    }
    parenthesized(1, 2, 3, 4);
    
  • 花括号包围的初始化器:这是使用花括号符号执行初始化的情况:

    template <typename... T>
    void brace_enclosed(T... args)
    {
       int arr1[sizeof...(args) + 1] = {args..., 0};     
       // arr1: {1,2,3,4,0}
       int arr2[sizeof...(args)] = { step_it(args)... };
       // arr2: {2,3,4,5}
    }
    brace_enclosed(1, 2, 3, 4);
    
  • 基指定符和成员初始化器列表:包展开可以指定类声明中的基类列表。此外,它还可以出现在成员初始化器列表中,因为这可能是调用基类构造函数所必需的:

    struct A {};
    struct B {};
    struct C {};
    template<typename... Bases>
    struct X : public Bases...
    {
       X(Bases const & ... args) : Bases(args)...
       { }
    };
    A a;
    B b;
    C c;
    X x(a, b, c);
    
  • using 声明。这基于前面的示例进行演示:

    struct A 
    {
       void execute() { std::cout << "A::execute\n"; }
    };
    struct B 
    {
       void execute() { std::cout << "B::execute\n"; }
    };
    struct C 
    {
       void execute() { std::cout << "C::execute\n"; }
    };
    template<typename... Bases>
    struct X : public Bases...
    {
       X(Bases const & ... args) : Bases(args)...
       {}
       using Bases::execute...;
    };
    A a;
    B b;
    C c;
    X x(a, b, c);
    x.A::execute();
    x.B::execute();
    x.C::execute();
    
  • Lambda 捕获:Lambda 表达式的捕获子句可以包含一个包展开,如下例所示:

    template <typename... T>
    void captures(T... args)
    {
       auto l = [args...]{ 
                   return sum(step_it(args)...); };
       auto s = l();
    }
    captures(1, 2, 3, 4);
    
  • 折叠表达式:这些将在本章接下来的部分中详细讨论:

    template <typename... T>
    int sum(T... args)
    {
       return (... + args);
    }
    
  • sizeof… 操作符:本节前面已经展示了示例。这里再次展示一个:

    template <typename... T>
    auto make_array(T... args)
    {
       return std::array<std::common_type_t<T...>, 
                         sizeof...(T)> {args...};
    };
    auto arr = make_array(1, 2, 3, 4);
    
  • 应用到相同声明的 alignas 指定符。参数包可以是类型包或非类型包。以下列出了两种情况下的示例:

    template <typename... T>
    struct alignment1
    {
       alignas(T...) char a;
    };
    template <int... args>
    struct alignment2
    {
       alignas(args...) char a;
    };
    alignment1<int, double> al1;
    alignment2<1, 4, 8> al2;
    
  • 属性列表:目前没有任何编译器支持。

现在我们已经对参数包及其展开有了更多的了解,我们可以继续前进,探索可变参数类模板。

可变参数类模板

类模板也可以有可变数量的模板参数。这是构建某些类型类别(如标准库中可用的 tuplevariant)的关键。在本节中,我们将看到如何编写一个简单的 tuple 类实现。元组是一种表示固定大小异构值集合的类型。

在实现可变函数模板时,我们使用了具有两个重载的递归模式,一个用于通用情况,另一个用于结束递归。对于可变参数类模板,也需要采取相同的方法,但我们需要为此目的使用特化。接下来,你可以看到对元组的最小实现:

template <typename T, typename... Ts>
struct tuple
{
   tuple(T const& t, Ts const &... ts)
      : value(t), rest(ts...)
   {
   }
   constexpr int size() const { return 1 + rest.size(); }
   T            value;
   tuple<Ts...> rest;
};
template <typename T>
struct tuple<T>
{
   tuple(const T& t)
      : value(t)
   {
   }
   constexpr int size() const { return 1; }
   T value;
};

第一个类是基本模板。它有两个模板参数:一个类型模板和一个参数包。这意味着,至少必须指定一个类型来实例化此模板。基本模板的元组有两个成员变量:value,类型为 T,和 rest,类型为 tuple<Ts…>。这是模板参数剩余部分的展开。这意味着一个包含 N 个元素的元组将包含第一个元素和另一个元组;这个第二个元组反过来又包含第二个元素和另一个元组;这个第三个嵌套元组包含剩余部分。这种模式一直持续到我们最终得到一个只有一个元素的元组。这是由部分特化 tuple<T> 定义的。与基本模板不同,这个特化不聚合另一个元组对象。

我们可以使用这个简单的实现来编写如下代码:

tuple<int> one(42);
tuple<int, double> two(42, 42.0);
tuple<int, double, char> three(42, 42.0, 'a');
std::cout << one.value << '\n';
std::cout << two.value << ',' 
          << two.rest.value << '\n';
std::cout << three.value << ',' 
          << three.rest.value << ','
          << three.rest.rest.value << '\n';

虽然这可行,但通过 rest 成员访问元素,例如在 three.rest.rest.value 中,非常繁琐。元组中的元素越多,以这种方式编写代码就越困难。因此,我们希望使用一些辅助函数来简化访问元组元素。以下是如何将之前的代码转换的片段:

std::cout << get<0>(one) << '\n';
std::cout << get<0>(two) << ','
          << get<1>(two) << '\n';
std::cout << get<0>(three) << ','
          << get<1>(three) << ','
          << get<2>(three) << '\n';

在这里,get<N> 是一个变长函数模板,它接受一个元组作为参数,并返回元组中 N 索引处的元素引用。其原型可能如下所示:

template <size_t N, typename... Ts>
typename nth_type<N, Ts...>::value_type & get(tuple<Ts...>& t);

模板参数是元组类型的索引和参数包。然而,其实施需要一些辅助类型。首先,我们需要知道元组中 N 索引处的元素类型。这可以通过以下 nth_type 变长类模板来检索:

template <size_t N, typename T, typename... Ts>
struct nth_type : nth_type<N - 1, Ts...>
{
   static_assert(N < sizeof...(Ts) + 1,
                 "index out of bounds");
};
template <typename T, typename... Ts>
struct nth_type<0, T, Ts...>
{
   using value_type = T;
};

再次,我们有一个使用递归继承的基本模板,以及针对索引 0 的特化。特化定义了一个名为 value_type 的别名,用于第一个类型模板(这是模板参数列表的头部)。这个类型仅用作确定元组元素类型的机制。我们需要另一个变长类模板来检索值。这将在下面的列表中展示:

template <size_t N>
struct getter
{
   template <typename... Ts>
   static typename nth_type<N, Ts...>::value_type& 
   get(tuple<Ts...>& t)
   {
      return getter<N - 1>::get(t.rest);
   }
};
template <>
struct getter<0>
{
   template <typename T, typename... Ts>
   static T& get(tuple<T, Ts...>& t)
   {
      return t.value;
   }
};

我们可以看到这里相同的递归模式,有一个基本模板和一个显式特化。类模板被称为 getter,它有一个单一的模板参数,这是一个非类型模板参数。这代表我们想要访问的元组元素的索引。这个类模板有一个名为 get 的静态成员函数。这是一个变长函数模板。基本模板中的实现使用元组的 rest 成员作为参数调用 get 函数。另一方面,显式特化的实现返回元组成员值的引用。

在定义了所有这些之后,我们现在可以提供一个实际的实现来为辅助的可变参数函数模板get。这个实现依赖于getter类模板,并调用其get可变参数函数模板:

template <size_t N, typename... Ts>
typename nth_type<N, Ts...>::value_type & 
get(tuple<Ts...>& t)
{
   return getter<N>::get(t);
}

如果这个例子看起来有点复杂,也许逐步分析它将有助于你更好地理解它是如何工作的。因此,让我们从以下片段开始:

tuple<int, double, char> three(42, 42.0, 'a');
get<2>(three);

我们将使用cppinsights.io网络工具来检查从这个片段中发生的模板实例化。首先查看的是类模板tuple。我们有一个主模板和几个特化,如下所示:

template <typename T, typename... Ts>
struct tuple
{
   tuple(T const& t, Ts const &... ts)
      : value(t), rest(ts...)
   { }
   constexpr int size() const { return 1 + rest.size(); }
   T value;
   tuple<Ts...> rest;
};
template<> struct tuple<int, double, char>
{
  inline tuple(const int & t, 
               const double & __ts1, const char & __ts2)
  : value{t}, rest{tuple<double, char>(__ts1, __ts2)}
  {}
  inline constexpr int size() const;
  int value;
  tuple<double, char> rest;
};
template<> struct tuple<double, char>
{
  inline tuple(const double & t, const char & __ts1)
  : value{t}, rest{tuple<char>(__ts1)}
  {}
  inline constexpr int size() const;
  double value;
  tuple<char> rest;
};
template<> struct tuple<char>
{
  inline tuple(const char & t)
  : value{t}
  {}
  inline constexpr int size() const;
  char value;
};
template<typename T>
struct tuple<T>
{
   inline tuple(const T & t) : value{t}
   { }
   inline constexpr int size() const
   { return 1; }
   T value;
};

tuple<int, double, char>结构包含一个int和一个tuple<double, char>,它包含一个double和一个tuple<char>,后者又包含一个char值。这个最后的类代表了元组递归定义的末尾。这可以概念性地用以下图形表示:

![图 3.1 – 一个示例元组![图 3.1 – 一个示例元组图 3.1 – 一个示例元组接下来,我们有nth_type类模板,对于它,我们再次有一个主模板和几个特化,如下所示:cpptemplate <size_t N, typename T, typename... Ts>``````cppstruct nth_type : nth_type<N - 1, Ts...>``````cpp{``````cpp   static_assert(N < sizeof...(Ts) + 1,``````cpp                 "index out of bounds");``````cpp};``````cpptemplate<>``````cppstruct nth_type<2, int, double, char> : ``````cpp   public nth_type<1, double, char>``````cpp{ };``````cpptemplate<>``````cppstruct nth_type<1, double, char> : public nth_type<0, char>``````cpp{ };``````cpptemplate<>``````cppstruct nth_type<0, char>``````cpp{``````cpp   using value_type = char;``````cpp};``````cpptemplate<typename T, typename ... Ts>``````cppstruct nth_type<0, T, Ts...>``````cpp{``````cpp   using value_type = T;``````cpp};````nth_type<2, int, double, char>`特化是从`nth_type<1, double, char>`派生的,后者又从`nth_type<0, char>`派生,它是层次结构中的最后一个基类(递归层次结构的末尾)。`nth_type`结构在`getter`辅助类模板中用作返回类型,其实例化如下:cpptemplate <size_t N>``````cppstruct getter``````cpp{``````cpp   template <typename... Ts>``````cpp   static typename nth_type<N, Ts...>::value_type& ``````cpp   get(tuple<Ts...>& t)``````cpp   {``````cpp      return getter<N - 1>::get(t.rest);``````cpp   }``````cpp};``````cpptemplate<>``````cppstruct getter<2>``````cpp{``````cpp   template<>``````cpp   static inline typename ``````cpp   nth_type<2UL, int, double, char>::value_type & ``````cpp   get<int, double, char>(tuple<int, double,  char> & t)``````cpp   {``````cpp      return getter<1>::get(t.rest);``````cpp   } ``````cpp};``````cpptemplate<>``````cppstruct getter<1>``````cpp{``````cpp   template<>``````cpp   static inline typename nth_type<1UL, double,``````cpp                                   char>::value_type &``````cpp   get<double, char>(tuple<double, char> & t)``````cpp   {``````cpp      return getter<0>::get(t.rest);``````cpp   }``````cpp};``````cpptemplate<>``````cppstruct getter<0>``````cpp{``````cpp   template<typename T, typename ... Ts>``````cpp   static inline T & get(tuple<T, Ts...> & t)``````cpp   {``````cpp      return t.value;``````cpp   }``````cpp   template<>``````cpp   static inline char & get(tuple & t)``````cpp   {``````cpp      return t.value;``````cpp   }``````cpp};最后,我们使用的`get`函数模板,用于检索`tuple`元素的值,定义如下:cpptemplate <size_t N, typename... Ts>``````cpptypename nth_type<N, Ts...>::value_type & ``````cppget(tuple<Ts...>& t)``````cpp{``````cpp   return getter::get(t);``````cpp}``````cpptemplate<>``````cpptypename nth_type<2UL, int, double, char>::value_type & ``````cppget<2, int, double, char>(tuple<int, double, char> & t)``````cpp{``````cpp  return getter<2>::get(t);``````cpp}如果对`get`函数的调用更多,将存在更多的`get`特化。例如,对于`get<1>(three)`,将添加以下特化:cpptemplate<>``````cpptypename nth_type<1UL, int, double, char>::value_type & ``````cppget<1, int, double, char>(tuple<int, double, char> & t)``````cpp{``````cpp  return getter<1>::get(t);``````cpp}这个例子帮助我们展示了如何实现具有主模板和变体递归的末尾特化的可变参数类模板。你可能已经注意到了使用关键字`typename`作为前缀来修饰`nth_type<N, Ts...>::value_type`类型,这是一个**依赖类型**。在 C++20 中,这不再是必要的。然而,这个主题将在*第四章*中详细讨论,*高级模板概念*。由于实现可变参数模板通常很冗长且可能很繁琐,C++17 标准添加了**折叠表达式**来简化这项任务。我们将在下一节探讨这个主题。# 折叠表达式一个返回所有传入参数总和的`sum`函数。为了方便,我们在这里再次展示它:cpptemplate ``````cppT sum(T a)``````cpp{``````cpp   return a;``````cpp}``````cpptemplate <typename T, typename... Args>``````cppT sum(T a, Args... args)``````cpp{``````cpp   return a + sum(args...);``````cpp}使用折叠表达式,这个需要两个重载的实现可以简化为以下形式:cpptemplate <typename... T>``````cppint sum(T... args)``````cpp{``````cpp    return (... + args);``````cpp}不再需要重载函数。表达式`(... + args)`代表折叠表达式,在评估时变为`((((arg0 + arg1) + arg2) + … ) + argN)`。括号是折叠表达式的一部分。我们可以像使用初始实现一样使用这个新实现,如下所示:cppint main()``````cpp{``````cpp    std::cout << sum(1) << '\n';``````cpp    std::cout << sum(1,2) << '\n';``````cpp    std::cout << sum(1,2,3,4,5) << '\n';``````cpp}```有四种不同的折叠类型,如下列出:Table 3.1

Table 3.1

Table 3.1

在这个表格中,使用了以下名称:

  • pack是一个包含未展开参数包的表达式,而arg1arg2argN-1argN是这个包中包含的参数。

  • op是以下二元运算符之一:+ - * / % ^ & | = < > << >> += -= *= /= %= ^= &= |= <<= >>= == != <= >= && || , .* ->*.

  • init是一个不包含未展开参数包的表达式。

在一元折叠中,如果包不包含任何元素,则只允许一些运算符。以下表格列出了这些运算符,以及空包的值:

Table 3.2

Table 3.2

Table 3.2

一元折叠和二元折叠在初始化值的使用上有所不同,后者才需要初始化值。二元折叠中二元运算符重复两次(必须是相同的运算符)。我们可以通过包含一个初始化值将可变参数函数模板sum从使用一元右折叠表达式转换为使用二元右折叠。以下是一个示例:

template <typename... T>
int sum_from_zero(T... args)
{
   return (0 + ... + args);
}

有人说sumsum_from_zero函数模板之间没有区别。这实际上并不正确。让我们考虑以下调用:

int s1 = sum();           // error
int s2 = sum_from_zero(); // OK

不带参数调用sum将产生编译器错误,因为一元折叠表达式(在这种情况下是运算符+)必须有非空展开。然而,二元折叠表达式没有这个问题,所以不带参数调用sum_from_zero是有效的,并且函数将返回0

在这两个使用sumsum_from_zero的示例中,参数包args直接出现在折叠表达式中。然而,它可以是表达式的一部分,只要它没有被展开。以下是一个示例:

template <typename... T>
void printl(T... args)
{
   (..., (std::cout << args)) << '\n';
}
template <typename... T>
void printr(T... args)
{
   ((std::cout << args), ...) << '\n';
}

这里,参数包args(std::cout << args)表达式的一部分。这不是一个折叠表达式。折叠表达式是((std::cout << args), ...)。这是一个以逗号运算符为操作符的一元左折叠。可以使用printlprintr函数,如下所示:

printl('d', 'o', 'g');  // dog
printr('d', 'o', 'g');  // dog

在这两种情况下,打印到控制台的文字是 dog。这是因为一元左折叠展开为 (((std::cout << 'd'), std::cout << 'o'), << std::cout << 'g'),而一元右折叠展开为 (std::cout << 'd', (std::cout << 'o', (std::cout << 'g'))),并且这两个表达式以相同的方式评估。这是因为由逗号分隔的表达式是按从左到右的顺序评估的。这对于内置的逗号运算符来说是正确的。对于重载逗号运算符的类型,其行为取决于运算符是如何重载的。然而,重载逗号运算符的角落案例非常少(例如简化多维数组的索引)。例如,Boost.AssignSOCI 库重载了逗号运算符,但通常,这是一个你应该避免重载的运算符。

让我们考虑另一个例子,在折叠表达式内部的表达式中使用参数包。以下可变参数函数模板将多个值插入到 std::vector 的末尾:

template<typename T, typename... Args>
void push_back_many(std::vector<T>& v, Args&&... args)
{
   (v.push_back(args), ...);
}
push_back_many(v, 1, 2, 3, 4, 5); // v = {1, 2, 3, 4, 5}

参数包 argsv.push_back(args) 表达式一起使用,该表达式通过逗号运算符展开。一元左折叠表达式是 (v.push_back(args), ...)

与使用递归实现可变参数模板相比,折叠表达式有几个优点。这些优点如下:

  • 更少且更简单的代码要编写。

  • 更少的模板实例化,这导致编译时间更快。

  • 由于将多个函数调用替换为单个表达式,代码可能更快。然而,在实际上,至少在启用优化时,这一点可能并不成立。我们已经看到编译器通过删除这些函数调用来优化代码。

现在我们已经看到了如何创建可变函数模板、可变类模板以及如何使用折叠表达式,我们接下来要讨论的是其他可以变元的模板类型:别名模板和变量模板。我们将从前者开始。

可变参数别名模板

可以模板化的任何内容也可以使其可变。别名模板是一系列类型的别名(另一个名称)。可变参数别名模板是一系列具有可变数量模板参数的类型名称。根据到目前为止积累的知识,编写别名模板应该是相当简单的。让我们看一个例子:

template <typename T, typename... Args>
struct foo 
{
};
template <typename... Args>
using int_foo = foo<int, Args...>;

类模板 foo 是可变的,并且至少接受一个类型模板参数。另一方面,int_foo 只是 foo 类型的一个家族的实例的另一个名称,其中 int 作为第一个类型模板参数。它们可以这样使用:

foo<double, char, int> f1;
foo<int, char, double> f2;
int_foo<char, double> f3;
static_assert(std::is_same_v<decltype(f2), decltype(f3)>);

在这个片段中,f1 一方面和 f2f3 另一方面是不同 foo 类型的实例,因为它们是从不同的 foo 模板参数集合中实例化的。然而,f2f3 是同一类型的实例,即 foo<int, char, double>,因为 int_foo<char, double> 只是这个类型的别名。

前面将给出一个类似的例子,虽然稍微复杂一些。标准库中包含一个名为 std::integer_sequence 的类模板,它表示一个整数编译时序列,以及一些别名模板,以帮助创建各种这样的整数序列。尽管这里展示的代码是一个简化的片段,但它们的实现至少在概念上可以如下所示:

template<typename T, T... Ints>
struct integer_sequence
{};
template<std::size_t... Ints>
using index_sequence = integer_sequence<std::size_t,
                                        Ints...>;
template<typename T, std::size_t N, T... Is>
struct make_integer_sequence : 
  make_integer_sequence<T, N - 1, N - 1, Is...> 
{};
template<typename T, T... Is>
struct make_integer_sequence<T, 0, Is...> : 
  integer_sequence<T, Is...> 
{};
template<std::size_t N>
using make_index_sequence = make_integer_sequence<std::size_t, 
                                                  N>;
template<typename... T>
using index_sequence_for = 
   make_index_sequence<sizeof...(T)>;

这里有三个别名模板:

  • index_sequence,它为 size_t 类型创建一个 integer_sequence;这是一个可变别名模板。

  • index_sequence_for,它从一个参数包中创建一个 integer_sequence;这也是一个可变别名模板。

  • make_index_sequence,它为 size_t 类型创建一个包含值 0, 1, 2, …, N-1integer_sequence。与之前的例子不同,这不是一个可变模板的别名。

本章最后要讨论的主题是可变参数变量模板。

可变参数变量模板

如前所述,变量模板也可以是可变的。然而,变量不能递归定义,也不能像类模板那样进行特化。折叠表达式,它简化了从变量数量的参数生成表达式的过程,对于创建可变参数变量模板非常有用。

在下面的例子中,我们定义了一个名为 Sum 的可变参数变量模板,它在编译时初始化为所有提供的非类型模板参数的整数之和:

template <int... R>
constexpr int Sum = (... + R);
int main()
{
    std::cout << Sum<1> << '\n';
    std::cout << Sum<1,2> << '\n';
    std::cout << Sum<1,2,3,4,5> << '\n';
}

这与使用折叠表达式编写的 sum 函数类似。然而,在这种情况下,要加的数字是作为函数参数提供的。在这里,它们是作为变量模板的模板参数提供的。区别主要在于语法;在启用优化的情况下,生成的汇编代码的最终结果可能相同,因此性能也相似。

可变参数变量模板遵循与其他所有模板相同的模式,尽管它们不像其他模板那样常用。然而,通过结束这个主题,我们现在已经完成了 C++中可变参数模板的学习。

概述

在本章中,我们探索了一个重要的模板类别,即可变参数模板,它们是具有可变数量模板参数的模板。我们可以创建可变函数模板、类模板、变量模板和别名模板。创建可变函数模板和可变类模板的技术不同,但都涉及一种编译时递归。对于后者,这是通过模板特化来完成的,而对于前者,则是通过函数重载。折叠表达式有助于将变量数量的参数展开成一个单一的表达式,避免了使用函数重载的需要,并使得创建一些可变变量模板类别成为可能,如我们之前所见到的。

在下一章中,我们将探讨一系列更高级的功能,这将有助于巩固你对模板知识的理解。

问题

  1. 可变参数模板是什么?为什么它们有用?

  2. 什么是参数包?

  3. 在哪些上下文中可以展开参数包?

  4. 折叠表达式是什么?

  5. 使用折叠表达式有哪些好处?

进一步阅读

第二部分:高级模板功能

在本部分中,你将探索各种高级功能,包括名称绑定和依赖名称、模板递归、模板参数推导和前向引用。在这里,你将了解帮助查询类型信息并使用各种语言特性进行条件编译的类型特性。此外,你还将学习如何使用 C++20 的概念和约束来指定模板参数的要求,并探索标准概念库的内容。

本部分包括以下章节:

  • 第四章, 高级模板概念

  • 第五章, 类型特性和条件编译

  • 第六章, 概念和约束

第四章:第四章:高级模板概念

在前面的章节中,我们学习了 C++模板的核心基础。到这一点,你应该能够编写可能不是非常复杂的模板。然而,关于模板还有很多细节,本章专门讨论这些更高级的话题。接下来我们将讨论以下主题:

  • 理解名称绑定和依赖名称

  • 探索模板递归

  • 理解模板参数推导

  • 学习前向引用和完美前向

  • 使用decltype指定符和std::declval类型操作符

  • 理解模板中的友谊

完成这一章后,你将获得对这些高级模板概念的更深入理解,并能够理解和编写更复杂的模板代码。

我们将从这个章节开始学习名称绑定和依赖名称。

理解名称绑定和依赖名称

术语名称绑定指的是在模板中使用每个名称的声明查找过程。在模板中使用两种类型的名称:依赖名称非依赖名称。前者是依赖于模板参数类型或值的名称,该参数可以是类型、非类型或模板参数。不依赖于模板参数的名称称为非依赖。对于依赖和非依赖名称,名称查找的方式不同:

  • 对于依赖名称,它在模板实例化的点进行。

  • 对于非依赖名称,它在模板定义的点进行。

我们首先将查看非依赖名称。如前所述,名称查找发生在模板定义的点。这位于模板定义之前立即。为了理解它是如何工作的,让我们考虑以下示例:

template <typename T>
struct processor;          // [1] template declaration
void handle(double value)  // [2] handle(double) definition
{
   std::cout << "processing a double: " << value << '\n';
}
template <typename T>
struct parser              // [3] template definition
{
   void parse()
   {
      handle(42);          // [4] non-dependent name
   }
};
void handle(int value)     // [5] handle(int) definition
{
   std::cout << "processing an int: " << value << '\n';
}
int main()
{
   parser<int> p;          // [6] template instantiation
   p.parse();
}

在右侧的注释中有几个标记的参考点。在点 [1],我们有一个名为 parser 的类模板的声明。在点 [2] 后,定义了一个名为 handle 的函数,它接受一个 double 类型的参数。类模板的定义在点 [3]。这个类包含一个名为 run 的单一方法,该方法使用值 42 作为参数调用 handle 函数,在点 [4]

名称 handle 是一个非依赖名称,因为它不依赖于任何模板参数。因此,名称查找和绑定在此处进行。handle 必须是在点 [3] 处已知的函数,并且 [2] 处定义的函数是唯一的匹配项。在类模板定义之后,在点 [5] 我们有 handle 函数的重载定义,它接受一个整数作为其参数。这对于 handle(42) 是一个更好的匹配,但它是在名称绑定之后进行的,因此将被忽略。在 main 函数中,在点 [6],我们为 int 类型实例化了 parser 类模板。在调用 run 函数时,文本 处理一个双精度数:42 将被打印到控制台输出。

下一个例子旨在向您介绍依赖名称的概念。让我们首先看看代码:

template <typename T>
struct handler          // [1] template definition
{
   void handle(T value)
   {
      std::cout << "handler<T>: " << value << '\n';
   }
};
template <typename T>
struct parser           // [2] template definition
{
   void parse(T arg)
   {
      arg.handle(42);   // [3] dependent name
   }
};
template <>
struct handler<int>     // [4] template specialization
{
   void handle(int value)
   {
      std::cout << "handler<int>: " << value << '\n';
   }
};
int main()
{
   handler<int> h;         // [5] template instantiation
   parser<handler<int>> p; // [6] template instantiation
   p.parse(h);
}

这个例子与上一个例子略有不同。parser 类模板非常相似,但 handle 函数已成为另一个类模板的成员。让我们一点一点地分析它。

在注释中标记的 [1] 点处,我们有名为 handler 的类模板的定义。它包含一个名为 handle 的单个公共方法,该方法接受 T 类型的参数并将它的值打印到控制台。接下来,在点 [2],我们有名为 parser 的类模板的定义。这与上一个例子相似,但有一个关键方面:在点 [3],它在其参数上调用名为 handle 的方法。因为参数的类型是模板参数 T,这使得 handle 成为一个依赖名称。依赖名称在模板实例化点进行查找,因此 handle 在此点未绑定。继续代码,在点 [4],有一个 handler 类模板针对 int 类型的模板特化。作为一个特化,这是对依赖名称的一个更好的匹配。因此,当模板在点 [6] 实例化时,handler<int>::handle 是绑定到 [3] 处使用的依赖名称的名称。运行此程序将在控制台打印 handler<int>: 42

现在我们已经了解了名称绑定是如何发生的,让我们学习一下这与模板实例化有何关联。

两阶段名称查找

上一个章节的关键要点是,对于依赖名称(依赖于模板参数的名称)和非依赖名称(不依赖于模板参数的名称,以及当前模板实例化中定义的名称),名称查找的方式不同。当编译器通过模板的定义时,它需要确定一个名称是依赖的还是非依赖的。进一步的名字查找依赖于这种分类,并且要么在模板定义点(对于非依赖名称)要么在模板实例化点(对于依赖名称)发生。因此,模板的实例化发生在两个阶段:

  • 第一阶段发生在定义点,当模板语法被检查并且名称被分类为依赖或非依赖时。

  • 第二阶段发生在实例化点,当模板参数被替换为模板参数时。依赖名称的名称绑定发生在这一点。

这个两步过程被称为两阶段名称查找。为了更好地理解它,让我们考虑另一个例子:

template <typename T>
struct base_parser
{
   void init()
   {
      std::cout << "init\n";
   }
};
template <typename T>
struct parser : base_parser<T>
{
   void parse()
   {
      init();        // error: identifier not found
      std::cout << "parse\n";
   }
};
int main()
{
   parser<int> p;
   p.parse();
}

在这个片段中,我们有两个类模板:base_parser,它包含一个名为init的公共方法,以及parser,它从base_parser派生并包含一个名为parse的公共方法。parse成员函数调用一个名为init的函数,并且意图是调用这里的基类方法init。然而,编译器将发出错误,因为它找不到init。这种情况发生的原因是init是一个非依赖名称(因为它不依赖于模板参数)。因此,它必须在parser模板的定义点已知。尽管存在base_parser<T>::init,但编译器不能假设这就是我们想要调用的,因为主模板base_parser可以稍后特化,init可以定义为其他内容(例如类型、变量、另一个函数,或者它可能完全不存在)。因此,名称查找不会在基类中发生,而是在其封装作用域中发生,并且在parser中没有名为init的函数。

这个问题可以通过将init改为依赖名称来解决。这可以通过在前面加上this->base_parser<T>::来实现。通过将init变成依赖名称,其名称绑定从模板定义点移动到模板实例化点。在下面的片段中,这个问题通过通过this指针调用init来解决:

template <typename T>
struct parser : base_parser<T>
{
   void parse()
   {
      this->init();        // OK
      std::cout << "parse\n";
   }
};

继续这个例子,让我们考虑在定义parser类模板之后,为int类型提供了一个base_parser的特化。这可以看起来如下所示:

template <>
struct base_parser<int>
{
   void init()
   {
      std::cout << "specialized init\n";
   }
};

此外,让我们考虑以下parser类模板的使用:

int main()
{
   parser<int> p1;
   p1.parse();
   parser<double> p2;
   p2.parse();
}

当你运行这个程序时,以下文本将被打印到控制台:

specialized init
parse
init
parse

这种行为的原因是p1parser<int>的一个实例,并且它的基类base_parser<int>有一个特化实现了init函数,并将specialized init打印到控制台。另一方面,p2parser<double>的一个实例。由于没有为double类型提供base_parser的特化,因此正在调用主模板中的init函数,这只会将init打印到控制台。

这个更广泛主题的下一个主题是使用类型依赖的名称。让我们学习它是如何工作的。

依赖类型名称

在迄今为止看到的示例中,依赖名称是一个函数或成员函数。然而,也存在依赖名称是类型的情况。以下示例展示了这一点:

template <typename T>
struct base_parser
{
   using value_type = T;
};
template <typename T>
struct parser : base_parser<T>
{
   void parse()
   {
      value_type v{};                       // [1] error
      // or
      base_parser<T>::value_type v{};       // [2] error
      std::cout << "parse\n";
   }
};

在此代码片段中,base_parser 是一个定义了 T 的类型别名 value_type 的类模板。从 base_parser 派生的 parser 类模板需要在它的 parse 方法中使用此类型。然而,value_typebase_parser<T>::value_type 都不起作用,编译器正在发出错误。value_type 不起作用,因为它是一个非依赖名称,因此它不会在基类中查找,只会在封装作用域中查找。base_parser<T>::value_type 也不起作用,因为编译器无法假设这实际上是一个类型。base_parser 的一个特化可能随后出现,value_type 可能被定义为不是类型的东西。

为了解决这个问题,我们需要告诉编译器该名称指的是一个类型。否则,默认情况下,编译器假设它不是一个类型。这是通过 typename 关键字在定义点完成的,如下所示:

template <typename T>
struct parser : base_parser<T>
{
   void parse()
   {
      typename base_parser<T>::value_type v{}; // [3] OK
      std::cout << "parse\n";
   }
};

实际上,这个规则有两个例外:

  • 在指定基类时

  • 在初始化类成员时

让我们看看这两个异常的示例:

struct dictionary_traits
{
    using key_type = int;
    using map_type = std::map<key_type, std::string>;
    static constexpr int identity = 1;
};
template <typename T>
struct dictionary : T::map_type      // [1]
{
    int start_key { T::identity };   // [2]
    typename T::key_type next_key;   // [3]
};
int main()
{
    dictionary<dictionary_traits> d;
}

dictionay_traits 是一个用作 dictionary 类模板模板参数的类。这个类从 T::map_type 派生(见行 [1]),但在这里不需要使用 typename 关键字。字典类定义了一个名为 start_key 的成员,它是一个初始化为 T::identity 值的 int(见行 [2])。同样,这里也不需要 typename 关键字。然而,如果我们想定义类型 T::key_type(见行 [3])的另一个成员,我们确实需要使用 typename

[3] 中使用 typename 的要求已经放宽,之前不再需要使用 typename 关键字作为前缀。

在 C++20 中,typename 在以下上下文中是隐式的(可以由编译器推导):

  • 在使用声明中

  • 在数据成员的声明中

  • 在函数参数的声明或定义中

  • 在尾随返回类型中

  • 在模板的泛型参数的默认参数中

  • static_castconst_castreinterpret_castdynamic_cast 语句的类型标识符中

以下代码片段中展示了这些上下文的一些示例:

template <typename T>
struct dictionary : T::map_type
{
   int start_key{ T::identity };
   T::key_type next_key;                              // [1]
   using value_type = T::map_type::mapped_type;       // [2]
   void add(T::key_type const&, value_type const&) {} // [3]
};.

在此代码片段中所有标记为 [1][2][3] 的行上,在 C++20 之前,需要使用 typename 关键字来指示类型名称(如 T::key_typeT::map_type::mapped_type)。当用 C++20 编译时,这不再是必要的。

注意

第二章模板基础 中,我们了解到 typenameclass 关键字可以用来引入类型模板参数,并且它们是可以互换的。这里的 typename 虽然有类似的目的,但不能用 class 关键字替换。

不仅类型可以是依赖名称,其他模板也可以。我们将在下一小节中探讨这个主题。

依赖模板名称

在某些情况下,依赖名称是一个模板,例如函数模板或类模板。然而,编译器的默认行为是将依赖名称解释为非类型,这会导致与比较运算符 < 的使用相关的错误。让我们用一个例子来演示这一点:

template <typename T>
struct base_parser
{
   template <typename U>
   void init()
   {
      std::cout << "init\n";
   }
};
template <typename T>
struct parser : base_parser<T>
{
   void parse()
   {
      // base_parser<T>::init<int>();        // [1] error
      base_parser<T>::template init<int>();  // [2] OK
      std::cout << "parse\n";
   }
};

这与前面的代码片段类似,但 base_parser 中的 init 函数也是一个模板。尝试使用 base_parser<T>::init<int>() 语法调用它,如点 [1] 所见,会导致编译器错误。因此,我们必须使用 template 关键字来告诉编译器依赖名称是一个模板。这如点 [2] 所示进行。

请记住,template 关键字只能跟在作用域解析运算符 (::)、通过指针的成员访问 (->) 和成员访问 (.) 之后。正确使用的例子有 X::template foo<T>()this->template foo<T>()obj.template foo<T>()

依赖名称不一定是函数模板。它也可以是类模板,如下所示:

template <typename T>
struct base_parser
{
   template <typename U>
   struct token {};
};
template <typename T>
struct parser : base_parser<T>
{
   void parse()
   {
      using token_type = 
         base_parser<T>::template token<int>; // [1]
      token_type t1{};
      typename base_parser<T>::template token<int> t2{}; 
                                                     // [2]
      std::cout << "parse\n";
   }
};

token 类是 base_parser 类模板的内联类模板。它可以像在标记为 [1] 的行中那样使用,其中定义了一个类型别名(然后用于实例化一个对象),或者像在行 [2] 中那样直接使用来声明一个变量。请注意,在 [1] 处不需要 typename 关键字,因为使用声明表明我们正在处理一个类型,但在 [2] 处是必需的,因为否则编译器会假设它是一个非类型名称。

在观察当前模板实例化的某些上下文中,不需要使用 typenametemplate 关键字。这将是下一小节的主题。

当前实例化

在类模板定义的上下文中,可能避免使用 typenametemplate 关键字来区分依赖名称,因为编译器能够推断出一些依赖名称(例如嵌套类的名称)来引用当前实例化。这意味着一些错误可以在定义点而不是实例化点更早地识别出来。

根据 C++ 标准,§13.8.2.1 - 依赖类型,以下表格展示了可以引用当前实例化的名称的完整列表:

表 4.1

表 4.1

以下规则用于考虑一个名称是否是当前实例化的一部分:

  • 在当前实例化或其非依赖基中找到的无限定名称(不在作用域解析运算符 :: 的右侧出现)

  • 如果其限定符(位于作用域解析运算符 :: 左侧的部分)命名了当前实例化,并且在该实例化或其非依赖基中找到,则该限定符为作用域解析运算符右侧出现的作用域限定名称

  • 在类成员访问表达式中使用的名称,其中对象表达式是当前实例化,且该名称在当前实例化或其非依赖基中找到

    注意

    据说,如果一个基类是一个依赖于模板参数的依赖类型(依赖于模板参数)并且不在当前实例化中,则该基类是一个依赖类。否则,称基类为非依赖类

这些规则可能听起来有点难以理解;因此,让我们通过几个示例来尝试理解它们,如下所示:

template <typename T>
struct parser
{
   parser* p1;          // parser is the CI
   parser<T>* p2;       // parser<T> is the CI
   ::parser<T>* p3;     // ::parser<T> is the CI
   parser<T*> p4;       // parser<T*> is not the CI
   struct token
   {
      token* t1;                  // token is the CI
      parser<T>::token* t2;       // parser<T>::token is the CI
      typename parser<T*>::token* t3; 
                         // parser<T*>::token is not the CI
   };
};
template <typename T>
struct parser<T*>
{
   parser<T*>* p1;   // parser<T*> is the CI
   parser<T>*  p2;   // parser<T> is not the CI
};

在主模板 parser 中,名称 parserparser<T>::parser<T> 都指向当前实例化。然而,parser<T*> 并不是。类 token 是主模板 parser 的嵌套类。在这个类的范围内,tokenparser<T>::token 都表示当前实例化。对于 parser<T*>::token 并不适用。这个片段还包含了对指针类型 T* 的主模板的部分特化。在这个部分特化的上下文中,parser<T*> 是当前实例化,但 parser<T> 不是。

依赖名称是模板编程的一个重要方面。本节的关键要点是,名称被分类为依赖(依赖于模板参数)和非依赖(不依赖于模板参数)。非依赖类型的名称绑定发生在定义点,依赖类型的名称绑定发生在实例化点。在某些情况下,需要关键字 typenametemplate 来消除歧义,并告诉编译器一个名称指的是类型或模板。然而,在类模板定义的上下文中,编译器能够确定一些依赖名称指的是当前实例化,这使得它能够更快地识别错误。

在下一节中,我们将注意力转向一个我们已经简要涉及过的主题,即模板递归。

探索模板递归

第三章 可变模板中,我们讨论了可变模板,并看到它们是通过类似于递归的机制实现的。实际上,这是重载函数和类模板特化分别实现的。然而,可以创建递归模板。为了展示这是如何工作的,我们将查看实现阶乘函数的编译时版本。这通常以递归方式实现,一个可能的实现如下:

constexpr unsigned int factorial(unsigned int const n)
{
   return n > 1 ? n * factorial(n - 1) : 1;
}

这应该很容易理解:返回函数参数与递归调用递减参数的函数返回值相乘的结果,或者如果参数是01,则返回值1。参数的类型(以及返回值)是unsigned int,以避免对负整数进行调用。

要在编译时计算阶乘函数的值,我们需要定义一个包含持有函数值的成员数据的类模板。其实现如下:

template <unsigned int N>
struct factorial
{
   static constexpr unsigned int value = 
      N * factorial<N - 1>::value;
};
template <>
struct factorial<0>
{
   static constexpr unsigned int value = 1;
};
int main()
{
   std::cout << factorial<4>::value << '\n';
}

第一个定义是主要模板。它有一个非类型模板参数,表示需要计算阶乘的值。这个类包含一个静态constexpr数据成员value,其初始化为将参数N与使用递减参数实例化的阶乘类模板的值相乘的结果。递归需要一个结束条件,这由非类型模板参数的显式特化提供,即值为0时,成员值初始化为1

当在main函数中遇到实例化factorial<4>::value时,编译器会生成从factorial<4>factorial<0>的所有递归实例化。这些实例化看起来如下:

template<>
struct factorial<4>
{
   inline static constexpr const unsigned int value = 
      4U * factorial<3>::value;
};
template<>
struct factorial<3>
{
   inline static constexpr const unsigned int value = 
      3U * factorial<2>::value;
};
template<>
struct factorial<2>
{
   inline static constexpr const unsigned int value = 
      2U * factorial<1>::value;
};
template<>
struct factorial<1>
{
   inline static constexpr const unsigned int value = 
      1U * factorial<0>::value;
};
template<>
struct factorial<0>
{
   inline static constexpr const unsigned int value = 1;
};

从这些实例化中,编译器能够计算出数据成员factorial<N>::value的值。再次提到,当启用优化时,此代码甚至不会被生成,但结果常量会直接用于生成的汇编代码中。

阶乘类模板的实现相对简单,类模板基本上只是静态数据成员value的包装。实际上,我们可以通过使用变量模板来避免它。这可以定义如下:

template <unsigned int N>
inline constexpr unsigned int factorial = N * factorial<N - 1>;
template <>
inline constexpr unsigned int factorial<0> = 1;
int main()
{
   std::cout << factorial<4> << '\n';
}

factorial类模板的实现与factorial变量模板的实现之间有显著的相似性。对于后者,我们基本上取出了数据成员值并称之为factorial。另一方面,这也许更方便使用,因为它不需要像factorial<4>::value那样访问数据成员值。

计算编译时阶乘的第三种方法是使用函数模板。以下是一个可能的实现:

template <unsigned int n>
constexpr unsigned int factorial()
{
   return n * factorial<n - 1>();
}
template<> constexpr unsigned int factorial<1>() { 
                                               return 1; }
template<> constexpr unsigned int factorial<0>() { 
                                               return 1; }
int main()
{
   std::cout << factorial<4>() << '\n';
}

您可以看到有一个主要模板递归调用factorial函数模板,并且我们有两个完全特化,针对值10,都返回1

这三种不同方法中哪一种最好可能是有争议的。然而,阶乘模板的递归实例化的复杂性保持不变。然而,这取决于模板的性质。以下代码片段显示了复杂性增加的例子:

template <typename T>
struct wrapper {};
template <int N>
struct manyfold_wrapper
{
   using value_type = 
      wrapper<
             typename manyfold_wrapper<N - 1>::value_type>;
};
template <>
struct manyfold_wrapper<0>
{
   using value_type = unsigned int;
};
int main()
{
   std::cout << 
    typeid(manyfold_wrapper<0>::value_type).name() << '\n';
   std::cout << 
    typeid(manyfold_wrapper<1>::value_type).name() << '\n';
   std::cout << 
    typeid(manyfold_wrapper<2>::value_type).name() << '\n';
   std::cout << 
    typeid(manyfold_wrapper<3>::value_type).name() << '\n';
}

在这个例子中有两个类模板。第一个被称为wrapper,它有一个空实现(实际上它包含的内容并不重要),但它代表了一个类型(或更精确地说,某个类型的值)的包装类。第二个模板被称为manyfold_wrapper。这代表了一个类型多次包装的包装器,因此得名manyfold_wrapper。这个数字的包装上限没有终点,但有一个下限的起点。值为0的全特化定义了一个名为value_type的成员类型,用于unsigned int类型。因此,manyfold_wrapper<1>定义了一个名为value_type的成员类型,用于wrapper<unsigned int>manyfold_wrapper<2>定义了一个名为value_type的成员类型,用于wrapper<wrapper<unsigned int>>,依此类推。因此,执行main函数将在控制台打印以下内容:

unsigned int
struct wrapper<unsigned int>
struct wrapper<struct wrapper<unsigned int> >
struct wrapper<struct wrapper<struct wrapper<unsigned int> > >

C++标准没有指定递归嵌套模板实例化的限制,但建议一个最小限制为 1,024。然而,这只是一个建议,而不是要求。因此,不同的编译器实现了不同的限制。VC++ 16.11编译器的限制设置为 500,GCC 12为 900,Clang 13为 1,024。当超过这个限制时,会生成编译器错误。这里展示了几个例子:

对于 VC++:

fatal error C1202: recursive type or function dependency context too complex

对于 GCC:

fatal error: template instantiation depth exceeds maximum of 900 (use '-ftemplate-depth=' to increase the maximum)

对于 Clang:

fatal error: recursive template instantiation exceeded maximum depth of 1024
use -ftemplate-depth=N to increase recursive template instantiation depth

对于 GCC 和 Clang,可以使用编译器选项-ftemplate-depth=N来增加嵌套模板实例化的最大值。这个选项对于 Visual C++编译器不可用。

递归模板帮助我们以递归方式在编译时解决一些问题。你使用递归函数模板、变量模板还是类模板取决于你试图解决的问题或可能你的偏好。然而,你应该记住模板递归的深度是有限制的。尽管如此,还是要谨慎使用模板递归。

本章接下来要讨论的下一个高级主题是模板参数推断,包括函数和类。我们接下来从前者开始。

函数模板参数推断

在本书的早期,我们简要地讨论了编译器有时可以从函数调用的上下文中推断出模板参数的事实,这允许你避免显式指定它们。模板参数推断的规则更为复杂,我们将在本节中探讨这个主题。

让我们从查看一个简单的例子开始讨论:

template <typename T>
void process(T arg)
{
   std::cout << "process " << arg << '\n';
}
int main()
{
   process(42);          // [1] T is int
   process<int>(42);     // [2] T is int, redundant
   process<short>(42);   // [3] T is short
}

在这个片段中,process是一个具有单个类型模板参数的函数模板。调用process(42)process<int>(42)是相同的,因为在第一种情况下,编译器能够从传递给函数的参数的值推断出类型模板参数T的类型为int

当编译器尝试推断模板参数时,它会将模板参数的类型与调用函数时使用的参数类型进行匹配。有一些规则控制着这种匹配。编译器可以匹配以下类型:

  • 形式为 TT constT volatile 的类型(既包括 cv-限定类型也包括非限定类型):

    struct account_t
    {
       int number;
    };
    template <typename T>
    void process01(T) { std::cout << "T\n"; }
    template <typename T>
    void process02(T const) { std::cout << "T const\n"; }
    template <typename T>
    void process03(T volatile) { std::cout << "T volatile\n"; }
    int main()
    {
       account_t ac{ 42 };
       process01(ac);  // T
       process02(ac);  // T const
       process03(ac);  // T volatile
    }
    
  • 指针 (T*)、左值引用 (T&) 和右值引用 (T&&):

    template <typename T>
    void process04(T*) { std::cout << "T*\n"; }
    template <typename T>
    void process04(T&) { std::cout << "T&\n"; }
    template <typename T>
    void process05(T&&) { std::cout << "T&&\n"; }
    int main()
    {
       account_t ac{ 42 };
       process04(&ac);  // T*
       process04(ac);  // T&
       process05(ac);  // T&&
    }
    
  • 数组,如 T[5]C[5][n],其中 C 是类类型,n 是非类型模板参数:

    template <typename T>
    void process06(T[5]) { std::cout << "T[5]\n"; }
    template <size_t n>
    void process07(account_t[5][n]) 
    { std::cout << "C[5][n]\n"; }
    int main()
    {
       account_t arr1[5] {};
       process06(arr1);  // T[5]
       account_t ac{ 42 };
       process06(&ac);   // T[5]
       account_t arr2[5][3];
       process07(arr2);  // C[5][n]
    }
    
  • 函数指针,形式为 T(*)(), C(*)(T), 和 T(*)(U),其中 C 是类类型,TU 是类型模板参数:

    template<typename T>
    void process08(T(*)()) { std::cout << "T (*)()\n"; }
    template<typename T>
    void process08(account_t(*)(T)) 
    { std::cout << "C (*) (T)\n"; }
    template<typename T, typename U>
    void process08(T(*)(U)) { std::cout << "T (*)(U)\n"; }
    int main()
    {
       account_t (*pf1)() = nullptr;
       account_t (*pf2)(int) = nullptr;
       double    (*pf3)(int) = nullptr;
       process08(pf1);    // T (*)()
       process08(pf2);    // C (*)(T)
       process08(pf3);    // T (*)(U)
    }
    
  • 成员函数指针,具有以下形式之一,T (C::*)(), T (C::*)(U), T (U::*)(), T (U::*)(V), C (T::*)(), C (T::*)(U), 和 D (C::*)(T),其中 CD 是类类型,TUV 是类型模板参数:

    struct account_t
    {
       int number;
       int get_number() { return number; }
       int from_string(std::string text) { 
          return std::atoi(text.c_str()); }
    };
    struct transaction_t
    {
       double amount;
    };
    struct balance_report_t {};
    struct balance_t
    {
       account_t account;
       double    amount;
       account_t get_account()  { return account; }
       int get_account_number() { return account.number; }
       bool can_withdraw(double const value)  
          {return amount >= value; };
       transaction_t withdraw(double const value) { 
          amount -= value; return transaction_t{ -value }; }
       balance_report_t make_report(int const type) 
       {return {}; }
    };
    template<typename T>
    void process09(T(account_t::*)())
    { std::cout << "T (C::*)()\n"; }
    template<typename T, typename U>
    void process09(T(account_t::*)(U))
    { std::cout << "T (C::*)(U)\n"; }
    template<typename T, typename U>
    void process09(T(U::*)())
    { std::cout << "T (U::*)()\n"; }
    template<typename T, typename U, typename V>
    void process09(T(U::*)(V))
    { std::cout << "T (U::*)(V)\n"; }
    template<typename T>
    void process09(account_t(T::*)())
    { std::cout << "C (T::*)()\n"; }
    template<typename T, typename U>
    void process09(transaction_t(T::*)(U))
    { std::cout << "C (T::*)(U)\n"; }
    template<typename T>
    void process09(balance_report_t(balance_t::*)(T))
    { std::cout << "D (C::*)(T)\n"; }
    int main()
    {
       int (account_t::* pfm1)() = &account_t::get_number;
       int (account_t::* pfm2)(std::string) = 
          &account_t::from_string;
       int (balance_t::* pfm3)() =
          &balance_t::get_account_number;
       bool (balance_t::* pfm4)(double) =
          &balance_t::can_withdraw;
       account_t (balance_t::* pfm5)() = 
          &balance_t::get_account;
       transaction_t(balance_t::* pfm6)(double) = 
          &balance_t::withdraw;
       balance_report_t(balance_t::* pfm7)(int) = 
          &balance_t::make_report;
       process09(pfm1);    // T (C::*)()
       process09(pfm2);    // T (C::*)(U)
       process09(pfm3);    // T (U::*)()
       process09(pfm4);    // T (U::*)(V)
       process09(pfm5);    // C (T::*)()
       process09(pfm6);    // C (T::*)(U)
       process09(pfm7);    // D (C::*)(T)
    }
    
  • 数据成员指针,如 T C::*C T::*T U::*,其中 C 是类类型,TU 是类型模板参数:

    template<typename T>
    void process10(T account_t::*) 
    { std::cout << "T C::*\n"; }
    template<typename T>
    void process10(account_t T::*) 
    { std::cout << "C T::*\n"; }
    template<typename T, typename U>
    void process10(T U::*) { std::cout << "T U::*\n"; }
    int main()
    {
       process10(&account_t::number);   // T C::*
       process10(&balance_t::account);  // C T::*
       process10(&balance_t::amount);   // T U::*
    }
    
  • 参数列表至少包含一个类型模板参数的模板;一般形式为 C<T>,其中 C 是类类型,T 是类型模板参数:

    template <typename T>
    struct wrapper
    {
       T data;
    };
    template<typename T>
    void process11(wrapper<T>) { std::cout << "C<T>\n"; }
    int main()
    {
       wrapper<double> wd{ 42.0 };
       process11(wd); // C<T>
    }
    
  • 参数列表至少包含一个非类型模板参数的模板;一般形式为 C<i>,其中 C 是类类型,i 是非类型模板参数:

    template <size_t i>
    struct int_array
    {
       int data[i];
    };
    template<size_t i>
    void process12(int_array<i>) { std::cout << "C<i>\n"; }
    int main()
    {
       int_array<5> ia{};
       process12(ia); // C<i>
    }
    
  • 模板模板参数的参数列表至少包含一个类型模板参数;一般形式为 TT<T>,其中 TT 是模板模板参数,T 是类型模板:

    template<template<typename> class TT, typename T>
    void process13(TT<T>) { std::cout << "TT<T>\n"; }
    int main()
    {
       wrapper<double> wd{ 42.0 };
       process13(wd);    // TT<U>
    }
    
  • 模板模板参数的参数列表至少包含一个非类型模板参数;一般形式为 TT<i>,其中 TT 是模板模板参数,i 是非类型模板参数:

    template<template<size_t> typename TT, size_t i>
    void process14(TT<i>) { std::cout << "TT<i>\n"; }
    int main()
    {
       int_array<5> ia{};
       process14(ia);    // TT<i>
    }
    
  • 参数列表没有依赖于模板参数的模板参数的模板模板参数;形式为 TT<C>,其中 TT 是模板模板参数,C 是类类型:

    template<template<typename> typename TT>
    void process15(TT<account_t>) { std::cout << "TT<C>\n"; }
    int main()
    {
       wrapper<account_t> wa{ {42} };
       process15(wa);    // TT<C>
    }
    

尽管编译器能够推断许多模板参数的类型,如前所述,但它所能做的也有局限性。以下列表举例说明了这些限制:

  • 编译器不能从非类型模板参数的类型推断类型模板参数的类型。在以下示例中,process 是一个有两个模板参数的函数模板:一个名为 T 的类型模板和一个类型为 T 的非类型模板 i。使用包含五个双精度浮点数的数组调用函数不允许编译器确定 T 的类型,尽管这是指定数组大小的值的类型:

    template <typename T, T i>
    void process(double arr[i])
    {
       using index_type = T;
       std::cout << "processing " << i 
                 << " doubles" << '\n';
    
    std::cout << "index type is " 
              << typeid(T).name() << '\n';
    }
    int main()
    {
       double arr[5]{};
       process(arr);         // error
       process<int, 5>(arr); // OK
    }
    
  • 编译器无法从默认值的类型推导出模板参数的类型。这一点在下面的代码中通过具有单个类型模板参数但有两个函数参数(都是类型T且都有默认值)的函数模板process进行说明。

没有参数的process()调用失败,因为编译器无法从函数参数的默认值推导出类型模板参数T的类型。process<int>()调用是正确的,因为模板参数是显式提供的。process(6)调用也是正确的,因为第一个函数参数的类型可以从提供的参数推导出来,因此类型模板参数也可以推导出来:

template <typename T>
void process(T a = 0, T b = 42)
{
   std::cout << a << "," << b << '\n';
}
int main()
{
   process();        // [1] error
   process<int>();   // [2] OK
   process(10);      // [3] OK
}
  • 尽管编译器可以从函数指针或成员函数指针推导出函数模板参数,如我们之前所看到的,但这个能力有一些限制:它不能从函数模板的指针推导出参数,也不能从具有多个重载函数匹配所需类型的函数的指针推导出参数。

在下面的代码中,函数模板invoke接受一个指向具有两个参数的函数的指针,第一个参数是类型模板参数T,第二个是int,并返回void。这个函数模板不能传递指向alpha(见[1])的指针,因为这是一个函数模板,也不能传递指向beta(见[2])的指针,因为beta有多个重载可以匹配类型T。然而,可以传递指向gamma(见[3])的指针,并且它将正确推导出第二个重载的类型:

template <typename T>
void invoke(void(*pfun)(T, int))
{
   pfun(T{}, 42);
}
template <typename T>
void alpha(T, int)
{ std::cout << "alpha(T,int)" << '\n'; }
void beta(int, int)
{ std::cout << "beta(int,int)" << '\n'; }
void beta(short, int)
{ std::cout << "beta(short,int)" << '\n'; }
void gamma(short, int, long long)
{ std::cout << "gamma(short,int,long long)" << '\n'; }
void gamma(double, int) 
{ std::cout << "gamma(double,int)" << '\n'; }
int main()
{
   invoke(&alpha);  // [1] error
   invoke(&beta);   // [2] error
   invoke(&gamma);  // [3] OK
}
  • 编译器的另一个限制是数组主维度的参数推导。原因是这并非函数参数类型的一部分。这个限制的例外情况是维度指向引用或指针类型。以下代码片段展示了这些限制:

    • [1]处的process1()调用产生错误,因为编译器无法推导出非类型模板参数Size的值,因为Size指向数组的主体维度。

    • 在标记为[2]的点处的process2()调用是正确的,因为非类型模板参数Size指向数组的第二维度。

    • 另一方面,对process3()(在[3]处)和process4()(在[4]处)的调用都是成功的,因为函数参数要么是单个维度的数组的引用或指针:

      template <size_t Size>
      void process1(int a[Size])
      { std::cout << "process(int[Size])" << '\n'; };
      template <size_t Size>
      void process2(int a[5][Size])
      { std::cout << "process(int[5][Size])" << '\n'; };
      template <size_t Size>
      void process3(int(&a)[Size])
      { std::cout << "process(int[Size]&)" << '\n'; };
      template <size_t Size>
      void process4(int(*a)[Size])
      { std::cout << "process(int[Size]*)" << '\n'; };
      int main()
      {
         int arr1[10];
         int arr2[5][10];
         process1(arr1);   // [1] error
         process2(arr2);   // [2] OK
         process3(arr1);   // [3] OK
         process4(&arr1);  // [4] OK
      }
      
  • 如果在函数模板参数列表中的表达式中使用了非类型模板参数,则编译器无法推导其值。

在下面的代码片段中,ncube是一个具有非类型模板参数N的类模板,N代表维度数。函数模板process也具有非类型模板参数N,但它在模板参数列表中的类型表达式中被用作表达式的一部分。因此,编译器不能从函数参数的类型中推断出N的值(如[1]所示),这必须显式指定(如[2]所示):

template <size_t N>
struct ncube 
{
   static constexpr size_t dimensions = N;
};
template <size_t N>
void process(ncube<N - 1> cube)
{
   std::cout << cube.dimensions << '\n';
}
int main()
{
   ncube<5> cube;
   process(cube);    // [1] error
   process<6>(cube); // [2] OK
}

本节中讨论的所有模板参数推断规则也适用于变长函数模板。然而,所有讨论都是在函数模板的上下文中进行的。模板参数推断也适用于类模板,我们将在下一节中探讨这个主题。

类模板参数推断

C++17之前,模板参数推断仅适用于函数,而不适用于类。这意味着当一个类模板需要实例化时,必须提供所有的模板参数。下面的代码片段展示了几个例子:

template <typename T>
struct wrapper
{
   T data;
};
std::pair<int, double> p{ 42, 42.0 };
std::vector<int>       v{ 1,2,3,4,5 };
wrapper<int>           w{ 42 };

通过利用函数模板的模板参数推断,一些标准类型具有创建该类型实例的辅助函数,无需显式指定模板参数。例如,std::make_pair用于std::pairstd::make_unique用于std::unique_ptr。这些辅助函数模板与auto关键字一起使用,避免了为类模板指定模板参数的需要。以下是一个示例:

auto p = std::make_pair(42, 42.0);

虽然并非所有标准类模板都有创建实例的辅助函数,但编写自己的并不困难。在下面的代码片段中,我们可以看到一个用于创建std::vector<T>实例的make_vector函数模板,以及一个用于创建wrapper<T>实例的make_wrapper函数模板:

template <typename T, typename... Ts, 
          typename Allocator = std::allocator<T>>
auto make_vector(T&& first, Ts&&... args)
{
   return std::vector<std::decay_t<T>, Allocator> {
      std::forward<T>(first),
      std::forward<Ts>(args)... 
   };
}
template <typename T>
constexpr wrapper<T> make_wrapper(T&& data)
{
   return wrapper{ data };
}
auto v = make_vector(1, 2, 3, 4, 5);
auto w = make_wrapper(42);

C++17 标准通过为类模板提供模板参数推断来简化了它们的使用。因此,从 C++17 开始,本节中展示的第一个代码片段可以简化如下:

std::pair   p{ 42, 42.0 };   // std::pair<int, double>
std::vector v{ 1,2,3,4,5 };  // std::vector<int>
wrapper     w{ 42 };         // wrapper<int>

这是因为编译器能够从初始化器的类型中推断出模板参数。在这个例子中,编译器是从变量的初始化表达式中推断出来的。但是编译器也能够从new表达式和函数式转换表达式中推断出模板参数。下面将举例说明:

template <typename T>
struct point_t
{
   point_t(T vx, T vy) : x(vx), y(vy) {}
private:
   T x;
   T y;
};
auto p = new point_t(1, 2);   // [1] point<int>
                              // new expression
std::mutex mt;
auto l = std::lock_guard(mt); // [2] 
// std::lock_guard<std::mutex>
// function-style cast expression

类模板的模板参数推断方式与函数模板不同,但它依赖于后者。当在变量声明或函数式转换中遇到类模板的名称时,编译器会继续构建一组所谓的推断指南

有代表虚构类类型构造函数签名的虚构函数模板。用户也可以提供推导指南,这些将被添加到编译器生成的指南列表中。如果在虚构函数模板的构造集合上解析重载失败(返回类型不是匹配过程的一部分,因为这些函数代表构造函数),则程序是不良形式,并生成错误。否则,所选函数模板特化的返回类型成为推导出的类模板特化。

为了更好地理解这一点,让我们看看推导指南实际上看起来是什么样子。在下面的代码片段中,你可以看到编译器为 std::pair 类生成的部分指南。实际的列表更长,为了简洁,这里只展示了部分:

template <typename T1, typename T2>
std::pair<T1, T2> F();
template <typename T1, typename T2>
std::pair<T1, T2> F(T1 const& x, T2 const& y);
template <typename T1, typename T2, typename U1, 
          typename U2>
std::pair<T1, T2> F(U1&& x, U2&& y);

这组隐式推导指南是从类模板的构造函数生成的。这包括默认构造函数、拷贝构造函数、移动构造函数以及所有转换构造函数,参数以精确的顺序复制。如果构造函数是显式的,那么推导指南也是显式的。然而,如果类模板没有用户定义的构造函数,则会为假设的默认构造函数创建一个推导指南。假设的拷贝构造函数的推导指南总是被创建。

用户可以在源代码中提供自定义推导指南。其语法类似于具有尾随返回类型的函数,但没有 auto 关键字。推导指南可以是函数或函数模板。重要的是要记住,这些必须在应用于类模板的同一命名空间中提供。因此,如果我们想为 std::pair 类添加一个用户定义的推导指南,它必须在 std 命名空间中完成。以下是一个示例:

namespace std
{
   template <typename T1, typename T2>
   pair(T1&& v1, T2&& v2) -> pair<T1, T2>;
}

到目前为止显示的推导指南都是函数模板。但如前所述,它们不必是函数模板。它们也可以是普通函数。为了演示这一点,让我们考虑以下示例:

std::pair  p1{1, "one"};    // std::pair<int, const char*>
std::pair  p2{"two", 2};    // std::pair<const char*, int>
std::pair  p3{"3", "three"};
                    // std::pair<const char*, const char*>

对于 std::pair 类的编译器生成的推导指南,推导出的类型分别是 p1std::pair<int, const char*>p2std::pair<const char*, int>p3std::pair<const char*, const char*>。换句话说,编译器推导出的类型,在用到字面字符串时是 const char*(正如预期的那样)。我们可以通过提供几个用户定义的推导指南来告诉编译器推导出 std::string 而不是 const char*。这些将在下面的列表中展示:

namespace std
{
   template <typename T>
   pair(T&&, char const*) -> pair<T, std::string>;
   template <typename T>
   pair(char const*, T&&) -> pair<std::string, T>;
   pair(char const*, char const*) -> 
      pair<std::string, std::string>;
}

注意,前两个是函数模板,但第三个是一个普通函数。有了这些指南,从上一个例子中推导出的 p1p2p3 的类型分别是 std::pair<int, std::string>std::pair<std::string, int>std::pair<std::string, std::string>

让我们再来看一个用户定义引导的例子,这次是一个用户定义的类。让我们考虑以下表示范围的类模板:

template <typename T>
struct range_t
{
   template <typename Iter>
   range_t(Iter first, Iter last)
   {
      std::copy(first, last, std::back_inserter(data));
   }
private:
   std::vector<T> data;
};

这个实现并没有太多内容,但实际上,它已经足够满足我们的需求了。让我们考虑你想要从一个整数数组构造一个范围对象:

int arr[] = { 1,2,3,4,5 };
range_t r(std::begin(arr), std::end(arr));

运行此代码将生成错误。不同的编译器会生成不同的错误信息。也许 Clang 提供的错误信息最能描述问题:

error: no viable constructor or deduction guide for deduction of template arguments of 'range_t'
   range_t r(std::begin(arr), std::end(arr));
           ^
note: candidate template ignored: couldn't infer template argument 'T'
      range_t(Iter first, Iter last)
      ^
note: candidate function template not viable: requires 1 argument, but 2 were provided
   struct range_t

然而,无论实际的错误信息是什么,意义都是相同的:range_t的模板参数推导失败。为了使推导工作,需要提供一个用户定义的推导引导,并且它需要看起来如下:

template <typename Iter>
range_t(Iter first, Iter last) -> 
   range_t<
      typename std::iterator_traits<Iter>::value_type>;

这个推导引导所指示的是,当遇到带有两个迭代器参数的构造函数调用时,模板参数T的值应该被推导为迭代器特质的值类型。迭代器特质是一个将在第五章中讨论的主题,即类型特质和条件编译。然而,有了这个,之前的代码片段可以正常运行,并且编译器推导出r变量的类型为range_t<int>,正如预期的那样。

在本节的开始部分,提供了一个示例,其中提到w的类型被推导为wrapper<int>

wrapper w{ 42 }; // wrapper<int>

在 C++17 中,没有用户定义的推导引导,这实际上是不正确的。原因是wrapper<T>是一个聚合类型,并且在 C++17 中,类模板参数推导不能从聚合初始化中工作。因此,为了使前面的代码行工作,需要提供以下推导引导:

template <typename T>
wrapper(T) -> wrapper<T>;

幸运的是,在 C++20 中不再需要这样的用户定义的推导引导。这个标准版本提供了对聚合类型的支持(只要任何依赖的基类没有虚拟函数或虚拟基类,并且变量是从一个非空初始化器列表中初始化的)。

类模板参数推导仅在没有提供模板参数的情况下才有效。因此,以下对p1p2的声明都是有效的,并且发生了类模板参数推导;对于p2,推导出的类型是std::pair<int, std::string>(假设之前定义的引导可用)。然而,p3p4的声明会产生错误,因为类模板参数推导没有发生,因为存在一个模板参数列表(<><int>),但它不包含所有必需的参数:

std::pair<int, std::string> p1{ 1, "one" };  // OK
std::pair p2{ 2, "two" };                    // OK
std::pair<> p3{ 3, "three" };                // error
std::pair<int> p4{ 4, "four" };              // error

类模板参数推导可能不会总是产生预期的结果。让我们考虑以下示例:

std::vector v1{ 42 };
std::vector v2{ v1, v1 };
std::vector v3{ v1 };

v1的推导类型是std::vector<int>,而v2的推导类型是std::vector<std::vector<int>>。然而,编译器应该推导出v3的类型是什么?有两种选择:std::vector<std::vector<int>>std::vector<int>。如果你的预期是前者,你可能会失望地发现编译器实际上推导出后者。这是因为推导依赖于参数的数量和类型

当参数数量大于一个时,它将使用接受初始化列表的构造函数。对于v2变量,那就是std::initializer_list<std::vector<int>>。当参数数量为一个时,则考虑参数的类型。如果参数的类型是std::vector(考虑这个显式情况)的类型,那么将使用复制构造函数,推导出的类型是参数的声明类型。这是变量v3的情况,推导出的类型是std::vector<int>。否则,将使用接受初始化列表(单个元素)的构造函数,就像变量v1的情况一样,其推导出的类型是std::vector<int>。这些可以用 cppinsights.io 工具更好地可视化,该工具显示了以下生成的代码(对于前面的片段)。注意,为了简洁起见,已经移除了分配器参数:

std::vector<int> v1 = 
   std::vector<int>{std::initializer_list<int>{42}};
std::vector<vector<int>> v2 = 
   std::vector<vector<int>>{
      std::initializer_list<std::vector<int>>{
         std::vector<int>(v1), 
         std::vector<int>(v1)
      }
   };
std::vector<int> v3 = std::vector<int>{v1};

类模板参数推导是 C++17 的一个有用特性,在 C++20 中对聚合类型进行了改进。它有助于避免在编译器能够推导出它们时编写不必要的显式模板参数,尽管在某些情况下,编译器可能需要用户定义的推导指南才能使推导工作。它还避免了创建工厂函数的需求,例如std::make_pairstd::make_tuple,这些函数是模板参数推导可用之前从类模板中受益的解决方案。

模板参数推导的内容比我们之前讨论的要多。有一个特殊的情况是函数模板参数推导,称为前向引用。这将在下一部分讨论。

前向引用

在 C++11 中添加到语言中的最重要的特性之一是移动语义,它通过避免不必要的复制来提高性能。移动语义由另一个称为右值引用的 C++11 特性支持。在讨论这些之前,值得提到的是,在 C++中,我们有两种类型的值:

  • &运算符。左值可以出现在赋值表达式的左侧和右侧。

  • &运算符。右值是字面量和临时对象,并且只能出现在赋值表达式的右侧。

    注意

    在 C++11 中,还有一些其他的值类别,如 glvalue、prvalue 和 xvalue。在这里讨论它们不会对当前主题有所帮助。然而,你可以在 en.cppreference.com/w/cpp/language/value_category 上了解更多关于它们的信息。

引用是已存在对象或函数的别名。正如我们有两种类型的值一样,在 C++11 中,我们有两种类型的引用:

  • &,例如在 &x 中,是左值的引用。

  • &&,例如在 &&x 中,是右值的引用。

让我们通过一些示例来更好地理解这些概念:

struct foo
{
   int data;
};
void f(foo& v)
{ std::cout << "f(foo&)\n"; }
void g(foo& v)
{ std::cout << "g(foo&)\n"; }
void g(foo&& v)
{ std::cout << "g(foo&&)\n"; }
void h(foo&& v)
{ std::cout << "h(foo&&)\n"; }
foo x = { 42 };   //  x is lvalue
foo& rx = x;      // rx is lvalue

在这里我们有三个函数:f,它接受一个左值引用(即 int&);g,它有两个重载,一个用于左值引用,另一个用于右值引用(即 int&&);以及 h,它接受一个右值引用。我们还有两个变量,xrx。在这里,x 是一个左值,其类型是 foo。我们可以用 &x 来获取它的地址。左值还包括 rx,它是一个左值引用,其类型是 foo&。现在,让我们看看我们如何调用每个 fgh 函数:

f(x);       // f(foo&)
f(rx);      // f(foo&)
f(foo{42}); // error: a non-const reference
            // may only be bound to an lvalue

因为 xrx 都是左值,所以将它们传递给 f 是可以的,因为这个函数接受左值引用。然而,foo{42} 是一个临时对象,因为它在 f 的调用上下文之外不存在。这意味着它是一个右值,将 foo{42} 传递给 f 将导致编译器错误,因为函数的参数类型是 foo&,并且非常量引用只能绑定到左值。如果函数 f 的签名改为 f(int const &v),则可以解决这个问题。接下来,让我们讨论一下 g 函数:

g(x);             // g(foo&)
g(rx);            // g(foo&)
g(foo{ 42 });     // g(foo&&)

在前面的代码片段中,使用 xrx 调用 g 将解析为第一个重载,它接受左值引用。然而,使用 foo{42}(这是一个临时对象,因此是右值)调用它将解析为第二个重载,它接受右值引用。让我们看看当我们想要对 h 函数进行相同的调用时会发生什么:

h(x);      // error, cannot bind an lvalue to an rvalue ref
h(rx);           // error
h(foo{ 42 });    // h(foo&&)
h(std::move(x)); // h(foo&&)

这个函数接受一个右值引用。尝试将 xrx 传递给它会导致编译器错误,因为左值不能绑定到右值引用。表达式 foo{42} 是一个右值,可以作为参数传递。我们还可以通过将 h 函数的语义从左值改为右值,将左值 x 传递给该函数。这是通过 std::move 实现的。这个函数实际上并没有移动任何东西;它只是进行了一种从左值到右值的类型转换。

然而,重要的是要理解将右值传递给函数有两个目的:要么对象是临时的,不存在于调用之外,函数可以对其做任何操作,要么函数应该接收并拥有该对象。这就是移动构造函数和移动赋值运算符的目的,而且你很少会看到其他函数接受右值引用。在我们最后的例子中,在函数 h 内部,参数 v 是一个左值,但它绑定到一个右值上。变量 x 在调用 h 之前存在,但传递给 std::move 后将其转换成了右值。在 h 的调用返回后,它仍然作为一个左值存在,但你应该假设函数 h 对其做了某些操作,其状态可以是任何东西。

因此,右值引用的一个目的是启用移动语义。但它还有一个目的,那就是启用 gh

void g(foo& v)  { std::cout << "g(foo&)\n"; }
void g(foo&& v) { std::cout << "g(foo&&)\n"; }
void h(foo& v)  { g(v); }
void h(foo&& v) { g(v); }

在这个片段中,g 的实现与之前看到的相同。然而,h 也有两个重载,一个接受左值引用并调用 g,另一个接受右值引用并也调用 g。换句话说,函数 h 只是将参数转发给 g。现在,让我们考虑以下调用:

foo x{ 42 };
h(x);          // g(foo&)
h(foo{ 42 });  // g(foo&)

从这个角度来看,你可能会预期 h(x) 的调用将导致调用接受左值引用的重载 g,而 h(foo{42}) 的调用将导致调用接受右值引用的重载 g。然而,实际上,它们都会调用 g 的第一个重载,因此会在控制台上打印 g(foo&)。一旦你理解了引用的工作原理,这个解释实际上很简单:在 h(foo&& v) 的上下文中,参数 v 实际上是一个左值(它有一个名称,你可以获取它的地址),因此用 v 调用 g 将会调用接受左值引用的重载。为了让它按预期工作,我们需要按照以下方式更改 h 函数的实现:

void h(foo& v)  { g(std::forward<foo&>(v)); }
void h(foo&& v) { g(std::forward<foo&&>(v)); }

std::forward 是一个函数,它能够正确地转发值。该函数的作用如下:

  • 如果参数是一个左值引用,那么函数的行为就像是对 std::move 的调用(将语义从左值转换为右值)。

  • 如果参数是一个右值引用,那么它不做任何事情。

我们到目前为止讨论的所有内容都与模板无关,模板是本书的主题。然而,函数模板也可以用来接受左值和右值引用,并且首先理解它们在非模板场景中的工作方式是很重要的。这是因为,在模板中,右值引用的工作方式略有不同,有时它们是右值引用,但有时它们实际上是左值引用。

表现出这种行为的引用被称为转发引用。然而,它们通常被称为通用引用。这是 Scott Meyers 在 C++11 之后不久提出的术语,因为在标准中没有这个类型的引用的术语。为了解决这个不足,并且因为它觉得“通用引用”这个术语并不能很好地描述它们的语义,C++标准委员会在 C++14 中将这些转发引用称为转发引用。然而,这两个术语在文献中都是同样存在的。为了忠实于标准术语,我们将在本书中称它们为转发引用。

为了开始讨论转发引用,让我们考虑以下重载的函数模板和类模板:

template <typename T>
void f(T&& arg)               // forwarding reference
{ std::cout << "f(T&&)\n"; }
template <typename T>
void f(T const&& arg)         // rvalue reference
{ std::cout << "f(T const&&)\n"; }
template <typename T>
void f(std::vector<T>&& arg)  // rvalue reference
{ std::cout << "f(vector<T>&&)\n"; }
template <typename T>
struct S
{
   void f(T&& arg)            // rvalue reference
   { std::cout << "S.f(T&&)\n"; }
};

我们可以这样调用这些函数:

int x = 42;
f(x);                   // [1] f(T&&)
f(42);                  // [2] f(T&&)
int const cx = 100;
f(cx);                  // [3] f(T&&)
f(std::move(cx));       // [4] f(T const&&)
std::vector<int> v{ 42 };
f(v);                   // [5] f(T&&)
f(std::vector<int>{42});// [6] f(vector<T>&&)
S<int> s;
s.f(x);                 // [7] error
s.f(42);                // [8] S.f(T&&)

从这个片段中,我们可以注意到:

  • [1][2] 处用左值或右值调用 f 会解析为第一个重载,f(T&&)

  • [3] 处用常量左值调用 f 也会解析为第一个重载,但用 [4] 处的常量右值调用 f 会解析为第二个重载,f(T const&&),因为它是一个更好的匹配。

  • [5] 处用左值 std::vector 对象调用 f 会解析为第一个重载,但用 [6] 处的右值 std::vector 对象调用 f 会解析为第三个重载,f(vector<T>&&),因为它是一个更好的匹配。

  • [7] 处用左值调用 S::f 是一个错误,因为左值不能绑定到右值引用,但用 [8] 处的右值调用它是正确的。

在这个例子中,所有的 f 函数重载都接受一个右值引用。然而,第一个重载中的 && 并不必然意味着一个右值引用。它意味着 如果传递了一个右值,则是一个右值引用;如果传递了一个左值,则是一个左值引用。这样的引用被称为 T&&,没有其他。T const&&std::vector<T>&& 不是转发引用,而是普通的右值引用。同样,类模板 Sf 函数成员中的 T&& 也是一个右值引用,因为 f 不是一个模板,而是类模板的非模板成员函数,因此这个转发引用的规则不适用。

转发引用是函数模板参数推导的一个特例,我们之前在本章中讨论过这个话题。它们的目的在于通过模板启用完美转发,并且它们是由 C++11 中的一个新特性引用折叠所实现的。让我们先看看这个,然后再展示它们是如何解决完美转发问题的。

在 C++11 之前,不可能取一个引用的引用。然而,现在在 C++11 中,对于 typedefs 和模板来说这是可能的。以下是一个例子:

using lrefint = int&;
using rrefint = int&&;
int x = 42;
lrefint&  r1 = x; // type of r1 is int&
lrefint&& r2 = x; // type of r2 is int&
rrefint&  r3 = x; // type of r3 is int&
rrefint&& r4 = 1; // type of r4 is int&&

规则相当简单:一个右值引用的右值引用会折叠为一个右值引用;所有其他组合都会折叠为一个左值引用。这可以用以下表格形式表示:

表 4.2

表 4.2

以下表格中显示的任何其他组合都不涉及引用折叠规则。这些规则仅适用于两种类型都是引用的情况:

表 4.3

表 4.3

前向引用不仅适用于模板,也适用于自动推导规则。当发现auto&&时,意味着一个前向引用。这同样不适用于其他任何东西,例如auto const&&这样的 cv-限定形式。以下是一些示例:

int x = 42;
auto&& rx = x;          // [1] int&
auto&& rc = 42;         // [2] int&&
auto const&& rcx = x;   // [3] error
std::vector<int> v{ 42 };
auto&& rv = v[0];       // [4] int&

在前两个示例中,rxrc都是前向引用,分别绑定到一个左值和一个右值。然而,rcx是一个右值引用,因为auto const&&并不表示一个前向引用。因此,尝试将其绑定到一个左值是一个错误。同样,rv是一个前向引用,绑定到一个左值。

如前所述,前向引用的目的是实现完美前向传递。我们之前已经看到了完美前向传递的概念,但是在非模板上下文中。然而,它在模板中也以类似的方式工作。为了演示这一点,让我们将函数h重新定义为模板函数。它看起来如下:

void g(foo& v)  { std::cout << "g(foo&)\n"; }
void g(foo&& v) { std::cout << "g(foo&&)\n"; }
template <typename T> void h(T& v)  { g(v); }
template <typename T> void h(T&& v) { g(v); }
foo x{ 42 };
h(x);          // g(foo&)
h(foo{ 42 });  // g(foo&)

g重载的实现是相同的,但h重载现在是函数模板。然而,用左值和右值调用h实际上解析为对g的相同调用,第一个重载接受一个左值。这是因为函数h的上下文中,v是一个左值,所以将其传递给g将调用接受左值的重载。这是因为函数h的上下文中,v是一个左值,所以将其传递给g将调用接受左值的重载。

解决这个问题的方法与我们之前在讨论模板时看到的方法相同。然而,有一个区别:我们不再需要两个重载,而是一个接受前向引用的单个重载:

template <typename T>
void h(T&& v)
{
   g(std::forward<T>(v));
}

此实现使用std::forward将左值作为左值和右值作为右值传递。它对变长函数模板也以类似的方式工作。以下是对std::make_unique函数的概念性实现,该函数创建一个std::unique_ptr对象:

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

总结本节内容,请记住,前向引用(也称为通用引用)基本上是函数模板参数的一个特殊推导规则。它们基于引用折叠的规则工作,其目的是实现完美前向传递。也就是说,通过保留其值语义将引用传递给另一个函数:右值应该作为右值传递,左值应该作为左值传递。

本章接下来我们将讨论的主题是decltype说明符。

decltype说明符

这个说明符是在 C++11 中引入的,它返回一个表达式的类型。它通常与auto说明符一起在模板中使用。一起使用时,它们可以用来声明一个函数模板的返回类型,该模板的返回类型依赖于其模板参数,或者声明一个封装另一个函数并从封装的函数执行返回结果的函数的返回类型。

decltype 指示符不限于在模板代码中使用。它可以与不同的表达式一起使用,并根据表达式产生不同的结果。规则如下:

  1. 如果表达式是一个标识符或类成员访问,则结果是表达式所命名的实体的类型。如果实体不存在,或者是一个具有重载集(存在多个具有相同名称的函数)的函数,则编译器将生成错误。

  2. 如果表达式是函数调用或重载运算符函数,则结果是函数的返回类型。如果重载运算符被括号包围,则忽略这些括号。

  3. 如果表达式是左值,则结果类型是表达式类型的左值引用。

  4. 如果表达式是其他内容,则结果类型是表达式的类型。

为了更好地理解这些规则,我们将通过一系列示例来探讨。对于这些示例,我们将考虑以下在 decltype 表达式中使用的函数和变量:

int f() { return 42; }
int g() { return 0; }
int g(int a) { return a; }
struct wrapper
{
   int val;
   int get() const { return val; }
};
int a = 42;
int& ra = a;
const double d = 42.99;
long arr[10];
long l = 0;
char* p = nullptr;
char c = 'x';
wrapper w1{ 1 };
wrapper* w2 = new wrapper{ 2 };

下面的列表展示了 decltype 指示符的多种用法。每种情况下适用的规则以及推导出的类型,都在每行的注释中指定:

decltype(a) e1;             // R1, int
decltype(ra) e2 = a;        // R1, int&
decltype(f) e3;             // R1, int()
decltype(f()) e4;           // R2, int
decltype(g) e5;             // R1, error
decltype(g(1)) e6;          // R2, int
decltype(&f) e7 = nullptr;  // R4, int(*)()
decltype(d) e8 = 1;         // R1, const double
decltype(arr) e9;           // R1, long[10]
decltype(arr[1]) e10 = l;   // R3, long&
decltype(w1.val) e11;       // R1, int
decltype(w1.get()) e12;     // R1, int
decltype(w2->val) e13;      // R1, int
decltype(w2->get()) e14;    // R1, int
decltype(42) e15 = 1;       // R4, int
decltype(1 + 2) e16;        // R4, int
decltype(a + 1) e17;        // R4, int
decltype(a = 0) e18 = a;    // R3, int&
decltype(p) e19 = nullptr;  // R1, char*
decltype(*p) e20 = c;       // R3, char&
decltype(p[0]) e21 = c;     // R3, char&

我们不会详细解释所有这些声明。大多数这些声明根据指定的规则相对容易理解。然而,以下几点值得考虑,以澄清一些推导出的类型:

  • decltype(f) 只命名了一个具有重载集的函数,因此适用规则 1。decltype(g) 也命名了一个函数,但它有一个重载集。因此,适用规则 1,编译器生成错误。

  • decltype(f())decltype(g(1)) 都使用了函数调用作为表达式,因此适用第二条规则,即使 g 有重载集,声明也是正确的。

  • decltype(&f) 使用了函数 f 的地址,因此适用第四条规则,得到 int(*)()

  • decltype(1+2)decltype(a+1) 使用了返回右值的重载运算符 +,因此适用第四条规则。结果是 int。然而,decltype(a = 1) 使用了返回左值的赋值运算符,因此适用第三条规则,得到左值引用 int&

使用 decltype 指示符的 a=1 声明变量 e,但在声明之后,a 的值是它被初始化时的值:

int a = 42;
decltype(a = 1) e = a;
std::cout << a << '\n';  // prints 42

关于模板实例化的这个规则有一个例外。当与 decltype 指示符一起使用的表达式包含模板时,模板在编译时评估表达式之前被实例化:

template <typename T>
struct wrapper
{
   T data;
};
decltype(wrapper<double>::data) e1;  // double
int a = 42;
decltype(wrapper<char>::data, a) e2; // int&

e1 的类型是 double,因此为推导出此类型实例化了 wrapper<double>。另一方面,e2 的类型是 int&(因为变量 a 是左值)。然而,即使类型仅从变量 a 推导出来(由于使用了逗号运算符),这里也实例化了 wrapper<char>

之前提到的规则不是用于确定类型的唯一规则。还有几个用于数据成员访问的规则。这些如下所示:

  • decltype 表达式中使用的对象的 constvolatile 指定符不会对推导出的类型产生影响。

  • 对象或指针表达式是左值还是右值不会影响推导出的类型。

  • 如果数据成员访问表达式被括号括起来,例如 decltype((expression)),则前两条规则不适用。对象的 constvolatile 修饰符会影响推导出的类型,包括对象的值的有效性。

列表中的前两条规则可以通过以下片段来证明:

struct foo
{
   int          a = 0;
   volatile int b = 0;
   const int    c = 42;
};
foo f;
foo const cf;
volatile foo* pf = &f;
decltype(f.a) e1 = 0;       // int
decltype(f.b) e2 = 0;       // int volatile
decltype(f.c) e3 = 0;       // int const
decltype(cf.a) e4 = 0;      // int
decltype(cf.b) e5 = 0;      // int volatile
decltype(cf.c) e6 = 0;      // int const
decltype(pf->a) e7 = 0;     // int
decltype(pf->b) e8 = 0;     // int volatile
decltype(pf->c) e9 = 0;     // int const
decltype(foo{}.a) e10 = 0;  // int
decltype(foo{}.b) e11 = 0;  // int volatile
decltype(foo{}.c) e12 = 0;  // int const

每个情况的推导类型在右侧的注释中提到。当表达式被括号括起来时,这两个规则会颠倒。让我们看一下以下片段:

foo f;
foo const cf;
volatile foo* pf = &f;
int x = 1;
int volatile y = 2;
int const z = 3;
decltype((f.a)) e1 = x;       // int&
decltype((f.b)) e2 = y;       // int volatile&
decltype((f.c)) e3 = z;       // int const&
decltype((cf.a)) e4 = x;      // int const&
decltype((cf.b)) e5 = y;      // int const volatile&
decltype((cf.c)) e6 = z;      // int const&
decltype((pf->a)) e7 = x;     // int volatile&
decltype((pf->b)) e8 = y;     // int volatile&
decltype((pf->c)) e9 = z;     // int const volatile&
decltype((foo{}.a)) e10 = 0;  // int&&
decltype((foo{}.b)) e11 = 0;  // int volatile&&
decltype((foo{}.c)) e12 = 0;  // int const&&

在这里,所有用于声明变量 e1e9decltype 表达式都是左值,所以推导出的类型是左值引用。另一方面,用于声明变量 e10e11e12 的表达式是一个右值;因此,推导出的类型是右值引用。此外,cf 是一个常量对象,foo::a 的类型是 int。因此,结果类型是 const int&。同样,foo::b 的类型是 volatile int;因此,结果类型是 const volatile int&。这只是这个片段中的几个例子,但其他例子遵循相同的推导规则。

因为 decltype 是一个类型指定符,所以多余的 constvolatile 修饰符以及引用指定符会被忽略。这可以通过以下示例来证明:

int a = 0;
int& ra = a;
int const c = 42;
int volatile d = 99;
decltype(ra)& e1 = a;          // int&
decltype(c) const e2 = 1;      // int const
decltype(d) volatile e3 = 1;   // int volatile

到目前为止,在本节中,我们已经学习了 decltype 指定符的工作方式。然而,它的真正目的是在模板中使用,其中函数的返回值取决于其模板参数,并且在实例化之前是未知的。为了理解这种情况,让我们从以下函数模板的示例开始,该模板返回两个值中的较小值:

template <typename T>
T minimum(T&& a, T&& b)
{
   return a < b ? a : b;
}

我们可以这样使用它:

auto m1 = minimum(1, 5);       // OK
auto m2 = minimum(18.49, 9.99);// OK
auto m3 = minimum(1, 9.99);    
                     // error, arguments of different type

前两次调用都是正确的,因为提供的参数类型相同。然而,第三次调用将产生编译器错误,因为参数类型不同。为了使其工作,我们需要将整数值转换为 double 类型。然而,有一个替代方案:我们可以编写一个函数模板,它接受两个可能不同类型的参数并返回两个中的较小值。这可以看起来如下所示:

template <typename T, typename U>
??? minimum(T&& a, U&& b)
{
   return a < b ? a : b;
}

问题是,这个函数的返回类型是什么?这可以有不同的实现方式,具体取决于你使用的标准版本。

在 C++11 中,我们可以使用带尾随返回类型的 auto 指定符,其中我们使用 decltype 指定符从表达式中推导出返回类型。这看起来如下所示:

template <typename T, typename U>
auto minimum(T&& a, U&& b) -> decltype(a < b ? a : b)
{
   return a < b ? a : b;
}

如果您使用的是 C++14 或更新的标准版本,这种语法可以简化。尾随返回类型不再是必需的。您可以像下面这样编写相同的函数:

template <typename T, typename U>
decltype(auto) minimum(T&& a, U&& b)
{
   return a < b ? a : b;
}

可以进一步简化,简单地使用 auto 作为返回类型,如下所示:

template <typename T, typename U>
auto minimum(T&& a, U&& b)
{
   return a < b ? a : b;
}

尽管在这个例子中 decltype(auto)auto 有相同的效果,但这并不总是如此。让我们考虑以下例子,其中有一个返回引用的函数,另一个函数完美转发参数调用它:

template <typename T>
T const& func(T const& ref)
{
   return ref;
}
template <typename T>
auto func_caller(T&& ref)
{
   return func(std::forward<T>(ref));
}
int a = 42;
decltype(func(a))        r1 = func(a);        // int const&
decltype(func_caller(a)) r2 = func_caller(a); // int

函数 func 返回一个引用,而 func_caller 应该完美转发到这个函数。通过使用 auto 作为返回类型,它在上面的代码片段中被推断为 int(参见变量 r2)。为了完美转发返回类型,我们必须使用 decltype(auto),如下所示:

template <typename T>
decltype(auto) func_caller(T&& ref)
{
   return func(std::forward<T>(ref));
}
int a = 42;
decltype(func(a))        r1 = func(a);        // int const&
decltype(func_caller(a)) r2 = func_caller(a); // int const&

这次,结果正如预期的那样,这个代码片段中 r1r2 的类型都是 int const&

正如我们在本节中看到的,decltype 是一个类型指定符,用于推导表达式的类型。它可以在不同的上下文中使用,但其目的是为了模板确定函数的返回类型并确保其完美转发。与 decltype 一起出现的另一个特性是 std::declval,我们将在下一节中探讨。

std::declval 类型操作符

std::declval 是一个实用类型操作函数,它包含在 <utility> 头文件中。它与我们已经看到的 std::movestd::forward 等函数属于同一类别。它所做的是非常简单的:它为其类型模板参数添加一个右值引用。这个函数的声明如下所示:

template<class T>
typename std::add_rvalue_reference<T>::type declval() noexcept;

这个函数没有定义,因此不能直接调用。它只能用于 decltypesizeoftypeidnoexcept。这些是仅在编译时评估的上下文,不在运行时评估。std::declval 的目的是帮助对没有默认构造函数或虽然有但无法访问(因为它私有或受保护)的类型进行依赖类型评估。

为了理解它是如何工作的,让我们考虑一个类模板,它将不同类型的两个值组合在一起,并且我们想要为将这些类型的两个值相加的结果创建一个类型别名。这样的类型别名应该如何定义?让我们从以下形式开始:

template <typename T, typename U>
struct composition
{
   using result_type = decltype(???);
};

我们可以使用 decltype 指示符,但我们需要提供一个表达式。我们不能说 decltype(T + U),因为这些是类型,而不是值。我们可以调用默认构造函数,因此可以使用表达式 decltype(T{} + U{})。这对于内置类型如 intdouble 来说可以正常工作,如下面的代码片段所示:

static_assert(
  std::is_same_v<double, 
                 composition<int, double>::result_type>);

它也可以适用于具有(可访问的)默认构造函数的类型。但它不能适用于没有默认构造函数的类型。以下是一个这样的类型包装器示例:

struct wrapper
{
   wrapper(int const v) : value(v){}
   int value;
   friend wrapper operator+(int const a, wrapper const& w)
   {
      return wrapper(a + w.value);
   }
   friend wrapper operator+(wrapper const& w, int const a)
   {
      return wrapper(a + w.value);
   }
};
// error, no appropriate default constructor available
static_assert(
  std::is_same_v<wrapper, 
                 composition<int,wrapper>::result_type>);

这里的解决方案是使用 std::declval()。类模板组合的实现将如下所示更改:

template <typename T, typename U>
struct composition
{
   using result_type = decltype(std::declval<T>() + 
                                std::declval<U>());
};

通过这个更改,之前显示的静态断言编译时没有任何错误。这个函数避免了使用特定值来确定表达式类型的需要。它产生一个类型为 T 的值,而不涉及默认构造函数。它返回右值引用的原因是使我们能够处理不能从函数返回的类型,例如数组和抽象类型。

之前 wrapper 类的定义中包含了两个友元运算符。当涉及到模板时,友元关系有一些特定的特性。我们将在下一节中讨论这个问题。

在模板中理解友元关系

当你定义一个类时,你可以使用 protectedprivate 访问说明符来限制对其成员数据和成员函数的访问。如果一个成员是私有的,它只能在类内部访问。如果一个成员是受保护的,它可以通过派生类使用公共或受保护的访问来访问。然而,一个类可以通过使用 friend 关键字将对其私有或受保护成员的访问权限授予其他函数或类。这些被授予特殊访问权限的函数或类被称为友元。让我们看看一个简单的例子:

struct wrapper
{   
   wrapper(int const v) :value(v) {}
private:
   int value;
   friend void print(wrapper const & w);
};
void print(wrapper const& w)
{ std::cout << w.value << '\n'; }
wrapper w{ 42 };
print(w);

wrapper 类有一个名为 value 的私有数据成员。存在一个名为 print 的自由函数,它接受一个 wrapper 类型的参数,并将包装的值打印到控制台。然而,为了能够访问它,该函数被声明为 wrapper 类的一个友元。

我们将不会关注非模板中友元关系的工作方式。你应该熟悉这个特性,以便在模板的上下文中讨论它。当涉及到模板时,事情会变得有些复杂。我们将通过几个示例来探讨这个问题。让我们从以下内容开始:

struct wrapper
{
   wrapper(int const v) :value(v) {}
private:
   int value;
   template <typename T>
   friend void print(wrapper const&);
   template <typename T>
   friend struct printer;
};   
template <typename T>
void print(wrapper const& w)
{ std::cout << w.value << '\n'; }
template <typename T>
struct printer
{
   void operator()(wrapper const& w)
   { std::cout << w.value << '\n'; }
};
wrapper w{ 42 };
print<int>(w);
print<char>(w);
printer<int>()(w);
printer<double>()(w);

print 函数现在是一个函数模板。它有一个类型模板参数,但实际上并没有在某个地方使用。这看起来可能有点奇怪,但它是一个有效的代码,我们需要通过指定模板参数来调用它。然而,它有助于我们提出一个观点:无论模板参数如何,任何 print 的模板实例化都可以访问 wrapper 类的私有成员。注意声明它为 wrapper 类友元时使用的语法:它使用了模板语法。同样适用于类模板 printer。它被声明为 wrapper 类和任何模板实例的友元,无论模板参数如何,都可以访问其私有部分。

如果我们只想限制对某些模板实例的访问怎么办?比如只对 int 类型的特殊化进行限制?那么,我们可以将这些特殊化声明为友元,如下所示:

struct wrapper;
template <typename T>
void print(wrapper const& w);
template <typename T>
struct printer;
struct wrapper
{
   wrapper(int const v) :value(v) {}
private:
   int value;
   friend void print<int>(wrapper const&);
   friend struct printer<int>;
};
template <typename T>
void print(wrapper const& w)
{ std::cout << w.value << '\n'; /* error */ }
template <>
void print<int>(wrapper const& w)
{ std::cout << w.value << '\n'; }
template <typename T>
struct printer
{
   void operator()(wrapper const& w)
   { std::cout << w.value << '\n'; /* error*/ }
};
template <>
struct printer<int>
{
   void operator()(wrapper const& w)
   { std::cout << w.value << '\n'; }
};
wrapper w{ 43 };
print<int>(w);
print<char>(w);
printer<int>()(w);
printer<double>()(w);

在此片段中,wrapper类与之前相同。对于print函数模板和printer类模板,我们都有一个主模板和针对int类型的完全特化。只有int实例被声明为wrapper类的友元。尝试在主模板中访问wrapper类的私有部分将生成编译器错误。

在这些示例中,授予其私有部分友元权限的是非模板类。但类模板也可以声明友元。让我们看看在这种情况下它是如何工作的。我们将从类模板和非模板函数的情况开始:

template <typename T>
struct wrapper
{
   wrapper(T const v) :value(v) {}
private:
   T value;
   friend void print(wrapper<int> const&);
};
void print(wrapper<int> const& w)
{ std::cout << w.value << '\n'; }
void print(wrapper<char> const& w)
{ std::cout << w.value << '\n'; /* error */ }

在此实现中,wrapper类模板将接受wrapper<int>作为参数的打印重载声明为友元。因此,在此重载函数中,我们可以访问私有数据成员value,但在任何其他重载中则不行。当友元函数或类是模板且我们只想让一个特化访问私有部分时,也会发生类似的情况。让我们看看以下片段:

template <typename T>
struct printer;
template <typename T>
struct wrapper
{
   wrapper(T const v) :value(v) {}
private:
   T value;
   friend void print<int>(wrapper<int> const&);
   friend struct printer<int>;
};
template <typename T>
void print(wrapper<T> const& w)
{ std::cout << w.value << '\n'; /* error */ }
template<>
void print(wrapper<int> const& w)
{ std::cout << w.value << '\n'; }
template <typename T>
struct printer
{
   void operator()(wrapper<T> const& w)
   { std::cout << w.value << '\n'; /* error */ }
};
template <>
struct printer<int>
{
   void operator()(wrapper<int> const& w)
   { std::cout << w.value << '\n'; }
};

wrapper类模板的实现将print函数模板和printer类模板的int特化授予了友元权限。尝试在主模板(或任何其他特化)中访问私有数据成员value将生成编译器错误。

如果意图是让wrapper类模板允许对print函数模板或printer类模板的任何实例化提供友元访问,那么实现这一点的语法如下:

template <typename T>
struct printer;
template <typename T>
struct wrapper
{
   wrapper(T const v) :value(v) {}
private:
   T value;
   template <typename U>
   friend void print(wrapper<U> const&);
   template <typename U>
   friend struct printer;
};
template <typename T>
void print(wrapper<T> const& w)
{  std::cout << w.value << '\n'; }
template <typename T>
struct printer
{
   void operator()(wrapper<T> const& w)
   {  std::cout << w.value << '\n';  }
};

注意,在声明友元时,语法是template <typename U>而不是template <typename T>。模板参数的名称U可以是任何名称,但不能是T。这会遮蔽wrapper类模板的模板参数名称,这是错误的。但请记住,使用这种语法,printprinter的任何特化都可以访问wrapper类模板任何特化的私有成员。如果你想只有满足wrapper类模板模板参数的友元特化可以访问其私有部分,那么你必须使用以下语法:

template <typename T>
struct wrapper
{
   wrapper(T const v) :value(v) {}
private:
   T value;
   friend void print<T>(wrapper<T> const&);
   friend struct printer<T>;
};

这与我们之前看到的类似,当时只有对int特化提供了访问权限,但现在是对任何匹配T的特化。

除了这些情况之外,类模板还可以将友元权限授予类型模板参数。以下示例展示了这一点:

template <typename T>
struct connection
{
   connection(std::string const& host, int const port) 
      :ConnectionString(host + ":" + std::to_string(port)) 
   {}
private:
   std::string ConnectionString;
   friend T;
};
struct executor
{
   void run()
   {
      connection<executor> c("localhost", 1234);
      std::cout << c.ConnectionString << '\n';
   }
};

connection类模板有一个名为ConnectionString的私有数据成员。类型模板参数T是类的友元。executor类使用connection<executor>实例化,这意味着executor类型是模板参数,并从与connection类的友元关系中受益,因此它可以访问私有数据成员ConnectionString

如所有这些示例所示,模板的友谊与非模板实体之间的友谊略有不同。请记住,朋友可以访问类的所有非公开成员。因此,友谊应该谨慎授予。另一方面,如果您需要授予对某些私有成员的访问权限,但不授予所有成员,这可以通过客户-律师模式实现。这种模式允许您控制对类私有部分的访问粒度。您可以在以下网址了解更多关于此模式的信息:en.wikibooks.org/wiki/More_C%2B%2B_Idioms/Friendship_and_the_Attorney-Client

摘要

在本章中,我们学习了一系列高级主题。我们首先从名称绑定和依赖名称开始,学习了如何使用typenametemplate关键字来告诉编译器我们正在引用哪种依赖名称。然后,我们学习了递归模板以及如何使用不同的方法实现递归函数的编译时版本。

我们还学习了函数模板和类模板的参数推导,以及如何通过用户定义的推导指南帮助编译器完成后者。本章的一个重要主题是转发引用以及它们如何帮助我们实现完美转发。在章节的末尾,我们学习了decltype类型说明符、std::declvalue类型实用工具,以及最后,在类模板的上下文中友谊是如何工作的。

在下一章中,我们将开始利用到目前为止积累的关于模板的知识来进行模板元编程,这基本上是在编译时评估的代码。

问题

  1. 名称查找何时执行?

  2. 什么是推导指南?

  3. 转发引用是什么?

  4. decltype做什么?

  5. std::declval做什么?

进一步阅读

第五章:第五章:类型特性和条件编译

类型特性是一种重要的元编程技术,它使我们能够在编译时检查类型的属性或对类型进行转换。类型特性本身是模板,你可以将其视为元类型。了解诸如类型的本质、其支持的操作以及其各种属性等信息对于执行模板代码的条件编译至关重要。在编写模板库时也非常有用。

在本章中,你将学习以下内容:

  • 理解和定义类型特性

  • 理解 SFINAE 及其目的

  • 使用enable_if类型特性启用 SFINAE

  • 使用constexpr if

  • 探索标准类型特性

  • 看看使用类型特性的真实世界示例

到本章结束时,你将很好地理解类型特性是什么,它们如何有用,以及 C++标准库中可用的类型特性。

我们将首先探讨类型特性是什么以及它们如何帮助我们。

理解和定义类型特性

简而言之,类型特性是包含一个常量值的类模板,该值代表我们对类型提出的问题的答案。这样一个问题的例子是:这个类型是浮点类型吗?构建提供此类类型信息类型特性的技术依赖于模板特化:我们定义了一个主模板以及一个或多个特化。

让我们看看我们如何构建一个类型特性,它可以在编译时告诉我们一个类型是否是浮点类型:

template <typename T>
struct is_floating_point
{
   static const bool value = false;
};
template <>
struct is_floating_point<float>
{
   static const bool value = true;
};
template <>
struct is_floating_point<double>
{
   static const bool value = true;
};
template <>
struct is_floating_point<long double>
{
   static const bool value = true;
};

这里有两个需要注意的地方:

  • 我们已经定义了一个主模板以及几个完整的特化,每个特化对应一个浮点类型。

  • 主模板有一个初始化为false值的static const布尔成员;完整的特化将此成员的值设置为true

构建类型特性没有比这更多的东西了。is_floating_point<T>是一个类型特性,它告诉我们一个类型是否是浮点类型。我们可以如下使用它:

int main()
{
   static_assert(is_floating_point<float>::value);
   static_assert(is_floating_point<double>::value);
   static_assert(is_floating_point<long double>::value);
   static_assert(!is_floating_point<int>::value);
   static_assert(!is_floating_point<bool>::value);
}

这证明了我们已经正确构建了类型特性。但这并不显示一个真正的用例场景。为了使这个类型特性真正有用,我们需要在编译时使用它来对它提供的信息进行一些操作。

假设我们想要构建一个对浮点值进行操作的函数。存在多种浮点类型,如floatdoublelong double。为了我们避免编写多个实现,我们将构建一个模板函数。然而,这意味着我们实际上可以将其他类型作为模板参数传递,因此我们需要一种方法来防止这种情况。一个简单的解决方案是使用我们之前看到的static_assert()语句,并在用户提供的值不是浮点值时产生错误。这可以看起来如下:

template <typename T>
void process_real_number(T const value)
{
   static_assert(is_floating_point<T>::value);
   std::cout << "processing a real number: " << value 
             << '\n';
}
int main()
{
   process_real_number(42.0);
   process_real_number(42); // error: 
                            // static assertion failed
}

这是一个非常简单的例子,但它展示了如何使用类型特性进行条件编译。除了使用 static_assert() 之外,还有其他方法,我们将在本章中探讨它们。目前,让我们看看第二个例子。

假设我们有一些类,这些类定义了向输出流写入操作的函数。这基本上是一种序列化的形式。然而,一些类通过重载的 operator<< 来支持这一功能,而另一些类则通过一个名为 write 的成员函数来实现。下面的列表展示了两个这样的类:

struct widget
{
   int         id;
   std::string name;
   std::ostream& write(std::ostream& os) const
   {
      os << id << ',' << name << '\n';
      return os;
   }
};
struct gadget
{
   int         id;
   std::string name;
   friend std::ostream& operator <<(std::ostream& os, 
                                    gadget const& o);
};
std::ostream& operator <<(std::ostream& os, 
                          gadget const& o)
{
   os << o.id << ',' << o.name << '\n';
   return os;
}

在这个例子中,widget 类包含一个名为 write 的成员函数。然而,对于 gadget 类,为了达到相同的目的,流操作符 << 被重载。我们可以使用以下代码来使用这些类:

widget w{ 1, "one" };
w.write(std::cout);
gadget g{ 2, "two" };
std::cout << g;

然而,我们的目标是为这些类定义一个函数模板,使我们能够以相同的方式对待它们。换句话说,我们不应该使用 write<< 操作符,而应该能够编写以下代码:

serialize(std::cout, w);
serialize(std::cout, g);

这引发了一些问题。首先,这样的函数模板看起来会是什么样子,其次,我们如何知道一个类型是否提供了 write 方法或重载了 << 操作符?第二个问题的答案是类型特性。我们可以在编译时构建一个类型特性来帮助我们回答这个问题。这样的类型特性可能看起来如下所示:

template <typename T>
struct uses_write
{
   static constexpr bool value = false;
};
template <>
struct uses_write<widget>
{
   static constexpr bool value = true;
};

这与我们之前定义的类型特性非常相似。uses_write 告诉我们一个类型是否定义了 write 成员函数。主要模板将名为 value 的数据成员设置为 false,但 widget 类的全特化将此设置为 true。为了避免使用冗长的语法 uses_write<T>::value,我们还可以定义一个变量模板,将语法简化为 uses_write_v<T> 的形式。这个变量模板看起来如下所示:

template <typename T>
inline constexpr bool uses_write_v = uses_write<T>::value;

为了使练习简单,我们假设那些没有提供 write 成员函数重载的类型会使用输出流操作符。实际上,情况可能并非如此,但为了简化,我们将基于这个假设进行构建。

定义提供统一序列化 API 的函数模板 serialize 的下一步是定义更多的类模板。然而,这些模板将遵循相同的路径——一个主要模板提供一种序列化形式,一个全特化提供不同的序列化形式。以下是相应的代码:

template <bool>
struct serializer
{
   template <typename T>
   static void serialize(std::ostream& os, T const& value)
   {
      os << value;
   }
};
template<>
struct serializer<true>
{
   template <typename T>
   static void serialize(std::ostream& os, T const& value)
   {
      value.write(os);
   }
};

serializer类模板有一个单独的模板参数,它是一个非类型模板参数。它也是一个匿名模板参数,因为我们没有在实现中使用它。这个类模板包含一个成员函数。实际上,它是一个具有单个类型模板参数的成员函数模板。这个参数定义了我们将要序列化的值的类型。主模板使用<<运算符将值输出到提供的流中。另一方面,serializer类模板的完全特化使用成员函数write来完成同样的任务。请注意,我们完全特化了serializer类模板,而不是serialize成员函数模板。

现在剩下的唯一事情是实现所需的自由函数serialize。它的实现将基于serializer<T>::serialize函数。让我们看看它是如何实现的:

template <typename T>
void serialize(std::ostream& os, T const& value)
{
   serializer<uses_write_v<T>>::serialize(os, value);
}

这个函数模板的签名与serializer类模板中的serialize成员函数的签名相同。在主模板和完全特化之间的选择是通过变量模板uses_write_v来完成的,它提供了一个方便的方式来访问uses_write类型特质的值数据成员。

在这些示例中,我们看到了如何实现类型特质并在编译时使用它们提供的信息来对类型施加限制或选择一个实现或另一个实现。具有类似目的的另一种元编程技术称为SFINAE,我们将在下一章中介绍。

探索 SFINAE 及其目的

当我们编写模板时,我们有时需要限制模板参数。例如,我们有一个函数模板,它应该适用于任何数值类型,因此是整数和浮点数,但不应该与任何其他类型一起工作。或者我们可能有一个类模板,它应该只接受平凡类型作为参数。

也有可能存在重载的函数模板,每个都应该只与某些类型一起工作。例如,一个重载应该适用于整数类型,另一个只适用于浮点数类型。有几种不同的方法可以实现这个目标,我们将在本章和下一章中探讨它们。

然而,类型特质以某种方式涉及到所有这些。本章将要讨论的第一个特性是称为 SFINAE 的功能。另一种优于 SFINAE 的方法是由概念表示的,我们将在下一章中讨论。

SFINAE代表Substitution Failure Is Not An Error。当编译器遇到函数模板的使用时,它会替换参数以实例化模板。如果在此处发生错误,它不被视为错误代码,而只是推导失败。函数将从重载集中移除,而不是引发错误。只有在重载集中没有匹配项时才会发生错误。

没有具体的例子,很难真正理解 SFINAE。因此,我们将通过几个例子来解释这个概念。

每个标准容器,如std::vectorstd::arraystd::map,不仅具有使我们能够访问其元素的迭代器,而且还可以修改容器(例如,在迭代器指向的元素之后插入)。因此,这些容器有成员函数来返回容器的第一个和最后一个元素之后的迭代器。这些方法被称为beginend

除了cbegincendrbeginrendcrbegincrend等其他方法之外,但这些超出了本主题的目的。在 C++11 中,还有自由函数std::beginstd::end,它们执行相同的操作。然而,这些函数不仅与标准容器一起工作,也与数组一起工作。这些函数的一个好处是使数组能够使用基于范围的for循环。问题是这个非成员函数如何实现以同时与容器和数组一起工作?当然,我们需要两个函数模板的重载。一个可能的实现如下:

template <typename T>
auto begin(T& c) { return c.begin(); }   // [1]
template <typename T, size_t N>
T* begin(T(&arr)[N]) {return arr; }      // [2]

第一个重载调用成员函数begin并返回值。因此,这个重载仅限于具有成员函数begin的类型;否则,将发生编译器错误。第二个重载简单地返回数组的第一个元素的指针。这仅限于数组类型;其他任何类型都会产生编译器错误。我们可以如下使用这些重载:

std::array<int, 5> arr1{ 1,2,3,4,5 };
std::cout << *begin(arr1) << '\n';       // [3] prints 1
int arr2[]{ 5,4,3,2,1 };
std::cout << *begin(arr2) << '\n';       // [4] prints 5

如果你编译这段代码,不会出现错误,甚至没有警告。原因是 SFINAE。当解析对begin(arr1)的调用时,将std::array<int, 5>替换到第一个重载(在[1]处)成功,但第二个(在[2]处)的替换失败。而不是在这个点发出错误,编译器只是忽略它,因此它构建了一个只有一个实例化的重载集,因此它可以成功找到调用的匹配。同样,当解析对begin(arr2)的调用时,第一个重载对int[5]的替换失败并被忽略,但第二个成功并被添加到重载集中,最终找到对调用的良好匹配。因此,这两个调用都可以成功执行。如果两个重载中的任何一个不存在,begin(arr1)begin(arr2)将无法匹配函数模板,并发生编译器错误。

SFINAE 仅在函数所谓的直接上下文中适用。直接上下文基本上是模板声明(包括模板参数列表、函数返回类型和函数参数列表)。因此,它不适用于函数体。让我们考虑以下例子:

template <typename T>
void increment(T& val) { val++; }
int a = 42;
increment(a);  // OK
std::string s{ "42" };
increment(s);  // error

increment函数模板的直接上下文中对类型T没有限制。然而,在函数体中,参数val使用后缀operator++进行递增。这意味着,对于任何未实现后缀operator++的类型,用T替换都是失败的。然而,这种失败是一个错误,并且编译器不会忽略它。

C++标准(许可使用链接:[creativecommons.org/licenses/by-sa/3.0/](http://creativecommons.org/licenses/by-sa/3.0/))定义了被认为是 SFINAE 错误的错误列表(在段落§13.10.2模板参数推导C++20标准版本)。这些 SFINAE 错误包括以下尝试:

  • 创建一个void类型的数组、一个引用类型的数组、一个函数类型的数组、一个负大小的数组、一个大小为零的数组和一个非整型大小的数组

  • 在作用域解析运算符::的左侧使用不是类或枚举的类型(例如,在T::value_type中,其中T是一个数值类型)

  • 创建一个指向引用的指针

  • 创建一个指向void的引用

  • 创建一个指向T成员的指针,其中T不是一个类类型

  • 在类型不包含该成员的情况下使用类型成员

  • 在需要类型而成员不是类型的情况下使用类型成员

  • 在需要模板而成员不是模板的情况下使用类型成员

  • 在需要非类型成员而成员不是非类型的情况下使用类型成员

  • 创建一个具有void类型参数的函数类型

  • 创建一个返回数组类型或另一个函数类型的函数类型

  • 在模板参数表达式或函数声明中执行无效的类型转换

  • 向非类型模板参数提供一个无效的类型

  • 实例化包含多个不同长度包的包展开

此列表中的最后一个错误是在 C++11 中与变长模板一起引入的。其他错误是在 C++11 之前定义的。我们不会继续举例说明所有这些错误,但我们可以看看几个更多例子。第一个例子是尝试创建一个大小为零的数组。假设我们想要有两个函数模板重载,一个处理偶数大小的数组,另一个处理奇数大小的数组。这个问题的解决方案如下:

template <typename T, size_t N>
void handle(T(&arr)[N], char(*)[N % 2 == 0] = 0)
{
   std::cout << "handle even array\n";
}
template <typename T, size_t N>
void handle(T(&arr)[N], char(*)[N % 2 == 1] = 0)
{
   std::cout << "handle odd array\n";
}
int arr1[]{ 1,2,3,4,5 };
handle(arr1);
int arr2[]{ 1,2,3,4 };
handle(arr2);

模板参数和第一个函数参数与我们看到的数组 begin 重载类似。然而,这些 handle 的重载有一个带有默认值 0 的第二个匿名参数。此参数的类型是指向类型为 char 的数组的指针,以及由表达式 N%2==0N%2==1 指定的大小。对于每个可能的数组,这两个中的一个是 true,另一个是 false。因此,第二个参数是 char(*)[1]char(*)[0],后者是一个 SFINAE 错误(尝试创建大小为零的数组)。因此,我们能够调用其他重载之一而不生成编译器错误,这要归功于 SFINAE。

在本节中,我们将要查看的最后一个示例将展示尝试使用一个不存在的类的成员的 SFINAE。让我们从以下代码片段开始:

template <typename T>
struct foo
{
   using foo_type = T;
};
template <typename T>
struct bar
{
   using bar_type = T;
};
struct int_foo : foo<int> {};
struct int_bar : bar<int> {};

在这里,我们有两个类,foo,它有一个名为 foo_type 的成员类型,以及 bar,它有一个名为 bar_type 的成员类型。还有从这两个类派生出来的类。目标是编写两个函数模板,一个用于处理 foo 类的层次结构,另一个用于处理 bar 类的层次结构。一个可能的实现如下:

template <typename T>
decltype(typename T::foo_type(), void()) handle(T const& v)
{
   std::cout << "handle a foo\n";
}
template <typename T>
decltype(typename T::bar_type(), void()) handle(T const& v)
{
   std::cout << "handle a bar\n";
}

两个重载都有一个模板参数和一个类型为 T const& 的函数参数。它们还返回相同的类型,该类型是 void。表达式 decltype(typename T::foo_type(), void()) 可能需要一点考虑才能更好地理解。我们已经在 第四章 中讨论了 decltype高级模板概念。记住,这是一个类型指定器,用于推导表达式的类型。我们使用逗号运算符,因此第一个参数将被评估然后丢弃,所以 decltype 将只从 void() 推导类型,推导出的类型是 void。然而,参数 typename T::foo_type()typename T::bar_type() 确实使用了内部类型,并且这仅存在于 foobar 中。这就是 SFINAE 表现出来的地方,如下面的代码片段所示:

int_foo fi;
int_bar bi;
int x = 0;
handle(fi); // OK
handle(bi); // OK
handle(x);  // error

使用 int_foo 值调用 handle 将匹配第一个重载,而第二个由于替换失败而被丢弃。同样,使用 int_bar 值调用 handle 将匹配第二个重载,而第一个由于替换失败而被丢弃。然而,使用 int 调用 handle 将导致两个重载都发生替换失败,因此用于替换 int 的最终重载集将为空,这意味着没有匹配的调用。因此,将发生编译器错误。

SFINAE 不是实现条件编译的最佳方式。然而,在现代 C++ 中,它可能最好与一个名为 enable_if 的类型特性一起使用。这就是我们接下来要讨论的。

使用 enable_if 类型特性启用 SFINAE

C++标准库是一系列子库的集合。其中之一是类型支持库。这个库定义了诸如std::size_tstd::nullptr_tstd::byte等类型,以及使用std::type_info等类提供的运行时类型识别支持,还包括一系列类型特性。类型特性分为两类:

  • 允许我们在编译时查询类型属性的类型特性。

  • 允许我们在编译时执行类型转换的类型特性(例如添加或删除const限定符,或添加或删除指针或引用从类型)。这些类型特性也称为元函数

第二类类型特性之一是std::enable_if。它用于启用 SFINAE 并从函数的重载集中删除候选者。一个可能的实现如下:

template<bool B, typename T = void>
struct enable_if {};
template<typename T>
struct enable_if<true, T> { using type = T; };

有一个主要模板,包含两个模板参数,一个布尔非类型模板参数和一个默认参数为void的类型参数。这个主要模板是一个空类。还有一个针对非类型模板参数true值的局部特化。然而,这仅仅定义了一个简单地称为type的成员类型,它是一个模板参数T的别名模板。

enable_if元函数旨在与布尔表达式一起使用。当这个布尔表达式评估为true时,它定义一个名为type的成员类型。如果布尔表达式为false,则不定义这个成员类型。让我们看看它是如何工作的。

记得本章开头“理解和定义类型特性”部分中的例子,其中我们定义了提供write方法将内容写入输出流的类,以及为相同目的重载了operator<<运算符的类。在那个部分,我们定义了一个名为uses_write的类型特性,并编写了一个serialize函数模板,允许我们以统一的方式序列化这两种类型的对象(widgetgadget)。然而,实现相当复杂。使用enable_if,我们可以以简单的方式实现该函数。一个可能的实现如下所示:

template <typename T, 
          typename std::enable_if<
             uses_write_v<T>>::type* = nullptr>
void serialize(std::ostream& os, T const& value)
{
   value.write(os);
}
template <typename T,
          typename std::enable_if<
             !uses_write_v<T>>::type*=nullptr>
void serialize(std::ostream& os, T const& value)
{
   os << value;
}

在这个实现中,有两个重载的函数模板。它们都有两个模板参数。第一个参数是一个类型模板参数,称为T。第二个是一个具有默认值nullptr的匿名非类型模板参数的指针类型。我们使用enable_if来定义名为type的成员,只有当uses_write_v变量评估为true时。因此,对于具有成员函数write的类,第一个重载的替换成功,但第二个重载失败,因为typename * = nullptr不是一个有效的参数。对于重载了operator<<运算符的类,我们有相反的情况。

enable_if元函数可以在以下几种场景中使用:

  • 定义一个具有默认参数的模板参数,这是我们之前看到的

  • 定义具有默认参数的函数参数

  • 指定函数的返回类型

因此,我之前提到,提供的 serialize 重载的实现只是众多可能性中的一种。下面展示的是一种类似的实现,它使用 enable_if 来定义具有默认参数的函数参数:

template <typename T>
void serialize(
   std::ostream& os, T const& value, 
   typename std::enable_if<
               uses_write_v<T>>::type* = nullptr)
{
   value.write(os);
}
template <typename T>
void serialize(
   std::ostream& os, T const& value,
   typename std::enable_if<
               !uses_write_v<T>>::type* = nullptr)
{
   os << value;
}

你会注意到,我们基本上将参数从模板参数列表移动到了函数参数列表中。没有其他变化,用法相同,如下所示:

widget w{ 1, "one" };
gadget g{ 2, "two" };
serialize(std::cout, w);
serialize(std::cout, g);

第三个选择是使用 enable_if 来包装函数的返回类型。这种实现方式与之前略有不同(默认参数对于返回类型来说没有意义)。以下是它的样子:

template <typename T>
typename std::enable_if<uses_write_v<T>>::type serialize(
   std::ostream& os, T const& value)
{
   value.write(os);
}
template <typename T>
typename std::enable_if<!uses_write_v<T>>::type serialize(
   std::ostream& os, T const& value)
{
   os << value;
}

在这个实现中,如果 uses_write_v<T>true,则定义返回类型。否则,会发生替换失败,并执行 SFINAE。

尽管在这些所有示例中,enable_if 类型特性被用来在函数模板的重载解析期间启用 SFINAE,但这种类型特性也可以用来限制类模板的实例化。以下示例中,我们有一个名为 integral_wrapper 的类,它应该仅用整型类型实例化,还有一个名为 floating_wrapper 的类,它应该仅用浮点型类型实例化:

template <
   typename T,
   typename=typenamestd::enable_if_t<
                        std::is_integral_v<T>>>
struct integral_wrapper
{
   T value;
};
template <
   typename T,
   typename=typename std::enable_if_t<
                        std::is_floating_point_v<T>>>
struct floating_wrapper
{
   T value;
};

这两个类模板都有两个类型模板参数。第一个被称为 T,但第二个是匿名的,并有一个默认参数。这个参数的值是否定义,是通过 enable_if 类型特性根据布尔表达式的值来确定的。

在这个实现中,我们可以看到:

  • 一个名为 std::enable_if_t 的别名模板,这是一种方便访问 std::enable_if<B, T>::type 成员类型的途径。它定义如下:

    template <bool B, typename T = void>
    using enable_if_t = typename enable_if<B,T>::type;
    
  • 两个变量模板 std::is_integral_vstd::is_floating_point_v,它们是方便访问数据成员 std::is_integral<T>::valuestd::is_floating_point<T>::value 的途径。std::is_integralstd::is_floating_point 类是标准类型特性,分别检查一个类型是否是整型或浮点型。

之前展示的两个包装类模板可以这样使用:

integral_wrapper w1{ 42 };   // OK
integral_wrapper w2{ 42.0 }; // error
integral_wrapper w3{ "42" }; // error
floating_wrapper w4{ 42 };   // error
floating_wrapper w5{ 42.0 }; // OK
floating_wrapper w6{ "42" }; // error

只有这两种实例化是有效的,w1 因为 integral_wrapper 是用 int 类型实例化的,以及 w5 因为 floating_wrapper 是用 double 类型实例化的。所有其他实例化都会生成编译器错误。

应该指出的是,这些代码示例仅在 C++20 提供的 integral_wrapperfloating_wrapper 定义下工作。对于标准的前版本,即使是 w1w5 的定义也会生成编译器错误,因为编译器无法推断模板参数。为了使它们工作,我们必须将类模板更改为包含构造函数,如下所示:

template <
   typename T,
   typename=typenamestd::enable_if_t<
                        std::is_integral_v<T>>>
struct integral_wrapper
{
   T value;
   integral_wrapper(T v) : value(v) {}
};
template <
   typename T,
   typename=typename std::enable_if_t<
                        std::is_floating_point_v<T>>>
struct floating_wrapper
{
   T value;
   floating_wrapper(T v) : value(v) {}
};

虽然 enable_if 有助于以更简单、更易读的代码实现 SFINAE,但它仍然相当复杂。幸运的是,在 constexpr if 语句中。让我们接下来探索这个替代方案。

使用 constexpr if

C++17 的一个特性使得 SFINAE 变得更加容易。它被称为 constexpr if,它是 if 语句的编译时版本。它有助于将复杂的模板代码替换为更简单的版本。让我们先看看 C++17 对 serialize 函数的实现,这个函数可以统一序列化小工具和设备:

template <typename T>
void serialize(std::ostream& os, T const& value)
{
   if constexpr (uses_write_v<T>)
      value.write(os);
   else
      os << value;
}

constexpr if 的语法是 if constexpr(condition)。条件必须是一个编译时表达式。在评估表达式时不会执行短路逻辑。这意味着如果表达式的形式是 a && ba || b,那么 ab 都必须是正确形成的。

constexpr if 允许我们在编译时根据表达式的值丢弃一个分支。在我们的例子中,当 uses_write_v 变量是 true 时,else 分支被丢弃,并保留第一个分支的主体。否则,情况相反。因此,我们最终得到以下针对 widgetgadget 类的特殊化:

template<>
void serialize<widget>(std::ostream & os,
                      widget const & value)
{
   if constexpr(true)
   {
      value.write(os);
   }
}
template<>
void serialize<gadget>(std::ostream & os,
                       gadget const & value)
{
   if constexpr(false) 
   {
   } 
   else
   {
      os << value;
   } 
}

当然,这段代码很可能会被编译器进一步简化。因此,最终这些特化可能看起来就像下面这样:

template<>
void serialize<widget>(std::ostream & os,
                       widget const & value)
{
   value.write(os);
}
template<>
void serialize<gadget>(std::ostream & os,
                       gadget const & value)
{
   os << value;
}

最终结果是和我们在 SFINAE 和 enable_if 中实现的结果相同,但这里我们编写的实际代码更简单,更容易理解。

constexpr if 是简化代码的强大工具,我们实际上在 第三章可变参数模板参数包 段落中,当我们实现一个名为 sum 的函数时,就看到了它。这在这里再次展示:

template <typename T, typename... Args>
T sum(T a, Args... args)
{
   if constexpr (sizeof...(args) == 0)
      return a;
   else
      return a + sum(args...);
}

在这个例子中,constexpr if 帮助我们避免有两个重载,一个用于通用情况,另一个用于结束递归。这本书中已经提出的一个例子,其中 constexpr if 可以简化实现的是来自 第四章高级模板概念探索模板递归 部分的 factorial 函数模板。该函数看起来如下:

template <unsigned int n>
constexpr unsigned int factorial()
{
   return n * factorial<n - 1>();
}
template<> 
constexpr unsigned int factorial<1>() { return 1; }
template<> 
constexpr unsigned int factorial<0>() { return 1; }

使用 constexpr if,我们可以用单个模板替换所有这些,让编译器负责提供正确的特化。这个函数的 C++17 版本可能看起来如下所示:

template <unsigned int n>
constexpr unsigned int factorial()
{
   if constexpr (n > 1)
      return n * factorial<n - 1>();
   else
      return 1;
}

constexpr if 语句在许多情况下都很有用。本节最后给出的示例是一个名为 are_equal 的函数模板,它确定提供的两个参数是否相等。通常,你会认为使用 operator== 就足以确定两个值是否相等。这在大多数情况下是正确的,除了浮点值。因为只有一些浮点数可以无精度损失地存储(如 1、1.25、1.5 以及任何分数部分是 2 的逆幂精确和的数),在比较浮点数时我们需要特别注意。通常,这是通过确保两个浮点值之间的差小于某个阈值来解决的。因此,此类函数的一个可能的实现如下:

template <typename T>
bool are_equal(T const& a, T const& b)
{
   if constexpr (std::is_floating_point_v<T>)
      return std::abs(a - b) < 0.001;
   else
      return a == b;
}

T 类型是浮点类型时,我们比较两个数字差值的绝对值与所选阈值。否则,我们回退到使用 operator==。这使得我们不仅可以在算术类型上使用此函数,还可以在重载了相等操作符的任何其他类型上使用。

are_equal(1, 1);                                   // OK
are_equal(1.999998, 1.999997);                     // OK
are_equal(std::string{ "1" }, std::string{ "1" }); // OK
are_equal(widget{ 1, "one" }, widget{ 1, "two" }); // error

我们能够使用类型 intdoublestd::string 作为参数调用 are_equal 函数模板。然而,尝试使用 widget 类型的值进行相同的操作将触发编译器错误,因为 == 操作符没有为此类型重载。

到目前为止,在本章中,我们已经了解了类型特性是什么以及执行条件编译的不同方法。我们还看到了标准库中的一些类型特性。在本章的第二部分,我们将探讨标准在类型特性方面能提供什么。

探索标准类型特性

标准库提供了一系列类型特性,用于查询类型的属性以及在对类型进行转换。这些类型特性作为类型支持库的一部分,在 <type_traits> 头文件中可用。类型特性包括以下几类:

  • 查询类型类别(基本或复合)

  • 查询类型属性

  • 查询支持的操作

  • 查询类型关系

  • 修改 cv 说明符、引用、指针或符号

  • 杂项转换

虽然查看每个单独的类型特性超出了本书的范围,但我们将探索所有这些类别,以了解它们包含的内容。在以下小节中,我们将列出构成每个这些类别的类型特性(或其中大部分)。这些列表以及每个类型特性的详细信息可以在 C++ 标准中找到(请参阅章节末尾的 进一步阅读 部分,以获取免费可用的草案版本链接)或 cppreference.com 网站上的 en.cppreference.com/w/cpp/header/type_traits(许可使用链接:creativecommons.org/licenses/by-sa/3.0/)。

我们将首先从查询类型类别的类型特性开始。

查询类型类别

到目前为止,在这本书中,我们已经使用了几个类型特性,例如 std::is_integralstd::is_floating_pointstd::is_arithmetic。这些只是用于查询基本和复合类型类别的一些标准类型特性。以下表格列出了此类类型特性的完整集合:

表 5.1表 5.1![表 5.1表 5.1

表 5.1

所有这些类型特性都在 C++11 中可用。从 C++17 开始,对于每个类型特性,都有一个变量模板可用于简化对名为 value 的布尔成员的访问。对于名为 is_abc 的类型特性,存在一个名为 is_abc_v 的变量模板。这对于所有具有名为 value 的布尔成员的类型特性都适用。这些变量的定义非常简单。下面的代码片段显示了 is_arithmentic_v 变量模板的定义:

template< class T >
inline constexpr bool is_arithmetic_v =
   is_arithmetic<T>::value;

这里是使用一些这些类型特性的示例:

template <typename T>
std::string as_string(T value)
{
   if constexpr (std::is_null_pointer_v<T>)
      return "null";
   else if constexpr (std::is_arithmetic_v<T>)
      return std::to_string(value);
   else
      static_assert(always_false<T>);
}
std::cout << as_string(nullptr) << '\n'; // prints null
std::cout << as_string(true) << '\n';    // prints 1
std::cout << as_string('a') << '\n';     // prints a
std::cout << as_string(42) << '\n';      // prints 42
std::cout << as_string(42.0) << '\n';   // prints 42.000000
std::cout << as_string("42") << '\n';    // error

函数模板 as_string 返回一个包含作为参数传递的值的字符串。它仅适用于算术类型,对于 nullptr_t,它返回值 "null"

你一定注意到了语句 static_assert(always_false<T>),并想知道这个 always_false<T> 表达式实际上是什么。它是一个评估为 falsebool 类型的变量模板。其定义与以下内容一样简单:

template<class T> 
constexpr bool always_false = std::false_type::value;

这是因为语句 static_assert(false) 会使程序无效。原因是其条件不会依赖于模板参数,而是评估为 false。当无法为模板内的 constexpr if 语句的子语句生成有效特化时,程序是无效的(且不需要诊断)。为了避免这种情况,static_assert 语句的条件必须依赖于模板参数。使用 static_assert(always_false<T>),编译器不知道这会评估为 true 还是 false,直到模板实例化。

我们将要探索的类型特性的下一类别允许我们查询类型的属性。

查询类型属性

能够查询类型属性的类型特性如下:

表 5.2表 5.2表 5.2

表 5.2

尽管大多数这些内容可能很容易理解,但有两个看起来似乎是相同的。这两个是 is_trivialis_trivially_copyable。这两个对于标量类型或标量类型的数组都是真的。它们也对于平凡可复制的类或此类数组的数组是真的,但 is_trivial 只对具有平凡默认构造函数的可复制类是真的。

根据 C++ 20 标准中的段落 §11.4.4.1,如果一个默认构造函数不是用户提供的,并且类没有虚成员函数、没有虚基类、没有具有默认初始化器的非静态成员,它的每个直接基类都有一个平凡的默认构造函数,以及类类型的每个非静态成员也都有一个平凡的默认构造函数,那么这个默认构造函数是平凡的。为了更好地理解这一点,让我们看看下面的例子:

struct foo
{
   int a;
};
struct bar
{
   int a = 0;
};
struct tar
{
   int a = 0;
   tar() : a(0) {}
};
std::cout << std::is_trivial_v<foo> << '\n'; // true
std::cout << std::is_trivial_v<bar> << '\n'; // false
std::cout << std::is_trivial_v<tar> << '\n'; // false
std::cout << std::is_trivially_copyable_v<foo> 
          << '\n';                                 // true
std::cout << std::is_trivially_copyable_v<bar> 
          << '\n';                                 // true
std::cout << std::is_trivially_copyable_v<tar> 
          << '\n';                                 // true

在这个例子中,有三个类似的类。这三个类,foobartar,都是平凡可复制的。然而,只有 foo 类是平凡类,因为它有一个平凡的默认构造函数。bar 类有一个具有默认初始化器的非静态成员,而 tar 类有一个用户定义的构造函数,这使得它们不是平凡的。

除了平凡可复制性之外,我们还可以通过其他类型特性查询其他操作。我们将在下一节中看到这些。

查询支持的操作

以下一系列类型特性帮助我们查询支持的操作:

表 5.3表 5.3

表 5.3

除了在 C++17 中引入的最后一个子集之外,其他都是在 C++11 中可用的。这些类型特性的每一种都有多个变体,包括用于检查操作是否平凡或声明为非抛出异常的 noexcept 限定符。

现在我们来看一下允许我们查询类型之间关系的类型特性。

查询类型关系

在这个类别中,我们可以找到几个类型特性,这些特性有助于查询类型之间的关系。这些类型特性如下:

表 5.4

表 5.4

在这些类型特性中,可能最常用的是 std::is_same。这个类型特性在确定两个类型是否相同时非常有用。请注意,这个类型特性考虑了 constvolatile 限定符;因此,例如,intint const 不是同一类型。

我们可以使用这个类型特性来扩展之前展示的 as_string 函数的实现。记住,如果你用 truefalse 作为参数调用它,它会打印 10,而不是 true/false。我们可以添加一个对 bool 类型的显式检查,并返回包含这两个值之一的字符串,如下所示:

template <typename T>
std::string as_string(T value)
{
   if constexpr (std::is_null_pointer_v<T>)
      return "null";
   else if constexpr (std::is_same_v<T, bool>)
      return value ? "true" : "false";
   else if constexpr (std::is_arithmetic_v<T>)
      return std::to_string(value);
   else
      static_assert(always_false<T>);
}
std::cout << as_string(true) << '\n';    // prints true
std::cout << as_string(false) << '\n';   // prints false

到目前为止所看到的所有类型特性都是用来查询关于类型的一些信息的。在接下来的几节中,我们将看到对类型进行修改的类型特性。

修改 cv-指定符、引用、指针或符号

执行类型转换的类型特性也被称为元函数。这些类型特性提供了一个成员类型(typedef),称为 type,它表示转换后的类型。这一类类型特性包括以下内容:

表 5.5表 5.5

表 5.5

除了在 C++20 中添加的 remove_cvref 之外,表中列出的所有其他类型特性在 C++11 中都是可用的。这并不是标准库中所有的元函数。更多内容将在下一节中列出。

其他转换

除了之前列出的元函数之外,还有其他执行类型转换的类型特性。其中最重要的列在下面的表中:

表 5.6表 5.6

表 5.6

从这个列表中,我们已经讨论了 enable_if。这里还有一些其他值得举例的类型特性。让我们首先看看 std::decay,为此,让我们考虑以下对 as_string 函数的略微修改后的实现:

template <typename T>
std::string as_string(T&& value)
{
   if constexpr (std::is_null_pointer_v<T>)
      return "null";
   else if constexpr (std::is_same_v<T, bool>)
      return value ? "true" : "false";
   else if constexpr (std::is_arithmetic_v<T>)
      return std::to_string(value);
   else
      static_assert(always_false<T>);
}

唯一的改变是我们传递参数给函数的方式。我们不是按值传递,而是按右值引用传递。如果你还记得从 第四章高级模板概念,这是一个转发引用。我们仍然可以调用传递右值(如字面量)的函数,但传递左值将触发编译器错误:

std::cout << as_string(true) << '\n';  // OK
std::cout << as_string(42) << '\n';    // OK
bool f = true;
std::cout << as_string(f) << '\n';     // error
int n = 42;
std::cout << as_string(n) << '\n';     // error

最后两次调用将触发 static_assert 语句失败。实际类型模板参数是 bool&int&。因此 std::is_same<bool, bool&> 将初始化 value 成员为 false。同样,std::is_arithmetic<int&> 也会这样做。为了评估这些类型,我们需要忽略引用以及 constvolatile 修饰符。帮助我们这样做的是 std::decay 类型特性,它执行了之前表中描述的几个转换。其概念实现如下:

template <typename T>
struct decay
{
private:
    using U = typename std::remove_reference_t<T>;
public:
    using type = typename std::conditional_t< 
        std::is_array_v<U>,
        typename std::remove_extent_t<U>*,
        typename std::conditional_t< 
            std::is_function<U>::value,
            typename std::add_pointer_t<U>,
            typename std::remove_cv_t<U>
        >
    >;
};

从这个片段中,我们可以看到 std::decay 是通过其他元函数实现的,包括 std::conditional,这是根据编译时表达式在一种类型或另一种类型之间进行选择的关键。实际上,这个类型特性被多次使用,这是如果你需要根据多个条件进行选择时可以做的事情。

通过 std::decay 的帮助,我们可以修改 as_string 函数的实现,去除引用和 cv-限定符:

template <typename T>
std::string as_string(T&& value)
{
   using value_type = std::decay_t<T>;
   if constexpr (std::is_null_pointer_v<value_type>)
      return "null";
   else if constexpr (std::is_same_v<value_type, bool>)
      return value ? "true" : "false";
   else if constexpr (std::is_arithmetic_v<value_type>)
      return std::to_string(value);
   else
      static_assert(always_false<T>);
}

通过如上所示更改实现,我们使之前调用 as_string 时未能编译的调用不再出现任何错误。

std::decay 的实现中,我们看到了 std::conditional 的重复使用。这是一个相对容易使用且有助于简化许多实现的元函数。在 第二章模板基础 部分,定义别名模板 小节中,我们看到了一个构建名为 list_t 的列表类型的例子。这个类型有一个成员别名模板 type,它根据列表的大小是别名模板类型 T(如果列表大小为 1)还是 std::vector<T>(如果更大)。让我们再次看看这个片段:

template <typename T, size_t S>
struct list
{
   using type = std::vector<T>;
};
template <typename T>
struct list<T, 1>
{
   using type = T;
};
template <typename T, size_t S>
using list_t = typename list<T, S>::type;

通过以下方式使用 std::conditional 可以大大简化这个实现:

template <typename T, size_t S>
using list_t = 
   typename std::conditional<S == 
                 1, T, std::vector<T>>::type;

没有必要依赖类模板特化来定义这样的列表类型。整个解决方案可以简化为定义一个别名模板。我们可以通过一些 static_assert 语句来验证它是否按预期工作,如下所示:

static_assert(std::is_same_v<list_t<int, 1>, int>);
static_assert(std::is_same_v<list_t<int, 2>,
                             std::vector<int>>);

展示每个标准类型特性的用法超出了本书的范围。然而,本章的下一节提供了需要使用几个标准类型特性的更复杂示例。

看看使用类型特性的真实世界示例

在本章的前一节中,我们已经探讨了标准库提供的各种类型特性。为每个类型特性寻找示例是困难和不必要的。然而,展示一些可以使用多个类型特性解决问题的示例是值得的。我们将在下一节中这样做。

实现复制算法

我们将要查看的第一个示例问题是 std::copy 标准算法(来自 <algorithm> 头文件)的一个可能的实现。记住,我们下面将要看到的是实际实现的一个可能版本,它有助于我们了解类型特性的用法。这个算法的签名如下:

template <typename InputIt, typename OutputIt>
constexpr OutputIt copy(InputIt first, InputIt last,
                        OutputIt d_first);

作为备注,这个函数仅在 C++20 中是 constexpr,但我们可以在这个上下文中讨论它。它的作用是将范围 [first, last) 中的所有元素复制到以 d_first 开始的另一个范围中。还有一个重载版本,std::copy_if,它复制所有匹配谓词的元素,但这些对我们示例不重要。这个函数的直接实现如下:

template <typename InputIt, typename OutputIt>
constexpr OutputIt copy(InputIt first, InputIt last,
                        OutputIt d_first)
{
   while (first != last)
   {
      *d_first++ = *first++;
   }
   return d_first;
}

然而,在某些情况下,这个实现可以通过简单地复制内存来优化。但是,为了达到这个目的,必须满足一些条件:

  • 两个迭代器类型,InputItOutputIt,必须是指针。

  • 两个模板参数,InputItOutputIt,必须指向相同的类型(忽略 cv-限定符)。

  • InputIt指向的类型必须有一个平凡的复制赋值运算符。

我们可以使用以下标准类型特性来检查这些条件:

  • std::is_same(以及std::is_same_v变量)用于检查两个类型是否相同。

  • std::is_pointer(以及std::is_pointer_v变量)用于检查一个类型是否为指针类型。

  • std::is_trivially_copy_assignable(以及std::is_trivially_copy_assignable_v变量)用于检查一个类型是否具有平凡的复制赋值运算符。

  • std::remove_cv(以及std::remove_cv_t别名模板)用于从一个类型中移除 cv 限定符。

让我们看看我们如何实现这一点。首先,我们需要一个带有泛型实现的原始模板,然后是一个针对指针类型的优化实现。我们可以使用类模板和成员函数模板来实现,如下所示:

namespace detail
{
   template <bool b>
   struct copy_fn
   {
      template<typename InputIt, typename OutputIt>
      constexpr static OutputIt copy(InputIt first, 
                                     InputIt last, 
                                     OutputIt d_first)
      {
         while (first != last)
         {
            *d_first++ = *first++;
         }
         return d_first;
      }
   };
   template <>
   struct copy_fn<true>
   {
      template<typename InputIt, typename OutputIt>
      constexpr static OutputIt* copy(
         InputIt* first, InputIt* last,
         OutputIt* d_first)
      {
         std::memmove(d_first, first, 
                      (last - first) * sizeof(InputIt));
         return d_first + (last - first);
      }
   };
}

要在源和目标之间复制内存,我们在这里使用std::memmove,即使对象重叠也会复制数据。这些实现提供在一个名为detail的命名空间中,因为它们是copy函数使用的实现细节,而不是直接由用户使用。这个泛型copy算法的实现可能如下所示:

template<typename InputIt, typename OutputIt>
constexpr OutputIt copy(InputIt first, InputIt last, 
                        OutputIt d_first)
{
   using input_type = std::remove_cv_t<
      typename std::iterator_traits<InputIt>::value_type>;
   using output_type = std::remove_cv_t<
      typename std::iterator_traits<OutputIt>::value_type>;
   constexpr bool opt =
      std::is_same_v<input_type, output_type> &&
      std::is_pointer_v<InputIt> &&
      std::is_pointer_v<OutputIt> &&
      std::is_trivially_copy_assignable_v<input_type>;
   return detail::copy_fn<opt>::copy(first, last, d_first);
}

你可以在这里看到,选择一个特化或另一个特化的决定是基于使用上述类型特性确定的constexpr布尔值。下面是一个使用此copy函数的示例:

std::vector<int> v1{ 1, 2, 3, 4, 5 };
std::vector<int> v2(5);
// calls the generic implementation
copy(std::begin(v1), std::end(v1), std::begin(v2));
int a1[5] = { 1,2,3,4,5 };
int a2[5];
// calls the optimized implementation
copy(a1, a1 + 5, a2);

请记住,这并不是你在标准库实现中找到的泛型算法copy的真实定义,这些实现被进一步优化。然而,这是一个很好的例子,展示了如何使用类型特性来解决实际问题。

为了简单起见,我将copy函数定义在看起来像是全局命名空间的地方。这是一个坏习惯。通常,代码,尤其是在库中,会被分组在命名空间中。在 GitHub 上伴随书籍的源代码中,你会找到这个函数定义在一个名为n520的命名空间中(这只是一个唯一的名称,与主题无关)。当我们调用我们定义的copy函数时,我们实际上需要使用完全限定的名称(包括命名空间名称),如下所示:

n520::copy(std::begin(v1), std::end(v1), std::begin(v2));

没有这个限定,一个名为copy的过程被传递给std::copy函数,因为我们传递的参数在std命名空间中。你可以阅读更多关于 ADL 的信息,请参阅en.cppreference.com/w/cpp/language/adl

现在,让我们看看另一个例子。

构建同质变长函数模板

对于第二个例子,我们想要构建一个只能接受相同类型或可以隐式转换为公共类型的变长函数模板。让我们从以下骨架定义开始:

template<typename... Ts>
void process(Ts&&... ts) {}

这个问题在于,以下所有函数调用都有效(请记住,这个函数的主体是空的,所以不会因为对某些类型执行不可用的操作而产生错误):

process(1, 2, 3);
process(1, 2.0, '3');
process(1, 2.0, "3");

在第一个例子中,我们传递了三个 int 值。在第二个例子中,我们传递了一个 int、一个 double 和一个 charintchar 都可以隐式转换为 double,所以这应该没问题。然而,在第三个例子中,我们传递了一个 int、一个 double 和一个 char const*,而最后一个类型既不能隐式转换为 int 也不能转换为 double。因此,这个最后的调用应该触发编译器错误,但实际上并没有。

为了做到这一点,我们需要确保当函数参数的共同类型不可用时,编译器将生成一个错误。为此,我们可以使用 static_assert 语句或 std::enable_if 和 SFINAE。然而,我们确实需要弄清楚是否存在共同类型。这可以通过 std::common_type 类型特性来实现。

std::common_type 是一个元函数,它定义了所有类型参数中可以隐式转换为的共同类型。因此,std::common_type<int, double, char>::type 将别名 double 类型。使用这种类型特性,我们可以构建另一个类型特性,它告诉我们是否存在共同类型。一个可能的实现如下:

template <typename, typename... Ts>
struct has_common_type : std::false_type {};
template <typename... Ts>
struct has_common_type<
          std::void_t<std::common_type_t<Ts...>>, 
          Ts...>
   : std::true_type {};
template <typename... Ts>
constexpr bool has_common_type_v =
   sizeof...(Ts) < 2 ||
   has_common_type<void, Ts...>::value;

你可以在这个片段中看到,我们基于几个其他类型特性来实现。首先,有 std::false_typestd::true_type 对。这些是 std::bool_constant<false>std::bool_constant<true> 的类型别名。std::bool_constant 类在 C++17 中可用,并且是 std::integral_constant 类的一个别名模板,该模板针对 bool 类型进行了特化。最后一个类模板包装了指定类型的静态常量。其概念实现如下(尽管也提供了一些操作):

template<class T, T v>
struct integral_constant
{
   static constexpr T value = v;
   using value_type = T;
};

这有助于我们简化需要定义布尔编译时值的类型特性的定义,正如我们在本章的几个例子中看到的那样。

has_common_type 类的实现中使用的第三个类型特性是 std::void_t。这个类型特性定义了多个类型和 void 类型之间的映射。我们使用它来在存在共同类型的情况下建立共同类型和 void 类型之间的映射。这使得我们能够利用 SFINAE 来特化 has_common_type 类模板。

最后,定义了一个名为 has_common_type_v 的变量模板,以简化 has_common_type 特性的使用。

所有这些都可以用来修改 process 函数模板的定义,以确保它只允许具有共同类型的参数。下面是一个可能的实现:

template<typename... Ts,
         typename = std::enable_if_t<
                       has_common_type_v<Ts...>>>
void process(Ts&&... ts) 
{ }

因此,像 process(1, 2.0, "3") 这样的调用将产生编译器错误,因为没有为这组参数重载 process 函数。

如前所述,有不同方法可以使用 has_common_type 特性来实现既定的目标。其中之一,使用 std::enable_if,已在本文中展示,但我们也可以使用 static_assert。然而,使用概念的方法会更好,我们将在下一章中看到这一点。

摘要

本章探讨了类型特性的概念,这些是定义类型元信息或类型转换操作的类。我们首先探讨了类型特性如何实现以及它们如何帮助我们。接下来,我们学习了 SFINAE,即 Substitution Failure Is Not An Error。这是一种技术,使我们能够为模板参数提供约束。

我们接着看到了如何通过 enable_ifconstexpr if 在 C++17 中更好地实现这一目的。在章节的第二部分,我们探讨了标准库中可用的类型特性,并展示了如何使用其中的一些。我们以几个实际案例结束本章,在这些案例中,我们使用了多个类型特性来解决特定问题。

在下一章中,我们将继续探讨通过学习 C++20 的概念和约束来限制模板参数的话题。

问题

  1. 什么是类型特性?

  2. 什么是 SFINAE?

  3. 什么是 constexpr if

  4. std::is_same 是做什么的?

  5. std::conditional 是做什么的?

进一步阅读

第六章:第六章:概念和约束

C++20 标准通过概念和约束提供了一系列对模板元编程的重大改进。约束是一种现代的方式来定义对模板参数的要求。概念是一组命名的约束。概念为传统的模板编写方式提供了几个好处,主要是代码可读性的提高、更好的诊断和减少编译时间。

在本章中,我们将讨论以下主题:

  • 理解概念的需求

  • 定义概念

  • 探索需求表达式

  • 编写约束

  • 了解带有约束的模板的排序

  • 限制非模板成员函数

  • 限制类模板

  • 限制变量模板和模板别名

  • 学习更多指定约束的方式

  • 使用概念来约束auto参数

  • 探索标准概念库

到本章结束时,你将很好地理解 C++20 的概念,并对标准库提供哪些概念有一个概述。

我们将本章的讨论从探讨导致概念发展的原因及其主要好处开始。

理解概念的需求

如本章引言中简要提到的,概念提供了一些重要的好处。可以说,其中最重要的好处是代码可读性和更好的错误信息。在我们查看如何使用概念之前,让我们回顾一个之前看到的例子,并看看它与这两个编程方面有什么关系:

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

这个简单的函数模板接受两个参数并返回它们的和。实际上,它并不返回和,而是将加法运算符应用于两个参数的结果。用户定义的类型可以重载此运算符并执行某些特定操作。术语“和”仅在讨论数学类型时才有意义,例如整数类型、浮点类型、std::complex类型、矩阵类型、向量类型等。

例如,对于字符串类型,加法运算符可以表示连接。而对于大多数类型,其重载根本没有任何意义。因此,仅通过查看函数的声明,而不检查其主体,我们并不能真正地说这个函数可能接受什么作为输入以及它做什么。我们可以这样调用这个函数:

add(42, 1);       // [1]
add(42.0, 1.0);   // [2]
add("42"s, "1"s); // [3]
add("42", "1");   // [4] error: cannot add two pointers

前三次调用都是好的;第一次调用添加了两个整数,第二次添加了两个double值,第三次连接了两个std::string对象。然而,第四次调用将产生编译错误,因为const char *被替换为类型模板参数T,并且加法运算符没有为指针类型重载。

这个add函数模板的意图是仅允许传递算术类型的值,即整数和浮点类型。在 C++20 之前,我们可以用几种方式做到这一点。

一种方法是通过使用std::enable_if和 SFINAE,正如我们在上一章中看到的。以下是一个这样的实现:

template <typename T,
   typename = typename std::enable_if_t
      <std::is_arithmetic_v<T>>>
T add(T const a, T const b)
{
   return a + b;
}

在这里首先要注意的是,可读性已经下降。第二个模板参数类型难以阅读,并且需要良好的模板知识才能理解。然而,这次,标记为 [3][4] 的行上的调用都在产生编译器错误。不同的编译器会发出不同的错误信息。以下是三个主要编译器的错误信息:

  • VC++ 17 中,输出如下:

    error C2672: 'add': no matching overloaded function found
    error C2783: 'T add(const T,const T)': could not deduce template argument for '<unnamed-symbol>'
    
  • GCC 12 中,输出如下:

    prog.cc: In function 'int main()':
    prog.cc:15:8: error: no matching function for call to 'add(std::__cxx11::basic_string<char>, std::__cxx11::basic_string<char>)'
    15 |     add("42"s, "1"s);
          |     ~~~^~~~~~~~~~~~~
    prog.cc:6:6: note: candidate: 'template<class T, class> T add(T, T)'
        6 |    T add(T const a, T const b)
          |      ^~~
    prog.cc:6:6: note:   template argument deduction/substitution failed:
    In file included from /opt/wandbox/gcc-head/include/c++/12.0.0/bits/move.h:57,
                     from /opt/wandbox/gcc-head/include/c++/12.0.0/bits/nested_exception.h:40,
    from /opt/wandbox/gcc-head/include/c++/12.0.0/exception:154,
                     from /opt/wandbox/gcc-head/include/c++/12.0.0/ios:39,
                     from /opt/wandbox/gcc-head/include/c++/12.0.0/ostream:38,
                     from /opt/wandbox/gcc-head/include/c++/12.0.0/iostream:39,
                     from prog.cc:1:
    /opt/wandbox/gcc-head/include/c++/12.0.0/type_traits: In substitution of 'template<bool _Cond, class _Tp> using enable_if_t = typename std::enable_if::type [with bool _Cond = false; _Tp = void]':
    prog.cc:5:14:   required from here
    /opt/wandbox/gcc-head/include/c++/12.0.0/type_traits:2603:11: error: no type named 'type' in 'struct std::enable_if<false, void>'
     2603 |     using enable_if_t = typename enable_if<_Cond, _Tp>::type;
          |           ^~~~~~~~~~~
    
  • Clang 13 中,输出如下:

    prog.cc:15:5: error: no matching function for call to 'add'
        add("42"s, "1"s);
        ^~~
    prog.cc:6:6: note: candidate template ignored: requirement 'std::is_arithmetic_v<std::string>' was not satisfied [with T = std::string]
       T add(T const a, T const b)
         ^
    

GCC 的错误信息非常冗长,而 VC++没有说明模板参数匹配失败的原因。Clang 在这方面做得更好,提供了更易于理解的错误信息。

在 C++20 之前,为这个函数定义限制的另一种方法是通过static_assert语句,如下面的片段所示:

template <typename T>
T add(T const a, T const b)
{
   static_assert(std::is_arithmetic_v<T>, 
                 "Arithmetic type required");
   return a + b;
}

然而,在这个实现中,我们回到了原始问题:仅仅通过查看函数的声明,我们无法知道它将接受什么样的参数,前提是存在任何限制。另一方面,错误信息如下:

  • VC++ 17 中:

    error C2338: Arithmetic type required
    main.cpp(157): message : see reference to function template instantiation 'T add<std::string>(const T,const T)' being compiled
         with
         [
             T=std::string
         ]
    
  • GCC 12 中:

    prog.cc: In instantiation of 'T add(T, T) [with T = std::__cxx11::basic_string<char>]':
    prog.cc:15:8:   required from here
    prog.cc:7:24: error: static assertion failed: Arithmetic type required
        7 |     static_assert(std::is_arithmetic_v<T>, "Arithmetic type required");
          |                   ~~~~~^~~~~~~~~~~~~~~~~~
    prog.cc:7:24: note: 'std::is_arithmetic_v<std::__cxx11::basic_string<char> >' evaluates to false
    
  • Clang 13 中:

    prog.cc:7:5: error: static_assert failed due to requirement 'std::is_arithmetic_v<std::string>' "Arithmetic type required"
        static_assert(std::is_arithmetic_v<T>, "Arithmetic type required");
        ^             ~~~~~~~~~~~~~~~~~~~~~~~
    prog.cc:15:5: note: in instantiation of function template specialization 'add<std::string>' requested here
        add("42"s, "1"s);
        ^
    

使用static_assert语句会导致无论使用哪种编译器都会收到类似的错误信息。

我们可以通过使用 C++20 中的约束来改进这两个讨论的方面(可读性和错误信息)。这些约束是通过新的requires关键字引入的,如下所示:

template <typename T>
requires std::is_arithmetic_v<T>
T add(T const a, T const b)
{
   return a + b;
}

requires关键字引入了一个子句,称为requires 子句,它定义了模板参数的约束。实际上,有两种不同的语法:一种是在模板参数列表之后跟随 requires 子句,如之前所见,另一种是在函数声明之后跟随 requires 子句,如下一个片段所示:

template <typename T>      
T add(T const a, T const b)
requires std::is_arithmetic_v<T>
{
   return a + b;
}

在选择这两种语法时,这是一个个人偏好的问题。然而,在两种情况下,可读性都比 C++20 之前的实现要好得多。仅通过阅读声明,你就知道类型模板参数T必须是算术类型。这也意味着该函数只是简单地相加两个数字。你实际上不需要看到定义就能知道这一点。让我们看看当我们用无效参数调用函数时错误信息如何变化:

  • VC++ 17 中:

    error C2672: 'add': no matching overloaded function found
    error C7602: 'add': the associated constraints are not satisfied
    
  • GCC 12 中:

    prog.cc: In function 'int main()':
    prog.cc:15:8: error: no matching function for call to 'add(std::__cxx11::basic_string<char>, std::__cxx11::basic_string<char>)'
       15 |     add("42"s, "1"s);
    |     ~~~^~~~~~~~~~~~~
    prog.cc:6:6: note: candidate: 'template<class T>  requires  is_arithmetic_v<T> T add(T, T)'
        6 |    T add(T const a, T const b)
          |      ^~~
    prog.cc:6:6: note:   template argument deduction/substitution failed:
    prog.cc:6:6: note: constraints not satisfied
    prog.cc: In substitution of 'template<class T>  requires  is_arithmetic_v<T> T add(T, T) [with T = std::__cxx11::basic_string<char>]':
    prog.cc:15:8:   required from here
    prog.cc:6:6:   required by the constraints of 'template<class T>  requires  is_arithmetic_v<T> T add(T, T)'
    prog.cc:5:15: note: the expression 'is_arithmetic_v<T> [with T = std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >]' evaluated to 'false'
        5 | requires std::is_arithmetic_v<T>
          |          ~~~~~^~~~~~~~~~~~~~~~~~
    
  • Clang 13 中:

    prog.cc:15:5: error: no matching function for call to 'add'
    add("42"s, "1"s);
        ^~~
    prog.cc:6:6: note: candidate template ignored: constraints not satisfied [with T = std::string]
       T add(T const a, T const b)
         ^
    prog.cc:5:10: note: because 'std::is_arithmetic_v<std::string>' evaluated to false
    requires std::is_arithmetic_v<T>
             ^
    

错误信息遵循之前看到的相同模式:GCC 太啰嗦,VC++缺少必要的信息(未满足的约束),而 Clang 则更加简洁,并能更好地指出错误的原因。总的来说,诊断信息有所改进,尽管仍有改进的空间。

约束是一个在编译时评估为真或假的谓词。在前面示例中使用的表达式 std::is_arithmetic_v<T>,仅仅是使用了一个标准类型特性(我们在上一章中看到过)。然而,这些是可以在约束中使用的不同类型的表达式,我们将在本章后面学习它们。

在下一节中,我们将探讨如何定义和使用命名约束。

定义概念

之前看到的约束是在它们被使用的地方定义的无名谓词。许多约束是通用的,可以在多个地方使用。让我们考虑以下类似于 add 函数的函数示例。这个函数执行算术值的乘法,如下所示:

template <typename T>
requires std::is_arithmetic_v<T>
T mul(T const a, T const b)
{
   return a * b;
}

add 函数中看到的相同的 requires 子句也出现在这里。为了避免这种重复的代码,我们可以定义一个可以在多个地方重用的命名约束。命名约束被称为 concept 关键字和模板语法。以下是一个示例:

template<typename T>
concept arithmetic = std::is_arithmetic_v<T>;

即使它们被分配了布尔值,概念名称也不应包含动词。它们代表需求,并用作模板参数的属性或限定符。因此,你应该优先选择诸如 arithmeticcopyableserializablecontainer 等名称,而不是 is_arithmeticis_copyableis_serializableis_container。之前定义的算术概念可以这样使用:

template <arithmetic T>
T add(T const a, T const b) { return a + b; }
template <arithmetic T>
T mul(T const a, T const b) { return a * b; }

从这个片段中,你可以看到概念被用来代替 typename 关键字。它用算术质量限定 T 类型,这意味着只有满足这个要求的类型才能用作模板参数。相同的算术概念可以用不同的语法定义,如下面的片段所示:

template<typename T>
concept arithmetic = requires { std::is_arithmetic_v<T>; };

这使用了 requires 表达式。requires 表达式使用花括号 {},而 requires 子句则不使用。requires 表达式可以包含一系列不同类型的约束:简单约束、类型约束、复合约束和嵌套约束。这里看到的是一个简单约束。为了定义这个特定的概念,这种语法更复杂,但最终效果相同。然而,在某些情况下,需要复杂的约束。让我们看一个例子。

考虑我们想要定义一个只接受容器类型作为参数的模板的情况。在概念可用之前,这可以通过类型特性、SFINAE 或 static_assert 语句的帮助来解决,正如我们在本章开头所看到的。然而,正式定义容器类型并不容易。我们可以基于标准容器的一些属性来完成它:

  • 它们具有成员类型 value_typesize_typeallocator_typeiteratorconst_iterator

  • 它们有一个成员函数 size,该函数返回容器中的元素数量。

  • 它们具有begin/endcbegin/cend成员函数,这些函数返回迭代器和常量迭代器,指向容器中的第一个元素和最后一个元素之后的一个元素。

通过从第五章积累的知识,类型特性和条件编译,我们可以定义一个is_containter类型特性如下:

template <typename T, typename U = void>
struct is_container : std::false_type {};
template <typename T>
struct is_container<T,
   std::void_t<typename T::value_type,
               typename T::size_type,
               typename T::allocator_type,
               typename T::iterator,
               typename T::const_iterator,
               decltype(std::declval<T>().size()),
               decltype(std::declval<T>().begin()),
               decltype(std::declval<T>().end()),
               decltype(std::declval<T>().cbegin()),
               decltype(std::declval<T>().cend())>> 
   : std::true_type{};
template <typename T, typename U = void>
constexpr bool is_container_v = is_container<T, U>::value;

我们可以使用static_assert语句来验证类型特性是否正确识别容器类型。以下是一个示例:

struct foo {};
static_assert(!is_container_v<foo>);
static_assert(is_container_v<std::vector<foo>>);

概念使得编写这样的模板约束变得更容易。我们可以使用概念语法和requires表达式来定义以下内容:

template <typename T>
concept container = requires(T t)
{
   typename T::value_type;
   typename T::size_type;
   typename T::allocator_type;
   typename T::iterator;
   typename T::const_iterator;
   t.size();
   t.begin();
   t.end();
   t.cbegin();
   t.cend();
};

这个定义既简短又易于阅读。它使用了简单的要求,如t.size(),以及类型要求,如typename T::value_type。它可以用来以先前看到的方式约束模板参数,也可以与static_assert语句一起使用(因为约束评估为编译时的布尔值):

struct foo{};
static_assert(!container<foo>);
static_assert(container<std::vector<foo>>);
template <container C>
void process(C&& c) {}

在下一节中,我们将深入探讨requires表达式中可以使用的各种要求类型。

探索requires表达式

一个requires表达式可能是一个复杂表达式,如前面示例中容器概念的例子所示。requires表达式的实际形式非常类似于函数语法,如下所示:

requires (parameter-list) { requirement-seq }

parameter-list是一个以逗号分隔的参数列表。与函数声明不同的是,不允许使用默认值。然而,在此列表中指定的参数没有存储、链接或生命周期。编译器不会为它们分配任何内存;它们仅用于定义要求。但是,它们确实有作用域,那就是requires表达式的闭合花括号。

requirements-seq是一系列要求。每个此类要求必须以分号结束,就像 C++中的任何语句一样。有四种类型的要求:

  • 简单要求

  • 类型要求

  • 复合要求

  • 嵌套要求

这些要求可能引用以下内容:

  • 范围内的模板参数

  • requires表达式的参数列表中引入的局部参数

  • 任何从封装上下文中可见的其他声明

在以下小节中,我们将探讨所有提到的要求类型。一开始,我们将查看简单要求。

简单要求

一个true。表达式不能以requires关键字开头,因为这定义了一个嵌套要求(稍后讨论)。

当我们之前定义arithmeticcontainer概念时,我们已经看到了简单语句的例子。让我们再看几个例子:

template<typename T>
concept arithmetic = requires 
{
   std::is_arithmetic_v<T>; 
};
template <typename T>
concept addable = requires(T a, T b) 
{ 
   a + b; 
};
template <typename T>
concept logger = requires(T t)
{
   t.error("just");
   t.warning("a");
   t.info("demo");
};

第一个概念,算术,与我们之前定义的是同一个。std::is_arithmetic_v<T> 表达式是一个简单的需求。注意,当参数列表为空时,它可以完全省略,就像在这个例子中,我们只检查 T 类型模板参数是否为算术类型。

addablelogger 概念都有参数列表,因为我们正在检查 T 类型值的操作。a + b 是一个简单需求,因为编译器只需检查加号运算符是否为 T 类型重载。在最后一个例子中,我们确保 T 类型有三个名为 errorwarninginfo 的成员函数,这些函数接受一个 const char* 类型或可以从 const char* 构造的类型的单个参数。记住,实际传递给参数的值并不重要,因为这些调用从未执行过;它们只是被检查是否正确。

让我们简要地阐述最后一个例子,并考虑以下片段:

template <logger T>
void log_error(T& logger)
{}
struct console_logger
{
   void error(std::string_view text){}
   void warning(std::string_view text) {}
   void info(std::string_view text) {}
};
struct stream_logger
{
   void error(std::string_view text, bool = false) {}
   void warning(std::string_view text, bool = false) {}
   void info(std::string_view text, bool) {}
};

log_error 函数模板需要一个满足 logger 需求的类型参数。我们有两个类,称为 console_loggerstream_logger。第一个满足 logger 需求,但第二个不满足。这是因为 info 函数不能使用单个 const char* 类型的参数调用。此函数还需要一个布尔类型的第二个参数。前两个方法 errorwarning 为第二个参数定义了一个默认值,因此它们可以用如 t.error("just")warning("a") 这样的调用执行。

然而,由于第三个成员函数,stream_logger 不是一个满足预期需求的日志类,因此不能与 log_error 函数一起使用。console_loggerstream_logger 的使用在以下片段中得到了示例:

console_logger cl;
log_error(cl);      // OK
stream_logger sl;
log_error(sl);      // error

在下一节中,我们将探讨需求类型的第二类,即类型需求。

类型需求

typename 后跟一个类型名称。我们已经在前面的 container 约束定义中看到了几个例子。类型名称必须有效,需求才为真。类型需求可用于多个目的:

  • 为了验证嵌套类型的存在(例如在 typename T::value_type; 中)

  • 为了验证类模板特化是否命名了一个类型

  • 为了验证别名模板特化是否命名了一个类型

让我们看看几个例子,了解如何使用类型需求。在第一个例子中,我们检查一个类型是否包含内部类型 key_typevalue_type

template <typename T>
concept KVP = requires 
{
   typename T::key_type;
   typename T::value_type;
};
template <typename T, typename V>
struct key_value_pair
{
   using key_type = T;
   using value_type = V;
   key_type    key;
   value_type  value;
};
static_assert(KVP<key_value_pair<int, std::string>>);
static_assert(!KVP<std::pair<int, std::string>>);

类型,key_value_pair<int, std::string> 满足这些类型需求,但 std::pair<int, std::string> 不满足。std::pair 类型确实有内部类型,但它们被称为 first_typesecond_type

在第二个例子中,我们检查类模板特化是否命名了一个类型。类模板是 container,特化是 container<T>

template <typename T>
requires std::is_arithmetic_v<T>
struct container
{ /* ... */ };
template <typename T>
concept containerizeable = requires {
   typename container<T>;
};
static_assert(containerizeable<int>);
static_assert(!containerizeable<std::string>);

在这个片段中,container 是一个只能针对算术类型(如 intlongfloatdouble)进行特化的类模板。因此,存在如 container<int> 这样的特化,但不存在 container<std::string>containerizeable 概念指定了对类型 T 的要求,以定义一个有效的 container 特化。因此,containerizeable<int> 为真,但 containerizeable<std::string> 为假。

现在我们已经理解了简单的要求和类型要求,是时候探索更复杂的要求类别了。首先我们要看的是复合要求。

复合要求

简单的要求使我们能够验证一个表达式是否有效。然而,有时我们需要验证一个表达式的某些属性,而不仅仅是它是否有效。这可以包括表达式是否不会抛出异常或对结果类型的要求(例如函数的返回类型)。一般形式如下:

{ expression } noexcept -> type_constraint;

noexcept 说明符和 type_constraint(带前导 ->)都是可选的。替换过程和约束检查如下:

  1. 模板参数在表达式中被替换。

  2. 如果指定了 noexcept,则表达式不得抛出异常;否则,要求为假。

  3. 如果存在类型约束,则模板参数也被替换到 type_contraintdecltype((expression)) 中,并且必须满足 type_constraint 强加的条件;否则,要求为假。

我们将讨论几个示例,以了解如何使用复合要求。在第一个例子中,我们检查一个函数是否带有 noexcept 说明符:

template <typename T>
void f(T) noexcept {}
template <typename T>
void g(T) {}
template <typename F, typename ... T>
concept NonThrowing = requires(F && func, T ... t)
{
   {func(t...)} noexcept;
};
template <typename F, typename ... T>
   requires NonThrowing<F, T...>
void invoke(F&& func, T... t)
{
   func(t...);
}

在这个片段中,有两个函数模板:f 被声明为 noexcept;因此,它不应该抛出任何异常,而 g 可能会抛出异常。NonThrowing 概念强制要求类型 F 的变异性函数不得抛出异常。因此,在以下两个调用中,只有第一个是有效的,第二个将产生编译器错误:

invoke(f<int>, 42);
invoke(g<int>, 42); // error

Clang 生成的错误信息如下所示:

prog.cc:28:7: error: no matching function for call to 'invoke'
      invoke(g<int>, 42);
      ^~~~~~
prog.cc:18:9: note: candidate template ignored: constraints not satisfied [with F = void (&)(int), T = <int>]
   void invoke(F&& func, T... t)
        ^
prog.cc:17:16: note: because 'NonThrowing<void (&)(int), int>' evaluated to false
      requires NonThrowing<F, T...>
               ^
prog.cc:13:20: note: because 'func(t)' may throw an exception
      {func(t...)} noexcept;
                   ^

这些错误信息告诉我们,invoke(g<int>, 42) 调用无效,因为 g<int> 可能会抛出异常,导致 NonThrowing<F, T…> 评估为 false

对于第二个例子,我们将定义一个概念,为计时器类提供要求。具体来说,它要求存在一个名为 start 的函数,它可以无参数调用,并且返回 void。它还要求存在一个名为 stop 的第二个函数,它可以无参数调用,并且返回一个可以转换为 long long 的值。该概念定义如下:

template <typename T>
concept timer = requires(T t)
{
   {t.start()} -> std::same_as<void>;
   {t.stop()}  -> std::convertible_to<long long>;
};

注意,类型约束不能是任何编译时布尔表达式,而是一个实际的类型要求。因此,我们使用其他概念来指定返回类型。std::same_asstd::convertible_to 都是 <concepts> 头文件中标准库中可用的概念。我们将在 探索标准概念库 部分了解更多关于这些内容。现在,让我们考虑以下实现计时器的类:

struct timerA
{
   void start() {}
   long long stop() { return 0; }
};
struct timerB
{
   void start() {}
   int stop() { return 0; }
};
struct timerC
{
   void start() {}
   void stop() {}
   long long getTicks() { return 0; }
};
static_assert(timer<timerA>);
static_assert(timer<timerB>);
static_assert(!timer<timerC>);

在这个例子中,timerA 满足计时器概念,因为它包含两个必需的方法:返回 voidstart 和返回 long longstop。同样,timerB 也满足计时器概念,因为它具有相同的方法,尽管 stop 返回的是 int。然而,int 类型可以隐式转换为 long long 类型;因此,类型要求得到了满足。最后,timerC 也具有相同的方法,但它们都返回 void,这意味着 stop 返回类型的要求没有得到满足,因此,计时器概念施加的约束没有得到满足。

剩下的最后一种要求是嵌套要求。我们将在下一部分进行探讨。

嵌套要求

最后一种要求是嵌套要求。嵌套要求是通过 requires 关键字引入的(记住我们提到简单要求是不用 requires 关键字引入的要求),并且具有以下形式:

requires constraint-expression;

表达式必须由替换的参数满足。模板参数替换到 constraint-expression 中仅用于检查表达式是否满足。

在下面的例子中,我们想要定义一个函数,该函数可以对可变数量的参数执行加法操作。然而,我们想要施加一些条件:

  • 有多个参数。

  • 所有参数具有相同的类型。

  • 表达式 arg1 + arg2 + … + argn 是有效的。

为了确保这一点,我们定义了一个名为 HomogenousRange 的概念如下:

template<typename T, typename... Ts>
inline constexpr bool are_same_v = 
   std::conjunction_v<std::is_same<T, Ts>...>;
template <typename ... T>
concept HomogenousRange = requires(T... t)
{
   (... + t);
   requires are_same_v<T...>;
   requires sizeof...(T) > 1;
};

这个概念包含一个简单要求和两个嵌套要求。一个嵌套要求使用 are_same_v 变量模板,其值由一个或多个类型特性(std::is_same)的合取确定,另一个是编译时布尔表达式 size…(T) > 1

使用这个概念,我们可以定义 add 可变参数函数模板如下:

template <typename ... T>
requires HomogenousRange<T...>
auto add(T&&... t)
{
   return (... + t);
}
add(1, 2);   // OK
add(1, 2.0); // error, types not the same
add(1);      // error, size not greater than 1

之前示例中演示的第一个调用是正确的,因为有两个参数,并且它们都是 int 类型。第二个调用产生了一个错误,因为参数的类型不同(intdouble)。同样,第三个调用也产生了一个错误,因为只提供了一个参数。

HomogenousRange 概念也可以通过几个 static_assert 语句进行测试,如下所示:

static_assert(HomogenousRange<int, int>);
static_assert(!HomogenousRange<int>);
static_assert(!HomogenousRange<int, double>);

我们已经走过了所有可以用于定义约束的requires表达式的类别。然而,约束也可以组合,这正是我们将要讨论的。

组合约束

我们已经看到了多个约束模板参数的例子,但在所有这些情况下,我们只使用了一个约束。然而,使用&&||运算符,约束可以组合。使用&&运算符组合两个约束称为析取

对于一个合取为真,两个约束都必须为真。就像逻辑AND操作的情况一样,两个约束从左到右进行评估,如果左边的约束是假的,则不会评估右边的约束。让我们看一个例子:

template <typename T>
requires std::is_integral_v<T> && std::is_signed_v<T>
T decrement(T value) 
{
   return value--;
}

在这个片段中,我们有一个返回接收到的参数递减值的函数模板。然而,它只接受有符号整数值。这是通过两个约束的合取来指定的,即std::is_integral_v<T> && std::is_signed_v<T>。同样,可以使用不同的方法来定义合取,如下所示:

template <typename T>
concept Integral = std::is_integral_v<T>;
template <typename T>
concept Signed = std::is_signed_v<T>;
template <typename T>
concept SignedIntegral = Integral<T> && Signed<T>;
template <SignedIngeral T>      
T decrement(T value)
{
   return value--;
}

你可以看到这里定义了三个概念:一个约束整型类型,一个约束有符号类型,以及一个约束整型和有符号类型的。

并非(disjunctions)以类似的方式工作。对于一个非约束为真,至少必须有一个约束为真。如果左边的约束为真,则不会评估右边的约束。再次,让我们看一个例子。如果你还记得本章第一节的add函数模板,我们用std::is_arithmetic类型特性对其进行了约束。然而,我们可以使用std::is_integralstd::is_floating_point来得到相同的结果,如下所示:

template <typename T>
requires std::is_integral_v<T> || std::is_floating_point_v<T>
T add(T a, T b)
{
   return a + b;
}

表达式std::is_integral_v<T> || std::is_floating_point_v<T>定义了两个原子约束的非约束。我们将在稍后更详细地探讨这种约束。目前,请记住,原子约束是一个不能分解成更小部分的bool类型表达式。同样,就像我们之前所做的那样,我们也可以构建一个概念的非约束,并使用它。下面是如何做的:

template <typename T>
concept Integral = std::is_integral_v<T>;
template <typename T>
concept FloatingPoint = std::is_floating_point_v<T>;
template <typename T>
concept Number = Integral<T> || FloatingPoint<T>;
template <Number T>
T add(T a, T b)
{
   return a + b;
}

如前所述,合取(conjunctions)和析取(disjunctions)是短路操作的。这在检查程序的正确性方面有重要的含义。考虑一个形式为A<T> && B<T>的合取,那么首先会检查和评估A<T>,如果它是假的,则不再检查第二个约束B<T>

类似地,对于A<T> || B<T>析取,在检查A<T>之后,如果它评估为真,则不会检查第二个约束B<T>。如果你想要检查两个合取的有效性并确定它们的布尔值,那么你必须使用&&||运算符的不同方式。合取或析取仅在&&||符号分别嵌套在括号内或作为&&||运算符的操作数时形成。否则,这些运算符被视为逻辑运算符。让我们用例子来解释这一点:

template <typename T>
requires A<T> || B<T>
void f() {}
template <typename T>
requires (A<T> || B<T>)
void f() {}
template <typename T>
requires A<T> && (!A<T> || B<T>)
void f() {}

在所有这些例子中,||符号定义了一个析取。然而,当它用于类型转换表达式或逻辑&&||符号时,它们定义了一个逻辑表达式:

template <typename T>
requires (!(A<T> || B<T>))
void f() {}
template <typename T>
requires (static_cast<bool>(A<T> || B<T>))
void f() {}

在这些情况下,整个表达式首先检查其正确性,然后确定其布尔值。值得一提的是,在这个后一个例子中,两个表达式!(A<T> || B<T>)static_cast<bool>(A<T> || B<T>)都需要被括号包围,因为requires子句的表达式不能以!符号或类型转换开始。

交集和并集不能用于约束模板参数包。然而,有一个解决方案可以实现这一点。让我们考虑一个具有所有参数都必须是整型要求的add函数模板的可变实现。人们会尝试以下形式来编写这样的约束:

template <typename ... T>
requires std::is_integral_v<T> && ...
auto add(T ... args)
{
   return (args + ...);
}

这将生成编译器错误,因为在这个上下文中不允许使用省略号。为了避免这个错误,我们可以将表达式括在括号中,如下所示:

template <typename ... T>
requires (std::is_integral_v<T> && ...)
auto add(T ... args)
{
   return (args + ...);
}

表达式(std::is_integral_v<T> && ...)现在是一个折叠表达式。它不是一个合取,正如人们所期望的那样。因此,我们得到一个单一的原子约束。编译器将首先检查整个表达式的正确性,然后确定其布尔值。要构建一个合取,我们首先需要定义一个概念:

template <typename T>
concept Integral = std::is_integral_v<T>;

我们接下来需要做的是更改requires子句,使其使用新定义的概念而不是布尔变量std::is_integral_v<T>

template <typename ... T>
requires (Integral<T> && ...)
auto add(T ... args)
{
   return (args + ...);
}

看起来变化不大,但实际上,由于使用了概念,验证正确性和确定布尔值对于每个模板参数都是单独发生的。如果某个类型的约束未满足,其余部分将短路,验证将停止。

你一定注意到了在本节前面我两次使用了术语原子约束。因此,人们会问,什么是原子约束?它是一个不能进一步分解的bool类型表达式。原子约束是在约束归一化过程中形成的,当编译器将约束分解为原子约束的交集和并集时。这如下所示:

  • 表达式E1 && E2被分解为E1E2的合取。

  • 表达式 E1 || E2 被分解为 E1E2 的析取。

  • 概念 C<A1, A2, … An> 在将所有模板参数替换到其原子约束中后,被替换为其定义。

原子约束用于确定约束的部分排序,这反过来又确定了函数模板和类模板特化的部分排序,以及重载解析中非模板函数的下一个候选。我们将在下一节讨论这个主题。

了解具有约束的模板的排序

当编译器遇到函数调用或类模板实例化时,它需要确定哪个重载(对于函数)或特化(对于类)是最合适的。一个函数可能具有不同的类型约束。类模板也可以用不同的类型约束进行特化。为了决定哪个是最合适的,编译器需要确定哪个是最受约束的,并且在将所有模板参数替换后,同时评估为 true。为了找出这一点,它执行 约束规范化。这是将约束表达式转换为原子约束的合取和析取的过程,如前一小节所述。

如果一个原子约束 A 意味着另一个原子约束 B,则称 AB 的子集。如果一个约束声明 D1 的约束包含另一个声明 D2 的约束,则称 D1 至少与 D2 一样受约束。此外,如果 D1 至少与 D2 一样受约束,但反之不成立,则称 D1D2 更受约束。更受约束的重载被选为最佳匹配。

我们将通过几个示例来讨论约束如何影响重载解析。首先,让我们从以下两个重载开始:

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

第一个重载是一个非模板函数,它接受两个 int 参数并返回它们的和。第二个是我们在本章中已经看到的模板实现。

有这两个重载后,让我们考虑以下调用:

add(1.0, 2.0);  // [1]
add(1, 2);      // [2]

第一次调用(在第 [1] 行)接受两个 double 类型的值,因此只有模板重载匹配。因此,将调用其 double 类型的实例化。第二次调用 add 函数(在第 [2] 行)接受两个整数参数。两个重载都是可能的匹配。编译器将选择最具体的一个,即非模板重载。

如果两个重载都是模板,但其中一个有约束,会怎样?以下是一个讨论的例子:

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

第一个重载是之前看到的函数模板。第二个重载与第一个具有相同的实现,只是它指定了对模板参数的要求,该参数限制为整型类型。如果我们考虑之前代码片段中的相同两个调用,对于第 [1] 行的调用,使用两个 double 值,只有第一个重载是一个良好的匹配。对于第 [2] 行的调用,使用两个整数值,两个重载都是良好的匹配。然而,第二个重载更加受限(它有一个约束,而第一个没有约束),因此编译器将选择这个重载进行调用。

在下一个示例中,两个重载都有约束。第一个重载要求模板参数的大小为四,第二个重载要求模板参数必须是整型类型:

template <typename T>
requires (sizeof(T) == 4)
T add(T a, T b)
{
   return a + b;
}
template <typename T>
requires std::is_integral_v<T>
T add(T a, T b)
{
   return a + b;
}

让我们考虑对这个重载函数模板的以下调用:

add((short)1, (short)2);  // [1]
add(1, 2);                // [2]

[1] 行的调用使用 short 类型的参数。这是一个大小为 2 的整型类型;因此,只有第二个重载是匹配的。然而,第 [2] 行的调用使用 int 类型的参数。这是一个大小为 4 的整型类型。因此,两个重载都是良好的匹配。然而,这是一个模糊的情况,编译器无法在这两个之间选择,并将触发一个错误。

然而,如果我们稍微改变这两个重载,如下一个代码片段所示,会发生什么?

template <typename T>
requires std::is_integral_v<T>
T add(T a, T b)
{
   return a + b;
}
template <typename T>
requires std::is_integral_v<T> && (sizeof(T) == 4)
T add(T a, T b)
{
   return a + b;
}

两个重载都要求模板参数必须是整型类型,但第二个还要求整型类型的大小必须是 4 字节。因此,对于第二个重载,我们使用两个原子约束的组合。我们将讨论相同的两个调用,一个使用 short 参数,另一个使用 int 参数。

对于第 [1] 行的调用,传递两个 short 值,只有第一个重载是良好的匹配,因此将调用这个重载。对于第 [2] 行的调用,它接受两个 int 参数,两个重载都是匹配的。然而,第二个重载更加受限。尽管如此,编译器无法决定哪个是更好的匹配,并将发出一个模糊调用错误。这可能让你感到惊讶,因为一开始我说最受限的重载将从重载集中被选中。在我们的例子中并不适用,因为我们使用了类型特性来约束这两个函数。如果我们使用概念而不是类型特性,行为会有所不同。下面是如何做的:

template <typename T>
concept Integral = std::is_integral_v<T>;
template <typename T>
requires Integral<T>
T add(T a, T b)
{
   return a + b;
}
template <typename T>
requires Integral<T> && (sizeof(T) == 4)
T add(T a, T b)
{
   return a + b;
}

现在不再存在歧义;编译器将从重载集中选择第二个重载作为最佳匹配。这表明概念在编译器中被优先处理。记住,使用概念有不同方式来使用约束,但前面的定义只是用一个概念替换了一个类型特性;因此,它们在演示这种行为方面可能是一个更好的选择:

template <Integral T>
T add(T a, T b)
{
   return a + b;
}
template <Integral T>
requires (sizeof(T) == 4)
T add(T a, T b)
{
   return a + b;
}

本章讨论的所有示例都涉及约束函数模板。然而,也可以约束非模板成员函数以及类模板和类模板特化。我们将在下一节讨论这些内容,并从前者开始。

限制非模板成员函数

类模板的成员函数可以以与我们迄今为止看到的方式类似的方式进行约束。这使得模板类能够只为满足某些要求的类型定义成员函数。在以下示例中,相等运算符被约束:

template <typename T>
struct wrapper
{
   T value;
   bool operator==(std::string_view str)
   requires std::is_convertible_v<T, std::string_view>
   {
      return value == str;
   }
};

wrapper类持有T类型的值,并且只为可以转换为std::string_view的类型定义了operator==成员。让我们看看这是如何使用的:

wrapper<int>         a{ 42 };
wrapper<char const*> b{ "42" };
if(a == 42)   {} // error
if(b == "42") {} // OK

这里有两个wrapper类的实例化,一个用于int,一个用于char const*。尝试将a对象与字面量42进行比较会生成编译器错误,因为此类型没有定义operator==。然而,将b对象与字符串字面量"42"进行比较是可能的,因为对于可以隐式转换为std::string_view的类型,定义了相等运算符,而char const*就是这样的类型。

限制非模板成员是有用的,因为它比强制成员成为模板并使用 SFINAE 的解决方案更干净。为了更好地理解这一点,让我们考虑以下wrapper类的实现:

template <typename T>
struct wrapper
{
    T value;
    wrapper(T const & v) :value(v) {}
};

这个类模板可以如下实例化:

wrapper<int> a = 42;            //OK
wrapper<std::unique_ptr<int>> p = 
   std::make_unique<int>(42);   //error

第一行编译成功,但第二行生成编译器错误。不同的编译器会发出不同的消息,但错误的根本是调用隐式删除的std::unique_ptr的复制构造函数。

我们想要做的是限制wrapperT类型对象的复制构造,使其只对可复制的T类型有效。在 C++20 之前可用的方法是,将复制构造函数转换为模板并使用 SFINAE。这看起来如下所示:

template <typename T>
struct wrapper
{
   T value;
   template <typename U,
             typename = std::enable_if_t<
                   std::is_copy_constructible_v<U> &&
                   std::is_convertible_v<U, T>>>
   wrapper(U const& v) :value(v) {}
};

这次,当我们尝试从std::unique_ptr<int>值初始化wrapper<std::unique_ptr<int>>时,也会出现错误,但错误不同。例如,以下是 Clang 生成的错误信息:

prog.cc:19:35: error: no viable conversion from 'typename __unique_if<int>::__unique_single' (aka 'unique_ptr<int>') to 'wrapper<std::unique_ptr<int>>'
    wrapper<std::unique_ptr<int>> p = std::make_unique<int>(42); // error
                                  ^   ~~~~~~~~~~~~~~~~~~~~~~~~~
prog.cc:6:8: note: candidate constructor (the implicit copy constructor) not viable: no known conversion from 'typename __unique_if<int>::__unique_single' (aka 'unique_ptr<int>') to 'const wrapper<std::unique_ptr<int>> &' for 1st argument
struct wrapper
       ^
prog.cc:6:8: note: candidate constructor (the implicit move constructor) not viable: no known conversion from 'typename __unique_if<int>::__unique_single' (aka 'unique_ptr<int>') to 'wrapper<std::unique_ptr<int>> &&' for 1st argument
struct wrapper
       ^
prog.cc:13:9: note: candidate template ignored: requirement 'std::is_copy_constructible_v<std::unique_ptr<int, std::default_delete<int>>>' was not satisfied [with U = std::unique_ptr<int>]
        wrapper(U const& v) :value(v) {}
        ^

帮助理解问题原因的最重要信息是最后一条。它指出,将U替换为std::unique_ptr<int>的要求不满足布尔条件。在 C++20 中,我们可以更好地对T模板参数实施相同的限制。这次,我们可以使用约束,并且复制构造函数不再需要是模板。C++20 中的实现可以如下所示:

template <typename T>
struct wrapper
{
   T value;
   wrapper(T const& v) 
      requires std::is_copy_constructible_v<T> 
      :value(v)
   {}
};

不仅代码量更少,不需要复杂的 SFINAE 机制,而且它更简单,更容易理解。它还可能生成更好的错误信息。在 Clang 的情况下,前面列出的最后一个注意事项被以下内容所取代:

prog.cc:9:5: note: candidate constructor not viable: constraints not satisfied
    wrapper(T const& v) 
    ^
prog.cc:10:18: note: because 'std::is_copy_constructible_v<std::unique_ptr<int> >' evaluated to false
        requires std::is_copy_constructible_v<T>

在关闭这一节之前,值得提一下,不仅类成员的非模板函数可以被约束,自由函数也可以。非模板函数的使用场景很少,可以使用如 constexpr if 之类的简单替代解决方案来实现。尽管如此,让我们来看一个例子:

void handle(int v)
{ /* do something */ }
void handle(long v)
    requires (sizeof(long) > sizeof(int))
{ /* do something else */ }

在这个代码片段中,handle函数有两个重载版本。第一个重载接受一个int类型的值,第二个接受一个long类型的值。这些重载函数的函数体并不重要,但它们应该执行不同的操作,仅当long的大小与int的大小不同时。标准规定int的大小至少为 16 位,尽管在大多数平台上它是 32 位。long的大小至少为 32 位。然而,有些平台,如int是 32 位而long是 64 位。在这些平台上,两个重载都应该可用。在所有其他平台上,如果两种类型的大小相同,则只有第一个重载应该可用。这可以按照前面所示的形式定义,尽管在 C++17 中可以使用 constexpr if 以以下方式实现:

void handle(long v)
{
   if constexpr (sizeof(long) > sizeof(int))
   {
      /* do something else */
   }
   else
   {
      /* do something */
   }
}

在下一节中,我们将学习如何使用约束来定义类模板的模板参数的限制。

约束类模板

类模板和类模板特化也可以像函数模板一样被约束。首先,我们将再次考虑wrapper类模板,但这次要求它只能针对整型模板参数工作。这可以在 C++20 中简单地按照以下方式指定:

template <std::integral T>
struct wrapper
{
   T value;
};
wrapper<int>    a{ 42 };    // OK
wrapper<double> b{ 42.0 };  // error

实例化int类型的模板是可以的,但对于double类型则不行,因为这不是一个整型。

可以使用 requires 子句和类模板特化来指定的要求也可以被约束。为了演示这一点,让我们考虑这样一个场景:当我们想要特化wrapper类模板,但仅针对大小为4字节的类型时。这可以按照以下方式实现:

template <std::integral T>
struct wrapper
{
   T value;
};
template <std::integral T>
requires (sizeof(T) == 4)
struct wrapper<T>
{
   union
   {
      T value;
      struct
      {
         uint8_t byte4;
         uint8_t byte3;
         uint8_t byte2;
         uint8_t byte1;
      };
   };
};

我们可以使用以下代码片段中的这个类模板:

wrapper<short> a{ 42 };
std::cout << a.value << '\n';
wrapper<int> b{ 0x11223344 };
std::cout << std::hex << b.value << '\n';
std::cout << std::hex << (int)b.byte1 << '\n';
std::cout << std::hex << (int)b.byte2 << '\n';
std::cout << std::hex << (int)b.byte3 << '\n';
std::cout << std::hex << (int)b.byte4 << '\n';

对象awrapper<short>的实例;因此,使用的是主模板。另一方面,对象bwrapper<int>的实例。由于int的大小为 4 字节(在大多数平台上),使用的是特化,我们可以通过byte1byte2byte3byte4成员访问包装值的各个类型。

最后关于这个话题,我们将讨论变量模板和模板别名也可以被约束的情况。

约束变量模板和模板别名

如你所知,除了函数模板和类模板之外,C++中还有变量模板和别名模板。它们也不例外,需要定义约束。到目前为止讨论的模板参数约束规则同样适用于这两个。在本节中,我们将简要演示它们。让我们从变量模板开始。

这是一个典型的例子,用于定义PI常量,以展示变量模板是如何工作的。实际上,这是一个看起来如下所示的定义:

template <typename T>
constexpr T PI = T(3.1415926535897932385L);

然而,这仅对浮点类型(以及可能的其他类型,如尚不存在于 C++中的decimal)有意义。因此,这个定义应该限制为浮点类型,如下所示:

template <std::floating_point T>
constexpr T PI = T(3.1415926535897932385L);
std::cout << PI<double> << '\n';  // OK
std::cout << PI<int> << '\n';     // error

使用PI<double>是正确的,但PI<int>会产生编译错误。这就是约束可以以简单和可读的方式提供的内容。

最后,我们语言中最后一种模板类别,别名模板,也可以被约束。在下面的代码片段中,我们可以看到一个这样的例子:

template <std::integral T>
using integral_vector = std::vector<T>;

T是整型时,integral_vector模板是std::vector<T>的一个别名。同样的效果可以通过以下更长一些的声明来实现:

template <typename T>
requires std::integral<T>
using integral_vector = std::vector<T>;

我们可以这样使用这个integral_vector别名模板:

integral_vector<int>    v1 { 1,2,3 };       // OK
integral_vector<double> v2 {1.0, 2.0, 3.0}; // error

定义v1对象没有问题,因为int是整型。然而,定义v2向量会产生编译错误,因为double不是整型。

如果你注意到了本节中的示例,你会注意到它们没有使用我们在本章之前使用的类型特性(以及相关的变量模板),而是使用了几个概念:std::integralstd::floating_point。这些定义在<concepts>头文件中,并帮助我们避免重复定义基于可用的 C++11(或更新的)类型特性。我们将在稍后查看标准概念库的内容。在我们这样做之前,让我们看看我们还可以用其他什么方法在 C++20 中定义约束。

学习更多指定约束的方法

在本章中,我们已经讨论了 requires 子句和 requires 表达式。尽管两者都是通过新的requires关键字引入的,但它们是不同的事物,应该被完全理解:

  • 一个 requires 子句 决定一个函数是否参与重载解析。这是基于编译时布尔表达式的值来发生的。

  • 一个 requires 表达式 决定一组一个或多个表达式是否良好形成,而不会对程序的行为产生任何副作用。一个 requires 表达式是一个布尔表达式,它可以与 requires 子句一起使用。

让我们再次看看一个例子:

template <typename T>
concept addable = requires(T a, T b) { a + b; };
                       // [1] requires expression
template <typename T>
requires addable<T>    // [2] requires clause
auto add(T a, T b)
{
   return a + b;
}

[1] 上以requires关键字开始的构造是一个要求表达式。它验证表达式a + b对于任何T都是良好形成的。另一方面,行 [2] 上的构造是一个要求子句。如果布尔表达式addable<T>评估为true,则函数参与重载解析;否则,它不参与。

虽然要求子句应该使用概念,但也可以使用要求表达式。基本上,任何可以放在概念定义中=符号右侧的内容都可以与要求子句一起使用。这意味着我们可以做以下操作:

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

虽然这是完全合法的代码,但关于它是否是使用约束的好方法存在争议。我建议避免创建以requires requires开头的构造。它们可读性较差,可能造成混淆。此外,命名概念可以在任何地方使用,而带有要求表达式的需求子句如果需要用于多个函数,则必须重复。

现在我们已经看到如何使用约束和概念以多种方式约束模板参数,让我们看看我们如何简化函数模板语法并约束模板参数。

使用概念约束auto参数

第二章 模板基础 中,我们讨论了 C++14 中引入的泛型 lambda 以及 C++20 中引入的 lambda 模板。至少有一个参数使用auto指定符的 lambda 称为泛型 lambda。编译器生成的函数对象将具有模板调用操作符。以下是一个示例以刷新您的记忆:

auto lsum = [](auto a, auto b) {return a + b; };

C++20 标准将此功能推广到所有函数。您可以在函数参数列表中使用auto指定符。这会将函数转换为模板函数。以下是一个示例:

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

这是一个接受两个参数并返回它们的和(或者更准确地说,是应用operator+于两个值的结果)的函数。使用auto作为函数参数的这种函数称为缩写函数模板。它基本上是函数模板的简写语法。前一个函数的等效模板如下:

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

我们可以像调用任何模板函数一样调用这个函数,编译器将通过用实际类型替换模板参数来生成适当的实例化。例如,让我们考虑以下调用:

add(4, 2);   // returns 6
add(4.0, 2); // returns 6.0

我们可以使用 cppinsights.io 网站来检查基于这两个调用的add缩写函数模板生成的编译器代码。以下是一些特化:

template<>
int add<int, int>(int a, int b)
{
  return a + b;
}
template<>
double add<double, int>(double a, int b)
{
  return a + static_cast<double>(b);
}

由于缩写函数模板实际上只是一个具有简化语法的常规函数模板,因此用户可以显式特化这样的函数。以下是一个示例:

template<>
auto add(char const* a, char const* b)
{
   return std::string(a) + std::string(b);
}

这是对char const*类型的完全特化。这种特化使我们能够进行如add("4", "2")这样的调用,尽管结果是std::string类型。

这种简写函数模板的类别被称为add函数,其参数类型受限为整型:

auto add(std::integral auto a, std::integral auto b)
{
   return a + b;
}

如果我们再次考虑之前看到的相同调用,第一个调用将成功,但第二个调用将产生编译器错误,因为没有重载可以接受doubleint类型的值:

add(4, 2);   // OK
add(4.2, 0); // error

受限的auto也可以用于变长简写函数模板。以下是一个示例片段:

auto add(std::integral auto ... args)
{
   return (args + ...);
}

最后但同样重要的是,受限的auto也可以与泛型 lambda 一起使用。如果我们希望本节开头显示的泛型 lambda 仅用于整型类型,那么我们可以将其约束如下:

auto lsum = [](std::integral auto a, std::integral auto b) 
{
   return a + b;
};

随着本节的结束,我们已经看到了 C++20 中与概念和约束相关的所有语言特性。接下来要讨论的是标准库提供的一组概念,其中我们已经看到了一些。我们将在下一节中这样做。

探索标准概念库

标准库提供了一组基本概念,可以用来定义对函数模板、类模板、变量模板和别名模板的模板参数的要求,正如我们在本章中看到的。C++20 的标准概念分布在几个头文件和命名空间中。虽然不是全部,但我们将在本节中介绍其中的一些。您可以在网上找到所有这些概念,地址为en.cppreference.com/

主要的概念集可以在<concepts>头文件和std命名空间中找到。其中大部分概念与一个或多个现有的类型特性等价。对于其中一些,它们的实现是明确定义的;对于一些,它是未指定的。它们被分为四个类别:核心语言概念、比较概念、对象概念和可调用概念。这个概念集包含以下内容(但不仅限于此):

图片

图片

图片

图片

表 6.1

其中一些概念是通过类型特性定义的,一些是其他概念或概念与类型特性的组合,还有一些至少部分地具有未指定的实现。以下是一些示例:

template < class T >
concept integral = std::is_integral_v<T>;
template < class T >
concept signed_integral = std::integral<T> && 
                          std::is_signed_v<T>;
template <class T>
concept regular = std::semiregular<T> && 
                  std::equality_comparable<T>;

C++20 还引入了一种基于概念的新迭代器系统,并在<iterator>头文件中定义了一组概念。以下表格中列出了其中一些:

图片

图片![表 6.2图片

表 6.2

这是 C++标准中对random_access_iterator概念的定义:

template<typename I>
concept random_access_iterator =
   std::bidirectional_iterator<I> &&
   std::derived_from</*ITER_CONCEPT*/<I>,
                     std::random_access_iterator_tag> &&
   std::totally_ordered<I> &&
   std::sized_sentinel_for<I, I> &&
   requires(I i, 
            const I j, 
            const std::iter_difference_t<I> n)
   {
      { i += n } -> std::same_as<I&>;
      { j +  n } -> std::same_as<I>;
      { n +  j } -> std::same_as<I>;
      { i -= n } -> std::same_as<I&>;
      { j -  n } -> std::same_as<I>;
      {  j[n]  } -> std::same_as<std::iter_reference_t<I>>;
   };

如您所见,它使用了几个概念(其中一些未在此列出)以及一个 requires 表达式来确保某些表达式是良好形成的。

此外,在 <iterator> 头文件中,有一组旨在简化通用算法约束的概念。其中一些概念在下一表中列出:

表 6.3

表 6.3

C++20 包含的几个主要特性之一(包括概念、模块和协程)是范围。ranges 库定义了一系列类和函数,用于简化范围操作。其中之一是一组概念。这些概念在 <ranges> 头文件和 std::ranges 命名空间中定义。以下列出了一些这些概念:

表 6.4表 6.4

表 6.4

这里是如何定义这些概念的一些例子:

template< class T >
concept range = requires( T& t ) {
   ranges::begin(t);
   ranges::end  (t);
};
template< class T >
concept sized_range = ranges::range<T> &&
   requires(T& t) {
      ranges::size(t);
   };
template< class T >
concept input_range = ranges::range<T> && 
   std::input_iterator<ranges::iterator_t<T>>;

如前所述,这里列出的概念比这些还要多。未来可能会添加更多。本节的目的不是作为标准概念的完整参考,而是作为对这些概念的介绍。您可以从官方 C++ 参考文档中了解更多关于这些概念的信息,该文档可在 en.cppreference.com/ 找到。至于范围,我们将在 第八章 范围和算法 中了解更多关于它们的信息,并探索标准库提供的功能。

摘要

C++20 标准为语言和标准库引入了一些新的主要功能。其中之一是概念,这是本章的主题。概念是一个命名约束,可以用来定义函数模板、类模板、变量模板和别名模板的模板参数的要求。

在本章中,我们详细探讨了如何使用约束和概念以及它们的工作原理。我们学习了 requires 子句(确定模板是否参与重载解析)和 requires 表达式(指定表达式良好形式的条件)。我们还看到了指定约束的各种语法。我们还学习了提供函数模板简化语法的简化函数模板。在本章末尾,我们探讨了标准库中可用的基本概念。

在下一章中,我们将把注意力转向如何将到目前为止积累的知识应用于实现各种基于模板的模式和惯用法。

问题

  1. 约束和概念是什么?

  2. requires 子句和 requires 表达式是什么?

  3. requires 表达式的类别有哪些?

  4. 约束如何影响重载解析中模板的排序?

  5. 简化函数模板是什么?

进一步阅读

第三部分:应用模板

在本部分,你将应用迄今为止积累的模板知识。你将了解静态多态以及如“古怪递归模板模式”和混入等模式,以及类型擦除、标签分派、表达式模板和类型列表。你还将了解标准容器的设计、迭代器和算法,并学习如何实现自己的。我们将探索 C++20 的 Ranges 库及其范围和约束算法,并学习如何编写自己的范围适配器。

本节包含以下章节:

  • 第七章, 模式和惯用语

  • 第八章, 范围和算法

  • 第九章, Ranges 库

第七章:第七章:模式和惯用法

本书的前几部分旨在帮助您了解有关模板的各个方面,从基础知识到最先进的功能,包括来自 C++20 的最新概念和约束。现在,是我们将这一知识付诸实践并学习各种元编程技术的时候了。在本章中,我们将讨论以下主题:

  • 动态多态与静态多态

  • 奇特重复模板模式CRTP

  • 混合

  • 类型擦除

  • 标签分派

  • 表达式模板

  • 类型列表

到本章结束时,您将很好地理解各种多编程技术,这将帮助您解决各种问题。

让我们以讨论两种多态形式:动态和静态,开始本章。

动态多态与静态多态

当您学习面向对象编程时,您会了解其基本原理,即抽象封装继承多态。C++ 是一种支持面向对象编程的多范式编程语言。尽管关于面向对象编程原理的更广泛讨论超出了本章和本书的范围,但至少讨论与多态相关的一些方面是值得的。

那么,什么是多态?这个术语来源于希腊语中的“多种形式”。在编程中,它是不同类型的对象被当作同一类型对象处理的能力。C++ 标准实际上将多态类定义为如下(见 C++20 标准,段落 11.7.2虚函数):

声明或继承虚函数的类称为多态类。

它还根据此定义定义了多态对象,如下所示(见 C++20 标准,段落 6.7.2对象模型):

一些对象是多态的(11.7.2);其实施生成与每个此类对象关联的信息,使得在程序执行期间可以确定该对象类型。

然而,这实际上指的是所谓的动态多态(或后期绑定),但还有一种称为静态多态(或早期绑定)的多态形式。动态多态在运行时通过接口和虚函数发生,而静态多态在编译时通过重载函数和模板发生。这已在 Bjarne Stroustrup 为 C++ 语言提供的术语表中描述(见 www.stroustrup.com/glossary.html):

多态 - 为不同类型的实体提供一个单一接口。虚函数通过基类提供的接口提供动态(运行时)多态。重载函数和模板提供静态(编译时)多态。

让我们看看动态多态的一个例子。以下是一个表示游戏中不同单位的类的层次结构。这些单位可以攻击其他单位,因此有一个基类,它有一个名为attack的纯虚函数,并且有几个派生类实现了特定的单位,这些单位覆盖了这个虚函数并执行不同的操作(当然,为了简单起见,这里我们只是打印一条消息到控制台)。它看起来如下:

struct game_unit
{
   virtual void attack() = 0;
};
struct knight : game_unit
{
   void attack() override
   { std::cout << "draw sword\n"; }
};
struct mage : game_unit
{
   void attack() override
   { std::cout << "spell magic curse\n"; }
};

基于这个类的层次结构(根据标准,这些类被称为fight,如下所示。它接受一个指向基类game_unit类型对象的指针序列,并调用attack成员函数。以下是它的实现:

void fight(std::vector<game_unit*> const & units)
{
   for (auto unit : units)
   {
      unit->attack();
   }
}

这个函数不需要知道每个对象的实际类型,因为由于动态多态,它可以像它们具有相同的(基)类型一样处理它们。以下是一个使用它的例子:

knight k;
mage m;
fight({&k, &m});

但现在假设你可以将一个法师和一个骑士组合起来,创建一个新的单位,一个具有这两个单位特殊能力的骑士法师。C++使我们能够编写如下代码:

knight_mage km = k + m;
km.attack();

这不是现成的,但语言支持运算符重载,我们可以为任何用户定义的类型做这件事。为了使前面的行成为可能,我们需要以下内容:

struct knight_mage : game_unit
{
   void attack() override
   { std::cout << "draw magic sword\n"; }
};
knight_mage operator+(knight const& k, mage const& m)
{
   return knight_mage{};
}

请记住,这些只是一些简单的代码片段,没有任何复杂的代码。但是,将一个knight和一个mage相加以创建一个knight_mage的能力,与将两个整数相加的能力,或者一个double和一个int相加的能力,或者两个std::string对象相加的能力不相上下。这是因为有多个+运算符的重载(既适用于内置类型也适用于用户定义的类型),并且根据操作数,编译器会选择适当的重载。因此,可以说有这种运算符的许多形式。这对于所有可以重载的运算符都适用;+运算符只是一个典型的例子,因为它无处不在。这就是编译时多态,称为静态多态

运算符不是唯一可以重载的函数。任何函数都可以重载。尽管我们在书中看到了许多例子,但让我们再看一个:

struct attack  { int value; };
struct defense { int value; };
void increment(attack& a)  { a.value++; }
void increment(defense& d) { d.value++; }

在这个代码片段中,increment函数为attackdefense类型重载,允许我们编写如下代码:

attack a{ 42 };
defense d{ 50 };
increment(a);
increment(d);

我们可以用一个函数模板替换increment的两个重载。变化很小,如下面的代码片段所示:

template <typename T>
void increment(T& t) { t.value++; }

之前的代码仍然可以工作,但有一个显著的区别:在前面的例子中,我们有两个重载,一个用于attack,一个用于defense,因此你可以用这些类型的对象调用函数,但不能用其他类型的对象。在后面,我们有一个模板,为任何可能的类型T定义了一组重载函数,这些类型具有名为value的数据成员,其类型支持后增量运算符。我们可以为这样的函数模板定义约束,这在本书的前两章中我们已经看到。然而,关键要点是重载函数和模板是实现 C++语言中静态多态的机制。

动态多态会带来性能开销,因为为了知道要调用哪些函数,编译器需要构建一个指向虚函数的指针表(以及在虚继承的情况下,还需要一个指向虚基类的指针表)。因此,在以多态方式调用虚函数时,存在一定程度的间接性。此外,虚函数的细节并未提供给编译器,编译器无法对其进行优化。

当这些事情可以验证为性能问题时,我们可以提出这样的问题:我们能否在编译时获得动态多态的好处?答案是肯定的,有一种方法可以实现这一点:奇特重复模板模式,我们将在下一节讨论。

奇特重复模板模式

这种模式有一个相当奇特的名字:奇特重复模板模式,简称CRTP。它被称为奇特,因为它相当奇怪且不直观。这个模式最初由 James Coplien 在 1995 年的《C++ Report》杂志专栏中描述(并为其命名)。这个模式如下:

  • 存在一个定义(静态)接口的基类模板。

  • 派生类本身是基类模板的模板参数。

  • 基类中的成员函数调用其类型模板参数(即派生类)的成员函数。

让我们看看这个模式在实际中的实现看起来是什么样子。我们将把之前的游戏单位示例转换为使用 CRTP 的版本。模式实现如下:

template <typename T>
struct game_unit
{
   void attack()
   {
      static_cast<T*>(this)->do_attack();
   }
};
struct knight : game_unit<knight>
{
   void do_attack()
   { std::cout << "draw sword\n"; }
};
struct mage : game_unit<mage>
{
   void do_attack()
   { std::cout << "spell magic curse\n"; }
};

game_unit类现在是一个模板类,但包含相同的成员函数attack。内部,这会将this指针向上转换为T*,然后调用名为do_attack的成员函数。knightmage类从game_unit类派生,并将自身作为类型模板参数T的参数传递。两者都提供了一个名为do_attack的成员函数。

注意到基类模板中的成员函数和派生类中调用的成员函数具有不同的名称。否则,如果它们具有相同的名称,派生类的成员函数将隐藏基类中的成员,因为这些不再是虚函数。

需要更改的 fight 函数是接受游戏单位集合并调用 attack 函数的函数。它需要实现为一个函数模板,如下所示:

template <typename T>
void fight(std::vector<game_unit<T>*> const & units)
{
   for (auto unit : units)
   {
      unit->attack();
   }
}

使用这个函数与之前略有不同。它如下所示:

knight k;
mage   m;
fight<knight>({ &k });
fight<mage>({ &m });

我们已经将运行时多态移至编译时。因此,fight 函数不能以多态方式处理 knightmage 对象。相反,我们得到两个不同的重载,一个可以处理 knight 对象,另一个可以处理 mage 对象。这是静态多态。

尽管这个模式最终可能看起来并不复杂,但你现在可能正在问自己一个问题:这个模式实际上有什么用?你可以使用 CRT 解决不同的问题,包括以下这些:

  • 限制类型实例化的次数

  • 添加通用功能并避免代码重复

  • 实现组合设计模式

在接下来的小节中,我们将查看这些问题中的每一个,并看看如何使用 CRTP 来解决它们。

使用 CRTP 限制对象计数

假设我们为创建骑士和法师的游戏需要一些物品以有限的数量实例可用。例如,有一种特殊的剑类型叫做 Excalibur,应该只有一个实例。另一方面,有一个魔法咒语的书,但游戏一次不能有超过三个实例。我们如何解决这个问题?显然,剑的问题可以用单例模式解决。但当我们需要限制数量到一个更高的但仍然有限的值时,单例模式就帮不上什么忙(除非我们将其转换为“multiton”),但 CRTP 可以。

首先,我们从一个基类模板开始。这个类模板唯一做的事情是记录它被实例化的次数。计数器,这是一个静态数据成员,在构造函数中增加,在析构函数中减少。当这个计数超过一个定义的限制时,会抛出一个异常。以下是实现:

template <typename T, size_t N>
struct limited_instances 
{
   static std::atomic<size_t> count;
   limited_instances()
   {
      if (count >= N)
         throw std::logic_error{ "Too many instances" };
      ++count;
   }
   ~limited_instances() { --count; }
};
template <typename T, size_t N>
std::atomic<size_t> limited_instances<T, N>::count = 0;

模板的第二部分是定义派生类。对于提到的问题,它们如下所示:

struct excalibur : limited_instances<excalibur, 1>
{};
struct book_of_magic : limited_instances<book_of_magic, 3>
{};

我们可以实例化 excalibur 一次。当我们第二次尝试这样做(而第一个实例仍然存活)时,将会抛出一个异常:

excalibur e1;
try
{
   excalibur e2;
}
catch (std::exception& e)
{
   std::cout << e.what() << '\n';
}

同样,我们可以实例化 book_of_magic 三次,当我们第四次尝试这样做时,将会抛出一个异常:

book_of_magic b1;
book_of_magic b2;
book_of_magic b3;
try
{
   book_of_magic b4;
}
catch (std::exception& e)
{
   std::cout << e.what() << '\n';
}

接下来,我们看看一个更常见的场景,向类型添加通用功能。

使用 CRTP 添加功能

当好奇地重复模板模式可以帮助我们的时候,另一个案例是通过基类中的泛型函数提供通用功能给派生类,该基类仅依赖于派生类成员。让我们通过一个例子来理解这个用例。

假设我们的一些游戏单位具有step_forthstep_back这样的成员函数,这些函数可以将它们移动一个位置,向前或向后。这些类将如下所示(至少是基础形式):

struct knight
{
   void step_forth();
   void step_back();
};
struct mage
{
   void step_forth();
   void step_back();
};

然而,可能有一个要求,即所有可以前后移动一步的东西也应该能够前进或后退任意数量的步骤。然而,这个功能可以根据step_forthstep_back函数实现,这有助于避免在每个游戏单元类中都有重复的代码。因此,这个问题的 CRTP 实现将如下所示:

template <typename T>
struct movable_unit
{
   void advance(size_t steps)
   {
      while (steps--)
         static_cast<T*>(this)->step_forth();
   }
   void retreat(size_t steps)
   {
      while (steps--)
         static_cast<T*>(this)->step_back();
   }
};
struct knight : movable_unit<knight>
{
   void step_forth() 
   { std::cout << "knight moves forward\n"; }
   void step_back()
   { std::cout << "knight moves back\n"; }
};
struct mage : movable_unit<mage>
{
   void step_forth()
   { std::cout << "mage moves forward\n"; }
   void step_back()
   { std::cout << "mage moves back\n"; }
};

我们可以通过调用基类的advanceretreat成员函数来前进和后退单位,如下所示:

knight k;
k.advance(3);
k.retreat(2);
mage m;
m.advance(5);
m.retreat(3);

你可能会争辩说,使用非成员函数模板也可以达到相同的结果。为了讨论的目的,这样的解决方案如下所示:

struct knight
{
   void step_forth()
   { std::cout << "knight moves forward\n"; }
   void step_back()
   { std::cout << "knight moves back\n"; }
};
struct mage
{
   void step_forth()
   { std::cout << "mage moves forward\n"; }
   void step_back()
   { std::cout << "mage moves back\n"; }
};
template <typename T>
void advance(T& t, size_t steps)
{
   while (steps--) t.step_forth();
}
template <typename T>
void retreat(T& t, size_t steps)
{
   while (steps--) t.step_back();
}

客户端代码需要更改,但更改实际上很小:

knight k;
advance(k, 3);
retreat(k, 2);
mage m;
advance(m, 5);
retreat(m, 3);

这两种选择可能取决于问题的性质和你的偏好。然而,CRTP 的优势在于它很好地描述了派生类的接口(例如,我们例子中的knightmage)。使用非成员函数,你不必知道这种功能,这可能会来自你需要包含的头文件。然而,使用 CRTP,类的接口对使用者来说非常清晰。

对于我们在这里讨论的最后一种场景,让我们看看 CRTP 如何帮助实现组合设计模式。

实现组合设计模式

在他们著名的书籍《设计模式:可复用面向对象软件元素》中,四人帮(Erich Gamma、Richard Helm、Ralph Johnson 和 John Vlissides)描述了一种结构模式,称为组合(composite),它使我们能够将对象组合成更大的结构,并统一处理单个对象和组合。当你想要表示对象的分-整体层次结构,并且你想要忽略单个对象和单个对象的组合之间的差异时,可以使用此模式。

要将这种模式付诸实践,让我们再次考虑游戏场景。我们有一些具有特殊能力并能执行不同动作的英雄,其中之一是与另一个英雄结盟。这可以很容易地建模如下:

struct hero
{
   hero(std::string_view n) : name(n) {}
   void ally_with(hero& u)
   {
      connections.insert(&u);
      u.connections.insert(this);
   }
private:
   std::string name;
   std::set<hero*> connections;
   friend std::ostream& operator<<(std::ostream& os, 
                                   hero const& obj);
};
std::ostream& operator<<(std::ostream& os, 
                         hero const& obj)
{
   for (hero* u : obj.connections)
      os << obj.name << " --> [" << u->name << "]" << '\n';
   return os;
}

这些英雄由包含名称、指向其他hero对象的连接列表以及定义两个英雄之间联盟的成员函数ally_withhero类表示。我们可以如下使用它:

hero k1("Arthur");
hero k2("Sir Lancelot");
hero k3("Sir Gawain");
k1.ally_with(k2);
k2.ally_with(k3);
std::cout << k1 << '\n';
std::cout << k2 << '\n';
std::cout << k3 << '\n';

运行此代码片段的输出如下:

Arthur --> [Sir Lancelot]
Sir Lancelot --> [Arthur]
Sir Lancelot --> [Sir Gawain]
Sir Gawain --> [Sir Lancelot]

到目前为止,一切都很简单。但要求是英雄可以被分组形成团体。应该允许一个英雄与一个团体结盟,以及一个团体可以与一个英雄或整个团体结盟。突然之间,我们需要提供的功能函数爆炸式增长:

struct hero_party;
struct hero
{
   void ally_with(hero& u);
   void ally_with(hero_party& p);
};
struct hero_party : std::vector<hero>
{
   void ally_with(hero& u);
   void ally_with(hero_party& p);
};

这就是组合设计模式如何帮助我们统一处理英雄和党派,并避免不必要的代码重复。通常,有几种不同的实现方式,但其中一种方式是使用好奇重复模板模式。实现需要定义公共接口的基类。在我们的情况下,这将是一个只有一个名为 ally_with 的成员函数的类模板:

template <typename T>
struct base_unit
{
   template <typename U>
   void ally_with(U& other);
};

我们将定义 hero 类为从 base_unit<hero> 派生的派生类。这次,hero 类不再自己实现 ally_with。然而,它具有 beginend 方法,旨在模拟容器的行为:

struct hero : base_unit<hero>
{
   hero(std::string_view n) : name(n) {}
   hero* begin() { return this; }
   hero* end() { return this + 1; }
private:
   std::string name;
   std::set<hero*> connections;
   template <typename U>
   friend struct base_unit;
   template <typename U>
   friend std::ostream& operator<<(std::ostream& os,
                                   base_unit<U>& object);
};

模拟一组英雄的类称为 hero_party,它从 std::vector<hero>(用于定义 hero 对象的容器)和 base_unit<hero_party> 中继承。这就是为什么 hero 类有 beginend 函数,帮助我们执行对 hero 对象的迭代操作,就像我们对 hero_party 对象所做的那样:

struct hero_party : std::vector<hero>, 
                    base_unit<hero_party>
{};

我们需要实现基类的 ally_with 成员函数。代码如下所示。它的作用是遍历当前对象的所有子对象,并将它们与提供的参数的所有子对象连接起来:

template <typename T>
template <typename U>
void base_unit<T>::ally_with(U& other)
{
   for (hero& from : *static_cast<T*>(this))
   {
      for (hero& to : other)
      {
         from.connections.insert(&to);
         to.connections.insert(&from);
      }
   }
}

hero 类将 base_unit 类模板声明为友元,以便它可以访问 connections 成员。它还将 operator<< 声明为友元,以便这个函数可以访问 connectionsname 私有成员。有关模板和友元的更多信息,请参阅第四章高级模板概念。输出流操作符的实现如下所示:

template <typename T>
std::ostream& operator<<(std::ostream& os,
                         base_unit<T>& object)
{
   for (hero& obj : *static_cast<T*>(&object))
   {
      for (hero* n : obj.connections)
         os << obj.name << " --> [" << n->name << "]" 
            << '\n';
   }
   return os;
}

在定义了所有这些之后,我们可以编写如下代码:

hero k1("Arthur");
hero k2("Sir Lancelot");
hero_party p1;
p1.emplace_back("Bors");
hero_party p2;
p2.emplace_back("Cador");
p2.emplace_back("Constantine");
k1.ally_with(k2);
k1.ally_with(p1);
p1.ally_with(k2);
p1.ally_with(p2);
std::cout << k1 << '\n';
std::cout << k2 << '\n';
std::cout << p1 << '\n';
std::cout << p2 << '\n';

从这里我们可以看到,我们能够将 hero 与另一个 herohero_party 结盟,以及将 hero_partyhero 或另一个 hero_party 结盟。这正是我们提出的目标,我们能够在 herohero_party 之间不重复代码的情况下实现它。执行前面的代码片段的输出如下:

Arthur --> [Sir Lancelot]
Arthur --> [Bors]
Sir Lancelot --> [Arthur]
Sir Lancelot --> [Bors]
Bors --> [Arthur]
Bors --> [Sir Lancelot]
Bors --> [Cador]
Bors --> [Constantine]
Cador --> [Bors]
Constantine --> [Bors]

在看到 CRTP 如何帮助实现不同的目标之后,让我们看看 C++标准库中 CRTP 的使用。

标准库中的 CRTP

标准库包含一个名为 std::enabled_shared_from_this 的辅助类型(在 <memory> 头文件中),它允许由 std::shared_ptr 管理的对象以安全的方式生成更多的 std::shared_ptr 实例。std::enabled_shared_from_this 类是 CRTP 模式中的基类。然而,前面的描述可能听起来很抽象,所以让我们通过示例来尝试理解它。

假设我们有一个名为 building 的类,并且我们正在以下方式创建 std::shared_ptr 对象:

struct building {};
building* b = new building();
std::shared_ptr<building> p1{ b }; // [1]
std::shared_ptr<building> p2{ b }; // [2] bad

我们有一个原始指针,在行[1]中,我们实例化了一个std::shared_ptr对象来管理其生命周期。然而,在行[2]中,我们为同一个指针实例化了一个第二个std::shared_ptr对象。不幸的是,这两个智能指针彼此之间一无所知,因此当它们超出作用域时,它们都会删除在堆上分配的building对象。删除已经删除的对象是未定义的行为,很可能会导致程序崩溃。

std::enable_shared_from_this类帮助我们以安全的方式从一个现有的对象创建更多的shared_ptr对象。首先,我们需要实现 CRTP 模式,如下所示:

struct building : std::enable_shared_from_this<building>
{
};

在有了这个新的实现之后,我们可以调用成员函数shared_from_this从一个对象创建更多的std::shared_ptr实例,它们都指向同一个对象实例:

building* b = new building();
std::shared_ptr<building> p1{ b };    // [1]
std::shared_ptr<building> p2{ 
   b->shared_from_this()};            // [2] OK

std::enable_shared_from_this的接口如下:

template <typename T>
class enable_shared_from_this
{
public:
  std::shared_ptr<T>       shared_from_this();
  std::shared_ptr<T const> shared_from_this() const;
  std::weak_ptr<T>       weak_from_this() noexcept;
  std::weak_ptr<T const> weak_from_this() const noexcept;
  enable_shared_from_this<T>& operator=(
     const enable_shared_from_this<T> &obj ) noexcept;
};

之前的例子展示了enable_shared_from_this是如何工作的,但它并没有帮助理解何时应该使用它。因此,让我们修改例子以展示一个现实世界的例子。

让我们考虑一下我们拥有的建筑可以进行升级。这是一个需要一些时间并涉及多个步骤的过程。这个任务以及游戏中的其他任务都是由一个指定的实体执行的,我们将称之为executor。在其最简单的形式中,这个executor类有一个名为execute的公共成员函数,它接受一个函数对象并在不同的线程上执行它。以下是一个简单的实现:

struct executor
{
   void execute(std::function<void(void)> const& task)
   {
      threads.push_back(std::thread([task]() { 
         using namespace std::chrono_literals;
         std::this_thread::sleep_for(250ms);
         task(); 
      }));
   }
   ~executor()
   {
      for (auto& t : threads)
         t.join();
   }
private:
   std::vector<std::thread> threads;
};

building类有一个指向executor的指针,它由客户端传递。它还有一个名为upgrade的成员函数,它启动执行过程。然而,实际的升级发生在另一个不同的、私有的、名为do_upgrade的函数中。这是从传递给executorexecute成员函数的 lambda 表达式调用的。所有这些都在以下列表中展示:

struct building
{
   building()  { std::cout << "building created\n"; }
   ~building() { std::cout << "building destroyed\n"; }
   void upgrade()
   {
      if (exec)
      {
         exec->execute([self = this]() {
            self->do_upgrade();
         });
      }
   }      
   void set_executor(executor* e) { exec = e; }
private:
   void do_upgrade()
   {
      std::cout << "upgrading\n";
      operational = false;
      using namespace std::chrono_literals;
      std::this_thread::sleep_for(1000ms);
      operational = true;
      std::cout << "building is functional\n";
   }
   bool operational = false;
   executor* exec = nullptr;
};

客户端代码相对简单:创建一个executor,创建一个由shared_ptr管理的建筑,设置executor引用,并运行升级过程:

int main()
{
   executor e;
   std::shared_ptr<building> b = 
      std::make_shared<building>();
   b->set_executor(&e);
   b->upgrade();
   std::cout << "main finished\n";
}

如果你运行这个程序,你会得到以下输出:

building created
main finished
building destroyed
upgrading
building is functional

我们可以看到的是,在升级过程开始之前,建筑就被销毁了。这会导致未定义的行为,尽管这个程序没有崩溃,但在现实世界的程序中肯定会崩溃。

这种行为的罪魁祸首是这个升级代码中的特定一行:

exec->execute([self = this]() {
   self->do_upgrade();
});

我们正在创建一个捕获this指针的 lambda 表达式。该指针在它指向的对象被销毁后被使用。为了避免这种情况,我们需要创建并捕获一个shared_ptr对象。通过std::enable_shared_from_this类来做到这一点是安全的。需要进行两个更改。第一个是将building类实际派生自std::enable_shared_from_this类:

struct building : std::enable_shared_from_this<building>
{
   /* … */
};

第二个变化要求我们在 lambda 捕获中调用shared_from_this

exec->execute([self = shared_from_this()]() {
   self->do_upgrade();
});

这只是我们代码中微小的两个变化,但效果是显著的。构建对象不再在 lambda 表达式在单独的线程上执行之前被销毁(因为现在有一个额外的共享指针指向与主函数中创建的共享指针相同的对象)。因此,我们得到了预期的输出(无需对客户端代码进行任何更改):

building created
main finished
upgrading
building is functional
building destroyed

你可能会争辩说,在主function完成后,我们不应该关心会发生什么。请注意,这只是一个演示程序,在实际应用中,这种情况发生在某个其他函数中,并且程序在函数返回后继续运行很长时间。

通过这一点,我们结束了关于奇特重复模板模式(curiously recurring template pattern)的讨论。接下来,我们将探讨一种称为混合(mixins)的技术,它通常与 CRTP 模式结合使用。

混入(Mixins)

混入是设计用来向其他类添加功能的小类。如果你阅读有关混合的内容,你经常会发现奇特重复模板模式(CRTP)被用来在 C++中实现混合。这是一个错误的说法。CRTP 有助于实现与混合类似的目标,但它们是不同的技术。混合的关键点在于它们应该向类添加功能,而不成为它们的基类,这是 CRTP 模式的关键。相反,混合应该从它们添加功能的类中继承,这是 CRTP 模式颠倒过来的。

记得之前关于骑士和法师的例子,它们可以用step_forthstep_back成员函数前后移动?knightmage类是从添加了advanceretreat函数的movable_unit类模板派生出来的,这些函数使得单位可以向前或向后移动几步。同样的例子可以使用混合以相反的顺序实现。下面是如何做的:

struct knight
{
   void step_forth()
   {
      std::cout << "knight moves forward\n";
   }
   void step_back()
   {
      std::cout << "knight moves back\n";
   }
};
struct mage
{
   void step_forth()
   {
      std::cout << "mage moves forward\n";
   }
   void step_back()
   {
      std::cout << "mage moves back\n";
   }
};
template <typename T>
struct movable_unit : T
{
   void advance(size_t steps)
   {
      while (steps--)
         T::step_forth();
   }
   void retreat(size_t steps)
   {
      while (steps--)
         T::step_back();
   }
};

你会注意到,knightmage现在是没有基类的类。它们都提供了step_forthstep_back成员函数,就像我们在实现 CRTP 模式时做的那样。现在,movable_unit类模板从这些类之一派生出来,并定义了advanceretreat函数,这些函数在循环中调用step_forthstep_back。我们可以这样使用它们:

movable_unit<knight> k;
k.advance(3);
k.retreat(2);
movable_unit<mage> m;
m.advance(5);
m.retreat(3);

这与 CRTP 模式非常相似,只是现在我们创建movable_unit<knight>movable_unit<mage>的实例,而不是knightmage。以下图表显示了两种模式的比较(CRTP 位于左侧,混合位于右侧):

![图 7.1:CRTP 和混合模式的比较]

图 7.1:CRTP 和混合模式的比较

图 7.1:CRTP 和混合模式的比较

我们可以将通过混入实现的静态多态与通过接口和虚函数实现的动态多态结合起来。我们将通过一个关于战斗游戏单位的例子来演示这一点,当我们讨论 CRTP 时,我们已经有一个早期的例子,其中“骑士”和“法师”类有一个名为attack的成员函数。

假设我们想要定义多种攻击风格。例如,每个游戏单位可以使用激进的或温和的攻击风格。这意味着有四种组合:激进的骑士和温和的骑士,以及激进的法师和温和的法师。另一方面,骑士和法师都可以是独自战斗的战士,他们习惯于单独作战,或者他们是团队玩家,总是与其他单位一起组成团队作战。

这意味着我们可以有单独的激进骑士和单独的温和骑士,以及团队玩家的激进骑士和团队玩家的温和骑士。对于法师也是如此。正如你所看到的,组合的数量增长了很多,混入是一个很好的方法,可以在不扩展“骑士”和“法师”类的情况下提供这种附加功能。最后,我们希望能够在运行时以多态的方式处理所有这些。让我们看看我们如何做到这一点。

首先,我们可以定义激进的和温和的战斗风格。这些可能就像以下这样简单:

struct aggressive_style
{
   void fight()
   {
      std::cout << "attack! attack attack!\n";
   }
};
struct moderate_style
{
   void fight()
   {
      std::cout << "attack then defend\n";
   }
};

接下来,我们定义混入作为能够单独作战或团队作战的要求。这些类是模板,并从它们的模板参数派生:

template <typename T>
struct lone_warrior : T
{
   void fight()
   {
      std::cout << "fighting alone.";
      T::fight();
   }
};
template <typename T>
struct team_warrior : T
{
   void fight()
   {
      std::cout << "fighting with a team.";
      T::fight();
   }
};

最后,我们需要定义“骑士”和“法师”类。这些类本身将是战斗风格的混入。然而,为了能够在运行时以多态的方式处理它们,我们从一个包含纯虚方法attack的基类game_unit派生它们,这些类实现了这个方法:

struct game_unit
{
   virtual void attack() = 0;
   virtual ~game_unit() = default;
};
template <typename T>
struct knight : T, game_unit
{
   void attack()
   {
      std::cout << "draw sword.";
      T::fight();
   }
};
template <typename T>
struct mage : T, game_unit
{
   void attack()
   {
      std::cout << "spell magic curse.";
      T::fight();
   }
};

“骑士”和“法师”对attack成员函数的实现使用了T::fight方法。你可能已经注意到,一方面,aggresive_stylemoderate_style类以及另一方面lone_warriorteam_warrior混入类都提供了这样的成员函数。这意味着我们可以做以下组合:

std::vector<std::unique_ptr<game_unit>> units;
units.emplace_back(new knight<aggressive_style>());
units.emplace_back(new knight<moderate_style>());
units.emplace_back(new mage<aggressive_style>());
units.emplace_back(new mage<moderate_style>());
units.emplace_back(
   new knight<lone_warrior<aggressive_style>>());
units.emplace_back(
   new knight<lone_warrior<moderate_style>>());
units.emplace_back(
   new knight<team_warrior<aggressive_style>>());
units.emplace_back(
   new knight<team_warrior<moderate_style>>());
units.emplace_back(
   new mage<lone_warrior<aggressive_style>>());
units.emplace_back(
   new mage<lone_warrior<moderate_style>>());
units.emplace_back(
   new mage<team_warrior<aggressive_style>>());
units.emplace_back(
   new mage<team_warrior<moderate_style>>());
for (auto& u : units)
   u->attack();

总共有 12 种组合是我们在这里定义的。这所有的一切都只需要六个类。这显示了混入如何帮助我们添加功能,同时将代码的复杂性保持在较低水平。如果我们运行代码,我们会得到以下输出:

draw sword.attack! attack attack!
draw sword.attack then defend
spell magic curse.attack! attack attack!
spell magic curse.attack then defend
draw sword.fighting alone.attack! attack attack!
draw sword.fighting alone.attack then defend
draw sword.fighting with a team.attack! attack attack!
draw sword.fighting with a team.attack then defend
spell magic curse.fighting alone.attack! attack attack!
spell magic curse.fighting alone.attack then defend
spell magic curse.fighting with a team.attack! attack attack!
spell magic curse.fighting with a team.attack then defend

我们在这里探讨了两种模式,CRTP 和混入,它们都旨在向其他类添加额外的(常见)功能。然而,尽管它们看起来相似,但它们的结构相反,不应该混淆。一种从无关类型中利用共同功能的技术称为类型擦除,我们将在下一节讨论。

类型擦除

术语“空指针”(这是 C 语言的遗留问题,应该避免),但真正的类型擦除是通过模板实现的。在我们讨论这个话题之前,让我们简要地看看其他的内容。

最基本的类型擦除形式是使用void指针。这在 C 语言中很典型,虽然在 C++中也是可能的,但并不推荐使用。它不是类型安全的,因此容易出错。然而,为了讨论的目的,让我们看看这种方法的例子。

假设我们再次有knightmage类型,它们两者都有一个攻击函数(一种行为),我们想要以相同的方式处理它们以展示这种行为。让我们先看看这些类:

struct knight
{
   void attack() { std::cout << "draw sword\n"; }
};
struct mage
{
   void attack() { std::cout << "spell magic curse\n"; }
};

在 C 语言风格的实现中,我们可以为这些类型中的每一个都定义一个函数,它接受一个指向该类型对象的void*,将其转换为期望的指针类型,然后调用attack成员函数:

void fight_knight(void* k)
{
   reinterpret_cast<knight*>(k)->attack();
}
void fight_mage(void* m)
{
   reinterpret_cast<mage*>(m)->attack();
}

它们有类似的签名;唯一不同的是名称。因此,我们可以定义一个函数指针,然后将一个对象(或者更精确地说,一个对象的指针)与处理它的函数的指针相关联。下面是如何做的:

using fight_fn = void(*)(void*);
void fight(
   std::vector<std::pair<void*, fight_fn>> const& units)
{
   for (auto& u : units)
   {
      u.second(u.first);
   }
}

在这个最后的片段中没有任何类型信息。所有这些信息都是通过void指针被擦除的。fight函数可以这样调用:

knight k;
mage m;
std::vector<std::pair<void*, fight_fn>> units {
   {&k, &fight_knight},
   {&m, &fight_mage},
};
fight(units);

从 C++的角度来看,这可能会看起来很奇怪。确实如此。在这个例子中,我将 C 技术结合到了 C++类中。希望我们不会在生产代码中看到这样的代码片段。如果你将mage传递给fight_knight函数或相反,仅仅是一个简单的打字错误,事情就会出错。尽管如此,这是可能的,这也是一种类型擦除的形式。

在 C++中,一个明显的替代方案是通过继承使用多态。这正是我们在本章开头看到的第一个解决方案。为了方便,我在这里再次呈现它:

struct game_unit
{
   virtual void attack() = 0;
};
struct knight : game_unit
{
   void attack() override 
   { std::cout << "draw sword\n"; }
};
struct mage : game_unit
{
   void attack() override 
   { std::cout << "spell magic curse\n"; }
};
void fight(std::vector<game_unit*> const & units)
{
   for (auto unit : units)
      unit->attack();
}

fight函数可以统一处理knightmage对象。它对传递给它的实际对象的地址一无所知(在vector中)。然而,可以争论说类型并没有被完全擦除。knightmage都是game_unitfight函数处理任何是game_unit的类型。要使另一个类型由这个函数处理,它需要从game_unit纯抽象类派生。

有时候这是不可能的。也许我们想要以类似的方式处理无关的类型(这个过程称为鸭子类型),但我们无法改变这些类型。例如,我们并不拥有源代码。这个问题的解决方案是使用模板进行真正的类型擦除。

在我们看到这个模式的样子之前,让我们一步一步地来理解这个模式是如何发展的,从无关的knightmage开始,以及我们不能修改它们的假设。然而,我们可以围绕它们编写包装器,以提供对公共功能(行为)的统一接口:

struct knight
{
   void attack() { std::cout << "draw sword\n"; }
};
struct mage
{
   void attack() { std::cout << "spell magic curse\n"; }
};
struct game_unit
{
   virtual void attack() = 0;
   virtual ~game_unit() = default;
};
struct knight_unit : game_unit
{
   knight_unit(knight& u) : k(u) {}
   void attack() override { k.attack(); }\
private:
   knight& k;
};
struct mage_unit : game_unit
{
   mage_unit(mage& u) : m(u) {}
   void attack() override { m.attack(); }
private:
   mage& m;
};
void fight(std::vector<game_unit*> const & units)
{
   for (auto u : units)
      u->attack();
}

我们不需要在game_unit中调用attack成员函数与knightmage中的调用相同。它可以有任意名称。这种选择纯粹是基于模仿原始行为名称。fight函数接受指向game_unit的指针集合,因此能够统一处理knightmage对象,如下所示:

knight k;
mage m;
knight_unit ku{ k };
mage_unit mu{ m };
std::vector<game_unit*> v{ &ku, &mu };
fight(v);

这个解决方案的问题是存在大量的重复代码。knight_unitmage_unit类大部分相同。当其他类需要以类似方式处理时,这种重复会更多。解决代码重复的方法是使用模板。我们将knight_unitmage_unit替换为以下类模板:

template <typename T>
struct game_unit_wrapper : public game_unit
{
   game_unit_wrapper(T& unit) : t(unit) {}
   void attack() override { t.attack(); }
private:
   T& t;
};

在我们的源代码中,这个类只有一个副本,但编译器会根据其使用情况实例化多个特殊化版本。任何类型信息都已删除,除了某些类型限制——T类型必须有一个不带参数的成员函数名为attack。请注意,fight函数没有任何变化。尽管如此,客户端代码需要稍作修改:

knight k;
mage m;
game_unit_wrapper ku{ k };
game_unit_wrapper mu{ m };
std::vector<game_unit*> v{ &ku, &mu };
fight(v);

这导致我们将抽象基类和包装类模板放入另一个类中,形成了类型擦除模式的形式:

struct game
{
   struct game_unit
   {
      virtual void attack() = 0;
      virtual ~game_unit() = default;
   };
   template <typename T>
   struct game_unit_wrapper : public game_unit
   {
      game_unit_wrapper(T& unit) : t(unit) {}
      void attack() override { t.attack(); }
   private:
      T& t;
   };
   template <typename T>
   void addUnit(T& unit)
   {
      units.push_back(
         std::make_unique<game_unit_wrapper<T>>(unit));
   }
   void fight()
   {
      for (auto& u : units)
         u->attack();
   }
private:
   std::vector<std::unique_ptr<game_unit>> units;
};

game类包含一组game_unit对象,并有一个方法可以向任何具有attack成员函数的游戏单元添加新包装器。它还有一个成员函数fight,用于调用共同的行为。这次,客户端代码如下所示:

knight k;
mage m;
game g;
g.addUnit(k);
g.addUnit(m);
g.fight();

在类型擦除模式中,抽象基类被称为概念,从它继承的包装器被称为模型。如果我们按照既定的正式方式实现类型擦除模式,它看起来如下所示:

struct unit
{
   template <typename T>
   unit(T&& obj) : 
      unit_(std::make_shared<unit_model<T>>(
               std::forward<T>(obj))) 
   {}
   void attack()
   {
      unit_->attack();
   }
   struct unit_concept
   {
      virtual void attack() = 0;
      virtual ~unit_concept() = default;
   };
   template <typename T>
   struct unit_model : public unit_concept
   {
      unit_model(T& unit) : t(unit) {}
      void attack() override { t.attack(); }
   private:
      T& t;
   };
private:
   std::shared_ptr<unit_concept> unit_;
};
void fight(std::vector<unit>& units)
{
   for (auto& u : units)
      u.attack();
}

在这个片段中,game_unit被重命名为unit_conceptgame_unit_wrapper被重命名为unit_model。除了名称之外,它们没有其他变化。它们是新类unit的成员,该类存储指向实现unit_concept的对象的指针;这可以是unit_model<knight>unit_model<mage>unit类有一个模板构造函数,使我们能够从knightmage对象创建这样的模型对象。

它还有一个公共成员函数,attack(再次,这可以有任何名称)。另一方面,fight函数处理unit对象并调用它们的attack成员函数。客户端代码可能如下所示:

knight k;
mage m;
std::vector<unit> v{ unit(k), unit(m) };
fight(v);

如果你想知道这个模式在现实世界代码中的使用情况,标准库本身就有两个例子:

  • std::function:这是一个通用多态函数包装器,使我们能够存储、复制和调用任何可调用的事物,例如函数、lambda 表达式、绑定表达式、函数对象、成员函数指针和数据成员指针。以下是一个使用std::function的示例:

    class async_bool
    {
       std::function<bool()> check;
    public:
       async_bool() = delete;
       async_bool(std::function<bool()> checkIt)
          : check(checkIt)
       { }
       async_bool(bool val)
          : check([val]() {return val; })
       { }
       operator bool() const { return check(); }
    };
    async_bool b1{ false };
    async_bool b2{ true };
    async_bool b3{ []() { std::cout << "Y/N? "; 
                          char c; std::cin >> c; 
                          return c == 'Y' || c == 'y'; } };
    if (b1) { std::cout << "b1 is true\n"; }
    if (b2) { std::cout << "b2 is true\n"; }
    if (b3) { std::cout << "b3 is true\n"; }
    
  • std::any:这是一个表示可以存储任何可复制构造类型值的容器的类。以下代码片段中使用了示例:

    std::any u;
    u = knight{};
    if (u.has_value())
       std::any_cast<knight>(u).attack();
    u = mage{};
    if (u.has_value())
       std::any_cast<mage>(u).attack();
    

类型擦除是一种结合面向对象编程中的继承和模板以创建可以存储任何类型的包装器的惯用语。在本节中,我们看到了这种模式的形状和它的工作方式,以及一些该模式的实际应用实例。

在本章的下一部分,我们将讨论一种称为标签分派的技术。

标签分派

std::enable_ifSFINAE简单易懂且易于使用。术语标签描述的是一个没有成员(数据)或函数(行为)的空类。此类仅用于定义函数的参数(通常是最后一个),以决定在编译时是否选择它。为了更好地理解这一点,让我们考虑一个例子。

标准库中包含一个名为std::advance的实用函数,其形式如下:

template<typename InputIt, typename Distance>
void advance(InputIt& it, Distance n);

注意,在 C++17 中,这也可以是constexpr(关于这一点,稍后会有更多介绍)。此函数通过n个元素增加给定的迭代器。然而,存在几种迭代器类别(输入、输出、前向、双向和随机访问)。这意味着此类操作可以以不同的方式计算:

  • 对于输入迭代器,它可能需要调用operator++多次,次数为n

  • 对于双向迭代器,它可能需要调用operator++多次(如果n是正数),或者调用operator--多次(如果n是负数)。

  • 对于随机访问迭代器,它可以使用operator+=直接通过n个元素来增加它。

这意味着可以有三种不同的实现,但应该可以在编译时选择最适合所调用迭代器类别的那个。解决这个问题的一个方法是标签分派。首先要做的事情是定义标签。如前所述,标签是空类。因此,对应于五种迭代器类型的标签可以定义为以下内容:

struct input_iterator_tag {};
struct output_iterator_tag {};
struct forward_iterator_tag : input_iterator_tag {};
struct bidirectional_iterator_tag : 
   forward_iterator_tag {};
struct random_access_iterator_tag : 
   bidirectional_iterator_tag {};

这正是 C++标准库中在std命名空间中定义的方式。这些标签将被用来为std::advance的每个重载定义一个额外的参数,如下所示:

namespace std
{
   namespace details 
   {
      template <typename Iter, typename Distance>
      void advance(Iter& it, Distance n, 
                   std::random_access_iterator_tag)
      {
         it += n;
      }
      template <typename Iter, typename Distance>
      void advance(Iter& it, Distance n, 
                   std::bidirectional_iterator_tag)
      {
         if (n > 0)
         {
            while (n--) ++it;
         }
         else
         {
            while (n++) --it;
         }
      }
      template <typename Iter, typename Distance>
      void advance(Iter& it, Distance n, 
                   std::input_iterator_tag)
      {
         while (n--)
         {
            ++it;
         }
      }
   }
}

这些重载在std命名空间的一个单独的(内部)命名空间中定义,这样就不会污染标准命名空间中的不必要的定义。您可以看到,这些重载中的每一个都有三个参数:迭代器的引用、要增加(或减少)的元素数量,以及一个标签。

最后要做的事情是提供一个advance函数的定义,该函数旨在直接使用。此函数没有第三个参数,但它通过确定所调用迭代器的类别来调用这些重载之一。其实现可能如下所示:

namespace std
{
   template <typename Iter, typename Distance>
   void advance(Iter& it, Distance n)
   {
      details::advance(it, n,
         typename std::iterator_traits<Iter>::
                          iterator_category{});
   }
}

这里看到的 std::iterator_traits 类定义了一种迭代器类型的接口。为此,它包含几个成员类型,其中之一是 iterator_category。这解析为之前定义的迭代器标签之一,例如 std::input_iterator_tag 用于输入迭代器或 std::random_access_iterator_tag 用于随机访问迭代器。因此,基于提供的迭代器类别,它实例化这些标签类之一,确定在编译时从 details 命名空间中选择适当的重载实现。我们可以如下调用 std::advance 函数:

std::vector<int> v{ 1,2,3,4,5 };
auto sv = std::begin(v);
std::advance(sv, 2);
std::list<int> l{ 1,2,3,4,5 };
auto sl = std::begin(l);
std::advance(sl, 2);

std::vector 迭代器的类别类型是随机访问。另一方面,std::list 的迭代器类别类型是双向的。然而,我们可以使用一个函数,该函数通过利用标签分发的技术,依赖于不同的优化实现。

标签分发的替代方案

在 C++17 之前,标签分发的唯一替代方案是 SFINAE 与 enable_if。我们已经在 第五章类型特性和条件编译 中讨论了此主题。这是一种相当过时的技术,在现代 C++ 中有更好的替代方案。这些替代方案是 constexpr ifconcepts。让我们逐一讨论它们。

使用 constexpr if

C++11 引入了 constexpr 值的概念,这些值在编译时已知,但还有 constexpr 函数,这些函数可以在编译时(如果所有输入都是编译时值)进行评估。在 C++14、C++17 和 C++20 中,许多标准库函数或标准库类的成员函数已被更改为 constexpr。其中之一是 std::advance,其在 C++17 中的实现基于 C++17 中也添加的 constexpr if 功能(在 第五章类型特性和条件编译 中讨论过)。

以下是在 C++17 中的一个可能的实现:

template<typename It, typename Distance>
constexpr void advance(It& it, Distance n)
{
   using category = 
     typename std::iterator_traits<It>::iterator_category;
   static_assert(std::is_base_of_v<std::input_iterator_tag,
                                   category>);
   auto dist = 
     typename std::iterator_traits<It>::difference_type(n);
   if constexpr (std::is_base_of_v<
                    std::random_access_iterator_tag, 
                    category>)
   {
      it += dist;
   }
   else
   {
      while (dist > 0)
      {
         --dist;
         ++it;
      }
      if constexpr (std::is_base_of_v<
                       std::bidirectional_iterator_tag, 
                       category>)
      {
         while (dist < 0)
         {
            ++dist;
            --it;
         }
      }
   }
}

虽然此实现仍然使用我们之前看到的迭代器标签,但它们不再用于调用不同的重载函数,而是用于确定某些编译时表达式的值。std::is_base_of 类型特性(通过 std::is_base_of_v 变量模板)用于在编译时确定迭代器类别。

此实现有几个优点:

  • 算法具有单个实现(在 std 命名空间中)

  • 不需要使用在单独命名空间中定义的实现细节进行多次重载

客户端代码不受影响。因此,库实现者能够用基于 constexpr if 的新版本替换基于标签分发的旧版本,而不影响调用 std::advance 的任何代码行。

然而,在 C++20 中有一个更好的替代方案。让我们接下来探索它。

使用概念

上一章是关于约束和概念,这些概念是在 C++20 中引入的。我们不仅看到了这些功能是如何工作的,还看到了标准库在几个头文件中定义的一些概念,如<concepts><iterator>。其中一些概念指定了一个类型是某种迭代器类别。例如,std::input_iterator指定了一个类型是输入迭代器。同样,以下概念也被定义了:std::output_iteratorstd::forward_iteratorstd::bidirectional_iteratorstd::random_access_iteratorstd::contiguous_iterator(最后一个表示迭代器是一个随机访问迭代器,指的是存储在内存中连续的元素)。

std::input_iterator概念定义如下:

template<class I>
   concept input_iterator =
      std::input_or_output_iterator<I> &&
      std::indirectly_readable<I> &&
      requires { typename /*ITER_CONCEPT*/<I>; } &&
      std::derived_from</*ITER_CONCEPT*/<I>, 
                        std::input_iterator_tag>;

不深入太多细节,值得注意的是,这个概念是一组约束,用于验证以下内容:

  • 迭代器是可解引用的(支持*i)并且是可递增的(支持++ii++)。

  • 迭代器类别是从std::input_iterator_tag派生出来的。

这意味着类别检查是在约束内进行的。因此,这些概念仍然基于迭代器标签,但与标签分发的技术相比,这种方法有显著的不同。因此,在 C++20 中,我们可能对std::advance算法有另一种实现,如下所示:

template <std::random_access_iterator Iter, class Distance>
void advance(Iter& it, Distance n)
{
   it += n;
}
template <std::bidirectional_iterator Iter, class Distance>
void advance(Iter& it, Distance n)
{
   if (n > 0)
   {
      while (n--) ++it;
   }
   else
   {
      while (n++) --it;
   }
}
template <std::input_iterator Iter, class Distance>
void advance(Iter& it, Distance n)
{
   while (n--)
   {
      ++it;
   }
}

在这里有几个需要注意的地方:

  • 高级函数又有三个不同的重载。

  • 这些重载是在std命名空间中定义的,并且不需要单独的命名空间来隐藏实现细节。

虽然我们再次明确地写了几种重载,但与基于 constexpr if 的解决方案相比,这个解决方案更容易阅读和理解,因为代码被很好地分成了不同的单元(函数),这使得它更容易跟踪。

标签分发是在编译时选择重载的重要技术。它有其权衡,但如果你在使用 C++17 或 C++20,那么它也有更好的替代方案。如果你的编译器支持概念,你应该根据前面提到的原因选择这个替代方案。

本章我们将探讨的下一个模式是表达式模板。

表达式模板

表达式模板是一种元编程技术,它允许在编译时延迟计算。这有助于避免在运行时发生的低效操作。然而,这并非没有代价,因为表达式模板需要更多的代码,并且可能难以阅读或理解。它们通常用于线性代数库的实现中。

在了解表达式模板是如何实现之前,让我们先了解它们解决的是什么问题。为此,让我们假设我们想要对矩阵进行一些操作,为此我们实现了基本操作,如加法、减法和乘法(两个矩阵之间的乘法或标量与矩阵之间的乘法)。我们可以有以下表达式:

auto r1 = m1 + m2;
auto r2 = m1 + m2 + m3;
auto r3 = m1 * m2 + m3 * m4;
auto r4 = m1 + 5 * m2;

在这个片段中,m1m2m3m4 是矩阵;同样,r1r2r3r4 是通过右侧操作得到的矩阵。第一个操作没有问题:m1m2 相加,结果赋值给 r1。然而,第二个操作不同,因为有三个矩阵相加。这意味着首先将 m1m2 相加,然后创建一个临时变量,该临时变量随后与 m3 相加,并将结果赋值给 r2

对于第三次操作,有两个临时变量:一个用于计算 m1m2 的乘积,另一个用于计算 m3m4 的乘积;这两个乘积相加后,结果赋值给 r3。最后,最后一个操作与第二个操作类似,意味着通过标量 5 和矩阵 m2 的乘积得到一个临时对象,然后将这个临时对象加到 m1 上,并将结果赋值给 r4

操作越复杂,生成的临时变量就越多。当对象较大时,这可能会影响性能。表达式模板通过将计算建模为编译时表达式来帮助避免这种情况。整个数学表达式(如 m1 + 5 * m2)在赋值评估时成为一个单独的表达式模板,而不需要任何临时对象。

为了演示这一点,我们将使用向量而不是矩阵来构建一些示例,因为这些数据结构更简单,练习的重点不是关注数据的表示,而是创建表达式模板。在下面的列表中,你可以看到一个向量的最小实现,它提供了几个操作:

  • 从初始化列表或表示大小的值(没有初始化值)构造实例

  • 获取向量中的元素数量

  • 使用下标运算符([])访问元素

代码如下:

template<typename T>
struct vector
{
   vector(std::size_t const n) : data_(n) {}
   vector(std::initializer_list<T>&& l) : data_(l) {}
   std::size_t size() const noexcept
   { 
      return data_.size();
   }
   T const & operator[](const std::size_t i) const
   {
      return data_[i];
   }
   T& operator[](const std::size_t i)
   {
      return data_[i];
   }
private:
   std::vector<T> data_;
};

这看起来非常类似于 std::vector 标准容器,实际上,它使用这个容器内部来存储数据。然而,这个方面与我们想要解决的问题无关。记住,我们使用向量而不是矩阵,因为它更容易用几行代码表示。有了这个类,我们可以定义必要的操作:加法和乘法,这两个操作都是在两个向量之间以及标量和向量之间进行的:

template<typename T, typename U>
auto operator+ (vector<T> const & a, vector<U> const & b)
{
   using result_type = decltype(std::declval<T>() + 
                                std::declval<U>());
   vector<result_type> result(a.size());
   for (std::size_t i = 0; i < a.size(); ++i)
   {
      result[i] = a[i] + b[i];
   }
   return result;
}
template<typename T, typename U>
auto operator* (vector<T> const & a, vector<U> const & b)
{
   using result_type = decltype(std::declval<T>() + 
                                std::declval<U>());
   vector<result_type> result(a.size());
   for (std::size_t i = 0; i < a.size(); ++i)
   {
      result[i] = a[i] * b[i];
   }
   return result;
}
template<typename T, typename S>
auto operator* (S const& s, vector<T> const& v)
{
   using result_type = decltype(std::declval<T>() + 
                                std::declval<S>());
   vector<result_type> result(v.size());
   for (std::size_t i = 0; i < v.size(); ++i)
   {
      result[i] = s * v[i];
   }
   return result;
}

这些实现相对简单,应该不会在这个阶段造成理解上的问题。+*运算符接受两种可能不同类型的两个向量,例如vector<int>vector<double>,并返回一个包含结果类型元素的向量。这是通过使用std::declval将模板类型TU的两个值相加的结果来确定的。这已经在第四章中讨论过,高级模板概念。类似的实现也适用于标量与向量的乘法。有了这些运算符,我们可以编写以下代码:

vector<int> v1{ 1,2,3 };
vector<int> v2{ 4,5,6 };
double a{ 1.5 };
vector<double> v3 = v1 + a * v2;       // {7.0, 9.5, 12.0}
vector<int>    v4 = v1 * v2 + v1 + v2; // {9, 17, 27}

如前所述,这将创建一个临时对象来计算v3,并在计算v4时创建两个临时对象。以下图表展示了这些示例。第一个图表显示了第一个计算,v3 = v1 + a * v2

![Figure 7.2: 第一个表达式的概念表示]

![img/Figure_7.2_B18367.jpg]

![Figure 7.2: 第一个表达式的概念表示]

下一个图表展示了第二个表达式v4 = v1 * v2 + v1 + v2的概念表示:

![Figure 7.3: 第二个表达式的概念表示]

![img/Figure_7.3_B18367.jpg]

图 7.3:第二个表达式的概念表示

为了避免这些临时变量,我们可以使用表达式模板模式重写vector类的实现。这需要几个更改:

  • 定义类模板来表示两个对象之间的表达式(例如,两个向量的加法或乘法表达式)。

  • 修改vector类并为其内部数据参数化容器,默认情况下将是一个std::vector,如之前所述,但也可以是一个表达式模板。

  • 改变重载的+*运算符的实现。

让我们看看这是如何实现的,从向量实现开始。以下是代码:

template<typename T, typename C = std::vector<T>>
struct vector
{
   vector() = default;
   vector(std::size_t const n) : data_(n) {}
   vector(std::initializer_list<T>&& l) : data_(l) {}
   vector(C const & other) : data_(other) {}
   template<typename U, typename X>
   vector(vector<U, X> const& other) : data_(other.size()) 
   {
      for (std::size_t i = 0; i < other.size(); ++i)
         data_[i] = static_cast<T>(other[i]);
   }
   template<typename U, typename X>
   vector& operator=(vector<U, X> const & other)
   {
      data_.resize(other.size());
      for (std::size_t i = 0; i < other.size(); ++i)
         data_[i] = static_cast<T>(other[i]);
      return *this;
   }
   std::size_t size() const noexcept
   {
      return data_.size();
   }
   T operator[](const std::size_t i) const
   {
      return data_[i];
   }
   T& operator[](const std::size_t i)
   {
      return data_[i];
   }
   C& data() noexcept { return data_; }
   C const & data() const noexcept { return data_; }
private:
   C data_;
};

除了初始实现中可用的操作外,这次我们还定义了以下内容:

  • 默认构造函数

  • 从一个容器到转换构造函数

  • 从包含可能不同类型元素的vector到复制构造函数

  • 从包含可能不同类型元素的vector到复制赋值运算符

  • 成员函数data,它提供了对底层容器中数据的访问

表达式模板是一个简单的类模板,它存储两个操作数并提供了一种执行操作评估的方法。在我们的情况下,我们需要实现两个向量的加法、两个向量的乘法以及标量与向量的乘法表达式。让我们看看两个向量加法表达式模板的实现:

template<typename L, typename R>
struct vector_add 
{
   vector_add(L const & a, R const & b) : lhv(a), rhv(b) {}
   auto operator[](std::size_t const i) const
   {
      return lhv[i] + rhv[i];
   }
   std::size_t size() const noexcept
   {
      return lhv.size();
   }
private:
   L const & lhv;
   R const & rhv;
};

此类存储对两个向量(实际上,任何重载下标运算符并提供size成员函数的类型)的常量引用。表达式的评估发生在重载的下标运算符中,而不是整个向量中;只有指示索引处的元素被添加。

注意,此实现不处理不同大小的向量(你可以将其作为练习来改变)。然而,由于加法操作仅在调用下标运算符时发生,因此应该很容易理解这种方法的懒加载性质。

我们需要的两个操作的乘法表达式模板以类似的方式实现。代码将在下一列表中展示:

template<typename L, typename R>
struct vector_mul
{
   vector_mul(L const& a, R const& b) : lhv(a), rhv(b) {}
   auto operator[](std::size_t const i) const
   {
      return lhv[i] * rhv[i];
   }
   std::size_t size() const noexcept
   {
      return lhv.size();
   }
private:
   L const & lhv;
   R const & rhv;
};
template<typename S, typename R>
struct vector_scalar_mul
{
   vector_scalar_mul(S const& s, R const& b) : 
      scalar(s), rhv(b) 
   {}
   auto operator[](std::size_t const i) const
   {
      return scalar * rhv[i];
   }
   std::size_t size() const noexcept
   {
      return rhv.size();
   }
private:
   S const & scalar;
   R const & rhv;
};

变更的最后一部分是修改重载的+*运算符的定义,如下所示:

template<typename T, typename L, typename U, typename R>
auto operator+(vector<T, L> const & a, 
               vector<U, R> const & b)
{
   using result_type = decltype(std::declval<T>() +
                                std::declval<U>());
   return vector<result_type, vector_add<L, R>>(
      vector_add<L, R>(a.data(), b.data()));
}
template<typename T, typename L, typename U, typename R>
auto operator*(vector<T, L> const & a, 
               vector<U, R> const & b)
{
   using result_type = decltype(std::declval<T>() + 
                                std::declval<U>());
   return vector<result_type, vector_mul<L, R>>(
      vector_mul<L, R>(a.data(), b.data()));
}
template<typename T, typename S, typename E>
auto operator*(S const& a, vector<T, E> const& v)
{
   using result_type = decltype(std::declval<T>() + 
                                std::declval<S>());
   return vector<result_type, vector_scalar_mul<S, E>>(
      vector_scalar_mul<S, E>(a, v.data()));
}

尽管实现此模式时代码更为复杂,但客户端代码无需更改。前面展示的代码片段无需任何修改即可工作,但以懒加载的方式运行。结果集中每个元素的评估是由向量类中的复制构造函数和复制赋值运算符中出现的下标运算符的调用触发的。

如果这个模式让你觉得繁琐,有一个更好的选择:范围库。

使用范围作为表达式模板的替代方案

C++20 的一个主要特性是范围库。范围是容器的一般化——一个允许你遍历其数据的类。范围库的一个关键元素是视图。这些是其他范围的非拥有包装器,通过某些操作转换底层范围。

此外,它们是懒加载的,构建、复制或销毁它们的时间不依赖于底层范围的大小。懒加载(即转换是在请求元素时而不是在创建视图时应用)是库的关键特性。然而,这正是表达式模板也提供的内容。因此,许多表达式模板的使用可以用范围来替代。范围将在下一章中详细讨论。

C++范围库基于v1 + a * v2

namespace rv = ranges::views;
std::vector<int> v1{ 1, 2, 3 };
std::vector<int> v2{ 4, 5, 6 };
double a { 1.5 };
auto sv2 = v2 | 
           rv::transform(&a {return a * val; });
auto v3 = rv::zip_with(std::plus<>{}, v1, sv2);

没有必要为向量类实现自定义实现;它只需与std::vector容器一起工作。也不需要重载任何运算符。代码应该很容易理解,至少如果你对范围库有些熟悉的话。首先,我们创建一个视图,通过将每个元素乘以一个标量来转换v2向量中的元素。然后,创建第二个视图,它对v1范围和前一个操作的结果视图的元素应用加法运算符。

不幸的是,这段代码不能使用 C++20 的标准库来编写,因为 zip_with 视图尚未包含在 C++20 中。然而,这个视图将在 C++23 中以 zip_view 的名称提供。因此,在 C++23 中,我们将能够以下这种方式编写这段代码:

namespace rv = std::ranges::views;
std::vector<int> v1{ 1, 2, 3 };
std::vector<int> v2{ 4, 5, 6 };
double a { 1.5 };
auto sv2 = v2 | 
           rv::transform(&a {return a * val; });
auto v3 = rv::zip_wiew(std::plus<>{}, v1, sv2);

要总结对表达式模板模式的讨论,你应该记住以下要点:该模式旨在为昂贵的操作提供惰性求值,但这是以编写更多代码(也可能更繁琐)和增加编译时间为代价的。然而,截至 C++20,这种模式的良好替代品是范围库。我们将在 第九章范围库 中了解这个新库。

对于本章的下一节和最后一节,我们将探讨类型列表。

类型列表

类型列表(也拼作 typelist)是一种编译时构造,它使我们能够管理一系列类型。类型列表在某种程度上类似于元组,但它不存储任何数据。类型列表仅携带类型信息,并在编译时专门用于实现不同的元编程算法、类型切换或设计模式,例如 抽象工厂访问者

重要提示

虽然两种拼写 type listtypelist 都在使用,但大多数情况下,你会在 C++ 书籍和文章中找到术语 typelist。因此,我们将使用这种形式在这本书中。

类型列表是由 Andrei Alexandrescu 在他十年前出版的书籍 Modern C++ Design 中推广的,这本书在 C++11 发布之前十年出版(以及变长模板)。Alexandrescu 如下定义了类型列表:

template <class T, class U>
struct Typelist
{
  typedef T Head;
  typedef U Tail;
};

在他的实现中,类型列表由一个头部组成——这是一个类型,以及一个尾部——这是另一个类型列表。为了在类型列表上执行各种操作(将在稍后讨论),我们还需要一个类型来表示类型列表的末尾。这可以是一个简单的、空类型,Alexandrescu 如下定义:

class null_typelist {};

有这两个构造,我们可以以下这种方式定义类型列表:

typedef Typelist<int, 
                 Typelist<double, null_typelist>> MyList;

变长模板使类型列表的实现更简单,如以下代码片段所示:

template <typename ... Ts>
struct typelist {};
using MyList = typelist<int, double>;

类型列表操作的实现(如访问给定索引处的类型、从列表中添加或删除类型等)根据所选方法的不同而有很大差异。在这本书中,我们只考虑变长模板版本。这种方法的优势在于不同层面的简单性:类型列表的定义更短,不需要一个类型来表示列表的末尾,定义类型列表别名也更短、更容易阅读。

今天,也许许多原本由 typelists 提供解决方案的问题也可以使用可变参数模板来解决。然而,仍然有一些场景需要使用 typelists。以下是一个例子:让我们考虑一个可变元函数(一个执行类型转换的类型特性),它对类型模板参数执行一些转换(例如添加const限定符)。这个元函数定义了一个成员类型来表示输入类型,另一个来表示转换后的类型。如果你尝试如下定义它,它将不会工作:

template <typename ... Ts>
struct transformer
{
   using input_types  = Ts...;
   using output_types = std::add_const_t<Ts>...;
};

这段代码会产生编译错误,因为在这个上下文中无法展开参数包。这是我们讨论过的主题,见第三章可变参数模板。解决这个问题的方法是使用 typelist,如下所示:

template <typename ... Ts>
struct transformer
{
   using input_types  = typelist<Ts...>;
   using output_types = typelist<std::add_const_t<Ts>...>;
};
static_assert(
   std::is_same_v<
      transformer<int, double>::output_types, 
      typelist<int const, double const>>);

变化很小,但产生了预期的结果。尽管这是一个需要 typelists 的很好的例子,但它并不是一个使用 typelists 的典型例子。我们将在下一个例子中查看这样的例子。

使用 typelists

在我们查看如何在 typelists 上实现操作之前,探索一个更复杂的例子是值得的。这应该能让你理解 typelists 的可能用法,尽管你总是可以在线搜索更多内容。

让我们回到游戏单位的例子。为了简单起见,我们只考虑以下类:

struct game_unit
{
   int attack;
   int defense;
};

游戏单位有两个数据成员表示攻击和防御的索引(或等级)。我们希望通过一些函数对象的帮助对这些成员进行操作。以下列表显示了两个这样的函数:

struct upgrade_defense
{
   void operator()(game_unit& u)
   {
      u.defense = static_cast<int>(u.defense * 1.2);
   }
};
struct upgrade_attack
{
   void operator()(game_unit& u)
   {
      u.attack += 2;
   }
};

第一个增加了防御指数的 20%,而第二个增加了攻击指数两个单位。尽管这是一个为了演示用例的小例子,但你可以想象出更多这样的函数,它们可以以一些定义良好的组合方式应用。然而,在我们的例子中,我们想在game_unit对象上应用这两个函数。我们希望有一个如下所示的函数:

void upgrade_unit(game_unit& unit)
{
   using upgrade_types = 
      typelist<upgrade_defense, upgrade_attack>;
   apply_functors<upgrade_types>{}(unit);
}

这个upgrade_unit函数接受一个game_unit对象,并将upgrade_defenseupgrade_attack函数对象应用于它。为此,它使用另一个名为apply_functors的辅助函数对象。这是一个只有一个模板参数的类模板。这个模板参数是一个 typelist。apply_functors函数对象的可能实现如下所示:

template <typename TL>
struct apply_functors
{
private:
   template <size_t I>
   static void apply(game_unit& unit)
   {
      using F = at_t<I, TL>;
      std::invoke(F{}, unit);
   }
   template <size_t... I>
   static void apply_all(game_unit& unit, 
                         std::index_sequence<I...>)
   {
      (apply<I>(unit), ...);
   }
public:
   void operator()(game_unit& unit) const
   {
      apply_all(unit, 
                std::make_index_sequence<length_v<TL>>{});
   }
};

这个类模板有一个重载的调用操作符和两个私有辅助函数:

  • apply,它将 typelist 中的I索引处的函数对象应用于game_unit对象。

  • apply_all,它通过使用包展开中的apply函数,将 typelist 中的所有函数对象应用于game_unit对象。

我们可以这样使用upgrade_unit函数:

game_unit u{ 100, 50 };
std::cout << std::format("{},{}\n", u.attack, u.defense);
// prints 100,50
upgrade_unit(u);
std::cout << std::format("{},{}\n", u.attack, u.defense);
// prints 102,60

如果你注意到了apply_functors类模板的实现,你会注意到使用了at_t别名模板和length_v变量模板,这些我们尚未定义。我们将在下一节中探讨这两个以及更多内容。

实现类型列表的操作

类型列表是一种仅在编译时携带有价值信息的类型。类型列表充当其他类型的容器。当你与类型列表一起工作时,你需要执行各种操作,例如计算列表中的类型数量、访问给定索引处的类型、在列表开头或末尾添加类型,或者反向操作,从列表开头或末尾移除类型等。如果你仔细想想,这些是在使用如向量这样的容器时通常会使用的典型操作。因此,在本节中,我们将讨论如何实现以下操作:

  • size:确定列表的大小

  • front:检索列表中的第一个类型

  • back:检索列表中的最后一个类型

  • at:检索列表中指定索引处的类型

  • push_back:将新类型添加到列表的末尾

  • push_front:将新类型添加到列表的开头

  • pop_back:移除列表末尾的类型

  • pop_front:移除列表开头的类型

类型列表是一个编译时构造。它是一个不可变实体。因此,添加或移除类型的操作不会修改类型列表,而是创建一个新的类型列表。我们很快就会看到这一点。但首先,让我们从最简单的操作开始,即检索类型列表的大小。

为了避免与size_t类型命名混淆,我们将把这个操作称为lenght_t,而不是size_t。我们可以这样定义它:

namespace detail
{
   template <typename TL>
   struct length;
   template <template <typename...> typename TL, 
             typename... Ts>
   struct length<TL<Ts...>>
   {
      using type = 
        std::integral_constant<std::size_t, sizeof...(Ts)>;
   };
}
template <typename TL>
using length_t = typename detail::length<TL>::type;
template <typename TL>
constexpr std::size_t length_v = length_t<TL>::value;

detail命名空间中,我们有一个名为length的类模板。有一个主要模板(没有定义)和一个针对类型列表的特化。这个特化定义了一个名为type的成员类型,它是一个std::integral_constant,其值为表示参数包Ts中参数数量的std::size_t类型。此外,我们还有一个别名模板length_h,它是length类模板中名为type的成员的别名。最后,我们有一个变量模板length_v,它从名为valuestd::integral_constant成员的值初始化,这个值也被称为value

我们可以使用一些static_assert语句来验证此实现的正确性,如下所示:

static_assert(
   length_t<typelist<int, double, char>>::value == 3);
static_assert(length_v<typelist<int, double, char>> == 3);
static_assert(length_v<typelist<int, double>> == 2);
static_assert(length_v<typelist<int>> == 1);

这里使用的方法将被用于定义所有其他操作。接下来,让我们看看如何访问列表中的前一个类型。这将在下一个列表中展示:

struct empty_type {};
namespace detail
{
   template <typename TL>
   struct front_type;
   template <template <typename...> typename TL, 
             typename T, typename... Ts>
   struct front_type<TL<T, Ts...>>
   {
      using type = T;
   };
   template <template <typename...> typename TL>
   struct front_type<TL<>>
   {
      using type = empty_type;
   };
}
template <typename TL>
using front_t = typename detail::front_type<TL>::type;

detail命名空间中,我们有一个名为front_type的类模板。同样,我们声明了一个主模板但没有定义。然而,我们有两个特化:一个用于包含至少一个类型的类型列表,另一个用于空类型列表。在前者的情况下,type成员是对类型列表中第一个类型的别名。在后一种情况下,没有类型,因此type成员是对名为empty_type的类型的别名。这是一个空类,其唯一的作用是在不需要返回类型的情况下作为操作的返回类型。我们可以如下验证实现:

static_assert(
   std::is_same_v<front_t<typelist<>>, empty_type>);
static_assert(
   std::is_same_v<front_t<typelist<int>>, int>);
static_assert(
   std::is_same_v<front_t<typelist<int, double, char>>, 
                  int>);

如果你期望访问后类型操作的实现与之前相似,你不会失望。以下是它的样子:

namespace detail
{
   template <typename TL>
   struct back_type;
   template <template <typename...> typename TL, 
             typename T, typename... Ts>
   struct back_type<TL<T, Ts...>>
   {
      using type = back_type<TL<Ts...>>::type;
   };
   template <template <typename...> typename TL, 
             typename T>
   struct back_type<TL<T>>
   {
      using type = T;
   };
   template <template <typename...> typename TL>
   struct back_type<TL<>>
   {
      using type = empty_type;
   };
}
template <typename TL>
using back_t = typename detail::back_type<TL>::type;

与此实现相比,唯一的显著区别是back_type类模板有三个特化,并且涉及到递归。这三个特化分别用于空类型列表、只有一个类型的类型列表以及包含两个或更多类型的类型列表。最后一个(实际上是在前面的列表中的第一个)在其type成员的定义中使用了模板递归。我们已经在第四章中看到了它是如何工作的,高级模板概念。为了确保我们正确实现了操作,我们可以进行以下验证:

static_assert(
   std::is_same_v<back_t<typelist<>>, empty_type>);
static_assert(
   std::is_same_v<back_t<typelist<int>>, int>);
static_assert(
   std::is_same_v<back_t<typelist<int, double, char>>,
                  char>);

除了访问类型列表中的第一个和最后一个类型外,我们还对在任意给定索引处访问类型感兴趣。然而,这个操作的实现并不简单。让我们先看看它:

namespace detail
{
   template <std::size_t I, std::size_t N, typename TL>
   struct at_type;
   template <std::size_t I, std::size_t N,
             template <typename...> typename TL, 
             typename T, typename... Ts>
   struct at_type<I, N, TL<T, Ts...>>
   {
      using type = 
         std::conditional_t<
            I == N, 
            T, 
            typename at_type<I, N + 1, TL<Ts...>>::type>;
   };
   template <std::size_t I, std::size_t N>
   struct at_type<I, N, typelist<>>
   {
      using type = empty_type;
   };
}
template <std::size_t I, typename TL>
using at_t = typename detail::at_type<I, 0, TL>::type;

at_t别名模板有两个模板参数:一个索引和一个类型列表。at_t模板是detail命名空间中at_type类模板的成员类型的别名。主模板有三个模板参数:一个表示要检索的类型位置的索引(I),另一个表示在列表中类型迭代当前位置的索引(N),以及一个类型列表(TL)。

这个主模板有两个特化:一个用于包含至少一个类型的类型列表,另一个用于空类型列表。在后一种情况下,成员type是对empty_type类型的别名。在前一种情况下,成员type通过std::conditional_t元函数定义。这定义了它的成员type,当I == N时为第一个类型(T),当这个条件不成立时为typename at_type<I, N + 1, TL<Ts...>>::type。在这里,我们又使用了模板递归,每次迭代都会增加第二个索引的值。下面的static_assert语句验证了实现:

static_assert(
   std::is_same_v<at_t<0, typelist<>>, empty_type>);
static_assert(
   std::is_same_v<at_t<0, typelist<int>>, int>);
static_assert(
   std::is_same_v<at_t<0, typelist<int, char>>, int>);
static_assert(
   std::is_same_v<at_t<1, typelist<>>, empty_type>);
static_assert(
   std::is_same_v<at_t<1, typelist<int>>, empty_type>);
static_assert(
   std::is_same_v<at_t<1, typelist<int, char>>, char>);
static_assert(
   std::is_same_v<at_t<2, typelist<>>, empty_type>);
static_assert(
   std::is_same_v<at_t<2, typelist<int>>, empty_type>);
static_assert(
   std::is_same_v<at_t<2, typelist<int, char>>, 
                  empty_type>);

需要实现的下一个操作类别是在类型列表的开始和结束处添加一个类型。我们称这些为push_back_tpush_front_t,它们的定义如下:

namespace detail
{
   template <typename TL, typename T>
   struct push_back_type;
   template <template <typename...> typename TL, 
             typename T, typename... Ts>
   struct push_back_type<TL<Ts...>, T>
   {
      using type = TL<Ts..., T>;
   };
   template <typename TL, typename T>
   struct push_front_type;
   template <template <typename...> typename TL, 
             typename T, typename... Ts>
   struct push_front_type<TL<Ts...>, T>
   {
      using type = TL<T, Ts...>;
   };
}
template <typename TL, typename T>
using push_back_t = 
   typename detail::push_back_type<TL, T>::type;
template <typename TL, typename T>
using push_front_t = 
   typename detail::push_front_type<TL, T>::type;

根据我们之前看到的操作,这些应该很容易理解。相反的操作,当我们从一个 typelist 中移除第一个或最后一个类型时,更为复杂。第一个,pop_front_t,看起来如下:

namespace detail
{
   template <typename TL>
   struct pop_front_type;
   template <template <typename...> typename TL, 
             typename T, typename... Ts>
   struct pop_front_type<TL<T, Ts...>>
   {
      using type = TL<Ts...>;
   };
   template <template <typename...> typename TL>
   struct pop_front_type<TL<>>
   {
      using type = TL<>;
   };
}
template <typename TL>
using pop_front_t = 
   typename detail::pop_front_type<TL>::type;

我们有主要的模板 pop_front_type 和两个特化:第一个是为至少包含一个类型的 typelist,第二个是为空 typelist。后者将成员 type 定义为一个空列表;前者将成员 type 定义为一个由类型列表参数组成的尾部的 typelist。

最后一个操作,从 typelist 中移除最后一个类型,称为 pop_back_t,实现如下:

namespace detail
{
   template <std::ptrdiff_t N, typename R, typename TL>
   struct pop_back_type;
   template <std::ptrdiff_t N, typename... Ts, 
             typename U, typename... Us>
   struct pop_back_type<N, typelist<Ts...>, 
                           typelist<U, Us...>> 
   { 
      using type = 
         typename pop_back_type<N - 1, 
                                typelist<Ts..., U>,
                                typelist<Us...>>::type;
   };
   template <typename... Ts, typename... Us>
   struct pop_back_type<0, typelist<Ts...>, 
                           typelist<Us...>>
   { 
      using type = typelist<Ts...>;
   };
   template <typename... Ts, typename U, typename... Us>
   struct pop_back_type<0, typelist<Ts...>, 
                           typelist<U, Us...>>
   { 
      using type = typelist<Ts...>;
   };
   template <>
   struct pop_back_type<-1, typelist<>, typelist<>>
   {
      using type = typelist<>;
   };
}
template <typename TL>
using pop_back_t = typename detail::pop_back_type<
   static_cast<std::ptrdiff_t>(length_v<TL>)-1, 
               typelist<>, TL>::type;

为了实现这个操作,我们需要从一个 typelist 开始,递归地逐个元素地构建另一个 typelist,直到我们到达输入 typelist 的最后一个类型,这个类型应该被省略。为此,我们使用一个计数器来告诉我们我们迭代 typelist 的次数。

这是从 typelist 的大小减一开始的,我们需要在达到零时停止。因此,pop_back_type 类模板有四个特化,一个用于我们在 typelist 中的某个迭代时的通用情况,两个用于计数器达到零的情况,一个用于计数器达到值减一的情况。这是初始 typelist 为空的情况(因此,length_t<TL> - 1 将评估为 -1)。以下是一些断言,展示了如何使用 pop_back_t 并验证其正确性:

static_assert(std::is_same_v<pop_back_t<typelist<>>, 
                             typelist<>>);
static_assert(std::is_same_v<pop_back_t<typelist<double>>, 
                             typelist<>>);
static_assert(
   std::is_same_v<pop_back_t<typelist<double, char>>, 
                             typelist<double>>);
static_assert(
   std::is_same_v<pop_back_t<typelist<double, char, int>>,
                             typelist<double, char>>);

在这些定义的基础上,我们提供了一系列必要的操作,用于处理 typelists。length_tat_t 操作在前面示例中用于在 game_unit 对象上执行函数对象。希望这一节为 typelists 提供了一个有用的介绍,并使你不仅能够理解它们的实现方式,还能理解它们的使用方法。

摘要

本章致力于学习各种元编程技术。我们首先理解了动态多态和静态多态之间的区别,然后探讨了实现静态多态的有趣重复出现的模板模式。

Mixins 是另一种具有与 CRTP 相似目的的图案——向类添加功能,但与 CRTP 不同,它不会修改它们。我们学到的第三种技术是类型擦除,它允许处理与类型无关的类似类型。在第二部分,我们学习了标签分派——允许我们在编译时选择重载,以及表达式模板——允许在编译时进行延迟计算,以避免在运行时发生低效的操作。最后,我们探讨了 typelists,并学习了它们的使用方法以及如何使用它们实现操作。

在下一章中,我们将探讨标准模板库的核心支柱,容器、迭代器和算法。

问题

  1. CRTP 可以解决哪些典型问题?

  2. Mixin 是什么?它们的目的何在?

  3. 类型擦除是什么?

  4. 标签调度是什么?它的替代方案有哪些?

  5. 表达式模板是什么?它们在哪里被使用?

进一步阅读

第八章:第八章: 范围和算法

到达本书的这一部分,你已经学习了有关 C++模板的语法和机制的所有内容,直到最新的标准版本 C++20。这为你编写从简单到复杂的模板提供了必要的知识。模板是编写泛型库的关键。即使你可能不会自己编写这样的库,你也会使用一个或多个。事实上,你用 C++编写的日常代码就使用了模板。主要原因在于,作为一个现代 C++开发者,你正在使用基于模板的标准库。

然而,标准库是一系列许多库的集合,例如容器库、迭代器库、算法库、数值库、输入/输出库、文件系统库、正则表达式库、线程支持库、实用库等。总的来说,这是一个庞大的库,至少可以写一本书的内容。然而,探索库的一些关键部分,可以帮助你更好地理解你经常使用或可能使用的一些概念和类型。

由于在一个章节中讨论这个主题会导致章节内容显著增加,我们将讨论分为两部分。在本章中,我们将讨论以下主题:

  • 理解容器、迭代器和算法的设计

  • 创建自定义容器和迭代器

  • 编写自定义通用算法

到本章结束时,你将对标准模板库的三个主要支柱有很好的理解,这些支柱是容器、迭代器和算法。

我们将从这个章节开始,概述标准库在这个方面的提供内容。

理解容器、迭代器和算法的设计

容器是表示元素集合的类型。这些集合可以根据各种数据结构实现,每个都有不同的语义:列表、队列、树等。标准库提供了三类容器:

  • vector, deque, list, array, 和 forward_list

  • set, map, multiset, 和 multimap

  • unordered_set, unordered_map, unordered_multiset, 和 unordered_multimap

此外,还有提供不同接口的容器适配器,这一类别包括stack, queue, 和 priority_queue类。最后,还有一个名为span的类,它表示对连续对象序列的非拥有视图。

这些容器为什么要使用模板的原因在第第一章模板介绍中进行了说明。你不想为需要存储在容器中的每种不同类型的元素重复编写相同的实现。可以说,标准库中最常用的容器如下:

  • vector:这是一个在内存中连续存储的变量大小元素集合。在没有特殊要求的情况下,这是你选择的默认容器。内部存储根据需要自动扩展或收缩以容纳存储的元素。向量分配比所需更多的内存,以降低扩展的风险。扩展是一个昂贵的操作,因为需要分配新内存,将当前存储的内容复制到新存储中,最后还需要丢弃之前的存储。因为元素在内存中连续存储,所以可以通过索引以常数时间随机访问。

  • array:这是一个在内存中连续存储的固定大小元素集合。大小必须是编译时常量表达式。array类的语义与包含 C 风格数组的结构体相同(T[n])。就像vector类型一样,array类的元素可以以常数时间随机访问。

  • map:这是一个将值与唯一键关联的集合。键通过比较函数排序,map类通常实现为红黑树。搜索、插入或删除元素的操作具有对数复杂度。

  • set:这是一个唯一键的集合。键是容器中实际存储的值;与map类的情况不同,没有键值对。然而,就像map类的情况一样,set通常实现为红黑树,搜索、插入和删除元素的操作具有对数复杂度。

不论它们的类型如何,标准容器有一些共同点:

  • 几个常见的成员类型

  • 用于存储管理的分配器(除了std::array类)

  • 几个常见的成员函数(其中一些在某些容器中可能缺失)

  • 使用迭代器访问存储的数据

所有标准容器都定义了以下成员类型:

using value_type      = /* ... */;
using size_type       = std::size_t;
using difference_type = std::ptrdiff_t;
using reference       = value_type&;
using const_reference = value_type const&;
using pointer         = /* ... */;
using const_pointer   = /* ... */;
using iterator        = /* ... */;
using const_iterator  = /* ... */;

这些名称实际所代表的数据类型可能因容器而异。例如,对于std::vectorvalue_type是模板参数T,但对于std::mapvalue_typestd::pair<const Key, T>类型。这些成员类型的目的在于帮助进行泛型编程。

除了表示编译时已知大小的数组的std::array类之外,所有其他容器都动态分配内存。如果没有指定,这通过一个称为std::allocator的对象来控制。这个标准分配器使用全局的newdelete运算符来分配和释放内存。所有标准容器的构造函数(包括复制和移动构造函数)都有重载,允许我们指定一个分配器。

标准容器还定义了一些常见的成员函数。以下是一些示例:

  • size,返回元素数量(在std::forward_list中不存在)。

  • empty,它检查容器是否为空。

  • clear,它清除容器的内容(std::arraystd::stackstd::queuestd::priority_queue中不存在)。

  • swap,它交换容器对象的内容。

  • beginend方法,它们返回容器的开始和结束迭代器(std::stackstd::queuestd::priority_queue中不存在,尽管这些不是容器而是容器适配器)。

最后一个要点提到了迭代器。这些类型抽象了在容器中访问元素细节,提供了一种统一的方式来识别和遍历容器的元素。这很重要,因为标准库的关键部分由通用算法表示。有超过一百种这样的算法,从序列操作(如countcount_iffindfor_each)到修改操作(如copyfilltransformrotatereverse),再到分区和排序(partitionsortnth_element等)以及其他操作。迭代器对于确保它们具有通用性至关重要。如果每个容器都有不同的访问其元素的方式,编写通用算法将几乎是不可能的。

让我们考虑将元素从一个容器复制到另一个容器的简单操作。例如,我们有一个std::vector对象,我们希望将其元素复制到std::list对象中。这可能看起来如下所示:

std::vector<int> v {1, 2, 3};
std::list<int> l;
for (std::size_t i = 0; i < v.size(); ++i)
   l.push_back(v[i]);

如果我们想从std::list复制到std::set,或者从std::set复制到std::array呢?每种情况都需要不同类型的代码。然而,通用算法使我们能够以统一的方式完成这些操作。以下代码片段展示了这样的一个例子:

std::vector<int> v{ 1, 2, 3 };
// copy vector to vector
std::vector<int> vc(v.size());
std::copy(v.begin(), v.end(), vc.begin());
// copy vector to list
std::list<int> l;
std::copy(v.begin(), v.end(), std::back_inserter(l));
// copy list to set
std::set<int> s;
std::copy(l.begin(), l.end(), std::inserter(s, s.begin()));

在这里,我们有一个std::vector对象,我们将它的内容复制到另一个std::vector中,同时也复制到一个std::list对象中。因此,std::list对象的内容随后被复制到std::set对象中。在所有情况下,都使用了std::copy算法。这个算法有几个参数:两个迭代器定义源的开始和结束,以及一个迭代器定义目标开始。算法通过输出迭代器逐个复制输入范围中的元素到输出迭代器指向的元素,然后增加输出迭代器。从概念上讲,它可以如下实现:

template<typename InputIt, class OutputIt>
OutputIt copy(InputIt first, InputIt last, 
              OutputIt d_first)
{
   for (; first != last; (void)++first, (void)++d_first)
   {
      *d_first = *first;
   }
   return d_first;
}

重要提示

这个算法在第五章中进行了讨论,类型特性和条件编译,当时我们探讨了如何利用类型特性来优化其实现。

考虑到前面的示例,存在目标容器尚未为其内容分配空间以进行复制的情况。这与将元素复制到列表和集合的情况相同。在这种情况下,使用类似迭代器的类型,std::back_insert_iteratorstd::insert_iterator,间接通过 std::back_inserterstd::inserter 辅助函数,将元素插入到容器中。std::back_insert_iterator 类使用 push_back 函数,而 std::insert_iterator 使用 insert 函数。

C++中有六个迭代器类别:

  • 输入迭代器

  • 输出迭代器

  • 前向迭代器

  • 双向迭代器

  • 随机访问迭代器

  • 连续迭代器

连续迭代器类别是在 C++17 中添加的。所有操作符都可以使用前缀或后缀增量运算符进行递增。以下表格显示了每个类别定义的附加操作:

![Table 8.1]

![img/Table_8.01_B18367.jpg]

表 8.1

除了输出类别外,每个类别都包含关于它的所有内容。这意味着前向迭代器是输入迭代器,双向迭代器是前向迭代器,随机访问迭代器是双向迭代器,最后,连续迭代器是随机访问迭代器。然而,任何前五个类别中的迭代器也可以同时是输出迭代器。这样的迭代器被称为可变迭代器。否则,它们被称为常量迭代器。

C++20 标准增加了对概念和概念库的支持。该库为这些迭代器类别中的每一个定义了标准概念。以下表格显示了它们之间的关联:

![Table 8.2]

![Table 8.02_B18367.jpg]

表 8.2

重要提示

迭代器概念在第六章中简要讨论了,概念和约束

所有容器都具有以下成员:

  • begin:它返回一个指向容器开头的迭代器。

  • end:它返回一个指向容器末尾的迭代器。

  • cbegin:它返回一个指向容器开头的常量迭代器。

  • cend:它返回一个指向容器末尾的常量迭代器。

一些容器还具有返回反向迭代器的成员:

  • rbegin:它返回一个指向反转容器开头的反向迭代器。

  • rend:它返回一个指向反转容器末尾的反向迭代器。

  • rcbegin:它返回一个指向反转容器开头的常量反向迭代器。

  • rcend:它返回一个指向反转容器末尾的常量反向迭代器。

有两件事必须理解得很好,才能与容器和迭代器一起工作:

  • 容器的末尾不是容器的最后一个元素,而是最后一个元素之后的元素。

  • 反向迭代器提供对容器中元素的逆序访问。容器第一个元素的反向迭代器实际上是未反转容器的最后一个元素。

为了更好地理解这两点,让我们看看以下示例:

std::vector<int> v{ 1,2,3,4,5 };
// prints 1 2 3 4 5
std::copy(v.begin(), v.end(),
          std::ostream_iterator<int>(std::cout, " "));
// prints 5 4 3 2 1
std::copy(v.rbegin(), v.rend(),
          std::ostream_iterator<int>(std::cout, " "));

std::copy 的第一次调用按给定顺序打印容器中的元素。另一方面,std::copy 的第二次调用按相反顺序打印元素。

以下图表说明了迭代器和容器元素之间的关系:

![Figure 8.1]

![Figure 8.1_B18367.jpg]

![Figure 8.1]

由两个迭代器(一个开始和一个结束,即最后一个元素之后的那个)分隔的元素序列(无论它们存储在内存中的数据结构是什么)被称为范围。这个术语在 C++标准(特别是与算法一起)和文献中广泛使用。它也是 C++20 中范围库得名的术语,将在第九章“范围库”中讨论。

除了标准容器的 begin/end 成员函数集之外,还有同名的独立函数。它们之间的等价性在以下表中展示:

![Table 8.3]

![Table 8.03_B18367.jpg]

表 8.3

虽然这些自由函数在处理标准容器时并不带来很多好处,但它们帮助我们编写通用的代码,可以处理标准容器和 C 样式的数组,因为这些自由函数都为静态数组重载。以下是一个示例:

std::vector<int> v{ 1,2,3,4,5 };
std::copy(std::begin(v), std::end(v), 
          std::ostream_iterator<int>(std::cout, " "));
int a[] = { 1,2,3,4,5 };
std::copy(std::begin(a), std::end(a),
          std::ostream_iterator<int>(std::cout, " "));

没有这些函数,我们不得不编写 std::copy(a, a + 5, …)。也许这些函数的一个大好处是它们使我们能够使用基于范围的 for 循环来使用数组,如下所示:

std::vector<int> v{ 1,2,3,4,5 };
for (auto const& e : v) 
   std::cout << e << ' ';
int a[] = { 1,2,3,4,5 };
for (auto const& e : a)
   std::cout << e << ' ';

这本书的目的不是教你如何使用每个容器或许多标准算法。然而,学习如何创建容器、迭代器和算法对你来说应该是有帮助的。这就是我们将要做的。

创建自定义容器和迭代器

理解容器和迭代器工作原理的最好方式是亲自创建它们。为了避免实现标准库中已经存在的东西,我们将考虑一些不同的事物——更确切地说,是一个循环缓冲区。这是一个当满时覆盖现有元素的容器。我们可以考虑不同的方式来使这样的容器工作;因此,我们首先定义其要求是很重要的。这些要求如下:

  • 容器应该具有在编译时已知的固定容量。因此,将不会有运行时内存管理。

  • 容量是容器可以存储的元素数量,而大小是它实际包含的元素数量。当大小等于容量时,我们说容器已满。

  • 当容器满时,添加新元素将覆盖容器中最旧的元素。

  • 添加新元素始终在末尾进行;删除现有元素始终在开头进行(容器中最旧的元素)。

  • 应该能够通过下标操作符和迭代器对容器的元素进行随机访问。

根据这些要求,我们可以考虑以下实现细节:

  • 元素可以存储在数组中。为了方便,这可以是 std::array 类。

  • 我们需要两个变量,我们称之为 headtail,以存储容器中第一个和最后一个元素的下标。这是必需的,因为由于容器的循环性质,开始和结束会随着时间的推移而移动。

  • 第三个变量将存储容器中的元素数量。这很有用,因为否则我们无法从 head 和 tail 指数的值中区分容器是否为空或只有一个元素。

    重要提示

    这里提供的实现仅用于教学目的,并不打算作为生产就绪的解决方案。有经验的读者会发现实现的不同方面可以进行优化。然而,这里的目的是学习如何编写容器,而不是如何优化实现。

以下图表显示了这样一个循环缓冲区的视觉表示,具有八个不同状态的元素容量:

图 8.2

图 8.2

我们可以从这张图中看到以下内容:

  • 图 A:这是一个空缓冲区。容量是 8,大小是 0,而 headtail 都指向下标 0

  • 图 B:这个缓冲区包含一个元素。容量仍然是 8,大小是 1,而 headtail 都仍然指向下标 0

  • 图 C:这个缓冲区包含两个元素。大小是 2,head 包含下标 0,而 tail 包含下标 1

  • 图 D:这个缓冲区已满。大小是 8,等于容量,head 包含下标 0,而 tail 包含下标 7

  • 图 E:这个缓冲区仍然已满,但已添加了一个额外的元素,触发了缓冲区中最老元素的重写。大小是 8,head 包含下标 1,而 tail 包含下标 0

现在我们已经研究了循环缓冲区的语义,我们可以开始编写实现。我们将从容器类开始。

实现循环缓冲区容器

容器类的代码太长,无法放在一个列表中,所以我们将将其分成多个片段。第一个如下:

template <typename T, std::size_t N>
   requires(N > 0)
class circular_buffer_iterator;
template <typename T, std::size_t N>
   requires(N > 0)
class circular_buffer
{
  // ...
};

这里有两个东西:一个名为 circular_buffer_iterator 的类模板的前向声明,以及一个名为 circular_buffer 的类模板。它们具有相同的模板参数,一个类型模板参数 T,表示元素的类型,以及一个非类型模板参数,表示缓冲区的容量。使用约束来确保提供的容量值始终为正。如果您不使用 C++20,可以将约束替换为 static_assert 语句或 enable_if 来强制执行相同的限制。以下代码片段都是 circular_buffer 类的一部分。

首先,我们有一系列成员类型定义,为与 circular_buffer 类模板相关的不同类型提供别名。这些将在类的实现中使用。它们将在下面展示:

public:
   using value_type = T;
   using size_type = std::size_t;
   using difference_type = std::ptrdiff_t;
   using reference = value_type&;
   using const_reference = value_type const&;
   using pointer = value_type*;
   using const_pointer = value_type const*;
   using iterator = circular_buffer_iterator<T, N>;
   using const_iterator = 
      circular_buffer_iterator<T const, N>;

第二,我们有存储缓冲区状态的成员数据。实际元素存储在一个 std::array 对象中。头、尾和大小都存储在 size_type 数据类型的变量中。这些成员都是私有的:

private:
   std::array<value_type, N> data_;
   size_type                 head_ = 0;
   size_type                 tail_ = 0;
   size_type                 size_ = 0;

第三,我们有实现之前描述的功能的成员函数。以下所有成员都是公开的。首先列出的是构造函数:

constexpr circular_buffer() = default;
constexpr circular_buffer(value_type const (&values)[N]) :
   size_(N), tail_(N-1)
{
   std::copy(std::begin(values), std::end(values), 
             data_.begin());
}
constexpr circular_buffer(const_reference v):
   size_(N), tail_(N-1)
{
   std::fill(data_.begin(), data_.end(), v);
}

在这里定义了三个构造函数(尽管我们可以想到更多的)。这些是默认构造函数(它也是默认的),用于初始化一个空的缓冲区,一个从类似 C 语言的数组中构造的构造函数,它通过复制数组元素来初始化一个满缓冲区,最后是一个接受单个值的构造函数,通过将该值复制到缓冲区的每个元素中来初始化一个满缓冲区。这些构造函数允许我们以以下任何一种方式创建循环缓冲区:

circular_buffer<int, 1> b1;              // {}
circular_buffer<int, 3> b2({ 1, 2, 3 }); // {1, 2, 3}
circular_buffer<int, 3> b3(42);          // {42, 42, 42}

接下来,我们定义了几个描述循环缓冲区状态的成员函数:

constexpr size_type size() const noexcept 
{ return size_; }
constexpr size_type capacity() const noexcept 
{ return N; }
constexpr bool empty() const noexcept 
{ return size_ == 0; }
constexpr bool full() const noexcept 
{ return size_ == N; }
constexpr void clear() noexcept 
{ size_ = 0; head_ = 0; tail_ = 0; };

size 函数返回缓冲区中的元素数量,capacity 函数返回缓冲区可以容纳的元素数量,empty 函数用于检查缓冲区是否没有元素(等同于 size() == 0),而 full 函数用于检查缓冲区是否已满(等同于 size() == N)。还有一个名为 clear 的函数,可以将循环缓冲区置于空状态。请注意,此函数不会销毁任何元素(不会释放内存或调用析构函数),而只是重置定义缓冲区状态的值。

我们需要访问缓冲区的元素;因此,定义了以下函数来达到这个目的:

constexpr reference operator[](size_type const pos)
{
   return data_[(head_ + pos) % N];
}
constexpr const_reference operator[](size_type const pos) const
{
   return data_[(head_ + pos) % N];
}
constexpr reference at(size_type const pos)
{
   if (pos < size_)
      return data_[(head_ + pos) % N];
   throw std::out_of_range("Index is out of range");
}
constexpr const_reference at(size_type const pos) const
{
   if (pos < size_)
      return data_[(head_ + pos) % N];
   throw std::out_of_range("Index is out of range");
}
constexpr reference front()
{
   if (size_ > 0) return data_[head_];
   throw std::logic_error("Buffer is empty");
}
constexpr const_reference front() const
{
   if (size_ > 0) return data_[head_];
   throw std::logic_error("Buffer is empty");
}
constexpr reference back()
{
   if (size_ > 0) return data_[tail_];
   throw std::logic_error("Buffer is empty");
}
constexpr const_reference back() const
{
   if (size_ > 0) return data_[tail_];
   throw std::logic_error("Buffer is empty");
}

每个这些成员都有一个 const 重载,用于调用缓冲区的常量实例。常量成员返回一个常量引用;非常量成员返回一个正常引用。这些方法如下:

  • 下标运算符返回对指定索引的元素的引用,而不检查索引的值

  • at方法与下标操作符类似,但它会检查索引是否小于大小,如果不是,则抛出异常

  • 返回第一个元素引用的front方法;如果缓冲区为空,则抛出异常

  • 返回最后一个元素引用的back方法;如果缓冲区为空,则抛出异常

我们有成员函数来访问元素,但我们还需要成员函数来向/从缓冲区添加和删除元素。添加新元素总是发生在末尾,因此我们将此称为push_back。删除现有元素总是发生在开始位置(最老的元素),因此我们将此称为pop_front。让我们首先看看前者:

constexpr void push_back(T const& value)
{
   if (empty())
   {
      data_[tail_] = value;
      size_++;
   }
   else if (!full())
   {
      data_[++tail_] = value;
      size_++;
   }
   else
   {
      head_ = (head_ + 1) % N;
      tail_ = (tail_ + 1) % N;
      data_[tail_] = value;
   }
}

这基于定义的要求和来自图 8.2的视觉表示:

  • 如果缓冲区为空,则将值复制到tail_索引指向的元素,并增加大小。

  • 如果缓冲区既不为空也不为满,则执行相同的操作,但还要增加tail_索引的值。

  • 如果缓冲区已满,则增加head_tail_的值,然后将值复制到tail_索引指向的元素。

此函数将value参数复制到缓冲区元素。然而,这可以针对临时对象或推送至缓冲区后不再需要的对象进行优化。因此,提供了一个接受 rvalue 引用的重载。这会将值移动到缓冲区,避免不必要的复制。此重载在以下代码片段中显示:

constexpr void push_back(T&& value)
{
   if (empty())
   {
      data_[tail_] = value;
      size_++;
   }
   else if (!full())
   {
      data_[++tail_] = std::move(value);
      size_++;
   }
   else
   {
      head_ = (head_ + 1) % N;
      tail_ = (tail_ + 1) % N;
      data_[tail_] = std::move(value);
   }
}

实现从缓冲区删除元素的pop_back函数采用类似的方法。以下是实现:

constexpr T pop_front()
{
   if (empty()) throw std::logic_error("Buffer is empty");
   size_type index = head_;
   head_ = (head_ + 1) % N;
   size_--;
   return data_[index];
}

如果缓冲区为空,则抛出异常。否则,增加head_索引的值,并返回head_前一个位置的元素值。这在以下图中进行了视觉描述:

图 8.3

图 8.3

我们在这里可以看到以下内容:

  • 图 A:缓冲区有 3 个元素(1、2 和 3),位于索引0,而位于索引2

  • 图 B:已从前面删除了一个元素,其索引为0。因此,现在是索引1,而仍然位于索引2。缓冲区现在有两个元素。

  • 图 C:缓冲区有八个元素,这是其最大容量,并且一个元素已被覆盖。位于索引1,而位于索引0

  • 图 D:已从前面删除了一个元素,其索引为1。现在位于索引2,而仍然位于索引0。缓冲区现在有七个元素。

下一个代码片段展示了同时使用push_backpop_front成员函数的示例:

circular_buffer<int, 4> b({ 1, 2, 3, 4 });
assert(b.size() == 4);
b.push_back(5);
b.push_back(6);
b.pop_front();
assert(b.size() == 3);
assert(b[0] == 4);
assert(b[1] == 5);
assert(b[2] == 6);

最后,我们有成员函数beginend,它们返回指向缓冲区第一个和最后一个元素之后的迭代器。以下是它们的实现:

iterator begin()
{
   return iterator(*this, 0);
}
iterator end()
{
   return iterator(*this, size_);
}
const_iterator begin() const
{
   return const_iterator(*this, 0);
}
const_iterator end() const
{
   return const_iterator(*this, size_);
}

为了理解这些,我们需要看看迭代器类实际上是如何实现的。我们将在下一节中探讨这一点。

实现循环缓冲区容器的迭代器类型

在我们开始 circular_buffer 容器时,我们在前一节的开始处声明了迭代器类模板。然而,我们还需要定义其实现。但是,我们还有一件事必须做:为了让迭代器类能够访问容器的私有成员,它需要被声明为友元。这是如下完成的:

private:
   friend circular_buffer_iterator<T, N>;

现在让我们看看 circular_buffer_iterator 类,它实际上与容器类有相似之处。这包括模板参数、约束以及成员类型集合(其中一些与 circular_buffer 中的类型相同)。以下是类的片段:

template <typename T, std::size_t N>
requires(N > 0)
class circular_buffer_iterator
{
public:
   using self_type = circular_buffer_iterator<T, N>;
   using value_type = T;
   using reference = value_type&;
   using const_reference = value_type const &;
   using pointer = value_type*;
   using const_pointer = value_type const*;
   using iterator_category =
      std::random_access_iterator_tag;
   using size_type = std::size_t;
   using difference_type = std::ptrdiff_t;
public:
   /* definitions */
private:
   std::reference_wrapper<circular_buffer<T, N>> buffer_;
   size_type              index_ = 0;
};

circular_buffer_iterator 类有一个指向循环缓冲区的引用和一个指向它所指向的缓冲区元素的索引。对 circular_buffer<T, N> 的引用被封装在一个 std::reference_wrapper 对象中。原因将在稍后揭晓。这样的迭代器可以通过提供这两个参数显式创建。因此,只有一个构造函数如下所示:

explicit circular_buffer_iterator(
   circular_buffer<T, N>& buffer,
   size_type const index):
   buffer_(buffer), index_(index)
{ }

如果现在回顾 circular_bufferbeginend 成员函数的定义,我们可以看到第一个参数是 *this,第二个参数对于开始迭代器是 0,对于结束迭代器是 size_。第二个值是迭代器所指向的元素从头部开始的偏移量。因此,0 是第一个元素,size_ 是缓冲区中最后一个元素之后的一个元素。

我们决定我们需要对缓冲区的元素进行随机访问;因此,迭代器类别是随机访问。成员类型 iterator_categorystd::random_access_iterator_tag 的别名。这意味着我们需要提供此类迭代器支持的所有操作。在本章的前一节中,我们讨论了迭代器类别和每个类别所需的操作。我们将在下一节中实现所有必需的操作。

我们从输入迭代器的要求开始,如下所示:

self_type& operator++()
{ 
   if(index_ >= buffer_.get().size())
      throw std::out_of_range("Iterator cannot be 
                  incremented past the end of the range");
   index_++;
   return *this;
}
self_type operator++(int)
{
   self_type temp = *this;
   ++*this;
   return temp;
}
bool operator==(self_type const& other) const
{
   return compatible(other) && index_ == other.index_;
}
bool operator!=(self_type const& other) const
{
   return !(*this == other);
}
const_reference operator*() const
{
   if (buffer_.get().empty() || !in_bounds())
      throw std::logic_error("Cannot dereferentiate the 
                              iterator");
   return buffer_.get().data_[
      (buffer_.get().head_ + index_) % 
       buffer_.get().capacity()];
}
const_reference operator->() const
{
   if (buffer_.get().empty() || !in_bounds())
      throw std::logic_error("Cannot dereferentiate the 
                              iterator");
   return buffer_.get().data_[
      (buffer_.get().head_ + index_) % 
       buffer_.get().capacity()];
}

我们在这里实现了递增(前缀和后缀),检查相等/不等,以及解引用。当元素无法解引用时,*-> 运算符会抛出异常。这种情况发生在缓冲区为空,或者索引不在范围内(在 head_tail_ 之间)。我们使用了两个辅助函数(都是私有的),分别称为 compatibleis_bounds。这些将在下面展示:

bool compatible(self_type const& other) const
{
   return buffer_.get().data_.data() == 
          other.buffer_.get().data_.data();
}
bool in_bounds() const
{
   return
      !buffer_.get().empty() &&
      (buffer_.get().head_ + index_) % 
       buffer_.get().capacity() <= buffer_.get().tail_;
}

如果 ab 是两个前向迭代器并且它们相等,那么它们要么是不可解引用的,否则它们的迭代器值 *a*b 指向同一个对象。反之亦然,这意味着如果 *a*b 相等,那么 ab 也相等。这对于我们的实现是正确的。

前向迭代器的另一个要求是它们是可交换的。这意味着如果ab是两个前向迭代器,那么swap(a, b)应该是一个有效的操作。这又让我们回到了使用std::reference_wrapper对象来持有对circular_buffer<T, N>的引用。引用是不可交换的,这将使得circular_buffer_iterator也不可交换。然而,std::reference_wrapper是可交换的,这也使得我们的迭代器类型可交换。这可以通过一个static_assert语句来验证,如下所示:

static_assert(
   std::is_swappable_v<circular_buffer_iterator<int, 10>>);

重要提示

使用原始指针到circular_buffer类的替代方案是使用std::reference_wrapper,因为指针可以赋值,因此是可交换的。使用哪种方式是风格和个人选择的问题。在这个例子中,我更喜欢避免使用原始指针的解决方案。

为了满足双向迭代器类别的需求,我们需要支持递减操作。在下一个代码片段中,您可以看到前置和后置递减运算符的实现:

self_type& operator--()
{
   if(index_ <= 0)
      throw std::out_of_range("Iterator cannot be 
           decremented before the beginning of the range");
   index_--;
   return *this;
}
self_type operator--(int)
{
   self_type temp = *this;
   --*this;
   return temp;
}

最后,我们有+-(以及复合的+=-=)操作的要求。这些将在下面展示:

self_type operator+(difference_type offset) const
{
   self_type temp = *this;
   return temp += offset;
}
self_type operator-(difference_type offset) const
{
   self_type temp = *this;
   return temp -= offset;
}
difference_type operator-(self_type const& other) const
{
   return index_ - other.index_;
}
self_type& operator +=(difference_type const offset)
{
   difference_type next = 
      (index_ + next) % buffer_.get().capacity();
   if (next >= buffer_.get().size())
      throw std::out_of_range("Iterator cannot be 
                incremented past the bounds of the range");
   index_ = next;
   return *this;
}
self_type& operator -=(difference_type const offset)
{
   return *this += -offset;
}

随机访问迭代器必须支持与其他操作的不等比较。这意味着我们需要重载<<=>>=运算符。然而,<=>>=运算符可以基于<运算符来实现。因此,它们的定义可以如下所示:

bool operator<(self_type const& other) const
{
   return index_ < other.index_;
}
bool operator>(self_type const& other) const
{
   return other < *this;
}
bool operator<=(self_type const& other) const
{
   return !(other < *this);
}
bool operator>=(self_type const& other) const
{
   return !(*this < other);
}

最后,但同样重要的是,我们需要提供通过下标运算符([])访问元素的方法。一个可能的实现如下:

value_type& operator[](difference_type const offset)
{
   return *((*this + offset));
}
value_type const & operator[](difference_type const offset) const
{
   return *((*this + offset));
}

通过这种方式,我们已经完成了环形缓冲区迭代器类型的实现。如果您在跟踪这两个类的众多代码片段时遇到困难,您可以在 GitHub 仓库中找到这本书的完整实现。下面展示了使用迭代器类型的一个简单示例:

circular_buffer<int, 3> b({1, 2, 3});
std::vector<int> v;
for (auto it = b.begin(); it != b.end(); ++it)
{
   v.push_back(*it);
}

这段代码实际上可以通过基于范围的 for 循环来简化。在这种情况下,我们不直接使用迭代器,而是编译器生成的代码。因此,以下代码片段与上一个代码片段等价:

circular_buffer<int, 3> b({ 1, 2, 3 });
std::vector<int> v;
for (auto const e : b)
{
   v.push_back(e);
}

然而,这里提供的circular_buffer_iterator实现不允许以下代码片段编译:

circular_buffer<int, 3> b({ 1,2,3 });
*b.begin() = 0;
assert(b.front() == 0);

这要求我们能够通过迭代器写入元素。然而,我们的实现没有满足输出迭代器类别的需求。这要求表达式如*it = v*it++ = v是有效的。为了做到这一点,我们需要提供非 const 重载的*->运算符,它们返回非 const 引用类型。这可以通过以下方式实现:

reference operator*()
{
   if (buffer_.get().empty() || !in_bounds())
      throw std::logic_error("Cannot dereferentiate the 
                              iterator");
   return buffer_.get().data_[
      (buffer_.get().head_ + index_) % 
       buffer_.get().capacity()];
}
reference operator->()
{
   if (buffer_.get().empty() || !in_bounds())
      throw std::logic_error("Cannot dereferentiate the 
                              iterator");
   return buffer_.get().data_[
      (buffer_.get().head_ + index_) % 
       buffer_.get().capacity()];
}

在 GitHub 仓库中可以找到使用circular_buffer类带有和没有迭代器的更多示例。接下来,我们将专注于实现一个适用于任何范围的通用算法,包括我们在这里定义的circular_buffer容器。

编写自定义通用算法

在本章的第一节中,我们看到了使用迭代器抽象容器元素访问对于构建通用算法的重要性。然而,练习编写这样的算法应该对你有所帮助,因为它可以帮助你更好地理解迭代器的使用。因此,在本节中,我们将编写一个通用算法。

标准库具有许多这样的算法。其中缺失的一个是合并算法。合并的含义实际上被不同的人以不同的方式解释或理解。对于一些人来说,合并意味着取两个或更多输入范围并创建一个新的范围,其中输入范围的元素是交错排列的。这将在以下图中举例说明:

图 8.4

图 8.4

对于其他人来说,合并意味着取两个或更多输入范围并创建一个新的范围,其中元素是由输入范围的元素组成的元组。这将在下一图中展示:

图 8.5

图 8.5

在本节中,我们将实现第一个算法。为了避免混淆,我们将称之为flatzip。以下是它的要求:

  • 算法接收两个输入范围并将结果写入输出范围。

  • 该算法接收迭代器作为参数。一对第一个和最后一个输入迭代器定义了每个输入范围的界限。一个输出迭代器定义了输出范围的开始,元素将被写入到那里。

  • 两个输入范围应包含相同类型的元素。输出范围必须包含相同类型的元素或可以隐式转换为输入类型的类型。

  • 如果两个输入范围的大小不同,算法将在处理完较小的那个范围后停止(如前图所示)。

  • 返回值是复制到超过最后一个元素的输出迭代器。

描述的算法的一个可能实现如下所示:

template <typename InputIt1, typename InputIt2,
          typename OutputIt>
OutputIt flatzip(
   InputIt1 first1, InputIt1 last1,
   InputIt2 first2, InputIt2 last2,
   OutputIt dest)
{
   auto it1 = first1;
   auto it2 = first2;
   while (it1 != last1 && it2 != last2)
   {
      *dest++ = *it1++;
      *dest++ = *it2++;
   }
   return dest;
}

如代码片段所示,实现相当简单。我们在这里所做的只是同时遍历两个输入范围,并交替从它们中复制元素到目标范围。当到达最小范围的末尾时,两个输入范围的迭代停止。我们可以如下使用此算法:

// one range is empty
std::vector<int> v1 {1,2,3};
std::vector<int> v2;
std::vector<int> v3;
flatzip(v1.begin(), v1.end(), v2.begin(), v2.end(),
        std::back_inserter(v3));
assert(v3.empty());
// neither range is empty
std::vector<int> v1 {1, 2, 3};
std::vector<int> v2 {4, 5};
std::vector<int> v3;
flatzip(v1.begin(), v1.end(), v2.begin(), v2.end(),
        std::back_inserter(v3));
assert(v3 == std::vector<int>({ 1, 4, 2, 5 }));

这些示例使用std::vector作为输入和输出范围。然而,flatzip算法对容器一无所知。容器中的元素通过迭代器访问。因此,只要迭代器满足指定的要求,我们就可以使用任何容器。这包括我们之前编写的circular_buffer容器,因为circular_buffer_container满足输入和输出迭代器类别的需求。这意味着我们也可以编写以下代码片段:

circular_buffer<int, 4> a({1, 2, 3, 4});
circular_buffer<int, 3> b({5, 6, 7});
circular_buffer<int, 8> c(0);
flatzip(a.begin(), a.end(), b.begin(), b.end(), c.begin());
std::vector<int> v;
for (auto e : c)
   v.push_back(e);
assert(v == std::vector<int>({ 1, 5, 2, 6, 3, 7, 0, 0 }));

我们有两个输入循环缓冲区:a,它有四个元素,和b,它有三个元素。目标循环缓冲区有八个元素的空间,所有元素都被初始化为零。在应用 flatzip 算法后,目标循环缓冲区的六个元素将被ab缓冲区中的值写入。结果是循环缓冲区将包含元素1, 5, 2, 6, 3, 7, 0, 0

摘要

本章致力于探讨如何使用模板构建通用库。尽管我们无法对这些主题进行深入探讨,但我们已经探讨了 C++标准库中容器、迭代器和算法的设计。这些是标准库的支柱。我们花了大部分时间来理解编写类似于标准容器的容器以及提供对其元素访问的迭代器类需要什么。为此,我们实现了一个表示循环缓冲区的类,这是一个固定大小的数据结构,当容器满时,元素将被覆盖。最后,我们实现了一个通用算法,该算法将两个范围中的元素进行合并。这对于任何容器都适用,包括循环缓冲区容器。

如本章所讨论的,范围是一个抽象概念。然而,随着 C++20 的引入,它引入了新的范围库,其中包含了一个更具体的概念。这就是本书最后一章我们将要讨论的内容。

问题

  1. 标准库中的序列容器有哪些?

  2. 在标准容器中定义了哪些常见的成员函数?

  3. 迭代器是什么?有多少种类别存在?

  4. 随机访问迭代器支持哪些操作?

  5. 范围访问函数是什么?

第九章:第九章:范围库

上一章致力于理解标准库的三个主要支柱:容器、迭代器和算法。在整个章节中,我们使用了范围这个抽象概念来表示由两个迭代器分隔的元素序列。C++20 标准通过提供一个范围库来简化与范围的工作,该库由两个主要部分组成:一方面,定义非拥有范围和范围适配器的类型,另一方面,与这些范围类型一起工作的算法,并且不需要迭代器来定义元素的范围。

在本章的最后,我们将讨论以下主题:

  • 从抽象范围到范围库的过渡

  • 理解范围概念和视图

  • 理解约束算法

  • 编写自己的范围适配器

到本章结束时,你将很好地理解范围库的内容,并且能够编写自己的范围适配器。

让我们从范围这个抽象概念过渡到 C++20 范围库开始本章。

从抽象范围到范围库的进步

在上一章中,我们多次使用了术语 范围std::vectorstd::liststd::map 是范围抽象的具体实现。它们拥有元素的所有权,并且使用各种数据结构实现,例如数组、链表或树。标准算法是通用的。它们对容器不可知。它们对 std::vectorstd::liststd::map 一无所知。它们通过迭代器处理范围抽象。然而,这有一个缺点:我们总是需要从一个容器中检索开始和结束迭代器。以下是一些例子:

// sorts a vector
std::vector<int> v{ 1, 5, 3, 2, 4 };
std::sort(v.begin(), v.end());
// counts even numbers in an array
std::array<int, 5> a{ 1, 5, 3, 2, 4 };
auto even = std::count_if(
   a.begin(), a.end(), 
   [](int const n) {return n % 2 == 0; });

在需要仅处理容器部分元素的情况下很少。在绝大多数情况下,你只需要反复编写 v.begin()v.end()。这包括 cbegin()/cend()rbegin()/rend() 或独立的函数 std::begin()/std::end() 等变体。理想情况下,我们希望缩短所有这些,并能够编写以下内容:

// sorts a vector
std::vector<int> v{ 1, 5, 3, 2, 4 };
sort(v);
// counts even numbers in an array
std::array<int, 5> a{ 1, 5, 3, 2, 4 };
auto even = std::count_if(
   a, 
   [](int const n) {return n % 2 == 0; });

另一方面,我们经常需要组合操作。大多数时候,这涉及到许多操作和代码,即使使用标准算法,代码也过于冗长。让我们考虑以下示例:给定一个整数序列,我们想要按值(而不是位置)降序打印控制台上的所有偶数的平方,除了前两个。解决这个问题有多种方法。以下是一个可能的解决方案:

std::vector<int> v{ 1, 5, 3, 2, 8, 7, 6, 4 };
// copy only the even elements
std::vector<int> temp;
std::copy_if(v.begin(), v.end(), 
             std::back_inserter(temp), 
             [](int const n) {return n % 2 == 0; });
// sort the sequence
std::sort(temp.begin(), temp.end(), 
          [](int const a, int const b) {return a > b; });
// remove the first two
temp.erase(temp.begin() + temp.size() - 2, temp.end());
// transform the elements
std::transform(temp.begin(), temp.end(), 
               temp.begin(),
               [](int const n) {return n * n; });
// print each element
std::for_each(temp.begin(), temp.end(), 
              [](int const n) {std::cout << n << '\n'; });

我相信大多数人都会同意,尽管熟悉标准算法的任何人都可以轻松阅读此代码,但它仍然需要写很多。它还要求一个临时容器和重复调用 begin/end。因此,我也期待大多数人会更容易理解以下版本的先前代码,并且可能更喜欢这样编写:

std::vector<int> v{ 1, 5, 3, 2, 8, 7, 6, 4 };
sort(v);
auto r = v
         | filter([](int const n) {return n % 2 == 0; })
         | drop(2)
         | reverse
         | transform([](int const n) {return n * n; });
for_each(r, [](int const n) {std::cout << n << '\n'; });

这就是 C++20 标准借助范围库提供的功能。这有两个主要组成部分:

  • 视图范围适配器,它们表示非拥有可迭代序列。这使得我们更容易组合操作,如最后一个示例所示。

  • 约束算法,它使我们能够对具体范围(标准容器或范围)进行操作,而不是对由一对迭代器界定的抽象范围进行操作(尽管这也是可能的)。

我们将在下一节中探讨范围库的这两个提供项,我们将从范围开始。

理解范围概念和视图

术语 范围 指的是一个抽象概念,它定义了由起始和结束迭代器界定的元素序列。因此,范围代表了一个可迭代的元素序列。然而,这样的序列可以通过多种方式定义:

  • 使用起始迭代器和结束哨兵。此类序列从起始位置迭代到结束位置。一个 哨兵 是一个指示序列结束的对象。它可以与迭代器类型相同,也可以是不同类型。

  • 使用起始对象和大小(元素数量),表示所谓的计数序列。此类序列从起始位置迭代 N 次(其中 N 代表大小)。

  • 使用起始值和谓词,表示所谓的条件终止序列。此类序列从起始位置迭代,直到谓词返回 false。

  • 只使用起始值,表示所谓的无界序列。此类序列可以无限迭代。

所有这些可迭代序列都被视为范围。因为范围是一个抽象概念,C++20 库定义了一系列概念来描述范围类型的必要条件。这些概念可以在 <ranges> 头文件和 std::ranges 命名空间中找到。下表展示了范围概念的列表:

表 9.1

表 9.1

标准库为容器和数组定义了一系列访问函数。这些包括 std::beginstd::end 而不是成员函数 beginendstd::size 而不是成员函数 size 等。这些被称为 <ranges><iterator> 头文件以及 std::ranges 命名空间。它们列在下一表中:

表 9.2

表 9.2

以下代码片段展示了这些函数的一些用法:

std::vector<int> v{ 8, 5, 3, 2, 4, 7, 6, 1 };
auto r = std::views::iota(1, 10);
std::cout << "size(v)=" << std::ranges::size(v) << '\n';
std::cout << "size(r)=" << std::ranges::size(r) << '\n';
std::cout << "empty(v)=" << std::ranges::empty(v) << '\n';
std::cout << "empty(r)=" << std::ranges::empty(r) << '\n';
std::cout << "first(v)=" << *std::ranges::begin(v) << '\n';
std::cout << "first(r)=" << *std::ranges::begin(r) << '\n';
std::cout << "rbegin(v)=" << *std::ranges::rbegin(v) 
          << '\n';
std::cout << "rbegin(r)=" << *std::ranges::rbegin(r) 
          << '\n';
std::cout << "data(v)=" << *std::ranges::data(v) << '\n'; 

在这个片段中,我们使用了一种名为 std::views::iota 的类型。正如命名空间所暗示的,这是一个视图。视图是一个带有额外限制的范围。视图是轻量级对象,具有非拥有语义。它们以一种不需要复制或修改序列的方式呈现底层元素序列(范围)的视图。其关键特性是延迟评估。这意味着无论它们应用何种转换,它们仅在请求(迭代)元素时执行,而不是在创建视图时执行。

C++20 提供了一系列视图,C++23 也增加了新的视图。视图在 <ranges> 头文件和 std::ranges 命名空间中以 std::ranges::abc_view 的形式提供,例如 std::ranges::iota_view。然而,为了使用方便,在 std::views 命名空间中,也存在形式为 std::views::abc 的变量模板,例如 std::views::iota。这正是我们在上一个示例中看到的。以下是使用 iota 的两个等效示例:

// using the iota_view type
for (auto i : std::ranges::iota_view(1, 10))
   std::cout << i << '\n';
// using the iota variable template
for (auto i : std::views::iota(1, 10))
   std::cout << i << '\n';

iota 视图是称为 工厂 的特殊视图类别的一部分。这些工厂是新生成范围上的视图。以下是在范围库中可用的工厂:

表 9.3

表 9.3

如果你想知道为什么 empty_viewsingle_view 有用,答案应该不难找到。这些在处理空范围或只有一个元素的范围的模板代码中很有用。你不想为处理这些特殊情况有多个函数模板的重载;相反,你可以传递一个 empty_viewsingle_view 范围。以下是一些使用这些工厂的示例片段。这些片段应该是自解释的:

constexpr std::ranges::empty_view<int> ev;
static_assert(std::ranges::empty(ev));
static_assert(std::ranges::size(ev) == 0);
static_assert(std::ranges::data(ev) == nullptr);
constexpr std::ranges::single_view<int> sv{42};
static_assert(!std::ranges::empty(sv));
static_assert(std::ranges::size(sv) == 1);
static_assert(*std::ranges::data(sv) == 42);

对于 iota_view,我们已经看到了一些使用有界视图的示例。下一个片段再次展示了不仅使用由 iota 生成的有界视图,还使用由 iota 生成的无界视图的示例:

auto v1 = std::ranges::views::iota(1, 10);
std::ranges::for_each(
     v1, 
     [](int const n) {std::cout << n << '\n'; });
auto v2 = std::ranges::views::iota(1) |
          std::ranges::views::take(9);
std::ranges::for_each(
     v2,
     [](int const n) {std::cout << n << '\n'; });

最后一个示例利用了另一个名为 take_view 的视图。它产生另一个视图的前 N 个元素(在我们的例子中,是 9)的视图(在我们的情况下,是使用 iota 产生的无界视图)。我们将在稍后讨论更多关于这个话题的内容。但首先,让我们用一个示例来使用第四个视图工厂,basic_iostream_view。让我们考虑我们有一个文本中的文章价格列表,这些价格由空格分隔。我们需要打印这些价格的总和。有不同方法可以解决这个问题,但这里给出了一种可能的解决方案:

auto text = "19.99 7.50 49.19 20 12.34";
auto stream = std::istringstream{ text };
std::vector<double> prices;
double price;
while (stream >> price)
{
   prices.push_back(price);
}
auto total = std::accumulate(prices.begin(), prices.end(), 
                             0.0);
std::cout << std::format("total: {}\n", total);

突出的部分可以用以下两行代码替换,这些代码使用 basic_iostream_view 或更精确地说,使用 istream_view 别名模板:

for (double const price : 
        std::ranges::istream_view<double>(stream))
{
   prices.push_back(price);
}

istream_view 范围工厂所做的是在 istringstream 对象上反复应用操作符 >>,每次应用都会产生一个值。您不能指定分隔符;它只与空白符一起工作。如果您更喜欢使用标准算法而不是手工编写的循环,可以使用 ranges::for_each 限制算法以相同的方式产生结果,如下所示:

std::ranges::for_each(
   std::ranges::istream_view<double>(stream),
   &prices {
      prices.push_back(price); });

本章中给出的示例包括了 filtertakedropreverse 等视图。这些只是 C++20 中可用的标准视图中的一小部分。C++23 中正在添加更多,未来标准版本可能还会添加更多。以下表格列出了整个标准视图集:

表 9.4表 9.4表 9.4表 9.4

表 9.4

除了前表中列出的视图(范围适配器)之外,还有一些在其他特定场景中可能很有用的视图。为了完整性,这些视图列在下一个表中:

表 9.5表 9.5

表 9.5

现在我们已经列举了所有标准范围适配器,让我们看看使用其中一些的更多示例。

探索更多示例

在本节之前,我们看到了以下示例(这次带有显式命名空间):

namespace rv = std::ranges::views;
std::ranges::sort(v);
auto r = v
        | rv::filter([](int const n) {return n % 2 == 0; })
        | rv::drop(2)
        | rv::reverse
        | rv::transform([](int const n) {return n * n; });

这实际上是以下内容的更简短、更易读的版本:

std::ranges::sort(v);auto r =
  rv::transform(
    rv::reverse(
      rv::drop(
        rv::filter(
          v,
          [](int const n) {return n % 2 == 0; }),
        2)),
    [](int const n) {return n * n; });

第一个版本是可能的,因为管道操作符(|)被重载以简化以更易读的形式组合视图。一些范围适配器接受一个参数,而一些可能接受多个参数。以下规则适用:

  • 如果范围适配器 A 接受一个参数,一个视图 V,那么 A(V)V|A 是等价的。这样的范围适配器是 reverse_view,以下是一个示例:

    std::vector<int> v{ 1, 5, 3, 2, 8, 7, 6, 4 };
    namespace rv = std::ranges::views;
    auto r1 = rv::reverse(v);
    auto r2 = v | rv::reverse;
    
  • 如果范围适配器 A 接受多个参数,一个视图 Vargs…,那么 A(V, args…)A(args…)(V)V|A(args…) 是等价的。这样的范围适配器是 take_view,以下是一个示例:

    std::vector<int> v{ 1, 5, 3, 2, 8, 7, 6, 4 };
    namespace rv = std::ranges::views;
    auto r1 = rv::take(v, 2);
    auto r2 = rv::take(2)(v);
    auto r3 = v | rv::take(2);
    

到目前为止,我们已经看到了 filtertransformreversedrop 的应用。为了完成本章的这一部分,让我们通过一系列示例来展示 表 8.7 中视图的使用。在所有以下示例中,我们将考虑 rv 作为 std::ranges::views 命名空间的别名:

  • 以相反的顺序打印序列中的最后两个奇数:

    std::vector<int> v{ 1, 5, 3, 2, 4, 7, 6, 8 };
    for (auto i : v |
      rv::reverse |
      rv::filter([](int const n) {return n % 2 == 1; }) |
      rv::take(2))
    {
       std::cout << i << '\n'; // prints 7 and 3
    }
    
  • 打印一个范围中连续小于 10 的数字子序列,但不包括第一个连续的奇数:

    std::vector<int> v{ 1, 5, 3, 2, 4, 7, 16, 8 };
    for (auto i : v |
     rv::take_while([](int const n){return n < 10; }) |
     rv::drop_while([](int const n){return n % 2 == 1; })
    )
    {
       std::cout << i << '\n'; // prints 2 4 7
    }
    
  • 打印序列中的第一个元素、第二个元素,以及分别的第三个元素:

    std::vector<std::tuple<int,double,std::string>> v = 
    { 
       {1, 1.1, "one"}, 
       {2, 2.2, "two"}, 
       {3, 3.3, "three"}
    };
    for (auto i : v | rv::keys)
       std::cout << i << '\n'; // prints 1 2 3
    for (auto i : v | rv::values)
       std::cout << i << '\n'; // prints 1.1 2.2 3.3
    for (auto i : v | rv::elements<2>)
       std::cout << i << '\n'; // prints one two three
    
  • 打印整数向量向量的所有元素:

    std::vector<std::vector<int>> v { 
       {1,2,3}, {4}, {5, 6}
    };
    for (int const i : v | rv::join)
       std::cout << i << ' ';  // prints 1 2 3 4 5 6
    
  • 打印整数向量向量的所有元素,但每个向量元素之间插入一个 0。范围适配器 join_with 是 C++23 中的新功能,可能尚未被编译器支持:

    std::vector<std::vector<int>> v{
       {1,2,3}, {4}, {5, 6}
    };
    for(int const i : v | rv::join_with(0))
       std::cout << i << ' ';  // print 1 2 3 0 4 0 5 6
    
  • 从一个句子中打印出单独的单词,其中分隔符是空格:

    std::string text{ "this is a demo!" };
    constexpr std::string_view delim{ " " };
    for (auto const word : text | rv::split(delim))
    {
       std::cout << std::string_view(word.begin(), 
                                     word.end()) 
                 << '\n';
    }
    
  • 从整数数组和双精度浮点向量元素创建元组的视图:

    std::array<int, 4> a {1, 2, 3, 4};
    std::vector<double> v {10.0, 20.0, 30.0};
    auto z = rv::zip(a, v)
    // { {1, 10.0}, {2, 20.0}, {3, 30.0} }
    
  • 创建一个由整数数组和双精度浮点向量元素相乘的元素的视图:

    std::array<int, 4> a {1, 2, 3, 4};
    std::vector<double> v {10.0, 20.0, 30.0};
    auto z = rv::zip_transform(
       std::multiplies<double>(), a, v)
    // { {1, 10.0}, {2, 20.0}, {3, 30.0} }
    
  • 打印整数序列相邻元素的配对:

    std::vector<int> v {1, 2, 3, 4};
    for (auto i : v | rv::adjacent<2>)
    {
       // prints: (1, 2) (2, 3) (3, 4)
       std::cout << std::format("({},{})", 
                                i.first, i.second)";
    }
    
  • 打印从整数序列中乘以每个三个连续值得到的结果:

    std::vector<int> v {1, 2, 3, 4, 5};
    for (auto i : v | rv::adjacent_transform<3>(
        std::multiplies()))
    {
       std::cout << i << ' '; // prints: 3 24 60
    }
    

希望这些例子能帮助您理解每个可用视图的可能用例。您可以在本书的源代码中找到更多示例,以及在本节中提到的进一步阅读部分的文章中。在下一节中,我们将讨论 ranges 库的另一个部分,即约束算法。

理解约束算法

标准库提供了超过一百个通用算法。正如我们之前在 ranges 库的介绍部分所讨论的,它们有一个共同点:它们在迭代器的帮助下与抽象范围一起工作。它们接受迭代器作为参数,有时返回迭代器。这使得它们在标准容器或数组上重复使用变得繁琐。以下是一个例子:

auto l_odd = [](int const n) {return n % 2 == 1; };
std::vector<int> v{ 1, 1, 2, 3, 5, 8, 13 };
std::vector<int> o;
auto e1 = std::copy_if(v.begin(), v.end(),
                       std::back_inserter(o),
                       l_odd);
int arr[] = { 1, 1, 2, 3, 5, 8, 13 };
auto e2 = std::copy_if(std::begin(arr), std::end(arr), 
                       std::back_inserter(o), 
                       l_odd);

在这个片段中,我们有一个向量 v 和一个数组 arr,我们将这两个中的奇数元素复制到一个第二个向量 o 中。为此,使用了 std::copy_if 算法。它接受开始和结束输入迭代器(定义输入范围),一个输出迭代器到第二个范围,其中复制的元素将被插入,以及一个一元谓词(在这个例子中,是一个 lambda 表达式)。它返回的是指向目标范围中最后一个复制元素之后的迭代器。

如果我们查看 std::copy_if 算法的声明,我们将找到以下两个重载:

template <typename InputIt, typename OutputIt,
          typename UnaryPredicate>
constexpr OutputIt copy_if(InputIt first, InputIt last,
                           OutputIt d_first,
                           UnaryPredicate pred);
template <typename ExecutionPolicy,
          typename ForwardIt1, typename ForwardIt2,
          typename UnaryPredicate>
ForwardIt2 copy_if(ExecutionPolicy&& policy,
                   ForwardIt1 first, ForwardIt1 last,
                   ForwardIt2 d_first,
                   UnaryPredicate pred);

第一个重载是这里使用和描述的重载。第二个重载是在 C++17 中引入的。这允许您指定一个执行策略,如并行或顺序。这基本上使得标准算法的并行执行成为可能。然而,这与本章的主题无关,我们不会进一步探讨。

大多数标准算法在 std::ranges 命名空间中都有一个新约束版本。这些算法位于 <algorithm><numeric><memory> 头文件中,并具有以下特性:

  • 它们与现有算法具有相同的名称。

  • 它们有重载,允许您指定一个范围,无论是使用开始迭代器和结束哨兵,还是作为一个单独的范围参数。

  • 它们具有修改后的返回类型,提供了关于执行更多信息的说明。

  • 它们支持投影以应用于处理后的元素。投影是一个可以调用的实体。它可以是一个成员指针、一个 lambda 表达式或函数指针。这样的投影在算法逻辑使用元素之前应用于范围元素。

下面是如何声明std::ranges::copy_if算法的重载的:

template <std::input_iterator I,
          std::sentinel_for<I> S,
          std::weakly_incrementable O,
          class Proj = std::identity,
          std::indirect_unary_predicate<
             std::projected<I, Proj>> Pred>
requires std::indirectly_copyable<I, O>
constexpr copy_if_result<I, O> copy_if(I first, S last,
                                       O result,
                                       Pred pred,
                                       Proj proj = {} );
template <ranges::input_range R,
      std::weakly_incrementable O,
      class Proj = std::identity,
      std::indirect_unary_predicate<
      std::projected<ranges::iterator_t<R>, Proj>> Pred>
requires std::indirectly_copyable<ranges::iterator_t<R>, O>
constexpr copy_if_result<ranges::borrowed_iterator_t<R>, O>
          copy_if(R&& r,
                  O result,
                  Pred pred,
                  Proj proj = {});

如果这些看起来更难阅读,那是因为它们有更多的参数、约束和更长的类型名。然而,好处是它们使得代码更容易编写。以下是之前代码片段的重写,使用了std::ranges::copy_if

std::vector<int> v{ 1, 1, 2, 3, 5, 8, 13 };
std::vector<int> o;
auto e1 = std::ranges::copy_if(v, std::back_inserter(o), 
                               l_odd);
int arr[] = { 1, 1, 2, 3, 5, 8, 13 };
auto e2 = std::ranges::copy_if(arr, std::back_inserter(o), 
                               l_odd);
auto r = std::ranges::views::iota(1, 10);
auto e3 = std::ranges::copy_if(r, std::back_inserter(o), 
                               l_odd);

这些示例展示了两个事情:如何从std::vector对象和数组中复制元素,以及如何从视图(范围适配器)中复制元素。它们没有展示的是投影。这之前已经简要提到过。在这里,我们将更详细地讨论它,并提供更多示例。

投影是一个可调用的实体。它基本上是一个函数适配器。它影响谓词,提供了一种执行函数组合的方式。它不提供改变算法的方法。例如,假设我们有以下类型:

struct Item
{
   int         id;
   std::string name;
   double      price;
};

此外,为了解释的目的,让我们也考虑以下元素序列:

std::vector<Item> items{
   {1, "pen", 5.49},
   {2, "ruler", 3.99},
   {3, "pensil case", 12.50}
};

投影允许你在谓词上执行组合。例如,假设我们想要将所有以字母p开头的项复制到第二个向量中。我们可以编写以下代码:

std::vector<Item> copies;
std::ranges::copy_if(
   items, 
   std::back_inserter(copies),
   [](Item const& i) {return i.name[0] == 'p'; });

然而,我们也可以编写以下等效示例:

std::vector<Item> copies;
std::ranges::copy_if(
   items, 
   std::back_inserter(copies),
   [](std::string const& name) {return name[0] == 'p'; },
   &Item::name);

在这个例子中,投影是应用于每个Item元素并在执行谓词(这里是一个 lambda 表达式)之前应用的成员指针表达式&Item::name。这在你已经有可重用的函数对象或 lambda 表达式,并且不想为传递不同类型的参数编写另一个时非常有用。

以这种方式,项目无法用于将范围从一种类型转换为另一种类型。例如,你不能直接将std::vector<Item>中的项的名称复制到std::vector<std::string>中。这需要使用std::ranges::transform范围适配器,如下面的代码片段所示:

std::vector<std::string> names;
std::ranges::copy_if(
   items | rv::transform(&Item::name),
   std::back_inserter(names),
   [](std::string const& name) {return name[0] == 'p'; });

有许多受限算法,但在此我们不会列出它们。相反,你可以在标准中直接查看它们,或者在en.cppreference.com/w/cpp/algorithm/ranges页面上查看。

本章我们将讨论的最后一个主题是编写自定义范围适配器。

编写自己的范围适配器

标准库包含一系列范围适配器,可用于解决许多不同的任务。在新版标准中,还在不断添加更多。然而,可能会有一些情况,你希望创建自己的范围适配器,以便与范围库中的其他适配器一起使用。这实际上并不是一个简单任务。因此,在本章的最后部分,我们将探讨编写此类范围适配器所需遵循的步骤。

为了这个目的,我们将考虑一个范围适配器,它取范围中的每个Nth元素并跳过其他元素。我们将把这个适配器称为step_view。我们可以用它来编写如下代码:

for (auto i : std::views::iota(1, 10) | views::step(1))
   std::cout << i << '\n';
for (auto i : std::views::iota(1, 10) | views::step(2))
   std::cout << i << '\n';
for (auto i : std::views::iota(1, 10) | views::step(3))
   std::cout << i << '\n';
for (auto i : std::views::iota(1, 10) | views::step(2) | 
              std::views::take(3))
   std::cout << i << '\n';

第一个循环将打印从一到九的所有数字。第二个循环将打印所有奇数,1, 3, 5, 7, 9。第三个循环将打印 1, 4, 7。最后,第四个循环将打印 1, 3, 5。

要使这成为可能,我们需要实现以下实体:

  • 定义范围适配器的类模板

  • 一个推导指南,帮助进行范围适配器的类模板参数推导

  • 定义范围适配器迭代器类型的类模板

  • 定义范围适配器哨兵类型的类模板

  • 一个重载的管道运算符 (|) 和辅助函数,这是其实现所必需的

  • 一个编译时常量全局对象,用于简化范围适配器的使用

让我们逐一了解它们,并学习如何定义它们。我们将从哨兵类开始。哨兵是超出末尾迭代器的抽象。它允许我们检查迭代是否到达范围的末尾。哨兵使得末尾迭代器可以与范围迭代器具有不同的类型。哨兵不能解引用或增加。以下是它的定义方式:

template <typename R>
struct step_iterator;
template <typename R>
struct step_sentinel
{
   using base      = std::ranges::iterator_t<R>;
   using size_type = std::ranges::range_difference_t<R>;
   step_sentinel() = default;
   constexpr step_sentinel(base end) : end_{ end } {}
   constexpr bool is_at_end(step_iterator<R> it) const;
private:
   base      end_;
};
// definition of the step_iterator type
template <typename R>
constexpr bool step_sentinel<R>::is_at_end(
   step_iterator<R> it) const
{
   return end_ == it.value();
}

哨兵从一个迭代器构建,并包含一个名为 is_at_end 的成员函数,该函数检查存储的范围迭代器是否等于存储在 step_iterator 对象中的范围迭代器。这种类型,step_iterator,是一个类模板,定义了我们的范围适配器的迭代器类型,我们称之为 step_view。以下是此迭代器类型的实现:

template <typename R>
struct step_iterator : std::ranges::iterator_t<R>
{
   using base
      = std::ranges::iterator_t<R>;
   using value_type
      = typename std::ranges::range_value_t<R>;
   using reference_type
      = typename std::ranges::range_reference_t<R>;
   constexpr step_iterator(
      base start, base end,
      std::ranges::range_difference_t<R> step) :
      pos_{ start }, end_{ end }, step_{ step }
   {
   }
   constexpr step_iterator operator++(int)
   {
      auto ret = *this;
      pos_ = std::ranges::next(pos_, step_, end_);
      return ret;
   }
   constexpr step_iterator& operator++()
   {
      pos_ = std::ranges::next(pos_, step_, end_);
      return *this;
   }
   constexpr reference_type operator*() const
   {
      return *pos_;
   }
   constexpr bool operator==(step_sentinel<R> s) const
   {
      return s.is_at_end(*this);
   }
   constexpr base const value() const { return pos_; }
private:
   base                                pos_;
   base                                end_;
   std::ranges::range_difference_t<R>  step_;
};

此类型必须具有几个成员:

  • 一个名为 base 的别名模板,表示底层范围迭代器的类型。

  • 一个名为 value_type 的别名模板,表示底层范围元素的类型。

  • 重载的运算符 ++*

  • 重载的运算符 == 将此对象与哨兵进行比较。

++ 运算符的实现使用了 std::ranges::next 限制性算法来增加一个具有 N 个位置的迭代器,但不会超出范围的末尾。

为了使用 step_iteratorstep_sentinel 对对 step_view 范围适配器,你必须确保这个对实际上是良好形成的。为此,我们必须确保 step_iterator 类型是一个输入迭代器,并且 step_sentinel 类型确实是 step_iterator 类型的哨兵类型。这可以通过以下 static_assert 语句来完成:

namespace details
{
   using test_range_t = 
      std::ranges::views::all_t<std::vector<int>>;
   static_assert(
      std::input_iterator<step_iterator<test_range_t>>);
   static_assert(
      std::sentinel_for<step_sentinel<test_range_t>, 
      step_iterator<test_range_t>>);
}

step_iterator 类型用于 step_view 范围适配器的实现。至少,它可能看起来如下:

template<std::ranges::view R>
struct step_view : 
   public std::ranges::view_interface<step_view<R>>
{
private:
   R                                   base_;
   std::ranges::range_difference_t<R>  step_;
public:
   step_view() = default;
   constexpr step_view(
      R base,
      std::ranges::range_difference_t<R> step)
         : base_(std::move(base))
         , step_(step)
   {
   }
   constexpr R base() const&
      requires std::copy_constructible<R>
   { return base_; }
   constexpr R base()&& { return std::move(base_); }
   constexpr std::ranges::range_difference_t<R> const& increment() const 
   { return step_; }
   constexpr auto begin()
   {
      return step_iterator<R const>(
         std::ranges::begin(base_),
         std::ranges::end(base_), step_);
   }
   constexpr auto begin() const 
   requires std::ranges::range<R const>
   {
      return step_iterator<R const>(
         std::ranges::begin(base_),
         std::ranges::end(base_), step_);
   }
   constexpr auto end()
   {
      return step_sentinel<R const>{ 
         std::ranges::end(base_) };
   }
   constexpr auto end() const 
   requires std::ranges::range<R const>
   {
      return step_sentinel<R const>{ 
         std::ranges::end(base_) };
   }
   constexpr auto size() const 
   requires std::ranges::sized_range<R const>
   {
      auto d = std::ranges::size(base_); 
      return step_ == 1 ? d : 
         static_cast<int>((d + 1)/step_); }
   constexpr auto size() 
   requires std::ranges::sized_range<R>
   {
      auto d = std::ranges::size(base_); 
      return step_ == 1 ? d : 
         static_cast<int>((d + 1)/step_);
   }
};

定义范围适配器时必须遵循一个模式。这个模式由以下方面表示:

  • 类模板必须有一个模板参数,该参数满足 std::ranges::view 概念。

  • 类模板应该从std::ranges:view_interface派生。它本身也接受一个模板参数,这应该是范围适配器类。这基本上是我们学到的 CRTP 的实现,如第七章模式和惯用语中所述。

  • 该类必须有一个默认构造函数。

  • 该类必须有一个base成员函数,该函数返回底层范围。

  • 该类必须有一个begin成员函数,该函数返回范围第一个元素的迭代器。

  • 该类必须有一个end成员函数,该函数返回范围最后一个元素之后的一个迭代器或一个哨兵。

  • 对于满足std::ranges::sized_range概念要求的范围,此类还必须包含一个名为size的成员函数,该函数返回范围中的元素数。

为了使能够使用类模板参数推导来为step_view类进行推导,应该定义一个用户定义的推导指南。这些在第四章高级模板概念中进行了讨论。这样的指南应该如下所示:

template<class R>
step_view(R&& base, 
          std::ranges::range_difference_t<R> step)
   -> step_view<std::ranges::views::all_t<R>>;

为了使能够使用管道迭代器(|)将此范围适配器与其他适配器组合,此操作符必须被重载。然而,我们需要一些辅助函数对象,如下所示:

namespace details
{
   struct step_view_fn_closure
   {
      std::size_t step_;
      constexpr step_view_fn_closure(std::size_t step)
         : step_(step)
      {
      }
      template <std::ranges::range R>
      constexpr auto operator()(R&& r) const
      {
         return step_view(std::forward<R>(r), step_);
      }
   };
   template <std::ranges::range R>
   constexpr auto operator | (R&& r, 
                              step_view_fn_closure&& a)
   {
      return std::forward<step_view_fn_closure>(a)(
         std::forward<R>(r));
   }
}

step_view_fn_closure类是一个函数对象,它存储一个表示每个迭代器要跳过的元素数的值。其重载的调用操作符接受一个范围作为参数,并返回一个由范围和跳步数值创建的step_view对象。

最后,我们希望使代码能够以类似于标准库中可用的方式编写,标准库为每个存在的范围适配器在std::views命名空间中提供了一个编译时全局对象。例如,而不是使用std::ranges::transform_view,你可以使用std::views::transform。同样,而不是使用(在某些命名空间中的)step_view,我们希望有一个对象,views::step。为了做到这一点,我们需要另一个函数对象,如下所示:

namespace details
{
   struct step_view_fn
   {
      template<std::ranges::range R>
      constexpr auto operator () (R&& r, 
                                  std::size_t step) const
      {
         return step_view(std::forward<R>(r), step);
      }
      constexpr auto operator () (std::size_t step) const
      {
         return step_view_fn_closure(step);
      }
   };
}
namespace views
{
   inline constexpr details::step_view_fn step;
}

step_view_fn类型是一个函数对象,它有两个重载的调用操作符:一个接受一个范围和一个整数,并返回一个step_view对象;另一个接受一个整数并返回一个用于此值的闭包,或者更准确地说,是一个step_view_fn_closure实例,这是我们之前看到的。

在实现了所有这些之后,我们可以成功运行本节开头所示的代码。我们已经完成了简单范围适配器的实现。希望这能给你一个编写范围适配器的概念。当你查看细节时,ranges 库非常复杂。在本章中,你学习了关于库内容的一些基础知识,它如何简化你的代码,以及如何通过自定义功能来扩展它。如果你想要使用其他资源学习更多,这些知识应该是一个起点。

摘要

在本书的最后一章,我们探讨了 C++20 范围库。我们从一个范围的概念过渡到新的范围库开始讨论。我们了解了这个库的内容以及它如何帮助我们编写更简单的代码。我们重点讨论了范围适配器,但也看了约束算法。在本章的结尾,我们学习了如何编写一个自定义范围适配器,它可以与标准适配器一起使用。

问题

  1. 什么是范围?

  2. 范围库中的视图是什么?

  3. 什么是约束算法?

  4. 什么是哨兵?

  5. 如何检查哨兵类型是否对应迭代器类型?

进一步阅读

附录

第十章:结束语

我们现在到了这本书的结尾。模板不是 C++编程中最容易的部分。的确,人们通常觉得它们很难或很糟糕。然而,模板在 C++代码中被广泛使用,你很可能在编写任何类型的代码时都会每天使用模板。

我们从学习模板是什么以及为什么我们需要它们开始这本书。然后我们学习了如何定义函数模板、类模板、变量模板和别名模板。我们了解了模板参数、特化和实例化。在第三章中,我们学习了具有可变数量参数的模板,这些模板被称为变长模板。下一章专门介绍了更高级的模板概念,如名称绑定、递归、参数推导和前向引用。

我们接着学习了类型特性、SFINAE 和 constexpr if 的使用,并探讨了标准库中可用的类型特性集合。第六章专门介绍了概念和约束,它们是 C++20 标准的一部分。我们学习了如何以不同的方式指定模板参数的约束,以及如何定义概念及其相关内容。我们还探讨了标准库中可用的概念集合。

在书的最后部分,我们专注于使用模板进行实际应用。首先,我们探索了一系列模式和惯用法,如 CRTP、混入、类型擦除、标签分派、表达式模板和类型列表。然后,我们学习了容器、迭代器和算法,它们是标准模板库的支柱,并编写了一些自己的。最后,最后一章专门介绍了 C++20 范围库,我们学习了范围、范围适配器和约束算法。

到达这个阶段,你已经完成了使用 C++模板学习元编程的旅程。然而,这个过程并没有结束。一本书只能为你提供学习一个主题所需的信息,并以一种易于理解和跟随的方式组织这些信息。但是,如果不实践你所学的知识,阅读一本书是徒劳的。你现在的工作是将从这本书中学到的知识应用到工作、学校或家庭中。因为只有通过实践,你才能真正掌握不仅 C++语言和模板元编程,还有任何其他技能。

我希望这本书能成为你在成为 C++模板高手的过程中宝贵的资源。在编写这本书的过程中,我努力寻找简洁与有意义的平衡,以便让你更容易学习这个困难的话题。我希望我在这方面取得了成功。

感谢您阅读这本书,并祝您在实践过程中好运。

第十一章:作业答案

第一章,模板简介

问题 1

我们为什么需要模板?它们提供了哪些优势?

答案

使用模板有几个好处:它们帮助我们避免编写重复的代码,促进了通用库的创建,并且可以帮助我们编写更少、更好的代码。

问题 2

如何调用模板函数?对于模板类又是如何?

答案

模板函数被称为函数模板。同样,模板类被称为类模板。

问题 3

有多少种模板参数类型?它们是什么?

答案

模板参数有三种类型:类型模板参数、非类型模板参数和模板模板参数。

问题 4

什么是部分专业化?完全专业化又是如何?

答案

专业化是一种为模板提供替代实现的技术,这种模板被称为主模板。部分专业化是为模板的一些参数提供的替代实现。当为所有模板参数提供参数时,这是一种完全专业化的替代实现。

问题 5

使用模板的主要缺点是什么?

答案

使用模板的主要缺点包括以下内容:复杂的语法、通常很长且难以阅读和理解的编译器错误,以及编译时间的增加。

第二章,模板基础

问题 1

哪些类型的类型可以用于非类型模板参数?

答案

非类型模板参数只能具有结构化类型。结构化类型包括整数类型、浮点类型(自 C++20 起包括)、枚举类型、指针类型(指向对象或函数)、成员指针类型(指向成员对象或成员函数)、左值引用类型(指向对象或函数),以及满足几个要求的字面类类型:所有基类都是公共的且不可变的,所有非静态数据成员都是公共的且不可变的,所有基类和所有非静态数据成员的类型也是结构化类型或其数组。这些类型的 constvolatile 修饰版本也是允许的。

问题 2

默认模板参数不允许在哪些地方使用?

答案

默认模板参数不能用于参数包的声明,在友元类模板的声明中,以及在函数模板或成员函数模板的显式特化的声明或定义中。

问题 3

显式实例化声明是什么?它与显式实例化定义在语法上有什么区别?

答案

显式实例化声明是告诉编译器模板实例化的定义位于不同的翻译单元中,并且不应生成新定义的方法。其语法与显式实例化定义相同,只是声明前使用了extern关键字。

问题 4

什么是别名模板?

答案

别名模板是一个名称,与类型别名不同,它不是引用另一个类型,而是引用一个模板,换句话说,是一系列类型。别名模板通过使用声明引入。它们不能通过typedef声明引入。

问题 5

什么是模板 lambda?

答案

模板 lambda 是 C++20 中引入的泛型 lambda 的改进形式。它们允许我们使用模板语法显式指定编译器为 lambda 表达式生成的函数对象的模板化函数调用运算符的形状。

第三章,变长模板

问题 1

什么是变长模板以及它们为什么有用?

答案

变长模板是具有可变数量参数的模板。它们不仅允许我们编写具有可变数量参数的函数,还可以编写类模板、可变模板和别名模板。与使用va_宏等其他方法不同,它们是类型安全的,不需要宏,也不需要我们显式指定参数数量。

问题 2

什么是参数包?

答案

存在两种参数包:模板参数包和函数参数包。前者是接受零个、一个或多个模板参数的模板参数。后者是接受零个、一个或多个函数参数的函数参数。

问题 3

在哪些上下文中可以展开参数包?

答案

参数包可以在多种上下文中展开,如下所示:模板参数列表、模板参数列表、函数参数列表、函数参数列表、括号初始化器、花括号初始化器、基指定符和成员初始化列表、折叠表达式、使用声明、lambda 捕获、sizeof...运算符、对齐指定符和属性列表。

问题 4

什么是折叠表达式?

答案

折叠表达式是一个涉及参数包的表达式,它将参数包的元素折叠(或归约)到二元运算符上。

问题 5

使用折叠表达式的优点是什么?

答案

使用折叠表达式的优点包括编写更少、更简单的代码,更少的模板实例化,这导致编译时间更快,并且可能更快地执行代码,因为多个函数调用被单个表达式所替代。

第四章,高级模板概念

问题 1

在何时进行名称查找?

答案

名称查找是在模板实例化的点上对依赖名称(依赖于模板参数的类型或值的名称)进行的,在模板定义的点上对非依赖名称(不依赖于模板参数的名称)进行的。

问题 2

推导指南是什么?

答案

推导指南是一种机制,告诉编译器如何执行类模板参数的推导。推导指南是代表虚构类类型的构造函数签名的虚构函数模板。如果虚构函数模板的构造集上解析过载失败,则程序是不良形式,并生成错误。否则,所选函数模板特化的返回类型成为推导出的类模板特化。

问题 3

什么是前向引用?

答案

前向引用(也称为通用引用)是模板中的一个引用,如果传递了右值作为参数,则表现得像一个右值引用;如果传递了左值作为参数,则表现得像一个左值引用。前向引用必须具有 T&& 形式,例如在 template <typename T> void f(T&&) 中。如 T const &&std::vector<T>&& 这样的形式不代表前向引用,而是正常的右值引用。

问题 4

decltype 做什么?

答案

decltype 说明符是一个类型说明符。它返回表达式的类型。它通常与 auto 说明符一起在模板中使用,以声明依赖于其模板参数的函数模板的返回类型,或者包装另一个函数并返回包装函数执行结果的函数的返回类型。

问题 5

std::declval 做什么?

答案

std::declval 是来自 <utility> 头文件的一个实用函数模板,它为其类型模板参数添加了一个右值引用。它只能在未评估上下文中使用(仅在编译时上下文中使用,在运行时不进行评估),其目的是帮助对没有默认构造函数或无法访问的(因为它私有或受保护)的类型进行依赖类型评估。

第五章,类型特性和条件编译

问题 1

什么是类型特性?

答案

类型特性是小的类模板,使我们能够查询类型的属性或对类型进行转换。

问题 2

什么是 SFINAE?

答案

SFINAESubstitution Failure Is Not An Error 的缩写。这是一个模板替换规则,其工作方式如下:当编译器遇到函数模板的使用时,它会替换参数以实例化模板;如果在此处发生错误,它不会被视为无效代码,而只是推导失败。因此,函数将从重载集中移除,而不是导致错误。因此,只有在特定函数调用在重载集中没有匹配项时才会发生错误。

问题 3

什么是 constexpr if

答案

constexpr ifif 语句的编译时版本。它的语法是 if constexpr(condition)。它自 C++17 起可用,允许我们根据编译时表达式的值在编译时丢弃一个分支。

问题 4

std::is_same 做什么?

答案

std::is_same 是一个类型特性,用于检查两个类型是否相同。它包括对 constvolatile 修饰符的检查,如果两个类型有不同的修饰符(例如 intint const),则返回 false

问题 5

std::conditional 做什么?

答案

std::conditional 是一个元函数,根据编译时常量选择一个类型或另一个类型。

第六章,概念和约束

问题 1

约束是什么?概念又是什么?

答案

约束是对模板参数施加的要求。概念是一组命名约束。

问题 2

需求子句和需求表达式是什么?

答案

需求子句是一个构造,允许我们指定模板参数或函数声明的约束。这个构造由 requires 关键字后跟一个编译时布尔表达式组成。需求子句会影响函数的行为,包括在重载解析中仅当布尔表达式为 true 时才包括它。另一方面,需求表达式具有 requires (parameters-list) expression; 的形式,其中 parameters-list 是可选的。它的目的是验证某些表达式是否良好形成,没有任何副作用或影响函数的行为。需求表达式可以与需求子句一起使用,尽管命名概念更受欢迎,主要是因为可读性。

问题 3

需求表达式的类别有哪些?

答案

需求表达式分为四类:简单需求、类型需求、复合需求和嵌套需求。

问题 4

约束如何影响重载解析中模板的排序?

答案

函数的约束会影响它们在重载解析集中的顺序。当多个重载与参数集匹配时,选择约束更强的重载。然而,请注意,使用类型特性(或一般而言的布尔表达式)和概念进行约束在语义上并不相同。有关此主题的详细信息,请回顾了解具有约束的模板排序部分。

问题 5

简化函数模板是什么?

答案

简化函数模板是 C++20 中引入的新特性,它为函数模板提供了简化的语法。可以使用auto指定符来定义函数参数,并且可以省略模板语法。编译器将自动从简化函数模板生成函数模板。这些函数可以使用概念进行约束,因此对模板参数施加要求。

第七章,模式和惯用法

问题 1

奇特重复模板模式通常用于解决哪些典型问题?

答案

奇特重复模板模式CRTP)通常用于解决为类型添加公共功能、避免代码重复、限制类型实例化的次数或实现组合设计模式等问题。

问题 2

混合模式是什么?它们的目的又是什么?

答案

混合模式是设计用来通过从它们打算补充的类继承来向其他类添加功能的小类。这与 CRTP 模式相反。

问题 3

类型擦除是什么?

答案

类型擦除是描述从类型中删除信息的模式的术语,这使得不相关的类型可以以通用方式处理。尽管可以通过void指针或多态实现类型擦除的形式,但真正的类型擦除模式是在 C++中使用模板实现的。

问题 4

标签分派是什么?它的替代方案有哪些?

答案

标签分派是一种技术,它使我们能够在编译时选择一个或另一个函数重载。虽然标签分派本身是std::enable_if和 SFINAE 的替代方案,但它也有自己的替代方案。这些是 C++17 中的 constexpr if 和 C++20 中的概念。

问题 5

表达式模板是什么?它们在哪里被使用?

答案

表达式模板是一种元编程技术,它允许在编译时进行计算的惰性评估。这种技术的优点是它避免了在运行时执行低效操作,代价是代码更加复杂,可能难以理解。表达式模板通常用于实现线性代数库。

第八章,范围和算法

问题 1

标准库中的序列容器有哪些?

答案

C++ 标准库中的序列容器包括 std::vectorstd::dequestd::liststd::arraystd::forward_list

问题 2

标准容器中定义了哪些常见的成员函数?

答案

标准库中大多数容器定义的成员函数包括 size(在 std::forward_list 中不存在),emptyclear(在 std::arraystd::stackstd::queuestd::priority_queue 中不存在),swapbeginend

问题 3

迭代器是什么?有多少个类别?

答案

迭代器是一种抽象,它使我们能够以通用方式访问容器中的元素,而无需了解每个容器的实现细节。迭代器对于编写通用算法至关重要。在 C++ 中有六种迭代器类别:输入迭代器、正向迭代器、双向迭代器、随机访问迭代器、连续迭代器(自 C++17 起存在)和输出迭代器。

问题 4

随机访问迭代器支持哪些操作?

答案

随机访问迭代器必须支持以下操作(除了输入、正向和双向迭代器所需操作之外):+- 算术运算符,不等式比较(与其他迭代器),复合赋值运算符和偏移量解引用运算符。

问题 5

什么是范围访问函数?

答案

范围访问函数是非成员函数,它们提供了一种统一的方式来访问容器、数组和 std::initializer_list 类的数据或属性。这些函数包括 std::size/std::ssizestd::emptystd::datastd::beginstd::end

第九章,范围库

问题 1

什么是范围?

答案

范围是对元素序列的抽象,由起始迭代器和结束迭代器定义。起始迭代器指向序列中的第一个元素。结束迭代器指向序列的最后一个元素之后的位置。

问题 2

范围库中的视图是什么?

答案

C++ 范围库中的一个视图,也称为范围适配器,是一个实现算法的对象,该算法接受一个或多个范围作为输入,可能还有其他参数,并返回一个调整后的范围。视图是延迟计算的,这意味着它们在迭代元素之前不会执行调整。

问题 3

什么是限制性算法?

答案

限制性算法是现有标准库算法的实现,但位于 C++20 范围库中。它们被称为限制性,因为它们的模板参数使用 C++20 概念进行约束。在这些算法中,而不是要求一个 begin-end 迭代器对来指定,一个值范围接受单个范围参数。然而,也存在接受迭代器-哨兵对的过载。

问题 4

什么是哨兵?

答案

哨兵是对结束迭代器的抽象。这使得结束迭代器可以具有与范围迭代器不同的类型。哨兵不能被解引用或递增。当测试范围结束的条件依赖于某些变量(动态)条件,并且你不知道何时到达范围的末尾(例如,某个条件变为假)时,哨兵非常有用。

问题 5

你如何检查哨兵类型是否对应迭代器类型?

答案

你可以通过使用来自 <iterator> 头文件的 std::sentinel_for 概念来检查哨兵类型是否可以与迭代器类型一起使用。

posted @ 2025-10-06 13:13  绝不原创的飞龙  阅读(2)  评论(0)    收藏  举报