二十五、C++移动语义、左值和右值、左值引用右值引用、移动构造函数、std::move、移动赋值操作符
本部分讨论一些更高级的C++特性:C++移动语义。但是讲移动语义之前我们得先了解什么左值右值、左值引用和右值引用。
1、C++的左值和右值、左值引用和右值引用
左值是有地址的值(located value),就是左值是有地址的。
左值大部分情况下是在等号的左边,右值在右边。
右值是一些,比如字面量、函数的一些返回结果等:
如果我通过返回int&,把上面的func函数的返回整成左值,会是什么情况?这里也引出什么是左值引用:
再看一个使用字符串的例子:
那有什么方法来检测某个值是左值还是右值吗?这里也引出什么是右值引用?
所以此时我们写个重载函数:
小结:
左值引用用一个&符号,右值引用则是用两个&&符号。
左值是带地址的数据,就是有存储支持的变量。右值是临时值,可以用右值引用&&来检测。
左值引用只能引用(接受)左值,除非加const,就也可以引用(接受)右值了。
右值引用只能应用(接受)右值。
左值、右值有什么用处呢?
尤其是在移动语义方面非常有用。移动语义我们后面还要讲。这里主要是想说分清左右值的目的在于优化。如果我们知道传入的是一个临时对象的话,我们就不需要担心它们是否活着、是否完整、是否拷贝,我们可以简单的偷用它的资源,给到特定的对象,或者在其他地方使用它们,因为我们知道它是暂时的,它不会存在很长时间,比如上面的ln+fn,就是暂时的,我们就可以从这个临时值中偷取资源,这对优化有很大帮助。能用右值就别用左值。所以有很多代码使用&&时,我们要知道这是右值引用。
2、C++移动语义:移动构造函数
其实移动语义底层逻辑也不复杂。但是你要非常清晰的说清什么是移动语义、用它做什么、实践中它是如何工作的等这些问题,就比较困难,因为牵扯到很多底层的、被包装了的、我们看不见的东西,所以很难说清。这里我尽量往细了说吧。
移动语义本质上就是允许我们移动对象。而这在C++11之前是不可能的,因为C++11才引入了右值引用,右值引用是移动语义必需的底层逻辑。通过上面的小标题,我们已经知道什么是右值,以及右值引用是什么。基本思想是,当我们写C++代码时,很多情况下,我们不需要或者不想把一个对象从一个地方复制到另一个地方,但又不得不复制,因为底层的设置就是要复制的。
举个例子:比如现在我要把一个对象当作参数,传递给一个函数。那么这个函数要获得那个对象的所有权,此时就只能copy这个对象。这里涉及到函数调用的相关知识点,不懂的可以参考【C++】数据类型、函数、头文件、断点调试、输入输出、条件与分支、VS项目设置_c++printf头文件-CSDN博客中的函数部分。
同理,当我们想从一个函数返回一个对象时也是一样的。我仍然需要在函数中创建那个对象,然后返回它,此时又是得复制数据了。不过现在有一种叫返回值优化的东西可以对这部分进行优化,所以这不再是个问题了。
就是说,我把一个对象当参数传入某个函数时,这个函数首先是需要得到这个对象的所有权或者其他,所以编译器或者操作系统首先是在当前堆栈帧中构造一个一次性对象,不管它在哪里,将它复制到我正在调用的函数中。然后才是开始执行这个函数的函数体。
当然,如果你的对象只是由一对整数或类似的东西组成,那么复制也没什么大不了。但如果你的对象需要堆分配内存之类的,就像下图的例子,它是一个字符串,需要复制它,就需要创建一个全新的堆分配。这就是一个沉重的复制过程。此时就是移动语义的用武之地(下下图)。如果我们只是移动对象而不是复制它,那么性能会更高。
写一个类作为例子,来演示这个沉重的复制过程:
从上图可见不管是代码K还是代码L,都调用了一次myString类的复制构造函数E。而E函数中还有堆分配new,是不是非常沉重。所以我们要用移动语义优化上面的代码。但是这里先不急着讲如何优化,下面我先讲透上面的代码:
(1)A和G处都是类的定义,定义一个类是不会引起内存分配的。定义一个类实例系统才会分配存储区,并把类实例名称引用到这块儿存储区。
(2)类实例对象的空间是在调用构造函数之前就分配好了的。调用构造函数是初始化这个实例的数据。
(3)实例化一个类会有一个this指针,类实例之间的区分就是通过this指针区分的。this指针就是一个地址;实例对象就是一些空间;构造函数、析构函数以及其它的函数,是一堆指令的集合。
(4)上图C处代码是myString类的无参构造函数。=default就表示,如果实例化myString类对象时没有参数,那就使用编译器生成的、默认的、构造函数。这也是在C++11标准中新引入的,编译器可以直接生成内联构造函数代码。
如果你的代码是myString s ;那实例对象s的m_data=nullptr,m_size=0。
此处再多说一句,=default只能用于特殊成员函数(构造函数、析构函数、拷贝/移动构造函数和拷贝/移动赋值操作符)。
(5)上图D处函数是myString类的有参构造函数。
(6)上图E处函数是myString类的复制构造函数。
(7)上图F处函数是myString类的析构函数。
(8)当我们实例化一个类实例时,系统是先分配一块不用初始化的内存空间,这块空间的大小是这个类的数据成员对齐后的大小。然后再执行这个类的构造函数。构造函数一般情况下就是初始化这块空间的,当然也会有其他功能,比如上面代码中还有new操作、mempy操作等。
当构造函数执行完毕后也就是这个类实例化完毕了。一个类实例化完毕,也就是说在内存中的一块存储区里存储了这个类的数据成员(而且一般是初始化完毕的)。
(9)当我们复制一个类实例时,如果这个类中有复制构造函数,那就类中的复制构造函数就自动重载了复制的操作。如果这个类中没有复制构造函数,那么就是底层的复制函数进行复制操作。
(10)不管是实例化一个类实例,还是复制一个类实例(不管是用复制构造函数复制的,还是用底层的复制函数复制的),都意味着创建新创建了一个类实例对象,当然也会同时生成一个指向这个对象的this指针。当这个类实例对象所在的作用域结束时,都会自动调用这个类的析构函数。
(11)有了上面的知识点铺垫,我们现在来理解代码K:
当操作系统开始执行上面的程序时,入口是main函数,所以代码K是程序执行的第一条指令。执行这条指令的过程是:
第一步是执行myString("liyuanyuan"),程序执行指针从K处跳到A处。也就是先去实例化一个没有名称的myString类实例。
系统先分配两个未初始化的资源:char* m_data(指针是4个字节)和uint32_t m_size(1个字节)。也就是B处的代码。
然后生成右值"liyuanyuan",当作构造函数D的参数,开始执行构造函数D,于是就打印了M、初始化了m_data和m_size:
uint32_t类型的m_size初始化的值是临时右值"liyuanyuan"的长度10;
char指针m_data初始化的值是:m_size为长度的、堆上的(因为是new嘛)、char数组的首地址。
并且同时把临时右值"liyuanyuan"也拷贝到堆上的char数组里面了。
这就是在main函数的栈上执行myString("liyuanyuan")的过程。执行完毕后的状态是:m_data、m_size是存储在main函数的栈内存上,这个.exe程序的进程堆上还有一个char数组,数组的首地址就是main函数线程中的m_data的值。
为了方便表述,这里生成的{m_data、m_size}这套数据暂时给个名字dataA吧。
第二步执行Entity e1(dataA),也是实例化一个名叫e1(这次是有名字的)的Entity类实例。于是执行指令跳到G处。
同理,系统先分配一块大小等于myString(4+1=5个字节)的、未初始化的栈空间(假设叫空间E)。
然后是在main函数的堆栈上复制一套dataA的数据,我们姑且将复制品称为dataB。为啥要复制dataA?因为为构造函数I准备参数啊。就是让dataB作为构造函数I的参数开始调用I,完成e1的实例化。但是这里的复制dataA操作就出现下面两种情况:
情况1:我在myString类中写了复制构造函数E,所以当系统复制dataA赋值给dataB时,会自动被E函数重载。那被E重载了,就打印了N、组成dataB的m_dataB的值就是10,组成dataB的m_sizeB就是E又在堆内存上new的另外一个字符串[liyuanyuan]的首地址。然后用dataB{m_dataB、m_sizeB}这套临时数据当作参数来实例化e1了。就是用{m_dataB、m_sizeB}初始化空间E(就是把{m_dataB、m_sizeB}拷贝到E中),并将名称e1引用到空间E上。实例对象e1就生成了。此时右值{m_dataB、m_sizeB}和堆上又new的数组就寿终正寝了,所以打印了P。至此代码K就执行完毕了。然后是执行下一条代码S,就是打印Q,最后执行到作用域结束T处,就释放第一步生成的、没名字的、数据是dataA{m_data、m_size}的那个myString类实例,于是又打印了个R。
情况2:如果我的myString类中没有写复制构造函数E呢?那系统是怎么赋值dataA的?那系统就用底层的复制函数把内存块dataA原原本本的拷贝到空间E里,将名称e1引用到空间E上。那此时的dataB的m_dataB就还是第一步时生成的地址,m_sizeB也是10。但是不管是调用E还是调用底层的复制函数,这都是一次生成一个新的myString类实例的操作。所以当系统把这个dataB当参数传入I并执行完毕后,释放dataB时,就调用了F析构函数,把第一步new的字符串数组也释放了。至此代码K算是执行完毕了,然后执行代码S,但是执行到T处时,第一步生成的myString类实例也该释放了,于是再次调用F,但是此时F就发现指针m_data指向的那块堆内存已经不见了(被m_dataB给释放了),于是没法delete了,就报崩溃了!!!
其实情况2就是浅拷贝,情况1是深拷贝。
情况2中dataB拷贝的是dataA中的m_data指针,这样就有两个指针指向堆上的同一个字符串数组,当dataB释放时就把堆上的字符串数组给释放了,那到作用域结束释放dataA时,m_data指向的内存就已经不存在了,就没法释放了,程序就崩溃了。也所以说上面的拷贝构造函数E是有必要写的,不然就崩溃了。
说明:
上面解释中看不懂类数据的内存分配的同学请参考:【C++】类、静态static、枚举、重载、多态、继承、重写、虚函数、纯需函数、虚析构函数_类 多态与重载-CSDN博客 中的类定义、类实例部分内容
看不懂复制、复制构造函数的请参考:【C++】理解C++中的复制、复制构造函数_c++ 复制函数-CSDN博客
看不懂堆栈的请参考:【C++】如何用C++创建对象,理解作用域、堆栈、内存分配_c++ 作用域 堆 内存-CSDN博客
看不懂进程线程的请参考:【C++】C++中的线程-CSDN博客
看不懂函数调用的请参考:【C++】数据类型、函数、头文件、断点调试、输入输出、条件与分支、VS项目设置_c++printf头文件-CSDN博客 中的函数部分
看不懂构造函数的请参考:【C++】类成员初始化列表、三元运算符、运算符及其重载、箭头操作符-CSDN博客 中的构造函数初始化列表部分
上面洋洋洒洒写了那么多,其实就是想说上面的代码其实并不优秀,因为拷贝过程太沉重了。如果说实例化时很沉重是无可奈何,那我只是拷贝一个一次性的、用完即丢的复制品都也这么沉重就太无语了。下面用移动构造函数优化代码:
加上上图中红框中的两个移动构造函数代码,就不会进行沉重的深拷贝了,就是进行了轻量的浅拷贝,而且加上C处的代码,程序也不会出现崩溃了。
上图A是对参数name进行了强制右值转换。这样初始化Entity实例对象时,如果有右值参数,就可以重载这个只接受右值参数的构造函数A了。如果没有代码A,那就得使用A上面的构造函数,这个构造函数即可接受左值也可接受右值。但是如果是右值参数传入,那它是先隐式转换,将右值转化为左值,然后才开始指向函数体的。所以也还是会发生深拷贝。所以我们一定要在Entity类中写一个只接受右值的构造函数A。
上图D处是使用std::move,这种写法等价于A。一般我们不建议使用A,因为不是什么对象都可以强制转换的。建议使用move,而这个下面一个小标题展开讲的内容。
3、移动语义:std::move与移动赋值操作符
上个小标题只讲了移动构造函数。其实移动语义还涉及到另外两个关键部分:std::move和move assignment operator(移动赋值操作符)。这两个小知识点是本不标题的讲解内容。例子还是我们的myString类和Entity类:
(1)std::move
(2)移动赋值操作符
待续。。。。