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

2024-04-18 13:40:38 浏览数 (2)

C 11新标准中最重要的特性之一就是引入了支持对象移动的能力,为了支持移动的操作,新标准引入了一种新的引用类型——右值引用,右值引用一个重要的性质就是只能绑定到一个将要销毁的对象。对对象执行移动操作后要确保源对象处于可析构的状态,源对象随时可能被销毁,所以程序在之后不要再去使用源对象的值,同时也要保证源对象析构之后不会对移入对象产生副作用。移动语义的加持使得移动一个如容器之类的大对象的成本可以像复制一个指针一样低廉了,于是出现了各种各样的传言:如编译器会使用移动操作来替代拷贝操作以获得效率上的提升,甚至说将符合C 98标准的以前的老代码用符合C 11新标准的编译器重新编译一次,一行代码未改即可获得运行速度上质的提升。对于种种传闻,事实上是否如此?接下来让我们拨开层层迷雾,来一探究竟,看完这篇文章,你的心中就会有答案。

为了支持对象的移动,新标准新增了移动构造函数和移动赋值运算符,移动构造函数和移动赋值运算符的情形类似,所以放在一起讨论。对于传闻中如果程序中没有定义移动构造函数,那么编译器就会帮助程序生成一个移动构造函数这一说法是否可靠?我们以实际的代码来分析一下,由于移动构造函数需要一个右值引用作为第一个参数,测试代码中可以使用标准库里的move函数来产生一个右值引用,move函数其实就是一个类型转换,它可以把一个左值转换成右值引用。看看下面的代码是否编译器会合成出来移动构造函数:

代码语言:cpp复制
#include <utility>

class Object {
    int a;
};

int main() {
    Object d;
    Object d1 = std::move(d);
    
    return 0;
}

把它编译成汇编代码看一下:

代码语言:plaintext复制
main:						# @main
    push    rbp
    mov     rbp, rsp
    mov     dword ptr [rbp - 4], 0
    mov     eax, dword ptr [rbp - 8]
    mov     dword ptr [rbp - 16], eax
    xor     eax, eax
    pop     rbp
    ret

实际上编译器并没有生成一个移动构造函数,甚至任何构造函数都没有生成。因为没有必要,在这种情况下,编译器可以做一些优化,执行按对象的成员逐个复制过去就可以了,不需要生成一个函数来做这个事情。上面汇编代码的第5、第6行就是将对象d(存放在栈空间[rbp - 8]中)的内容先拷贝到eax寄存器,然后再从寄存器eax拷贝到对象d1(存放在栈空间[rbp - 16]中)。

那么在什么情况下才会合成出来移动构造函数呢?

编译器合成移动构造函数的条件

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

  1. 类中没有定义拷贝构造函数、拷贝赋值运算符、析构函数;且:
  2. 类的定义中有一个类类型的成员,这个类成员定义了移动构造函数;或者:
  3. 继承的父类中定义了移动构造函数;或者:
  4. 类中定义了或者从父类中继承了一个以上的虚函数;或者:
  5. 类的继承链上有一个父类是virtual base class。

在上面C 代码的Object类中增加一个std::string类型的成员,std::string是标准库中提供的操作字符串的类,类中有定义了移动构造函数。Object类定义如下:

代码语言:cpp复制
class Object {
    std::string s;
    int a;
};

把它编译成汇编代码,可以看到这下汇编代码变得很多,不光生成了Object类的移动构造函数,还有默认构造函数和析构函数。main函数的汇编代码如下:

代码语言:plaintext复制
main:							# @main
    push    rbp
    mov     rbp, rsp
    sub     rsp, 96
    mov     dword ptr [rbp - 4], 0
    lea     rdi, [rbp - 48]
    call    Object::Object() [base object constructor]
    lea     rdi, [rbp - 88]
    lea     rsi, [rbp - 48]
    call    Object::Object(Object&&) [base object constructor]
    mov     dword ptr [rbp - 4], 0
    lea     rdi, [rbp - 88]
    call    Object::~Object() [base object destructor]
    lea     rdi, [rbp - 48]
    call    Object::~Object() [base object destructor]
    mov     eax, dword ptr [rbp - 4]
    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),那么此类的移动构造函数或者移动赋值运算符被定义为删除的。

如下面的例子:

代码语言:cpp复制
#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声明。

0 人点赞