接下来我将持续更新“深度解读《深度探索C++对象模型》”系列,敬请期待,欢迎关注!也可以关注公众号:iShare爱分享,主动获得推文。

提到默认构造函数,很多文章和书籍里提到:“在需要的时候编译器会自动生成一个默认构造函数”。那么关键的问题来了,到底是什么时候需要?是谁需要?比如下面的代码会生成默认构造函数吗?

#include <cstdio>

class Object {
public:
int val;
char* str;
}; int main() {
Object obj;
if (obj.val == 0 || obj.str == nullptr) {
printf("1\n");
} else {
printf("2\n");
} return 0;
}

答案是不会,为了获得确切的信息,我们把它编译成汇编语言,编译器是否有在背后给我们的代码增加代码或者扩充修改我们的代码,编译成汇编代码后便一目了然。下面是上面代码对应的汇编代码:

main:                                   # @main
push rbp
mov rbp, rsp
sub rsp, 32
mov dword ptr [rbp - 4], 0
cmp dword ptr [rbp - 24], 0
je .LBB0_2
cmp qword ptr [rbp - 16], 0
jne .LBB0_3
.LBB0_2:
lea rdi, [rip + .L.str]
mov al, 0
call printf@PLT
jmp .LBB0_4
.LBB0_3:
lea rdi, [rip + .L.str.1]
mov al, 0
call printf@PLT
.LBB0_4:
xor eax, eax
add rsp, 32
pop rbp
ret
.L.str:
.asciz "1\n"
.L.str.1:
.asciz "2\n"

从生成的汇编代码中并没有看到默认构造函数的代码,说明编译器这时不会为我们生成一个默认构造函数。

上面的C++例子中,程序的意图是想要有一个默认构造函数来初始化两个数据成员,这种情况是上面提到的“在有需要的时候”吗?很显然不是。这是程序的需要,是需要写代码的程序员去做这个事情,是程序员的责任而不是编译器的责任。只有编译器需要的时候,编译器才会生成一个默认构造函数,而且就算编译器生成了默认构造函数,类中的那两个数据成员也不会被初始化,除非定义的对象是全局的或者静态的。请记住,初始化对象中的成员的责任是程序员的,不是编译器的。现在我们知道了在只有编译器需要的时候才会生成默认构造函数,那么是什么时候才会生成呢?下面我们分几种情况来一一探究。

类中含有默认构造函数的类类型成员

编译器会生成默认构造函数的前提是:

  1. 没有任何用户自定义的构造函数;
  2. 类中至少含有一个成员是类类型的成员。

在上面的例子中我们增加一个类的定义,此类定义了一个默认构造函数,然后在Object类的定义里面增加一个这个类的对象成员,增加代码如下:

class Base {
public:
Base() {
printf("Base class default constructor\n");
}
int a;
}; // 在Object类的定义里加上
Base b;

编译后运行输出:

Base class default constructor
2 // 这一行的输出是随机的,暂时先不管

从结果可以看到,Base类的默认构造函数被调用了,那么肯定是有一个地方来调用它的,调用它的地方就在Object类的默认构造函数里面,我们来看下汇编代码,节选部分:

main:                                   # @main
push rbp
mov rbp, rsp
sub rsp, 32
mov dword ptr [rbp - 4], 0
lea rdi, [rbp - 32]
call Object::Object() [base object constructor]
cmp dword ptr [rbp - 32], 0
je .LBB0_2
cmp qword ptr [rbp - 24], 0
jne .LBB0_3 Object::Object() [base object constructor]: # @Object::Object() [base object constructor]
push rbp
mov rbp, rsp
sub rsp, 16
mov qword ptr [rbp - 8], rdi
mov rdi, qword ptr [rbp - 8]
add rdi, 16
call Base::Base() [base object constructor]
add rsp, 16
pop rbp
ret

从上面main函数的汇编代码里面第7行看到调用了Object::Object()函数,这个就是编译器为我们代码生成的Object类的默认构造函数,看看这个构造函数的汇编代码,在第20行代码里看到它调用了Base::Base(),也就是调用了Base的默认构造函数。

