[译] Item 3: Understand Decltype

decltype 是一个奇怪的发明。给一个名字或者表达式,decltype 可以告诉你这个名字或者表达式的类型。通常的情况下,他告诉你的都是你预期的。但是偶尔也会有些出人意料。

我们从最典型的情况开始 —— 那些不会让你惊奇的情况。与模板类型推导和 auto 类型推导不同,decltype 通常只是鹦鹉学舌般的返回你传入的名字或表达式的类型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
const int i = 0;    // decltype(i) is const int

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

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


Widget w;                   // decltype(w) is Widget

if (f(w)) {                 // decltype(f(w)) is bool
  // ...
}

template<typename T>        // simplified version of std::vector
class vector {
public:
    // ...
    T& operator[](std::size_t index);
    // ...
};

vector<int> v;              // decltype(v) is vector<int>

if (v[0] == 0) {            // decltype(v[0]) is int&
  // ...
}

是不是完全没有惊喜?

在 C++11 中,decltype 的作用也许就是声明一个返回值类型依赖于模板参数的模板函数。举个例子,假设我们准备写一个以容器 (接受下标操作的) 和索引 index 为参数的函数,验证用户之后返回。返回值的类型与容器下标操作的类型相同。

T 类型容器的 operator[] 返回值类型通常是 T&。对于 std::deque 来说,是这个样,对于 std::vector 来说,大部分情况下也是的,不过对于 std::vector<bool> 来说,情况就不一样了,它的返回值不是 bool&。这就是我们要在 Item 6 中讨论的问题。在这里,最重要的是,我们要知道容器的 operator[] 的返回值依赖于容器。

decltype 可以让这个问题变得简单,下面的代码展示如何利用 decltype 来计算返回值。它还可以进一步的精简,我们稍后再说:

1
2
3
4
5
6
template <typename Container, typename Index>
auto authAndAccess(Container& c, Index i) -> decltype(auto)  // works, but requires refinement 
{
  authenticateUser();
  return c[i];
}

函数名前面的 auto 在类型推导中什么用处都没有。取而代之的是 C++11 的尾随返回值类型 (trailing return type)。函数返回值的类型在函数参数列表之后声明 (在 –> 符号之后)。尾随返回值类型的好处是,函数的参数可以用于声明返回值类型。在 authAndAccess 中,我们的返回值类型声明用到了参数 ci。如果我们将返回值类型置于函数名前,那么我们就无法使用 ci,因为这时它们还没有声明。

上面的声明中,autoAndAccess 的返回值与我们预期的完全一致,就是容器 Containeroperator[] 的返回值类型。

C++11 支持单句 lambda 的返回值类型推导,而 C++14 进行了扩展,支持所有 lambda 和函数,包括那些多条语句的。这意味着在上面的例子中,使用 C++14 我们可以省略掉尾随返回值类型,仅仅保留最前面的 auto 即可。在这里,auto 是类型推导的占位符。编译器会根据函数的具体实现来推导函数的返回值类型。

1
2
3
4
5
6
template<typename Container, typename Index>
auto authAndAccess(Container& c, Index i)    // C++14, not require
{
  authenticateUser();
  return c[i];            // return type deduced from c[i]
}

但是,在这里返回值类型推导使用的是哪个类型推导规则?模板? auto ? 还是 decltype ?

也许你会有些惊讶,函数的 auto 返回值类型遵循的是模板类型推导规则。看起来 auto 类型推导规则,在这里是一个更好的选择,不过 auto 类型推导与模板类型推导机会是完全一致的。唯一的区别就是模板类型推导无法推导大括号初始化。

在这里,authAndAccess 的返回值类型推导使用模板类型推导是有问题的,不过 auto 类型推导也是一样存在问题。这里的问题是,我们需要推导的表达式是一个引用。

回想一下之前的讨论,operator[] 对于大多数容器类型 T 来说,返回值类型都是 T&,我们在 Item 1 中已经讨论过了,在模板类型推导时,表达式的引用会被忽略。考虑下这对我们上面的代码意味着什么:

1
2
3
std::queue<int> d;
// ...
authAndAccess(d, 5) = 10;  // authenticate user, return d[5], then assign 10 to it; this won't compile!

上面的代码中,d[5] 返回类型是 int&,但是 auto 返回值类型推导会将引用忽略掉,变成了 int 类型。而 int 类型作为一个函数的返回值,是一个右值,而上面的代码中企图将 10 赋值给一个右值。这在 C++ 中是禁止的,因此会编译失败。

这个问题是由于我们使用了会忽略引用的模板类型推导。在这里,我们需要的实际上是 decltype 类型推导。它能够保证返回值类型与 c[i] 的类型完全一致。

C++ 将引入新的类型推导规则,decltype 类型推导,在 C++14 中通过标识符 decltype(auto) 来实现。这看起来有些奇怪,但是很好的表达了意图:auto 是要推导的类型,decltype 表明需要遵循 decltype 类型推导规则。现在我们可以把之前的代码改成下面这样了:

