类型系统在语言设计中是一个非常重要的方面,在影响语言的好坏程度的时候,类型系统与相关的库同是起决定性的两个因素。
C++的类型系统为静态弱类型。虽说静态类型的优点是变量的类型明确,但是碰到这种声明char (*(*X())[])()
,在没有cdecl类似工具的辅助下一般人也很难看出变量的意义是什么。上面的例子属于C语言的范畴,C++在其基础上增加了更加复杂的模板类型推导和自动类型推导。在这些因素的影响下,如何读懂C++的类型便成为了一个很大的问题。本文便以弄懂C++的类型为出发点,剖析template<T> ,auto ,decltype
这三种语法下的类型推导。相关内容都是自己从internet及相关书籍总结而来,如有错误欢迎指出。
理解template类型推导
模板(template)被引入C++主要是用来处理泛型容器和通用算法问题。在被证明了是图灵完全之后,模板的使用场景便百花齐放,无所不用其极。看过boost库中spirit等类似库之后,很多人自此都换上了尖括号恐惧症。如果你经历过编译器报模板相关错误,那么你应该能理解我所说的。本文并不想去谈论模板元编程(我也没有资格去谈,水平不够),只讨论一下模板类型推导的问题。由于函数模板类型推导与类模板类型推导原理一样,下文只讨论函数模板推导。简单来说,函数模板声明有如下形式:
template <typename T> void f(ParamType param);
其中的ParamType
可以是T,T\&,T*,T\&\&,T**
等简单类型,也可以是一个类似于vector<T>
的模板类型。此外,const,volatile
等类型修饰符也可以在ParamType
中进行使用。
函数模板以如下形式进行调用:
f(expr);
函数模板中的类型推导需要同时考虑ParamType,expr
这两个因素,其中ParamType
起主导因素。对于ParamType
的形式,主要有以下三种:
-
ParamType
既不是指针也不是引用。 -
ParamType
是一个指针或者引用类型,但不是全局引用; -
ParamType
是一个全局引用;
接下来我们根据上面所说的三种形式来进行分别讨论。
paramType既不是指针也不是引用
此时,函数模板的形式如下。
template <typename T> void f(T param);
传递参数的时候,会直接按照值来传递,因此会调用拷贝构造函数来生成一个新的对象。param
的推导遵循如下规则:
-
如果
expr
是一个引用,那么先忽略其引用类型; -
如果有
const,volatile
修饰符,则一并忽略。
最后得到的类型就是T
的类型,例子如下。
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
ParamType是一个指针或者引用,但不是全局引用
此时ParamType
有T*,T\&
这两种形式,这两种形式的推导是一样的。这里以引用形式来说明,此时函数模板的声明如下:
template <typename T> void f(T& param);
在f(expr)
类型推导时,所做工作有如下两步:
-
如果
expr
是一个引用类型,即A\&
类型,先忽略引用部分.这一步会把简单引用和全局引用忽略这是通过remove\_reference<A\&>::type
来实现的。 -
然后模式匹配上一步返回的类型,通过与
ParamType
的模式匹配来获得T
的类型。
以例子来说,如果我们有下面三个变量声明:
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
则以这些变量作为实参带入f(expr)
,会有如下结果:
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&
首先,除去引用部分。第三个调用中,rx
的类型是const int \&
,在除去引用之后,变为const int
。在经过除去引用之后,这三个参数的类型分别为int,const int,const int
。
对于后两个调用,我们传入的实参cx,rx
都是const
的,则T
会被展开为const T
。在形参为引用类型时,const
是会传染的,对于其他的类型修饰符volatile
也是如。之后,类型推导剩余部分,即int
部分,直接匹配。所以最后我们得到的T
的类型为int ,const int,const int
,对应的ParamType
为int\&,const int \& ,const int \&
。
如果 ParamType
中已经含有了const,volatile
等修饰符,则我们不需要去考虑实参是否有这些修饰符,在推导ParamType
的时候统一加上就行了,而T
则不需要再去考虑const,volatile
。例子如下:
template <typename T> void f(const T& param); // param is now ref-to-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&
对于指针类型,也是同理:
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*
ParamType是全局引用
全局引用的形式为T\&\&
,看上去是右值引用,其实并不完全正确。此时,会有下面的两个规则。
-
如果
expr
是一个左值,形式为A
或者A\&
。那么T
和ParamType
的类型都会被推导为A\&
。 -
如果
expr
是一个右值,形式为A\&\&
。则T
的类型会被推导为A
,而ParamType
的类型会被推导为A\&\&
。
简而言之,假设expr
的类型为X
,T
的类型总是remove\_reference<X>::type
;而ParamType
的类型只有在X=A\&\&
的时候才是右值引用,其他情况都是左值引用。
使用上述规则的例子如下:
tempalte <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&&
事实上现在讨论的推导规则都在C++引用折叠规则之内,但是为了解释引用折叠规则就会牵扯到移动语义和完美转发,比较麻烦,因此这里就先不提了。
数组类型参数
需要注意的是:数组类型在形参不是引用的时候会退化为指针,即下面两个函数的声明是等价的。
void myFunc(int param[]); void myFunc(int* param);
而在形参为引用(包括左值引用和全局引用)的时候则会推导出数组的类型,不会退化为指针。例子如下:
const char name[] = "J.P.Briggs"; template <typename T> void f1(T& param); f1(name); // T's type and param's type are const char(&)[13] template <typename T> void f2(T&& param); f2(name); // T's type and param's type are const char(&)[13]
auto类型推导
auto
关键字首次出现是在c++11,广大人民群众拍手称快欢迎此关键字。auto
的类型推导与模板除了在初始化列表类型有差异外,几乎相同。所以这里只提差异之处,看下例:
auto x1 = 27; // type is int, value is 27 auto x2(27); // ditto auto x3 = { 27 }; // type is std::initializier_list<int>, value is { 27 } auto x4{ 27 }; // ditto
当推导一个以大括号初始化的类型变量的时候,auto
并不会直接推导出变量的最终类型,而是保留初始化列表类型,即std::initializer\_list<T>
类型。如果初始化列表中的值类型并不完全相同,则无法完成对于T
的推导,导致报错。即使用auto
来推导初始化列表时,不允许隐式转换,如下例:
auto x5 = { 1, 2, 3.0 }; // error! can't deduce T for std::initializer_list<int>
此外,当初始化列表使用在函数模板时,其类型推导也会产生错误。
template<typename T> void f(T param); // template with parameter declaration equivalent to x's f({ 11, 23, 9}); // error! can't deduce type for T
如果想让初始化列表也能参与到模板类型推导,则需要修改函数模板的声明。
template<typename T> void f(std::initializer_list<T> initList); f({ 11, 23, 9 }); // T deduce as int, and initList's type is std::initializer_list<int>
至于为什么会出现这些稀奇古怪的规则,我不清楚,Scott Meyers也不清楚。唯一的建议是不要让编译器去推导初始化列表的类型,否则就等着报模板错误吧。
decltype类型推导
decltype
关键字在很早的时候就进入了C++语言中,但是之前有很多限制用处不是很大,能使用的地方现在也基本被auto
代替了。在C++ 11中,他唯一的作用就是声明一个返回类型依赖于模板参数的模板函数,这个功能在使用auto
时会出错。如下例,我们来构造一个类似于数组操作符的函数:
template<typename Container, typename Index> auto access(Container& c, Index i) // { return c[i]; // return type deduced from c[i] }
C++语言中操作符[]
的返回值默认是A\&
的,而不是直接返回值,因为要处理a[i]=c
的情况。所以上文中c[i]
的类型是引用类型。但是,auto
的推导规则是类似于模板的推导规则的,即引用类型会被转换为非引用类型。在这个规则的作用下,会造成access[i]=c
无法通过编译。
为了达到所期望的要求,需要对原程序进行修改。在C++11中,引入了后置返回类型trailing return type
,实现所期望的功能需要修改为如下代码:
template <typename Container, typename Index> auto access(Container& c, Index i) -> decltype(auto) { return c[i]; }
为什么这样就可以得到正确的返回值类型?这个需要追究到C++标准中对于decltype
的说明。在C++标准中,对于decltype(e)
的类型有如下定义:
-
如果
e
是一个没有给括号包围的变量名或者一个没有被括号包围的类成员访问,则decltype(e)
的类型就是最终访问的变量的类型。如果访问的变量并不存在,则视为错误。 -
如果;
e
是一个函数调用或者重载操作符(如果有括号包围,则去除括号),则decltype(e)
是最终所调用函数的返回值类型; -
否则,如果
e
是一个左值,且e
的类型是T
,则decltype(e)
为T\&
; -
否则 ,
decltype(e)
的类型就是e
的类型。
下面的代码便是上述规则的一些实例:
const int&& foo(); int i; struct A { double x; }; const A* a = new A(); decltype(foo()) x1 = std::move(i); // type is const int&& decltype(i) x2; // type is int decltype(a->x) x3; // type is double decltype((a->x)) x4 = x3; // type is const double&
注意最后的两个声明,可以看出对于左值来说有没有加括号是有很大差别的。对于decltype(a->x)
,得到的是A::x
的类型,所以是double
。而对于decltype((a->x))
来说,返回的是(a->x)
的类型。由于a
的类型为const A*
,又因为(a->x)
是一个左值表达式,所以最后返回的是const double\&
。类似的例子还有:
decltype(auto) f1() { int x = 0; // ... return x; // decltype(x) is int, so f1 returns int } decltype(auto) f2() { int x = 0; // ... return (x); // decltype((x)) is int&, so f2 returns int& }
在寻找相关资料的过程中,还看到了decltype(a,b)
这样的用法,这时的类型等价于decltype(b)
。总而言之,就是一个坑。
要不是auto
在返回值推导时无法得到正确结果,这个关键字早就废了。虽说能够正确运行了,广大人民群众对于上面的函数声明还是不满意:为什么要写后面那一大串,编译期不会自己写么。于是在C++14,之前的函数定义代码又升级了,引入了decltype(auto)
。原有的代码变成了这样的最终形态:
template <typename Container, typename Index> decltype(auto) access(Container&& c, Index i) // final C++14 version { return std::forward<Container>(c)[i]; }
类似的,变量声明也可以使用decltype(auto)
,如下所示:
Widget w; const Widget& cw = w; auto myWiget1 = cw; // auto type deduction: // myWidget1's type is Widget decltype(auto) myWidget2 = cw; // decltype type deduction: // myWidget2's type is const Widget&
我似乎看到了广大人民群众洋溢着笑容,拍手称快。但是后置返回类型语法并无法完全废除,在模板特化中仍然有其不可替代的地位,这将在下一篇中讲到。
参考链接
-
知乎上关于类型系统的大概介绍:http://www.zhihu.com/question/19918532
-
Scott Meyers的新书
Effective Modern C++
的前3章,本文绝大部分内容抄袭自此:http://book.douban.com/subject/25923597/ -
热心网友Bitdewy对
Effective Modern C++
前几章的中文翻译,我厚颜无耻的抄了一部分:http://blog.bitdewy.me/ -
C++标准中关于
decltype
的部分,链接中文档第153页:http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2011/n3242.pdf