我们再仔细看一下Object类的默认构造函数的汇编代码,发现里面根本没有给两个成员变量val和str初始化,这也确确实实地说明了,类中成员变量的初始化的责任是程序员的责任,不是编译器的责任,如果需要初始化成员变量,需要在代码中明确地对它们进行初始化,编译器不会在背后隐式地初始化成员变量。所以上面程序的输出结果是一个随机的结果,有可能是1也有可能是2,因为不知道var或者str的值到底是什么。

那么如果Base类里面没有定义了默认构造函数,那么是否还会生成默认构造函数呢?可以把Base类里的默认构造函数代码注释掉,编译试试,结果可以看到这时并不会生成默认构造函数,无论是Base类还是Object类都不会。因为这时候编译器不需要,编译器不需要生成代码去调用Base类的默认构造函数,这也验证了是否生成默认构造函数是看编译器的需要而非看程序的需要。

那如果在Object类里已经定义了默认构造函数呢?如下面的代码:

Object() {
printf("Object class default constructor\n");
val = 1;
str = nullptr;
}

上面的默认构造函数的代码里显示地初始化了两个数据成员,但并没有显示初始化类成员对象b,我们看下运行输出结果:

Base class default constructor
Object class default constructor
1 // 这时这行输出结果是明确的

从结果来看,Base类的默认构造函数是被调用了的,我们并没有显示地调用它,那么它是在哪里被调用的呢?我们继续看下汇编代码:

Object::Object() [base object constructor]: # @Object::Object() [base object constructor]
push rbp
mov rbp, rsp
sub rsp, 16
mov qword ptr [rbp - 8], rdi
mov rdi, qword ptr [rbp - 8]
mov qword ptr [rbp - 16], rdi # 8-byte Spill
add rdi, 16
call Base::Base() [base object constructor]
lea rdi, [rip + .L.str.2]
mov al, 0
call printf@PLT
mov rax, qword ptr [rbp - 16] # 8-byte Reload
mov dword ptr [rax], 1
mov qword ptr [rax + 8], 0
add rsp, 16
pop rbp
ret

上面只节选了Object类的默认构造函数的汇编代码,其它的代码不用关注。上面汇编代码的第9行就是调用Base类的默认构造函数,第13到15行是给val和str赋值,[rbp - 16]是对象的起始地址,把它放到rax寄存器中,然后给它赋值为1,按声明顺序这应该是val变量,rax+8表示对象首地址偏移8字节,也即是str的地址,给它赋值为0,也就是空指针。这说明了在有用户自定义默认函数的情况下,编译器会插入一些代码去调用类类型成员的构造函数,帮助程序员去构造这个类对象成员,前提是这个类对象成员定义了默认构造函数,它需要被调用去初始化这个类对象,编译器这时才会生成一些代码去自动调用它。如果类中定义多个类对象成员,那么编译器将会按照声明的顺序依次去调用它们的构造函数。

继承自带有默认构造函数的类

编译器会自动生成默认构造函数的第二中情况是:

  1. 类中没有定义任何构造函数,
  2. 但继承自一个父类,这个父类定义了默认构造函数。

把上面的代码修改一下,Base类不再是Object的成员,而是改为Object类继承了Base类,修改如下:

class Object: public Base {
// 删除掉Base b;,其它不变
}

查看生成的汇编代码,可以看到编译器生成了Object类的默认构造函数的代码,里面调用了Base类的默认构造函数,代码这里就不贴出来了,跟上面的代码大同小异。其它的情况跟上面小节的分析很相似,这里也不再重复分析。

类中声明或者继承一个虚函数

  1. 如果一个类中定义了一个及以上的虚函数;
  2. 或者继承链上有一个以上的父类有定义了虚函数,同时类中没有任何自定义的构造函数。

