深度解读《深度探索C++对象模型》之默认构造函数

深度,解读,探索,c++,对象,模型,默认,构造函数 · 浏览次数 : 51

小编点评

**类大小** * 8 字节:如果平台是 32 位,类大小为 4 字节。 * 4 字节:如果平台是 32 位,类大小为 2 字节。 **默认构造函数的生成** 当类没有定义构造函数时,编译器会生成一个默认构造函数。默认构造函数会调用类对象成员或父类的默认构造函数,并将它们初始化到类中的数据成员。 **虚函数表指针** 默认构造函数会在构造对象时设置虚函数表指针。虚函数表指针存储了指向虚函数表的地址。 **代码示例** ```c++ class Object { public: virtual void virtual_func() { printf("This is a virtual function\\"); } int val; char* str; }; ``` **默认构造函数的生成逻辑** 当创建一个 `Object` 对象时,编译器会执行以下步骤: 1. 初始化 `rsp`指针到对象起始地址。 2. 读取 `vtable for Object` 的首地址。 3. 从 `vtable` 中获取虚函数表指针。 4. 将虚函数表指针赋值给 `rax` 指数。 5. 从 `rax` 指数开始,依次读取三个字节,这些字节存储了虚函数表的长度。 6. 创建虚函数表,并设置其长度。 7. 在构造函数中调用虚函数表。 **其他情况** 除了上述情况之外,如果类中没有定义任何构造函数,编译器不会生成默认构造函数,但可以构造出对象。

正文

接下来我将持续更新“深度解读《深度探索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++对象模型》之默认构造函数相似的内容:

深度解读《深度探索C++对象模型》之默认构造函数

C++的默认构造函数的作用是什么?什么时候会需要一个默认构造函数,以及默认构造函数从哪里来?这篇文章将从编译器的角度来分析这个问题。

深度解读《深度探索C++对象模型》之C++虚函数实现分析(三)

本系列深入分析编译器对于C++虚函数的底层实现,最后分析C++在多态的情况下的性能是否有受影响,多态究竟有多大的性能损失。

深度解读《深度探索C++对象模型》之C++虚函数实现分析(二)

本系列深入分析编译器对于C++虚函数的底层实现,最后分析C++在多态的情况下的性能是否有受影响,多态究竟有多大的性能损失。

深度解读《深度探索C++对象模型》之C++虚函数实现分析(一)

本系列深入分析编译器对于C++虚函数的底层实现,最后分析C++在多态的情况下的性能是否有受影响,多态究竟有多大的性能损失。

深度解读《深度探索C++对象模型》之数据成员的存取效率分析(二)

C++对象在经过类的封装后,存取对象中的数据成员的效率是否相比C语言的结构体访问效率要低下?本篇将从C++类的不同定义形式来一一分析C++对象的数据成员的访问在编译器中是如何实现的,以及它们的存取效率如何?

Python生成器深度解析:构建强大的数据处理管道

# 前言 生成器是Python的一种核心特性,允许我们在请求新元素时再生成这些元素,而不是在开始时就生成所有元素。它在处理大规模数据集、实现节省内存的算法和构建复杂的迭代器模式等多种情况下都有着广泛的应用。在本篇文章中,我们将从理论和实践两方面来探索Python生成器的深度用法。 ## 生成器的定义

Django ORM:最全面的数据库处理指南

**深度探讨Django ORM的概念、基础使用、进阶操作以及详细解析在实际使用中如何处理数据库操作。同时,我们还讨论了模型深入理解,如何进行CRUD操作,并且深化理解到数据库迁移等高级主题。为了全面解读Django ORM,我们也讨论了其存在的不足,并对其未来发展进行了展望。这篇文章旨在帮助读者全

编织人工智能:机器学习发展历史与关键技术全解析

>本文全面回顾了机器学习的发展历史,从早期的基本算法到当代的深度学习模型,再到未来的可解释AI和伦理考虑。文章深入探讨了各个时期的关键技术和理念,揭示了机器学习在不同领域的广泛应用和潜力。最后,总结部分强调了机器学习作为一种思维方式和解决问题的工具,呼吁所有参与者共同探索更智能、更可持续的未来,同时

OpenCV实战:从图像处理到深度学习的全面指南

> 本文深入浅出地探讨了OpenCV库在图像处理和深度学习中的应用。从基本概念和操作,到复杂的图像变换和深度学习模型的使用,文章以详尽的代码和解释,带领大家步入OpenCV的实战世界。 # 1. OpenCV简介 ## 什么是OpenCV? ![file](https://img2023.cnblo

深入解析React DnD拖拽原理,轻松掌握拖放技巧!

>我们是[袋鼠云数栈 UED 团队](http://ued.dtstack.cn/),致力于打造优秀的一站式数据中台产品。我们始终保持工匠精神,探索前端道路,为社区积累并传播经验价值。。 >本文作者:霁明 # 一、背景 ## 1、业务背景 业务中会有一些需要实现拖拽的场景,尤其是偏视觉方向以及移动端