我们或许会经常听到“模板元编程”这样的说法,又或者会将它们同泛型编程混为一谈。但严谨地说,它们是相对独立的三个概念:

  • C++ 模板(Template):是 C++ 提供的语法机制,允许在编译时生成代码(此过程即为实例化)
  • 泛型编程(Generic Programming):是一种编程范式,目的是将代码与特定的数据类型解耦
  • 元编程(Metaprogramming):是一种在编译阶段执行计算的技术。模板是实现这一技术的手段,不过引入 constexpr 后,后者才是更加现代化的选择

我们学习的重点是 C++ 模板本身。

 

1. 函数模板

1. 定义与范式

在 C++ 标准中,函数模板是一个抽象的函数声明,它定义了一族函数的生成规则。

其基本范式为由关键字 template 引导,后续 <> 包围的模板参数列表:

1
2
template<typename T1, typename T2, ...>
return_type function_name(parameter_list){}

typename 是模板参数的占位符,也可以使用 class

二者在大部分场景是等价的,但 typename 具有更明确的语义。此外,只有 typename 可以定义嵌套从属名称,而 class 不行,即:

当模板中的某个名称依赖于模板参数,并且想要将其当作一个类型时,必须在它前面加上 typename。否则,编译器编译器会默认把它当作一个变量或静态成员,从而导致编译错误

依赖名称是指名称的含义依赖于模板参数,如:T::fooT 是模板参数。但是 foo 具体是什么呢(类型、变量,枚举值)?编译器在编译阶段无法确定,只有使用具体类型替换模板参数进行模板实例化时,才能知道依赖名称的真实含义。

在默认情况下,依赖名称被当作非类型(变量/静态成员),如果想让它被解释为类型,就必须显式使用 typename 关键字:

1
2
3
4
5
6
template <typename Container>
void printFirst(const Container& c)
{
// Container::value_type* ptr; // 错误:编译器以为 Container::value_type 是一个变量
typename Container::value_type* ptr;
}

下面是一个函数模板示例,返回两个对象中较大那个:

1
2
3
4
5
template<typename T>
T max(T a, T b)
{
return a > b ? a : b;
}

例如,当我们调用 max(1, 2) 时,编译器首先进行类型推导(此处推导 T 为 int),然后实例化出 int 版本的 max 函数。此处的返回值的类型也是 T,这要求 T 还需要是可复制或可移动的,才可以作为返回值类型返回。

C++17 之前,类型 T 必须是可复制或移动才能传递参数。C++17 以后,即使复制构造函数和移动构造函数都无效,因为 C++17 强制的复制消除,也可以传递临时纯右值。

所谓复制消除简单来说,在 C++17 之前,即使编译器非常聪明(使用了 RVO,即返回值优化),它在语义检查上仍然比较死板,例如:

  • 当编译器遇到 T x = T() 时,会认为发生了两步:创建一个临时的 T 对象;调用复制/移动构造函数将其搬运给 x
  • 哪怕编译器想要优化这个搬运过程,C++ 标准也要求 T 必须具备可用的复制或移动构造函数。如果这二者被 delete 了,代码就无法通过编译

而 C++17 之后,对纯右值(prvalue) 进行了重新定义,prvalue 不再是一个临时对象,而是一个初始化操作(initializer)。T x = T() 不再被视为创建后搬运,二十直接在 x 的内存上执行 T 的构造函数。因为根本没有发生复制或移动的操作,所以编译器完全不需要检查复制或移动构造函数是否存在。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <iostream>

struct CantMove
{
CantMove() { std::cout << "Constructed\n"; }
CantMove(const CantMove&) = delete;
CantMove(CantMove&&) = delete;
};

CantMove makeCantMove()
{
// 返回一个纯右值 (prvalue)
return CantMove();
}

int main()
{
// C++17 以前:编译错误(即便有 RVO)
// C++17 以后:编译通过,直接在 obj 的位置构造
CantMove obj = makeCantMove();
}

 

2. 实例化

模板并不是真正的函数或类,而是定义了一族函数或类的生成规则。编译器不会为模板本身生成机器码,只有指定了具体的类型,它才会生成真正的代码,这个过程称之为实例化

根据使用模板方式的不同,实例化的触发时机也会不同:

  • 隐式实例化(Implicit Instantiation)

    在编译当前翻译单元时,如果遇到一个尚未实例化的调用,且该调用需要函数定义(不仅仅是声明),编译器就会按需生成代码

    该过程以翻译单元为单位,同一模板可能在多个翻译单元中被相同的模板参数隐式实例化。虽然链接器会丢弃重复实例化的代码,但每个翻译单元都要单独编译一次,增加了整体的编译时间

  • 显式实例化 (Explicit Instantiation)

    显式实例化分为两种形式:显式实例化声明和显式实例化定义。

    编译器在遇到显式实例化定义语句时会立刻生成需要的代码,如:

    1
    template int max<int>(int, int);

    该翻译单元编译后就有了该实例的强符号定义。

    显式实例化声明使用 extern template 来声明一个在某处已经有过显式实例化定义的模板,阻止本翻译单元对它进行隐式实例化,如:

    1
    extern int max<int>(int, int);

