跳转到内容

高级模板

第 12 章“使用模板编写泛型代码”已经介绍了 class template 和 function template 中最常用的那些特性。如果你只想具备一套模板基础,以便更好理解 Standard Library 的工作方式,或者偶尔自己写一些简单的 class template / function template,那么完全可以跳过这一章。不过,如果模板本身让你感兴趣,想进一步挖掘它的全部威力,那么请继续读下去。本章会带你了解一些有点冷门、但非常迷人的高级细节。

template parameter 一共有三类:template type parameter、non-type template parameter,以及 template template parameter。到目前为止,你已经在第 12 章见过 type parameter 和 non-type parameter,但还没见过 template template parameter。除此之外,type parameter 和 non-type parameter 本身也还有一些第 12 章没有展开的细节。本节会把这三类 template parameter 都再深入一层。

template 的主要价值就在于 template type parameter。你可以声明任意多个 type parameter。比如,你可以给第 12 章里的 Grid template 再加上第二个 type parameter,用来指定 Grid 底层所建立在其上的 container。Standard Library 定义了不少参数化的 container class,其中就包括 vectordeque。原始版本的 Grid class 使用 vector 来保存 grid 元素,但某些使用者可能更希望底层用的是 deque。如果新增一个 template type parameter,就可以让使用者自己决定底层 container 是 vector 还是 deque。当然,Grid 的实现要求这个底层 container 必须支持 random access;同时它还会用到该 container 的 resize() 成员函数,以及它的 value_type 类型别名。这里会使用一个 concept(见第 12 章)来强制保证传入的 container 类型满足这些操作要求。下面就是这个 concept,以及加入额外 template type parameter 之后的 class template 定义。变化之处都已标出。

template <typename Container>
concept GridContainerType =
std::ranges::random_access_range<Container> &&
requires(Container c) {
typename Container::value_type;
c.resize(1);
};
export template <typename T, GridContainerType Container>
class Grid
{
public:
explicit Grid(std::size_t width = DefaultWidth,
std::size_t height = DefaultHeight);
virtual ~Grid() = default;
// Explicitly default a copy constructor and assignment operator.
Grid(const Grid& src) = default;
Grid& operator=(const Grid& rhs) = default;
// Explicitly default a move constructor and assignment operator.
Grid(Grid&& src) = default;
Grid& operator=(Grid&& rhs) = default;
typename Container::value_type& at(std::size_t x, std::size_t y);
const typename Container::value_type& at(
std::size_t x, std::size_t y) const;
std::size_t getHeight() const { return m_height; }
std::size_t getWidth() const { return m_width; }
static constexpr std::size_t DefaultWidth { 10 };
static constexpr std::size_t DefaultHeight { 10 };
private:
void verifyCoordinate(std::size_t x, std::size_t y) const;
Container m_cells;
std::size_t m_width { 0 }, m_height { 0 };
};

现在这个 template 有两个参数:TContainer。因此,凡是你之前写成 Grid<T> 的地方,现在都必须改写成 Grid<T, Container>

现在数据成员 m_cells 的类型不再是 vector<optional<T>>,而变成了 Container。每个 Container 类型都带有一个名为 value_type 的类型别名,这一点已经由 GridContainerType concept 验证过了。在 Grid class template 定义及其成员函数定义中,你可以通过作用域解析运算符访问这个 value_type:也就是 Container::value_type。但由于 Container 是 template type parameter,因此 Container::value_type 是一个 dependent type name。一般来说,编译器默认不会把 dependent name 当成类型名来处理,这往往会引发一些相当晦涩的报错。为了明确告诉编译器“这里指的是类型”,你必须在前面加上 typename 关键字,也就是写成 typename Container::value_typeat() 两个成员函数的返回类型正是如此处理的:它们的返回类型,就是给定 container 类型内部保存元素的类型,也就是 typename Container::value_type

下面是构造函数定义:

template <typename T, GridContainerType Container>
Grid<T, Container>::Grid(std::size_t width, std::size_t height)
: m_width { width }, m_height { height }
{
m_cells.resize(m_width * m_height);
}

下面是其余成员函数的实现:

template <typename T, GridContainerType Container>
void Grid<T, Container>::verifyCoordinate(std::size_t x, std::size_t y) const
{
if (x >= m_width) {
throw std::out_of_range {
std::format("x ({}) must be less than width ({}).", x, m_width) };
}
if (y>= m_height) {
throw std::out_of_range {
std::format("y ({}) must be less than height ({}).", y, m_height) };
}
}
template <typename T, GridContainerType Container>
const typename Container::value_type&
Grid<T, Container>::at(std::size_t x, std::size_t y) const
{
verifyCoordinate(x, y);
return m_cells[x + y * m_width];
}
template <typename T, GridContainerType Container>
typename Container::value_type&
Grid<T, Container>::at(std::size_t x, std::size_t y)
{
return const_cast<typename Container::value_type&>(
std::as_const(*this).at(x, y));
}

现在你就可以像下面这样实例化并使用 Grid 对象:

Grid<int, vector<optional<int>>> myIntVectorGrid;
Grid<int, deque<optional<int>>> myIntDequeGrid;
myIntVectorGrid.at(3, 4) = 5;
println("{}", myIntVectorGrid.at(3, 4).value_or(0));
myIntDequeGrid.at(1, 2) = 3;
println("{}", myIntDequeGrid.at(1, 2).value_or(0));
Grid<int, vector<optional<int>>> grid2 { myIntVectorGrid };
grid2 = myIntVectorGrid;

你也可以尝试用 double 作为 Container 的模板类型参数来实例化 Grid class template:

Grid<int, double> test; // WILL NOT COMPILE

这一行无法编译。编译器会提示:double 类型并不满足与 Container 模板类型参数关联的 concept 所要求的约束。

和普通函数参数一样,template parameter 也可以带默认值。比如,你可能会想规定:Grid 默认使用 vector 作为底层 container。这时 class template 定义可以写成:

export template <typename T,
GridContainerType Container = std::vector<std::optional<T>>>
class Grid
{
// Everything else is the same as before.
};

你可以把第一个模板类型参数中的 T,直接拿来作为第二个模板类型参数默认值中 optional 的模板实参。C++ 语法还要求:在成员函数定义的 template header 那一行里,不能重复写这些默认值。有了这个默认实参后,使用者在实例化 Grid 时,就既可以直接使用默认底层 container,也可以选择显式指定。示例如下:

Grid<int, deque<optional<int>>> myDequeGrid;
Grid<int, vector<optional<int>>> myVectorGrid;
Grid<int> myVectorGrid2 { myVectorGrid };

这类做法在 Standard Library 里也很常见。stackqueuepriority_queueflat_(multi)setflat_(multi)map 这些 class template,都接受一个名为 Container 的模板类型参数,并为它提供默认值,用于指定底层 container。

不过,上一节里的 Container 参数其实还有一个问题。当你实例化 class template 时,需要写出像下面这样的代码:

Grid<int, vector<optional<int>>> myIntGrid;

注意这里 int 被重复写了两次:你既要把它指定为 Grid 的元素类型,又要把它指定为 vectoroptional 的元素类型。那么如果有人这样写呢?

Grid<int, vector<optional<SpreadsheetCell>>> myIntGrid;

这显然就不太对了。理想情况下,你可能更希望代码写成下面这样,从而彻底杜绝这种出错方式:

Grid<int, vector> myIntGrid;

这样的话,Grid class template 就应该自己推导出:它真正想要的是一个“保存 int 类型 optionalvector”。但编译器不会允许你把这样的实参传给普通 type parameter,因为单独的 vector 并不是一个类型,而是一个模板。

如果你想把一个模板作为模板参数传入,就必须使用一种特殊的参数形式,叫做 template template parameter。指定 template template parameter,有点类似于在普通函数中声明 function pointer parameter。function pointer 类型会包含函数的返回类型和参数类型;同样,指定 template template parameter 时,也必须完整写出这个模板自身的参数列表。

例如,像 vectordeque 这样的 container,其模板参数列表大致如下。其中 E 表示元素类型;Allocator 参数会在第 25 章“定制与扩展标准库”中讲到。

template <typename E, typename Allocator = std::allocator<E>>
class vector { /* Vector definition */ };

要把这种 container 作为 template template parameter 传入,你需要做的,其实就是把 class template 的声明原样复制过来(在本例中,就是 template <typename E, typename Allocator = std::allocator<E>> class vector),然后把类名 vector 替换成你自己的参数名 Container。基于前面这个模板规格,下面就是一个使用“container template 作为第二个模板参数”的 Grid class template 定义:

export template <typename T,
template <typename E, typename Allocator = std::allocator<E>> class Container
= std::vector>
class Grid
{
public:
// Omitted code that is the same as before.
std::optional<T>& at(std::size_t x, std::size_t y);
const std::optional<T>& at(std::size_t x, std::size_t y) const;
// Omitted code that is the same as before.
private:
void verifyCoordinate(std::size_t x, std::size_t y) const;
Container<std::optional<T>> m_cells;
std::size_t m_width { 0 }, m_height { 0 };
};

这里到底发生了什么?第一个模板参数和之前一样,仍然是元素类型 T。而第二个模板参数现在本身变成了一个模板,它代表某种 container,例如 vectordeque。正如前面看到的,这个“模板类型”本身必须接受两个参数:元素类型 E 和 allocator 类型。这个参数在 Grid template 中依然叫作 Container。只是现在它的默认值变成了 vector,而不再是 vector<T>,因为这里的 Container 已经从“具体类型”变成了“模板”。

更一般地说,template template parameter 的语法规则如下:

