译注:
这篇是我读过有关右值引用的文章中最通俗易懂的一篇,易懂的同时,其内容也非常全面,所以就翻译了一下以便加深理解。有翻译不准的地方请留言指出。
INTRODUCTION
右值引用是C++11标准中引入的新特性,由于右值引用所解决的问题并不是很直观,所以很难在一开始就很好的理解这一特性。因此,本文不试图在一开始直接去解释右值引用是什么,而是介绍一些待解决的问题,从而演示右值引用在解决这些问题中所起到的作用。通过这种方式让你更直观、自然的理解什么是右值引用。
右值引用的应用范围至少包括以下两类问题:
1. 实现move语义
2. 完美转发
接下来分别介绍这两个问题。
首先是move语义,在介绍move语义之前,我们首先回顾一下c++中的左值和右值。严格地给出左值和右值的定义并不容易,考虑到本文主要关注点在于右值引用,我们对左值和右值给出一个相对简单的解释。
C语言中对左值和右值的原始定义是:
如果表达式e可以出现在赋值语句的左手边和右手边,则e是一个左值,如果只能出现在赋值语句的右手边,那么e是一个右值。比如:
int a = 42;
int b = 43;
//a, b均为左值,那么
a = b;
b = a;
a = a * b;
//均为合法语句
//a * b 是右值
int c = a * b;//合法,右值出现在赋值语句右手边
a * b = 42; //不合法,右值出现在了赋值语句左手边
在c++中,凭直觉按这一定义去理解左值和右值也是可以的,但是,由于c++中存在着用户自定义类型,在可修改性和可赋值性上与c语言不尽相同,这也导致此定义再适用。下面我们给出一个定义,这一定义同样不够严谨,但对于理解右值引用而言已经足够:
有一个表达式(如 i, 5, 3* 4等)指向某一内存空间,如果我们可以通过取址运算符(&)取得其指向的内存地址,那么该表达式是一个左值,否则该表达式为右值。例如:
//左值i,以下语句均合法
int i = 42;
i = 43;
int* p =&i;
int&foo();
foo() = 42;
int* p1 =&foo();
//右值
int foobar();//foobar()为右值
int j = 0;
j = foobar();//合法,右值可以出现在赋值语句右侧
int* p2 =&foobar(); //不合法,不可对右值做取址操作
j = 42; //合法,42是右值,可以出现在右侧
MOVE SEMANTICS
假设有一个类X,该类中声明了一个指向其它资源的指针,设为m_pResource。上述的资源是指一些构造、析构、复制操作较为耗时的类(如std::vector)对象。对于类X,它的拷贝赋值操作符的定义有如下形式:
X& X::operator=(const X & rhs)
{
...
//复制rhs.m_pResource指向的内容
//析构this->m_pResource指向的内容
//令this->m_pResource指向复制的内容
...
}
构造函数与赋值操作类似。然后我们来看下面的程序段:
X foo();
X x;
//...
//对x进行了一系列操作
//...
x = foo();
最后一行中
- 复制了函数foo()返回的临时变量中的资源
- 析构了x.m_pResource指向的资源,并使其指向复制的资源
- 析构临时变量,同时释放其资源。
很明显,如果能够直接交换x和临时变量的资源指针,能够更高效的得到正确的结果,由临时变量的析构函数去析构原来x指向的资源。
换句话说,当赋值语句右手边是一个右值时,我们希望X的拷贝赋值操作符是这样的:
X& X::operator=(const X & rhs)
{
...
//交换rhs.m_pResource与this->m_pResource
...
}
这一语义即为move语义。
在C++11中,针对某一特殊情况进行特殊操作这一行为可以通过重载实现:
X& X::operator=(<特殊类型> rhs)
{
...
//交换rhs.m_pResource与this->m_pResource
...
}
既然我们需要对赋值运算符进行重载,那么首先“特殊类型”必需是一个引用类型,因为出于效率的考量,我们更希望参数是以引用的形式传入的。其次,我们希望这一“特殊类型”有这样的性质:当有两个重载函数分别接受引用类型和“特殊类型”作为参数,那么,左值参数必须调用前者,而右值参数必须调用后者。
将上述的“特殊类型”替换为右值引用,即为右值引用的定义。
RVALUE REFERENCES
如果X表示一个类型,那么X&&叫做类型X的右值引用。为了便于区分,原来的引用类型X&也称作左值引用。
右值引用与左值引用的行为很类似,但有几点区别。其中最重要的一点是,当存在重载函数时,左值参数优先调用左值引用函数,右值参数优先调用右值引用函数:
//重载函数
void foo(X& x);
void foo(X&& x);
X x
X foobar();
foo(x); //调用foo(x : X&)
foo(foobar());//调用foo(x : X&&)
综上,右值引用的主旨是:
允许编译器根据参数在编译时决定是否使用左值函数或右值函数。
我们可以对任何函数进行这样的重载,但是绝大多数情况下,我们只会在拷贝赋值操作符和复制构造函数中使用右值引用重载,即为了实现move语义:
X& X::operator=(X&& rhs)
{
//Move语义:交换this和rhs的内容
return *this;
}
右值引用重载复制构造函数的实现类似。
注:
- 如果实现了foo(X&x),但未实现foo(X&& x),那么左值可以调用该函数,右值无法调用;
- 如果实现了 foo(constX& x),但未实现foo(X&&x),那么左值和右值均可调用该函数,但编译器无法区分传入的参数是左值或右值。
- 如果实现了foo(X&&x),但未实现foo(X& x) 或 foo(const X& x),那么右值可以调用该函数,左值会引发编译错误。
FORCING MOVE SEMANTICS
众所周知,在c++标准第一修正案中有这样一句话:“委员会不应该制定任何规则去阻碍程序员们搬石砸脚”。正经一点说,就是在给程序员更多的可控性和减少程序员大意犯错造成的影响这两者中,C++更倾向于前者,即使这看起来更不安全。遵循这样的准则,c++11不仅允许程序员在右值上使用move语义,在任何需要的时候,也可以在左值上使用。一个比较好的例子是标准库中的swap函数。类似的,假设X是一个类,该类重载了拷贝构造函数和拷贝赋值操作符对右值实现了move语义。
template<classT>
void swap(T& a, T& b)
{
T tmp(a);
a = b;
b = tmp;
}
X a, b;
swap(a, b);
上述程序段中没有右值,因此,swap函数中的三行均未使用move语义,但显然move语义是可以应用在这里的:一个变量作为拷贝构造或赋值运算的源操作数,且该变量后续不再使用,或仅被用于赋值操作的目的操作数。
在c++11中,标准库提供了一个函数std::move来满足我们的需求。该函数将其参数转换为右值返回,没有任何其它操作。此时,c++11标准库中的swap函数变成如下形式:
template<classT>
void swap(T& a, T&b)
{
T tmp(std::move(a));
a = std::move(b);
b = std::move(tmp);
}
上面swap函数中的3行均使用了move语义。注意如果类T未实现move语义,即拷贝构造函数和拷贝赋值操作符没有右值引用重载,那么swap函数与之前的版本运行上没有区别。
std::move是一个非常简单的函数,但我们现在暂时不去关注它的具体实现,在后面的章节再展开讨论。
类似上述swap函数,尽量多的的使用std::move函数能给我们带来很多的好处:
- 对于一些实现了move语义的类型,很多的标准库函数、操作符会使用move语义,这有可能大幅提升程序性能。一个明显的例子是就地排序,就地排序函数中最主要的操作就是交换元素,因此对于提供了move语义的类型,该函数能够获得巨大的性能提升。
- STL库经常要求模板参数类型可复制,比如用作容器元素的类型。仔细的检查一下,可以发现在很多情况下,类型只要可移动(moveable)即可以满足要求。因此,我们可以在STL库中使用一些可移动但不可复制的类型(例如unique_pointer),比如将其作为STL容器的元素等,这在以前是不被允许的。
了解了std::move之后,我们来回顾一下之前提出的问题,有关使用右值引用对拷贝构造函数和拷贝赋值操作符的重载有哪些问题。来看下面一个简单的赋值语句:
a = b;
当写出这个语句时,我们期望变量a指向的对象被b所指向对象的拷贝所代替,在这个替代的过程中,我们期望a原来指向的对象被析构。那么对于下面这一行:
a =std::move(b);
如果move语义的实现是一个简单的交换操作,那么这一操作的结果是a和b所指向的对象相互交换,并没有对象在这一过程中被析构。a原本指向的对象在最终当然会在b的生命周期结束时被析构,但前提是b在后续过程中不作为move的源操作数,因为这会使得这一对象再次被交换。因此,目前为止的拷贝赋值操作符的实现中,我们无法得知a原来指向的对象何时会被析构。
在某种意义上说,我们进入了一个不确定性析构的危险区域:对一个变量进行了赋值,但这个变量之前存储的对象还存在于某一个位置。当对象的析构函数对其对象外的空间没有影响时,这样的情况不会遇到问题,但有的析构函数则会产生这样的负面影响,比如析构函数中需要释放一个同步锁。因此,在拷贝赋值操作符的右值引用重载中,对象的析构函数中所有可能有负面影响的部分都应该被显式执行。
X& X::operator=(X&& rhs)
{
//执行一些步骤,保证此对象在之后可随时析构和赋值而不产生负面影响
//move语义
return *this;
}
IS AN RVALUE REFERENCE AN RVALUE
同上,设X是一个类,该类重载了拷贝构造函数和拷贝赋值操作符以实现move语义。对于下面的程序段:
void foo(X&& x)
{
X anotherX = x;
}
一个有趣的问题:在foo的函数体内,哪个拷贝构造函数的重载会被调用?x是一个被声明为右值引用类型的变量,一般是指向一个右值的引用,因此,认为x本身也是一个右值是一个合理的想法,也就是说应该调用X(X&& rhs),换句话说,人们很容易认为一个变量被声明为右值引用,那么它本身也是一个右值。
右值引用的设计者们选择了一个更微秒的规则:被声明为右值引用的变量,其本身可以是右值也可以是左值,区分的准则是:如果该变量是一个匿名变量,那么是右值,否则是左值。
在上面的例子中,被声明为右值引用的x有名字,所以是左值。即在foo中调用的函数是X(const X& rhs)。
下面是一个匿名右值引用变量的例子:
X&& goo();
X x = goo();//此时会调用X(X&& rhs)
设计者们使用这一方案的初衷是:让move语义应用于非匿名变量时能够符合人们的使用习惯。比如,令 x是一个X类对象的右值引用,那么对于下面的语句:
X anotherX = x;
此时,如果x是一个右值,我们将会执行move语义,此后,x所指向的变量已经被移动,但由于x的生命周期尚未结束,后续对x的使用极易出错。我们使用move语义显然应该在不会因此出错的前提之下,即被移动的变量在其内容被移动后生命周期立即结束并析构。因此有了这样的规则:如果一个变量不是匿名变量,那么他是一个左值。
接下来我们看规则的另外半句:“如果是匿名变量,那么他是一个右值”。在上面goo函数的例子中,表达式goo()指向了一个内容,这一内容在赋值操作后被移动。理论上来说,被移动的变量仍然是可能被获取的(比如一个全局变量,显然这是一个左值)。回想上一小节的内容,有时这恰好是我们的需求:我们希望能够在必要的时候强制对左值变量使用move语义,上述“匿名变量是右值”的规则能够让我们自由地控制这一特性。
这也是std::move的机制。现在去详述std::move的机制仍然有一些早,但我们对它的理解又加深了一些:std::move将其参数以引用的方式传递,不对其做任何其它操作,函数的返回值是一个右值引用。所以,表达式std::move(x)声明了一个右值引用,又因为它是匿名的,所以它是一个右值。综上,std::move“可以将其非右值参数转换为右值”,其实现的方式是“匿名”。
下面我们观察一个例子来更好地理解“是否匿名规则”的重要性。
假设有一个类Base,我们通过重载其拷贝赋值操作符和拷贝构造函数为该类实现了move语义:
Base(constBase& rhs);
Base(Base&& rhs);
然后,实现Base类的子类Derived。为了保证Derived类对象中继承自Base的部分使用move语义,我们必须重载Derived类的拷贝赋值操作符和拷贝构造函数。子类拷贝构造函数的实现与父类类似,左值版本很简单:
Derived(const Derived& rhs) : Base(rhs)
{
//Derived类中的扩展内容
}
但右值拷贝构造函数会变得微妙得多。如果对“是否匿名规则”理解不够,那么可能会实现出下面的版本:
Derived(Derived&& rhs) : Base(rhs) //!!错误,rhs是一个左值
{
//Derived类中的扩展内容
}
上述实现中,基类拷贝构造函数将的左值重载会被调用,因为非匿名变量rhs是一个左值。但我们实际的需求是希望调用基类拷贝构造函数的右值重载,所以正确的实现方式应该是:
Derived(Derived&& rhs) : Base(std::move(rhs)) // 将会调用Base(Base&& rhs)
{
//Derived类中的扩展内容
}
MOVE SEMANTICS AND COMPILER OPTIMIZATIONS
对于下面的函数定义:
X foo()
{
X x;
//... 对x进行操作 ...
return x;
}
与之前章节相同,类型X实现了move语义。如果仅从上面的实现来看,你也许会觉得这个函数中发生了一次对象值的拷贝:从变量x到函数返回的对象。出于对函数性能的考虑,你也许会对这个函数进行这样的“优化”:
X foo()
{
X x;
//... 对x进行操作 ...
return std::move(x); //强制move语义
}
然而,这样的修改只会让函数的执行变慢。原因是,现在的编译器会对原函数进行返回值优化。换句话说,编译器会直接在函数返回值的位置创建变量,而不是创建一个局部变量然后在返回时将其复制到返回值的位置。很明显,这甚至比move语义更加高效。
从上面的例子可以看出,如果你想更好的使用右值引用和move语义,首先要全面的理解它们,同时还需要考虑编译器的“特殊影响”,比如返回值优化,省略复制(copy elision)。详细内容可以参考Scott Meyer的《Effective Modern C++》。对于它们的理解非常繁琐,但这也是C++的魅力所在。自己选择的路,跪着也要走完~
PERFECT FORWARDING: THE PROBLEM
除了move语义,右值引用所解决的另一个问题是完美转发(perfect forwarding)。例如下面的工厂函数(factory function):
template<typename T, typename Arg>
shared_ptr<T> factory(Arg arg)
{
return shared_ptr<T>(new T(arg));
}
显然,这个函数的目的是将参数arg转发到T的构造函数中。对于对象arg而言,最理想的情况是,所有的操作如同不存在这个工厂函数,直接使用arg调用了T的构造函数,这就是完美转发。上面的函数完全没有达到这一要求:通过值传递参数,不仅效率低下,当构造函数是引用传参时,还容易引发bug。
最常见的做法是让外层函数通过引用传递参数:
template<typename T, typename Arg>
shared_ptr<T> factory(Arg& arg)
{
return shared_ptr<T>(new T(arg));
}
这样情况有所改进,但仍然不完美。因为这样的版本中,右值无法调用这个函数:
factory<X>(hoo());//如果hoo通过值返回,则无法调用
factory<X>(41);
通过常引用重载可以解决这个问题:
template <typename T, typename Arg>
shared_ptr<T> factory(const Arg& arg)
{
return shared_ptr<T>(new T(arg));
}
但这一版本同样面临两个问题:首先,如果这个函数不是一个参数,而是多个,那么需要重载所有参数的const/non-const引用的组合,显然不是很好的方法。其次,这样的方案还不够完美,因为它阻碍了move语义:T的构造函数接受的参数是左值,即使类T有实现move语义的拷贝构造函数,也不可能在函数factory中被调用。
右值引用则可以解决上述两个问题。通过右值引用可以实现真正的完美转发而不需要使用重载。为了更好的理解如何实现,我们首先需要了解两条关于右值引用的规则。
PERFECT FORWARDING: THE SOLUTION
第一个有关右值引用的规则与左值引用也有关系,在C++11以前的版本,不允许使用引用的引用,比如A&&会引发编译错误。而c++11中,引入了引用折叠规则(reference collapsing rules):
- A& & = A&
- A& && = A&
- A&& & = A&
- A&& && = A&&
第二个规则是:在模板函数中,如果函数形参类型是模板参数类型的右值引用类型:
template<typename T>
void foo(T&&);
那么,有特殊模板参数推导规则,应用如下规则进行推导:
1. 如果实参类型是A且为左值,那么T推导为A&,因此根据引用折叠规则,形参类型为A&。
2. 如果实参类型是A且为右值,那么T推导为A,因此形参类型是A&&。
有了以上的规则后,我们可以使用右值引用来解决完美转发问题:
template<typename T, typename Arg>
shared_ptr<T> factory(const Arg&& arg)
{
return shared_ptr<T>(new T(std::forward<Arg>(arg)));
}
其中std::forward的实现如下:
template<class S>
S&& forward(typename remove_reference<S>::type& a) noexcept
{
return static_cast<S&&>(a);
}
(第9小节会介绍关键字noexcept,现在只需要知道这一关键字告诉编译器这个函数不会抛出异常,方便编译器进行优化。)下面我们来分别考虑左值和右值调用该函数的情况,上述实现如何解决完美转发问题。
设有类型A和X,假设函数factory<A>的实参为X类型且为左值:
X x;
factory<A>(x);
根据前述模板推导规则,函数factory的模板参数Arg被推导为X&,此时,编译器会展开模板函数factory和std::forward实例如下:
shared_ptr<A> factory(X& && arg)
{
return shared_ptr<A>(new A(std::forward<X&>(arg)));
}
X& && forward(remove_reference<X&>::type& a) noexcept
{
return static_cast<X& &&>(a);
}
经过std::remove_reference和前述引用折叠规则,最终变成:
shared_ptr<A> factory(X& arg)
{
return shared_ptr<A>(new A(std::forward<X&>(arg)));
}
X& std::forward(X& a)
{
return static_cast<X&>(a);
}
这实现了对左值的完美转发:参数arg经过两次间接的左值引用被传递给A的构造函数。
接下来看X类型右值作为实参的情况:
X foo();
factory<A>(foo());
同根根据模板推导规则,函数factory的模板参数arg被推导为X,编译器展开的函数实例如下:
shared_ptr<A> factory(X&& arg)
{
return shared_ptr<A>(new A(std::forward<X>(arg)))
}
X&& forward(X& a) noexcept
{
return static_cast<X&&>(a);
}
上面例子中的两次参数传递都是通过引用,实现了完美转发,除此之外,A的构造函数接受了一个匿名右值引用参数,根据之前介绍的“是否匿名规则”,A的构造函数将会调用右值重载。意味着没有factory函数封装时,如果A的构造函数会使用move语义,那么上述实现的转发可以保留move语义的使用。
在上面的应用中std::forward的唯一作用就是保留move语义的使用,虽然这可能没有什么价值。如果不使用std::forward,那么上述的实现依然可以正常运行,但A的构造函数的实参只会是非匿名参数,即只有左值实参。换句话说,std::forward的作用是转发外层封装函数的实参是左值或右值这一信息。
如果想要了解得更深入一点,可以考虑一下这个问题:为什么std::forward函数中需要调用remove_reference?答案是,实际上完全不需要。如果在std::forward中,不使用remove_reference<S>::type&而是直接使用S&,重新推导一下上面的实现,可以发现依然能够达到完美转发,但是需要显式地指定Arg为std::forward的模板参数。remove_reference的作用即是强制我们去这样指定。
现在,我们终于可以去看一下std::move的具体实现了,再强调一次:std::move的作用是通过引用将其接受的参数返回,并将其绑定到一个右值上。具体实现如下:
template<classT>
typenameremove_reference<T>::type&&
std::move(T&& a) noexcept
{
typedef typenameremove_reference<T>::type&& RvalRef;
return static_cast<RvalRef>(a);
}
如果我们对X类型的左值调用std::move:
X x;
std::move(x);
根据之前所说的特殊模板解析规则,std::move的模板参数为X&,因此,编译器将会为我们实例化如下:
typename remove_reference<X&>::type&&
std::move(X&&& a) noexcept
{
typedef typenameremove_reference<X&>::type&& RvalRef;
retun static_cast<RvalRef>(a);
}
经过remove_reference和引用折叠后:
X&&std::move(X& a) noexcept
{
return static_cast<X&&>(a);
}
上面函数中,实参x将被绑定到一个左值引用中传递给函数,函数将其转换为一个匿名的右值会用并返回。
留下一个需要思考的问题:对右值调用std::move同样没有问题。另外,也许你已经发现除了调用std::move之外,可以直接使用
static_cast<X&&>(x)
来实现move的功能,但为了可读性,最好还是用std::move。
PS: 本文介绍的引用折叠并不完整,跳过了一些有关const, volatile修饰符的内容,如果有兴趣,可以参阅Scott Meyer的《Effective Modern C++》 。
RVALUE REFERENCES AND EXCEPTIONS
一般来说,使用C++开发一个软件,你可以决定是否花费精力去处理异常、或者是否在程序中使用异常控制。在这方面右值引用有些不同,当通过重载拷贝构造函数或拷贝赋值操作符实现move语义时,通常建议按如下方式:
- 试着让你的实现无法抛出异常。这通常非常容易,因为move语义一般只会在两个对象间交换指针或资源句柄。
- 2. 当成功保证了你的实现不会抛出异常后,再通过使用noexcept关键字将这一信息显示的传递出来。
如果没有做这两件事,你的move语义版重载很可能不会如你所愿被调用,比如下面的情况:当一个std::vector进行resize时,我们显然希望vector中的元素在被重分配时使用move语义,但如果1、2没有被同时满足,那么编译器不会使用move版本。
至于具体的原因,本文不会详细介绍,只要记住以上两个建议就足够了。如果想要深入了解,可以参阅《Effective Mordern C++》中的第14条。
THE CASE OF THE IMPLICIT MOVE
在关于右值引用问题的讨论(通常是复杂且有争议的)期间,标准委员会曾决定,移动构造函数或移动赋值操作符(即使拷贝构造函数和拷贝赋值操作符的右值引用重载),应在用户提供时由编译器去自动生成。考虑到编译器对原来的拷贝构造函数和赋值操作符就采取了这样的策略,这看起来是很自然、很合理的需求。在2010年8月,Scott Meyers在comp.lang.c++上发布了一条消息,解释了编译器自动生成的移动构造函数会严重破坏已有代码的原因。
委员会认可了这一问题的严重性,然后对自动生成拷贝构造函数和拷贝赋值操作符的条件进行了限制,使现有代码被破坏的可能性很小(仍然无法完全避免)。最后的结果在Scott Meyer的《Effective Modern C++》中的第17条中有详细介绍。
隐式移动的问题直到标准定稿时依然存在争议。讽刺的是,委员会优先考虑隐式移动的原因,仅仅是试图解决第9小节所述的右值引用和异常问题。这一问题在之后通过使用新关键字noexcept得到了更合适的解决方案。如果不是几个月前发现了noexcpet这一方法,隐式移动甚至可能永无出头之日。
以上,就是有关右值引用的全部故事了。显而易见,其收益是巨大的,但其细节则是残酷的,如果c++是你的主业,你必须理解这些细节,否则你就放弃了对于这一工具的理解,而这是你的工作中心。值得庆幸的是,如果只是考虑每天的编程工作,那么关于右值引用,你只需要记住以下三点:
1. 通过对函数进行如下形式的重载:
void foo(X& x);//左值引用重载
void foo(X&& x);//右值引用重载
你可以让编译器在编译时确定是否使用左值或右值。最主要的应用是重载类拷贝构造函数和拷贝赋值运算符以实现move语义(实现move语义也是使用右值引用的唯一目的)。当你这样使用时,要注意处理异常,并尽可能多的使用关键字noexcpet。
2. std::move将其参数转换为右值。
3. 通过std::forward可以实现完美转发,如第8小节中的工厂函数的例子。