10.3 析构函数的出现时机

人皆有生死,对象也不例外。人的生死由自然决定,而编译器掌握着对象的生杀大权。构造函数是对象诞生的象征,对应的析构函数则是对象销毁时的特征。

对象何时被销毁呢?根据对象所在的作用域,当程序流程执行到作用域结束处时,便会将该作用域内的所有对象释放,释放的过程中会调用到对象的析构函数。析构函数与构造函数的出现时机相同,但并非有构造函数就一定会有对应的析构函数。析构函数的触发时机也需要视情况而定,主要分如下几种情况:

局部对象:作用域结束前调用析构函数

堆对象:释放堆空间前调用析构函数

参数对象:退出函数前,调用参数对象的析构函数

返回对象:如无对象引用定义,退出函数后,调用返回对象的析构函数,否则与对象引用的作用域一致

全局对象:main函数退出后调用析构函数

静态对象:main函数退出后调用析构函数

1.局部对象

要考察局部对象的析构函数的出现时机,应重点考察其作用域的结束处。与构造函数相比较而言,析构函数的出现时机相对固定。对于局部对象,当对象所在作用域结束后,将销毁该作用域的所有变量的栈空间,此时便是析构函数的出现时机。如代码清单10-7所示。

代码清单10-7 局部对象的析构函数调用—Debug版


//C++源码说明:局部对象的析构函数调用

class CNumber{

public:

CNumber(){

m_nNumber=1;

}

~CNumber(){

printf("~CNumber\r\n");

}

int m_nNumber;

};

void main(int argc, char*argv[]){

CNumber Number;

}//退出函数后调用析构函数

//C++源码与对应汇编代码讲解

void main(int argc, char*argv[]){

CNumber Number;

}

004015B0 lea ecx,[ebp-4];获取对象的首地址,作为this指针

004015B3 call@ILT+40(CNumber:~CNumber)(0040102d);调用析构函数

;析构函数的实现过程

~CNumber(){

;函数入口代码略

00401629 pop ecx;还原this指针到ecx中

0040162A mov dword ptr[ebp-4],ecx;使用临时空间保存this指针

printf("~CNumber\r\n");

0040162D push offset string"~CNumber\r\n"(00426038)

00401632 call printf(00401650)

00401637 add esp,4

}

;函数出口代码略,无返回值


代码清单10-7中的类CNumber提供了析构函数,在对象Number所在的作用域结束处,调用了析构函数~CNumber。析构函数同样属于成员函数,因此在调用的过程中也需要传递this指针。

析构函数与构造函数略有不同,析构函数不支持函数重载,并且只有一个参数,即this指针,而且编译器隐藏了这个参数的传递过程,对于开发者而言,它是一个隐藏了this指针的无参函数。

2.堆对象

堆对象比较特殊,编译器将它的生杀大权交给了使用者。一些粗心的使用者只知道创造堆对象,而忘记了销毁,导致程序中永远存在一些无用的堆对象,其他堆类型数据也是如此。程序中的资源是有限的,只申请资源而不释放资源会造成内存泄漏,这点在设计服务器端程序时尤其要注意。

使用new申请了堆对象空间以后,何时释放对象要看开发者在哪里调用了delete来释放对象所在的堆空间。delete的使用便是找到堆对象调用析构函数的关键点。我们先来看看释放堆空间前调用析构函数的过程,如代码清单10-8所示。

代码清单10-8 堆对象析构函数的调用—Debug版


//C++源码说明:

int main(int argc, char*argv[]){

CNumber*pNumber=NULL;//类CNumber的定义见代码清单10-1

pNumber=new CNumber;//为了便于讲解,这里没检查指针

pNumber->m_nNumber=2;

printf("%d\r\n",pNumber->m_nNumber);

if(pNumber!=NULL){

delete pNumber;

pNumber=NULL;

}

}

;析构函数的实现过程

