接下来我将持续更新“深度解读《深度探索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 [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类定义如下:

class Object {
std::string s;
int a;
};

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

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),那么此类的移动构造函数或者移动赋值运算符被定义为删除的。

如下面的例子:

#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并关注,以便在内容更新时直接向您推送。

深入分析C++对象模型之移动构造函数的更多相关文章

  1. 《深度探索c++对象模型》chapter2 构造函数语义学

    关于c++,最常听到的一个抱怨是,编译器背着程序员做了太多事情,conversion运算符是最常被引用的一个例子:jerry schwarz,iostream函数库的建筑师,就曾经说过一个故事,他说他 ...

  2. 【深度探索C++对象模型 | 02】构造函数语意学

    默认构造函数的构造操作.拷贝构造函数额构造操作  注意:默认构造函数和拷贝构造函数在必要时的时候由编译器产生出来. 参考资料 关于默认构造函数的几个错误认识(四种情况下,编译器会生成默认构造函数)

  3. 【C++】深度探索C++对象模型读书笔记--构造函数语义学(The Semantics of constructors)(四)

    成员们的初始化队伍(member Initia 有四种情况必须使用member initialization list: 1. 当初始化一个reference member时: 2. 当初始化一个co ...

  4. WorldWind源码剖析系列:BMNG类构造函数深入分析

    BMNG构造函数深入分析 一.主要类图 二.主要功能: 1)        BMNG类 BMNG类将包含以“Blue Marble”为主题的所有可渲染影像的根节点添加到当前星球的可渲染对象列表中,包括 ...

  5. Object Pascal对象模型中构造函数之研究

    http://www.delphi2007.net/delphiblog/html/delphi_2004511950333715.html 前言 近期,一直在使用 C++ 与 Object Pasc ...

  6. 【C++对象模型】构造函数语意学之二 拷贝构造函数

    关于默认拷贝构造函数,有一点和默认构造函数类似,就是编译器只有在[需要的时候]才去合成默认的拷贝构造函数. 在什么时候才是[需要的时候]呢? 也就是类不展现[bitwise copy semantic ...

  7. C++对象模型的那些事儿之四:拷贝构造函数

    前言 对于一个没有实例化的空类,编译器不会给它默认生成任何函数,当实例化一个空类后,编译器会根据需要生成相应的函数.这类函数包括一下几个: 构造函数 拷贝构造函数 析构函数 赋值运算符 在上一篇博文C ...

  8. C++对象模型的那些事儿之三:默认构造函数

    前言 继前两篇总结了C++对象模型及其内存布局后,我们继续来探索一下C++对象的默认构造函数.对于C++的初学者来说,有如下两个误解: 任何class如果没有定义default constructor ...

  9. C++对象模型——默认构造函数的合成

    最近在学习C++对象模型,看的书是侯捷老师的<深度探索C++对象模型>,发现自己以前对构造函数存在很多误解,作此笔记记录. 默认构造函数的误解 1.当程序猿定义了默认构造函数,编译器就会直 ...

  10. C++构造函数语义学(二)(基于C++对象模型)

    带有虚函数的情况. 下面情况编译器也会在需要的时候为其合成. 1.如果一个类自己声明为虚函数. 1 #include<iostream> 2 using namespace std; 3 ...

随机推荐

  1. Module not specified-使用IDEA出现问题

    一.问题由来 使用IDE导入一个项目时,准备启动这个项目,然后突然报错,错误信息如标题中所示Module not specified.这个项目之前都还好好的 怎么突然就运行不了了呢?让我感到很是疑惑, ...

  2. sentienl

    整合springboot Spring Cloud Alibaba Sentinel 下载 导入依赖 <dependency> <groupId>com.alibaba.clo ...

  3. husky 7.0.4 git hooks 前端 commit 钩子 git转svn [已解决]

    husky 7 的安装,注意下版本 第一步 安装 cnpm install husky@7.0.4 --save-dev 第二步 在package.json script加入 "prepar ...

  4. 线上机器 swap 过高导致告警

    哈喽大家好,我是咸鱼. 今天收到了一个告警,说有台服务器上的 swap 过高,已经用了 50% 以上了. 登录机器查看一下内存以及 swap 的使用情况. [root@localhost ~]# fr ...

  5. STM32 SPI DMA 源码解析及总结

    一 前言 最近在调试stm32的SPI时候i,遇到了一个非常诡异的问题.中间花费了不少时间才把问题搞定.这中间暴露的问题值得反思.借此机会,还是梳理一下stm32的SPI的代码做一个总结吧. 二 初始 ...

  6. Access注入-偏移注入

    Access注入-偏移注入 1.偏移注入使用场景及方法 一.偏移注入使用场景及方法 偏移注入使用的场景 1)在sql注入时遇到无法查询数据库字段名时,比如系统自带的数据权限不够无法访问系统自带库 2) ...

  7. php处理序列化jQuery serializeArray数据

    介绍jquery的几个常用处理表单的函数: 1.序列化表单内容元素为字符串,常用于ajax提交. $("form").serialize() 2. serializeArray() ...

  8. [转] vscode C/C++ 插件预定义环境变量(linux)

    原文 假设: 你当前编辑的文件是: /home/your-username/your-project/folder/file.ext /home/your-username/your-project ...

  9. 记录--【vue3】写hook三天,治好了我的组件封装强迫症。

    这里给大家分享我在网上总结出来的一些知识,希望对大家有所帮助 前言 我以前很喜欢封装组件,什么东西不喜欢别人的,总喜欢自己搞搞,这让人很有成就感,虽然是重复造轮子,但是能从无聊的crud业务中暂时解脱 ...

  10. C# 优雅的处理TCP数据(心跳,超时,粘包断包,SSL加密 ,数据处理等)

    Tcp是一个面向连接的流数据传输协议,用人话说就是传输是一个已经建立好连接的管道,数据都在管道里像流水一样流淌到对端.那么数据必然存在几个问题,比如数据如何持续的读取,数据包的边界等. Nagle's ...