半亩花草 发表于 2024-5-17 16:47:48

深入分析C++对象模型之移动构造函数

接下来我将持续更新“深度解读《深度探索C++对象模型》”系列,敬请期待,欢迎关注!也可以关注公众号:iShare爱分享,自动获得推文和全部的文章列表。
C++11新标准中最重要的特性之一就是引入了支持对象移动的本领,为了支持移动的操作,新标准引入了一种新的引用类型——右值引用,右值引用一个重要的性质就是只能绑定到一个将要销毁的对象。对对象实行移动操作后要确保源对象处于可析构的状态,源对象随时可能被销毁,所以程序在之后不要再去使用源对象的值,同时也要保证源对象析构之后不会对移入对象产生副作用。移动语义的加持使得移动一个如容器之类的大对象的成本可以像复制一个指针一样低廉了,于是出现了各种各样的传言:如编译器会使用移动操作来替代拷贝操作以获得服从上的提拔,甚至说将符合C++98标准的从前的老代码用符合C++11新标准的编译器重新编译一次,一行代码未改即可获得运行速度上质的提拔。对于种种传闻,毕竟上是否如此?接下来让我们拨开层层迷雾,来一探毕竟,看完这篇文章,你的心中就会有答案。
为了支持对象的移动,新标准新增了移动构造函数和移动赋值运算符,移动构造函数和移动赋值运算符的情况类似,所以放在一起讨论。对于传闻中假如程序中没有定义移动构造函数,那么编译器就会资助程序天生一个移动构造函数这一说法是否可靠?我们以实际的代码来分析一下,由于移动构造函数需要一个右值引用作为第一个参数,测试代码中可以使用标准库里的move函数来产生一个右值引用,move函数着实就是一个类型转换,它可以把一个左值转换成右值引用。看看下面的代码是否编译器会合成出来移动构造函数:
#include <utility>

class Object {
    int a;
};

int main() {
    Object d;
    Object d1 = std::move(d);
   
    return 0;
}把它编译成汇编代码看一下:
main:                                                # @main
    push    rbp
    mov   rbp, rsp
    mov   dword ptr , 0
    mov   eax, dword ptr
    mov   dword ptr , eax
    xor   eax, eax
    pop   rbp
    ret实际上编译器并没有天生一个移动构造函数,甚至任何构造函数都没有天生。因为没有须要,在这种情况下,编译器可以做一些优化,实行按对象的成员逐个复制已往就可以了,不需要天生一个函数来做这个事情。上面汇编代码的第5、第6行就是将对象d(存放在栈空间中)的内容先拷贝到eax寄存器,然后再从寄存器eax拷贝到对象d1(存放在栈空间中)。
那么在什么情况下才会合成出来移动构造函数呢?
编译器合成移动构造函数的条件

编译器只有在以下的这些情况下才会合成出来移动构造函数:

[*]类中没有定义拷贝构造函数、拷贝赋值运算符、析构函数;且:
[*]类的定义中有一个类类型的成员,这个类成员定义了移动构造函数;或者:
[*]继承的父类中定义了移动构造函数;或者:
[*]类中定义了或者从父类中继承了一个以上的虚函数;或者:
[*]类的继承链上有一个父类是virtual base class。
在上面C++代码的Object类中增加一个std::string类型的成员,std::string是标准库中提供的操作字符串的类,类中有定义了移动构造函数。Object类定义如下:
class Object {
    std::string s;
    int a;
};把它编译成汇编代码,可以看到这下汇编代码变得许多,不光天生了Object类的移动构造函数,还有默认构造函数和析构函数。main函数的汇编代码如下:
main:                                                        # @main
    push    rbp
    mov   rbp, rsp
    sub   rsp, 96
    mov   dword ptr , 0
    lea   rdi,
    call    Object::Object()
    lea   rdi,
    lea   rsi,
    call    Object::Object(Object&&)
    mov   dword ptr , 0
    lea   rdi,
    call    Object::~Object()
    lea   rdi,
    call    Object::~Object()
    mov   eax, dword ptr
    add   rsp, 96
    pop   rbp
    ret上面汇编代码的第7行调用了Object类的默认构造函数,因为string类里也定义了默认构造函数,所以这里需要去调用它,详细分析可见另外一篇的分析文章。第10行实际上就是调用Object类的移动构造函数了,在Object类的移动构造函数里会去调用string类的移动构造函数。所以可以推测出来,只有需要调用类类型成员的移动构造函数的时间编译器才会合成一个移动构造函数出来,在合成的移动构造函数中去调用它,上面的第3种情况也类似,第4和第5种情况是因为编译器需要重设虚表指针,所以也会天生一个移动构造函数来完成,这些情况跟合成拷贝构造函数的机制是类似的,详细的分析可以见《编译器背后的活动之拷贝构造函数》这篇文章,这里就不再逐一赘述了。
