(译)Universal References in C++11—Scott Meyers

Universal References in C++11

原文链接:http://isocpp.org/blog/2012/11/universal-references-in-c11-scott-meyers

T&& 并不总是意味着 “Rvalue Reference”

by Scott Meyers

 

Related materials:

 

可能rvalue references是C++11里面最重要的新特性了; 移动语义和完美转发都建立在它的基础之上。(如果你不熟悉rvalue references的基础, 移动语义, 或是完美转发, 再继续阅读本文之前你可能需要先看看 Thomas Becker’s overview 。)

从语法上来看,声明 rvalue references 看起来和声明"普通"的references(现在被称为 lvalue references)很像,只不过你需要用&&而不是&。下面这个函数需要一个类型为rvalue-reference-to-Widget的参数:

  1. void f(Widget&& param);

鉴于rvalue reference是通过 “&&”来声明的,似乎我们可以认定类型声明中如果有 “&&”,它就表示这是个rvalue reference。但事实并非如此:

  1. Widget&& var1 = someWidget; // 这里, “&&” 表示rvalue reference
  2.  
  3. auto&& var2 = var1; // 这里, “&&” 代表 rvalue reference
  4.  
  5. template<typename T>
  6. void f(std::vector<T>&& param); // 这里, “&&” 表示rvalue reference
  7.  
  8. template<typename T>
  9. void f(T&& param);               // 这里, “&&” 代表 rvalue reference

在本文当中,我会对类型声明中 “&&” 可能具有的两种含义进行阐释,讲解如何区分它们,并且会引入一个新术语以便在交流的时候清楚的表明在当前说的“&&”是哪种含义。正确的区分这两种含义非常重要,因为如果你看到“&&”就以为是“rvalue reference”的话,你可是会误读很多c++11代码的。

这个问题的本质实际上是,类型声明当中的“&&”有的时候意味着rvalue reference,但有的时候意味着rvalue reference 或者 lvalue reference。因此,源代码当中出现的 “&&” 有可能是 “&” 的意思,即是说,语法上看着像rvalue reference (“&&”),但实际上却代表着一个lvalue reference (“&”)。在这种情况下,此种引用比lvalue references 或者 rvalue references都要来的更灵活。Rvalue references只能绑定到右值上,lvalue references除了可以绑定到左值上,在某些条件下还可以绑定到右值上。[1]  相比之下,声明中带 “&&” 的,可能是lvalue references 或者 rvalue references 的引用可以绑定到任何东西上。这种引用灵活也忒灵活了,值得单独给它们起个名字。我称它们为 universal references

到底 “&&” 什么时候才意味着一个universal reference呢(即,代码当中的“&&”实际上可能是 “&”),具体细节还挺棘手的,所以这些细节我推迟到后面再讲。现在,我们还是先集中精力研究下下面的经验原则,因为你在日常的编程工作当中需要牢记它:

如果一个变量或者参数被声明为T&&,其中T是被推导的类型,那这个变量或者参数就是一个universal reference

“T需要是一个被推导类型”这个要求限制了universal references的出现范围。在实践当中,几乎所有的universal references都是函数模板的参数。因为auto声明的变量的类型推导规则本质上和模板是一样的,所以使用auto的时候你也可能得到一个universal references。产品代码中这可能不常见,但我在本文里给出了一些例子,因为由auto声明的universal reference看着没有模板的那么啰嗦。在本文的Nitty Gritty Details section当中,我会讲解说明使用typedef和decltype的时候也可能会出现universal references,但在我们讲解这些繁琐的细节之前,我们可以暂时认为universal references只会出现在模板参数和由auto声明的变量当中。

一个universal reference必须具有形如T&&,这个约束比它看起来要重要得多,但是我们稍后再对这一点进行详细的研究。现在,就先把这个约束记在脑子里吧。

和所有的引用一样,你必须对universal references进行初始化,而且正是universal reference的initializer决定了它到底代表的是lvalue reference 还是 rvalue reference:

  • 如果用来初始化universal reference的表达式是一个左值,那么universal reference就变成lvalue reference。
  • 如果用来初始化universal reference的表达式是一个右值,那么universal reference就变成rvalue reference。