那么编译器则会生成一个默认构造函数。《C++对象封装后的内存布局》一文中也提到,增加了虚函数后对象的大小会增加一个指针的大小,大小为8字节或者4字节(跟平台有关)。这个指针指向一个虚函数表,一般位于对象的起始位置。其实这个指针的值就是编译器设置的,在没有用户自定义构造函数的情况下,编译器会自动生成默认构造函数,并在其中设置这个值。下面来看下例子,去掉继承,在Object类中增加一个虚函数,其它不变,如下:

class Object {
public:
virtual void virtual_func() {
printf("This is a virtual function\n");
}
int val;
char* str;
};

来看下生成的汇编代码,节选Object类的默认构造函数:

Object::Object() [base object constructor]:		# @Object::Object() [base object constructor]
push rbp
mov rbp, rsp
mov qword ptr [rbp - 8], rdi
mov rax, qword ptr [rbp - 8]
lea rcx, [rip + vtable for Object]
add rcx, 16
mov qword ptr [rax], rcx
pop rbp
ret

说明编译器为我们生成了一个默认构造函数,我们来看看默认构造函数的代码里面干了什么。第2、3行时保存上个函数的堆栈信息,保证不破坏上个函数的堆栈。第4行rdi寄存器保存的是第一个参数,这个值是main函数调用这个默认构造函数时设置的,是对象的首地址,第5行是把它保存到rax寄存器中。第6-8行是最主要的内容,它的作用就是设置虚函数表指针的值,[rip + vtable for Object]是虚表的起始地址,看看它的内容是什么:

vtable for Object:
.quad 0
.quad typeinfo for Object
.quad Object::virtual_func()

它是一个表格,每一项占用8字节大小,共有三项内容,第一项内容为0,暂时先不管它,第二项是RTTI信息,这里只是一个指针,指向具体的RTTI表格,这里先不展开,第三项才是保存的虚函数Object::virtual_func的地址。所以第7行代码中加了16字节的偏移量,就是跳过前面两项,取得第三项的地址,然后第8行里把它赋值给[rax],这个地址就是对象的首地址,至此就完成了在对象的起始地址插入虚函数表指针的动作。

如果已经自定义了默认构造函数,那么编译器则会在自定义的函数里面插入设置虚函数表指针的这段代码,确保虚函数能够被正确调用。如果Object类没有定义虚函数,而是继承了一个有虚函数的类,那么它也继承了这个虚函数,编译器就会为它生成虚函数表,然后设置虚函数表指针,也就是会生成默认构造函数来做这个事情。

这里顺带提一下一个编码的误区,如果不小心可能就会掉入坑里,就是在这种情况下,如果你想要快速初始化两个数据成员,或者是受C语言使用习惯影响,直接使用memset函数来把obj对象清0,如下面这样:

Object obj;
memset((void*)&obj, 0, sizeof(obj));
Object* pobj = &obj;
pobj->virtual_func();

那么如最后一行使用指针或者引用来调用虚函数的时候,程序执行到这里就会崩溃,因为在默认构造函数里给对象设置的虚函数表指针被清空了,调用虚函数的时候是需要通过虚函数表指针去拿到虚函数的地址然后调用的,所以这里解引用了一个空指针,引起了程序的崩溃。所以请记住不要随便对一个类对象进行memset操作,除非这个类你确定只含有纯数据成员才可以这样做。

类的继承链上有一个virtual base class

如果类的继承链上有一个虚基类,同时类中没有定义任何构造函数,那么编译器就会自动生成一个默认构造函数,它的作用同上面分析虚函数时是一样的,就是在默认构造函数里设置虚表指针。如下面的例子:

class Grand {
public:
int a;
}; class Base1: virtual public Grand {
public:
int b;
}; class Base2: virtual public Grand {
public:
int c;
}; class Derived: public Base1, public Base2 {
public:
int d;
}; int main() {
Derived obj;
obj.a = 1;
Grand* p = &obj;
p->a = 10; return 0;
}