template <..., template <TemplateTypeParams> class ParameterName, ...>

在代码中,不能再单独把 Container 当作一个完整类型使用;你必须明确写成 Container<std::optional<T>>,才是最终的底层 container 类型。例如,m_cells 的声明现在变成这样:

Container<std::optional<T>> m_cells;

成员函数定义本身不需要变化,只是 template header 必须跟着改,例如:

template <typename T,
template <typename E, typename Allocator = std::allocator<E>> class Container>
void Grid<T, Container>::verifyCoordinate(std::size_t x, std::size_t y) const
{
// Same implementation as before...
}

这个 Grid class template 的用法如下:

Grid<int, vector> myGrid;
myGrid.at(1, 2) = 3;
println("{}", myGrid.at(1, 2).value_or(0));
Grid<int, vector> myGrid2 { myGrid };
Grid<int, deque> myDequeGrid;

这一节展示了:你确实可以把模板本身作为 type parameter 传给另一个模板。不过,这套语法看起来确实有些拧巴——而且它本来就拧巴。我个人建议尽量避免使用 template template parameters。事实上,Standard Library 自己也从不使用 template template parameters。

关于非类型模板参数的更多内容

Section titled “关于非类型模板参数的更多内容”

你可能还会希望允许使用者为 grid 中的每个 cell 指定一个默认初始值。下面这种实现思路就很合理:它把零初始化语法 T{} 用作第二个模板参数的默认值。

export template <typename T, T DEFAULT = T{}>
class Grid { /* Identical as before. */ };

这种定义是合法的。你完全可以在第二个模板参数的位置上,直接复用第一个模板参数中的类型 T。这样一来,就可以用这个 T 类型的初始值来初始化 grid 中的每一个 cell:

template <typename T, T DEFAULT>
Grid<T, DEFAULT>::Grid(std::size_t width, std::size_t height)
: m_width { width }, m_height { height }
{
m_cells.resize(m_width * m_height, DEFAULT);
}

其他成员函数定义保持不变,只是你必须把第二个模板参数也补进 template header,并把所有 Grid<T> 全部改成 Grid<T, DEFAULT>。做完这些改动后,就可以实例化带“全元素初始值”的 grid:

Grid<int> myIntGrid; // Initial value is int{}, i.e., 0
Grid<int, 10> myIntGrid2; // Initial value is 10

这里的初始值可以是任意整数。不过,假设你想像下面这样创建一个用于 SpreadsheetCellGrid

SpreadsheetCell defaultCell;
Grid<SpreadsheetCell, defaultCell> mySpreadsheet; // WILL NOT COMPILE

第二行会导致编译错误,因为模板参数 DEFAULT 的值必须在编译期已知;而 defaultCell 的值只有到运行期才会存在,因此它不能作为 DEFAULT 的合法取值。

在 C++20 之前,non-type template parameter 不能是对象,甚至也不能是 doublefloat。它们只允许是整数类型、enum、指针和引用。自 C++20 起,这些限制有所放宽:现在允许 floating-point 类型,甚至某些 class type 也可以作为 non-type template parameter。不过,这类 class type 仍然有很多限制,本书不再展开。只需知道:SpreadsheetCell 并不满足这些限制即可。

第 12 章中展示过的那个 Grid class template 针对 const char* 的 class specialization,属于 full class template specialization,因为它对 Grid 的每一个模板参数都做了特化;也就是说,在 specialization 里已经不再剩下任何模板参数。不过,specialize class 的方式并不只有这一种。你还可以写 partial class template specialization:只特化其中一部分模板参数,而把其余参数保留下来。例如,回忆一下那个带宽高 non-type parameter 的基础版 Grid template:

export template <typename T, std::size_t WIDTH, std::size_t HEIGHT>
class Grid
{
public:
Grid() = default;
virtual ~Grid() = default;
// Explicitly default a copy constructor and assignment operator.
Grid(const Grid& src) = default;
Grid& operator=(const Grid& rhs) = default;
// Explicitly default a move constructor and assignment operator.
Grid(Grid&& src) = default;
Grid& operator=(Grid&& rhs) = default;
std::optional<T>& at(std::size_t x, std::size_t y);
const std::optional<T>& at(std::size_t x, std::size_t y) const;
std::size_t getHeight() const { return HEIGHT; }
std::size_t getWidth() const { return WIDTH; }
private:
void verifyCoordinate(std::size_t x, std::size_t y) const;
std::optional<T> m_cells[WIDTH][HEIGHT];
};

你可以像下面这样,把这个 class template 针对 const char* C-style string 做特化:

export template <std::size_t WIDTH, std::size_t HEIGHT>
class Grid<const char*, WIDTH, HEIGHT>
{
public:
Grid() = default;
virtual ~Grid() = default;
// Explicitly default a copy constructor and assignment operator.
Grid(const Grid& src) = default;
Grid& operator=(const Grid& rhs) = default;
// Explicitly default a move constructor and assignment operator.
Grid(Grid&& src) = default;
Grid& operator=(Grid&& rhs) = default;
std::optional<std::string>& at(std::size_t x, std::size_t y);
const std::optional<std::string>& at(std::size_t x, std::size_t y) const;
std::size_t getHeight() const { return HEIGHT; }
std::size_t getWidth() const { return WIDTH; }
private:
void verifyCoordinate(std::size_t x, std::size_t y) const;
std::optional<std::string> m_cells[WIDTH][HEIGHT];
};

在这个例子里,你并没有特化全部模板参数。因此,这里的 template header 长这样:

export template <std::size_t WIDTH, std::size_t HEIGHT>
class Grid<const char*, WIDTH, HEIGHT>

这个 class template 自身只有两个参数:WIDTHHEIGHT。但你实际上是在为一个有三个模板实参的 Grid——即 TWIDTHHEIGHT——编写 specialization。因此,模板参数列表中只有两个参数,而显式写出来的 Grid<const char*, WIDTH, HEIGHT> 却包含三个实参。当你实例化这个模板时,依然必须把三个参数都写全;你不能只写宽和高。

Grid<int, 2, 2> myIntGrid; // Uses the original Grid
Grid<const char*, 2, 2> myStringGrid; // Uses the partial specialization
Grid<2, 3> test; // DOES NOT COMPILE! No type specified.

没错,这套语法确实会让人有点晕。另外,在 partial specialization 中,与 full specialization 不同,你必须在每一个成员函数定义前都重新写出 template header,就像下面这样:

template <std::size_t WIDTH, std::size_t HEIGHT>
const std::optional<std::string>&
Grid<const char*, WIDTH, HEIGHT>::at(std::size_t x, std::size_t y) const
{
verifyCoordinate(x, y);
return m_cells[x][y];
}

你之所以需要这个包含两个参数的 template header,是为了表明:这个成员函数本身仍然由这两个模板参数控制。还要注意的是,凡是引用这个完整类名的地方,都必须写成 Grid<const char*, WIDTH, HEIGHT>

不过,前面这个例子还没有真正体现 partial specialization 的威力。你完全可以为一整个类型子集编写专门实现,而不是只针对某个单独类型做特化。比如,你可以为 Grid class template 中“所有指针类型”写一份 specialization。这份 specialization 的 copy constructor 和 assignment operator 不再做浅拷贝,而是对指针所指向的对象执行深拷贝。

下面给出的是这个 class 的定义。这里假定你是在基于“只有一个模板参数”的最初版 Grid 做特化。在这个实现里,Grid 变成了所接收数据的 owner,因此会在适当的时候自动释放内存。由于 ownership 语义发生了变化,因此这里必须显式提供 copy/move constructor 以及 copy/move assignment operator。和往常一样,copy assignment operator 使用 copy-and-swap idiom,而 move assignment operator 使用 move-and-swap idiom,相关讨论可参考第 9 章“精通类与对象”;这也意味着你需要一个 noexcept swap() 成员函数。

export template <typename U>
class Grid<U*>
{
public:
explicit Grid(std::size_t width = DefaultWidth,
std::size_t height = DefaultHeight);
virtual ~Grid() = default;
// Copy constructor and copy assignment operator.
Grid(const Grid& src);
Grid& operator=(const Grid& rhs);
// Move constructor and move assignment operator.
Grid(Grid&& src) noexcept;
Grid& operator=(Grid&& rhs) noexcept;
void swap(Grid& other) noexcept;
std::unique_ptr<U>& at(std::size_t x, std::size_t y);
const std::unique_ptr<U>& at(std::size_t x, std::size_t y) const;
std::size_t getHeight() const { return m_height; }
std::size_t getWidth() const { return m_width; }
static constexpr std::size_t DefaultWidth { 10 };
static constexpr std::size_t DefaultHeight { 10 };
private:
void verifyCoordinate(std::size_t x, std::size_t y) const;
std::vector<std::unique_ptr<U>> m_cells;
std::size_t m_width { 0 }, m_height { 0 };
};

一如既往,关键点就在这两行:

export template <typename U>
class Grid<U*>

这套语法表示:这个 class template 是针对“所有指针类型”而写的 Grid specialization。这里有个很重要、但稍显反直觉的点:当你实例化 Grid<int*> 时,U 的值是 int,而不是 int*。听起来确实有点绕,但规则就是这样。

下面是这个 partial specialization 的一个使用示例:

