构造函数与析构函数是类的重要组成部分,它们在类中担任着至关重要的工作。构造函数常用来完成对象生成时数据初始化工作,而析构函数则常用于销毁时释放对象中所申请的资源。
当对象生成时,编译器会自动产生调用其类构造函数的代码,在编码过程中可以为类中的数据成员赋予恰当的初始值。当对象销毁时,编译器同样也会产生调用其类析构函数的代码。
构造函数与析构函数都是类中特殊的成员函数,构造函数支持函数重载,而析构函数只能是一个无参函数。它们不可定义放回值,调用构造函数后,返回值为对象首地址,也就是this指针。
在某些情况下,编译器会提供默认的构造函数和析构函数,但并不是任何情况下编译器都会提供。那么在何种情况下编译器会提供默认的构造函数和析构函数?编译器又是如何调用它们的呢?
构造函数出现的时机
对象生成时会自动调用构造函数。只要找到了定义对象的地方就找到了构造函数的调用时机,这看似简单,实际情况却相反,不同作用域的对象生命周期不同,如同局部对象、全局对象、静态对象等的生命周期各不相同,而当对象作为函数参数与返回值时,构造函数的出现时机又会有所不同。
将对象进行分类:不同类型对象的构造函数被调用的时机会发生变化,但都会遵循C++语法:定义的同时调用构造函数。那么,只要知道了对象的生命周期,便可推断出构造函数的调用时机。下面先根据生命周期将对象进行分类,然后分析各类对象的构造函数和析构函数的调用时机
- 局部对象
- 堆对象
- 参数对象
- 返回对象
- 全局对象
- 静态对象
局部对象
局部对象下的构造函数的出现时机比较容易识别。当对象产生时,便有可能引发构造函数的调用。编译器隐藏了构造函数的调用过程,使编码者无法看到调用细节。1
2
3
4
5
6
7
8
9
10
11
12
13
14class CNumber
{
public:
CNumber() //无参构造函数
{
m_nNumber = 1;
}
int m_nNumber;
};
int main(int argc, char* argv[])
{
CNumber Number; //类对象定义
return 0;
}
CNumber Number;
1
2
3 00401038 8D4D FC lea ecx,dword ptr ss:[ebp-0x4] ; 取得对象首地址,传入ecx中作为参数
0040103B E8 C5FFFFFF call test.00401005 ; 调用构造函数
00401040 33C0 xor eax,eaxCNumber
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22 00401060 > 55 push ebp
00401061 8BEC mov ebp,esp
00401063 83EC 44 sub esp,0x44
00401066 53 push ebx
00401067 56 push esi ; test.<ModuleEntryPoint>
00401068 57 push edi
00401069 51 push ecx
0040106A 8D7D BC lea edi,dword ptr ss:[ebp-0x44]
0040106D B9 11000000 mov ecx,0x11
00401072 B8 CCCCCCCC mov eax,0xCCCCCCCC
00401077 F3:AB rep stos dword ptr es:[edi]
00401079 59 pop ecx ; 还原ecx,ecx中保存对象的首地址
0040107A 894D FC mov dword ptr ss:[ebp-0x4],ecx ; ebp-4就是this指针
0040107D 8B45 FC mov eax,dword ptr ss:[ebp-0x4] ; eax保存对象的首地址
00401080 C700 01000000 mov dword ptr ds:[eax],0x1 ; 将数据成员m_nNumber设置为1
00401086 8B45 FC mov eax,dword ptr ss:[ebp-0x4] ; 将this指针存入eax中,作为返回值
00401089 5F pop edi ; 0019FF3C
0040108A 5E pop esi ; 0019FF3C
0040108B 5B pop ebx ; 0019FF3C
0040108C 8BE5 mov esp,ebp
0040108E 5D pop ebp ; 0019FF3C
0040108F C3 retn
当在进入对象的作用域时,编译器会产生调用构造函数的代码。由于构造函数属于成员函数,因此在调用的过程中同样需要传递this指针。构造函数调用结束后,会将this指针作为返回值。返回this指针便是构造函数的特征之一,结合C++的语法,我们可以总结 识别局部对象的构造函数的必要条件(这里并不是充分条件):
- 该成员函数是这个对象在作用域内调用的第一个成员函数,根据this指针即可区分每个对象。
- 这个函数返回this指针
构造函数必然满足以上两个条件,否则这个函数就不是构造函数。
堆对象
堆对象的识别重点在于识别堆空间的申请与使用。在C++语法中,堆空间的申请需要使用malloc函数、new运算符或者其他同类功能的函数。因此,识别堆对象有了重要的依据,如一下代码:
CNumber * pNumber = new CNumber;
这行代码看上去是申请了类型为CNumber类的一个堆对象,使用指针pNumber保存了对象的首地址。由于产生了对象,所以此行代码将会调用CNumber类的无参构造函数。
1 | class CNumber |
1 | 0040104D C745 F0 0000000>mov dword ptr ss:[ebp-0x10],0x0 ; 指针在ebp-10h,初始化为0(NULL) |
在使用new申请了堆空间后,需要调用构造函数,以完成对象的数据成员初始化过程。如果堆空间申请失败,则会避开构造函数的调用。因为在C++语法中,如果new运算执行成功,返回值为对象的首地址,否则为NULL。因此,需要编译器检查堆空间的申请结果,产生一个双分支结构,以决定是否触发构造函数。在识别堆对象的构造函数时,应重点分析此双分支结构,找到new运算的调用后,可立即在下文寻找判定new返回值的代码,在判定成功的分支处可迅速定位并得到构造函数。
C中的malloc函数和C++中的new运算的区别很大,很重要的两点是malloc不负责触发构造函数,它也不是运算符,无法进行运算符重载。
参数对象
参数对象属于局部对象中的一种特殊情况。当对象作为函数参数时,调用一个特殊的构造函数————拷贝构造函数。该构造函数只有一个参数,类型为对象的引用。
当对象为参数时,会触发此类对象的拷贝构造函数。如果在函数调用时传递参数对象,参数会进行复制,形参是实参的副本,相当于拷贝构造了一个全新的对象。由于定义了新对象,因此会触发拷贝构造函数,在这个特殊的构造函数中完成两个对象间数据的复制。如没有定义拷贝构造函数,编译器会对原对象与拷贝对象中的各数据成员直接进行数据复制,称为默认拷贝构造函数,这种拷贝方式属于浅拷贝。
虽然使用编译器提供的默认拷贝构造函数很方便,但在某些特殊的情况下,这种拷贝会导致程序错误。当类中有资源申请,并以数据成员来保存这些资源时,就需要使用者自己提供一个拷贝构造函数。在拷贝构造函数中,要处理的不仅仅是源对象的各数据成员,还有它们所指向的资源数据。把这种源对象中的数据成员间接访问到的其他资源并制作副本的拷贝构造函数称为深拷贝。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46class CMyString
{
public:
CMyString() //无参构造函数,初始化指针
{
m_pString=NULL;
}
CMyString(CMyString& obj) //拷贝构造函数
{
int nLen = strlen(obj.m_pString);
this->m_pString = new char[nLen + sizeof(char)];
strcpy(this->m_pString, obj.m_pString);
}
~CMyString() //析构函数,释放资源
{
if(m_pString != NULL)
{
delete [] m_pString;
m_pString = NULL;
}
}
void SetString(char * pString) //设置字符串的成员函数
{
int nLen = strlen(pString);
if(m_pString != NULL)
{
delete [] m_pString;
m_pString = NULL;
}
m_pString = new char[nLen + sizeof(char)];
strcpy(this->m_pString,pString);
}
char * m_pString;
};
void show(CMyString MyString) //参数是对象类型,会触发拷贝构造函数
{
printf(MyString.m_pString);
}
int main(int argc, char* argv[])
{
CMyString MyString;
MyString.SetString("Hello");
show(MyString);
return 0;
}
int main(int argc, char* argv[])
1 | 0040113D 8D4D F0 lea ecx,dword ptr ss:[ebp-0x10] |
CMyString(CMyString& obj)
1 | ;其余代码不再赘述,关键看返回值 |
show(CMyString MyString)
1 | ;获取参数对象的数据成员,并作为printf参数使用 |
在执行函数show之前,先进入到CMyString的拷贝构造函数中。在拷贝构造函数中,我们使用深拷贝方式。这时数据成员this->m_pString和obj.m_pString所保存的地址不同,但其中的数据内容却是相同的。
由于使用了深拷贝方式,对对象中的数据成员所指向的堆空间数据也进行了数据复制,因此当参数对象被销毁时,释放的堆空间数据是拷贝对象所制作的数据副本,对源对象没有任何影响。
返回对象
返回对象与参数对象相似,都是局部对象中的一种特殊情况。由于函数返回时需要对返回对象进行拷贝,因此同意会使用到拷贝构造函数。但是,两者使用拷贝构造函数的时机不同,当对象为参数时,在进入函数前使用拷贝构造函数,而返回对象则在函数返回时使用拷贝构造函数。
1 | CMyString GetString() //返回类型为对象 |
int main(int argc, char* argv[])
1 | 004012B8 8D45 FC lea eax,dword ptr ss:[ebp-0x4] ; 取对象MyString的首地址 |
CMyString GetString()
1 | 0040EE7D C745 EC 0000000>mov dword ptr ss:[ebp-0x14],0x0 |
GetMyString将返回对象的地址作为函数参数。在函数返回之前,利用拷贝构造函数将函数中局部对象的数据复制到参数指向的对象中,起到了返回对象的作用。与其等价的函数原型如下:
CMyString GetMyString(CMyString pMyString)
虽然编译器会对返回值为对象类型的函数进行调整,修改其参数与返回值,但是它留下了一个与返回指针类型不同的特征,那就是在函数中使用拷贝构造函数。返回值和参数为对象指针类型的函数,不会使用以参数为目标的拷贝构造函数,而是直接使用指针保存对象首地址。
在使用指针作为参数和返回值时,函数内没有对拷贝构造函数的调用。以此为依据,便可以分辨参数或返回值是对象还是对象的指针。如果在函数内参数指针申请了堆对象,那么此时就会存在new运算和构造函数的调用,因此就更容易分辨参数或返回值。
全部对象与静态对象
全局对象与静态对象的构造时机相同,它们的构造函数的调用被隐藏在深处,但识别过程很容易。因为程序中所有全局对象将会在同一地点调用构造函数以初始化数据。既然调用构造函数被固定在了某一个点上,无论这个点被隐藏得多深,只需找到一次即可。
全局对象构造函数的调用被隐藏在深处,在分析过程中可使用两种方法跟踪全局对象的构造函数:
- 直接定位初始化函数
先进入mainCRTStartup函数中,然后顺藤摸瓜,找到初始化函数_cinii,在_cinit函数的第二个_initterm处设置断点。运行程序后,进入_initterm的实现代码内,断点在(**pfbegin)();执行处,单步进入代理构造,即可得到全局对象的构造函数。 - 利用栈回溯
如果反汇编代码中出现了全局变量,由于全局对象的地址固定,因此可以在对象的数据成员中设置读写断点,调试运行程序,等待构造函数调用的到来。利用栈回溯窗口,找到程序的执行流程,依次向上查询即可找到构造函数调用的起始处。
其实,最简单的办法是对atexit设置断点,因为构造代理函数中会注册析构函数,其注册的方式是使用atexit。
每个对象都有默认的构造函数吗
有些C++类图书在介绍构造函数时会提及,当没有定义构造函数时,编译器会提供默认的构造函数,这个函数什么事情都不做,其内容类似于“{}”的形式。但是在许多情况下,编译器并没有提供默认的构造函数,而且进过O2选项优化编译后,某些结构简单的类会被转化为几个连续定义的变量,哪里还会需要构造函数呢?
1 | class CInit |

