型别推导
C++中有三套型别推导规则,分别为函数模板、auto、decltype。型别推导让我们不用写那些不言自明的冗余的型别。还提高了软件的适应性,在源代码的其中一个地方对一个型别进行改动可以自动传播到其他地方。
一、模板型别推导
知识速览
- 模板型别推导过程中,具有引用型别的实参会被忽略引用
- 万能引用的推导,左值会特殊化处理
- 按值传递的模板,型别中有const和volatile会被忽略
- 模板型别的推导中,数组和函数的实参会退化成为指针,除非被用来初始化引用
模板的使用如下:
//模板声明
template<typename T>
void f(ParamType param);
//模板调用
f(expr);
编译期,编译器会通过expr来推导T的型别和param的型别。这两个型别往往是不一样的。因为ParamType会包含一些修饰词。
我们不能简单的认为T的型别推导结果和传递给函数的实参的型别是一样的,即T的型别和expr的型别相同。因为T的型别不仅要考虑到expr,还要考虑ParamType的形式,所以要考虑以下三种主要情况和一些其他小情况。
ParamType是指针或者引用(不是万能引用)
这是最简单的情况,型别推导过程如下:
- 若expr具有引用型别,先忽略引用;
- 对expr的型别和ParamType的型别进行模式匹配,来决定T的型别。
ParamType是普通引用
template<typename T>
void f(T& param); //ParamType是普通的引用
int x = 27; //x的型别是int
const int cx = x; //cx的型别是const int
const int& rx = x; //rx的型别为const int&
下面对上述三个变量进行调用
f(x); //T的型别是int,param的型别是int&
x是int,T的型别是int,param的型别是int&
f(cx); //T是const int , param是const int&
cx的型别是const int。这时我们一定要注意,==当我们向引用型别的形参传入const对象,我们往往期望这个对象保持不可修改的属性。==所以T的型别也是const int。因为这个原因,对持有T&的模板传入const对象是安全的,该对象的常量性会成为T的型别推导的组成部分。
f(rx); //T的型别是const int,param是const int&
因为传入的是const,所以T的型别也是const。但是由于在型别推导过程中expr的引用性被忽略,所以T并没有被推导成为一个引用。
在此种情况下,左值和右值的推导步骤相同。
ParamType是const引用
如果我们把形参设置为const引用的话,结果会有变化。cx和rx因为传入的是const,他们的常量性会得到满足,但是由于我们假定ParamType具有const引用型别,所以T的型别推导中就没有必要包含常量性了。
template<typename T>
void f(const T& param); //ParamType是const引用
f(x); //T的型别是int,param的型别是const int&
f(cx); //T的型别是int,param的型别是const int&
f(rc); //T的型别是int,param的型别是const int&
ParamType是指针
指针的情况和引用完全相同。
template<typename T>
void f(const T* param); //ParamType是指针
int x = 27; //x是int
const int *px = &x; //px是const int型指针
f(&x); //T的型别是int,param的型别是int*
f(px); //T的型别是const int,param的型别是const int*
ParamType是万能引用
- 如果expr是左值,T和ParamType都会被推导成为左值引用。这是在模板推导中T唯一被推导为引用类型的情况。尽管在声明是使用的是右值引用的语法,但是推导的结果却是左值引用
- 如果expr是右值,那么和普通引用的规则一样。
template<typename T>
void f(T&& param);
int x = 27; //x的型别是int
const int cx = x; //cx的型别是const int
const int& rx = x; //rx的型别为const int&
f(x); //x是左值,T的型别和param的型别都是int&
f(cx); //cx是左值。T的型别和param的型别都是const int&
f(rx); //rx是左值,T的型别和param的型别都是const int&
f(27); //27是右值,T的型别是int,param的型别是int&&
ParamType不是指针或引用
template<typename T>
void f(T param);
这种情况下就是所谓的按值传递。无论传入的是什么、param都会是一个副本,即一个全新的对象。新的推导规则如下:
- 若expr具有引用型别,则忽略引用
- 若忽略引用后还有const、volatile,一并忽略
int x = 27; //x的型别是int
const int cx = x; //cx的型别是const int
const int& rx = x; //rx的型别为const int&
f(x); //T的型别和param的型别都是int
f(cx); //T的型别和param的型别都是int
f(rx); //T的型别和param的型别都是int
注意这里的param即使传入的参数具有const,它本身也不具有const型别。因为此时的param是个完全独立的副本,并不是传入的对象本身。传入的对象本身不可修改不代表这个副本不可修改。所以const和volatile都可以被忽略。
在按值传递时可以忽略常量性,但是考虑到以下情况:
template<typename T>
void f(T param);
const char* const ptr = "hello"; //ptr是指涉到const对象的const指针
f(ptr); //传递的型别为const char* const的实参,param型别是const char*
ptr所指涉的内存位置和指涉的字符串都不可被修改。但是ptr被传递给f时,这个指针会按照比特复制给param。依照按值传递的规则,ptr的常量性会被忽略,即修饰这个指针的const(右边的const)被忽略。最终param的型别是const char*:一个可以修改的指向const字符串的指针。即ptr指涉的对象的常量性得到保留、自身的常量性会在以复制方式创建新指针param的过程中被忽略
数组实参和函数实参
我们知道C++中,数组在很多时候会退化成首元素的指针。就如同下面的代码之所以能够编译就是因为退化作用:
const char name[] = "Helloworld"; //name的型别是const char[11]
const char* ptrToName = name; //数组退化成指针,ptr型别为const char*
即声明的数组可以按照指针来使用:
void myFun(int param[]);
void myFun(int* param); //二者等价
所以对于模板,当我们使用值传递的时候,数组也会退化成指针
template<typename T>
void f(T param); //值传递模板
f(name); //name是数组,但是T的型别被推导为const char*
尽管函数无法声明真正的数组型别的形参,但是可以将形参声明为数组的引用。即我们不用值传递而是引用方式传递实参,就可以得到数组型别:
template<typename T>
void f(T& param); //引用方式传递模板
f(name); //name是数组const char[11],T的型别是数组const char[11],param这个形参是const char(&)[11]
这个特性可以被我们大加利用,比如创造出一个模板,用来推导数组中含有的元素个数。如果我们声明constexpr,就可以在编译期得到元素个数,从而在声明一个数组的时候指定其尺寸和另一个数组(尺寸来自花括号初始化)相同。
template<typename T,std::size_t N>
constexpr std::size_t arraySize(T (&)[N]) noexcept
{
return N;
}
int keyVals[] = {1,2,3,4,5,6,7}; //7个元素
int mappedVals[arraySize(keyVals)]; //mappedVals数组被指定与keyVals长度相同
std::array<int,arraySize(leyVals)> appedVals;
除了数组,函数型别也会退化成为函数指针,并且一切适用于数组型别的推导都实用函数。
void someFunc(int,double); //someFunc是函数,型别为void(int,double)
template<typename T>
void f1(T param);
template<typename T>
void f2(T& param);
f1(someFunc); //param被推导为函数指针,型别是void(*)(int,double)
f2(someFunc); //param被推导为函数调用,型别为void (&)(int,double)
二、auto型别推导
- 一般情况下,auto型别推导规则和模板型别推导一样
- auto型别推导会假定大括号括起来的初始化表达式代表一个std::initializer_list
- 在函数返回值或者lambda式中使用auto,意思是使用模板型别推导而不是auto型别推导
auto型别推导在大多数情况下可模板型别推导一样,auto扮演模板中的T这个角色,变量的型别饰词就是模板中的ParamType。我们可以把它套用模板推导:
auto x = 27;
const auto cx = x;
const auto&rx = x;
为了推导x、cx、rx的型别,编译器仿佛对应每个声明生成了一个模板和一次使用对应的初始化表达式(仅仅是概念性的模板,方便理解,并不是真的这么做了)
template<typename T> //推导x生成的概念性模板
void func_for_x(T param);
func_for_x(27); //推导得出的param的型别就是x的型别
template<typename T> //推导cx生成的概念性模板
void func_for_cx(const T param);
func_for_cx(x); //推导得出的param的型别就是cx的型别
template<typename T> //推导rx生成的概念性模板
void func_for_rx(const T& param);
func_for_rx(x); //推导得出的param的型别就是cx的型别
我们完全可以套用模板推导的三种情况:指针或引用、万能引用、非指针非引用。甚至连数组和函数都是一样的。
但是C++11中为了支持统一初始化,增加了语法选项,这时候auto就会有例外情况。
int x1 = 27; //C++98
int x2(27); //C++98
int x3 = {27}; //C++11
int x4{27}; //C++11
上述都是初始化的代码,得到一个值为27的int。因为采用auto声明变量比采用固定型别类型声明变量更有优势,所以我们可以用auto替换int。
auto x1 = 27;
auto x2(27);
auto x3 = {27};
auto x4{27};
这些声明都是正确的,但是结果却并不相同。前两个语句生成的和之前一样,都是型别为int,值为27的变量。后两个语句生成的内容却是型别为std::initializer_list<int>,只含有单个值为27的元素。
当auto声明的变量的初始化表达式是用大括号括起来的时候,推导出的型别就是std::initializer_list。这样一来,如果型别推导失败(比如大括号里值的型别不同),编译就会不通过。要意识到这里的错误的原因是因为有两种型别推导,一种是auto的型别推导,一种是std::initializer_list<T>的型别推导。
所以auto和模板推导的真正区别在于,auto会假定用大括号括起来的初始化表达式代表一个std::initializer_list,但是模板推导却不会。 但是如果指定模板中的param的型别是std::initializer_list,则T的型别未知的时候还是会推导出应该有的型别。
auto x = {1,2,3}; //x的型别被推导成为一个std::initializer_list<int>
template<typename T>
void f(T param);
f({1,2,3}); //错误,无法推导T的型别
template<typename T>
void f(std::initializer_list<T> initlist);
f({1,2,3}); //T的型别推导为int,initlist的型别推导为std::initializer_list<int>
C++14中还有一点需要注意,C++14允许使用auto来说明函数返回值需要推导,而且C++14中lambda表达式也会在形参声明中使用auto。然后此时的这些型别推导隶属于模板型别推导而不是auto型别推导,所以带auto返回值的函数要是返回一个大括号括起来的初始化表达式是无法通过编译的。
auto createInitList()
{
return {1,2,3}; //错误
}
std::vector<int> v;
auto resetV = [&v](const auto& newValue){v = newValue}; //C++14
resetV({1,2,3}); //错误,无法为{1,2,3}完成型别推导
三、decltype
- 绝大多数情况下,decltype会得出变量或表达式的型别而不做任何修改
- 对于型别为T的左值表达式,除非该表达式仅有一个名字,否则总是得到T&
- C++14的decltype(auto)和auto一样,会从其初始化表达式出发来推导型别,但是它的型别推导用的是decltype规则。
对于给定的名字或者表达式,decltype能够告诉我们该名字或者表达式的型别。一般来说它返回的结果和我们预期的是一样的。和模板和auto的型别推导不同,decltype一般只会返回给定的名字或表达式的确定型别。
const int i = 0; //decltype(i)是const int
bool f(const Widget& w); //decltype(w)是const Widget&
//decltype(f)是bool(const Widget&)
struct Point{
int x; //decltype(x)是int
}
Widget w; //decltype(w)是Widget
f(w); //decltype(f(w))是bool
vector<int> v; //decltype(v)是vector<int>
if(v[0] == 0) //decltype(v[0])是int&
C++11中,decltype的主要作用是用于声明那些返回值型别依赖于形参型别的函数模板。
假设我们写一个函数,形参包括一个容器(支持方括号[]下标语法)和一个下标,并在返回下标操作结果前进行用户验证。函数的返回值型别必须与下标操作结果的返回值型别相同。
一般来说,含有型别T的容器,容器的operator[]会返回T&。(除了std::vector,其对应的operator[]返回的不是bool&,而是一个全新对象)。此时,最重要的就是容器的operator[]的返回型别取决于该容器本身。
//能运行,但是需要改进
template<typename Container,typename Index>
auto authAndAccexx(Container& c,Index i) -> decltype(c[i])
{
authenticateUser();
return c[i];
}
函数前面加auto并不是型别推导,而是返回值型别尾序语法。参考尾部返回类型。尾序返回好处在于指定返回值型别时可以使用此函数形参。在此处如果使用之前的前序语法,由于c和i还未声明从而无法使用。
采用这种声明形式,operator[]返回值是什么型别,authAndAccexx的返回值就是什么型别。
C++11允许对单表达式的lambda的返回值型别进行推导,C++14将这个允许范围扩大到了一切lambda式和一切函数。对于我们这个函数,意味着在C++14中可以去掉这个讨厌的返回值型别尾序语法,只保留auto。
//C++14,这样写不太正确
template<typename Container,typename Index>
auto authAndAccexx(Container& c,Index i)
{
authenticateUser();
return c[i]; //返回值型别是根据c[i]推导出来的
}
上面代码存在隐患,正如在第二条auto型别推导中说的,用于函数和lambda中的auto的意思是使用模板型别推导而不是auto型别推导。在我们知道,模板型别推导中会忽略初始化表达的引用性。这样就会出错:
std::deque<int> d;
authAndAccess(d,5) = 10; //验证用户并返回d[5],然后赋值d[5] = 10。//这句话无法通过编译
此处由于忽略了引用性,虽然d[5]返回的是int&,但是对authAndAccess的返回值实施auto型别推导,型别会变成int。作为函数的返回值,该int 是个右值。上述代码是将10赋值给一个右值int。在C++中是被禁止的。
为了让代码运作,就要实施decltype型别推导,即指定authAndAccess的返回值型别与表达式c[i]的型别完全一致。C++14中引入了decltype(auto)饰词:auto指定了想要实施推导的型别,推导过程中采用的是decltype的规则。代码可以写成一下形式:
//C++14,还需要改进
template<typename Container,typename Index>
decltype(auto)
auto authAndAccexx(Container& c,Index i)
{
authenticateUser();
return c[i]; //c[i]返回T&,函数返回T&,少见情况下c[i]返回一个对象,函数也一样返回对象
}
decltype(auto)并不限于在函数返回值型别中使用。变量声明的时候如果也想在初始化表达式里应用decltype型别推导规则,也可以照章办事:
Widget w;
const Widget& cw = w;
auto myWidget1 = cw; //auto型别推导,myWidget1的型别是Widget
decltype(auto) myWidget2 = cw; //decltype型别推导,myWidget2的型别是const Widget&
这些代码都要修改的空间。我们知道容器的传递方式是对非常量的左值引用,因为返回容器的某个元素的引用意味着允许客户对容器进行修改,不过也意味着无法向函数传递右值容器。右值是不能绑定到左值引用的。虽然传递右值容器是罕见情况,因为右值容器作为临时对象,会在函数结束的时候被析构掉。但是即使如此我们也可能会遇到传递临时对象的情况,客户可能仅仅是想要制作该临时容器的某元素的一个副本:
std::deque<std::string> makeStringDeque(); //工厂函数
//制作makeStringDeque返回的deque的第五个元素的副本
auto s = autoAndAccess(makeStringDeque(),5);
为了支持这种情况,我们就需要修订autoAndAccess的声明,以同时接受左值和右值。重载是一个办法,声明两个函数,一个左值引用形参一个右值引用形参。但是这么一来就需要维护两个函数。
我们需要一种既能够绑定左值也能够绑定右值的引用形参,这正是使用万能引用的时候。这样我们可以这么声明函数:
template<typename Container,typename Index>
decltype(auto) autoAndAccess(Container&& c,Index i);
在这个模板中我们对操作的容器型别并不知情,同时对下标对象也一样不知情。这种方式很有隐患,但是对于容器下标这个问题上,遵循标准库中给出的下标值示例应该是合理的。不过我们仍然需要更新模板实现,对万能引用要使用std::forward
//C++14最终版
template<typename Container,typename Index>
decltpe(auto)
authAndAccexx(Container&& c,Index i)
{
authenticateUser();
return std::forward<Container>(c)[i];
}
这个函数可以实现我们的所有需要,只是必须要有C++14编译器,否则我们只能使用C++11的版本,和C++14区别不大,只是需要我们自己指定返回值型别:
//C++11最终版
template<typename Container,typename Index>
auto
authAndAccexx(Container&& c,Index i)
-> decltype(std::forward<Container>(c)[i])
{
authenticateUser();
return std::forward<Container>(c)[i];
}
decltype还会有特殊的时候,虽然它几乎总是生成我们期望的型别,但是偶尔也有例外。我们可以看一个例子:
将decltype应用于一个名字上面,就会得到改名字的声明型别。名字本身是左值表达式,当仅有一个名字的时候,decltype的行为保持不变。不过如果是更复杂的左值表达式的话,decltype就保证得到的型别总是左值引用。换言之,只要一个左值表达式不仅是一个型别为T的名字,就得出一个T&的型别。这种行为一般没有什么影响,因为大多数左值表达式都带有左值引用饰词。例如返回左值的函数总是返回左值引用。但是还是会导致一个值得注意的后果:int x = 0;这个x是变量的名字,所以decltype(x)结果是int,但是如果不是x而是(x),这是个复杂的表达式,C++定义中(x)也是左值,所以decltype((x))的结果就变成了int&。
C++11中这个知识没什么用,但是在C++14中结合decltype(auto),就会影响到函数的型别推导结果:
decltype(auto) f1()
{
int x = 0;
return x; //decltype(x)是int,所以返回值是int
}
decltype(auto) f2()
{
int x = 0;
return (x); //decltype((x))是int&,所以返回值是int&
}
上述问题非常大,不仅仅是返回型别的不同,最重要的是我们返回了一个局部变量的引用。
所以我们使用decltype(auto)的时候一定要小心。
四、查看型别推导结果
我们会在三个阶段来看推导结果
撰写代码阶段
撰写代码时使用的IDE编辑器通常会在鼠标悬停至某个程序实体,如变量、形参、函数时显示出该实体的型别。
编译阶段
想要让编译器显示其推导的型别,一条有效途径就是使用该型别导致某些编译错误。报告错误的信息肯定能提及导致该错误的型别。例如:
const int theAnswer = 42;
auto x = theAnswer;
auto y = &theAnswer;
//想要查看x和y的型别,先声明一个类模板,但是不去定义它:
template<typename T>
class TD;
//只要试图具现该模板,就会诱发错误原因是找不到具现模板所需要的定义
TD<decltype(x)> xType; //诱发错误
运行阶段
运行时可以打印输出型别信息。困难的地方在于为我们关心的型别创立一种适合显示的字符表示。最简单的方法就是typeid和std::type_info::name:
std::cout<<typeid(x).name()<<std::endl;
std::cout<<typeid(y).name()<<std::endl;
针对某个对象,调用sypeid就得到了一个std::type_info对象,这个对象拥有一个成员函数name()。该函数产生一个代表型别的C风格字符串(const char*)。std::type_info::name的调用不保证返回任何有意义的内容,各种IDE可能返回的内容不太一样。对于微软的编译器,输出的结果很直观,x输出"int",y输出"int const *"。
上述例子很简单,但是遇到复杂一点的情况会出现一些问题:
template<typename T>
void f(const T& param); //打算调用的函数模板
std::vector<Widget> createVec(); //工厂函数
const auto vw = createVec(); //使用工厂函数初始化vw
if(!vw.empty())
{
f(&vw[0]); //调用f
}
这段代码具有代表性,涉及一个用户自定义类型,一个STL容器,还有一个auto变量。如果我们能够了解到传递给模板型别形参T和f的函数形参param的是什么型别,那是十分理想的。我们可以使用typeid试试:
template<typename T>
void f(const T& param)
{
using std::cout;
cout<<"T = "<<typeid(T).name()<<endl; //显示T
cout<<"param = "<<typeid(param).name()<<endl;
|
微软编译器会告诉我们:
T = class Widget const *
param = class Widget const *
不幸的是这个结果是不正确的。在模板中param被声明为const T&。输出的结果T和const的型别居然是一样的,这是很奇怪的。假设T是int,那么param就是const int&,完全不应该相同。
这种结果是不正确的,但是不正确的结果是符合要求的。标准规格上说,std::type_info::name中处理型别的方式就仿佛像函数模板按值传递形参一样。这样一来,根据模板按值传递,忽略了引用性和const,这个param的型别本来应该是const Widget* const&,却被报告成const Widget*。
这里我们可以使用Boost的TypeIndex库。函数模板boost::typeindex::type_id_with_cvr接受一个型别实参(我们想要获取的型别实参),而且不会移除const、volatile和引用饰词。该函数模板返回一个boost::typeindex::type_index对象,它利用成员函数pretty_name产生一个包含人类可读的型别表示的std::string:
#include <boost/type_index.hpp>
template<typename T>
void f(const T& param)
{
using std::cout;
using boost::typeindex::type_id_with_cvr;
//显示T和param的型别
cout<<"T = "<<type_id_with_cvr<T>().pretty_name()<<endl;
cout<<"param = "<<type_id_with_cvr<decltype(param)>().pretty_name()<<endl;
}