右值引用
右值引用(rvalue references)是一种新的用于绑定右值的引用类型。
那么什么是右值?
右值通常是编译器生成的用于表达式计算的临时变量或常量。目前来说,我们还不能安全地使用引用变量来绑定右值。
从编译原理上讲,右值是只存在于表达式计算时的未命名值。
下面这一表达式产生了一个右值:
x+(y*z); // A C++ expression that produces a temporary
对于上面的表达式,C++编译器会生成一个临时变量(右值)来存储y*z的结果,然后将其加上x。理论上,这个生成的临时变量(右值)会在整个表达式处理完成后被抛弃。
下面是使用C++定义右值引用的一个例子:
std::string&& rrstr; //C++11 rvalue reference variable
之前C++的引用变量:
std::string& ref;
现在有了新的名字,叫做左值引用。
现在,右值引用在C++中无处不在,并深刻影响着变量的生命周期。想要理解这一影响,就需要了解C++的移动语义(move semantics)。
移动语义(move semantics)
首先,我们定义一个对象的状态是其非静态成员变量的值的集合。
了解移动语义(move semantics)之前,我们需要先了解复制语义(copy semantics)。复制语义(copy semantics)会在不修改源对象的情况下,将目标对象的状态设置为和源对象一样。比如,复制字符串变量s1到s2的结果是两个完全独立的拥有相同状态的字符串。
然而,现实中的很多场景,我们并不是在复制对象 ,而是在移动对象。比如,当我们付房租时,房东会将钱从我们的银行账户取走,这就是在进行对象的移动。还有,我们从手机中取出SIM卡,然后将SIM卡安装在一部新的手机中,这也是在移动。我们经常使用的电脑上的复制粘贴功能实际上也是移动操作。
复制和移动不仅仅是概念上的不同,在实践上,移动操作通常要比复制操作快得多:移动操作只是将一个已经创建好得资源移动到新的位置,而复制操作则会创建一个新的资源,然后复制旧资源的状态。对于返回值为值类型的函数来说,移动操作的提升尤为明显:
string func()
{
string s;
//do something with s
return s;
}
string mystr=func();
对于上面的代码,在func()函数返回时,C++编译器会在调用它函数的栈内存上生成一个临时对象复制对象s,接着对象s被销毁掉,生成的临时对象被作为参数复制构造mystr对象,构造mystr对象完成后,临时对象也被销毁掉。可以看出,整个过程包含了不少复制和销毁操作,如果使用移动操作,可以减少这类不必要的复制和销毁操作。
移动一个字符串对象几乎是没有代价的,只需要将源字符串对象的数据成员变量的值赋给目标字符串对象的数据成员变量即可。而复制一个字符串对象需要动态分配内存,然后从源字符串对象复制每一个字符。
用于移动操作的特殊成员函数
C++11引入了两个新的特殊成员函数用于移动操作:移动构造(move constructor) 和**移动赋值运算符(move assignment operator)**函数。它们是对下面这4个特殊成员函数的补充:
- 默认构造函数(default constructor)
- 复制构造函数(copy constructor)
- 复制赋值运算符函数(copy assignment operator)
- 析构函数(destructor)
如果一个类(class)不包含任何用户定义的特殊成员函数(排除默认构造函数),C++会隐式地定义它们。
class S{};
对于上面的代码,它没有包含任何用户定义的特殊成员函数,C++会隐式地为其定义特殊成员函数。隐式定义的移动构造会使用成员变量的移动构造函数来移动成员变量,隐式定义的移动赋值运算符会使用成员变量的移动赋值运算符来移动成员变量。
一个对象被移动后,它的状态是未定义的,我们可以认为一个被移动过的对象不再拥有任何资源,不严格地,我们可以认为被移动过地对象的状态为空集。比如,我们将字符串s1移动到字符串s2,移动后,s2的状态和移动前的s1状态相同,s1则变成了一个空字符串。
使用移动构造函数
C::C(C&& other); //C++11 move constructor
上面的代码演示了如何定义一个移动构造成员函数。移动构造成员函数不会分配任何新的资源,它会将other 的资源占用,然后将other的状态设置为默认构造后的状态。
下面,我们使用一个具体的例子来解释这一过程。我们定义一个MemoryPage类来表示缓冲内存:
class MemoryPage
{
size_t size;
char * buf;
public:
explicit MemoryPage(int sz=512):
size(sz), buf(new char [size]) {}
~MemoryPage( delete[] buf;}
//typical C++03 copy ctor and assignment operator
MemoryPage(const MemoryPage&);
MemoryPage& operator=(const MemoryPage&);
};
一个典型的移动构造函数定义如下:
//C++11
MemoryPage(MemoryPage&& other): size(0), buf(nullptr)
{
// pilfer other's resource
size=other.size;
buf=other.buf;
// reset other
other.size=0;
other.buf=nullptr;
}
可以看出,移动构造函数既不分配内存,也不进行内存缓冲的复制,当然也就快了很多。
使用移动赋值运算符函数
C& C::operator=(C&& other);//C++11 move assignment operator
移动赋值运算符和复制构造函数类似,但是在占用源对象资源之前,会先释放自身的资源。移动复制运算符函数的执行逻辑如下:
- 释放当前*this的资源
- 占用other的资源
- 设置other为默认构造状态
- 返回*this
下面的代码演示了上述执行逻辑:
//C++11
MemoryPage& MemoryPage::operator=(MemoryPage&& other)
{
if (this!=&other)
{
// release the current object's resources
delete[] buf;
size=0;
// pilfer other's resource
size=other.size;
buf=other.buf;
// reset other
other.size=0;
other.buf=nullptr;
}
return *this;
}
函数重载
为了支持右值引用,C++ 11修改了函数重载的规则。比如像vector::push_back()这类标准库的函数现在都有两个重载版本,一个使用const T&作为参数用于之前的左值引用,一个新的T&&用于右值引用。
#include <vector>
using namespace std;
int main()
{
vector<MemoryPage> vm;
vm.push_back(MemoryPage(1024));
vm.push_back(MemoryPage(2048));
}
因为参数是右值,上面代码的两次push_back()调用实际调用的都是push_back(T&&)。push_back(T&&)函数将资源从参数给出的MemoryPage对象使用MemoryPage的移动到vector内部的MemoryPage对象。对于之前版本的C++,因为调用的是复制构造函数,则会发生额外的内存分配与复制。
之前提到,当参数是左值时,会调用push_back(const T&)函数:
#include <vector>
using namespace std;
int main()
{
vector<MemoryPage> vm;
MemoryPage mp1(1024);//lvalue
vm.push_back(mp); //push_back(const T&)
}
但我们仍然可以使用static_cast强制将一个左值转换为右值:
//calls push_back(T&&)
vm.push_back(static_cast<MemoryPage&&>(mp));
也可以用std::move这一新的标准库函数来完成转换:
vm.push_back(std::move(mp));//calls push_back(T&&)
看上去,在大多数情况下,我们需要的是push_back(T&&)函数,但需要记住的是push_back(T&&)会将源对象的状态设置为空,如果我们想让源对象的状态保持不变,应该使用复制构造语义。一般而言,我们应该同时定义移动构造函数,移动赋值运算符函数,复制构造函数,复制赋值运算符函数。
链接:https://zhuanlan.zhihu.com/p/222984499
总结
使用移动构造函数和移动赋值运算符函数只需要少量修改就能使旧代码获得巨大的性能提升,何乐而不为呢?