理解模板类型推断(template type deduction)
我们往往不能理解一个复杂的系统是如何运作的,但是却知道这个系统能够做什么。C++的模板类型推断便是如此,把参数传递到模板函数往往能让程序员得到满意的结果,但是却不能够比较清晰的描述其中的推断过程。模板类型推断是现代C++中被广泛使用的关键字auto的基础。当在auto上下文中使用模板类型推断的时候,它不会像应用在模板中那么直观,所以理解模板类型推断是如何在auto中运作的就很重要了。
下面将详细讨论。看下面的伪代码:
template<typename T>
void f(ParamType param);
通过下面的代码调用:
f(expr); //call f with some expression
在编译过程中编译器会使用expr推断两种类型:一个T的类型,一个是ParamType。而这两种类型往往是不一样的,因为ParamType通常会包含修饰符,比如const或者引用。如果一个模板被声明为下面这个样子:
template<typename T>
void f(const T& param);//ParamType is const T&
通过如下代码调用:
int x = 0;
f(x); //call f with an int
T会被推断成int,但是 ParamType会被推断成const int&。
我们很自然的会认为T的推断类型和传递到函数的参数类型是相同的,上面的例子就是这样的,参数x的类型为int,T也被推断成了int类型。但是往往情况不是这样子的。对T的类型推断不仅仅依赖参数expr的类型,也依赖ParamType的形式。
有三种情况:
- ParamType是指针或者引用类型,但不是universal reference(这个类型在以后的篇章中会讲到,现在只需要明白,这种类型不同于左值引用和右值引用即可。)
- ParamType是universal reference。
- ParamType即非指针也非引用。
下面将分别进行举例,每个例子都从下面的模板声明和函数调用伪代码演变而来:
template<typename T>
void f(ParamType param);
f(expr);
ParamType是指针或者引用类型
这种情况下的类型推断会是下面这个样子:
- 如果expr的类型是引用,忽略引用部分。
- 然后将expr的类型同ParamType进行模式匹配来最终决定T。
看下面的例子:
template <typename T>
void f(T ¶m);
声明如下变量:
int x = 27; //x 为int
const int cx = x;//cx为const int
const int& rx = x;//rx为指向const int的引用
对param和T的推断如下:
f(x); //T被推断为int,param的类型被推断为 int &
f(cx);//T被推断为const int,param的类型被推断为const int &
f(rx);//T被推断为const int(这里的引用会忽略),param的类型被推断为const int &
第二个和第三个函数调用中,cx和rx传递的是const值,因此T被推断成const int,产生的参数类型就是const int &,当你向一个引用参数传递一个const对象的时候,你不会希望这个值被修改,因此参数应该会被推断成为指向const的引用。模板类型推断也是这么做的,在推断类型T的时候const会变为类型的一部分。
第三个例子中,rx的类型是引用类型,T却被推断为非引用类型。因为类型推断过程中rx的引用类型会被忽略。
上面的例子只是说明了左值引用参数,对于右值引用参数同样试用。
如果我们将函数f的参数类型改成cont T&,实参cx和rx的const属性肯定不会变,但是现在我们将参数声明成为指向const的引用了,因此没有必要将const推断成为T的一部分:
template <typename T>
void f(const T ¶m);
声明的变量不变:
int x = 27; //不变
const int cx = x;//不变
const int& rx = x;//不变
对param和T的推断如下:
f(x); //T被推断为int,param的类型被推断为const int &
f(cx);//T被推断为int,param的类型被推断为const int &
f(rx);//T被推断为int(引用同样被忽略) ,param的类型被推断为const int &
如果param是指针或者指向const的指针,本质上同引用的推断过程是相同的。
指针和引用作为模板参数在推断过程中的结果是显而易见的,下面的例子就隐晦一些了。
ParamType是一个Universal Reference
这种类型的参数在声明时形式上同右值引用类似(如果一个函数模板的类型参数为T,将其声明为Universal Reference写成TT&&),但是传递进来的实参如果为左值,结果同右值引用就不太一样了(以后会讲到)。
Universal Reference的模板类型推断将会是下面这个样子:
- 如果expr是一个左值,T和ParamType都会被推断成左值引用。有点不可思议,首先,这是模板类型推断中唯一将T推断为引用的情况;其次,虽然ParamType的声明使用右值引用语法,但它最终却被推断成左值引用。
- 如果expr是一个右值,参考上一节(ParamType是指针或者引用类型)。
举个例子:
template <typename T>
void f(T &¶m);
int x = 27; //不变
const int cx = x;//不变
const int& rx = x;//不变
对param和T的推断如下:
f(x); //x为左值,因此T为int&,ParamType为 int&
f(cx);//cx为左值,因此T为const int&,ParamType也为const int&
f(rx);//rx为左值,因此T为const int&,ParamType也为const int&
f(27);//27为右值,T为int ,ParamType为int&&
这里的关键点是,模板参数为Universal Reference类型的时候,对于左值和右值的推断情况是不一样的。这种情况在模板参数为非Universal Reference类型的时候是不会发生的。
ParamType既不是指针也不是引用
这种情况也就是所谓的按值传递:
template <typename T>
void f(T param);//按值传递
传递到函数f中的实参值会是原来对象的一份拷贝。这决定了如何从expr中推断T:
- 同情况一类似,如果expr的类型是引用,忽略引用部分。
- 如果expr是const的,同样将其忽略。如果是volatile的,同样忽略。
看例子:
int x = 27; //不变
const int cx = x;//不变
const int& rx = x;//不变
对param和T的推断如下:
f(x); // T为int ParamType为 int
f(cx);//同上
f(rx);//同上
可以看到即使cx和rx为const,param也不是const的。因为param只是cx和rx的一份拷贝,所以不论param的类型如何都不会对原值造成影响。不能修改expr并不意味着不能修改expr的拷贝。
注意只有param是by-value的时候,const或者volatile才会被忽略。我们在前面的例子中说明了,如果参数类型为指向const的引用或者指针,类型推断过程中expr的const属性会被保留。但是看一下下面的情况,如果expr为指向const对象的const指针,而param的类型为by-value,结果会是什么样子的呢:
template <typename T>
void f(T param);//按值传递
const char * const ptr = "Fun with pointers";
f(ptr);
我们先回忆一下const指针,星号左边的const(离指针最近)表示指针是const的,不能修改指针的指向,星号右边的const表示指针指向的字符串是const的,不能修改字符串的内容。当ptr传递给f的时候,指针本身是按值传递的。因为在by-value参数的类型推断中const属性会被忽略,因此指针的const也就是星号右边的const会被忽略,最后推断出来的参数类型为const char * ptr,也就是可以修改指针指向,不能修改指针所指内容。
数组参数
上面的三种情况涵盖了模板类型推断的大部分情况,但是有另外一种情况不得不说,就是数组。虽然数组和指针有时候看上去是可以互换的,造成这种幻觉的一个主要原因是在许多情况下,数组可以退化为指向第一个数组元素的指针,正是这种退化下面的代码才能编译通过:
const char name[]="HarlanC";//name的类型为const char[8]
const char*ptrToName = name;//数组退化成指针
虽然指针和数组的类型不同,但由于数组退化为指针的规则,上边的代码能够编译通过。
如果将数组传递给带有by-value参数的模板,会发生什么呢?
template <typename T>
void f(T param);//按值传递
f(name);
将数组作为函数参数的语法是合法的。
void myFunc(int param[]);
但是这里的数组参数会被当做指针参数来处理,也就是说下面的声明和上面的声明是等价的:
void myFunc(int* param); // same function as above
因为数组参数会被当做指针参数来处理,所以将一个数组传递给按值传递的模板函数会被推断为一个指针类型。当调用模板函数f的时候,类型参数T会被推断成const char*:
f(name); // name is array, but T deduced as const char*
虽然函数不能声明一个真正的数组参数(即使这么声明也会被当做指针来处理),但是能够将参数声明为指向数组的引用。我们将模板函数做如下修改:
template <typename T>
void f(T& param);//按引用传递
传递一个数组实参:
f(name);
这时候会将T推断成一个真正的数组类型。这个类型同时包含了数组的大小,在上面的例子中,T会被推断成const char [8],而f的参数类型为const char (&)[8]。
使用这种声明有一个妙用。我们可以创建一个模板来推断出数组中包含的元素数量:
//在编译期返回数组大小 ,
//注意下面的函数参数是没有名字的
//因为我们只关心数组的元素数量
template<typename T, std::size_t N>
constexpr std::size_t arraySize(T (&)[N]) noexcept
{
return N;
}
将函数返回值声明成constexpr类型的意味着这个值在编译期就能够得到。这样我们可以在编译期获取一个数组的大小,然后声明另外一个相同大小的数组:
int keyVals[] = { 1, 3, 7, 9, 11, 22, 35 };
int mappedVals[arraySize(keyVals)];
使用std::array更能够体现你是一个现代C++程序员:
std::array<int, arraySize(keyVals)> mappedVals;
函数参数
数组不是能够退化成指针的唯一类型。函数类型也能够退化为指针,我们所讨论的关于数组的类型推断过程同样适用于函数:
void someFunc(int, double); // someFunc是一个函数,类型为void(int, double)
template<typename T>
void f1(T param); //passed by value
template<typename T>
void f2(T& param); // passed by ref
f1(someFunc); // param 被推断为 ptr-to-func void (*)(int, double)
f2(someFunc); // param 被推断为ref-to-func void (&)(int, double)
要点总结
- 模板类型推断会把引用当做非引用来处理,也就是说会把参数的引用属性忽略掉。
- 当模板参数类型为universal reference 时,进行类型推断会对左值入参做特殊处理。
- 当模板类型参数为by-value时,const或者volatile会被当做非const或者非volatile处理。
- 当模板类型参数为by-value时,入参为函数或者数组时会退化为指针。