跳转到内容

高级模板

第 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,其中就包括 vector 和 deque。原始版本的 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 有两个参数:T 和 Container。因此,凡是你之前写成 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_type。at() 两个成员函数的返回类型正是如此处理的:它们的返回类型,就是给定 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 里也很常见。stack、queue、priority_queue、flat_(multi)set 和 flat_(multi)map 这些 class template,都接受一个名为 Container 的模板类型参数,并为它提供默认值,用于指定底层 container。

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

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

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

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

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

Grid<int, vector> myIntGrid;

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

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

例如,像 vector 和 deque 这样的 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,例如 vector 或 deque。正如前面看到的,这个“模板类型”本身必须接受两个参数:元素类型 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

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

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

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

在 C++20 之前,non-type template parameter 不能是对象,甚至也不能是 double 或 float。它们只允许是整数类型、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 自身只有两个参数:WIDTH 和 HEIGHT。但你实际上是在为一个有三个模板实参的 Grid——即 T、WIDTH 和 HEIGHT——编写 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() 重载——在这个例子中就是 int、double 和 string。

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 是一个包含三个参数 a1、a2、a3 的 parameter pack,它们的类型分别是 A1、A2 和 A3。那么展开后的调用形式如下:

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:Mixin1 和 Mixin2。为了便于说明,这两个类都保持得比较简单:构造函数接收一个整数并保存起来,同时提供一个成员函数,用于输出该对象的相关信息。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,它们分别是 a1、a2、a3,类型分别为 A1、A2 和 A3。那么这个 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 是一个包含三个参数 v1、v2、v3 的 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 是一个包含三个参数 v1、v2 和 v3 的 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 的引用作为函数参数;函数体里则负责实例化 TuplePrintHelper。main() 中展示了这个简化版本的用法。你不再需要手动写函数模板参数,因为编译器会根据实参自动推导出来。

前面已经介绍过的 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_constant、true_type 和 false_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_type 或 false_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_type 或 false_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_type 或 false_type 之一,而 processHelper() 又正好要求第二个参数是 true_type 或 false_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_same、is_base_of 和 is_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 会给某个类型加上 const,remove_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() 根据两个值的类型是否相同,返回 true 或 false。如果你不想让 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 重载,并推导出:令 T1 和 T2 都为整数类型时,这个实例化是可行的。随后编译器尝试推导返回类型。由于两个参数类型相同,is_same_v<T1, T2> 为 true,于是 enable_if_t<true, bool> 的类型就是 bool。这样一来,一切正常,因此这个重载会被加入候选集。当编译器再看到第二个 checkType() 重载时,同样能先推导出 T1 和 T2 都为整数;但在计算返回类型时,它发现 !is_same_v<T1, T2> 为 false。于是 enable_if_t<false, bool> 不再代表任何类型,也就意味着这个 checkType() 没有返回类型。编译器会注意到这个问题,但由于 SFINAE 的存在,此时它不会立刻报真正的编译错误,而只是简单地把这个重载从候选集中移除。因此,在第一条语句的场景下,候选集里只剩下一个可用的 checkType(),编译器自然知道该调用哪个版本。

当编译器继续处理 main() 中第二条语句时,同样会重新寻找合适的 checkType()。它先看到第一个重载,并推导出 T1 为 int、T2 为 double。接着在尝试推导返回类型时,发现 T1 与 T2 并不相同,因此 is_same_v<T1, T2> 为 false。于是 enable_if_t<false, bool> 不再表示任何类型,这个 checkType() 因而失去返回类型。编译器会注意到这个问题,但仍不会立即报真正错误;由于 SFINAE,它只是把该重载从候选集中移除。接着编译器再看第二个 checkType(),此时 T1 和 T2 不同,因此 !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 一共有三个:conjunction、disjunction 和 negation。与它们对应的变量模板(以 _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,它接收两个模板类型参数 T1 和 T2。使用 type trait 验证这两个类型都属于 arithmetic type;如果是,就执行乘法并返回结果;如果不是,就抛出一个异常,异常中应包含这两个类型的名字。

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