C++ 构造函数和编译器探讨

我们以前在学习 C++ 构造函数的时候,经常会有以下的一些认知:

  1. 当类的内部没有提供默认构造函数时,编译器会给类提供一个无实现的无参数的构造函数。
  2. 当类的内部没有提供默认的析构函数时,编译器会给类的内部提供一个无实现的默认构造函数。
  3. 当类的内部没有提供拷贝构造函数时,编译器会给类的内部提供一个逐字节拷贝的拷贝构造函数。

这些理解准确吗?

我认为这么理解并不严谨。应该从使用(程序员开发)角度、编译器角度来理解更加准确一些。

接下来,我们从以下几个方面来探讨下编译器是否自动提供相关构造函数的问题:

  1. 无参默认构造函数
  2. 析构函数
  3. 拷贝构造函数

以下代码运行环境为:win10 专业版 + vs2019 社区版。

1. 无参默认构造函数

从开发人员角度:当类内部没有提供无参默认构造函数时,编译器会提供一个无参构造函数。

从编译器角度:是否提供默认的无参构造函数要根据实际场景,只有当需要提供的时候才会提供。

为什么会是这样呢?

大家思考下,如果不需要提供默认构造函数,编译器仍然提供,当我们创建对象时,就需要调用构造函数,但是,调用构造函数是需要开销的。如下代码所示:

class Box {};

void test()
{
	Box box;
}

Box 是一个空类,如果提供默认构造函数,将会导致代码第 5 行创建 Box 对象时,必须调用构造函数,C++ 是以执行效率著称的语言,肯定不会这么做。

那么,什么情况下编译器才会给类提供默认构造函数呢?有以下几个场景:

  1. 当类的内部包含对象成员时,并且对象成员存在默认构造函数
  2. 当类继承了父类,并且父类存在默认构造函数
  3. 当类中包含虚函数时

1.1 当类的内部包含对象成员时,并且对象成员存在默认构造函数

请先看下面的示例代码:

class Weapon
{
public:
	Weapon() {}
};

class Person 
{
public:
	Weapon weapon;
};

void test()
{
	Person person;
}

第 10 行代码:Person 类的内部包含了对象成员 weapon。

第4 行代码:weapon 对象内部包含了无参的默认构造函数。

此时,我们看下第 15 行代码的反汇编结果:

Person person;
lea         ecx,[person]  
call        Person::Person (0171375h)

从汇编代码来看,创建 person 对象时调用了 Person 的构造函数,但是该构造函数我们并没有提供,所以,这是编译器给 Person 类自动添加的构造函数。

有些同学会想,为什么这种场景下,编译器会为 Person 添加默认构造函数呢?

这是因为当类的内部存在对象成员时,person 的对象的构造过程如下:

  1. 首先,初始化对象成员 weapon;
  2. 然后,初始化当前对象 person.

如果,对象成员 weapon 没有提供构造函数,编译器则认为该对象不需要任何初始化操作。但是,一旦增加了默认构造函数,编译器就必须调用该构造函数。

那么,调用对象成员的默认构造函数的代码写在哪里呢?

所以,只能给当前对象 person 增加一个默认构造函数,在其中编写调用 weapon 构造函数的代码。

请看下面编译器默认添加的构造函数的汇编代码:

00171770  push        ebp  
00171771  mov         ebp,esp  
00171773  sub         esp,0CCh  
00171779  push        ebx  
0017177A  push        esi  
0017177B  push        edi  
0017177C  push        ecx  
0017177D  lea         edi,[ebp-0CCh]  
00171783  mov         ecx,33h  
00171788  mov         eax,0CCCCCCCCh  
0017178D  rep stos    dword ptr es:[edi]  
0017178F  pop         ecx  
00171790  mov         dword ptr [this],ecx  
00171793  mov         ecx,dword ptr [this]  
00171796  call        Weapon::Weapon (01711BDh)  
0017179B  mov         eax,dword ptr [this]  
0017179E  pop         edi  
0017179F  pop         esi  
001717A0  pop         ebx  
001717A1  add         esp,0CCh  
001717A7  cmp         ebp,esp  
001717A9  call        __RTC_CheckEsp (017123Fh)  
001717AE  mov         esp,ebp  
001717B0  pop         ebp  
001717B1  ret

从第 15 行代码中,我们发现调用了 Weapon 类的构造函数。

1.2 当类继承了父类,并且父类存在默认构造函数

请先看下面的示例代码:

class Animal
{
public:
	Animal() {}
};

class Cat : public  Animal {};

void test()
{
	Cat cat;
}

我们接下来,看下第 11 行代码对应的汇编代码:

Cat cat;
lea         ecx,[cat]  
call        Cat::Cat (09B13BBh) 

从汇编代码可以看到,编译器调用了 Cat 的构造函数,而我们又没有提供构造函数,所以,这是编译器自动添加的构造函数。

为什么父类 Animal 存在默认构造函数时,编译器就会为子类 Cat 增加默认构造函数呢?

原因和前面 1.1 中叙述是一样的。父类的构造函数需要调用,那么,调用父类构造函数的代码写在哪里呢?

所以,编译器就为 Cat 类增加一个构造函数,并在其中安插调用父类 Animal 构造函数的代码,请看下面 Cat 类构造函数的汇编代码:

