C++干货系列——右值引用与移动语义
转自:C++干货系列 - 知乎 (zhihu.com)
C++17的左值和右值 | RuiBing, welcomes you to his home page. 讲得也不错
[c++11]我理解的右值引用、移动语义和完美转发 - 简书 (jianshu.com)
我希望在讲述一个知识点时,能够从容易理解的角度,由浅入深循序渐进,将我学习过程中遇到的问题和疑惑呈现出来,然后以解决问题+探索的形式慢慢铺述开。同时,如果学完一个东西我甚至不知道如何去用,更重要的是该在什么地方用的话——我认为这次学习是失败的——我们并不是为了考试在看知乎。
引子——左值持久;右值短暂
在C++11以前,所有引用都是左值引用(lvalue reference)——对左值的引用。lvalue
这个词来自于C语言,指的是可以放在赋值表达式左边的对象,这些对象都是在对上或栈上分配的命名对象,他们有明确的内存地址。相对应的右值rvalue
,如文字常量和临时变量,指的是可以出现在赋值表达式右侧的对象。左值引用只能绑定在左值上,右值就会编译错误:
int& i = 419; // Compile Error
好吧,这个错误有点蠢,应该也不会有人会这样用。但是我们通常会使用一个const
将右值绑定到左值引用上:
const int& size = 1000;
这样看熟悉多了。我们也可以通过对左值的const
引用创建临时性对象,作为参数传递给函数。其允许隐式转换,例如可以写成:
void printV(std::string const& s);
printV("hello world!"); //创建了临时std::string对象
上例printV
函数允许接受的是一个常量左值引用,在调用时首先创建了一个临时变量字符串——这是一个右值,然后通过拷贝构造函数创建一个左值形参,并取他的常量引用传入函数。从上例中,可以发现我们本想传入一个字符串,但却创建了两个变量——一个左值和一个右值。这一切都是源于目标函数的形参是一个左值引用,如果能把目标函数的形参能设为第一次创建的临时变量的引用——一个右值的引用,那么就会节约一次创建变量的时间和空间消耗。这就是右值引用最直接的思想,当可利用的临时变量的尺寸很大,那么由此节省出来的资源还是相当可观的。更进一步,有许多类型的变量在逻辑上并不允许有拷贝,如智能指针unique_ptr
,多个指针共同指向一个对象并且有且只有一个指针拥有对对象操作的所有权,这时“所有权”在对象之间更多的是一种“转移”的概念,类似的概念还有线程的所有权以及IO类的IO缓冲——这些类都包含不能被共享的资源。因此这些类型的对象不能被拷贝但可以移动,C++11为此引入了移动语义和支持相关操作的新的引用类型右值引用。
右值引用
我们通过&&
而不是&
来声明右值引用变量,由于右值引用绑定的通常是一个将要销毁的对象,我们可以自由地将一个右值“移动到”另一个对象中,有一种“拦截”的感觉。
int i = 0; // 这是一个左值
int &r = i; // 正确,左值引用
int &&k = i; // 错误,右值引用只能绑定右值
int &&p = 42; // 正确,42是右值字面常量
string &&slref = "This is a rvalue"; // 正确,字面常量字符串为右值。
const int &p = 42; // 正确,可以将右值隐式转换为常量左值引用
int &&t = i * 42; // 正确,计算结果为临时变量右值。
使用右值引用可以自由地接管所引用对象的资源,有临时变量的属性可知,右值引用所引用的对象应该是即将销毁的对象,并且该用户除了被赋值和析构操作外不应再作其他用途。
标准库move函数
int &&rr = std::move(419);
看起来很傻,不是吗?没错,这并不能展示std::move
的正确用法,反而让我们的理解更加困难——这玩意儿到底有什么用?我们可以从另一个角度看这个问题,std::move
其实本质功能是创造出一个右值引用强行绑定在一个左值上,我们可以使用这个右值引用做什么呢?下面的代码展示了 std::move 是如何转移一个动态对象到一个线程中去的:
// 智能指针unique_ptr指向一个大型数据体
void process_big_object(std::unique_ptr<big_object>);
std::unique_ptr<big_object> p(new big_object);
p->prepare_data(42);
std::thread t(process_big_object, std::move(p))
在 std::thread
的构造函数中指定 std::move(p)
,big_object
对象的所有权就被首先转移到新创建线程的的内部存储中,之后传递给process_big_object
函数。
移动构造函数与移动赋值运算符
类比拷贝构造函数,如果我们也能为类创建一个以右值引用为对象的构造函数,在其中实现“转移的逻辑”(与之对应拷贝构造函数实现的是拷贝的逻辑),那么每用一个临时变量初始化一个新的对象时都可以避免一次拷贝。我们看下边这个数据块类:
class DataBlock{
public:
int* data;
static const int blockSize = 1000;
string str;
DataBlock(string s):data(new int[blockSize]){
this->str = s;
cout << str << " Constructor being invoked!" << endl;
}
// Complete copy
DataBlock(const DataBlock& db):data(new int[blockSize]){
this->str = db.str;
for (int i = 0; i < blockSize; i++) {
this->data[i] = db.data[i];
}
cout << str << " Copy Constructor being invoked!" << endl;
}
~DataBlock(){
if(this->data != nullptr){
delete[] data;
data = nullptr;
}
cout << str << " Destructor being invoked!" << endl;
}
};
我们执行以下代码的过程:
vector<DataBlock> dbVec;
dbVec.push_back(DataBlock("First"));
会得到以下输出:
First Constructor being invoked!
First Copy Constructor being invoked!
First Destructor being invoked!
First Destructor being invoked!
由以上结果可知,构造函数被调用了一次而拷贝构造函数也被调用了一次。原因在于创造的临时变量(右值)无法传入左值引用为形参的push_back
函数,默认会拷贝出一个左值形参,再将这个左值形参塞进vector
,这对一个大块对象来说是太不划算了。什么?你觉得还可以?那么我们再来几次,请问以下过程调用了几次拷贝构造函数呢?
vector<DataBlock> dbVec;
dbVec.push_back(DataBlock("First"));
cout << "=====================================" << endl;
dbVec.push_back(DataBlock("Second"));
cout << "=====================================" << endl;
dbVec.push_back(DataBlock("Third"));
请看输出:
First Constructor being invoked!
First Copy Constructor being invoked!
First Destructor being invoked!
=====================================
Second Constructor being invoked!
Second Copy Constructor being invoked!
First Copy Constructor being invoked!
First Destructor being invoked!
Second Destructor being invoked!
=====================================
Third Constructor being invoked!
Third Copy Constructor being invoked!
First Copy Constructor being invoked!
Second Copy Constructor being invoked!
First Destructor being invoked!
Second Destructor being invoked!
Third Destructor being invoked!
First Destructor being invoked!
Second Destructor being invoked!
Third Destructor being invoked!
答案不是三次,而是六次。这时因为,vector
在分配空间时capacity是按2的倍数分配的,当塞入第二个元素时,此时实际占用空间为2,而capacity为1,vector
就会单独再申请大小为之前2倍的capacity,并把第一个元素重新拷贝进新空间,再插入第二个元素,而当插入第三个元素时会重新申请大小为4的空间,并把前两号元素再拷贝进去,这就是额外的三次拷贝的来历。说了这么多,如果我们引入一个Move Constructor事情就会大不一样,上述所有拷贝构造函数的调用都会被替换为移动构造函数。
// Move Constructor
DataBlock(DataBlock&& db) noexcept {
this->data = db.data;
db.data = nullptr;
cout << "Move Constructor being invoked!" << endl;
}
加入移动构造函数后,输出结果为:
First Constructor being invoked!
First Move Constructor being invoked!
First Destructor being invoked!
=====================================
Second Constructor being invoked!
Second Move Constructor being invoked!
First Move Constructor being invoked!
First Destructor being invoked!
Second Destructor being invoked!
=====================================
Third Constructor being invoked!
Third Move Constructor being invoked!
First Move Constructor being invoked!
Second Move Constructor being invoked!
First Destructor being invoked!
Second Destructor being invoked!
Third Destructor being invoked!
First Destructor being invoked!
Second Destructor being invoked!
Third Destructor being invoked!
与拷贝构造函数不同,移动构造函数并不分配任何新的内存,他接管给定的DataBlock
对象中的内存,在接管内存后,将给定对象中的指针都置为nulltpr
,这样就完成了移动操作,但是该对象将继续存在,最终移动源对象会被销毁,意味着将在其上运行析构函数。如果忘记了改变db.data
的值,则移动后的源对象会释放掉我们刚刚移动的内存,造成严重的后果。
标准库容器与异常、关键字noexcept
为什么移动构造函数中有关键字noexcept
?noexcept
关键字同志标准库我们的构造函数不会抛出任何异常:由于移动操作是“拦截窃取”操作,通常它不分配任何资源,因此移动操作通常不会跑出任何异常。当编写一个不抛出异常的移动操作时,我们应当将此事通知标准库,我们将看到,除非标准库知道我们的移动构造函数不会抛出异常,否则他会认为我们的类对象可能会抛出异常,并且为了处理这种可能性而做一些额外的工作。如果去掉noexcept
,我们会得到以下输出:
First Constructor being invoked!
First Move Constructor being invoked!
First Destructor being invoked!
=====================================
Second Constructor being invoked!
Second Move Constructor being invoked!
First Copy Constructor being invoked!
First Destructor being invoked!
Second Destructor being invoked!
=====================================
Third Constructor being invoked!
Third Move Constructor being invoked!
First Copy Constructor being invoked!
Second Copy Constructor being invoked!
First Destructor being invoked!
Second Destructor being invoked!
Third Destructor being invoked!
First Destructor being invoked!
Second Destructor being invoked!
Third Destructor being invoked!
我们会发现,在vector
重新分配空间并执行原有对象从老空间到新空间的拷贝时,vector
此时并不会默认调用移动构造函数而是拷贝构造函数,这是因为如果重新分配过程使用了移动构造函数,且在移动了部分而不是全部元素后抛出一个异常就会产生问题——旧空间的移动源元素已经改变了,而新空间中为构造的元素可能尚不存在,在此情况下,vector将不能满足自身保持不变的要求。另一方面,如果vector
调用的是拷贝构造函数,它很容易得可以保持旧元素不变且释放新分配的(但还未成功构造的)内存并返回。vector
原有的元素仍然存在。因此,如果我们希望vector
在重新分配空间时执行的是移动操作而不是拷贝,我们通过将移动构造函数标记为noexcept
来做到这一点。
右值引用与函数模板
右值引用固然是个好事,但是如果每个函数都写一个左值引用再写一个右值引用的版式,那也太麻烦了吧。没错,但是右值引用的函数模板恰恰可以解决这个问题。在使用右值引用作为函数模板的参数时和之前的用法可能不同,如果函数模板参数以右值引用作为一个模板参数,当对应位置提供左值的时候,模板会自动将其类型认定为左值引用;当提供右值时,会当作普通数据引用。看下边这个例子吧:
template<typename T>
void printV(T&& t){}
当传入一个右值时,T的类型被推导为:
printV(42); // printV<int>(42)
printV(3.14); // printV<double>(3.14)
printV(std::string("Hello")) // printV<std::string>(std::string("Hello"))
不过,向函数传入一个左值时,T
会被推导为一个左值引用:
int i = 42;
printV(i); // printV<int&>(i)
因为函数参数声明T&&
,就是引用的引用咯(当然不是这个意思,但是这里可以强行这么理解一下),那么以上printV
的类型就相当于:
printV<int&>(int&);
这就允许一个函数模板既可以接受左值也可以接受右值参数。
写在最后
移动语义固然是个好方法,但是必须要保证的是被移动过的对象一定要手动将其置于可析构的状态,因为被调用的右值往往马上就要经历析构;如果一个以后源对象具有不确定的状态,对其调用std::move
是危险的,当我们调用move
时必须确认移动后源对象没有其他用户。
一般来说,有五个基本操作可以控制类的拷贝操作:拷贝构造函数、拷贝赋值运算符、移动构造函数、移动赋值运算符和析构函数,C++并不要求我们定义所有这些操作,可以只定义其中一个或者两个,而不必定义所有,但是这些操作通常应该被视为一个整体,只需要其中一个操作,而不需要定义所有操作的情况是及其少见的。
当我们在决定一个类是否需要自定义版本的拷贝成员时,一个基本原则是首先确定这个类是否需要一个析构函数,通常,对析构的需求要比对拷贝或移动的需求更为明显,如果这个类需要一个析构函数,我们几乎可以肯定它也需要一个拷贝构造函数和移动构造函数。