只有在你能区分左值和右值的前提下,这个信息才有用。想要对这些术语进行精确定义是一件很难的事(c++11标准基本上是通过举例来说明一个表达式是否是一个lvalue还是rvalue的),但实践当中,下面的定义就足够了

  • 如果你可以对一个表达式取址,那这个表达式就是个lvalue。
  • 如果一个表达式的类型是一个lvalue reference (例如, T&const T&, 等.),那这个表达式就是一个lvalue。
  • 其它情况,这个表达式就是一个rvalue。从概念上来讲(通常实际上也是这样),rvalue对应于临时对象,例如函数返回值或者通过隐式类型转换得到的对象,大部分字面值(e.g., 10 and 5.3)也是rvalues。

回头再来看下本文开头的代码:

  1. Widget&& var1 = someWidget;
  2. auto&& var2 = var1;

你可以对var1取址,所以var1是一个lvalue。var2的类型声明是auto&&,所以它就是一个universal reference,并且因为它会被var1 (一个lvalue)初始化,进而,var2就变成了一个lvalue reference。如果草草略过这段代码,你可能就会以为var2是rvalue reference; 类型声明当中的 “&&” 会误导你得出这个结论。但实际上,当一个universal reference being被lvalue初始化的时候,var2就变成了lvalue reference。就好像我们是这么声明var2的:

  1. Widget& var2 = var1;

正如前面所说的,如果一个表达式的类型是lvalue reference,它就是lvalue。我们来看看下面这个例子:

  1. std::vector<int> v;
  2. ...
  3. auto&& val = v[0]; // val becomes an lvalue reference (see below)

val是universal reference,并且被v[0]初始化,即是说用调用std::vector<int>::operator[]的结果来初始化。这个函数返回vector元素的lvalue reference。[2]  因为所有的lvalue references都是lvalues,并且这个lvalue被用来初始化valval就变成了lvalue reference,即使它的类型声明看起啦像是rvalue reference。

我前面说universal reference在函数模板的参数中最常见。我们再来看看本文开头时给出的模板:

  1. template<typename T>
  2. void f(T&& param); // “&&” 可能 代表rvalue reference

Given this call to f,

  1. f(10); // 10 is an rvalue

param 被字面值10初始化,因为你不能对字面值取址,所以10是一个rvalue。这就意味着上面对f的调用当中,universal reference param被一个rvalue初始化,所以 param 就变成了rvalue reference – 具体来讲,就是 int&&

另一方面,如果像这样调用f,

  1. int x = 10;
  2. f(x); // x is an lvalue

param 被变量 x 初始化,因为你能对 x 取址,所以x是个lvalue。这就是说,这里对f的调用,universal reference param被一个lvalue初始化,因此param就变成lvalue reference – 准确的说,就是int&。

现在f旁边的注释看起来就很清晰明了了吧: param的类型到底是lvalue reference 还是 rvalue reference 取决于调用 f 时候传入的参数。有时候 param 会变成 lvalue reference,有时候会变成 rvalue reference。param 实质上就是一个universal reference

还记得只有在发生类型推导的时候 “&&” 才代表 universal reference 吗。如果没有类型推导,就没有universal reference。这种时候,类型声明当中的“&&”总是代表着rvalue reference。因此:

  1. template<typename T>
  2. void f(T&& param); // deduced parameter type ⇒ type deduction;
  3. // && ≡ universal reference
  4.  
  5. template<typename T>
  6. class Widget {
  7. ...
  8. Widget(Widget&& rhs); // fully specified parameter type ⇒ no type deduction;
  9. ... // && ≡ rvalue reference
  10. };
  11.  
  12. template<typename T1>
  13. class Gadget {
  14. ...
  15. template<typename T2>
  16. Gadget(T2&& rhs); // deduced parameter type ⇒ type deduction;
  17. ... // && ≡ universal reference
  18. };
  19.  
  20. void f(Widget&& param); // fully specified parameter type ⇒ no type deduction;
  21. // && ≡ rvalue reference