009B17D0  push        ebp  
009B17D1  mov         ebp,esp  
009B17D3  sub         esp,0CCh  
009B17D9  push        ebx  
009B17DA  push        esi  
009B17DB  push        edi  
009B17DC  push        ecx  
009B17DD  lea         edi,[ebp-0CCh]  
009B17E3  mov         ecx,33h  
009B17E8  mov         eax,0CCCCCCCCh  
009B17ED  rep stos    dword ptr es:[edi]  
009B17EF  pop         ecx  
009B17F0  mov         dword ptr [this],ecx  
009B17F3  mov         ecx,dword ptr [this]  
009B17F6  call        Animal::Animal (09B13B6h)  
009B17FB  mov         eax,dword ptr [this]  
009B17FE  pop         edi  
009B17FF  pop         esi  
009B1800  pop         ebx  
009B1801  add         esp,0CCh  
009B1807  cmp         ebp,esp  
009B1809  call        __RTC_CheckEsp (09B123Fh)  
009B180E  mov         esp,ebp  
009B1810  pop         ebp  
009B1811  ret  

从第 15 行我们看到,Cat 构造函数中调用了 Animal 的构造函数。

1.3 当类中包含虚函数时

请先看下面的示例代码:

class Animal
{
public:
	virtual void show() {}
};

void test()
{
	Animal animal;
}

第 4 行在 Animal 类内部增加了虚函数 show。

第 9 行创建 animal 对象代码对应的汇编代码如下:

Animal animal;
lea         ecx,[animal]  
call        Animal::Animal (083111Dh)  

很显然,编译器为 Animal 增加了默认的构造函数。那么,此处增加的默认构造函数到底做了什么事情呢?

请看 Animal 构造函数的汇编代码:

008317B0  push        ebp  
008317B1  mov         ebp,esp  
008317B3  sub         esp,0CCh  
008317B9  push        ebx  
008317BA  push        esi  
008317BB  push        edi  
008317BC  push        ecx  
008317BD  lea         edi,[ebp-0CCh]  
008317C3  mov         ecx,33h  
008317C8  mov         eax,0CCCCCCCCh  
008317CD  rep stos    dword ptr es:[edi]  
008317CF  pop         ecx  
008317D0  mov         dword ptr [this],ecx  
008317D3  mov         eax,dword ptr [this]  
008317D6  mov         dword ptr [eax],offset Animal::`vftable' (0837B34h)  
008317DC  mov         eax,dword ptr [this]  
008317DF  pop         edi  
008317E0  pop         esi  
008317E1  pop         ebx  
008317E2  mov         esp,ebp  
008317E4  pop         ebp  
008317E5  ret  

从上面的汇编代码第 15 行,我们可以得知:该构造函数内部其实包含了一些和虚函数表有关的代码。

我们知道 ,当类的内部包含虚函数时,编译器会创建一张包含了虚函数入口地址的表,并且在创建的 Animal 对象内部安插一个 vfptr(虚函数表指针,指向虚函数表的指针),该指针的初始化操作就是在 Animal 的构造函数中完成的。

2. 析构函数

当类的内部不提供析构函数时,编译器会根据需要确定是否为类自动添加析构函数。主要有以下几个场景:

  1. 类的内部包含对象成员,并且对象成员存在析构函数。
  2. 当子类继承父类,并且父类存在析构函数。

这是因为,当对象析构时,需要调用对象成员的析构函数、或者父类的析构函数。那么,析构函数的调用代码写在哪里呢?

编译器给类增加一个析构函数,并在析构函数中添加调用对象成员、父类析构函数的的代码。

3. 拷贝构造函数

当类的内部不提供拷贝构造函数,比那一起也会根据需要确定是否为该类增加拷贝构造函数。

编译器为了增加默认的拷贝构造函数,主要包含以下几个场景:

  1. 类的内部包含对象成员,并且对象成员存在拷贝构造函数。
  2. 当子类继承父类,并且父类存在析构函数。
  3. 当类内部包含虚函数时。

前两种情况和前面构造函数、析构函数的原因相同。为了能够实现对象成员、父类数据的拷贝,并且父类提供了拷贝构造函数,当前类或者子类必须添加拷贝构造函数,并在其中调用对象成员或者父类的拷贝构造函数完成对象拷贝。

当类内部包含虚函数时,为什么也会提供默认的拷贝构造函数呢?

请看下面的代码:

class Animal
{
public:
	Animal() {}
	virtual void show() {}
};


void test()
{
	Animal a1;
	Animal a2(a1);
}

第 11 行创建 a1 对象时,编译器会创建一张虚函数表(该表并不存储在 a1 对象内部),并且在 a1 对象中安插一个 vfptr 指针,并使其指向虚函数表。

第 12 行创建 a2 对象时,通过对象拷贝行为,此时由于 a2 对象内部有一个特殊的 vfptr 指针需要初始化,此时,编译器给 Animal 增加了拷贝构造函数,并在拷贝构造函数中编写初始化 vfptr 的代码,使其指向虚函数表。

注意:a1 和 a2 中 vfptr 虚函数表指针指向的是同一个虚函数表。虚函数表不会因为创建了多个对象而创建多个,而 vfptr 指针则是每个创建的对象都包含 1 个。

至此,本文章讲解完毕,希望对你有帮助!

未经允许不得转载:一亩三分地 » C++ 构造函数和编译器探讨
评论 (0)

5 + 3 =