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