上面的例子没什么好说的。在每一个例子当中,如果你看到T&& (其中T是模板参数),那这里就有类型推导,那T&&就是universal reference。如果你看到 “&&” 跟在一个具体的类型名后面 (e.g., Widget&&),那它就是个rvalue reference。

我前面说过声明引用的时候必须用 “T&&”的形式才能获得一个universal reference。这个一个很重要的信息。再看看这段代码:

  1. template<typename T>
  2. void f(std::vector<T>&& param); // “&&” means rvalue reference

这里,我们同时有类型推导和一个带“&&”的参数,但是参数确不具有 “T&&” 的形式,而是 “std::vector<t>&&”。其结果就是,参数就只是一个普通的rvalue reference,而不是universal reference。 Universal references只以 “T&&”的形式出现!即便是仅仅加一个const限定符都会使得“&&”不再被解释为universal reference:

  1. template<typename T>
  2. void f(const T&& param); // “&&” means rvalue reference

现在, “T&&” 正是universal reference所需要的形式。这不是说你的模板参数非得要用T:

  1. template<typename MyTemplateParamType>
  2. void f(MyTemplateParamType&& param); // “&&” means universal reference

有的时候你可以在函数模板的声明中看到T&&,但却没有发生类型推导。来看下std::vector push_back 函数:[3]

  1. template <class T, class Allocator = allocator<T> >
  2. class vector {
  3. public:
  4. ...
  5. void push_back(T&& x); // fully specified parameter type ⇒ no type deduction;
  6. ... // && ≡ rvalue reference
  7. };

这里, T 是模板参数, 并且push_back接受一个T&&, 但是这个参数却不是universal reference!  这怎么可能?

如果我们看看push_back在类外部是如何声明的,这个问题的答案就很清楚了。我会假装std::vectorAllocator 参数不存在,因为它和我们的讨论无关。我们来看看没Allocator参数的std::vector::push_back:

  1. template <class T>
  2. void vector<T>::push_back(T&& x);

push_back 不能离开std::vector<T>这个类而独立存在。但如果我们有了一个叫做std::vector<T>的类,我们就已经知道了T是什么东西,那就没必要推导T。

举个例子可能会更好。如果我这么写

  1. Widget makeWidget(); // factory function for Widget
  2. std::vector<Widget> vw;
  3. ...
  4. Widget w;
  5. vw.push_back(makeWidget()); // create Widget from factory, add it to vw

代码中对 push_back 的使用会让编译器实例化类 std::vector<Widget> 相应的函数。这个 push_back 的声明看起来像这样:

  1. void std::vector<Widget>::push_back(Widget&& x);

看到了没? 一旦我们知道了类是 std::vector<Widget>,push_back的参数类型就完全确定了: 就是Widget&&。这里完全不需要进行任何的类型推导。

对比下 std::vector 的 emplace_back,它看起来是这样的:

  1. template <class T, class Allocator = allocator<T> >
  2. class vector {
  3. public:
  4. ...
  5. template <class... Args>
  6. void emplace_back(Args&&... args); // deduced parameter types ⇒ type deduction;
  7. ... // && ≡ universal references
  8. };

emplace_back 看起来需要多个参数(Args和args的声明当中都有...),但重点是每一个参数的类型都需要进行推导。函数的模板参数 Args 和类的模板参数T无关,所以即使我知道这个类具体是什么,比如说,std::vector<Widget>,但我们还是不知道emplace_back的参数类型是什么。我们看下在类std::vector<Widget>外面声明的 emplace_back 会更清楚的表明这一点 (我会继续忽略 Allocator 参数):

  1. template<class... Args>
  2. void std::vector<Widget>::emplace_back(Args&&... args);

很明显,就算你知道class的类型是 std::vector<Widget> ,给emplace_back传递参数时候,编译器依然会去推导参数类型。其结果就是,std::vector::emplace_back的参数是universal reference,而std::vector::push_back 的参数是个rvalue reference。

