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

  • 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. 模板参数推导


Reference