1
2
3
4
5
6
template<typename Container, typename Index>
decltype(auto) authAndAccess(Container& c, Index i)    // C++14, works, but still requires refinement
{
  authenticateUser();
  return c[i];
}

现在 authAndAccess 的返回值与 c[i] 的类型完全一致了,一般情况下当 c[i] 返回 T& 类型时,authAndAccess 也会返回 T&,而当 c[i] 需要返回一个 object 类型时,authAndAccess 也会返回 object 类型。

decltype(auto) 的使用,不仅限于函数返回值类型,当你需要使用 decltype 类型推导规则时,它可以用于声明变量:

1
2
3
4
5
6
7
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&

但肯定还有两个问题困扰着你,一个就是上面的代码中提到的优化,我们到现在还没有谈到,现在就让我们来看这个问题。

回头看看我们的 C++14 版本的 authAndAccess 函数声明;

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

容器参数的类型是左值引用,这样可以让容器返回元素供调用者修改。但是这意味着这个函数无法接受右值的容器作为参数了,因为右值是无法绑定到左值引用的。

不可否认,传一个右值给 authAndAccess 的场景非常少见。一个右值的容器作为一个临时对象会在函数 authAndAccess 结束时销毁,这意味着容器元素的引用( authAndAccess 函数的返回值)会失效。但是给 authAndAccess 传入一个临时对象还是有意义的。调用者有时会需要一个容器元素的拷贝的,比如下面的代码:

1
2
3
4
5
std::deque<std::string> makeStringDeque();  // factory function

// make copy of 5th element of deque returned
// from makeStringDeque
auto s = authAndAccess(makeStringDeque(), 5);

支持上面的用法,意味着我们要将原本的函数修改为同时支持左值和右值。重载是可以解决这个问题,但是这样一来我们就需要维护两个函数了。有一种办法可以避免同时维护两个函数,我们可以让函数 authAndAccess 同时支持左值以及右值参数,Item 24 中,我们会详细的介绍全局引用。修改后的 authAndAccess 函数声明如下和:

1
2
template <typename Container, typename Index>          // c is now a 
decltype(auto) authAndAccess(Container&& c, Index i);  // universal reference

在上面的模板中,我们不知道 Container 的类型,同时我们也忽略了 index 对象的类型。对一个未知类型使用值传递会因为不必要的拷贝而造成性能问题,也会有对象切割问题(Item 41),还会被同事吐槽,不过在这里我们只考虑标准库容器的情况(比如,std::string, std::vector 以及 std::dequeoperator[]),在这里仍然坚持使用值传递。

不过我们还需要更新一下模板函数的实现,根据 Item 25,我们使用 std::forward 把全局引用包起来,

1
2
3
4
5
6
template <typename Container, typename Index>
decltype(auto) authAndAccess(Container&& c, Index i)  // final C++14 version
{
  authenticateUser();
  return std::forward<Container>(c)[i];
}

上面的代码完全符合我们的需求,不过需要支持 C++14 的编译器。如果你现在还没有支持 C++14 的编译器的话,那就需要一个 C++11 的版本。和 C++14 的版本非常相似,唯一的不同点是我们需要手动的指定返回值类型,

1
2
3
4
5
6
template <typename Container, typename Index>    // final C++11 version
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& 类型。这很少会产生冲突,因为大部分的左值表达式都内含左值引用的限定符。例如返回左值的函数通常返回的都是左值引用。

但是这还是会产生一些不期望的问题,例如,

1
int x = 0;

x 是变量名,decltype(x) 的类型是 int。但是,使用括号将 x 包起来,情况就不一样了。x 是一个左值, (x) 是一个左值表达式,decltype((x)) 的类型是 int&。一个括号改变了 decltype 的类型。

在 C++11 中,这不是大问题,但在 C++14 中,由于支持了 decltype(auto),这个微不足道的变化会变的影响含漱的返回值类型推导。

1
2
3
4
5
6
7
8
9
10
11
12
13
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&
}

注意,f2 不仅仅只是与 f1 的返回值类型不同,它还返回了一个局部变量的引用。这使得你的代码不知不觉的就产生了未定义行为。

这就是说当你使用 decltype(auto) 时,必须要非常小心。一些非常小的细节就可能会影响 decltype(auto) 的类型推导结果。确定类型推导的结果是否符合你的预期,你需要用到 Item 4 中介绍的技术手段。

与此同时,不要忘记从更高的角度来审视这个问题。decltype(不论是否与 auto 在一起)偶尔会产生让你惊讶的结果,但是它不是一般情况。decltype 通常都会符合你的预期。这对于变量名来说是非常正确的。这种情况下,decltype 就像它们看起来的那样:它推导的结果就是变量声明的类型。

需要记住的

  • decltype 几乎总是与表达式的类型完全一致。
  • 对于类型为 T 的左值表达式(除了变量名),decltype 的类型为 T&
  • C++14 支持 decltype(auto),与 auto 类似,在初始化的时候推导变量的类型,但是使用 decltype 类型推导规则。

Comments