我们或许会经常听到“模板元编程”这样的说法,又或者会将它们同泛型编程混为一谈。但严谨地说,它们是相对独立的三个概念:
- C++ 模板(Template):是 C++ 提供的语法机制,允许在编译时生成代码(此过程即为实例化)
- 泛型编程(Generic Programming):是一种编程范式,目的是将代码与特定的数据类型解耦
- 元编程(Metaprogramming):是一种在编译阶段执行计算的技术。模板是实现这一技术的手段,不过引入
constexpr后,后者才是更加现代化的选择
我们学习的重点是 C++ 模板本身。
1. 函数模板
1. 定义与范式
在 C++ 标准中,函数模板是一个抽象的函数声明,它定义了一族函数的生成规则。
其基本范式为由关键字 template 引导,后续 <> 包围的模板参数列表:
1 | template<typename T1, typename T2, ...> |
typename 是模板参数的占位符,也可以使用 class。
二者在大部分场景是等价的,但
typename具有更明确的语义。此外,只有typename可以定义嵌套从属名称,而class不行,即:当模板中的某个名称依赖于模板参数,并且想要将其当作一个类型时,必须在它前面加上
typename。否则,编译器编译器会默认把它当作一个变量或静态成员,从而导致编译错误依赖名称是指名称的含义依赖于模板参数,如:
T::foo,T是模板参数。但是foo具体是什么呢(类型、变量,枚举值)?编译器在编译阶段无法确定,只有使用具体类型替换模板参数进行模板实例化时,才能知道依赖名称的真实含义。在默认情况下,依赖名称被当作非类型(变量/静态成员),如果想让它被解释为类型,就必须显式使用
typename关键字:
1 | template <typename Container> |
下面是一个函数模板示例,返回两个对象中较大那个:
1 | template<typename T> |
例如,当我们调用 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. 实例化
模板并不是真正的函数或类,而是定义了一族函数或类的生成规则。编译器不会为模板本身生成机器码,只有指定了具体的类型,它才会生成真正的代码,这个过程称之为实例化
根据使用模板方式的不同,实例化的触发时机也会不同:
-
隐式实例化(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 | template <typename T> |
使用
__FUNCSIG__或__PRETTY_FUNCTION__可以得到包含函数完整签名的字符串,包含模板参数T推导出的具体类型
调用 func(expr) 时,编译器需要根据实参 expr 推导出模板参数 T。T 的推导结果不仅取决于 expr 的类型,还和 T 本身的形式(值、引用、指针、万能引用)有关。
1. 值类型推导
1 | template <typename T> |
param 按值传递,此时实参的类型会被退化处理:
- 忽略引用:例如实参是
int&、const int&,T推导为int - 忽略顶层
const/volatile:例如const int,T推导为int - 数组退化为指针:例如
int[10],T推导为int* - 函数退化为函数指针:例如
void(int),T推导为void(*)(int)
按值传递时,形参是实参的拷贝。实参的
const只保证原对象不被修改,而拷贝可随意改动。因此保留const毫无意义
2. 左值引用推导
1 | template <typename T> |
形参类型为左值引用 T& 时,保留实参的 cv 限定符,且不会发生数组/函数退化:
-
保留 cv 限定符:
-
例如实参是
const int,T推导为const int,param类型为const int& -
例如实参是
const int&,T推导为const int,param类型为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)); // 编译错误如果想要编译通过:
- 使用
const T&:
1
2 template <typename T>
void func(const T& param);当引用被
const修饰时,相当于向编译器承诺不通过这个引用修改对象。既然只是读取,那么绑定到临时对象就没有安全问题,临时对象的值可以在其生命周期内被正常使用
- 使用万能引用
T&&,能够同时绑定左值和右值
1
2 template <typename T>
void func(T&& param);
3. 指针类型推导
1 | template <typename T> |
形参为 T* 时,规则与值类型类似会忽略顶层 const,但保留底层 const(指向常量的指针)。数组和函数会退化为指针。如果形参不是指针,T* 无法匹配,编译失败:
1 | int i = 24; |
4. 万能引用推导
形参形式为 T&& 且 T 是模板参数时,它并不是普通的右值引用,而是万能引用(转发引用),能够同时绑定左值和右值。推导规则运用引用折叠:
- 若实参是左值(类型设为
E),T推导为E&,形参类型E& &&折叠为E& - 若实参是右值(类型
E),T推导为E,形参类型E&&(无折叠)
右值引用的右值引用折叠成右值引用,所有其他组合均折叠成左值引用
1 | int y = 10; |
万能引用同样对数组和函数区分左值/右值
万能引用通常在 std::forward 配合下实现完美转发
Reference
