[译]C++11中的通用引用


Universal References in C++11 – Scott Meyers

或许C++11中最重要的新特征是右值引用;右值引用是移动语义和完美转发的基础(关于右值引用,移动语义,完美转发可以参考Thomas Becker’s overview)。

在语法上,右值引用和标准引用类似,区别是使用两个&,这个函数使用了Widget的右值引用:

void f(Widget&& param);

考虑到右值引用是使用“&&”声明的,似乎可以合理地假设类型声明中出现“&&”表示一个右值引用,事实并非如此:

Widget&& var1 = someWidget;     // here, “&&” means rvalue reference

auto&& var2 = var1;             // here, “&&” does not mean rvalue reference

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

template<typename T>
void f(T&& param);               // here, “&&”does _not_ mean rvalue reference

在本文中,我描述了“&&”在类型声明中的两种含义,解释如何区分它们,并引用了新的术语以便能简洁的解释&&。区分不同的含义是非常重要的,因为如果你把所有类型声明中的“&&”当成“右值引用”,你会误读大量的C++11代码。

本质上,在类型声明中,&&有时意味着右值引用,有时意味着 既是右值引用又是左值引用。所有一些源码中的 &&实际上有“&”的含义。引用(Universal References)可能比左值引用或右值引用更灵活。例如,右值引用仅能绑定到右值,左值引用可以绑定到左值,且在某些严格情况下可以绑定到右值。“&&”声明的引用可以是左值引用或右值引用,可以绑定到任何东西。这种异常灵活的引用值得拥有自己的名字。我称它们为通用引用(Universal References)。

&&何时表示为通用引用的细节是复杂的,所以我把解释放到后面。现在,让我们关注以下经验法则,这些在日常编程中需要记住的:

  • 如果一个变量或参数的声明是 T&& 类型(其中T是被推断的类型),那么这个变量或参数是一个通用引用。

类型推断的要求限制了通用引用被发现。实际上几乎所有的通用引用是函数模版的参数。因为对于auto声明的变量的类型推断的规则是和模版相同的,所以有auto声明的也可能是一个通用引用。这些在生产代码中是不常见的,但是我将在文章中展示这些例子,因为它们比模版更简洁。在 Nitty Gritty Details section 部分,我解释了在使用typedef和decltype时也可能出现通用引用,但是在我们深入了解基本细节之前,我将继续讨论通用引用,就好像通用引用只与函数模板参数和auto声明的变量有关一样。

通用引用的形式是T&&的约束比它本身看起来更重要,但我将把它的检查推迟到稍后。现在,请简单地记住这个需求。

和所有引用一样,通用引用必须被初始化,并且通用引用的初始化决定了它是一个左值引用或右值引用:

  • 如果用一个左值初始化一个通用引用,通用引用变成一个左值引用。
  • 如果用一个右值初始化一个通用引用,通用引用变成一个右值引用。

这个信息对于区分左值和右值是有用的。这些术语很难精确定义(C++ 11标准通常根据具体情况指定表达式是左值还是右值),但是在实践中,以下内容是足够的:

  • 如果你能对一个表达式取址,这个表达式是一个左值
  • 如果一个表达式的类型是一个左值引用(T& 或 const T&),这个表达式是一个左值
  • 否则,表达式是一个右值。概念上,右值和临时对象有关,例如函数返回的对象或通过隐式类型创建的对象。大多数字面量也是右值。

再次思考文章开始时的代码:

Widget&& var1 = someWidget;     // here, “&&” means rvalue reference
auto&& var2 = var1;             // here, “&&” does not mean rvalue reference

var1 可以取址,所以var1 是一个左值,var2 的auto&&的类型声明使它成为一个通用引用,并且因为var2var1 (左值)初始化,所以var2 变成了一个右值引用。如果没有仔细阅读源码,会让你把var2当成一个右值引用;&&的出现暗示这个结论。但是var2是有左值初始化的通用引用,所以var2成为了左值引用。就像var2是这样声明的:

Widget& var2 = var1;

如上所述,如果表达式具有左值引用类型,则它是左值,思考这个例子:

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