最后还有一点需要牢记: 一个表达式的lvalueness 或者 rvalueness 和它的类型无关。来看下 int。可以有lvalue的int (e.g., 声明为int的变量),还有rvalue的int (e.g., 字面值10)。用户定义类型Widget等等也是一样的。一个Widget对象可以是lvalue(e.g., a Widget 变量) 或者是rvalue (e.g., 创建Widget的工程函数的返回值)。表达式的类型不会告诉你它到底是个lvalue还是rvalue。
因为表达式的 lvalueness 或 rvalueness 独立于它的类型,我们就可以有一个 lvalue,但它的类型确是 rvalue reference,也可以有一个 rvalue reference 类型的 rvalue :

  1. Widget makeWidget(); // factory function for Widget
  2.  
  3. Widget&& var1 = makeWidget() // var1 is an lvalue, but
  4. // its type is rvalue reference (to Widget)
  5.  
  6. Widget var2 = static_cast<Widget&&>(var1); // the cast expression yields an rvalue, but
  7. // its type is rvalue reference (to Widget)

把 lvalues (例如 var1) 转换成 rvalues 比较常规的方式是对它们调用std::move,所以 var2 可以像这样定义:

  1. Widget var2 = std::move(var1); // equivalent to above

我最初的代码里使用 static_cast 仅仅是为了显示的说明这个表达式的类型是个rvalue reference (Widget&&)。

rvalue reference 类型的具名变量和参数是 lvalues。(你可以对他们取地址。) 我们再来看下前面提到的 Widget 和 Gadget 模板:

  1. template<typename T>
  2. class Widget {
  3. ...
  4. Widget(Widget&& rhs); // rhs’s type is rvalue reference,
  5. ... // but rhs itself is an lvalue
  6. };
  7.  
  8. template<typename T1>
  9. class Gadget {
  10. ...
  11. template <typename T2>
  12. Gadget(T2&& rhs); // rhs is a universal reference whose type will
  13. ... // eventually become an rvalue reference or
  14. }; // an lvalue reference, but rhs itself is an lvalue

在 Widget 的构造函数当中, rhs 是一个rvalue reference,所以我们知道它被绑定到了一个rvalue上面(i.e., 传递了一个rvalue给它), 但是 rhs 本身是一个 lvalue,所以,当我们想要用到这个被绑定在 rhs 上的rvalue 的 rvalueness 的时候,我们就需要把 rhs 转换回一个rvalue。之所以我们想要这么做,是因为我们想将它作为一个移动操作的source,这就是为什么我们用 std::move 将它转换回一个 rvalue。类似地Gadget 构造函数当中的rhs 是一个 universal reference,,所以它可能绑定到一个 lvalue 或者 rvalue 上,但是无论它被绑定到什么东西上,rhs 本身还是一个 lvalue。如果它被绑定到一个 rvalue 并且我们想利用这个rvalue 的 rvalueness, 我们就要重新将 rhs 转换回一个rvalue。如果它被绑定到一个lvalue上,当然我们就不想把它当做 rvalue。一个绑定到universal reference上的对象可能具有 lvalueness 或者 rvalueness,正是因为有这种二义性,所以催生了std::forward: 如果一个本身是 lvalue 的 universal reference 如果绑定在了一个 rvalue 上面,就把它重新转换为rvalue。函数的名字 (“forward”) 的意思就是,我们希望在传递参数的时候,可以保存参数原来的lvalueness 或 rvalueness,即是说把参数转发给另一个函数。

但 std::move 和 std::forward 并不是本文的重点。重点是,类型声明有 “&&” 不一定就是声明了一个rvalue reference。为了防止跑题,如果你想要进一步了解 std::move 和 std::forward 的话,你可以看看 Further Information section 这一节。 

问题的本质

这个问题的核心是,C++11当中的一些构造会弄出来引用的引用,而C++不允许出现引用的引用。如果代码当中显示的出现了一个引用的引用,那代码就是不合法的:

  1. Widget w1;
  2. ...
  3. Widget& & w2 = w1; // error! No such thing as “reference to reference”

但是,有些情况下,在你对类型进行操作的时候可能会搞出来引用的引用,编译器如果对这种情况报错是不对的。我们从C++98/C++03标准的时候就知道这件事了。