int main(int argc, char*argv[]){

CNumber*pNumber=NULL;

0040F28D mov dword ptr[ebp-10h],0;指针变量所在的地址为ebp-10处

pNumber=new CNumber;

pNumber->m_nNumber=2;

printf("%d\r\n",pNumber->m_nNumber);

if(pNumber!=NULL)

0040F2F1 cmp dword ptr[ebp-10h],0;用户使用的指针检查

0040F2F5 je main+0C6h(0040f326)

{

delete pNumber;

0040F2F7 mov edx, dword ptr[ebp-10h];获取指针变量

0040F2FA mov dword ptr[ebp-20h],edx

0040F2FD mov eax, dword ptr[ebp-20h]

0040F300 mov dword ptr[ebp-1Ch],eax;eax保存了指针变量中的数据

0040F303 cmp dword ptr[ebp-1Ch],0;编译器的指针检查

0040F307 je main+0B8h(0040f318);如果为空,则跳过析构函数调用

0040F309 push 1;标记,以后介绍多重继承时会详细介绍

0040F30B mov ecx, dword ptr[ebp-1Ch];传递this指针

;调用析构代理函数

0040F30E call@ILT+45(CNumber:'scalar deleting destructor')(00401032)

0040F313 mov dword ptr[ebp-28h],eax

0040F316 jmp main+0BFh(0040f31f);释放空间成功,跳过失败处理

0040F318 mov dword ptr[ebp-28h],0

pNumber=NULL;

0040F31F mov dword ptr[ebp-10h],0

}

}

;析构代理函数的实现分析

00401032 jmp CNumber:'scalar deleting destructor'(0040f350)

CNumber:'scalar deleting destructor':

0040F350 push ebp

0040F351 mov ebp, esp

0040F353 sub esp,44h

0040F356 push ebx

0040F357 push esi

0040F358 push edi

0040F359 push ecx

0040F35A lea edi,[ebp-44h]

0040F35D mov ecx,11h

0040F362 mov eax,0CCCCCCCCh

0040F367 rep stos dword ptr[edi];Debug初始化数据部分

0040F369 pop ecx;还原this指针

0040F36A mov dword ptr[ebp-4],ecx

0040F36D mov ecx, dword ptr[ebp-4];传入this指针,调用析构函数

0040F370 call@ILT+40(CNumber:~CNumber)(0040102d)

0040F375 mov eax, dword ptr[ebp+8]

0040F378 and eax,1;检查析构函数标记,以后介绍多重继承时会详细介绍

0040F37B test eax, eax

0040F37D je CNumber:'scalar deleting destructor'+3Bh(0040f38b)

0040F37F mov ecx, dword ptr[ebp-4]

0040F382 push ecx;压入堆空间的首地址

0040F383 call operator delete(00401710);释放堆空间

0040F388 add esp,4

0040F38B mov eax, dword ptr[ebp-4]

;函数出口分析略

0040F39E ret 4


在代码清单10-8中,看似简单的释放堆对象过程实际上做了很多事情。析构函数比较特殊,在释放过程中,需要使用析构代理函数间接调用析构函数。为什么不直接调用析构函数呢?原因有很多,其中的一个原因是,在某些情况下,需要释放的对象不止一个,如果直接调用析构函数,则无法完成多对象的析构,如以下代码所示:


//CNumber类的定义见代码清单10-1,请读者自行加入析构函数

CNumber*pArray=new CNumber[2];//申请对象数组

if(pArray!=NULL){

delete[]pArray;//释放对象数组

pArray=NULL;

}


在以上代码中,使用new申请对象数组。由于数组中有两个对象,因此申请和释放堆空间时,构造函数和析构函数各需要调用两次。编译器通过代理函数来完成这一系列的操作过程,如代码清单10-9所示。

代码清单10-9 多个堆对象的申请与释放—Debug版


//C++源码见上面紧挨着的一段代码所示

CNumber*pArray=new CNumber[2];//申请堆空间