将常用实例集中在少数翻译单元中显式定义,其他翻译单元只需要声明即可避免重复实例化,整体减少编译时间。

 

3. 模板参数推导

模板参数推导(Template Argument Deduction)是编译器从函数调用表达式(或模板的初始化表达式)中自动推动推断模板参数的过程。

 

对于以下函数模板:

1
2
3
4
5
6
template <typename T>
void func(T param)
{
std::cout << __FUNCSIG__ << '\n'; // MSVC
// std::cout << __PRETTY_FUNCTION__ << '\n'; // GCC/Clang
};

使用 __FUNCSIG____PRETTY_FUNCTION__ 可以得到包含函数完整签名的字符串,包含模板参数 T 推导出的具体类型

调用 func(expr) 时,编译器需要根据实参 expr 推导出模板参数 TT 的推导结果不仅取决于 expr 的类型,还和 T 本身的形式(值、引用、指针、万能引用)有关。

 

1. 值类型推导

1
2
template <typename T>
void func(T param);

param 按值传递,此时实参的类型会被退化处理:

  • 忽略引用:例如实参是 int&const int&T 推导为 int
  • 忽略顶层 const / volatile:例如 const intT 推导为 int
  • 数组退化为指针:例如 int[10]T 推导为 int*
  • 函数退化为函数指针:例如 void(int)T 推导为 void(*)(int)

按值传递时,形参是实参的拷贝。实参的 const 只保证原对象不被修改,而拷贝可随意改动。因此保留 const 毫无意义

 

2. 左值引用推导

1
2
template <typename T>
void func(T& param);

形参类型为左值引用 T& 时,保留实参的 cv 限定符,且不会发生数组/函数退化:

  • 保留 cv 限定符:

    • 例如实参是 const intT 推导为 const intparam 类型为 const int&

    • 例如实参是 const int&T 推导为 const intparam 类型为 const int&

      引用被忽略,但形参仍是左值引用

  • 数组不退化:例如实参 int[10]T 被推导为 int[10]param 类型为 int(&)[10]

  • 函数不退化:实参为函数名,T 推导为函数类型,param 为函数引用

 

非 const 的左值引用不能绑定到右值

右值一般代表没有持久身份的临时值,修改这样的临时对象并无实际用途,C++ 中也杜绝这种行为。如果能用一个非 const 的左值引用绑定它,就意味着可以修改它,这样的行为可以并且应该在编译期禁止:

1
2
3
4
5
>template <typename T>
>void func(T& param);

>int x = 24;
>func(std::move(x)); // 编译错误

如果想要编译通过:

  1. 使用 const T&
1
2
template <typename T>
void func(const T& param);

当引用被 const 修饰时,相当于向编译器承诺不通过这个引用修改对象。既然只是读取,那么绑定到临时对象就没有安全问题,临时对象的值可以在其生命周期内被正常使用

  1. 使用万能引用 T&&,能够同时绑定左值和右值
1
2
template <typename T>
void func(T&& param);

 

3. 指针类型推导

1
2
template <typename T>
void func(T* param);

形参为 T* 时,规则与值类型类似会忽略顶层 const,但保留底层 const(指向常量的指针)。数组和函数会退化为指针。如果形参不是指针,T* 无法匹配,编译失败:

1
2
3
4
5
6
int i = 24;
int* const top = &i;
const int* bottom = &i;

func(top); // T = int, param: int*
func(bottom); // T = const int, param: const int*

 

4. 万能引用推导

形参形式为 T&&T 是模板参数时,它并不是普通的右值引用,而是万能引用(转发引用),能够同时绑定左值和右值。推导规则运用引用折叠:

  • 若实参是左值(类型设为 E),T 推导为 E&,形参类型 E& && 折叠为 E&
  • 若实参是右值(类型 E),T 推导为 E,形参类型 E&&(无折叠)

右值引用的右值引用折叠成右值引用,所有其他组合均折叠成左值引用

1
2
3
4
5
6
7
int y = 10;
const int cy = 20;

func(y); // 左值: T = int&,param 类型: int&
func(cy); // 左值: T = const int&,param 类型: const int&
func(30); // 右值: T = int,param 类型: int&&
func(std::move(y)); // 右值: T = int,param 类型: int&&

万能引用同样对数组和函数区分左值/右值

万能引用通常在 std::forward 配合下实现完美转发

 


Reference