Grid<int> myIntGrid; // Uses the non-specialized grid.
Grid<int*> psGrid { 2, 2 }; // Uses the partial specialization for pointer types.
psGrid.at(0, 0) = make_unique<int>(1);
psGrid.at(0, 1) = make_unique<int>(2);
psGrid.at(1, 0) = make_unique<int>(3);
Grid<int*> psGrid2 { psGrid };
Grid<int*> psGrid3;
psGrid3 = psGrid2;
auto& element { psGrid2.at(1, 0) };
if (element != nullptr) {
println("{}", *element);
*element = 6;
}
println("{}", *psGrid.at(1, 0)); // psGrid is not modified.
println("{}", *psGrid2.at(1, 0)); // psGrid2 is modified.

输出如下:

3
3
6

这些成员函数的实现整体上都比较直接,唯一相对需要留意的是 copy constructor:它会通过逐个元素调用 copy constructor 的方式,来执行真正的深拷贝。

template <typename U>
Grid<U*>::Grid(const Grid& src)
: Grid { src.m_width, src.m_height }
{
// The ctor-initializer of this constructor delegates first to the
// non-copy constructor to allocate the proper amount of memory.
// The next step is to copy the data.
for (std::size_t i { 0 }; i < m_cells.size(); ++i) {
// Make a deep copy of the element by using its copy constructor.
if (src.m_cells[i] != nullptr) {
m_cells[i] = std::make_unique<U>(*src.m_cells[i]);
}
}
}

C++ 标准并不允许对 function template 做 partial specialization。取而代之的方式,是通过另一个 function template 对其进行 overload。我们还是回到第 12 章介绍过的 Find() algorithm。它由一个通用的 Find() function template,以及一个针对 const char* 的非模板重载组成。先回顾一下:

template <typename T>
optional<size_t> Find(const T& value, const T* arr, size_t size)
{
for (size_t i { 0 }; i < size; ++i) {
if (arr[i] == value) {
return i; // found it; return the index.
}
}
return {}; // failed to find it; return empty optional.
}
optional<size_t> Find(const char* value, const char** arr, size_t size)
{
for (size_t i { 0 }; i < size; ++i) {
if (strcmp(arr[i], value) == 0) {
return i; // found it; return the index.
}
}
return {}; // failed to find it; return empty optional.
}

假设你现在想要定制 Find(),使它在处理指针时会先解引用,然后直接对所指向对象使用 operator==。实现这种行为的正确方式,不是尝试做 function partial specialization,而是用另一个“更专门”的 function template 去 overload 原来的 Find()

template <typename T>
optional<size_t> Find(T* value, T* const* arr, size_t size)
{
for (size_t i { 0 }; i < size; ++i) {
if (*arr[i] == *value) {
return i; // Found it; return the index.
}
}
return {}; // failed to find it; return empty optional.
}

下面这段代码多次调用 Find();注释中说明了每一次调用最终命中了哪个版本的 Find()

optional<size_t> res;
int myInt { 3 }, intArray[] { 1, 2, 3, 4 };
size_t sizeArray { size(intArray) };
res = Find(myInt, intArray, sizeArray); // calls Find<int> by deduction
res = Find<int>(myInt, intArray, sizeArray); // calls Find<int> explicitly
double myDouble { 5.6 }, doubleArray[] { 1.2, 3.4, 5.7, 7.5 };
sizeArray = size(doubleArray);
// calls Find<double> by deduction
res = Find(myDouble, doubleArray, sizeArray);
// calls Find<double> explicitly
res = Find<double>(myDouble, doubleArray, sizeArray);
const char* word { "two" };
const char* words[] { "one", "two", "three", "four" };
sizeArray = size(words);
// calls Find<const char*> explicitly
res = Find<const char*>(word, words, sizeArray);
// calls overloaded Find for const char*s
res = Find(word, words, sizeArray);
int *intPointer { &myInt }, *pointerArray[] { &myInt, &myInt };
sizeArray = size(pointerArray);
// calls the overloaded Find for pointers
res = Find(intPointer, pointerArray, sizeArray);
SpreadsheetCell cell1 { 10 };
SpreadsheetCell cellArray[] { SpreadsheetCell { 4 }, SpreadsheetCell { 10 } };
sizeArray = size(cellArray);
// calls Find<SpreadsheetCell> by deduction
res = Find(cell1, cellArray, sizeArray);
// calls Find<SpreadsheetCell> explicitly
res = Find<SpreadsheetCell>(cell1, cellArray, sizeArray);
SpreadsheetCell *cellPointer { &cell1 };
SpreadsheetCell *cellPointerArray[] { &cell1, &cell1 };
sizeArray = size(cellPointerArray);
// Calls the overloaded Find for pointers
res = Find(cellPointer, cellPointerArray, sizeArray);

本章和第 12 章到目前为止介绍的 class / function template,只是 C++ 模板能力的一部分。模板在 C++ 中还有更进一步的能力,其中之一就是 template recursion。它与 function recursion 类似:函数通过不断以“稍微简单一点的问题版本”调用自己来完成求值。template recursion 也是同样的思路。本节会先说明为什么需要它,再展示它的具体写法。

到目前为止,Grid class template 只支持二维,这显然限制了它的适用范围。如果你想写一个 3-D 井字棋游戏,或者写一个处理四维矩阵的数学程序呢?当然,你可以为每种维度各写一个模板类或非模板类。但那样会重复大量代码。另一种思路,是只写一个一维 grid。然后,通过让 Grid 的元素类型本身再次是另一个 Grid,就可以拼出任意维度的 grid。这个“元素类型中的 Grid”又可以继续以 Grid 作为自己的元素类型,依此递归。下面是一个 OneDGrid class template 的实现。它本质上就是前面例子里 Grid 的一维版本,只是额外加入了 resize() 成员函数,并把 at() 换成了 operator[]。和 vector 这样的 Standard Library container 一样,这里的 operator[] 不做边界检查。对这个例子来说,m_elements 保存的是 T 的实例,而不是 std::optional<T> 的实例。

export template <typename T>
class OneDGrid final
{
public:
explicit OneDGrid(std::size_t size = DefaultSize) { resize(size); }
T& operator[](std::size_t x) { return m_elements[x]; }
const T& operator[](std::size_t x) const { return m_elements[x]; }
void resize(std::size_t newSize) { m_elements.resize(newSize); }
std::size_t getSize() const { return m_elements.size(); }
static constexpr std::size_t DefaultSize { 10 };
private:
std::vector<T> m_elements;
};

有了这个 OneDGrid,你就可以这样构造多维 grid:

OneDGrid<int> singleDGrid;
OneDGrid<OneDGrid<int>> twoDGrid;
OneDGrid<OneDGrid<OneDGrid<int>>> threeDGrid;
singleDGrid[3] = 5;
twoDGrid[3][3] = 5;
threeDGrid[3][3][3] = 5;

这段代码工作得没问题,但声明本身显然不够优雅。下一节会介绍一种更像样的做法。

借助 template recursion,你可以写出一个“真正的” N-dimensional grid,因为 grid 的维度本质上就是递归结构。看看下面这行声明就明白了:

OneDGrid<OneDGrid<OneDGrid<int>>> threeDGrid;

你完全可以把每一层嵌套的 OneDGrid 看成一次递归,而最内层“以 int 为元素的 OneDGrid”就是递归的 base case。换句话说,一个三维 grid,本质上就是“以一维 grid 为元素的一维 grid,而那些元素自己又是以一维 grid 为元素的一维 grid,最里面的元素才是 int”。与其让使用者手动书写这种递归,不如直接写一个 class template,让它替使用者把递归做掉。这样一来,你就能像下面这样创建 N-dimensional grid:

NDGrid<int, 1> singleDGrid;
NDGrid<int, 2> twoDGrid;
NDGrid<int, 3> threeDGrid;

NDGrid class template 接受两个模板参数:一个是元素类型,另一个是表示“维度”的整数。这里最关键的 insight 在于:NDGrid 的元素类型,其实并不是模板参数列表中显式写出的那个元素类型,而是“维度比当前小 1 的另一个 NDGrid”。也就是说,一个三维 grid 实际上是一个“保存二维 grid 的 vector”;而每个二维 grid 又是一个“保存一维 grid 的 vector”。

既然是递归,就一定需要 base case。你可以为“维度等于 1”的情形编写一个 partial specialization;在这个 specialization 中,元素类型不再是另一个 NDGrid,而就是模板参数中给定的元素类型本身。

下面先给出 NDGrid class template 的定义与实现,并标出它与上一节 OneDGrid 的主要差异。数据成员 m_elements 现在是一个 vector<NDGrid<T, N-1>>,这就是递归步骤本身。与此同时,operator[] 返回的也不再是 T,而是 NDGrid<T, N-1> 的引用。

除了 template recursion 本身之外,实现中最棘手的一点,是如何正确设置 grid 每一维的大小。这个版本采取的策略是:创建 N-dimensional grid 时,让每一维都使用同样的尺寸。要为每个维度单独指定大小,会复杂得多。这里假定使用者应当可以创建大小为 20、50 之类的 grid,因此构造函数会接收一个整数 size 参数。resize() 成员函数会同时调整 m_elements 的大小,并把每个元素初始化为 NDGrid<T, N-1> { newSize },从而递归地把 grid 的每一维都扩展到相同尺寸。

export template <typename T, std::size_t N>
class NDGrid final
{
public:
explicit NDGrid(std::size_t size = DefaultSize) { resize(size); }
NDGrid<T, N-1>& operator[](std::size_t x) { return m_elements[x]; }
const NDGrid<T, N-1>& operator[](std::size_t x) const {
return m_elements[x]; }
void resize(std::size_t newSize)
{
m_elements.resize(newSize, NDGrid<T, N-1> { newSize });
}
std::size_t getSize() const { return m_elements.size(); }
static constexpr std::size_t DefaultSize { 10 };
private:
std::vector<NDGrid<T, N-1>> m_elements;
};