;每个对象占4字节,却申请了12字节大小的堆空间,多出的4字节数据是什么呢

;在申请对象数组时,会使用堆空间的首地址处的4字节内容保存对象总个数

0040F6BD push 0Ch

0040F6BF call operator new(004020f0)

0040F6C4 add esp,4

0040F6C7 mov dword ptr[ebp-18h],eax;[ebp-18h]保存申请的堆空间的首地址

0040F6CA mov dword ptr[ebp-4],0

0040F6D1 cmp dword ptr[ebp-18h],0;检查堆空间的申请是否成功

0040F6D5 je main+75h(0040f705)

;压入析构函数的地址,作为构造代理函数参数

0040F6D7 push ffset@ILT+60(CNumber:~CNumber)(00401050)

;压入构造函数的地址,作为构造代理函数参数

0040F6DC push offset@ILT+30(CNumber:CNumber)(00401023)

0040F6E1 mov eax, dword ptr[ebp-18h];获取堆空间的首地址并保存到eax中

0040F6E4 mov dword ptr[eax],2;设置首地址的4字节数据为对象个数

0040F6EA push 2;压入对象个数,作为函数参数

0040F6EC push 4;压入对象大小,作为函数参数

0040F6EE mov ecx, dword ptr[ebp-18h]

0040F6F1 add ecx,4;跳过首地址的4字节数据

0040F6F4 push ecx;将第一个对象地址压栈,作为函数参数

;构造代理函数调用,该函数的讲解见代码清单10-10

0040F6F5 call'eh vector constructor iterator'(0040f5f0)

0040F6FA mov edx, dword ptr[ebp-18h]

0040F6FD add edx,4;跳过堆空间的前4字节的数据

0040F700 mov dword ptr[ebp-24h],edx;保存堆空间中的第一个对象的首地址

0040F703 jmp main+7Ch(0040f70c);跳过申请堆空间失败的处理

0040F705 mov dword ptr[ebp-24h],0;申请堆空间失败,赋值空指针

0040F70C mov eax, dword ptr[ebp-24h]

0040F70F mov dword ptr[ebp-14h],eax

0040F712 mov dword ptr[ebp-4],0FFFFFFFFh

0040F719 mov ecx, dword ptr[ebp-14h]

0040F71C mov dword ptr[ebp-10h],ecx;数据最后到了pArray,打开O2选项就简洁了

if(pArray!=NULL)

0040F71F cmp dword ptr[ebp-10h],0

0040F723 je main+0C4h(0040f754)

{

delete[]pArray;//释放堆空间

0040F725 mov edx, dword ptr[ebp-10h]

0040F728 mov dword ptr[ebp-20h],edx

0040F72B mov eax, dword ptr[ebp-20h]

0040F72E mov dword ptr[ebp-1Ch],eax

0040F731 cmp dword ptr[ebp-1Ch],0;检查对象指针是否为NULL

0040F735 je main+0B6h(0040f746)

;压入释放对象类型标志,1为单个对象,3为释放对象数组,0表示仅仅执行析构函数,不释放堆空间(其作用会在讲解多重继承时详细介绍)

;这个标志占2位,使用delete[]时标志为二进制11,直接使用delete时标志为二进制01

0040F737 push 3

0040F739 mov ecx, dword ptr[ebp-1Ch];压入释放堆对象首地址

;释放堆对象函数,该函数有两个参数,更多信息见代码清单10-11中的讲解

0040F73C call@ILT+85(CNumber:'vector deleting destructor')

(0040105a)

0040F741 mov dword ptr[ebp-28h],eax

0040F744 jmp main+0BDh(0040f74d)

0040F746 mov dword ptr[ebp-28h],0

pArray=NULL;

0040F74D mov dword ptr[ebp-10h],0

}


