C++ decltype(类型推导)精讲

学习了《C++ auto》一节我们应该知道,auto 用于通过一个表达式在编译时确定待定义的变量类型,auto 所修饰的变量必须被初始化,编译器需要通过初始化来确定 auto 所代表的类型,即必须要定义变量。若仅希望得到类型,而不需要(或不能)定义变量的时候应该怎么办呢?

C++11 新增了 decltype 关键字,用来在编译时推导出一个表达式的类型。它的语法格式如下:

decltype(exp)

其中,exp 表示一个表达式(expression)。

从格式上来看,decltype 很像 sizeof ——用来推导表达式类型大小的操作符。类似于 sizeof,decltype 的推导过程是在编译期完成的,并且不会真正计算表达式的值。

那么怎样使用 decltype 来得到表达式的类型呢?让我们来看一组例子:
int x = 0;
decltype(x) y = 1;           // y -> int
decltype(x + y) z = 0;       // z -> int
const int& i = x;
decltype(i) j = y;           // j -> const int &
const decltype(z) * p = &z;  // *p  -> const int, p  -> const int *
decltype(z) * pi = &z;       // *pi -> int      , pi -> int *
decltype(pi)* pp = π      // *pp -> int *    , pp -> int * *
对代码的说明:
1) y 和 z 的结果表明 decltype 可以根据表达式直接推导出它的类型本身。这个功能和上一节的 auto 很像,但又有所不同。auto 只能根据变量的初始化表达式推导出变量应该具有的类型。若想要通过某个表达式得到类型,但不希望新变量和这个表达式具有同样的值,此时 auto 就显得不适用了。

2) j 的结果表明 decltype 通过表达式得到的类型,可以保留住表达式的引用及 const 限定符。实际上,对于一般的标记符表达式(id-expression),decltype 将精确地推导出表达式定义本身的类型,不会像 auto 那样在某些情况下舍弃掉引用和 cv 限定符。

3) p、pi 的结果表明 decltype 可以像 auto 一样,加上引用和指针,以及 cv 限定符。

4) pp 的推导则表明,当表达式是一个指针的时候,decltype 仍然推导出表达式的实际类型(指针类型),之后结合 pp 定义时的指针标记,得到的 pp 是一个二维指针类型。这也是和 auto 推导不同的一点。

对于 decltype 和引用(&)结合的推导结果,与 C++11 中新增的引用折叠规则(Reference Collapsing)有关,因此,留到后面右值引用(Rvalue Reference)一节再详细讲解。

扩展阅读

关于 p、pi、pp 的推导,有个很有意思的地方。像 Microsoft Visual Studio 这样的 IDE,可以在运行时观察每个变量的类型。我们可以看到 p 的显示是这样的:

这其实是 C/C++ 的一个违反常理的地方:指针(*)、引用(&)属于说明符(declarators),在定义的时候,是和变量名,而不是类型标识符(type-specifiers)相结合的。

因此,const decltype(z)*p推导出来的其实是 *p 的类型(const int),然后再进一步运算出 p 的类型。

decltype 的推导规则

从前面的内容来看,decltype 的使用是比较简单的。但在简单的使用方法之后,也隐藏了不少细节。

我们先来看看 decltype(exp) 的推导规则:
  • 推导规则1,exp 是标识符、类访问表达式,decltype(exp) 和 exp 的类型一致。
  • 推导规则2,exp 是函数调用,decltype(exp) 和返回值的类型一致。
  • 推导规则3,其他情况,若 exp 是一个左值,则 decltype(exp) 是 exp 类型的左值引用,否则和 exp 类型一致。
关于推导规则,有很多种版本:
C++ 标准:ISO/IEC 14882:2011,7.1.6.2 Simple type specif iers,第4 款
MSDN:decltype Type Specif ier,http://msdn.microsoft.com/en-us/library/dd537655.aspx
维基百科:decltype,http://en.wikipedia.org/wiki/Decltype

虽然描述不同,但其实是等价的。为了方便理解,这里选取了 MSDN 的版本。
只看上面的推导规则,很难理解decltype(exp) 到底是一个什么类型。为了更好地讲解这些规则的适用场景,下面根据上面的规则分3种情况依次讨论:
  • 标识符表达式和类访问表达式。
  • 函数调用(非标识符表达式,也非类访问表达式)。
  • 带括号的表达式和加法运算表达式(其他情况)。

1) 标识符表达式和类访问表达式

先看第一种情况,下面的代码是一组简单的例子。

【实例】decltype 作用于标识符和类访问表达式示例。
class Foo
{
    public:
    static const int Number = 0;
    int x;
};
int n = 0;
volatile const int & x = n;
decltype(n) a = n;            // a -> int
decltype(x) b = n;            // b -> const volatile int &
decltype(Foo::Number) c = 0;  // c -> const int
Foo foo;
decltype(foo.x) d = 0;        // d -> int,类访问表达式
变量 a、b、c 保留了表达式的所有属性(cv、引用)。这里的结果是很简单的,按照推导规则1,对于标识符表达式而言,decltype 的推导结果就和这个变量的类型定义一致。

d 是一个类访问表达式,因此也符合推导规则1。

2) 函数调用

接下来,考虑第二种情况:如果表达式是一个函数调用(不符合推导规则1),结果会如何呢?请看下面的代码。

