写点什么

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

作者:爱分享
  • 2024-04-16
    广东
  • 本文字数:6107 字

    阅读完需:约 20 分钟

接下来我将持续更新“深度解读《深度探索 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 constructor2  // 这一行的输出是随机的,暂时先不管
复制代码


从结果可以看到,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 constructorObject class default constructor1    // 这时这行输出结果是明确的
复制代码


从结果来看,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    retObject::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 并关注,以便在内容更新时直接向您推送。



发布于: 24 分钟前阅读数: 5
用户头像

爱分享

关注

还未添加个人签名 2024-04-08 加入

还未添加个人简介

评论

发布
暂无评论
深度解读《深度探索C++对象模型》之默认构造函数_c++_爱分享_InfoQ写作社区