Type Deduction of CPP

类型系统在语言设计中是一个非常重要的方面,在影响语言的好坏程度的时候,类型系统与相关的库同是起决定性的两个因素。 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是一个指针或者引用,但不是全局引用

此时ParamTypeT*,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,对应的ParamTypeint\&,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\&。那么TParamType的类型都会被推导为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&

我似乎看到了广大人民群众洋溢着笑容,拍手称快。但是后置返回类型语法并无法完全废除,在模板特化中仍然有其不可替代的地位,这将在下一篇中讲到。

参考链接

Published:
2015-10-11 20:13
Category:
Tag:
CPP15