原创文章,未经许可,禁止转载!

Item 3:理解 decltype

decltype 是一个奇怪的东西,给他传入一个变量名称或者表达式,它就能返回给i类型。通常情况下,它都能返回你想要知道的类型。然而,他的结果有时候会让你抓狂,到处找资料甚至在论坛上问别人。

不过说到这里,先不要害怕,我们首先从那些非常直观的 case 说起。与模版和 auto 相反,decltype 是通过你给出的名称或者表达式返回给你具体的类型:

const int i = 0;                      // decltype(i) 是 const int

bool f(const Widget& w);              // decltype(w) 是 const Widget&

struct Point {
  int x, y;                           // decltype(Point::x) 是 int
};                                    // decltype(Point::y) 是 int

Widget w;                             // decltype(w) 是 Widget

if (f(w)) ...                         // decltype(f(w)) 是 bool

template <typename T>                 // 这里是 std::vector 的简化版
class vector {
  ...
  T& operator[](std::size_t index);
  ...
};

vector<int> v;                        // decltype(v) 是 vector<int>
...
if (v[0] == 0) ...                    // decltype(v[0]) 是 int

看到了吗?非常直观,没有惊喜!

在 C++ 11 里,可能主要使用 decltype 的地方就是模版函数的返回值,并且这个返回值的类型与函数参数类型相关。举个例子,假设我们要写一个函数,这个函数里面有一个容器支持用方括号( “[]“ )来索引,并且在返回索引操作结果之前对用户进行认证,那么这个函数的返回类型应该和索引操作得到的元素的类型是相同的。

一个容器,里面装有 T 类型的元素,容器的 “[]” 操作符应该返回一个 T& 的元素。比如,std::deque 就是这样的,std::vector 大多数情况也是这样的,但是,std::vector<bool>却不是,”[]” 操作符并不返回 bool&,而是返回一个全新对象。原因会在 Item 6 里详细讲述。这里需要知道的非常重要的一点就是容器 “[]” 运算符返回的类型与容器本身有关。

decltype 让这里的工作就变得简单了许多。首先用一个模版范例作为切入口来演示 decltype 推导函数的返回类型。这个例子还需要一些改进,但这个改进不着急,晚点来做。

template <typename Container, typename Index>    // 可以工作,但是需要改进
auto authAndAccess(Container& c, Index i) -> decltype(c[i])
{
  authenticateUser();
  return c[i];
}          

在函数名前的那个 auto ,对函数返回值的推导没有任何作用。而真正起作用的是 C++11 的尾随语法的使用,函数的返回类型的声明在函数参数列表后面,”->” 符号后面的部分。这种在函数尾部定义返回类型的方法的优势在于在一定的规则里,可以通过函数的参数来决定返回的类型。在 authAndAccess 函数中,返回类型利用了 c 和 i。如果想按照常规方式把返回类型写在函数名前,那么 c 和 i 都将无效,因为那时还没有定义。

根据声明,authAndAccess 不管返回什么类型,”[]” 操作符都可以根据容易的信息返回正确的类型。

C++ 11 还允许推导单语句的 lambda 表达式的返回类型,并且 C++ 14 还扩展了所有 lambda 表达式和函数,包括多语句(甚至可以给多返回提供类型推导)。这意味这在 authAndAccess 的案例中,可以省略函数尾部的声明,只需要在函数前加 auto。在这种情况下,auto 就代表了类型推导。重要的是,编译器会从函数的实现中去推导它的返回类型:

template <typename Container, typename Index>  // C++14 
auto authAndAccess(Container& c, Index i)      // 不太正确
{
  authenticateUser();
  return c[i];             // 返回值类型通过 c[i] 推导
}

Item 2 已经解释函数 auto 的推导,编译器使用了模版的推导规则。在这个案例里,用这个规则是有问题的。我们前面讨论过,大多数的容易使用 “[]” 运算符返回的类型是 T&,但是在 Item 1 中,模版的推导会忽略引用,对照下方的代码:

std::deque<int> d;
...
authAndAccess(d, 5) = 10  // 用户认证,然后返回 d[5],并给 d[5] 赋值10,这里编译通不过

在这里,d[5] 返回的类型是 int&,但是 auto 返回类型推导会忽略掉引用返回 int 型,并成为函数的返回类型,是一个右值,而上面的代码要给一个右值 int 赋值,在 C++ 是不允许的,所以代码无法编译通过。

如果想让 authAndAccess 如同我们想的那样工作,需要使用 decltype 来推导它的返回值类型,去指定 authAndAccess 的返回类型应该和 c[i] 的类型一致。C++ 的设计者,已经想到了这种需求,允许 C++14 使用 decltype(auto) 来指定类型。到底是 decltype 还是 auto?这个看上去矛盾的解法其实是最完美的:auto 指定了要推导的类型,而 decltype 要求规则必须推导。然后代码就变成了下面这样:

template <typename Container, typename index>
decltype(auto) authAndAccess(Container& c, Index i)  // C++14 可以工作,但仍需改进
{
  authenticateUser();
  return c[i];
}

现在 authAndAccess 可以正确的返回 c[i]。对大部分的 c[i],都返回一个 T&,而有些特殊的情况将返回一个对象,从而导致 authAndAccess 也返回一个对象。

decltype(auto) 的用法不仅仅局限与函数返回值,它也能很方便的声明变量,并通过初始化表达式来推导类型:

Widget w;
const Widget& cw = w;
auto myWidget1 = cw;  // auto 推导,myWidget1 的类型是 Widget
decltype(auto) myWidget2 = cw;  // decltype 的推导,myWidget2 的雷习惯是 const Widget&

