深入浅出C++:移动语义不移动
注:本文在2022年6月1日进行了一定程度的修改,本文在之前所讲述内容的结论并没有改动,只是扩展了一些之前文中并没有提到但与移动语义相关的内容。
结论先行:将【移动拷贝】当做一个面向【即将消逝对象】的特化版【拷贝操作】。
移动语义是C++11引入的概念,也是相较于C++03的一条重要改变。应该可以这么说:是移动语义的加入,使C++重新回到了神坛,STL那令人作呕的拷贝成本因他的加入得到了一定程度的解决。今天,我尝试以我个人的方式,深入浅出来简单谈一谈我对右值引用的理解。
但个人理解,针对不同对象肯定会有点出入,如果读者无法理解我所说的,就请尝试不要去强行理解了,毕竟每个人理解知识点的方式不一样,不必要强行一致。
文章开始之前,咱们或许应该理解一下简单的左值和右值的概念,但其实这个东西细分起来多少会有点麻烦,因为规范上,左值和右值也只是很宽泛的称呼,更详细的包括有范左值、纯右值、亡值、左值、右值等称呼,我直接放一个最官方的解释在这里,有需要的可以自行看:
<https://zh.cppreference.com/w/cpp/language/value_category>
请注意,在接下来特地谈到RVO(编译器返回值优化)之前,我们的代码不考虑RVO。
让我们设想这样一个简单的类和函数:
在上面的代码中,脱离返回值优化的情况下,对象被构造了三次。
Data temp(a)调用一次,而且在return temp的时候,还会调用一次左值拷贝构造,不仅如此,当返回值被接受给外面的dat的时候,又发生了一次左值拷贝。
要知道,每一个对象的产生,都是需要代价的,那些经典的书籍上(比如Effective C++)无不强调要避免多余的构造及对象产生。
可即便如此,有一点仍然是无法避免的:
但凡有一个拷贝,那么一个对象必须会产生 。
我详细解释一下这句话,就拿那个Data类来说,当一个【Data的对象a】要以【Data的对象b】为素本(接下来我会以素体或是素本来指代这些拷贝对象时用作参照的对象)产生拷贝操作了,那么对象a的内存无论如何都会开辟,对象a也无论如何都会构造,除非是使用reference或者pointer to 对象b。
当然,编译器返回值优化的后面再说,我再次强调一下,没有特别说明的话,我的所有说法都默认关闭编译器返回值优化。
在了解了这个基础的情况以后,我们将上述代码改动成这样。
在这个类的设计中,我们不再让这个类产生的对象拥有【数据本身】,而改成了一个【指向数据的指针】,而为了方便理解,我也就不用智能指针了。
这个改动对【指向对象的指针】也同样适用,总归就是: 我想要的数据不在对象里,而是在堆区的某处,而我拥有的是记录它所在位置的地址。(这个是指针的基本功,不懂的同学得去补充基础)
这样的对象在拷贝的时候,就会出现深浅拷贝问题。
我是想要一个指向同一数据的拷贝对象?还是想要一个【数据内容】和素体一模一样,但【并非同一个数据】的拷贝对象?
一般而言,当素体的生命周期仍然很长,也就是我们还要使用素体的时候,我们不应该使用浅拷贝,因为那会让拷贝对象指向与素体相同的数据,最后当我们修改了拷贝对象的时候,素体内指针所指的数据本体也发生了更改。
于是,在这种情况下,我们理所应当的选择了深拷贝,就像我在上述代码中写的那样,左值拷贝函数内,我要为val重新动态分配内存,分配的空间要和素体一样,然后再一个一个赋值,在这个过程里,我们不但生成了一个拷贝体的空间,还要生成一个val所指数据的空间,若是将val改为一个对象指针,我们也可以说,在这个过程里我们进行了两次构造:
对象本身的构造 + 对象内指针指向的堆区资源的构造(或者说空间分配)。
这样的情况可以用下面的话来解释:
一个拷贝构造,其实进行了复数次构造操作,或者说资源分配操作。
这是我们为了得到【内容一样,但是却不是同一个】拷贝体所付出的必要代价。在这里,拷贝体与素体的val是不同的,val是指针,指针是存储地址的变量。
这两个val的值不一样 代表着 他们所指向的地址不一样
即它们所指向的数据并不是同一个数据,但是这两个数据,它们的内容是相同的。我做这么套娃的解释是为了给一些指针基础可能有些薄弱的同学看,它看上去套娃,但其实本质很简单。
在没有右值拷贝的过程中,几乎所有拷贝操作都会按你的这个思路来进行。毕竟你设计这个左值拷贝构造函数,就是为了让对象以某个素体为参照生成一个【内容一模一样但并非同一个】的对象。
但是当素体的生命周期即将结束的情况?对成员指针所指对象的重新构造是不是显得多余,毕竟一个生命周期即将结束的对象,它的成员是正经对象也好是指针也好,我都应该于情于理的予以释放(比如在析构函数中),数据还好,要是裸指针不释放,得,那就是C与C++经典的内存泄漏问题。
然后你就发现:卧槽,素体的指针都不要了,我为什么不拿来直接用?而是傻乎乎的付出巨大成本拷贝了一个后,还让素体把它释放了? 这个时候回想一下我先行的结论:
将【移动拷贝】当做一个面向【即将消逝对象】的特化版【拷贝操作】
一个类拥有如此多的构造函数,本质上就是拥有多个函数重载,能够让你使用不同的形参将对象构造出来,移动拷贝也就是其中一个为了应对【即将消逝对象】的函数重载罢了。
于是就有了教科书般的移动拷贝定义:
就像浅拷贝似的,我拷贝对象直接把你素体的【数据的地址】拿来,再把你素体的【数据的地址】改为空地址,这样就不怕你在死的时候拉我想要的数据一起陪葬了。
要知道,一般来说,如果一个类内有指针指向堆区资源,那么为了内存不泄露,析构函数就要对这些指针或者free,或者delete。(哪怕是智能指针,也是会以一种根据智能指针具体类型的方式自动释放资源,这玩意有个学名,叫RAII)
这样之后,当面对一个【我知道它命不久矣】的【素体对象】,我就有那么一个特化的【拷贝操作】,来用一个相对节约的方式将它拷贝出来,这个过程就像是函数重载似的。
在上述的例子里,为了解释所谓【命不久矣的对象】,我们用函数返回值来说明,函数的返回值必定是一个【命不久矣的对象或者变量】,但是我马上就会在后面加入讨论RVO的时候告诉你,函数返回值大多数情况下其实并不需要开发者手动来进行不同构造函数的调用。
这个时候,就要补充一下,还记得我上面的红字说了什么吗?
但凡有一个拷贝,那么一个对象必须会产生。
也就是说,拷贝对象的内存空间说什么都要被构造出来,这样意味着:
假如你的类内包含的不是指向堆区资源的指针,而是实打实的数据,实打实的对象,那么这样的拷贝过程右值拷贝是没办法进行优化的。
除非:我不构造拷贝对象了,我直接拿着素体用行不行?
好了,从现在开始,我们的讨论就要带着编译器返回值优化来进行了,从此开始也是此次更新的一部分新内容。而这个部分从某种程度上说,也更像是我的effective modern C++的读书笔记。
编译器返回值优化便可以将【函数作用域内临时对象】的生命周期延长,直接将那个我们本来认为【是临时对象的对象】整个返回给你让你用,而不是给你拷贝一个【以临时对象为素体】的新对象,也是现代C++编译器的强大之处所在。
而我跟你说了这么多右值拷贝的事,人家一个编译器返回值优化全搞定了,是不是就仿佛......我说的话都好似放屁一般?
当然不是,RVO只是在大多数情况下让我们不用去考虑函数返回值的情况而已,记住,是大多数情况,而且还有很多情况需要你对移动拷贝有一个理解甚至是能够应用。
现代C++的编译器是无比强大的,当它遇到了一个函数返回的时候,会自行判断它能不能进行返回值优化,而我们常年遇到的情况大多都是编译器会帮我们进行优化的情况。
然后,我们就名正言顺的应当直面一个问题:编译器会在什么时候进行返回值优化呢?
effective modern C++在它的条款25提到(当然这是作者引用的标准哈):
1、局部对象和函数返回值类型一样的情况
2、返回的就是局部对象本身
本着标准肯定不会错的原则,其实就这么两条,但是考虑到人人理解可能都有偏颇的事实,有不少实际情况其实从我的思维上很难和这两条对应上。
而且我更愿意将这两条合并理解为:返回对象的数据类型与函数返回数据类型一致。
你可能需要稍微绕一绕,这个合并理解与第一条并不是完全一样,而且除此之外
我还要用我自己的理解来对这两条进行一下扩充,这仅仅是个人理解,并非是权威资料。
3、返回的对象不能是数组或是容器中的某一个元素
4、返回按值传递的形参,形参与返回类型相同(effective modern C++ 条款25)
好,我用我的理解来归并一下这几条特例,应该负责任的说,这只是我总结出来的,有不全面的可能性。
1、返回对象的数据类型与函数返回数据类型一致。
2、返回的对象不能是数组或是容器中的某一个元素
3、返回按值传递的形参,形参与返回类型相同(effective modern C++ 条款25)
接下来当我说第几条的时候,都是以这个我归纳的三条来进行说明,或者我会显式的称之为新三条的第几条,如果是引用了更早的四条,我会说老四条的第几条。
考虑下面的例子,这个例子类似于Effective Modern C++ 条款25给出的例子:
这里的返回值不是局部对象tmp而是tmp的引用,这样的情况是无法触发编译器返回值优化的,这个例子书中给出的原因是【没给返回局部对象本身】,属于老四条的第二条。
但结合我们归并的新三条总结来看,返回值类型要求是一个Data,而经由std::move以后,返回对象则是Data&&,促成了函数返回值与返回对象的数据类型不一致,进而无法进行RVO。
但是,我负责任的告诉各位读者,我自己做实验有个特例:
钻牛角尖的说,这个情况是对不上新三条的第一条以及老四条的第一条的,因为局部变量是一个Data类型,函数返回类型是一个const Data,这是两个不一样的类型。
当然,官方肯定有针对这个的说法,只是我还没有翻到相关的说明,再加上它本身不是什么值得特别关注的点,我也就不打算特地为这个翻阅资料,而是当一个既成事实先记下来就行了。这个例子确实是能触发RVO的。
另外两个例子很好理解,新三条的第3条你可能读着稍微有点别扭,但如果我给出你如下的代码,我相信你立刻就能理解:
仔细看看新三条的第一条对应的代码,如果足够细心就会发现,其实第一条和第二条是可以合并在一起的。因为返回数组中的某一个元素调用了operator[],这个运算符返回的是一个引用,引用的数据类型与函数返回值的数据类型不一致,造成了编译器并不会帮这段代码进行返回值优化。
以上就是我目前能够想到的和总结出来的无法触发RVO的情况,但你千万别跟我说我没算主动关了RVO的情况,我觉得那个就纯属是在碰瓷了。
我在开始讨论RVO的时候说过,函数返回值大多数情况下其实并不需要开发者手动来进行不同构造函数的调用。
这句话的意思是:但凡出现要返回局部对象的情况,你不需要添加任何奇奇怪怪的操作,直接返回就好了。就比如,你不需要手动为返回值加上std::move(),无论你是否清楚【当前是一种不会触发返回值优化的情况】。
因为编译器本身会按着一套很理想的方案为你整合出最适合的方式:
当编译器看到你的返回值是一个局部对象,它先会尝试这能不能进行RVO。
如果不能,他就会尝试进行移动拷贝,判断依据就是返回对象所对应类型有没有实现移动拷贝构造函数。
再不能,才会是最差的情况,使用左值拷贝。
或许哈,在函数返回值这个小小的点上讨论移动拷贝真就是放屁一样,咱们讲了那么多移动语义的理论,但RVO帮咱们省了几乎所有的事。
但请记住,纵然有RVO来为我们进行效率优化,但移动语义绝不是没有价值的东西。
且不说我提到的几种情况就无法触发RVO,需要你自己去实现移动拷贝构造来进行代码效率上的优化,在大环境里,我不信你碰不到那种【你需要将一个命不久矣的对象的资源过继给一个新对象】的情况,要知道,所谓【命不久矣】的对象,可没人说只有所谓的函数作用域内的临时对象,这样的家伙在编程乃至于内存管理的世界里数不胜数。
我给出一个一拍脑门想出来的案例:
假设你需要一类STL容器来存储两批数据。
第一批是一套流水线下的所有产品抽象,我们称之为goods。
另一批则是经过质检后,从所有产品中监测合格的合格产品,我们称之为hegede_goods,呸,不要这么命名,我们称之为qualified_goods。
如果有一个功能为质量检测的函数接收两个容器的引用,需要将goods容器内的数据转移至qualified_goods中。
如此想来,它是不是一个经典的需要使用移动语义的场景?
但对于这些东西的追根问底,就并不是我这篇文章打算讨论的了,到此为止,文章的主要内容以及更新内容基本结束了。
结尾之际,我仍然想表示,上述的理解是我个人在查阅了资料,实验观察结果后的个人总结。我和一些有经验的朋友们交流过,其中有和我拥有共识的,自然也有跟我产生分歧的。
我在C++17的指南上找到了这么一句话【当对象有很多基本类型的成员时移动语义还是要拷贝每个成员】(既然说到了C++17,我也补充一句,C++17中引入了std::data,所以各位小伙伴今后想给对象,尤其是可调用对象或是函数命名为data的时候,请三思而后行。)
我个人感觉,这句话可以为我的上述理解做解释【类内的成员若是基础类型或是非指针的数据,那么右值拷贝不会带来效率上的优化】
细想一下,这两个说法是可以相互解释的。无论你类里放了什么数据,这些数据都要拷贝,哪怕是指针在64位系统上也有8字节,这些实打实的数据都会拷贝,省不了。不过是指针本身是一个储存着地址的数据,那个真实地址的内存里,才存储着可以拿来用的数据而已。
default的移动拷贝从结果上说,与左值引用拷贝的效果是完全一样的,只是你没有履行使用std::move的默认规则——当使用此函数传递某个对象以后,便认定该对象死亡,不再访问和使用它。
这个规矩你不遵守,当然会有代码规范上的缺憾甚至是未定义行为的产生,但实际上我没有找到任何语法或是语义,能够确实的将某块内存的数据【通过移动的方式】(即在将内存1的数据移动到内存2上以后,内存1失效并被释放到另一块内存上)。到头来,std::move和std::forward更多的是为了精准调用参数为【右值引用】的函数重载罢了。这也正是标题所说的,移动语义其实并没有产生根本的移动行为。
所谓移动,更像是先拷贝再将【素体对数据的拥有权】【释放】以产生出的一个移动的感觉而已。这个清楚动作是要经开发人员之手来实现的,不是C++自己的内容,就像STL那样,STL的开发者在素体作为移动构造参数被访问时将其进行【释放自身对资源的拥有权】的操作。
C++的体系如汪洋大海,很难由一个人彻底吃透,不同的开发者拥有不同的观念和理解属于很正常的情况,而如果您跟我有歧义且自信能够说服我,欢迎私信或是评论区留言。
如果你觉得我的文章对您有帮助,还希望我可以更新别的内容,那么如果有机会,我会在下一次为各位带来如下内容:
深入浅出C++:完美转发不完美
最后,感谢您的阅读。