base case 的模板定义,则是一个针对“维度为 1”的 partial specialization。下面给出它的定义与实现。要注意的是,specialization 不会自动继承 primary template 中的任何代码,因此不少代码都需要重新写一遍。高亮部分展示了它与未特化的 NDGrid 之间的区别。

export template <typename T>
class NDGrid<T, 1> final
{
public:
explicit NDGrid(std::size_t size = DefaultSize) { resize(size); }
T& operator[](std::size_t x) { return m_elements[x]; }
const T& operator[](std::size_t x) const { return m_elements[x]; }
void resize(std::size_t newSize) { m_elements.resize(newSize); }
std::size_t getSize() const { return m_elements.size(); }
static constexpr std::size_t DefaultSize { 10 };
private:
std::vector<T> m_elements;
};

这里递归正式终止:元素类型直接就是 T,而不再是新的模板实例化。

现在,你就可以像下面这样写代码:

NDGrid<int, 3> my3DGrid { 4 };
my3DGrid[2][1][2] = 5;
my3DGrid[1][1][1] = 5;
println("{}", my3DGrid[2][1][2]);

如果你想避免 primary template 与 specialization 之间的重复代码,也可以把那些重复逻辑抽到一个 base class 里,再让 primary template 和 specialization 都继承自它;不过在这个小例子里,这种技巧带来的额外复杂度反而超过了收益。

普通模板只能接受固定数量的模板参数;而 variadic templates 则可以接受可变数量的模板参数。例如,下面这段代码定义了一个模板,它可以接收任意数量的模板参数,参数包名为 Types

template <typename... Types>
class MyVariadicTemplate { };

你可以用任意数量的模板实参来实例化 MyVariadicTemplate,如下所示:

MyVariadicTemplate<int> instance1;
MyVariadicTemplate<string, double, vector<int>> instance2;

它甚至允许零个模板实参:

MyVariadicTemplate<> instance3;

如果你不希望 variadic template 接受零个模板实参,可以改写成下面这样:

template <typename T1, typename... Types>
class MyVariadicTemplate { };

在这种定义下,若尝试用零个模板参数实例化 MyVariadicTemplate,就会得到编译错误。

你无法直接遍历 variadic template 所接收到的参数。要做到这一点,只能借助 template recursion 或 fold expressions。接下来的几节会分别展示这两种方式。

variadic templates 允许你创建 type-safe variable-length 参数列表。下面这个例子定义了一个名为 processValues() 的 variadic template,使它能够以类型安全的方式接收数量可变、类型各异的一组参数。processValues() 会依次处理这份可变参数列表中的每一个值,并对每一个单独参数调用一个名为 handleValue() 的函数。这意味着:你必须为所有想要支持的类型分别写好对应的 handleValue() 重载——在这个例子中就是 intdoublestring

void handleValue(int value) { println("Integer: {}", value); }
void handleValue(double value) { println("Double: {}", value); }
void handleValue(const string& value) { println("String: {}", value); }
void processValues() // Base case to stop recursion
{ /* Nothing to do in this base case. */ }
template <typename T1, typename... Tn>
void processValues(const T1& arg1, const Tn&... args)
{
handleValue(arg1);
processValues(args...);
}

这个例子展示了三点号运算符(...)的“双重用途”。它在三个位置出现,但含义分成两类。第一种,是它出现在 template parameter list 中的 typename 后面,以及函数参数列表中的类型 Tn 后面。在这两种场景下,它都表示 parameter pack。parameter pack 用来接收可变数量的参数。

第二种用法,是它出现在函数体里的参数名 args 后面。这时它表示 parameter pack expansion;也就是说,这个运算符会把 parameter pack 展开成多个独立参数。可以把它理解成:它会把左侧那部分模式,为 parameter pack 中的每一个模板参数各复制一遍,并用逗号分隔。看看下面这条语句:

processValues(args...);

这条语句会把 args parameter pack 展开成一组由逗号分隔的独立参数,然后用这组参数再次调用 processValues()。这个 template 至少总会有一个参数,也就是 T1。因此,每执行一次用 args... 的递归调用,参数数量就会减少一个。

由于 processValues() 的实现是递归的,所以必须有办法终止递归。这里的做法,就是额外实现一个“不接收任何参数”的 processValues() 版本。

你可以这样测试这个 processValues() variadic template:

processValues(1, 2, 3.56, "test", 1.1f);

这个例子所生成的递归调用过程如下:

processValues(1, 2, 3.56, "test", 1.1f);
handleValue(1);
processValues(2, 3.56, "test", 1.1f);
handleValue(2);
processValues(3.56, "test", 1.1f);
handleValue(3.56);
processValues("test", 1.1f);
handleValue("test");
processValues(1.1f);
handleValue(1.1f);
processValues();

要记住,这种可变参数列表的实现是完全 type-safe 的。processValues() 会根据每个参数的真实类型,自动调用正确的 handleValue() 重载。如果你向 processValues() 传入某种类型的参数,而该类型没有对应的 handleValue() 重载,编译器就会直接报错。

你也可以在 processValues() 的实现中使用第 12 章介绍过的 forwarding references。下面这个版本使用的是 forwarding references T&&,并通过 std::forward() 对所有参数做 perfect forwarding。perfect forwarding 的意思是:如果传给 processValues() 的是 rvalue,那么它会继续以 rvalue reference 身份被转发;如果传入的是 lvalue,那么它就会继续以 lvalue reference 身份被转发。

void processValues() // Base case to stop recursion
{ /* Nothing to do in this base case.*/ }
template <typename T1, typename... Tn>
void processValues(T1&& arg1, Tn&&... args)
{
handleValue(forward<T1>(arg1));
processValues(forward<Tn>(args)...);
}

其中有一条语句值得额外解释:

processValues(forward<Tn>(args)...);

这里的 ... 运算符用于展开 parameter pack。它会对 parameter pack 中的每个独立参数都调用一次 std::forward(),并用逗号把这些结果分隔开来。例如,假设 args 是一个包含三个参数 a1a2a3 的 parameter pack,它们的类型分别是 A1A2A3。那么展开后的调用形式如下:

processValues(forward<A1>(a1),
forward<A2>(a2),
forward<A3>(a3));

在一个使用 parameter pack 的函数体中,你还可以通过 sizeof...(pack) 获取 pack 中参数的个数。注意这里的 ... 并不是在做 pack expansion,而是 sizeof... 这套特殊语法的一部分:

int numberOfArguments { sizeof...(args) };

variadic templates 的一个很实用的场景,是编写一个安全且 type-safe 的 printf() 风格 function template。这会是个非常不错的练习题,你可以自己尝试实现。

constexpr if 语句是在编译期执行的 if 语句,而不是在运行期执行。如果 constexpr if 的某个分支永远不会被走到,那么那个分支就根本不会被编译。这种“编译期分支决策”在 variadic templates 场景里尤其有用。例如,前面那个 processValues() 实现需要一个 base case 来终止递归(也就是 void processValues(){} 这个重载)。但如果用上 constexpr if,这个 base case 就可以省掉。要注意的是,这项特性的正式名字叫 constexpr if,但你在实际代码里写的是 if constexpr

template <typename T1, typename... Tn>
void processValues(T1&& arg1, Tn&&... args)
{
handleValue(forward<T1>(arg1));
if constexpr (sizeof...(args) > 0) {
processValues(forward<Tn>(args)...);
}
}

在这个实现中,一旦 variadic parameter pack args 变为空,递归就会自动终止。与之前实现相比,唯一的区别在于:你不能再无参调用 processValues() 了,否则会直接产生编译错误。

parameter pack 几乎可以在任何地方使用。例如,下面这段代码就使用 parameter pack,为 MyClass 定义了可变数量的 mixin class。第 5 章“类设计”讨论过 mixin class 的概念。

class Mixin1
{
public:
explicit Mixin1(int i) : m_value { i } {}
virtual void mixin1Func() { println("Mixin1: {}", m_value); }
private:
int m_value;
};
class Mixin2
{
public:
explicit Mixin2(int i) : m_value { i } {}
virtual void mixin2Func() { println("Mixin2: {}", m_value); }
private:
int m_value;
};
template <typename... Mixins>
class MyClass : public Mixins...
{
public:
explicit MyClass(const Mixins&... mixins) : Mixins { mixins }... {}
virtual ~MyClass() = default;
};

这段代码先定义了两个 mixin class:Mixin1Mixin2。为了便于说明,这两个类都保持得比较简单:构造函数接收一个整数并保存起来,同时提供一个成员函数,用于输出该对象的相关信息。MyClass 这个 variadic template 通过 parameter pack typename... Mixins 来接收任意数量的 mixin class。随后,类本身从所有这些 mixin class 继承,而它的构造函数也接收同样数量的参数,以便分别初始化每一个被继承的 mixin class。要记住,... expansion operator 的本质就是:对它左边那段模式,为 parameter pack 中的每个模板参数各展开一次,并用逗号分隔。这个类的用法如下:

MyClass<Mixin1, Mixin2> a { Mixin1 { 11 }, Mixin2 { 22 } };
a.mixin1Func();
a.mixin2Func();
MyClass<Mixin1> b { Mixin1 { 33 } };
b.mixin1Func();
//b.mixin2Func(); // Error: does not compile.
MyClass<> c;
//c.mixin1Func(); // Error: does not compile.
//c.mixin2Func(); // Error: does not compile.

如果你尝试在 b 上调用 mixin2Func(),编译器会报错,因为 b 根本没有继承 Mixin2。这个程序的输出如下:

