朵拉大学毕业,因学业优秀,得以进入华为公司工作。显然,这意味着朵拉离开原大学,成为华为公司的一员。如果把朵拉(dora)视为一个对象,同时把华为(huawei)视为一个容器,下述代码表征了dora对象加入huawei容器的行为:
1 | huawei.push_back(dora); |
遗憾的是,上述代码的实际执行结果并不符合预期。按照本书19.2节的讨论,容器通过拷贝复制行为制造了一个dora的复制品,真正加入华为的是dora的复制品,而不是本尊。原有对象dora依然存在于内存中。
C++提供了移动拷贝构造函数以及移动赋值函数,它们可以把一个预期不再被需要的对象的资源,如string对象的缓冲区,直接移入另一个对象,从而避免不必要的对象复制行为,进而提高程序运行的效率。
版权声明
本文可以在互联网上自由转载,但必须:注明出处(作者:海洋饼干叔叔)并包含指向本页面的链接。
本文不可以以纸质出版为目的进行改编、摘抄。
19.9 移动语义*
19.9.1 对象复制的编译优化
编译器会穷尽所能进行代码优化,避免不必要的对象复制行为。在下述代码中,我们定义了一个Message类,其中包含一个动态分配的缓冲区buffer用于存储真正的消息文本。此外,Message类还定义了拷贝构造函数以及自定义operator=()操作符函数。理论上,一个Message对象可以十分“巨大”,对其进行复制费时费力。
1 | //Project - MessageCopy |
上述代码的执行结果为:
1 | Constructor, id = 1 |
🚩第44 ~ 47行:fetchMessage()函数构造“局部”对象m,然后返回。
🚩第50行:main()函数的“局部”对象s接收fetchMessage()的返回对象。
逻辑上,上述代码至少存在两个Message对象,分别是main()函数内的s以及fetchMessage()函数里的m。多数读者会推导出如下的代码执行序列:m被构造并返回;返回的m作为参数参与s的拷贝构造;m被析构;s在main()函数返回时被析构。但作者计算机上的执行结果不支持上述推导,整个程序的生命周期内,只有编号为1的对象被构造及析构,整个程序事实上只生成了一个Message对象!
显然,这是编译器代码优化的结果。编译器认为先构造一个临时对象m再复制给s是没有必要的,它选择绕过中间对象的m,直接构造s:在fetchMessage()函数内对对象m进行的操作,事实上发生在外部的s对象上。从程序结果上看,编译器做得很好,省时省力且没有“误解”程序员的本意。
但编译器还没有厉害到可以完美地避免一切不必要的对象复制的程度。将上述代码的main()函数稍作调整:在第50行先构造对象s,然后再用s接受fetchMessage()返回对象的赋值。
1 | 49 int main() { |
调整后的代码在作者的计算机上获得了如下的执行结果:
1 | Constructor, id = 0 |
根据程序执行结果,我们可以逐行反推代码的执行序列:1).编号为0的对象s被构造;2).编号为1的对象m被构造;3).由于s已存在,返回对象m通过s的operator=()操作符函数复制给s;4).对象m析构;5).打印s的内容;6).对象s析构,由于s由m复制而来,所以执行结果第6行显示的编号为1。
“不必要”的对象复制发生在第3步。在operator=()操作符函数里,s对象分配了新的缓冲区buffer,然后一个字节又一个字节地从m对象复制缓冲区内容。考虑到临时对象m将很快被销毁,如果直接将m对象的缓冲区“偷”走,直接“挪”给s对象,将显著提高程序的执行效率。【C++ 11】引入了移动语义(move semantics)来解决这个问题。
19.9.2 右值引用
1 | int a = 69; |
C++标准引入了术语左值(lvalue)和右值(rvalue)来区分两种不同类型的对象。上述代码中的a具有确定的内存地址,它可以被赋值,我们称a为一个左值对象。在机器语言层面,表达式a+3的计算通常是借助于CPU寄存器来完成的,然后再从寄存器复制到对象a的内存。这个位于寄存器的临时对象没有确定的内存地址且“用完即弃”,我们称该对象为一个右值对象。
要点🎯
左值对象具有如下特点:1) 可以放在=号操作符的左边被赋值;2) 通常拥有确定的内存,可以被取地址;3) “长期”存在。
右值对象具有如下特点:1) 可以放在=号操作符的右边,其值可被=号操作符利用;2)可能不被分配分存,不可以取地址;3) 用完即弃,通常很快被销毁。
1 | int a = 69; |
严格意义上,本书第6章所讨论的引用是指左值引用:对左值对象的引用。上述代码中的ar(reference of a)即为左值引用,它绑定了左值对象a。如第6章所述,ar事实上“包含”了对象a的指针,它通过该地址来“引用”左值对象a。一个左值引用,既可以放在=号操作符的左边被赋值,也可以放在=号右边的表达式中被取值(第3行)。如第4行注释所述,a+2是一个右值对象,它不具备确定的内存地址且用完即弃,无法将左值引用t与其绑定。
1 | int a = 69; |
【C++ 11】使用&&来标识右值引用:对右值对象的引用。上述代码中的arr(reference of right value object a)即为右值引用,它绑定了右值对象a + 2。右值对象通常不会被分配内存,上述代码中第3行和第4行的存在“迫使”编译器为临时对象a+2分配了内存,从而使得右值引用arr可以被赋值甚至被取地址。请读者不要写出如第3行、第4行这样正确而又十分有害的代码,如果确实需要一个对象来保存计算的中间结果,普通的左值对象是更佳选择。
1 | int a = 69; |
如上述代码第2行的注释所述,编译器不允许右值引用绑定在左值对象上,这就好比一个正在使用中的铁制下水道井盖被标识为“无主丢弃物”:右值引用arr1“告诉”编译器其引用的对象a将很快被销毁,而事实上左值对象a将在其作用域内“长期”生存。稍后我们将会看到一个右值引用所引用对象的“资源”可能会被“偷”走,此时,任何对那个被引用的左值对象的访问都十分危险。
上述代码的第3行通过std::move()函数强制性地为左值对象a建立右值引用,读者可以自行查看std::move()的源代码加以确认。这是程序员对编译器的承诺:右值引用arr2所引用的对象虽然是个左值,但我承诺代码将不再访问左值对象a,a的资源可以随时被“偷走”。当然,作为一个普通的int对象,a没什么资源可偷。但是,上一小节所介绍的Message对象中的缓冲区,有被偷的价值。
要点🎯
&&标识一个右值引用。右值引用用于向编译器表明:
1) 被引用的对象是个临时对象,将很快会销毁,这个对象内的资源可以被“偷走”;
2) 后续代码将不再访问这个被引用的对象。
19.9.3 移动赋值及移动构造
为了避免不必要的对象复制,我们修改了19.9.1节中的代码:
1 | //Project - MessageMove |
上述代码的执行结果为:
1 | Constructor, id = 0 |
相较于19.9.1节,上述代码主要有两处修改。
🚩第27 ~ 32行:Message的移动构造函数(move constructor),请读者注意其参数为Message&& r,这表明右值引用r所引用的对象是一个“无用”的临时对象,移动构造函数将其中的资源“移动”到当前对象之下,以避免不必要的复制。代码中的noexcept用于向编译器表明该函数不会抛出异常▲,详见第21章。
要点🎯
构造函数(constructor)、拷贝构造函数(copy constructor)以及移动构造函数(move constructor)都用于“创造”一个新对象。其中,构造函数“从无到有”地创造对象,拷贝构造则对其他对象进行克隆,而移动构造,则会利用“无用”的旧对象中的“有用”资源来创造新对象。
注意📍
移动构造函数的参数r不是常量型右值引用,这是因为移动构造函数将从r所引用的对象中窃取资源,显然这可以认为是对r的修改。
🚩第31行:将r的buffer指针设为空,这十分重要,它确保了那个关键资源已被移走的临时对象的析构过程不会出错。如果不这样做,r所引用的临时对象被销毁时,其析构函数将释放已不再属于它的缓冲区。谨慎地确保一个被移动后的对象处于可以安全析构的状态,是程序员的职责。
🚩第43 ~ 51行:Message的移动赋值(move assignment)操作符函数。类似地,该函数的参数为Message&& r,这表明右值引用r所引用的对象中的资源可以“移动”到当前对象之下,以避免不必要的复制。
要点🎯
赋值操作符函数和移动赋值操作符函数都以其它对象为参照物来修改已经存在的对象。区别在于,前者是克隆,后者可以通过移动来利用参数对象的资源。
🚩第45行:如果当前对象的地址等于r的地址,说明对象在自己对自己赋值,直接返回。这种情况下常规代码中不太容易遇到,但做为谨慎的程序员,应考虑这种情况。显然,自己“移动”自己的资源无法做到。
🚩第49行:将r的buffer指针设为空,确保被移动的对象处于可安全析构的状态。
从执行结果的第3行可知,当fetchMessage()函数返回的临时对象m赋值给对象s时,编译器为我们选择了移动赋值操作符函数而不是普通的赋值操作符函数。在移动赋值操作符函数里,对象s在删除自己的原有缓冲区之后,没有新建缓冲区并逐字节复制,而是移走了临时对象m的缓冲区资源为我所用。这样做,既加快了程序的执行速度,同时又不违背程序员的意图:在fetchMessage()函数返回后,局部对象m确实不再被需要了,从中获取可利用的资源是无害且有益的。
为了观察移动构造函数的行为,我们将上述程序中的main()函数修改为:
1 | int main() { |
修改后程序的执行结果为:
1 | Constructor, id = 1 |
从执行结果的第2行可知,编译器选择通过拷贝构造函数完成s1到s2的复制。这样做是合理的,语法上s1是左值对象,其引用也是左值引用,无法与移动构造的右值引用参数相匹配;逻辑上,s1不是临时对象,它应在作用域范围内“长生”,不可以“偷走”它的资源。
如果程序员确定不再需要s1对象,要求程序将s1的资源移动到s2,则应通过std::move()函数生成s1的右值引用。
1 | int main() { |
修改后程序的执行结果为:
1 | Constructor, id = 1 |
执行结果的第2行显示,由于程序员“承诺”s1对象不再被需要,编译器选择移动构造函数来完成从s1到s2的“复制”。在复制过程中,缓冲区资源从s1移动到了s2。语法上,std::move(s1)的类型为Message&&,它与移动构造函数的参数类型完美匹配。
19.9.4 移动语义与容器
移动语义与容器的性能息息相关。在19.2节,我们观察到当向量的容量不足以存储新加入的元素时,向量会申请新空间,然后通过拷贝构造函数将已有元素复制到新的存储位置;同时,新加入元素的复制也是通过拷贝构造完成的。
要点🎯
如果一个类型既没有自定义拷贝构造、也没有自定义operator=()操作符函数,且该类型的所有数据成员都支持移动构造和移动赋值,编译器会为该类型生成默认的移动构造及移动赋值函数,它们的默认行为即是将全部数据成员逐一进行移动。
细心的读者或许早已发现这是一种效率低下的“向量的生长”方法。编译器做出这种选择的原因是19.2节中的Fish类型没有移动构造函数。
19.9.3节中的Message类型有自定义的移动构造及移动赋值函数,我们通过下述代码观察元素类型为Message的向量的生长。
1 | //Project - MessageContainer |
上述代码的执行结果为:
1 | Constructor, id = 1 |
说明:在读者的计算机上,考虑到向量的空间管理策略的差异,执行结果可能不同。
🚩第 11、12行:s1、s2的构建,对应执行结果的第1、2行。
🚩第13行:通过msgs.push_back()函数将s1对象加入向量。由于s1是左值对象,真正被加入向量的是s1的复制品,对象复制通过拷贝构造完成,参见执行结果的第3行。
🚩第15行:通过std::move()强行生成s2的右值引用,将s2加入向量。由于std::move(s2)为右值引用,向量执行了Message的移动构造函数来复制对象,s2的资源被移动到了向量的内部元素中。相关移动构造函数的输出见执行结果的第5行。
同时,由于空间不足,向量进行了扩容。向量内原有的编号为1的Message对象也从旧空间搬家至了新空间,为了提高效率,向量通过移动构造来完成这种迁移。相关输出见执行结果的第6行。
执行结果的7 ~ 11行对应了5个对象的析构。这5个对象是:s1、s2、msgs向量内的两个元素以及因msgs向量扩容被迁移的旧对象。
总结🍵
如果元素类型支持移动语义,那么容器可以更高效地扩容、迁移和管理元素。
如果读者试图查看向量的push_back()源代码,至少可以看到两个函数名重载的版本:其中一个接受常量型左值引用,而另一个接受右值引用。其大致形式请参考下述伪代码:
1 | template <class T> |
push_back(const T& r)通过拷贝构造完成对象复制,push_back(T&& rr)则通过移动构造来完成。合理推测,向量的其它成员函数,比如insert(),也进行了类似的函数名重载。