在对一个 universal reference 的模板参数进行类型推导时候,同一个类型的 lvalues 和 rvalues 被推导为稍微有些不同的类型。具体来说,类型T的lvalues被推导为T&&(i.e., lvalue reference to T),而类型T的 rvalues 被推导为 T。(注意,虽然 lvalue 会被推导为lvalue reference,但 rvalues 却不会被推导为 rvalue references!) 我们来看下分别用rvalue和lvalue来调用一个接受universal reference的模板函数时会发生什么:

  1. template<typename T>
  2. void f(T&& param);
  3.  
  4. ...
  5.  
  6. int x;
  7.  
  8. ...
  9.  
  10. f(10); // invoke f on rvalue
  11. f(x); // invoke f on lvalue

当用rvalue 10调用 f 的时候, T 被推导为 int,实例化的 f 看起来像这样:

  1. void f(int&& param); // f instantiated from rvalue

这里一切都OK。但是当我们用lvalue x 来调用 f 的时候,T 被推导为int&,而实例化的 f 就包含了一个引用的引用:

  1. void f(int& && param); // initial instantiation of f with lvalue

因为这里出现了引用的引用,这实例化的代码乍一看好像不合法,但是像– “f(x)” –这么写代码是完全合理的。为了避免编译器对这个代码报错,C++11引入了一个叫做“引用折叠”(reference collapsing)的规则来处理某些像模板实例化这种情况下带来的"引用的引用"的问题。

因为有两种类型的引用 (lvalue references 和 rvalue references),那"引用的引用"就有四种可能的组合: lvalue reference to lvalue reference, lvalue reference to rvalue reference, rvalue reference to lvalue reference, 以及 rvalue reference to rvalue reference。引用折叠只有两条规则:

  • 一个 rvalue reference to an rvalue reference 会变成 (“折叠为”) 一个 rvalue reference.
  • 所有其他种类的"引用的引用" (i.e., 组合当中含有lvalue reference) 都会折叠为 lvalue reference.

在用lvalue实例化 f 时,应用这两条规则,会生成下面的合法代码,编译器就是这样处理这个函数调用的:

  1. void f(int& param); // instantiation of f with lvalue after reference collapsing

上面的内容精确的说明了一个 universal reference 是如何在经过类型推导和引用折叠之后,可以变为一个 lvalue reference的。实际上,universal reference 其实只是一个身处于引用折叠背景下的rvalue reference。

当一个变量本身的类型是引用类型的时候,这里就有点难搞了。这种情况下,类型当中所带的引用就被忽略了。例如:

  1. int x;
  2.  
  3. ...
  4.  
  5. int&& r1 = 10; // r1’s type is int&&
  6.  
  7. int& r2 = x; // r2’s type is int&

在调用模板函数 f 的时候 r1 和 r2 的类型都被当做 int。这个扒掉引用的行为,和"universal references 在类型推导期间, lvalues 被推导为 T& ,rvalues 被推导为 T" 这条规则无关。所以,这么调用模板函数的时候:

  1. f(r1);
  2.  
  3. f(r2);

r1 和 r2 的类型都被推导为 int&。这是为啥呢? 首先,r1r2 的引用部分被扒掉了(留下的只是 int),然后,因为它们都是 lvalues 所以当调用 f,对 universal reference 参数进行类型推导的时候,得到的类型都是 int&

我前面已经说过,引用折叠只发生在“像是模板实例化这样的场景当中”。 声明auto变量是另一个这样的场景。推导一个universal reference的 auto 变量的类型,在本质上和推导universal reference的函数模板参数是一样的,所以类型 T 的lvalue被推导为 T&,类型 T 的rvalue被推导为 T。我们再来看一下本文开头的实例代码:

  1. Widget&& var1 = someWidget; // var1 is of type Widget&& (no use of auto here)
  2.  
  3. auto&& var2 = var1; // var2 is of type Widget& (see below)

var1 的类型是 Widget&&,但是它的 reference-ness 在推导 var2 类型的时候被忽略了; var1 这时候就被当做 Widget。因为它是个lvalue,所以初始化一个 universal reference (var2)的时候,var1 的类型就被推导成 Widget&。在 var2 的定义当中将 auto 替换成 Widget& 会生成下面的非法代码:

  1. Widget& && var2 = var1; // note reference-to-reference

