接下来我将持续更新“深度解读《深度探索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. SQLServer 导入 Excel 表数据报错“文本被截断。。。”

    报错详情(关键信息就是:文本被截断) 错误 0xc020901c: 数据流任务 1: 输出"Excel 源输出"(9) 上的 输出列"xxxxx"(78) 出错 ...

  2. Nginx-web系列

    nginx 系列 目录 nginx 系列 一 简述 1.1 为什么要使用? 1.2 主要用于哪里? 二. Nginx 搭建环境 2.1 版本选择 2.2 环境准备 2.2 yum 直装 2.3 ngi ...

  3. electron fiddle 下载 镜像 下载不下来 已解决 electron-api-demos 安装

    fiddle 官网 https://www.electronjs.org/fiddle 一共3步 1. npm config set registry https://registry.npm.tao ...

  4. C++实现一个简单的生产者-消费者队列

    本文的代码都是ChatGPT生成,我只是做了微小的调整和整合,AI提示词如下: 设计一个C++类,支持生产者-消费者模型,可以通过size函数获取剩余数量 可能第一次生成的不一定合适,多刷新几次. 生 ...

  5. 制作B站直播简介

    本文只用于个人总结备份,如果对你有帮助就更好了. 准备工作 准备好简介要用的的背景图.头像图,上传到图床生成图片链接. 简介的内容可分为主播简介.直播时间.直播内容.联系方式,内容根据实际需要修改,需 ...

  6. K8S容器环境下资源限制与jvm内存回收

    一.k8s中的java资源限制与可能的问题 与以前单机跑单服务的情况相比,在k8s.docker容器化环境下的宿主机内存.cpu相对更大,所以当运行java类程序的时候,就必然有必要对容器进行内存限制 ...

  7. .NET Core使用 CancellationToken 取消API请求

    您是否曾经访问过一个网站,它需要很长时间加载,最终你敲击 F5 重新加载页面. 即使用户刷新了浏览器取消了原始请求,而对于服务器来说,API也不会知道它正在计算的值将在结束时被丢弃,刷新五次,服务器将 ...

  8. Java 22正式发布,一文了解全部新特性

    就在昨晚,Java 22正式发布!该版本提供了 12 项功能增强,其中包括 7 项预览功能和 1 项孵化器功能.它们涵盖了对 Java 语言.API.性能以及 JDK 中包含的工具的改进. 下面就来一 ...

  9. 记录--10个超级实用的Set、Map使用技巧

    这里给大家分享我在网上总结出来的一些知识,希望对大家有所帮助 Set是一种类似于数组的数据结构,但是它的值是唯一的,即Set中的每个值只会出现一次.Set对象的实例可以用于存储任何类型的唯一值,从而使 ...

  10. 记录--开发uniapp nvue App+微信小程序,我踩过的坑( 纯干货 )

    这里给大家分享我在网上总结出来的一些知识,希望对大家有所帮助 最近接了个项目,采用uniapp的nvue开发安卓和ios端+小程序端,第一次开发nvue,对于css布局这块,还是踩了很多坑.以及一些u ...