这里应该会有2个地方困惑你,一个是前面提到的待改进的 authAndAccess ,现在来解决它。

再看一眼在 C++14 的 authAndAccess 的声明

template<typename Container, typename Index>
decltype(auto) authAndAccess(Container& c, Index i);

容器里的元素通过左值引用返回,因为允许通过元素来修改容器内元素的值。不过这样的化就不能给函数传递右值了。右值不能绑定为左值(除非是 const 左值引用,但这里不是)。

不过,传递一个右值容器给 authAndAccess 只是一种边界情况。右值容器装的是临时对象,通常会在调用 authAndAccess 的最后销毁,这也意味着 authAndAccess 的返回值无效。而且,它可能传递一个临时对象给 authAndAccess,而用户可能指向要临时拷贝下容器内的数据,比如:

std::deque<std::string> makeStringDeque();     // 工厂函数

auto s = authAndAccess(makeStringDeque(), 5);  // 返回值是拷贝了第五个元素

由于支持这种使用方法,因此 authAndAccess 要能够接受左值和右值。那么这里可以使用函数重载(一种重载能够接受左值引用的参数,而另外一种需要支持右值引用),但是这样就需要维护 2 个函数,维护起来就比较麻烦。解决的方法就是让 authAndAccess 函数能够同时支持左值和右值参数,Univeral Reference,Item 24 会详细讲解 Univeral Reference 的工作方式。而 authAndAccess 在这里就可以定义成:

template <typename Container, typename Index>
decltype(auto) authAndAccess(Container&& c, Index i);  // c 的类型现在是一个 Univeral Reference

在这里,我们并不知道会操作什么类型的容器,因此这里要注意以下性能问题,要避免不必要的拷贝操作,处理好对象的切片问题(详见 Item 41),同时避免被同事们嘲笑。但在容易的索引问题上(比如 std::string 或者 std::deque 的 “[]” 运算符),根据标准库的范例看上去是合理的,因此这里可以坚持使用值传递。

然后,我们还需要在升级下模版的实现,根据 Item 25 的忠告,使用 std::forward 来传递 universal references:

template <typename Container, typename Index>
decltype(auto) authAndAccess(Container&& c, Index i)  // C++14 的最终版本
{
  authenticateUser();
  return std::forward<Container>(c)[i];
}

到这里就比较完美了,但是这样的代码需要 C++14 编译器的支持。如果你没有升级到 C++14,你只能用 C++ 11 的模版规则了,它其实和 C++14 基本是一致的,除了返回值的指定部分不一样,请看下面的代码:

template <typename Container, typename Index>   // C++11 的最终版本
auto authAndAccess(Container&& c, Index i) -> decltype(std::forward<Container>(c)[i])
{
  authenticateUser();
  return std::forward<Container>(c)[i];
}

其他的一些问题比如我之前提到的几乎所有的类型推导都符合预期,不太会有什么惊喜。说实话,应该不太会有例外,除非你是做底层库开发的。

为了能够完全理解 decltype 运行的行为,你必须熟悉它的特殊情况。这部分内容大多很晦涩难懂,不适合在这样的书里讨论,但还是可以深入学习并且使用一下的。

使用 decltype 来声明一个变量名,这个变量名显然是一个左值表达式,这并不影响 decltype 的行为。左值表达式比名称复杂的多,而 decltype 一般会保证类型是左值引用。左值表达式名字以外的部分的类型是 T,而用 decltype 得到的类型是 T&。这并不会有什么影响,因为大多数的左值表达式本来就是左值引用。函数返回左值大多情况也是返回左值引用。

下面这个问题很值得关注,

int x = 0;

这里,x 是变量名,所以 decltype(x) 是 int。但是用括号包裹 (x) 的部分是一个表达式,比变量名要复杂的多。如果是一个变量名,x 是一个左值,在C++ 中定义的表达式 (x) 也是一个左值。decltype((x)) 的类型是 int&。用括号将变量名括起来会改变 decltype 返回的类型。

在 C++11 中,这个问题还好,但是在 C++14 的加持下,小小的改动可能会影响到函数的返回类型:

decltype(auto) f1() 
{
  int x = 0;
  ...
  return x;    // decltype(x) 是 int,返回类型是 int
}

decltype(auto) f2()
{
  int x = 0;
  ...
  return (x);  // decltype((x)) 是 int&,返回类型是 int&
}

注意,这里不仅仅是 f2 和 f1 的返回类型不同,f2 还返回了一个临时变量的引用!这种代码会造成不可预料的行为,你是肯定不会想上这趟车的。

这节内容主要是告诉你要非常小心的使用 decltype(auto),看似无关紧要的细节会导致 decltype(auto) 错误的推导。要保证decltype 的类型是你所想要的,请仔细阅读 Item 4。

同时,不要忽视大局。当然,使用 decltype(包括单独使用和协同 auto 一起使用)有时候可能会有超出预料的效果,但这些并不是正常状态。正确的姿势是要让 decltype 推导的类型和你预期的一样。

通过变量名使用 decltype 是绝对正确的,因为 decltype 只有一个操作,就是告诉你变量名的类型。

重点总结:

  • decltype 在没有修改的情况下,几乎能给出变量和表达式正确的类型。
  • 左值表达式类型 T 除了变量名,decltype 返回的都是 T&。
  • C++14 支持 decltype(auto),虽然 auto 通过初始值来推导类型,但是这里必须遵照 decltype 的推导规则。

发表评论