编译器抑制合成移动构造函数的情况

虽然说合成移动构造函数的机遇和合成拷贝构造函数的类似,但是合成移动构造函数的条件要比合成拷贝构造函数要苛刻得多,在以下的情况中,移动构造函数的合成将受到抑制,编译器不会合成一个移动构造函数出来。

[*]类中只要定义了拷贝构造函数、拷贝赋值运算符和析构函数的此中一个,编译器就不会合成移动构造函数
有这么一个引导原则,叫做Rule of Three,大意是:主要你定义了拷贝构造函数、拷贝赋值运算符、析构函数中的一个,你就必须要全部定义它们。缘故原由就是既然你需要自己实现拷贝的操作,阐明这里需要管理资源,好比内存的申请和释放,在拷贝构造函数里需要管理资源,意味着在拷贝赋值运算符函数里也需要,反之亦然,同时也需要在析构函数中释放资源。由此可以得出的推论就是假如你定义了这此中的一个函数,阐明有资源需要特别处置惩罚,那么编译器合成出来的移动构造函数可能就不是你想要的结果,甚至粉碎程序的逻辑,引起潜在的bug,所以编译器就不会合成出来移动构造函数。
按照上面的推论,假如定义了析构函数,那么编译器就不应该天生拷贝构造函数和拷贝赋值运算符了,但是C++98标准中却留下了一个“bug“:在定义了析构函数之后,编译器还是会在有需要的时间合成出拷贝构造函数和拷贝赋值运算符,C++11标准为了兼容C++98,同样地也答应合成出来,但是对于移动构造函数和移动赋值运算符,C++11标准中明确规定了:只要定义了析构函数,编译器便不再合成出移动构造函数和移动赋值运算符。
假如你的代码中没有定义上面的三种函数,你的类中的成员也是可以移动的,编译器在这时也为程序合成出了移动构造函数或者移动赋值运算符,假如这统统正符合你的本意,那么这种情况下建议你,最幸亏你的代码中把移动构造函数或移动赋值运算符用=default显示地声明出来。缘故原由在于,假如有一个类,类中有一个容器,容器存放了大量的数据,类中没有定义拷贝构造函数和析构函数等,编译器也合成了移动构造函数,使得对象的移动非常高效。但是忽然有天来个需求,需要在对象的构造和析构时记载下来,于是你增加了构造函数和析构函数以满足需求,但是参加代码重新编译之后发现程序实行的服从变差了,甚至有可能差了几个数量级,根源在于你定义了析构函数之后,编译器便不再合成移动构造函数了,而是用拷贝操作更换了移动的操作,所以显示地声明它们是一种好的风俗,尽管我们不需要实现这个函数的代码,所以使用=default让编译器来自动天生。

[*]假如类的定义中有一个类类型的成员或者继承自一个父类,这个类成员或者父类里的移动构造函数或者移动赋值运算符被定义为删除的(=delete)或者是不可访问的(定义为private),那么此类的移动构造函数或者移动赋值运算符被定义为删除的。
如下面的例子:
#include <utility>
#include <string>

class Base {
public:
    Base() = default;
    Base(Base&& rhs) = delete;
    int b;
};