Mixin1: 11
Mixin2: 22
Mixin1: 33

C++ 支持 fold expressions。它让 variadic templates 里对 parameter pack 的处理变得轻松很多。fold expressions 可以用来对 parameter pack 中的每个值执行某种操作,也可以把整个 parameter pack 规约成一个单独值,等等。

下表列出了支持的四种 fold。表中的 Ѳ 可以是下列任意运算符:+ - * / % ^ & | << >> += -= *= /= %= ^= &= |= <<= >>= = == != < > <= >= && ||, .* ->*

NAMEEXPRESSIONIS EXPANDED TO
Unary right fold(pack Ѳ ...)pack0 Ѳ (... Ѳ (packn-1 Ѳ packn))
Unary left fold(... Ѳ pack)((pack0 Ѳ pack1) Ѳ ...) Ѳ packn
Binary right fold(pack Ѳ ... Ѳ Init)pack0 Ѳ (... Ѳ (packn-1 Ѳ (packn Ѳ Init)))
Binary left fold(Init Ѳ ... Ѳ pack)(((Init Ѳ pack0) Ѳ pack1) Ѳ ...) Ѳ packn

来看几个例子。前面 processValues() function template 的递归实现如下:

void processValues() { /* Nothing to do in this base case.*/ }
template <typename T1, typename... Tn>
void processValues(T1&& arg1, Tn&&... args)
{
handleValue(forward<T1>(arg1));
processValues(forward<Tn>(args)...);
}

由于它是递归定义的,因此必须有 base case 来终止递归。而使用 fold expressions 后,只需一个 function template 就足够了。这里使用的是针对逗号运算符的 unary right fold:

template <typename... Tn>
void processValues(Tn&&... args) { (handleValue(forward<Tn>(args)), ...); }

本质上,函数体里的那三个点会触发“以逗号运算符作为 Ѳ 的 folding”。这一行会展开成:对 parameter pack 中的每个参数都调用一次 handleValue(),并且这些 handleValue() 调用之间以逗号分隔。举例来说,假设 args 是一个包含三个参数的 parameter pack,它们分别是 a1a2a3,类型分别为 A1A2A3。那么这个 unary right fold 的展开形式如下:

(handleValue(forward<A1>(a1)),
(handleValue(forward<A2>(a2)) , handleValue(forward<A3>(a3))));

再看另一个例子。printValues() function template 会把它的全部参数逐个输出到控制台,并用换行分隔:

template <typename... Values>
void printValues(const Values&... values) { (println("{}", values), ...); }

假设 values 是一个包含三个参数 v1v2v3 的 parameter pack,那么这个 unary right fold 展开后就是:

(println("{}", v1), (println("{}", v2), println("{}", v3)));

你可以向 printValues() 传任意多个参数,例如:

printValues(1, "test", 2.34);

到目前为止,前面这些例子都使用了逗号运算符来做 folding;但 fold expression 几乎可以与任意运算符搭配使用。例如,下面这段代码定义了一个 variadic function template,并使用 binary left fold 计算所有传入值的总和。根据前面的总览表可知,binary left fold 总是需要一个 Init 值。因此,sumValues() 拥有两个模板类型参数:一个普通参数用于指定 Init 的类型,另一个则是可接受 0 个或更多参数的 parameter pack。

template <typename T, typename... Values>
auto sumValues(const T& init, const Values&... values)
{ return (init + ... + values);}

假设 values 是一个包含三个参数 v1v2v3 的 parameter pack,那么这个 binary left fold 的展开形式就是:

return (((init + v1) + v2) + v3);

sumValues() function template 可以这样测试:

println("{}", sumValues(1, 2, 3.3));
println("{}", sumValues(1));

sumValues() 也可以改写成 unary left fold 的形式:

template <typename... Values>
auto sumValues(const Values&... values) { return (... + values); }

而且,正如第 12 章所说,concept 也可以是 variadic 的。例如,你可以给 sumValues() 加约束,使它只允许接收“全部参数类型都相同”的一组实参:

template <typename T, typename... Us>
concept SameTypes = (std::same_as<T, Us> && ...);
template <typename T, typename... Values>
requires SameTypes<T, Values...>
auto sumValues(const T& init, const Values&... values)
{ return (init + ... + values); }

像下面这样调用这个带约束的版本,是完全没有问题的:

println("{}", sumValues(1.1, 2.2, 3.3)); // OK: 3 doubles, output is 6.6
println("{}", sumValues(1)); // OK: 1 integer, output is 1
println("{}", sumValues("a"s, "b"s)); // OK: 2 strings, output is ab

但下面这次调用则会失败,因为参数列表里混入了一个整数和两个 double

println("{}", sumValues(1, 2.2, 3.3)); // Error

对于 unary fold,允许出现“长度为 0 的 parameter pack”,但仅限于与逻辑与(&&)、逻辑或(||)以及逗号(,)运算符组合使用。对于空 parameter pack,对其应用 && 的结果是 true,应用 || 的结果是 false,而应用 , 则得到 void(),也就是一个 no-op。例如:

template <typename... Values>
bool allEven(const Values&... values) { return ((values % 2 == 0) && ...); }
template <typename... Values>
bool anyEven(const Values&... values) { return ((values % 2 == 0) || ...); }
int main()
{
println("{} {} {}", allEven(2,4,6), allEven(2,3), allEven());//true false true
println("{} {} {}", anyEven(1,2,3), anyEven(1,3), anyEven());//true false false
}

这一节会触及 template metaprogramming。这是一个既复杂又庞大的主题,市面上甚至有专门的书只讲它的各种细节。本书没有空间完整展开这些内容,因此这里只会介绍其中最重要的核心概念,并通过几个例子帮助你建立直觉。

template metaprogramming 的目标,是把某些计算从运行期提前到编译期来完成。你几乎可以把它看成“建立在另一门编程语言之上的一门小语言”。这一节会先从一个简单例子切入:在编译期计算某个数的 factorial,并在运行期把结果当成一个普通常量来使用。

template metaprogramming 可以让你在编译期执行计算,而不是在运行期。下面这段代码就是一个典型例子:它会在编译期计算某个数的 factorial。这个实现使用了本章前面讲过的 template recursion,因此需要一个递归模板,再配合一个用于终止递归的 base template。根据数学定义,0 的 factorial 是 1,因此这里把它作为 base case。

template <int f>
class Factorial
{
public:
static constexpr unsigned long long value { f * Factorial<f - 1>::value };
};
template <>
class Factorial<0>
{
public:
static constexpr unsigned long long value { 1 };
};
int main()
{
println("{}", Factorial<6>::value);
}

这段代码计算的是 6 的 factorial,也就是数学上写作 6! 的那个值,结果为 1×2×3×4×5×6,也就是 720。

对于“在编译期计算 factorial”这个具体例子来说,其实并不一定非得用 template metaprogramming。你完全可以不用任何模板,而是写成一个 consteval immediate function,如下所示。尽管如此,这个模板版本依然是理解“如何实现递归模板”的一个非常好的例子。

consteval unsigned long long factorial(int f)
{
if (f == 0) { return 1; }
else { return f * factorial(f - 1); }
}

factorial() 的调用方式和普通函数完全一样,区别只在于:consteval function 保证一定会在编译期执行。例如:

println("{}", factorial(6));

template metaprogramming 的第二个经典例子,是在编译期把 loop 展开,而不是在运行期执行 loop。要注意,loop unrolling 只应在真正需要时才使用,例如性能敏感代码里;通常情况下,编译器本身已经足够聪明,会帮你自动展开那些本来就适合展开的 loop。

这个例子同样使用了 template recursion,因为它需要在编译期反复执行某个“循环步骤”。每次递归时,Loop class template 都会以 i-1 再次实例化自己;当数字减到 0 时,递归就结束了。

template <int i>
class Loop
{
public:
template <typename FuncType>
static void run(FuncType func) {
Loop<i - 1>::run(func);
func(i);
}
};
template <>
class Loop<0>
{
public:
template <typename FuncType>
static void run(FuncType /* func */) { }
};

Loop template 可以这样使用:

void doWork(int i) { println("doWork({})", i); }
int main()
{
Loop<3>::run(doWork);
}

这段代码会让编译器在编译期直接把循环展开,并依次调用三次 doWork()。程序输出如下:

doWork(1)
doWork(2)
doWork(3)

这个例子使用 template metaprogramming 来打印 std::tuple 中的各个独立元素。tuple 已在第 24 章“其他词汇类型”中介绍过。它允许你保存任意多个值,并且每个值都可以拥有各自不同的类型。tuple 的大小和各元素类型都在编译期确定。然而,tuple 本身并没有内建的“遍历全部元素”的机制。下面这个例子会展示:如何用 template metaprogramming 在编译期遍历 tuple 中的各个元素。

和 template metaprogramming 中很多例子一样,这里仍然依赖 template recursion。TuplePrint class template 有两个模板参数:一个是 tuple 类型,另一个是整数,初始值设置为该 tuple 的大小。然后,它在构造函数里递归地实例化自己,并在每次调用时让这个整数递减。再通过 TuplePrint 的一个 partial specialization,在整数降到 0 时终止递归。main() 函数展示了这个 TuplePrint class template 的用法。

template <typename TupleType, int N>
class TuplePrint
{
public:
explicit TuplePrint(const TupleType& t) {
TuplePrint<TupleType, N - 1> tp { t };
println("{}", get<N - 1>(t));
}
};
template <typename TupleType>
class TuplePrint<TupleType, 0>
{
public:
explicit TuplePrint(const TupleType&) { }
};
int main()
{
using MyTuple = tuple<int, string, bool>;
MyTuple t1 { 16, "Test", true };
TuplePrint<MyTuple, tuple_size<MyTuple>::value> tp { t1 };
}