想要访问爷爷类Grand中的成员a,如果是通过静态类型的方式访问,如上面代码中的第23行,那么编译时是可以确定a相对于对象起始地址的偏移量的,直接通过偏移量就可以访问到,这在编译时就可以确定下来的。如果是通过动态类型来访问,也就是说是通过父类的指针或者引用类型来访问,因为在编译时不知道在运行时它指向什么类型,它既可以指向爷爷类或者父类,也可以指向孙子类,所以在编译时并不能确定它的具体类型,也就不能确定它的偏移量,所以这种情况只能通过虚表来访问,编译器在编译时会生成一个虚表,其实是和虚函数共用同一张表,也有的编译器是分开的,不同的编译器有不同的实现方法。通过在表中记录不同的类型有不同的偏移量,那么在运行时可以通过访问表得到具体的偏移量,从而得到成员a的地址。所以需要在对象构造时设置虚表的指针,具体的汇编代码跟上面虚函数的类似。

类内初始化

在C++11标准中,新增了在定义类时直接对成员变量进行初始化的机制,称为类内初始化。如下面的代码:

class Object {
public:
int val = 1;
char* str = nullptr;
}; int main() {
Object obj; return 0;
}

编译成对应的汇编代码:

main:                                   # @main
push rbp
mov rbp, rsp
sub rsp, 32
mov dword ptr [rbp - 4], 0
lea rdi, [rbp - 24]
call Object::Object() [base object constructor]
xor eax, eax
add rsp, 32
pop rbp
ret
Object::Object() [base object constructor]: # @Object::Object() [base object constructor]
push rbp
mov rbp, rsp
mov qword ptr [rbp - 8], rdi
mov rax, qword ptr [rbp - 8]
mov dword ptr [rax], 1
mov qword ptr [rax + 8], 0
pop rbp
ret

类内初始化,就是告诉编译器需要对这些成员变量在构造时进行初始化,那么编译器就需要生成一个默认构造函数来做这个事情,从上面的汇编代码可以看到,在Object::Object()函数里,第17、18行代码即是对两个成员分别赋值。

总结

上面的五种情况,编译器必须要为没有定义构造函数的类生成一个默认构造函数,或者在程序员定义的默认构造函数中扩充内容。这个被生成出来的默认构造函数只是为了满足编译器的需要而非程序员的需要,它需要去调用类对象成员或者父类的默认构造函数,或者设置虚表指针,所以在这个生成的默认构造函数里,它默认不会去初始化类中的数据成员,初始化它们是程序员的责任。

除了这几种情况之外的,如果我们没有定义任何构造函数,编译器也没有生成默认构造函数,但是它们却可以构造出来。C++语言的语义保证了在这种情况下,它们有一个隐式的、平凡的(或者无用的)默认构造函数来帮助构造对象,但是它们并不会也不需要被显示的生成出来。

此篇文章同步发布于我的微信公众号:编译器背后的行为之默认构造函数

如果您感兴趣这方面的内容,请在微信上搜索公众号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. [读书系列] 深度探索C++对象模型 初读

    2012年底-2014年初这段时间主要用C++做手游开发,时隔3年,重新拿起<深度探索C++对象模型>这本书,感觉生疏了很多,如果按前阵子的生疏度来说,现在不借助Visual Studio ...

  5. 深度探索C++对象模型

    深度探索C++对象模型 什么是C++对象模型: 语言中直接支持面向对象程序设计的部分. 对于各个支持的底层实现机制. 抽象性与实际性之间找出平衡点, 需要知识, 经验以及许多思考. 导读 这本书是C+ ...

  6. 《深度探索C++对象模型》读书笔记(一)

    前言 今年中下旬就要找工作了,我计划从现在就开始准备一些面试中会问到的基础知识,包括C++.操作系统.计算机网络.算法和数据结构等.C++就先从这本<深度探索C++对象模型>开始.不同于& ...

  7. 读书笔记《深度探索c++对象模型》 概述

    <深度探索c++对象模型>这本书是我工作一段时间后想更深入了解C++的底层实现知识,如内存布局.模型.内存大小.继承.虚函数表等而阅读的:此外在很多面试或者工作中,对底层的知识的足够了解也 ...

  8. 柔性数组-读《深度探索C++对象模型》有感 (转载)

    最近在看<深度探索C++对象模型>,对于Struct的用法中,发现有一些地方值得我们借鉴的地方,特此和大家分享一下,此间内容包含了网上搜集的一些资料,同时感谢提供这些信息的作者. 原文如下 ...

  9. 柔性数组-读《深度探索C++对象模型》有感

    最近在看<深度探索C++对象模型>,对于Struct的用法中,发现有一些地方值得我们借鉴的地方,特此和大家分享一下,此间内容包含了网上搜集的一些资料,同时感谢提供这些信息的作者. 原文如下 ...

  10. 拾遗与填坑《深度探索C++对象模型》3.3节

    <深度探索C++对象模型>是一本好书,该书作者也是<C++ Primer>的作者,一位绝对的C++大师.诚然该书中也有多多少少的错误一直为人所诟病,但这仍然不妨碍称其为一本好书 ...