class Object {
public:
    Base b;
    std::string s;
    int a;
};

int main() {
    Object d;
    Object d1 = std::move(d);        // 这行编译不通过。
   
    return 0;
}上面的例子中,编译器不再会天生移动构造函数和拷贝构造函数,所以第20行的代码将编译不通过,因为没有拷贝构造函数或移动构造函数供调用。

[*]假如类的析构函数被定义为删除的或不可访问的,那么此类的移动构造函数被定义为删除的。
移动操作并未使服从更高的情况

在某些情况下,移动构造函数或移动赋值运算符被精确地合成出来或者由程序员定义出来了,但是程序却并未如预期的提拔运行服从,如以下的场景:

[*]没有移动操作
假如类中有了移动构造函数(合成的或者用户定义的),同时类中有一个类类型的成员,这个成员刚好存放着大量数据,而此成员的类定义中没有定义移动构造函数,因此它只可以拷贝而不能移动。当对对象实施move操作时,实际上将会对对象的每个成员依次递归地实施move调用,它将匹配适合这个成员的操作,即假如成员是可移动则实行移动操作,假如不可移动的则实行拷贝操作。所以实际上将会调用此成员的拷贝构造函数。
另一种情况,如std::array容器,它是C++11标准新提供的容器类型,功能相当于内建的数组,它不同于别的容器类型将数据存储在堆中,然后使用指针指向数据,移动容器只需赋值指针,然后将源指针置空即可。array容器的数据是存放在对象上,即使数组里存放的元素类型能提供移动操作,那也得需要一个个地将每个元素实行一遍移动操作,这个时间是一个线性时间复杂度。

[*]移动的服从不高
std::string类往往接纳了小型字符串优化(small string optimization, SSO)的实现手法,SSO是将小型字符串(好比长度小于15个字符)直接存储在string对象内的缓冲区中,超过这个长度的则存放在堆上。之所以接纳SSO优化手法,就是因为在实际应用场景中大多数使用的字符串长度都比力短,这样可避免频仍地申请和释放内存带来的开销。在使用了SSO的情况下,移动一个string对象并不比力拷贝来得更快,实际上这种情况移动操作实行的是拷贝动作。

[*]移动操作未被调用
即使类中提供的移动操作比拷贝操作的服从显着要高得多,但是也有可能未能调用到移动操作,依然使用的是拷贝操作,导致实际结果服从不高的标题。好比标准库中的vector容器,它提供了一个push_back的接口,调用此接口向容器中参加一个元素,这时有可能容器的容量满了,需要申请一块更大的内存,然后把原先内存位置的元素搬已往再销毁掉。vector容器的实现者需要保证这个过程的前后状态要保持不变,在移动元素时,假如元素的类型提供了移动功能,那么vector容器就会使用它,但是要求这个移动操作必须是noexcept的,假如移动操作不能保证是noexcept的,vector容器就不会使用它。
试想一下,假如在移动到一半的时间,这时抛出了非常,移动操作随即制止,这时一半的元素在新空间中,一半的元素在旧的空间中,vector无法恢复到原先的状态。拷贝操作则不会存在这个标题,假如在拷贝过程中出现标题,那么只需要将新空间的元素和新申请的内存释放掉,vector的状态还是保持不变。
所以假如你的类型中的移动构造函数未加上noexcept声明,即使类型中的移动操作比对应的拷贝操作的服从要高效得多,编译器仍会强制去调用拷贝操作而非移动操作。因此建议当你定义自己版本的移动构造函数或移动赋值运算符的时间,要确保不会抛出非常,并在声明中明确加上noexcept声明。
假如您感爱好这方面的内容,请在微信上搜索公众号iShare爱分享或者微信号iTechShare并关注,以便在内容更新时直接向您推送。

免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!更多信息从访问主页:qidao123.com:ToB企服之家,中国第一个企服评测及商务社交产业平台。
页: [1]
查看完整版本: 深入分析C++对象模型之移动构造函数