左值、右值、左值引用、右值引用、移动语义、引用折叠、完美转发
英文原文
Introduction
c语言中的左值、右值定义:An lvalue is an expression e that may appear on the left or on the right hand side of an assignment, whereas an rvalue is an expression that can only appear on the right hand side of an assignment.
int a = 42;
int b = 43;
// a and b are both l-values:
a = b; // ok
b = a; // ok
a = a * b; // ok
// a * b is an rvalue:
int c = a * b; // ok, rvalue on right hand side of assignment
a * b = 42; // error, rvalue on left hand side of assignment
在C++中,上述规则依然有一定的适用范围,但是还有更好的定义:An lvalue is an expression that refers to a memory location and allows us to take the address of that memory location via the & operator. An rvalue is an expression that is not an lvalue
// lvalues:
int i = 42;
i = 43; // ok, i is an lvalue
int* p = &i; // ok, i is an lvalue
int& foo();
foo() = 42; // ok, foo() is an lvalue
int* p1 = &foo(); // ok, foo() is an lvalue
// rvalues:
int foobar();
int j = 0;
j = foobar(); // ok, foobar() is an rvalue
int* p2 = &foobar(); // error, cannot take the address of an rvalue
j = 42; // ok, 42 is an rvalue
Move Semantics(移动语义)
在进行类的拷贝初始化或者赋值时,会相应地调用拷贝构造函数与=重载,类似的操作如下代码段。
X& X::operator=(X const & rhs)
{
// [...]
// Make a clone of what rhs.m_pResource refers to.
// Destruct the resource that m_pResource refers to.
// Attach the clone to m_pResource.
// [...]
}
假设有如下的使用场景:
X foo();
X x;
// perhaps use x in various ways
x = foo();
最后的赋值语句,包含了一些操作:使用foo()返回值初始化一个临时变量、将其赋值给x、析构临时变量。
对上述赋值语句的一个优化思路是直接将临时变量的资源给到x,避免资源的复制拷贝操作,然后释放临时变量对资源的拥有权,而这也就是移动语义的含义。在C++11中,移动语义的行为表现如下:
X& X::operator=(<mystery type> rhs)
{
// [...]
// swap this->m_pResource and rhs.m_pResource
// [...]
}
因为我们定义了赋值运算符的重载,所以mystery type本质上必须是一个引用(我们当然希望赋值表达式的右边通过引用传递)。此外,我们还希望mystery type具有以下性质:当在两个重载之间进行选择,其中一个是普通引用,另一个是mystery type时,则rvalues必须更喜欢神秘类型,而lvalues必须首选普通引用,由此提出右值引用。
Rvalue References(右值引用)
如果X是任何类型,那么X&&被称为对X的右值引用。为了更好地区分,普通引用X&现在也被称为左值引用。
右值引用的行为与普通引用X&非常相似,但有几个例外。最重要的一点是,当涉及到函数重载解决方案时,lvalues更喜欢老式的左值引用,而rvalues则更喜欢新的右值引用:
void foo(X& x); // lvalue reference overload
void foo(X&& x); // rvalue reference overload
X x;
X foobar();
foo(x); // argument is lvalue: calls foo(X&)
foo(foobar()); // argument is rvalue: calls foo(X&&)
由此可以看出,根据传入参数的左值右值区分可以调用不同版本的重载函数,属于编译时行为。虽然这可以用于任何函数,但是为了移动语义的实现,最好只用在拷贝构造函数和赋值运算符上。
X& X::operator=(X const & rhs); // classical implementation
X& X::operator=(X&& rhs)
{
// Move semantics: exchange content between this and rhs
return *this;
}
- 实现了
void foo(X&);、未实现void foo(X&&);,foo只能接受左值,不能接受右值 - 实现了
void foo(X const &);、未实现void foo(X&&);,foo能接受左值或接受右值,但是无法区分接受的到底是左值还是右值 - 实现了
void foo(X&&);、未实现void foo(X&&);与void foo(X const &);,foo只能接受右值,不能接受左值
Forcing Move Semantics(强制移动语义)
C++11不仅允许在右值上使用移动语义,而且可以根据自己的判断在左值上使用。std库函数swap就是一个很好的例子。如前所述,假设X是一个类,我们重载了拷贝构造函数和赋值运算符,以实现右值的移动语义。
template<class T>
void swap(T& a, T& b)
{
T tmp(a);
a = b;
b = tmp;
}
X a, b;
swap(a, b);
上面的swap实现完全不涉及右值,也没有移动语义,离优化目标相去甚远。在C++11中,使用std::move实现的版本如下:
template<class T>
void swap(T& a, T& b)
{
T tmp(std::move(a));
a = std::move(b);
b = std::move(tmp);
}
X a, b;
swap(a, b);
此时swap中每一行都使用了移动语义。资源只有两份,存放的地址也没有变化,变化的只是指向他们的对象(指针)。本质上是资源的拥有权的转让,而不进行资源的复制拷贝操作。
std::move做了啥?就是将传入变量变为右值,然后就能调用相应的以右值为参数的拷贝构造函数与赋值运算符,他们的实现就是转移资源,而不进行资源的复制拷贝。使用std::move的优点如下:
-
For those types that implement move semantics, many standard algorithms and operations will use move semantics and thus experience a potentially significant performance gain. An important example is inplace sorting: inplace sorting algorithms do hardly anything else but swap elements, and this swapping will now take advantage of move semantics for all types that provide it.
-
The STL often requires copyability of certain types, e.g., types that can be used as container elements. Upon close inspection, it turns out that in many cases, moveability is enough. Therefore, we can now use types that are moveable but not copyable (unique_pointer comes to mind) in many places where previously, they were not allowed. For example, these types can now be used as STL container elements.
有了std::move,再回头看之前右值版本的赋值运算符实现有啥问题!
对于一个简单的移动语义操作a = std::move(b);,在某种意义上,我们已经进入了非确定性破坏的地狱:旧的对象由b所拥有,而旧的对象的销毁有时会对外部有破坏,因此,具有副作用的对象破坏的任何部分都应在复制赋值运算符的右值引用重载中明确执行:(这里有点看不懂)
X& X::operator=(X&& rhs)
{
// Perform a cleanup that takes care of at least those parts of the
// destructor that have side effects. Be sure to leave the object
// in a destructible and assignable state.
// Move semantics: exchange content between this and rhs
return *this;
}
Is an Rvalue Reference an Rvalue?(右值引用是右值吗?)
总结:对于声明为右值引用的值,有名字就是左值,没名字就是右值
//举例
void foo(X&& x)
{
X anotherX = x; //x是左值,calls X(X const & rhs)
// ...
}
X&& goo();
X x = goo(); // calls X(X&& rhs) because the thing on
// the right hand side has no name
这样设计的原理:允许移动语义默认应用于有名称的事物,如
X anotherX = x;
// x is still in scope!
这将是危险的混乱和容易出错的,因为我们刚刚移动的东西,也就是我们刚刚窃取的东西,仍然可以在后续的代码行中访问。但移动语义的要点是只在“不重要”的地方应用它,从某种意义上说,我们移动的对象在移动后立即死亡并消失。因此,规则是,“如果它有名字,那么它就是左值。”
回看std::move(x),其返回值声明为右值引用,因此,std::move(x)是右值,因为没有名字。
一个可能出错的例子:
Base(Base const & rhs); // non-move semantics
Base(Base&& rhs); // move semantics
Derived(Derived&& rhs)
: Base(rhs) // wrong: rhs is an lvalue,会调用Base(Base const & rhs);效率更低
{
// Derived-specific stuff
}
Derived(Derived&& rhs)
: Base(std::move(rhs)) // good, calls Base(Base&& rhs)
{
// Derived-specific stuff
}
Move Semantics and Compiler Optimizations(移动语义和编译器优化)
考虑如下函数:
X foo()
{
X x;
// perhaps do something to x
return x;
}
如果我们实现了X类的转移构造函数和移动语义的赋值运算符,也许我们会进一步这样写:
X foo()
{
X x;
// perhaps do something to x
return std::move(x); // making it worse!
}
不幸的是,这会使事情变得更糟而不是更好。任何现代编译器都会对原始函数定义应用返回值优化。换句话说,编译器将直接在foo返回值的位置构造X对象,而不是在本地构造X然后将其复制出去。很明显,这甚至比移动语义更好。
Perfect Forwarding: The Problem(完美转发:问题)
考虑如下的工厂函数模板:
template<typename T, typename Arg>
shared_ptr<T> factory(Arg arg)
{
return shared_ptr<T>(new T(arg));
}
显然,这里的目的是将参数arg从工厂函数转发到T的构造函数。理想情况下,就arg而言,一切都应该表现得就像工厂函数不存在一样,构造函数直接在客户端代码中调用:完美转发。上面的代码在这一点上失败得很惨:它引入了一个额外的值调用,如果构造函数通过引用获取其参数,则这一点尤其糟糕。最常见的解决方案(例如boost::bind)是让外部函数通过引用获取参数:
template<typename T, typename Arg>
shared_ptr<T> factory(Arg& arg)
{
return shared_ptr<T>(new T(arg));
}
这还行,但是不完美,因为无法接受右值参数:
factory<X>(hoo()); // error if hoo returns by value
factory<X>(41); // error
将参数改为常引用,可以接受右值:
template<typename T, typename Arg>
shared_ptr<T> factory(Arg const & arg)
{
return shared_ptr<T>(new T(arg));
}
这种方法有两个问题。首先,如果工厂不止一个参数,而是有几个参数,那么您必须为各种参数的所有非常量和常量引用组合提供重载。因此,该解决方案对具有多个参数的函数的扩展性极差。其次,这种转发并不完美,因为它屏蔽了移动语义:工厂主体中T的构造函数的参数是左值。因此,即使没有包装功能,移动语义也永远不会发生。
右值引用可以在不重载函数的情况下解决上述两个缺点
Perfect Forwarding: The Solution(完美转发:方案)
在C++11中,有如下引用折叠规则:
A& & becomes A&A& && becomes A&A&& & becomes A&A&& && becomes A&&
另外,对于通过模板参数的右值引用获取参数的函数模板(template<typename T> void foo(T&&);),有一个特殊的模板参数推导规则:
- 当对类型A的
左值调用foo时,T解析为A&,因此,通过上面的引用折叠规则,参数类型实际上变为A&。 - 当对A类型的
右值调用foo时,T解析为A,因此参数类型变为A&&。
基于这两个规则就可以解决上一节结尾的两个问题。
//新的实现如下
template<typename T, typename Arg>
shared_ptr<T> factory(Arg&& arg)
{
return shared_ptr<T>(new T(std::forward<Arg>(arg)));
}
template<class S>
S&& forward(typename remove_reference<S>::type& a) noexcept
{
return static_cast<S&&>(a);
}
在分别传入左值、右值时,结果如下:
X x;
factory<A>(x);
//左值
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);
}
//经过引用折叠后
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 foo();
factory<A>(foo());
//右值
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);
}
浙公网安备 33010602011771号