我们通过对代码清单10-9的分析了解了堆对象的产生与释放过程。在申请对象数组时,由于对象都在同一个堆空间中,编译器使用了堆空间的前4字节数据来保存对象的总个数。正是为了这4字节,许多初学者在申请对象数组时使用了new[],而在释放对象的过程中没有使用delete[](使用的是delete),于是产生了堆空间释放的错误。在使用delete(不使用delete[])的情况下,当数组元素为基本数据类型时不会出错,当数组元素为存在析构函数的对象时才会出错。我们接下来继续分析此类错误产生的原因,并寻找解决方案。

由于类对象与其他基本数据类型不同,在对象产生时,需要调用构造函数来初始化对象中的数据,因此用到了代理函数。代理函数的功能是根据对象数组的元素逐个调用它们的构造函数,完成初始化过程。堆对象的构造代理函数一共使用了5个参数,详细分析如代码清单10-10所示。

代码清单10-10 堆对象的构造代理函数—Debug版


;在代码清单10-9中,调用此函数时,共压入了5个参数,还原参数原型为:

;??_L@YGXPAXIHP6EX0@Z1@Z(void*pObj,//第一个对象所在堆空间的首地址

int nSizeObj,//对象占用内存空间的大小

int nLenObj,//对象个数

void(*p)(void),//构造函数指针,thiscall方式

void(*p)(void))//析构函数指针,thiscall方式

??_L@YGXPAXIHP6EX0@Z1@Z:;无源码对照

0040F5F0 push ebp

0040F5F1 mov ebp, esp

0040F5F3 push 0FFh

0040F5F5 push offset string"stream!=NULL"+30h(00426078)

0040F5FA push offset__except_handler3(00407fe0)

0040F5FF mov eax, fs:[00000000]

0040F605 push eax

0040F606 mov dword ptr fs:[0],esp;注册结构化异常处理

0040F60D add esp,0F0h

0040F610 push ebx

0040F611 push esi

0040F612 push edi

;======以上代码为函数入口的初始化和异常链的处理====================

0040F613 mov dword ptr[ebp-20h],0

0040F61A mov dword ptr[ebp-4],0

0040F621 mov dword ptr[ebp-1Ch],0;for循环变量赋初值部分

0040F628 jmp'eh vector constructor iterator'+43h(0040f633)

0040F62A mov eax, dword ptr[ebp-1Ch]

0040F62D add eax,1;步长累加部分

0040F630 mov dword ptr[ebp-1Ch],eax

0040F633 mov ecx, dword ptr[ebp-1Ch]

0040F636 cmp ecx, dword ptr[ebp+10h];循环判断部分

;与对象总个数进行比较,如果小于对象总个数则继续执行循环体

0040F639 jge'eh vector constructor iterator'+5Ch(0040f64c)

;获取对象所在堆空间的首地址,使用ecx传递this指针

0040F63B mov ecx, dword ptr[ebp+8]

0040F63E call dword ptr[ebp+14h];调用构造函数

0040F641 mov edx, dword ptr[ebp+8];edx作为对象数组元素的指针

;修改指针,使其指向下一对象的首地址

0040F644 add edx, dword ptr[ebp+0Ch]

0040F647 mov dword ptr[ebp+8],edx

;跳转到步长累加部分

0040F64A jmp'eh vector constructor iterator'+3Ah(0040f62a)

;结束for循环结构,完成构造函数的调用过程


代码清单10-10展示了申请多个堆对象的构造函数的调用过程。在Debug版下,编译器产生了for循环结构的代码,根据数组中对象总个数,从堆数组中的第一个对象的首地址开始,依次向后遍历数组中每个对象,将数组中每个对象的首地址作为this指针逐个调用构造函数。

在前面介绍的基础上,我们继续分析当堆空间销毁时编译器是如何产生调用析构函数的代码的。这个过程会不会和编译器产生调用构造函数代码的原理一样呢?如代码清单10-11所示。

代码清单10-11 堆对象释放函数分析—Debug版


;此段代码调用来自代码清单10-9

0040105A jmp CNumber:'vector deleting destructor'(004016e0)

