我们或许会经常听到“模板元编程”这样的说法,又或者会将它们同泛型编程混为一谈。但严谨地说,它们是相对独立的三个概念:
- 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. 模板参数推导
Reference
