我们以前在学习 C++ 构造函数的时候,经常会有以下的一些认知:
- 当类的内部没有提供默认构造函数时,编译器会给类提供一个无实现的无参数的构造函数。
- 当类的内部没有提供默认的析构函数时,编译器会给类的内部提供一个无实现的默认构造函数。
- 当类的内部没有提供拷贝构造函数时,编译器会给类的内部提供一个逐字节拷贝的拷贝构造函数。
这些理解准确吗?
我认为这么理解并不严谨。应该从使用(程序员开发)角度、编译器角度来理解更加准确一些。
接下来,我们从以下几个方面来探讨下编译器是否自动提供相关构造函数的问题:
- 无参默认构造函数
- 析构函数
- 拷贝构造函数
以下代码运行环境为:win10 专业版 + vs2019 社区版。
- 无参默认构造函数 {#title-0}
从开发人员角度:当类内部没有提供无参默认构造函数时,编译器会提供一个无参构造函数。
从编译器角度:是否提供默认的无参构造函数要根据实际场景,只有当需要提供的时候才会提供。
为什么会是这样呢?
大家思考下,如果不需要提供默认构造函数,编译器仍然提供,当我们创建对象时,就需要调用构造函数,但是,调用构造函数是需要开销的。如下代码所示:
class Box {};
void test()
{
Box box;
}
Box 是一个空类,如果提供默认构造函数,将会导致代码第 5 行创建 Box 对象时,必须调用构造函数,C++ 是以执行效率著称的语言,肯定不会这么做。
那么,什么情况下编译器才会给类提供默认构造函数呢?有以下几个场景:
- 当类的内部包含对象成员时,并且对象成员存在默认构造函数
- 当类继承了父类,并且父类存在默认构造函数
- 当类中包含虚函数时
1.1 当类的内部包含对象成员时,并且对象成员存在默认构造函数 {#title-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 的对象的构造过程如下:
- 首先,初始化对象成员 weapon;
- 然后,初始化当前对象 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 当类继承了父类,并且父类存在默认构造函数 {#title-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 当类中包含虚函数时 {#title-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 的构造函数中完成的。
- 析构函数 {#title-4}
当类的内部不提供析构函数时,编译器会根据需要确定是否为类自动添加析构函数。主要有以下几个场景:
- 类的内部包含对象成员,并且对象成员存在析构函数。
- 当子类继承父类,并且父类存在析构函数。
这是因为,当对象析构时,需要调用对象成员的析构函数、或者父类的析构函数。那么,析构函数的调用代码写在哪里呢?
编译器给类增加一个析构函数,并在析构函数中添加调用对象成员、父类析构函数的的代码。
- 拷贝构造函数 {#title-5}
当类的内部不提供拷贝构造函数,比那一起也会根据需要确定是否为该类增加拷贝构造函数。
编译器为了增加默认的拷贝构造函数,主要包含以下几个场景:
- 类的内部包含对象成员,并且对象成员存在拷贝构造函数。
- 当子类继承父类,并且父类存在析构函数。
- 当类内部包含虚函数时。
前两种情况和前面构造函数、析构函数的原因相同。为了能够实现对象成员、父类数据的拷贝,并且父类提供了拷贝构造函数,当前类或者子类必须添加拷贝构造函数,并在其中调用对象成员或者父类的拷贝构造函数完成对象拷贝。
当类内部包含虚函数时,为什么也会提供默认的拷贝构造函数呢?
请看下面的代码:
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 个。
至此,本文章讲解完毕,希望对你有帮助!