main() 里的 TuplePrint 这一句看上去有点复杂,因为它要求你把 tuple 的确切类型和大小都写成模板参数。这个问题可以通过引入一个 helper function template 来简化,让编译器自动推导这些模板参数。简化后的实现如下:

template <typename TupleType, int N>
class TuplePrintHelper
{
public:
explicit TuplePrintHelper(const TupleType& t) {
TuplePrintHelper<TupleType, N - 1> tp { t };
println("{}", get<N - 1>(t));
}
};
template <typename TupleType>
class TuplePrintHelper<TupleType, 0>
{
public:
explicit TuplePrintHelper(const TupleType&) { }
};
template <typename T>
void tuplePrint(const T& t)
{
TuplePrintHelper<T, tuple_size<T>::value> tph { t };
}
int main()
{
tuple t1 { 16, "Test"s, true };
tuplePrint(t1);
}

这里做的第一处改动,是把原来的 TuplePrint class template 重命名成 TuplePrintHelper。然后又额外提供了一个小的 function template,叫 tuplePrint()。它把 tuple 的类型作为模板类型参数接收,并把对该 tuple 的引用作为函数参数;函数体里则负责实例化 TuplePrintHelpermain() 中展示了这个简化版本的用法。你不再需要手动写函数模板参数,因为编译器会根据实参自动推导出来。

前面已经介绍过的 constexpr if,可以用来大幅简化很多 template metaprogramming 技巧。例如,前面那个打印 tuple 元素的例子,就可以借助 constexpr if 进一步简化。这样一来,原来的 template recursion base case 就不再需要了,因为递归终止条件可以直接由 constexpr if 负责。

template <typename TupleType, int N>
class TuplePrintHelper
{
public:
explicit TuplePrintHelper(const TupleType& t) {
if constexpr (N > 1) {
TuplePrintHelper<TupleType, N - 1> tp { t };
}
println("{}", get<N - 1>(t));
}
};
template <typename T>
void tuplePrint(const T& t)
{
TuplePrintHelper<T, tuple_size<T>::value> tph { t };
}

现在,我们甚至连 TuplePrintHelper class template 本身也可以去掉,直接改写成一个普通 function template tuplePrintHelper()

template <typename TupleType, int N>
void tuplePrintHelper(const TupleType& t)
{
if constexpr (N > 1) {
tuplePrintHelper<TupleType, N - 1>(t);
}
println("{}", get<N - 1>(t));
}
template <typename T>
void tuplePrint(const T& t)
{
tuplePrintHelper<T, tuple_size<T>::value>(t);
}

而且还可以再进一步:把这两个 function template 合并成一个,如下所示:

template <typename TupleType, int N = tuple_size<TupleType>::value>
void tuplePrint(const TupleType& t)
{
if constexpr (N > 1) {
tuplePrint<TupleType, N - 1>(t);
}
println("{}", get<N - 1>(t));
}

调用方式则和之前完全一样:

tuple t1 { 16, "Test"s, true };
tuplePrint(t1);

C++ 支持 compile-time integer sequence,它由定义在 <utility> 中的 std::integer_sequence 提供。在 template metaprogramming 中,一个非常常见的用途是:生成一串 compile-time 索引,也就是一个 size_t 类型的整数序列。为此,标准还提供了辅助类型 std::index_sequence。而 std::make_index_sequence 则可以生成一个与给定 parameter pack 长度相同的 index sequence。

借助 variadic templates、compile-time index sequence 以及 fold expressions,可以把 tuple 打印器实现成下面这样:

template <typename Tuple, size_t... Indices>
void tuplePrintHelper(const Tuple& t, index_sequence<Indices...>)
{
(println("{}", get<Indices>(t)) , ...);
}
template <typename... Args>
void tuplePrint(const tuple<Args...>& t)
{
tuplePrintHelper(t, make_index_sequence<sizeof...(Args)>{});
}

调用方式与之前完全一样:

tuple t1 { 16, "Test"s, true };
tuplePrint(t1);

在这次调用中,tuplePrintHelper() function template 里的 unary right fold expression 会展开成如下形式:

((println("{}", get<0>(t)),
(println("{}", get<1>(t)),
println("{}", get<2>(t)))));

type traits 允许你在编译期基于“类型本身”做出决策。比如,你可以验证某个类型是否继承自另一个类型、是否可转换成另一个类型、是否是 integral type,等等。C++ Standard Library 自带了非常丰富的一整套 type traits。与 type traits 相关的全部功能都定义在 <type_traits> 中。type traits 又被划分为多个类别。下面这个列表列出了一些示例,帮助你对各类 type traits 有个整体印象。若想查看完整列表,请查阅 Standard Library 参考资料(见附录 B“带注释的参考书目”)。

  • Primary type categories: is_void, is_integral, is_floating_point, is_pointer, is_function, …
  • Composite type categories: is_arithmetic, is_reference, is_object, is_scalar, …
  • Type properties: is_const, is_polymorphic, is_unsigned, is_constructible, is_copy_constructible, is_move_constructible, is_assignable, is_trivially_copyable, is_swappable, is_nothrow_swappable, has_virtual_destructor, has_unique_object_representations, is_scoped_enum*, is_implicit_lifetime*, …
  • Type relationships: is_same, is_base_of, is_convertible, is_invocable, is_nothrow_invocable, …
  • Property queries: alignment_of, rank, extent
  • const-volatile modifications: remove_const, add_const, …
  • Reference modifications: remove_reference, add_lvalue_reference, add_rvalue_reference
  • Pointer modifications: remove_pointer, add_pointer
  • Sign modifications: make_signed, make_unsigned
  • Array modifications: remove_extent, remove_all_extents
  • Constant evaluation context: is_constant_evaluated
  • Other transformations: enable_if, conditional, invoke_result, type_identity, remove_cvref, common_reference, decay, …
  • Logical operator traits: conjunction, disjunction, negation

其中带星号(*)的 type trait,是从 C++23 才开始提供的。

type traits 本身属于相当进阶的 C++ 特性。只看前面这个列表——而且它还是从标准完整列表中大幅删减过的版本——你就能看出来,本书不可能逐一解释全部细节。因此,本节只会挑几个常见用例展开,让你理解 type traits 是如何工作的。

在给出一个“模板如何使用 type traits”的示例之前,你首先需要更清楚地理解像 is_integral 这样的类到底是怎么工作的。C++ 标准定义了一个 integral_constant class,大致如下:

template <class T, T v>
struct integral_constant {
static constexpr T value = v;
using value_type = T;
using type = integral_constant<T, v>;
constexpr operator value_type() const noexcept { return value; }
constexpr value_type operator()() const noexcept { return value; }
};

标准还定义了 bool_constanttrue_typefalse_type 这几个类型别名:

template <bool B>
using bool_constant = integral_constant<bool, B>;
using true_type = bool_constant<true>;
using false_type = bool_constant<false>;

当你访问 true_type::value 时,得到的是 true;访问 false_type::value 时,得到的是 false。你也可以访问 true_type::type,其结果就是 true_type 本身的类型;false_type 同理。像 is_integral(用于检查某个类型是否为 integral type)和 is_class(用于检查某个类型是否是 class)这样的 trait,本质上都会继承自 true_typefalse_type。例如,Standard Library 对 bool 类型的 is_integral specialization 就是这样写的:

template <> struct is_integral<bool> : public true_type { };

这就使得你可以写出 is_integral<bool>::value,而它的值就是 true。当然,这些 specialization 不需要你自己写;它们本来就是 Standard Library 的一部分。

下面这段代码展示了 type category 最简单的一种用法:

if (is_integral<int>::value) { println("int is integral"); }
else { println("int is not integral"); }
if (is_class<string>::value) { println("string is a class"); }
else { println("string is not a class"); }

输出如下:

int is integral
string is a class

对每一个带有 value 成员的 trait,Standard Library 还会额外提供一个变量模板,其命名方式是在 trait 名后追加 _v。因此,与其写 some_trait<T>::value,你也可以直接写成 some_trait_v<T>——例如 is_integral_v<T>is_const_v<T> 等等。下面是 Standard Library 中 is_integral_v<T> 的定义方式:

template <class T>
inline constexpr bool is_integral_v = is_integral<T>::value;

利用这些变量模板,前面的例子就可以写得更简洁:

if (is_integral_v<int>) { println("int is integral"); }
else { println("int is not integral"); }
if (is_class_v<string>) { println("string is a class"); }
else { println("string is not a class"); }

事实上,因为 is_integral_v<T> 的值本身就是编译期常量,所以你甚至可以用 constexpr if 取代普通 if

当然,现实中你几乎不会像上面那样孤立地使用 type traits。它们真正有价值的地方,通常是在模板中根据类型的某些性质来生成不同代码。下面这组 function template 就是一个例子。代码定义了两个重载的 processHelper() function template,它们都接收一个类型参数。每个函数的第一个参数是真实的值,而第二个参数则是 true_typefalse_type 的一个实例。外层的 process() function template 只接收一个参数,并在内部调用 processHelper()

template <typename T>
void processHelper(const T& t, true_type)
{
println("{} is an integral type.", t);
}
template <typename T>
void processHelper(const T& t, false_type)
{
println("{} is a non-integral type.", t);
}
template <typename T>
void process(const T& t)
{
processHelper(t, is_integral<T>{});
}