而在引用折叠之后,就变成了

  1. Widget& var2 = var1; // var2 is of type Widget&

还有第三种发生引用折叠的场景,就是形成和使用 typedef 的时候。看一下这样一个类模板,

  1. template<typename T>
  2. class Widget {
  3. typedef T& LvalueRefType;
  4. ...
  5. };

然后这么使用这个模板,

  1. Widget<int&> w;

实例化的类当中就会包含一个非法的typedef:

  1. typedef int& & LvalueRefType;

引用折叠会把代码转化为合法的:

  1. typedef int& LvalueRefType;

如果我们接下来使用 typedef 时候又加上了引用,

  1. void f(Widget<int&>::LvalueRefType&& param);

在对 typedef 扩展之后会产生非法代码,

  1. void f(int& && param);

但引用折叠这时候又插了一脚进来,所以最终的声明会是这样:

  1. void f(int& param);

最后还有一种场景会有引用折叠发生,就是使用 decltype。和模板和 auto 一样,decltype 对表达式进行类型推导时候可能会返回 T 或者 T&,然后 decltype 会应用 C++11 的引用折叠规则。好吧, decltype 的类型推导规则其实和模板或者 auto 的类型推导不一样。这里的细节过于晦涩,所以就放在 Further Information section 里讲解,但是我们需要注意这样一个区别,即 decltype 对一个具名的、非引用类型的变量,会推导为类型 T (i.e., 一个非引用类型),在相同条件下,模板和 auto 却会推导出 T&。还有一个重要的区别就是 decltype 进行类型推导只依赖于 decltype 的表达式; 用来对变量进行初始化的表达式的类型(如果有的话)会被忽略。因此:

  1. Widget w1, w2;
  2.  
  3. auto&& v1 = w1; // v1 is an auto-based universal reference being
  4. // initialized with an lvalue, so v1 becomes an
  5. // lvalue reference referring to w1.
  6.  
  7. decltype(w1)&& v2 = w2; // v2 is a decltype-based universal reference, and
  8. // decltype(w1) is Widget, so v2 becomes an rvalue reference.
  9. // w2 is an lvalue, and it’s not legal to initialize an
  10. // rvalue reference with an lvalue, so
  11. // this code does not compile.

总结

在类型声明当中, “&&” 要不就是一个 rvalue reference ,要不就是一个 universal reference – 一种可以解析为lvalue reference或者rvalue reference的引用。对于某个被推导的类型T,universal references 总是以 T&& 的形式出现。

引用折叠是 会让 universal references (其实就是一个处于引用折叠背景下的rvalue references ) 有时解析为 lvalue references 有时解析为 rvalue references 的根本机制。引用折叠只会在一些特定的可能会产生"引用的引用"场景下生效。 这些场景包括模板类型推导,auto 类型推导, typedef 的形成和使用,以及 decltype 表达式。

Acknowledgments

Draft versions of this article were reviewed by Cassio Neri, Michal Mocny, Howard Hinnant, Andrei Alexandrescu, Stephan T. Lavavej, Roger Orr, Chris Oldwood, Jonathan Wakely, and Anthony Williams.  Their comments contributed to substantial improvements in the content of the article as well as in its presentation.

Notes

[1] I discuss rvalues and their counterpart, lvalues, later in this article. The restriction on lvalue references binding to rvalues is that such binding is permitted only when the lvalue reference is declared as a reference-to-const, i.e., a const T&.

[2] I’m ignoring the possibility of bounds violations. They yield undefined behavior.

[3] std::vector::push_back is overloaded. The version shown is the only one that interests us in this article.

Further Information

C++11, Wikipedia.

Overview of the New C++ (C++11), Scott Meyers, Artima Press, last updated January 2012.

C++ Rvalue References Explained, Thomas Becker, last updated September 2011.

decltype, Wikipedia.

“A Note About decltype,” Andrew Koenig, Dr. Dobb’s, 27 July 2011.

posted on 2014-08-05 21:53  DamnnnSure  阅读(1925)  评论(0编辑  收藏  举报

导航