val 是通用引用,由std::vector<int>::operator[] 调用的结果v[0] 进行初始化。这个函数返回vector中元素的左值引用。所有的左值引用是左值,并且val用这个左值进行了初始化,val变成了左值引用,即使声明看起来像右值引用。

通用引用经常使用作为函数模版的参数,思考文章开始的模版:

template<typename T>
void f(T&& param);    // “&&”does not mean rvalue reference

对f调用,

f(10);     // 10是右值

参数被字面量10初始化,因为10不能被取址,所以是右值。这意味着在f调用中,通用引用参数被一个右值初始化,所以这个参数变成了一个右值引用,即int &&。

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

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

参数被x初始化,因为x可以被取址,所以是左值。这意味着在f调用中,通用引用参数被一个左值初始化,所以这个参数变成了一个左值引用,即int &。

函数f的注释应该是很清晰的:是否参数的类型是一个左值或右值引用依赖于f被调用时传递的内容。param 是一个通用引用。

仅当类型推断发生时,“&&”是一个通用引用。没有类型推断的情况下,是不存在通用引用的。“&&”在类型声明中总是代表右值引用。因此:

template<typename T>
void f(T&& param);               // deduced parameter type ⇒ type deduction;
                                 // && ≡ universal reference
 
template<typename T>
class Widget {
    ...
    Widget(Widget&& rhs);        // fully specified parameter type ⇒ no type deduction;
    ...                          // && ≡ rvalue reference
};

template<typename T1>
class Gadget {
    ...
    template<typename T2>
    Gadget(T2&& rhs);            // deduced parameter type ⇒ type deduction;
    ...                          // && ≡ universal reference
};
 
void f(Widget&& param);          // fully specified parameter type ⇒ no type deduction;
                                 // && ≡ rvalue reference

在每个例子中,如果你看T&&(T是一个模版参数),具备类型推断,所以你正在看一个通用引用。如果你在一个具体类型(例如Widget&&)后面看到&&,你正在看一个右值引用。

引用声明的格式必须是“T&&”才会使引用变成通用引用。这是非常重要的。我们来看文章开始的声明:

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

这段代码中存在类型推断和“&&”声明的函数参数,但是参数声明的格式不是“T&&”,而是“std::vector<T>&&”。所以这个参数是一个标准的右值引用,不是一个通用引用。通用引用仅能出现在“T&&”格式!甚至一个简单的const修饰符也是可以禁用“&&”解释为通用引用:

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

有时你可以在函数模版声明中看到T&&,但是这仍不是类型推断。思考vector中的push_back函数:

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

这里的T是一个模版参数,并且push_back函数使用了T&&,然而这个参数不是通用引用!那怎么样才是呢?

如果我们看到push_back是怎么在类外声明的,答案是非常明显的。为了使代码更清晰,假定vector的Allocator参数不存在,因为它和讨论无关。push_back的声明如下:

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

如果没有包含它的std::vector<T>类,push_back就不存在。但是如果我们有一个类std::vector<T>,我们已经知道T是什么,所以没有必要推导它。

这个例子将会帮助你理解:

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

push_back的使用将使编译器实力化类std::vector<Widget>中的函数。push_back的声明是如下这样:

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

看,只要我们知道了std::vector<Widget>,push_back的参数类型已经被完整的推断出来了。所以这里是不存在类型推断操作的。

作为对此,vector中的emplace_back函数声明如下:

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

不要因为emplace back接受了数量可变的参数(如Args和args声明中的省略号所示)而忽略了必须推导每个参数的类型这一事实。函数模版参数Args是独立于类模版参数T的,所以即使我们知道类是std::vector<Widget>,不能得出emplace_back的函数参数的类型。std::vector<Widget>的emplace_back的类外声明如下(同样忽略Allocator):

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

显然,知道类是std::vector<Widget>并不能消除编译器推断参数类型的需要。所以emplace_back的参数是通用引用。