在调用 processHelper() 时,第二个参数写成了 is_integral<T>{}。它利用 is_integral<T> 来判断 T 是否为 integral type。由于 is_integral<T> 本身继承自 true_typefalse_type 之一,而 processHelper() 又正好要求第二个参数是 true_typefalse_type 的实例,因此这里才会写出空花括号 {}。两个重载版本的 processHelper() 都没有给 true_type / false_type 参数命名,因为函数体并不使用这个参数;这个参数存在的唯一目的,就是参与函数重载决议。

代码可以这样测试:

process(123);
process(2.2);
process("Test"s);

输出如下:

123 is an integral type.
2.2 is a non-integral type.
Test is a non-integral type.

前面的例子也可以改写成单个 function template,如下所示。不过这样就无法再展示“如何使用 type traits 来选择不同重载”了。

template <typename T>
void process(const T& t)
{
if constexpr (is_integral_v<T>) {
println("{} is an integral type.", t);
} else {
println("{} is a non-integral type.", t);
}
}

type relationship 的典型例子包括 is_sameis_base_ofis_convertible。这一节会用 is_same 做示范,其余 type relationship 的使用思路也是类似的。

下面这个 same() function template 使用 is_same type trait 来判断给定的两个参数是否属于相同类型,并输出对应信息:

template <typename T1, typename T2>
void same(const T1& t1, const T2& t2)
{
bool areTypesTheSame { is_same_v<T1, T2> };
println("'{}' and '{}' are {} types.", t1, t2,
(areTypesTheSame ? "the same" : "different"));
}
int main()
{
same(1, 32);
same(1, 3.01);
same(3.01, "Test"s);
}

输出如下:

'1' and '32' are the same types.
'1' and '3.01' are different types
'3.01' and 'Test' are different types

实际上,这个例子也可以完全不借助任何 type trait,而是直接使用两个 function template 构成的 overload set:

template <typename T1, typename T2>
void same(const T1& t1, const T2& t2)
{
println("'{}' and '{}' are different types.", t1, t2);
}
template <typename T>
void same(const T& t1, const T& t2)
{
println("'{}' and '{}' are the same type.", t1, t2);
}

第二个 function template 比第一个更专门,因此当它可用时——也就是两个参数类型相同的时候——overload resolution 会优先选择它。

第 18 章“标准库容器”解释过 Standard Library 中的辅助函数模板 std::move_if_noexcept()。它可以根据 move constructor 是否标记为 noexcept,有条件地选择调用 move constructor 或 copy constructor。Standard Library 并没有提供一个完全对应的辅助函数模板,让你方便地根据 move assignment operator 是否为 noexcept,来选择 move assignment 或 copy assignment。既然你现在已经了解了 template metaprogramming 和 type traits,我们就来看看如何自己实现一个 move_assign_if_noexcept()

回顾第 18 章可以知道,move_if_noexcept() 的本质只是:若给定类型的 move constructor 标记为 noexcept,就把某个引用转换成 rvalue reference;否则就把它转换成 reference-to-const

move_assign_if_noexcept() 需要做的也是类似的事情:如果 move assignment operator 标记为 noexcept,就把给定引用转换成 rvalue reference;否则转换成 reference-to-const

这里可以借助 std::conditional type trait 来实现条件选择。这个 trait 有三个模板参数:一个布尔值、一个在布尔值为 true 时使用的类型,以及一个在布尔值为 false 时使用的类型。conditional type trait 的实现如下:

template <bool B, class T, class F>
struct conditional { using type = T; };
template <class T, class F>
struct conditional<false, T, F> { using type = F; };

is_nothrow_move_assignable type trait 则可用来判断某个类型是否支持“不会抛异常的 move assignment”。对 class type 来说,这等价于检查该类型是否拥有一个标记了 noexcept 的 move assignment operator。下面就是 move_assign_if_noexcept() 的完整实现:

template <typename T>
constexpr conditional<is_nothrow_move_assignable_v<T>, T&&, const T&>::type
move_assign_if_noexcept(T& t) noexcept
{
return move(t);
}

Standard Library 还会为那些带 type 成员的 trait(例如 conditional)提供对应的 alias template。这类 alias template 的名字与 trait 本身相同,只是在末尾加上 _t。例如,conditional<B,T,F>::type 对应的 alias template conditional_t<B,T,F>,在标准库中的定义如下:

template <bool B, class T, class F>
using conditional_t = typename conditional<B,T,F>::type;

于是,与其写成:

conditional<is_nothrow_move_assignable_v<T>, T&&, const T&>::type

你就可以改写成:

conditional_t<is_nothrow_move_assignable_v<T>, T&&, const T&>

move_assign_if_noexcept() function template 可以这样测试:

class MoveAssignable
{
public:
MoveAssignable& operator=(const MoveAssignable&) {
println("copy assign"); return *this; }
MoveAssignable& operator=(MoveAssignable&&) {
println("move assign"); return *this; }
};
class MoveAssignableNoexcept
{
public:
MoveAssignableNoexcept& operator=(const MoveAssignableNoexcept&) {
println("copy assign"); return *this; }
MoveAssignableNoexcept& operator=(MoveAssignableNoexcept&&) noexcept {
println("move assign"); return *this; }
};
int main()
{
MoveAssignable a, b;
a = move_assign_if_noexcept(b);
MoveAssignableNoexcept c, d;
c = move_assign_if_noexcept(d);
}

输出如下:

copy assign
move assign

有不少 type trait 会对给定类型进行变换。例如,add_const type trait 会给某个类型加上 constremove_pointer type trait 会把某个类型里的指针去掉,等等。下面是一个例子:

println("{}", is_same_v<string, remove_pointer_t<string*>>);

输出是 true

自己实现这类“类型变换 trait”其实并不难。下面就是一个 my_remove_pointer type trait 的实现(做了轻微简化):

// my_remove_pointer class template.
template <typename T> struct my_remove_pointer { using type = T; };
// Partial specialization for pointer types.
template <typename T> struct my_remove_pointer<T*> { using type = T; };
// Partial specialization for const pointer types.
template <typename T> struct my_remove_pointer<T* const> { using type = T; };
// Alias template for ease of use.
template <typename T>
using my_remove_pointer_t = typename my_remove_pointer<T>::type;
int main()
{
println("{}", is_same_v<string, my_remove_pointer_t<string*>>);
}

enable_if 的使用建立在一个叫作 substitution failure is not an error(SFINAE)的原则之上,这是 C++ 的一个进阶特性。这个原则的意思是:当你为某组模板参数尝试实例化某个 function template 时,如果替换失败,不应立即视为编译错误;相反,这样的 specialization 只应该从可选重载集中被移除。本节只会介绍 SFINAE 的基础思路。

如果你有一组重载函数,就可以使用 enable_if,基于某些 type trait 的结果有选择地禁用其中某些重载。enable_if 往往放在重载函数的返回类型上使用,或者放在匿名的 non-type template parameter 上使用。enable_if 接受两个模板参数:第一个是布尔值,第二个是类型。如果第一个布尔值为 true,那么 enable_if class template 就会提供一个可以通过 ::type 访问的类型别名,其类型就是第二个模板参数给出的类型;如果布尔值为 false,则这个类型别名根本不存在。其实现如下:

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

前面某节里的 same() function template,可以借助 enable_if 改写成一组重载的 checkType() function template。这个实现中,checkType() 根据两个值的类型是否相同,返回 truefalse。如果你不想让 checkType() 返回任何值,也可以把 return 语句删掉,同时去掉 enable_if 的第二个模板参数。

template <typename T1, typename T2>
enable_if_t<is_same_v<T1, T2>, bool>
checkType(const T1& t1, const T2& t2)
{
println("'{}' and '{}' are the same types.", t1, t2);
return true;
}
template <typename T1, typename T2>
enable_if_t<!is_same_v<T1, T2>, bool>
checkType(const T1& t1, const T2& t2)
{
println("'{}' and '{}' are different types.", t1, t2);
return false;
}
int main()
{
checkType(1, 32);
checkType(1, 3.01);
checkType(3.01, "Test"s);
}

输出与之前一样:

'1' and '32' are the same types.
'1' and '3.01' are different types.
'3.01' and 'Test' are different types.

这段代码定义了两个 checkType() 重载。它使用 is_same_v 来判断两个类型是否相同,并把结果传给 enable_if_t。当传给 enable_if_t 的第一个参数为 true 时,enable_if_t 的类型就是 bool;否则,它根本没有类型。这就是 SFINAE 发挥作用的地方。

当编译器开始编译 main() 中第一条语句时,它会尝试寻找一个能接收两个整数值的 checkType()。它先看到第一个 checkType() function template 重载,并推导出:令 T1T2 都为整数类型时,这个实例化是可行的。随后编译器尝试推导返回类型。由于两个参数类型相同,is_same_v<T1, T2>true,于是 enable_if_t<true, bool> 的类型就是 bool。这样一来,一切正常,因此这个重载会被加入候选集。当编译器再看到第二个 checkType() 重载时,同样能先推导出 T1T2 都为整数;但在计算返回类型时,它发现 !is_same_v<T1, T2>false。于是 enable_if_t<false, bool> 不再代表任何类型,也就意味着这个 checkType() 没有返回类型。编译器会注意到这个问题,但由于 SFINAE 的存在,此时它不会立刻报真正的编译错误,而只是简单地把这个重载从候选集中移除。因此,在第一条语句的场景下,候选集里只剩下一个可用的 checkType(),编译器自然知道该调用哪个版本。

