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

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

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

c++ 复制代码
#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;
}

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

makefile 复制代码
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类的定义里面增加一个这个类的对象成员,增加代码如下:

c++ 复制代码
class Base {
public:
    Base() {
        printf("Base class default constructor\n");
    }
    int a;
};

// 在Object类的定义里加上
Base b;

编译后运行输出:

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

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

vbnet 复制代码
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类里已经定义了默认构造函数呢?如下面的代码:

c++ 复制代码
Object() {
    printf("Object class default constructor\n");
    val = 1;
    str = nullptr;
}

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

kotlin 复制代码
Base class default constructor
Object class default constructor
1		// 这时这行输出结果是明确的

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

ini 复制代码
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类,修改如下:

c++ 复制代码
class Object: public Base {
	// 删除掉Base b;,其它不变
}

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

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

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

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

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

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

typescript 复制代码
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]是虚表的起始地址,看看它的内容是什么:

javascript 复制代码
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,如下面这样:

c++ 复制代码
Object obj;
memset((void*)&obj, 0, sizeof(obj));
Object* pobj = &obj;
pobj->virtual_func();

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

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

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

c++ 复制代码
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标准中,新增了在定义类时直接对成员变量进行初始化的机制,称为类内初始化。如下面的代码:

c++ 复制代码
class Object {
public:
    int val = 1;
    char* str = nullptr;
};

int main() {
    Object obj;
    
    return 0;
}

编译成对应的汇编代码:

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

相关推荐
捕鲸叉5 小时前
创建线程时传递参数给线程
开发语言·c++·算法
A charmer5 小时前
【C++】vector 类深度解析:探索动态数组的奥秘
开发语言·c++·算法
Peter_chq5 小时前
【操作系统】基于环形队列的生产消费模型
linux·c语言·开发语言·c++·后端
青花瓷7 小时前
C++__XCode工程中Debug版本库向Release版本库的切换
c++·xcode
幺零九零零8 小时前
【C++】socket套接字编程
linux·服务器·网络·c++
捕鲸叉8 小时前
MVC(Model-View-Controller)模式概述
开发语言·c++·设计模式
Dola_Pan9 小时前
C++算法和竞赛:哈希算法、动态规划DP算法、贪心算法、博弈算法
c++·算法·哈希算法
yanlou2339 小时前
KMP算法,next数组详解(c++)
开发语言·c++·kmp算法
小林熬夜学编程9 小时前
【Linux系统编程】第四十一弹---线程深度解析:从地址空间到多线程实践
linux·c语言·开发语言·c++·算法
阿洵Rain10 小时前
【C++】哈希
数据结构·c++·算法·list·哈希算法