在图中,对象Init的定义处没有任何对象的汇编代码,也没有构造函数的调用过程,可见编译器并没有为其提供默认的构造函数。那么,在何种情况下编译器会提供默认的构造函数呢?
- 本类、本来定义的成员对象或父类中有虚函数存在
由于需要初始化虚表,且这个工作理应在构造函数中隐式完成,因此在没有定义构造函数的情况下,编译器会添加默认的构造函数用于隐式完成虚表的初始化工作 - 父类或本类中定义的成员对象带有构造函数
在对象被定义时,由于对象本身为派生类,因此构造顺序是先构造父类再构造自身。当父类中带有构造函数时,将会调用父类构造函数,而这个调用过程需要在构造函数内完成,因此编译器添加了默的构造函数来完成这个调用过程。成员对象带有构造含糊的情况与此相同。
在没有定义构造函数的情况下,当类中没有虚函数存在,父类和成员对象也没有定义构造函数时,提供默认的构造函数已没有任何意义,只会降低程序的执行效率。
析构函数的出现时机
根据对象所在的作用域,当程序流程执行到作用域结束处时,便会将该作用域内的所有对象释放,释放的过程中会调用到对象的析构函数。析构函数与构造函数的出现时机相同,但并非由构造函数就一定会有对应的析构函数。析构函数的触发时机也需要时情况而定,主要分如下几种情况:
- 局部对象:作用域结束前调用析构函数
- 堆对象:释放堆空间前调用析构函数
- 参数对象:退出函数前,调用参数对象的析构函数
- 返回对象:如无对象引用定义,退出函数后,调用返回对象的析构函数,否则与对象引用的作用域一致
- 全局对象:main函数退出后调用析构函数
- 静态对象:main函数退出后调用析构函数
局部对象
要考察局部对象的析构函数的出现时机,应重点考察其作用域的结束处。与构造函数相比较而言,析构函数的出现时机相对固定。对于局部对象,当对象所造作用域结束后,将销毁该作用域的所有变量的栈空间,此时便是析构函数的出现时机。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class CNumber
{
public:
CNumber()
{
m_nNumber=1;
}
~CNumber()
{
printf("~CNumber\n");
}
int m_nNumber;
};
int main(int argc,char* argv[])
{
CNumber Number;
return 0;
}
int main(int argc,char* argv[])
1 | 00401048 8D4D FC lea ecx,dword ptr ss:[ebp-0x4] ; 获取对象首地址,作为this指针 |
~CNumber()
1 | 004010E9 59 pop ecx ; 还原this指针到ecx中 |
类CNumber提供了析构函数,在对象Number所在的作用域结束处,调用了析构函数~CNumber。析构函数同样属于成员函数,因此在调用过程中也需要传递this指针。
析构函数与构造函数有所不同,析构函数不支持函数重载,并且只有一个参数,即this指针,而且编译器隐藏了这个参数的传递过程,对于开发者而言,它是一个隐藏了this指针的无参函数。
堆对象
堆对象比较特殊,编译器将它的生杀大权交给了使用者。一些粗心的使用者只知道创造堆对象,而忘记了销毁,导致程序中永远存在一些无用的堆对象,其他堆类型数据也是如此。程序中的资源是有限的,只申请资源而不释放资源会造成内存泄漏,这点在设计服务器端程序时尤其要注意。
使用new申请了堆对象空间以后,何时释放对象要看开发者在哪里调用了delete来释放对象所在的空间。delete的使用便是找到堆对象调用析构函数的关键点。先来看一个调用过程。
1 |
|
int main(int argc,char* argv[])
1 | 0040105D . C745 F0 00000>mov dword ptr ss:[ebp-0x10],0x0 ; 指针变量所在的地址为ebp-10处 |
析构代理函数的实现分析
1 | 00401190 > 55 push ebp |
看似简单的释放堆对象过程实际上做了很多事情。析构函数比较特殊,在释放过程中,需要使用析构代理函数间接调用析构函数。为什么不直接调用析构函数呢?原因有很多,其中的一个原因是,在某些情况下,需要释放的对象不止一个,如果直接调用析构函数,则无法完成多对象的析构1
2
3
4
5
6CNumber * pArray = new CNumber[2];
if(pArray != NULL)
{
delete [] pArray;
pArray = NULL;
}
在以上代码中,使用new申请对象数组。由于数组中有两个对象,因此申请和释放堆空间时,构造函数和析构函数各需调用两次。编译器通过代理函数来完成这一系列的操作过程。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42;每个对象占4字节,却申请了12字节大小的堆空间。在申请对象数组时,会使用堆空间的首地址处4字节内容保存对象总个数
0040105D . 6A 0C push 0xC
0040105F . E8 FC020000 call test.operator newnryreshold_pages
00401064 . 83C4 04 add esp,0x4
00401067 . 8945 E8 mov dword ptr ss:[ebp-0x18],eax ; ebp-0x18h保存申请的堆空间的首地址
0040106A . C745 FC 00000>mov dword ptr ss:[ebp-0x4],0x0
00401071 . 837D E8 00 cmp dword ptr ss:[ebp-0x18],0x0 ; 检查堆空间申请是否成功
00401075 . 74 2E je short test.004010A5
00401077 . 68 0F104000 push test.0040100F ; 压入析构函数地址,作为析构代理函数参数
0040107C . 68 05104000 push test.00401005 ; 压入构造函数地址,作为构造代理函数参数
00401081 . 8B45 E8 mov eax,dword ptr ss:[ebp-0x18] ; 获取堆空间首地址放入eax中
00401084 . C700 02000000 mov dword ptr ds:[eax],0x2 ; 设置首地址的4个字节数据为对象个数
0040108A . 6A 02 push 0x2 ; 压入对象个数,作为函数参数
0040108C . 6A 04 push 0x4 ; 压入对象大小,作为函数参数
0040108E . 8B4D E8 mov ecx,dword ptr ss:[ebp-0x18]
00401091 . 83C1 04 add ecx,0x4 ; 跳过首地址4字节数据
00401094 . 51 push ecx ; 将第一个对象地址压栈,作为函数参数
00401095 . E8 F6DD0000 call test.`eh vector constructor iterato>; 构造代理函数调用
0040109A . 8B55 E8 mov edx,dword ptr ss:[ebp-0x18]
0040109D . 83C2 04 add edx,0x4 ; 跳过堆空间前4字节数据
004010A0 . 8955 DC mov dword ptr ss:[ebp-0x24],edx ; 保存堆空间中的第一个对象的首地址
004010A3 . EB 07 jmp short test.004010AC ; 跳过申请堆空间失败的处理
004010A5 > C745 DC 00000>mov dword ptr ss:[ebp-0x24],0x0
004010AC > 8B45 DC mov eax,dword ptr ss:[ebp-0x24]
004010AF . 8945 EC mov dword ptr ss:[ebp-0x14],eax
004010B2 . C745 FC FFFFF>mov dword ptr ss:[ebp-0x4],-0x1
004010B9 . 8B4D EC mov ecx,dword ptr ss:[ebp-0x14]
004010BC . 894D F0 mov dword ptr ss:[ebp-0x10],ecx
004010BF . 837D F0 00 cmp dword ptr ss:[ebp-0x10],0x0 ; if(pArray != NULL)
004010C3 . 74 2F je short test.004010F4
004010C5 . 8B55 F0 mov edx,dword ptr ss:[ebp-0x10]
004010C8 . 8955 E0 mov dword ptr ss:[ebp-0x20],edx
004010CB . 8B45 E0 mov eax,dword ptr ss:[ebp-0x20]
004010CE . 8945 E4 mov dword ptr ss:[ebp-0x1C],eax
004010D1 . 837D E4 00 cmp dword ptr ss:[ebp-0x1C],0x0 ; 检查对象指针是否为NULL
004010D5 . 74 0F je short test.004010E6
;压入释放对象类型标志,1为单个对象,3为释放对象数组,0表示仅仅执行析构函数,不释放堆空间(其作用在多重继承时介绍)
004010D7 . 6A 03 push 0x3
004010D9 . 8B4D E4 mov ecx,dword ptr ss:[ebp-0x1C] ; 压入释放堆对象的首地址
004010DC . E8 38FFFFFF call test.00401019 ; 释放堆对象函数
004010E1 . 8945 D8 mov dword ptr ss:[ebp-0x28],eax
004010E4 . EB 07 jmp short test.004010ED
在申请对象数组时,由于对象都在同一个堆空间中,编译器使用了堆空间的前4字节数据来保存对象的总个数。正是为了这4字节,许多初学者在申请对象数组时使用了new [],而在释放的过程中没有使用delete [](使用的是delete),于是产生了堆空间释放的错误。在使用delete(不使用delete []的情况下,当数组元素为基本数据类型时不会出错,当数组元素为存在析构函数的对象时才会出错。
当使用new运算申请对象数组时,前4字节用于记录数组内元素的个数,以便于代理函数执行每个数组元素的构造函数和析构函数。但是对于基本数据类型来说,构造函数和析构函数的问题就不存在了,于是 delete 和 delete [] 的效果是一致的。为了代码可读性考虑,建议在采用new申请对象时,如果是数组,则释放空间时就用 delete [] ,否则就用 delete 。
C语言中的free函数与C++中的 delete 运算的区别很大,很重要的一点就是free不负责触发析构函数,同时free不是运算符,无法进行运算符重载。
参数对象和返回对象
参数对象与返回对象会在不同的时机触发拷贝构造函数,它们的析构时机与所在作用域相关。只要函数的参数为对象类型,就会在函数调用结束后调用它的析构函数,然后释放掉参数对象所占的内存空间。当返回值为对象时候的情况就不同了,返回对象时有赋值,如代码:1
CMyString MyString = GetMyString();
这是把MyString的地址作为隐含的参数传递给GetMyString(),在GetMyString()内部完成拷贝构造函数的过程。函数执行完毕后,MyString就已经构造完成了,所以析构函数由MyString的作用域来决定。
当返回值为对象的函数遇到这样的代码时:1
MyString = GetMyString();
因为这样的代码不是MyString在定义时赋初值,所以不会触发MyString的拷贝构造函数,这时候会产生临时对象作为GetMyString()的隐含参数,这个临时对象会在GetMyString()内部完成拷贝构造函数的过程。函数执行完毕后,如果MyString的类中定义了“=”运算符重载,则调用;否则 就根据对象成员逐个赋值。如果对象内数据量过大,就会调用rep movs这样的串操作指令批量赋值,这样的赋值也属于浅拷贝。临时对象以一条高级语句为生命周期,它在函数调用时产生,在语句执行完毕时销毁。
全局对象与静态对象
全局对象与静态对象相同,其构造函数在函数_cinit的第二个_initterm调用中被构造。它们的析构函数的调用时机是在main函数执行完毕之后。既然构造函数出现在初始化过程中,对应的析构函数就会出现在程序结束处。
小结
构造函数必要条件:
- 这个函数的调用,是这个对象在作用域内的第一次成员函数调用,看this指针即可以区分对象,是哪个对象的this指针就是哪个对象的成员函数
- 使用thiscall调用方式,使用ecx传递this指针
- 返回值为this指针
析构函数必要条件:
- 这个函数的调用,是这个对象在作用域内的最后一次成员函数调用,看this指针即可以区分对象,是哪个对象的this指针就是哪个对象的成员函数
- 使用thiscall调用方式,使用ecx传递this指针
- 没有返回值