当编译器继续处理 main() 中第二条语句时,同样会重新寻找合适的 checkType()。它先看到第一个重载,并推导出 T1intT2double。接着在尝试推导返回类型时,发现 T1T2 并不相同,因此 is_same_v<T1, T2>false。于是 enable_if_t<false, bool> 不再表示任何类型,这个 checkType() 因而失去返回类型。编译器会注意到这个问题,但仍不会立即报真正错误;由于 SFINAE,它只是把该重载从候选集中移除。接着编译器再看第二个 checkType(),此时 T1T2 不同,因此 !is_same_v<T1, T2>true,于是 enable_if_t<true, bool> 的类型就是 bool,这个重载因此有效。最终,第二条语句的候选集中同样只剩下一个可选版本,所以编译器很容易决定该调用哪个重载。

如果你不想让 enable_if 把返回类型弄得一团糟,那么另一种选择是:把 enable_if 放到额外的 non-type template parameter 上使用。这样代码其实更容易读。例如:

template <typename T1, typename T2, enable_if_t<is_same_v<T1, T2>>* = nullptr>
bool checkType(const T1& t1, const T2& t2)
{
println("'{}' and '{}' are the same types.", t1, t2);
return true;
}
template <typename T1, typename T2, enable_if_t<!is_same_v<T1, T2>>* = nullptr>
bool checkType(const T1& t1, const T2& t2)
{
println("'{}' and '{}' are different types.", t1, t2);
return false;
}

如果你想把 enable_if 用在一组 constructor 上,那就不能把它放在返回类型上了,因为 constructor 根本没有返回类型。在这种场景下,你就必须使用前面那种“附加 non-type template parameter”的方式。

本节介绍的这套 enable_if 写法,是 C++20 之前的主流做法。从 C++20 开始,你应优先使用第 12 章讨论过的 concept。注意前面 enable_if 代码与下面这个使用 concept 的版本,在语法上其实有点相似;不过很明显,concept 版本的可读性要高得多。

template <typename T1, typename T2> requires is_same_v<T1, T2>
bool checkType(const T1& t1, const T2& t2)
{
println("'{}' and '{}' are the same types.", t1, t2);
return true;
}
template <typename T1, typename T2> requires !is_same_v<T1, T2>
bool checkType(const T1& t1, const T2& t2)
{
println("'{}' and '{}' are different types.", t1, t2);
return false;
}

总体来说,建议谨慎使用 SFINAE。只有在你确实需要解决某些重载歧义,而这些歧义又无法通过 specialization、concept 等其他手段解决时,才考虑使用它。例如,如果你的目标只是“当模板参数类型不对时,让编译失败”,那么与其用 SFINAE,不如使用 concept,或者使用本章后面会讲到的 static_assert()。当然,SFINAE 也有正当用武之地,但一定要记住下面这点。

依赖 SFINAE 往往既棘手又复杂。如果你的 SFINAE / enable_if 逻辑不小心禁用了错误的重载版本,那么你最终得到的往往会是一些非常隐晦的编译错误,而且很难定位。

使用 constexpr if 简化 enable_if 构造

Section titled “使用 constexpr if 简化 enable_if 构造”

正如前面例子所示,enable_if 用起来很快就会变得复杂。而 constexpr if 可以显著简化其中某些使用场景。

例如,假设你有下面这两个类:

class IsDoable
{
public:
virtual void doit() const { println("IsDoable::doit()"); }
};
class Derived : public IsDoable { };

你可以写一个 function template callDoit():如果传入类型支持 doit() 成员函数,就调用它;否则输出一条错误信息。你当然可以用 enable_if 来实现,通过检查给定类型是否继承自 IsDoable

template <typename T>
enable_if_t<is_base_of_v<IsDoable, T>, void> callDoit(const T& t)
{
t.doit();
}
template <typename T>
enable_if_t<!is_base_of_v<IsDoable, T>, void> callDoit(const T&)
{
println("Cannot call doit()!");
}

测试代码如下:

Derived d;
callDoit(d);
callDoit(123);

输出如下:

IsDoable::doit()
Cannot call doit()!

不过,使用 constexpr if 可以把这个 enable_if 版本大幅简化:

template <typename T>
void callDoit(const T& t)
{
if constexpr (is_base_of_v<IsDoable, T>) {
t.doit();
} else {
println("Cannot call doit()!");
}
}

这里没法用普通 if 语句来达到同样效果。因为对于普通 if,两个分支都必须先通过编译;如果你传入一个并非继承自 IsDoable 的类型 T,那语句 t.doit() 就会直接编译失败。而 constexpr if 不一样:如果传入类型不是 IsDoable 的派生类,那么 t.doit() 这个分支甚至根本不会被编译。

除了使用 is_base_of type trait,你也可以使用 requires expression;参见第 12 章。下面这个版本的 callDoit() 就利用 requires expression 来检查:对象 t 是否可以调用 doit() 成员函数。

template <typename T>
void callDoit(const T& t)
{
if constexpr (requires { t.doit(); }) {
t.doit();
} else {
println("Cannot call doit()!");
}
}

logical operator traits 一共有三个:conjunctiondisjunctionnegation。与它们对应的变量模板(以 _v 结尾)也都提供了。这些 trait 可以接受可变数量的模板类型实参,并被用于对 type traits 做逻辑运算,例如:

print("{} ", conjunction_v<is_integral<int>, is_integral<short>>);
print("{} ", conjunction_v<is_integral<int>, is_integral<double>>);
print("{} ", disjunction_v<is_integral<int>, is_integral<double>,
is_integral<short>>);
print("{} ", negation_v<is_integral<int>>);

输出如下:

true false true false

static_assert() 允许你在编译期断言某些条件必须成立。所谓 assertion,就是“必须为真”的条件;如果它为假,编译器就会报错。一次 static_assert() 调用接受两个参数:一个是在编译期求值的表达式,以及一个可选的字符串。当表达式求值结果为 false 时,编译器会给出一个包含该字符串的错误。例如,下面可以用来检查当前是否正在使用 64 位编译器:

static_assert(sizeof(void*) == 8, "Requires 64-bit compilation.");

如果你用的是 32 位编译器,指针大小只有 4 字节,那么编译器可能给出如下错误:

test.cpp(3): error C2338: Requires 64-bit compilation.

其中的字符串参数是可选的,例如:

static_assert(sizeof(void*) == 8);

在这种情况下,如果表达式求值为 false,你得到的就是编译器相关的错误信息。例如,Microsoft Visual C++ 给出的错误如下:

test.cpp(3): error C2607: static assertion failed

static_assert() 还可以与 type traits 结合使用。例如:

template <typename T>
void foo(const T& t)
{
static_assert(is_integral_v<T>, "T must be an integral type.");
}

正如本节展示的那样,template metaprogramming 是一种非常强大的工具,但它同时也可能迅速变得相当复杂。还有一个前面没有特别提到的问题是:由于一切都发生在编译期,因此你没法依赖 debugger 去精准定位问题。如果你决定在自己的代码中使用 template metaprogramming,那么务必要写好说明性的注释,清楚解释“这里到底在做什么”以及“为什么要这么做”。如果这类 template metaprogramming 代码没有得到充分文档化,那么别人将很难理解你的代码,甚至几年后的你自己也可能看不懂当初到底写了什么。

本章延续了第 12 章对模板的讨论。这两章一起展示了:如何利用模板进行泛型编程,以及如何利用 template metaprogramming 在编译期完成计算。理想情况下,你现在应该已经对这些特性的能力与威力有了更清晰的认识,也大致知道了可以如何把这些技术用到自己的代码中。如果第一次阅读时,你没有完全吃透所有语法,或者没能跟上所有例子,也不必担心。对初学者来说,这些技巧本来就不容易掌握;而一旦模板开始复杂起来,语法本身也会变得十分棘手。等你真正坐下来编写 class template 或 function template 时,可以回过头把本章和第 12 章当作语法参考手册来查阅。

通过完成下面这些练习,你可以巩固本章讨论的内容。所有练习的参考解答都包含在本书网站 www.wiley.com/go/proc++6e 提供的代码下载包中。不过,如果你在某道题上卡住了,建议先回过头重读本章相关部分,尽量自己找到答案,再去看网站上的解答。

  1. 练习 26-1: 在练习 12-2 中,你曾为 const char* 键和值编写了一个 KeyValuePair class template 的 full specialization。请把它改写成 partial specialization:其中 value 固定为 const char*,但 key 允许是任意类型。

  2. 练习 26-2: 使用 template recursion,在编译期计算 Fibonacci 数列中的第 n 项。Fibonacci 数列从 0 和 1 开始,之后每一项都是前两项之和,因此依次为:0、1、1、2、3、5、8、13、21、34、55、89,等等。

    你还能不能再提供一个变量模板,让这个递归版 Fibonacci 模板用起来更方便?

  3. 练习 26-3: 修改你在练习 26-2 中的解法,使计算依然发生在编译期,但不再使用任何模板递归或函数递归。

  4. 练习 26-4: 编写一个 variadic function template,命名为 push_back_values(),它接收一个 vector 的引用和可变数量的值,并使用 fold expression 把这些值全部 push 进该 vector。然后再写一个 insert_values() function template,完成同样的事情,不过这次改用 vector::insert(initializer_list<value_type>) 的方式。两种实现之间有什么差别?

  5. 练习 26-5: 编写一个非 abbreviated 的 multiply() function template,它接收两个模板类型参数 T1T2。使用 type trait 验证这两个类型都属于 arithmetic type;如果是,就执行乘法并返回结果;如果不是,就抛出一个异常,异常中应包含这两个类型的名字。

  6. 练习 26-6: 高级题。把你在练习 26-5 中的解法改写成 abbreviated function template。