CNumber:'vector deleting destructor':

;函数入口部分略

004016F9 pop ecx

004016FA mov dword ptr[ebp-4],ecx

004016FD mov eax, dword ptr[ebp+8]

00401700 and eax,2;判断释放标志,是否为对象数组

00401703 test eax, eax

00401705 je CNumber:'vector deleting destructor'+5Fh(0040173f)

;压入析构函数,作为析构代理函数参数使用

00401707 push offset@ILT+75(CNumber:'vector deleting destructor')

(00401050)

0040170C mov ecx, dword ptr[ebp-4];获取堆空间的首地址

0040170F mov edx, dword ptr[ecx-4];获取对象个数

00401712 push edx;压入堆空间中的对象总数

00401713 push 4;压入每个对象大小

00401715 mov eax, dword ptr[ebp-4]

00401718 push eax;压入第一个对象的首地址

;调用析构函数代理,完成所有堆对象的析构调用过程

00401719 call'eh vector destructor iterator'(00401830)

0040171E mov ecx, dword ptr[ebp+8];获取释放标志

00401721 and ecx,1;检查是否释放堆空间

00401724 test ecx, ecx

00401726 je CNumber:'vector deleting destructor'+57h(00401737)

00401728 mov edx, dword ptr[ebp-4];edx保留了对象数组的首地址

0040172B sub edx,4;修正为堆空间的首地址

0040172E push edx

0040172F call operator delete(00401aa0);调用delete释放堆空间

;结尾处理过程略


堆对象在析构过程中没有像构造过程那样直接调用代理函数,而是插入了中间的检测(见代码清单10-11中地址00401700处),用于检查参数是否为对象数组。在释放单个堆对象时,向中间处理函数传入参数1作为释放标志。由于堆空间中只有一个堆对象,没有记录对象个数的数据存在,因此可直接调用对象的析构函数并释放堆空间。

释放对象数组时,在delete后面添加符号“[]”是一个关键之处。单个对象的释放不可以添加符号“[]”,因为这样会在堆空间释放时传入释放标记3,执行到中间的检测时,判断标记为3,将会把delete的目标指针减4(见代码清单10-11中地址0040172B处),于是释放单个对象的空间时就会发生错误,当执行到delete时会产生堆空间释放错误。

在申请对象堆空间时,许多初学者会在申请过程中错误地将申请多个对象写成有参构造函数的调用,而在释放时却加入了符号“[]”,如以下代码所示:


//类定义

class CNumber{

public:

CNumber(int nNumber){printf("%d\r\n",nNumber)}

};

//调用过程

void main(){

//调用对象的有参构造函数,而非申请5个对象堆空间

CNumber*pNumber=new CNumber(5);

//此处使用了释放对象数组的语句,如此一来将会以对象数组的方式安排内存结构

delete[]pNumber;

}


对于以上讨论的堆内存格式,当使用new运算申请对象数组时,前4字节用于记录数组内元素的个数,以便于代理函数执行每个数组元素的构造函数和析构函数。但是,对于基本数据类型来说,构造函数和析构函数的问题就不存在了,于是delete和delete[]的效果是一致的。为了代码的可读性考虑,建议读者在采用new申请对象时,如果是数组,则释放空间时就用delete[],否则就用delete。

C语言中的free函数与C++中的delete运算的区别很大,很重要的一点就是free不负责触发析构函数,同时,free不是运算符,无法进行运算符重载。

3.参数对象和返回对象

参数对象与返回对象会在不同的时机触发拷贝构造函数,它们的析构时机与所在作用域相关。只要函数的参数为对象类型,就会在函数调用结束后调用它的析构函数,然后释放掉参数对象所占的内存空间。当返回值为对象时候情况就不同了,返回对象时有赋值,如代码清单10-4中的代码:

CMyString MyString=GetMyString();