【实例】decltype 作用于函数调用的示例。
int& func_int_r(void);           // 左值(lvalue,可简单理解为可寻址值)
int&& func_int_rr(void);         // x值(xvalue,右值引用本身是一个xvalue)
int func_int(void);              // 纯右值(prvalue,将在后面的章节中讲解)
const int& func_cint_r(void);    // 左值
const int&& func_cint_rr(void);  // x值
const int func_cint(void);       // 纯右值
const Foo func_cfoo(void);       // 纯右值
// 下面是测试语句
int x = 0;
decltype(func_int_r())   a1 = x;      // a1 -> int &
decltype(func_int_rr())  b1 = 0;      // b1 -> int &&
decltype(func_int())     c1 = 0;      // c1 -> int
decltype(func_cint_r())  a2 = x;      // a2 -> const int &
decltype(func_cint_rr()) b2 = 0;      // b2 -> const int &&
decltype(func_cint())    c2 = 0;      // c2 -> int
decltype(func_cfoo())    ff = Foo();  // ff -> const Foo
可以看到,按照推导规则2,decltype 的结果和函数的返回值类型保持一致。

这里需要注意的是,c2 是 int 而不是 const int。这是因为函数返回的 int 是一个纯右值(prvalue)。对于纯右值而言,只有类类型可以携带 cv 限定符,此外则一般忽略掉 cv 限定。

如果在 gcc 下编译上面的代码,会得到一个警告信息如下:

warning: type qualif?iers ignored on function return type [-Wignored-qualifiers] cint func_cint(void);

因此,decltype 推导出来的 c2 是一个 int。

作为对比,可以看到 decltype 根据 func_cfoo() 推导出来的 ff 的类型是 const Foo。

3) 带括号的表达式和加法运算表达式

最后,来看看第三种情况:
struct Foo { int x; };
const Foo foo = Foo();
decltype(foo.x)   a = 0;  // a -> int
decltype((foo.x)) b = a;  // b -> const int &
int n = 0, m = 0;
decltype(n + m) c = 0;    // c -> int
decltype(n += m) d = c;   // d -> int &
a 和 b 的结果:仅仅多加了一对括号,它们得到的类型却是不同的。

a 的结果是很直接的,根据推导规则1,a 的类型就是 foo.x 的定义类型。

b 的结果并不适用于推导规则1和2。根据 foo.x 是一个左值,可知括号表达式也是一个左值。因此可以按照推导规则3,知道 decltype 的结果将是一个左值引用。

foo 的定义是 const Foo,所以 foo.x 是一个 const int 类型左值,因此 decltype 的推导结果是 const int&。

同样,n+m 返回一个右值,按照推导规则3,decltype 的结果为 int。

最后,n+=m 返回一个左值,按照推导规则3,decltype 的结果为 int&。

decltype 的实际应用

decltype 的应用多出现在泛型编程中,请看下面的例子。

【实例】泛型类型定义可能存在问题的示例。
#include <vector>
template <class ContainerT>
class Foo
{
    typename ContainerT::iterator it_; // 类型定义可能有问题
public:
    void func(ContainerT& container)
    {
        it_ = container.begin();
    }
    // ...
};
int main(void)
{
    typedef const std::vector<int> container_t;
    container_t arr;
    Foo<container_t> foo;
    foo.func(arr);
    return 0;
}
单独看类 Foo 中的 it_ 成员定义,很难看出会有什么错误,但在使用时,若上下文要求传入一个 const 容器类型,编译器马上会弹出一大堆错误信息。

原因就在于,ContainerT::iterator 并不能包括所有的迭代器类型,当 ContainerT 是一个 const 类型时,应当使用 const_iterator。

要想解决这个问题,在 C++98/03 下只能想办法把 const 类型的容器用模板特化单独处理,比如增加一个像下面这样的模板特化:
template <class ContainerT>
class Foo<const ContainerT>
{
    typename ContainerT::const_iterator it_;
public:
    void func(const ContainerT& container)
    {
        it_ = container.begin();
    }
    // ...
};
这实在不能说是一个好的解决办法。若 const 类型的特化只是为了配合迭代器的类型限制,Foo 的其他代码也不得不重新写一次。

有了 decltype 以后,就可以直接这样写:
template <class ContainerT>
class Foo
{
    decltype(ContainerT().begin()) it_;
public:
    void func(ContainerT& container)
    {
        it_ = container.begin();
    }
    // ...
};
是不是舒服很多了?

decltype 也经常用在通过变量表达式抽取变量类型上,如下面的这种用法:
vector<int> v;
// ...
decltype(v)::value_type i = 0;
在冗长的代码中,人们往往只会关心变量本身,而并不关心它的具体类型。比如在上例中,只要知道v是一个容器就够了(可以提取 value_type),后面的所有算法内容只需要出现 v,而不需要出现像vector<int> 这种精确的类型名称。这对理解一些变量类型复杂但操作统一的代码片段有很大好处。

实际上,标准库中有些类型都是通过 decltype 来定义的:
typedef decltype(nullptr) nullptr_t;  //通过编译器关键字nullptr定义类型nullptr_t
typedef decltype(sizeof(0)) size_t;
这种定义方法的好处是,从类型的定义过程上就可以看出来这个类型的含义。