随机推荐

  1. 6、mysql的SQL优化

    1. 大批量插入数据 1) 主键顺序插入 因为InnoDB类型的表是按照主键的顺序保存的,所以将导入的数据按照主键的顺序排列,可以有效的提高导入数据的效率.如果InnoDB表没有主键,那么系统会自动默 ...

  2. 基于 Mindspore 框架与 ModelArts 平台的 MNIST 手写体识别实验

    简介 实验包含 2部分: 基于 Mindspore 框架的模型本地训练及预测 基于 Modelarts 平台和 PyTorch框架的模型训练及部署 基于 Mindspore 框架的模型本地训练及预测 ...

  3. ubuntu中在命令行如何打开图形界面的文件夹的几种方法

    方法一: 使用自带的命令:nautilus . 打开当前文件夹 nautilus . 打开指定路径文件夹 nautilus ddd/ccc/ 方法二:xdg-open xdg-open 命令相当于在 ...

  4. TLSR8258方案开发之BLE协议接口代码解析

    一 前言 这里的代码是在原厂基础上修改了不少.虽然代码复杂了不少,但是逻辑也清晰了不少. 二  广播协议 想要熟悉并修改ble的广播协议和内容,请查阅结构体: static const attribu ...

  5. 后端基础PHP—正则表达

    后端基础PHP-正则表达式 1.正则表达式的介绍 2.正则表达式的语法 一.正则表达式的介绍 正则表达式的介绍 · 正则表达式,又称规则表达式,通过一种特殊的语言来挑选符合条件的数据 · 在代码中简写 ...

  6. 常用命令rsyncscp-1

    常用命令:rsync/scp scp scp命令文件传输 scp命令用于在Linux下进行远程拷贝文件的命令,和它类似的命令有cp,不过cp只是在本机进行拷贝不能跨服务器,而且scp传输是加密的.可能 ...

  7. 数据好合: Argilla 和 Hugging Face Spaces 携手赋能社区合力构建更好的数据集

    最近,Argilla 和 Hugging Face 共同 推出 了 Data is Better Together 计划,旨在凝聚社区力量协力构建一个对提示进行排名的偏好数据集.仅用几天,我们就吸引了 ...

  8. SSH和SFTP是否相同

    SSH和SFTP是否相同?SSH和SFTP是经典的对.在确保通信安全方面,它们交织在一起,尽管它们具有类似的功能,但它们并不是一回事.那么,它们之间有什么区别?请仔细阅读,找出答案. 什么是SSH? ...

  9. MicroNet: 低秩近似分解卷积以及超强激活函数,碾压MobileNet | 2020新文分析

    论文提出应对极低计算量场景的轻量级网络MicroNet,包含两个核心思路Micro-Factorized convolution和Dynamic Shift-Max,Micro-Factorized ...

  10. java:寻找两个字符串的最长公共子串

    java:寻找两个字符串的最长公共子串 // 找一个字符串的所有子串 public static List<String> findAllStr(String s) { List<S ...