这是把MyString的地址作为隐含的参数传递给GetMyString(),在GetMyString()内部完成拷贝构造的过程。函数执行完毕后,MyString就已经构造完成了,所以析构函数由MyString的作用域来决定,代码分析见代码清单10-3和代码清单10-4中函数调用的结尾处,即return操作后的汇编代码。

当返回值为对象的函数遇到这样的代码时:


MyString=GetMyString();


因为这样的代码不是MyString在定义时赋初值,所以不会触发MyString的拷贝构造函数,这时候会产生临时对象作为GetMyString()的隐含参数,这个临时对象会在GetMyString()内部完成拷贝构造函数的过程。函数执行完毕后,如果MyString的类中定义了“=”运算符重载,则调用之;否则就根据对象成员逐个赋值。如果对象内数据量过大,就会调用rep movs这样的串操作指令批量赋值,这样的赋值也属于浅拷贝。临时对象以一条高级语句为生命周期,它在函数调用时产生,在语句执行完毕时销毁。C和C++以分号作为语句的结束符,也就是说,一旦分号出现,就会触发临时对象的析构函数。特殊情况是,当引用这个临时对象时,它的生命期会和引用一致。又如:


Number=GetNumber(),printf("Hello\r\n");


这是一条语句,逗号运算符后是printf调用,于是临时对象的析构在printf函数执行完毕后才会触发,对此细节感兴趣的读者可以把这个问题作为练手的例子进行分析。

4.全局对象与静态对象

全局对象与静态对象相同,其构造函数在函数_cinit的第二个_initterm调用中被构造。它们的析构函数的调用时机是在main函数执行完毕之后。既然构造函数出现在初始化过程中,对应的析构函数就会出现在程序结束处。我们来看一下mainCRTStartup函数,它在调用main函数结束后使用了exit用来终止程序,如图10-3所示。

图 10-3 程序结束

在main函数调用结束后,由exit来结束进程,从而终止程序的运行。全局对象的析构函数的调用也在其中,由exit函数内的doexit实现,关键代码如下:


if(__onexitbegin){//__onexitbegin为函数指针数组的首地址

_PVFV*pfend=__onexitend;//__onexitend为函数指针数组的尾地址

while(--pfend>=__onexitbegin)//从后向前依次释放全局对象

if(*pfend!=NULL)

(**pfend)();//调用数组中保存的函数

}


__onexitbegin指向一个指针数组,该数组中保存着各类资源释放时的函数的首地址。编译器是在何时生成这样一个数组的呢?

全局构造函数的调用是在_cinit函数的第二个_initterm函数内完成,而在第二个_initterm函数中,会先执行__onexitinit函数的初始化函数指针数组。在执行每个全局对象构造代理函数时都会先执行对象的构造函数,然后使用atexit注册析构代理函数,具体细节会在介绍虚函数时详细讲解。

如果定义一个全局对象CMyString g_MyStringTwo;,该对象的全局析构代理函数的分析如下所示:


;该代理函数由编译器添加,无源码对照

;函数入口部分略

004014D8 mov ecx, offset g_MyStringTwo(0042af7c)

004014DD call@ILT+35(CMyString:~CMyString)(00401028)

;函数退出部分略

004014F2 ret


由于在数组中保存的析构代理函数被定义为无参函数,因此在调用析构函数时无法传递this指针。于是编译器需要为每个全局对象和静态对象建立一个中间代理的析构函数,用于传入全局对象的this指针。

本章对全局对象的构造函数和析构函数的分析是针对VC++6.0的,在更高的版本中,全局对象的构造函数和析构代理函数在细节上可能会有所变化,希望读者切勿死记硬背。

关于触发析构函数的时机的讲解到此就结束了。在分析析构函数时,可以构造函数作为参照,但并非出现了构造函数就一定会产生析构函数。在没有编写析构函数的类中,编译器会根据情况决定是否提供默认的析构函数。默认的构造函数和析构函数与虚函数的知识点紧密相关,具体分析见第11章。