理解模板类型推断
当一个复杂系统的用户对于系统内部如何工作一无所知,并且对其所做的工作表示很满意,这其实算是对一个系统设计很高的评价了。按照这种说法,C++中的模板类型推断真的是一个巨大的成功。成千上万的程序员已经试过向模板函数中传递参数,并且获得了满意的结果,即使这其中很大一部分程序员对于这些函数的类型是如何推断出来的了解甚少。
如果你也是上面群体中的一员,那么我有一个好消息和一个坏消息。好消息是模板类型推断是C++最迷人的特征之一:auto的基石。如果你很满意C98的模板类型推断,那么你也会爱上C11对于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);
T被推断成int类型,但是ParamType却被推断成const int&类型。
其实在我们看来,T的推断类型和传入到函数中的参数类型一样再合理不过了,也就是说,T就是expr的类型。上面的例子就满足了:x是int类型,T也被推断成int。但是请注意,它并不总是按这样的方式工作。对于T的类型推断其实不仅仅依赖于expr的类型,同时也和ParamType的形式有关。分三种情况:
- ParamType是一个指针或者引用,但是不是一个universal引用(universal引用会在Item 24中介绍。目前你只需要知道它们是存在的并且和左值引用以及右值引用是不同的)
- ParamType是一个universal引用
- ParamType既不是一个指针也不是一个引用
所以我们其实有三种Case需要考虑。每一个都会使用我们约定的通用模板形式和调用形式:
template<typename T>
void f(ParamType param)
f(expr); //deduce T and ParamType from expr
Case1 :ParamType是一个指针或者引用,但是不是一个universal引用
这其实是最简单的一个case。在这个case里,类型推断工作流程差不多就是这样:
- 如果expr是一个引用,那么忽略引用部分
- 然后使用expr的类型和ParamType进行模式匹配,从而得出T
例如,如果这是我们的模板,
template<typename T>
void f(T& param); // param is a reference
并且我们的变量声明如下:
int x = 27; //x is an int
const int cx = x; //cx is a const int
const int& rx = x; //rx is a reference to x as a const int
那么在不同的调用中对于param和T的推断类型如下:
f(x); //T is int , param's type is int&
f(cx); //T is const int,
//param's type is const int&
f(rx); //T is const int,
//param's type is const int&
在上面第二个和第三个调用中,注意因为cx和rx都是常量,T被推断成const int,这也就使得param的类型是const int&。这些对于调用者来说很重要。当他们传递一个常引用(指向常量的引用)作为参数时,他们希望的是这些对象(在调用函数内)保持不变。这就是为什么向一个接收T&作为参数的模板传递一个常量是安全行为的原因:对象的常量性(constness)已经成为T类型推断的一部分。
在第三个调用中,注意即使rx的类型是一个引用,T也被推断成非引用类型。这是因为rx的引用性(reference-ness)在类型推断中被忽略了。
以上展示的都是关于左值引用作为参数的例子,针对右值引用作为参数的情况,其工作原理和上面的一模一样。当然,对于右值引用参数,只能传递右值参数,但是这点限制和我们所说的类型推断其实并没有啥关系。
如果我们将f的参数类型从T&改成const T&,情况会改变一点,但也不至于到会让你吃惊的地步。cx和rx的常量性依旧被保持了下来,但是因为我们现在假设param是一个常引用,所以就没必要将对于const的推断加入到T的类型推断中:
template<typename T>
void f(const T& param); // param is now a ref-const
int x =27; //as before
const int cx= x; // as before
const int& rx = x; // as before
f(x);// T is int, param's type is const int&
f(cx);// T is int, param's type is const int&
f(rx);// T is int, param's type is const int&
和前面一样,rx的引用性在类型推断过程中被忽略了。
如果param是个指针(或者指向常量的指针)而不是一个引用,工作流程和之前一模一样:
template<typename T>
void f(T* param); // param is now a pointer
int x = 27; // as before
const int *px = &x; // px is a ptr to x as a const int
f(&x); // T is int, param's type is int*
f(px); // T is const int,
// param's type is const int*
到现在,你可能觉得自己一直在点头打哈欠,因为C++对于引用和指针的类型推导实在是太自然了,看它们被写进去真的很无聊。这一切都那么的理所当然!而这正是我们心中所想的那个类型推导。
Case 2: ParamType是一个universal 引用
当模板采用universal引用作为参数的时候,情况就不再那么明显了。这样的参数一般像右值引用那样声明(比如,在一个接收类型参数T的函数模板中,一个universal引用的声明类型就是T&&),但是当左值参数被传递进来时,它们表现的又和右值不一样。关于这个的整个故事会在Item 24中讲到,但是下面是一个精要版:
- 如果expr是一个左值,那么T和ParamType都会被推断成左值引用。这很不寻常。第一,这是第一次模板类型推断将T推断成引用类型。第二,尽管ParamType使用了右值引用的语法进行了声明,但是它的推断类型是一个左值引用。
- 如果expr是一个右值,那么“正常规则”(比如Case1里面的)就可以适用了。
举个例子:
template<typename T>
void f(T&& param); // param is now a universal reference
int x = 27; // as before
const int cx = x; // as before
const int& rx = x; // as before
f(x); // x is lvalue, so T is int&,
// param's type is also int&
f(cx); // cx is lvalue, so T is const int&,
// param's type is also const int&
f(rx); // rx is lvalue, so T is const int&,
// param's type is also const int&
f(27); // 27 is rvalue, so T is int,
// param's type is therefore int&&
Item 24会详细介绍为什么这些例子会如此表现。这里的重点是对于universal引用的类型推断和对于左值或者右值的类型推断都不一样。特别地,当universal引用被使用时,其是区别对待左值参数和右值参数的,而这在non-universal引用中是永远不会发生的。
Case 3:ParamType既不是指针也不是引用
当ParamType既不是指针也不是引用时,我们其实在处理pass-by-value(译者注:要理解传值和传址的区别):
template<typename T>
void f(T param); //param is now passed by value
这意味着我们对每一个传递进去的参数都需要拷贝一份–一个全新的对象。基于param是一个全新的对象这一事实,我们可以得出T是如何从expr中进行类型推断的:
- 和之前一样,如果expr的类型是引用,那么忽略该引用部分
- 在忽略了引用部分后,如果expr是一个常量,那么也忽略掉。如果它是volatile,也忽略掉。
所以:
int x = 27; // as before
const int cx = x; // as before
const int& rx = x; // as before
f(x); // T's and param's types are both int
f(cx); // T's and param's types are again both int
f(rx); // T's and param's types are still both int
注意尽管cx和rx代表了常量,param却不是常量。这其实说得通。param是一个完全独立于cx和rx的对象–一份对cx或者rx的拷贝。cx和rx不能被修改跟param是否可以完全没有关系。这也是为什么expr的常量性(volatile-ness也是,如果有的话)可以在param类型推断的时候被忽略的原因:expr不能被修改并不意味着它的拷贝不能。
很重要的一点就是const(和volatile)只有在传值参数时才可以被忽略。正如我们所见的那样,对于参数是常引用或者指向常量的指针,常量性是需要在类型推断中被保留的。但是考虑下面这个case,expr是一个指向常量的常指针,并且expr是以传值的形式传递进去的:
template<typename T>
void f(T param); // param is still passed by value
const char* const ptr = "Fun with pointers";
// ptr is const pointer to const object
f(ptr); // pass arg of type const char * const
这里,右边的const表示指针ptr是不可变的:ptr不能被修改指向其他位置,也不可以被置为null。(而左边的const指的是其指向的对象是常量,所以不可被修改)当ptr被传递给f的时候,组成该指针的所有位(bits)被复制到了param。所以,指针自身(ptr)也是按值传递。为了和传值参数的类型推断规则一致,ptr的常量性将被忽略,所以param的推断类型是const char,比如是一个指向常量字符串的可修改ptr。ptr所指向的对象的常量性在类型推断过程中被保留了,但是ptr在被拷贝到新的指针param的过程中,其自身的常量性就被忽略了。
数组参数
上面基本上已经涵盖了主流的模板类型推断,但是还有一种很有趣的case值得一看。那就是区别于指针类型的数组类型,尽管他们有时看起来是可互换的。造成这一混乱的一大原因在于,在许多上下文中,一个数组类型会退化(decay into)成指向该数组第一个元素的指针。这种退化允许下面这样的代码通过编译:
const char name[] = "J. P. Briggs"; //name's type is const char[13]
const char * ptrToName = name; // array decays to pointer
这里,const char类型的指针ptrToName被name初始化,而name的类型是const char[13]。这两种类型并不相同,但是因为数组到指针的退化原则,上面的代码可以通过编译。
但是如果我们将数组传递给一个接收传值参数的模板会怎么样呢?那时候会发什么什么?
template<typename T>
void f(T param); //template with by-value parameter
f(name); //what types are deduced for T and param?
我们首先观察到函数参数中并没有数组类型。对,语法是合法的,
void myFunc(int param[]);
但是数组的声明会被处理成指针声明,意味着myFunc函数等价于如下声明:
void myFunc(int* param); //same function as above
这种数组和指针参数之间的等价性是来源于作为C++基础的C根源那里,在那宣扬了这种数组和指针类型是一样的思想。
因为数组参数声明被处理成指针参数,按值传递的函数模板的数组类型参数也被推断成一个指针类型。那就是说在对模板f的调用中,它的类型参数T被推断成const char*:
f(name); //name is array, but T deduced as const char*
但是现在有了转机。尽管函数不能声明参数为真正的数组,它们可以声明参数为数组引用。所以如果我们将模板f的参数改成引用:
template<typename T>
void f(T& param);// template with by-reference parameter
并且我们传递一个数组给它,
f(name); //pass array to f
那么T被推断出的类型就是真正的数组类型。该类型包括了数组的大小,所以在该例子中,T被推断成const char[13],并且f的参数类型(一个对于该数组的引用)是const char (&)[13]。对,这语法看起来有毒,但是懂得它会让你比那些对此不care的人增加不少分。
有趣的是,声明数组指针的能力还使得模板能够推断出数组中的元素数量:
//return size of an array as a compile-time constant.(The
//array parameter has no name,because we care only about
//the number of elements it contains.)
template<typename T,std::size_t N> //see info
constexpr std::size_t arraySize(T (&)[N]) noexcept //below on
{ //constexpr and noexpr
return N;
}
正如Item 15会解释的一样,将函数声明成constexpr可以使得其在编译器就获得结果。这就使得声明一个大小与前面一个用初始化列表初始化的数组大小一样的数组成为可能:
int keyVals[] = {1,3,7,9,11,22,35};//keyVa;s has 7 elements
int mappedVals[arraySize(keyVals)]; //so does mappedVals
当然,作为一个现代C++开发者,你应该很自然的更倾向于使用std::array而不是内置的数组类型:
std::array<int,arraySize(ketVals)> mappedVals;//mappedVals' size is 7
至于arraySize声明成noexcept,那是帮助编译器生成更好的代码。关于该细节,可以参考Item 14.
函数参数
数组并不是C++中唯一一个可以退化成指针的类型。函数类型也可以退化成函数指针,并且我们上面关于数组类型推断的所有内容一样可以应用于函数的类型推断。作为结果:
void someFunc(int , double); // someFunc is a function;
//type is void(int,double)
template<typename T>
void f1(T param); //in f1,param passed by value
template<typename T>
void f2(T& param); //in f2,param passed by ref
f1(someFunc); //param deduced as ptr-to-func;
//type is void (*)(int,double)
f2(someFunc); //param deduced as ref-to-func;
//type is void (&)(int,double)
这在现实中几乎没什么区别,但是如果你准备去了解数组到指针的退化,你也许也应该了解下函数到指针的退化。
所以现在你知道了:auto所涉及到的关于模板类型推断的规则。我在最开始的时候就说,它们非常的直观,而且在大多数情况下,它们也的确如此。在给universal引用做类型推断而参数又是一个左值时的特殊处理确实让水变得浑浊一点,然而,对于数组和函数的退化规则则让这水变得更加污浊。有时候只是单纯地想揪住你的编译器并且命令道:”告诉你推断出了什么类型!”那会发生什么?请看Item 4,因为编译器一直被致力于实现该功能。
记忆要点
- 在模板类型推断时,引用类型参数被处理成非引用类型,比如它们的引用性会被忽略
- 在给一个universal引用类型的参数进行类型推断时,左值参数需要特殊处理
- 在给按值传递的参数进行类型推断时,const以及volatile等限定的参数会被处理成non-const以及non-volatile
- 在模板类型推断时,数组或者函数类型的参数会退化成指针,除非他们用了引用形式。
。