最后值得记住的一点是:表达式是左值还是右值是和表达式的类型无关的。对于类型int,int s 是int类型的左值,字面量10是int类型的右值。这是和自定义类型Widget一样的。Widget对象可以是左值(例如,Widget变量)或右值(例如,从Widget创建工厂函数返回的对象)。一个表达式的类型并不能让你知道它是一个左值还是右值。因为表达式是左值还是右值是和表达式的类型无关的,所以有右值引用类型的左值(var1),也有右值引用类型的右值(static_cast<Widget&&>(var1))。

Widget makeWidget();                       // factory function for Widget
 
Widget&& var1 = makeWidget()               // var1 is an lvalue, but
                                           // its type is rvalue reference (to Widget)
 
Widget var2 = static_cast<Widget&&>(var1); // the cast expression yields an rvalue, but
                                           // its type is rvalue reference  (to Widget)

从左值转换到右值的传统方式是使用std::move,所以var2可以这样定义:

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

我使用static_cast仅是为了使表达式的类型是一个右值引用(Widget&&)这个概念更清晰。

右值引用类型的命名变量和参数是左值(可以对它们取地址)。思考如下代码:

template<typename T>
class Widget {
    ...
    Widget(Widget&& rhs);        // rhs’s type is rvalue reference,
    ...                          // but rhs itself is an lvalue
};
 
template<typename T1>
class Gadget {
    ...
    template <typename T2>
    Gadget(T2&& rhs);            // rhs is a universal reference whose type will
    ...                          // eventually become an rvalue reference or
};                               // an lvalue reference, but rhs itself is an lvalue

在Widget的构造函数中,rhs是一个右值引用,所以我们知道它被绑定到一个右值(也就是说,一个右值被传递给它),但是rhs本身是一个左值,所以如果我们想要利用它所绑定对象的右值,我们必须将它转换回一个右值。我们这样做的动机通常是将其用作移动操作的源,这就是为什么将左值转换为右值的方法是使用std::move。类似地,Gadget的构造函数中的rhs是一个通用引用,因此它可以绑定到左值或右值,但不管它绑定到什么,rhs本身都是一个左值。如果它被绑定到一个右值而我们想要利用它被绑定对象的右值,我们必须把rhs转换回一个右值。如果它被绑定到一个左值,当然,我们不想把它当作一个右值。关于通用引用所绑定对象的左值和右值的这种模糊性,使用std::forward接受通用引用左值,并仅在它所绑定的表达式为右值时将其转换为右值。函数的名称(“forward”)表明,函数的名称(“forward”)表明,我们希望在传递(转发)给另一个函数时保留调用参数的左值性或右值性。

std::movestd::forward不是这篇文章的重点,实际上“&&”可以声明右值引用,也可以不是右值引用。为了避免丢失重点,可以在参考资料中了解move和forward。


通用引用(universal reference)的细节

问题的真正核心在于c++ 11中的某些结构会产生对引用的引用,而对引用的引用在c++中是不允许的。如果源代码显式地包含对引用的引用,则代码无效:

Widget w1;
Widget& & w2 = w1;

但是,在某些情况下,对引用的引用是由于编译期间发生的类型操作而产生的,在这种情况下,拒绝代码可能会有问题。我们从c++的初始标准(即c++ 98/ c++ 03)的经验中知道这一点。

在为通用引用的模板形参进行类型推导期间,将同一类型的左值和右值推导为具有略微不同的类型。特别是类型T的左值被推断为T&,T的右值被简单地推断为T(左值被推断为左值引用,右值没有被推断为右值引用!)。思考一个带有通用引用参数的函数模版被右值和左值调用时发生了什么:

template<typename T>
void f(T&& param);
...
int x;
...
f(10);                           // invoke f on rvalue
f(x);                            // invoke f on lvalue

在使用右值10调用f时,T被推断为int,f的实例化时:

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

然而在使用左值x调用f时,T被推断为int&,f的实例化包含了一个引用的引用:

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

由于reference-to-reference的原因,实例化代码是无效的,但是源码“f(x)”是完全有理由的这么用的。为了避免拒绝这样的代码,当存在引用的引用时,C++11执行“引用折叠”(reference collapsing)。

