[本文大纲] 引言 模板实例化 隐式实例化 显式实例化 模板具体化 显式具体化 部分具体化 函数重载和具体化 类型推断 隐式类型转换 支持的类型转换 引用和const 通用引用、引用折叠和完美转发 通用引用和右值引用 类型萃取 typename 关联类型 类型萃取 迭代器萃取 特性萃取 - SFINAE特性 auto和decltype auto decltype 后置返回值类型 逗号运算符与类型限制 lambda函数 可变参数模板 参数包展开 函数模板:使用函数展开 函数模板:使用初始化表展开 函数模板:使用递归展开 类模板:使用继承递归展开 模板函数重载决议 重载匹配规则 指针 列表和数组 类类型 引用和const 通用引用的重载 辅助函数重载匹配 其他问题 模板与继承 模板与友元函数 支持隐式转换 函数回调 |
引言
C++作为常用于高性能开发环境的编程语言,不仅提供了对底层的精细控制方法,而且为工程开发提供了足够的特性支持,C++的模板特性就是其中重要的体现之一,它的意义体现在以下多个方面:
① 作为泛型编程的基础。C++标准库中提供的泛型容器以及其它第三方泛型库普遍依赖C++模板。泛型意味着我们在编写代码时可以忽略对象本身具体类型,而认为它可以是任意类型,在使用的时候才去决定它的类型。
如果我们基于泛型的角度去认识模板,就是通过模板可以实现一次编程适配所有类型。如果单是实现这一点那么模板并没有那么复杂,引入复杂度的可能来自以下这些内容,一是我们总是需要对特定类型做特殊处理,二是编译器本身在翻译模板的时候可能会产生的问题,比如模板参数会被推断成什么类型、多个特化的情况下如何匹配模板类型等。
② 实现代码的复用和生成。模板本质上就是编译器在编译期间为我们生成代码,我们可以简单地把它理解成一个更高级版的宏定义。
如果基于代码生成的角度去认识模板,我们可以基于模板去实现一些高级语言的特性,比如反射;或者像高级语言一样做一些易用的封装接口,比如实现一个通用消息转发类。更进一步,我们可以利用代码生成去完成一些编译期的计算。
* 阅读本文需要对左右值的概念有一定的了解,需要对模板基础概念有一定的了解
模板实例化
首先需要明确的一点是,模板是一个纯编译期的特性,它不具备运行时动态的特性,并且模板的生成仅在使用模板的时候,编译器才会根据模板定义生成对应类型的实例;这一特性导致了如下情况的发生:
① 只有实际使用到的模板参数才会被实例化。换言之,如果从未调用过某个模板参数,那么就不会生成对应的实例。这个规则同样适用于模板类的函数,也就是说模板类的函数可以是部分实例化的。
② 模板不支持运行时动态推断。一个最简单的例子是,如果我们定义一个非类型模板参数作为数组大小的模板数组,那么它只能是一个静态数组:
template <typename T, int SIZE>
class Array {
T arr[SIZE];
};
Array<int, 5> arr0; // ok
Array<int, n> arr1; // error
③ 模板的声明和定义必须同时写在头文件中,不然可能会产生链接错误。这是因为当我们写下模板函数定义的实现的时候,我们只是向编译器描述了模板函数应该是什么样的,但是编译器并没有真正实例化,只有当我们实际调用某个模板函数的时候,才会生成对应的实例化结果。假如我们把模板的函数定义放在cpp文件中,实例化的时候就找不到函数模板定义。
隐式实例化
当我们在讨论模板的声明和定义能否分离编译的时候,我们实际上基于这一事实,就是模板是在实际调用的时候才去实例化的,这被称为隐式实例化:
min(1.0f, 4.0f); // 生成min<float>(float, float)的实例
显式实例化
当然,我们也可以直接地指出实例化的类型,称为显式实例化。这通常应用于当我们需要频繁使用某个实例化的类型时,我们在头文件中完成显式实例化,比如:
using ObjType = Array<Object, ObjectAllocator>;
这样我们就确保了我们可以在不同文件中都去访问ObjType类,并且它是一定存在的。
显式实例化只需要像上述这样的声明,不需要额外的定义,定义是由编译器根据模板内容生成的。
假设我们仅仅需要引用有限数量的模板实例,我们也可以利用显式实例化来完成分离编译。但这通常只在少数场景上是有效的。
模板特化/具体化
模板特化/具体化(specialization),是指我们可能需要为特殊类型的实例化做一些模板的改动。其中,包含了显式具体化(全特化)和部分具体化(偏特化)两种改动方式。
显式具体化
对于一些特殊类型,模板的实现是不合适的,所以需要我们对这种情况做特殊处理。这个时候就可以使用显式具体化(explicit specialization),也被翻译为全特化。
比如,对const char*的字符串类型,我们需要做特殊处理:
template<>
bool Compare<const char*>(const char* a, const char* b)
{
//...
}
或者对类模板做处理:
template<typename T1, typename T2>
class A
{
T1 a;
T2 b;
};
template<>
class A<float, int>
{
float a;
int b;
};
显式具体化的特点是声明template时,尖括号的内容是空的。
部分具体化
我们还可以对部分实例做特殊的模板改动,这被称为部分具体化(partial specialization),也被翻译为偏特化。
template<typename T1>
class A<T1, int>
{
T1 a;
int b;
};
当我们在实现部分具体化时,我们在尖括号内仅省略我们具体化的参数,而没有具体化的参数仍然保留在尖括号内。
部分具体化只能作用于类模板,不能作用于函数模板
函数重载和具体化
具体化实际上只是告诉编译器,在生成特定类型的模板函数实例化时,需要做什么特殊操作。它有别于函数重载,因为它根本就没有生成可供选择的同名函数重载。
类型推断
当我们使用隐式实例化的时候,编译器会去推断我们匹配的类型。当我们提供了准确的类型时,类型推断一般不会出什么问题,但如果我们提供了会产生歧义的类型呢?
隐式类型转换
如果我们调用普通的函数,并提供了不匹配的类型,那么函数可能会通过隐式转换来完成类型转换,比如:
double min(double a, double b)
{
return a < b ? a : b;
}
float x = min(1.0, 2);
此时2作为int类型,可以隐式转换为double类型,调用成立。
但是对于模板而言,对于不同的类型,它更倾向于生成新的类型实例,而不是基于已有的类型实例进行隐式转换,比如同样对于上述例子,如果我们把min函数写成模板函数:
template<typename T>
T min(T a, T b)
{
return a < b ? a : b;
}
int x = min(1.0, 2.0); // min<float>
int y = min(1, 2); // min<in>
int z = min(1.0, 2); // error
当我们先调用min(1.0, 2.0)时,会生成min<double>模板实例,随后调用min(1, 2)时,会新生成min<int>的模板实例,而不是将int隐式转换为double,并调用min<double>的实例。
但如果我们一定要让min(1,2)使用min<double>的实例,我们可以使用显式实例化:
int y = min<double>(1, 2);
如果我们提供了不匹配的输入,比如min(1.0, 2),由于模板调用默认不进行隐式转换,编译器无法确认生成min<double>还是min<int>的实例,就会发生编译报错。
支持的类型转换
特别的,模板仅支持以下有限的类型转换:
const/reference
我们同样使用以上模板函数为例,假如我们混合输入const和非const的类型:
const int a = 1;
int b = 2;
int x = min(a, b); // min<int>();
以上例子是可以通过编译的,事实上,模板处理值传递时总是会忽略顶层const,比如两个参数都是const int类型,模板总是会翻译成int。
函数/数组
当我们传入函数或者数组的时候,它会退化成函数指针/数组指针,相当于发生了类型转换:
template<typename T>
void func(T param);
void test(int param);
int arr[2] = { 1, 2 };
func(arr); // int*
func(test); // void(*)(int)
总体而言,模板推断会忽略顶层const/reference、函数和数组。但如果我们强制通过引用来访问,则不会发生类型转换:
int arr[2] = { 1, 2 };
auto& arr1 = arr; // int(&)[2]
const int ci = 1;
auto& cr = ci; // const int&
以上设计是可以理解的,比如对于数组来说,如果对不同大小的数组都生成对应大小的实例化函数,那么将会出现代码膨胀。另一方面,大小不一致也会不被认为是一个类型。
引用和const
我们在考虑模板的类型推断的时候,还需要考虑到变量的修饰属性,也就是const和左右值属性。假设我们在声明模板的时候,添加了const或是&/&&的修饰符,那么将会发生什么呢?我们分以下几种情况讨论。
值类型
template<typename T>
void func1(T);
正如我们在前面所提及,对于非引用的模板参数,模板推断会忽略const和引用属性。
int i = 1;
const int ci = 1;
int& ri = i;
func1(i); // int
func1(ci); // int
func1(ri); // int
左值引用
template<typename T>
void func2(T&);
假如我们在函数参数中添加了&的左值限定,这个时候用右值作为输入就会出错。
int i = 1;
const int ci = 1;
func2(i); // int
func2(ci); // const int
func2(1); // error
在上述例子中,对于包含了const的输入,模板会翻译出const。这和模板参数不带任何左右值限定的情况完全不一样。
常量左值引用
template<typename T>
void func3(const T&);
假如我们在函数参数中添加了&的左值限定和const,这个时候理论上我们可以输入任何参数,并且由于参数本身包含了const,我们推断出的结果就不会包含const,这是基于const本身可以提升临时变量的生命周期。
int i = 1;
const int ci = 1;
func3(i); // int
func3(ci); // int
func3(1); // int
右值引用
template<typename T>
void func4(T&&);
假设我们在函数参数中添加了&&的右值,我们可以输入右值,但同时也能输入左值,只是编译器会将左值推断成左值引用类型。
int i = 1;
const int ci = 1;
func4(i); // int&
func4(ci); // const int&
func4(1); // int
这是一个非常特殊的引用,因为它和我们认识的非模板函数的右值引用完全不一样,它保留了变量的const/引用属性,比如对于数组而言,就不会退化成指针:
template<typename T>
void TestType(T&& param)
{
cout << typeid(T).name() << endl;
}
Test("abc"); // const char[3]
常量右值引用
template<typename T>
void func5(const T&&);
只能接受右值作为输入。
在以上情况中,通常我们使用非引用的版本。对于一些常见的作为工具函数使用的模板函数,我们其实并不关心其const/引用属性,此时它如果生成了const/引用的多个实例化,对于开发者来说是一种不必要的代码膨胀。
当我们使用了包含左值/右值的版本时,通常我们是为了处理一些和引用/const相关的逻辑,比如我们可能想要保留原有参数的const/引用属性,或者移除/添加const/引用属性,又或者是完成左右值转换等。
比如在标准库中就提供了一些辅助方法: