Effective Modern C++ 条款1:理解模板型别推导
成百上千的程序员都在向函数模板传递实参,并拿到了完全满意的结果,而这些程序员中却有很多对这些函数使用的型别是如何被推导出的过程连最模糊的描述都讲不出来。
但是当模板型别推导规则应用于auto语境时,它们不像应用于模板时那样符合直觉。所以了解作为auto基础的模板型别推导的方方面面就变得相当重要了。
本条款将说明这些推导过程。这里通过一段伪代码来说明,函数模板大致形如:
1 template<typename T> 2 void f(ParamType param);
而一次调用形如:
f(expr); //以某表达式调用f
在编译期,编译器通过expr推导两个型别:一个是T的型别,另一个是ParamType的型别,这两个型别往往不一样。
因为,ParamType常会包含了一些饰词,如const或引用符号等限定词。例如,若模板声明如下:
1 template<typename T> 2 void f(const T& param); //ParamType是const T&
而调用语句如下:
1 int x = 0; 2 f(x); //以一个int调用f
在此例中,T被推导为int,而ParamType则被推导为const int&。
上述情况,T的型别推导结果和传递给函数的实参型别是同一的,即T的型别就是expr的型别,x的型别是int,T的型别也被推导为int。但是这一点并不总是成立,T的型别推导结果,不仅仅依赖expr的型别,还依赖ParamType的形状。具体分为三种情况:
1、ParamType具有指针或引用型别,但不是万能引用;
2、ParamType是一个万能引用;
3、ParamType既非指针也非引用。
下面我们对这三种型别推到场景进行逐个考察。采用的还是前面所述的模板和调用形式:
template<typename T> void f(ParamType param); f(expr); // 从expr来推导T和ParamType的型别
情况1:ParamType具有指针或引用型别,但不是万能引用
这种情况下,型别推导会这样运作:
1、若expr具有引用型别,先将引用部分忽略;
2、而后,对expr的型别和ParamType的型别执行模式匹配,来决定T的型别。
例如,我们的模式如下:
template<typename T> void f(T& param); //Param是个引用
又声明了如下变量:
int x = 27; //x的型别是int const int cx = x; //cx的型别是const int const int& rx = x; //rx是x的型别为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对象是安全的:该对象的常量性(constness)会成为T的型别推导结果的组成部分。
第三个调用中,即使rx具有引用型别,T也未被推导为一个引用。原因在于,rx的引用性(reference-ness)会在型别推导过程中被忽略。
如果将形参型别从T&改为const T&,结果会有点变化。cx和rx的常量性仍然得到了满足,由于现在回假定param具有const引用型别,T的型别推导结果中包含的const也就没有必要了。
template<typename T> void f(const T& param); //Param是个const引用 int x = 27; //x的型别是int const int cx = x; //cx的型别是const int const int& rx = x; //rx是x的型别为const int的引用 f(x); //T的型别是int,param的型别是const int& f(cx); //T的型别是int,param的型别是const int& f(rx); //T的型别是int,param的型别是const int&
一如前例,rx的引用性在型别推导过程中是被忽略的。
param是指针而非引用时,其推导方式也是一样的:
template<typename T> void f(T* param); //param是个指针 int x = 27; //x的型别是int const int* px = x; //px是指涉到x的指针,型别为const int f(&x); //T的型别是int,param的型别是int* f(px); //T的型别是const int,param的型别是const int*
情况2:ParamType是一个万能引用
此类形参的声明方式类似右值引用(即函数模板中持有型别形参T时,万能引用的声明型别写作T&&),但是当传入实参是左值时,其表现会有所不同。
1、如果expr是个左值,T和ParamType都会被推导为左值引用。这一结果具有双重的奇特之处:首先,这是在模板型别推导中,T被推导为引用型的唯一情形。其次,尽管在声明时使用的是右值引用语法,它的型别推导结果却是左值引用。
2、如果expr是个右值,则应用“常规”规则。
例如:
template<typename T> void f(T&& param); //param是个万能引用 int x = 27; //x的型别是int const int cx = x; //cx的型别是const int const int& rx = x; //rx是x的型别为const int的引用 f(x); //x是个左值,所以T的型别是int&,param的型别是int& f(cx); //cx是个左值,T的型别是const int&, //param的型别是const int&
f(rx); //rx是个左值,T的型别是const int&, //param的型别是const int&
f(27); //27是个右值,所以T的型别是int, //这样param的型别就是int&&
情况3:ParamType既非指针也非引用
当ParamType既非指针也非引用时,就是所谓的按值传递:
template<typename T> void f(T param); //param按值传递
这样,无论传入的是什么,param都会是它的一个副本,也即一个全新对象。
param是一个全新对象促成了如何从expr推导出T的型别的规则:
1、一如之前,若expr具有引用型别,则忽略其引用部分;
2、忽略expr的引用性后,若expr是个const对象,也忽略const属性。若其是个volatile对象,也忽略之。
int x = 27; //x的型别是int const int cx = x; //cx的型别是const int const int& rx = x; //rx是x的型别为const int的引用 f(x); //T和param的型别都是int f(cx); //T和param的型别都是int f(rx); //T和param的型别都是int
如上所示,即使cx和rx代表const值,param仍然不具有const型别。这是合理的,因为param是个完全独立于cx和rx存在的对象--是cx和rx的副本。从而cx和rx不可修改这一事实并不能说明param是否可以修改。所以expr的常量性和挥发性可以在推导param的型别时加以忽略:仅仅由于expr不可修改,并不能断定其副本也不可修改。
需要重点说明的是,const和volatile仅会在按值形参处被忽略。若形参是const的引用或指针,expr的常量性会在型别推导过程中加以保留。
但是,当expr是个指涉到const对象的const指针,且expr按值传递给param:
template<typename T> void f(T param); //param按值传递 const char* const prt = "Hello world"; //ptr是个指涉到const对象的 //const 指针 f(ptr); //传递型别为const char* const 的实参
如上所示,星号右侧的const把ptr声明为const:ptr不可以指涉到其他内存位置,也不可被置为null;位于星号左侧的const将ptr指涉到的对象(字符串)声明为const,即字符串不可修改。在ptr被传递给f时,这个指针本身将会按比特复制给param,就是说,ptr这个指针自己会被按值传递。依照按值传递形参的型别推导规则,ptr的常量性会被忽略,param的型别会被推导为const char*,即一个可修改的、指涉到一个const字符串的指针。
在型别推导中,ptr指涉的对象的常量性会被保留,其自身的常量性则会在以复制方式创建新指针param的过程中被忽略。
数组实参
以上已经基本讨论完模板型别推导的主流情况,但还有一个边缘情况要了解。这种情况就是:数组型别有别于指针型别,尽管有时它们看起来可以互换。形成这种假设的主要原因是,在很多语境下,数组会退化成指涉到的其首元素的指针。下面这段代码能够通过编译,就是因为这种退化机制在发挥作用:
const char name[] = "Hello World"; //name的型别是const char[12] const char* ptrToName = name; //数组退化成指针
这里型别为const char*的指针是ptrToName是通过name来初始化的,而后者的型别是const char[13]。这两个型别(const char* 和const char[13])并不统一,但是因为数组到指针的退化规则地存在,上述代码能够通过编译。
但当一个数组传递给持有按值形参的模板时,又会怎么样呢?
template<typename T> void f(T param); //持有按值形参的模板 f(name); //T和param的型别会被推导成什么呢?
我们观察到,并没有任何的函数形参具有数组型别。但是,下面的语法是合法的:
void myFunc(int param[]);
但是虽然数组声明可以按照指针声明方式加以处理,那就意味着myFunc可以等价地声明如下:
void myFunc(int* param);
这种数组和指针形参的等价性,是作为c++基础的C根源遗迹,它使得“数组和指针型别是一回事”这一假象愈加扑朔迷离。
由于数组形参声明会按照它们好像是指针形参那样加以处理,按值传递给函数模板的数组型别将被推导成指针型别。
这样的话,在模板f的调用中,其型别形参T会被推导成const char*:
f(name); //name是个数组,但T的型别却被推导成const char*
难点来了!尽管函数无法声明真正的数组型别的形参,它们却能够将形参声明成数组的引用!所以,如果我们修改模板f,指定按引用方式传递其实参,
template<typename T> void f(T& param); /按引用方式传递形参的模板 f(name); //向f传递一个数组
在这种情况下,T的型别会被推导成实际的数组型别!这个型别中会包含数组尺寸,在本例中,T的型别推导结果是const char[12],而f的形参(该数组的一个引用)型别被推导成const char(&) [12]。
函数实参
数组并非C++中唯一可以退化为指针之物。函数型别也同样可退化成函数指针,并且我们针对数组型别推导的一切讨论都适用于函数及其向函数指针的退化。所以结果如下:
void someFunc(int ,double); //someFunc是个函数, //其型别是void(int ,double) template<typename T> void f1(T param); //f1中,param按值传递 template<typename T> void f2(T& param); //f2中,param按引用传递 f1(someFunc); //param被推导为函数指针, //具体型别是void(*)(int, double) f2(someFunc); //param被推导为函数引用, //具体型别是void(&)(int, double)
在实践中,这些型别推导结果和前面讲过的没有什么不一样。
要点速记:
1、在模板推导的过程中,具有引用型别的实参会被当成非引用型来处理,就是说,其引用性会被忽略掉;
2、对万能引用形参进行推导时,左值实参会进行特殊处理;右值实参则按照情况1处理;
3、对按值传递的形参进行推导时,若实参型别中带有const或volatile饰词,则它们还是会被当作不带const或volatile饰词的型别处理;
4、在模板型别推导过程中,数组或函数型别的实参会退化成对应的指针,除非它们被用来初始化引用。