因为存在两种引用,所以一共有四种组合方式:

  • 左值引用到左值引用 & &
  • 左值引用到右值引用 & &&
  • 右值引用到左值引用 && &
  • 右值引用到右值引用 && &&

一共有两种折叠规则:

  • 右值引用到右值引用成为(折叠)右值引用
  • 其他所有的成为左值引用

基于规则,对于一个左值,f的实例化变成有效的代码,编译器会这样处理此次调用:

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

这表明了通用引用在经过类型推断和引用折叠之后可能成为左值引用的机制。事实是,通用引用只是引用折叠上下文中的右值引用。

当推断一个本身就是引用的变量的类型时,事情变得更加微妙。在这种情况下,类型的引用部分将被忽略。例如:

int x;
...
int&& r1 = 10;                   // r1’s type is int&&
int& r2 = x;                     // r2’s type is int&

在调用模板f时,r1和r2的类型都被认为是int。这种引用剥离行为与以下规则无关:在通用引用的类型推导过程中,左值被推导为T&类型,右值被推导为T类型。

f(r1);
f(r2);

r1和r2都被推断为int&。为什么?首先r1和r2类型的引用部分被去除(变成int),然后因为都是左值,所以在f的调用中通用引用的类型推断都是int&。

正如我所指出的,引用折叠发生在“模板实例化的上下文中”。第二种相同的情况是auto变量的定义。对于通用引用的auto变量的推断与函数模版中的通用引用参数的推断是相同的,类型T的左值被推断为T&,类型T的右值被推断为T。思考文章开始时的例子:

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

var1的类型是Widget&&,但是在初始化var2的类型推断过程中,var1的引用性是被忽略的,被当作类型Widget。因为它是一个左值,被用来初始化通用引用(var2),它的推断类型是Widget&。在var2的定义中用Widget&替换auto将产生以下无效代码:

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

在引用折叠后,变成:

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

第三种会发生引用折叠的上下文是typedef的使用。给予一个类模版:

template<typename T>
class Widget {
    typedef T& LvalueRefType;
    ...
};

和这个模版的使用:

Widget<int&> w;

实例化之后的类将包含以下代码(无效):

typedef int& & LvalueRefType;

引用折叠将其简化为以下合法代码:

typedef int& LvalueRefType;

如果我们带有引用的上下文中使用这个typedef,例如:

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

在typedef替换之后,以下无效代码生成:

void f(int& && param);

但是引用折叠发生了,所以f的最终声明是:

void f(int& param);

最后一种会发生引用折叠的上下文是decltype的使用。与模板和auto的情况一样,decltype对生成类型为T或T&的表达式执行类型推导,然后decltype应用c++ 11的引用折叠规则。遗憾的是,decltype使用的类型推导规则与模板或auto类型推导期间使用的规则不同。细节过于晦涩,无法在这里进行介绍(参考资料提供了进一步的信息),但是一个值得注意的区别是,给定一个非引用类型的命名变量,decltype会推断出类型T(非引用类型),而在相同的条件下,模板和auto推断出类型T&。另一个重要的区别是decltype的类型推导只依赖于decltype表达式;初始化表达式的类型(如果有的话)将被忽略。例如:

Widget w1, w2;
 
auto&& v1 = w1;          // v1 is an auto-based universal reference being
                         // initialized with an lvalue, so v1 becomes an
                         // lvalue reference referring to w1.

decltype(w1)&& v2 = w2;  // v2 is a decltype-based universal reference, and
                         // decltype(w1) is Widget, so v2 becomes an rvalue reference.
                         // w2 is an lvalue, and it’s not legal to initialize an
                         // rvalue reference with an lvalue, so
                         // this code does not compile.

总结

在类型声明中,“&&”可以是右值引用,也可以是通用引用 —— 一个可以解析为左值引用或右值引用的引用。通用引用总是T&&的格式,其中T是需要被推断的类型。

引用折叠机制使通用引用有时解析为左值引用,有时解析为右值引用。引用折叠发生在编译期间出现引用的引用的上下文中。这些上下文可以是模版类型推断、auto类型推断,typedef的使用,decltype表达式。


更多参考资料

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.


文章作者: Moyin
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 Moyin !
  目录