第10章 关于构造函数和析构函数

构造函数与析构函数是类的重要组成部分,它们在类中担任着至关重要的工作。构造函数常用来完成对象生成时的数据初始化工作,而析构函数则常用于在对象销毁时释放对象中所申请的资源。

当对象生成时,编译器会自动产生调用其类构造函数的代码,在编码过程中可以为类中的数据成员赋予恰当的初始值。当对象销毁时,编译器同样也会产生调用其类析构函数的代码。

构造函数与析构函数都是类中特殊的成员函数,构造函数支持函数重载,而析构函数只能是一个无参函数。它们不可定义返回值,调用构造函数后,返回值为对象首地址,也就是this指针。

在某些情况下,编译器会提供默认的构造函数和析构函数,但并不是任何情况下编译器都会提供。那么,在何种情况下编译器会提供默认的构造函数和析构函数?编译器又是如何调用它们的呢?本章将解决这些谜题。

10.1 构造函数的出现时机

对象生成时会自动调用构造函数。只要找到了定义对象的地方就找到了构造函数的调用时机,这看似简单,实际情况却相反,不同作用域的对象生命周期不同,如局部对象、全局对象、静态对象等的生命周期各不相同,而当对象作为函数参数与返回值时,构造函数的出现时机又会有所不同。

将对象进行分类:不同类型对象的构造函数被调用的时机会发生变化,但都会遵循C++语法:定义的同时调用构造函数。那么,只要知道了对象的生命周期,便可推断出构造函数的调用时机。下面先根据生命周期将对象进行分类,然后分析各类对象的构造函数和析构函数的调用时机。要讨论的各类对象如下:

局部对象

堆对象

参数对象

返回对象

全局对象

静态对象

1.局部对象

局部对象下的构造函数的出现时机比较容易识别。当对象产生时,便有可能引发构造函数的调用。编译器隐藏了构造函数的调用过程,使编码者无法看到调用细节。我们可以通过对代码清单10-1的分析来学习和了解编译器调用构造函数的全过程。

代码清单10-1 无参构造函数的调用过程—Debug版


//C++源码说明:定义含有无参构造函数的类

class CNumber{

public:

CNumber(){//无参构造函数

m_nNumber=1;

}

int m_nNumber;

};

void main(){

CNumber Number;//类对象定义

}

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

void main(){

CNumber Number;

0040B468 lea ecx,[ebp-4];取得对象首地址,传入ecx中作为参数

0040B46B call@ILT+5(CNumber:CNumber)(0040100a);调用构造函数

}

//构造函数CNumber分析

CNumber()

;函数入口代码分析略

0040B4A9 pop ecx;还原ecx, ecx中保存对象的首地址

0040B4AA mov dword ptr[ebp-4],ecx;[ebp-4]就是this指针

{

m_nNumber=1;

0040B4AD mov eax, dword ptr[ebp-4];eax中保存了对象的首地址

0040B4B0 mov dword ptr[eax],1;将数据成员m_nNumber设置为1

}

0040B4B6 mov eax, dword ptr[ebp-4];将this指针存入eax中,作为返回值

0040B4B9 pop edi

0040B4BA pop esi

0040B4BB pop ebx

0040B4BC mov esp, ebp

0040B4BE pop ebp

0040B4BF ret


当在进入对象的作用域时,编译器会产生调用构造函数的代码。由于构造函数属于成员函数,因此在调用的过程中同样需要传递this指针。构造函数调用结束后,会将this指针作为返回值。返回this指针便是构造函数的特征之一,结合C++的语法,我们可以总结识别局部对象的构造函数的必要条件(请读者注意,这并不是充分条件):

该成员函数是这个对象在作用域内调用的第一个成员函数,根据this指针即可以区分每个对象。

这个函数返回this指针。

构造函数必然满足以上两个条件,否则这个函数就不是构造函数。为什么构造函数会返回this指针呢?请继续看下面的讲解。

2.堆对象

堆对象的识别重点在于识别堆空间的申请与使用。在C++的语法中,堆空间的申请需要使用malloc函数、new运算符或者其他同类功能的函数。因此,识别堆对象有了重要的依据,如以下代码所示。

CNumber*pNumber=new CNumber;

这行代码看上去是申请了类型为CNumber类的一个堆对象,使用指针pNumber保存了对象的首地址。由于产生了对象,所以此行代码将会调用CNumber类的无参构造函数,分析如代码清单10-2所示。

代码清单10-2 构造函数返回值的使用—Debug版


//C++源码说明:申请堆对象

void main(){

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

pNumber=new CNumber;

//为了突出本节讨论的问题,这里没有检查new运算的返回值

pNumber->m_nNumber=2;

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

}

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

void main(){

CNumber*pNumber=NULL;

0040104D mov dword ptr[ebp-10h],0;指针在ebp-10h,初始化为0

pNumber=new CNumber;

00401054 push 4;压入类的大小,用于堆内存申请

00401056 call operator new(004011b0)

0040105B add esp,4

0040105E mov dword ptr[ebp-18h],eax;使用临时变量保存new返回值

00401061 mov dword ptr[ebp-4],0;[ebp-4]保存申请堆空间的次数

00401068 cmp dword ptr[ebp-18h],0;检测堆内存是否申请成功

0040106C je main+5Bh(0040107b);失败则跳过构造函数

0040106E mov ecx, dword ptr[ebp-18h];申请成功,将对象首地址传入ecx中

00401071 call@ILT+0(CNumber:CNumber)(00401005);调用构造函数

;构造函数返回this指针,保存到临时变量ebp-1Ch中

00401076 mov dword ptr[ebp-1Ch],eax

;结合0040106C处的je指令和下面的jmp指令,可发现编译器在这里产生了一个双分支结构,用于检查

new运算。如果执行成功,则调用构造函数,this指针保存在ebp-1Ch中,否则避开构造函数,将[ebp-

1Ch]设为0

00401079 jmp main+62h(00401082)

0040107B mov dword ptr[ebp-1Ch],0;申请堆空间失败,设置指针值为NULL

00401082 mov eax, dword ptr[ebp-1Ch]

;在没有打开/O2时,对象地址将在几个临时变量中倒换,最终保存到[ebp-10h]中,即指针变量pNumber

00401085 mov dword ptr[ebp-14h],eax

00401088 mov dword ptr[ebp-4],0FFFFFFFFh

0040108F mov ecx, dword ptr[ebp-14h]

00401092 mov dword ptr[ebp-10h],ecx

pNumber->m_nNumber=2;

00401095 mov edx, dword ptr[ebp-10h];edx得到this指针

00401098 mov dword ptr[edx],2;为成员变量m_nNumber赋值2

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

0040109E mov eax, dword ptr[ebp-10h]

004010A1 mov ecx, dword ptr[eax]

004010A3 push ecx

004010A4 push offset string"%d\r\n"(00424ff8)

004010A9 call printf(0040eb90)

004010AE add esp,8

return 0;

004010B1 xor eax, eax

}


在代码清单10-2中,在使用new申请了堆空间以后,需要调用构造函数,以完成对象的数据成员初始化过程。如果堆空间申请失败,则会避开构造函数的调用。因为在C++语法中,如果new运算执行成功,返回值为对象的首地址,否则为NULL。因此,需要编译器检查堆空间的申请结果,产生一个双分支结构,以决定是否触发构造函数。在识别堆对象的构造函数时,应重点分析此双分支结构,找到new运算的调用后,可立即在下文寻找判定new返回值的代码,在判定成功(new的返回值非0)的分支处可迅速定位并得到构造函数。

C中的malloc函数和C++中的new运算的区别很大,很重要的两点是malloc不负责触发构造函数,它也不是运算符,无法进行运算符重载。

在使用new申请对象堆空间时,许多初学者很容易将有参构造函数与对象数组搞混,在申请对象数组时很容易写错,申请对象数组却写成了调用有参构造函数。以int类型的堆空间申请为例,如下所示:


//圆括号是调用有参构造函数,最后只申请了一个int类型的堆变量并赋初值10

int*pInt=new int(10);

//方括号才是申请了10个int元素的堆数组

int*pInt=new int[10];


类的堆空间申请与以上情况相似,本想申请对象数组,但是写成了调用有参构造函数。虽然在编译时编译器不会报错,但需要该类中提供匹配的构造函数。当程序流程执行到释放对象数组时,则会触发错误,更详细的讲解见10.3节。

3.参数对象

参数对象属于局部对象中的一种特殊情况。当对象作为函数参数时,调用一个特殊的构造函数—拷贝构造函数。该构造函数只有一个参数,类型为对象的引用。

当对象为参数时,会触发此类对象的拷贝构造函数。如果在函数调用时传递参数对象,参数会进行复制,形参是实参的副本,相当于拷贝构造了一个全新的对象。由于定义了新对象,因此会触发拷贝构造函数,在这个特殊的构造函数中完成两个对象间数据的复制。如没有定义拷贝构造函数,编译器会对原对象与拷贝对象中的各数据成员直接进行数据复制,称为默认拷贝构造函数,这种拷贝方式属于浅拷贝,如以下代码所示:


CNumber one;//类CNumber的定义参考代码清单10-1

lea ecx,[ebp-10h]

call@ILT+45(CNumber:CNumber)(00401032)

CNumber two(one);//CNumber中没有提供参数为对象引用的构造函数

mov eax, dword ptr[ebp-10h]//取出对象one中的数据成员信息

mov dword ptr[ebp-14h],eax//赋值对象two中的数据成员信息


虽然使用编译器提供的默认拷贝构造函数很方便,但在某些特殊的情况下,这种拷贝会导致程序错误,如第9章中提到的资源释放错误。当类中有资源申请,并以数据成员来保存这些资源时,就需要使用者自己提供一个拷贝构造函数。在拷贝构造函数中,要处理的不仅仅是源对象的各数据成员,还有它们所指向的资源数据。把这种源对象中的数据成员间接访问到的其他资源并制作副本的拷贝构造函数称为深拷贝。如代码清单10-3所示。

代码清单10-3 深拷贝构造函数—Debug版


//C++源码说明:深拷贝构造函数的使用

class CMyString{//字符串处理类的定义

public:

CMyString(){//无参构造函数,初始化指针

m_pString=NULL;

}

CMyString(CMyString&obj){//拷贝构造函数

//注:如果在拷贝构造函数中直接复制指针值,那么对象内的两个成员指针会指向同一个资源,这属于浅拷贝

//this->m_pString=obj.m_pString;

//为实参对象中的指针所指向的堆空间制作一份副本,这就是深拷贝了

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(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;

}

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

//==========================main函数的调用过程========================//

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

CMyString MyString;

0040113D lea ecx,[ebp-10h]

00401140 call@ILT+5(CMyString:CMyString)(0040100a);无参构造函数

00401145 mov dword ptr[ebp-4],0

MyString.SetString("Hello");//调用成员函数

0040114C push offset string"Hello"(0042501c)

00401151 lea ecx,[ebp-10h]

00401154 call@ILT+0(CMyString:SetString)(00401005)

Show(MyString);

;这里的"push ecx"等价于"sub esp,4",但是"push ecx"的机器码更短,效率更高。CMyString的类型长度为4字节,所以传递参数对象时需要在栈顶留下4字节,以作为参数对象的空间,此时esp保存的内容就是参数对象的地址

00401159 push ecx

0040115A mov ecx, esp;获取参数对象的地址,保存到ecx中

0040115C mov dword ptr[ebp-14h],esp;ebp-14中保存参数对象的地址

0040115F lea eax,[ebp-10h];获取对象MyString的地址并保存到eax中

00401162 push eax;将MyString地址作为参数,调用拷贝构造函数

00401163 call@ILT+10(CMyString:CMyString)(0040100f)

00401168 mov dword ptr[ebp-1Ch],eax;ebp-1Ch保存了拷贝构造函数返回的this指针

0040116B call@ILT+25(Show)(0040101e);此时栈顶的参数对象传递完毕,开始函数调用

00401170 add esp,4

return 0;

00401173 mov dword ptr[ebp-18h],0

0040117A mov dword ptr[ebp-4],0FFFFFFFFh

00401181 lea ecx,[ebp-10h]

;调用对象CMyString的析构函数

00401184 call@ILT+20(CMyString:~CMyString)(00401019)

00401189 mov eax, dword ptr[ebp-18h]

}

//========================拷贝构造函数的调用======================//

//拷贝构造函数的实现过程与其他构造函数类似,只是多了一个对象引用作为参数

CMyString(CMyString&obj){

int nLen=strlen(obj.m_pString);

this->m_pString=new char[nLen+sizeof(char)];//使用this指针是为了与参数明显区分开

strcpy(this->m_pString, obj.m_pString);

}

;其他代码不再赘述,关键是看返回值

0040EF0C mov eax, dword ptr[ebp-4];将this指针作为返回值

//=========================Show函数的分析=======================//

void Show(CMyString MyString){

printf(MyString.m_pString);

;获取参数对象的数据成员,并作为printf参数使用

00401068 mov eax, dword ptr[ebp+8]

0040106B push eax

0040106C call printf(00401310)

00401071 add esp,4

}

;调用CMyString的析构函数

00401074 lea ecx,[ebp+8]

00401077 call@ILT+20(CMyString:~CMyString)(00401019)


在代码清单10-3中,在执行函数Show之前,先进入到CMyString的拷贝构造函数中。在拷贝构造函数中,我们使用深拷贝方式。这时数据成员this->m_pString和obj.m_pString所保存的地址不同,但其中的数据内容却是相同的,如图10-1所示。

图 10-1 拷贝指针与原指针对比

由于使用了深拷贝方式,对对象中的数据成员所指向的堆空间数据也进行了数据复制,因此当参数对象被销毁时,释放的堆空间数据是拷贝对象所制作的数据副本,对源对象没有任何影响。

4.返回对象

返回对象与参数对象相似,都是局部对象中的一种特殊情况。由于函数返回时需要对返回对象进行拷贝,因此同样会使用到拷贝构造函数。但是,两者使用拷贝构造函数的时机不同,当对象为参数时,在进入函数前使用拷贝构造函数,而返回对象则在函数返回时使用拷贝构造函数。如代码清单10-4所示。

代码清单10-4 返回对象的构造函数使用—Debug版


//C++源码说明:返回对象的构造函数的使用

//类的定义请查看代码清单10-3

CMyString GetMyString(){//返回类型为对象

CMyString MyString;

MyString.SetString("World");

return MyString;

}

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

CMyString MyString=GetMyString();

}

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

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

CMyString MyString=GetMyString();

00401218 lea eax,[ebp-4];取对象MyString的首地址

0040121B push eax;将对象的首地址作为参数传递

0040121C call@ILT+50(GetMyString)(00401037)

00401221 add esp,4;参数平衡

}

//函数GetMyString的实现过程

CMyString GetMyString(){

CMyString MyString;

00401144 lea ecx,[ebp-10h];传递this指针,调用构造函数

00401147 call@ILT+5(CMyString:CMyString)(0040100a)

0040114C mov dword ptr[ebp-4],1;对象计数器,调试中作为参考,这里不必理会

MyString.SetString("World");

00401153 push offset string"World"(0042501c)

00401158 lea ecx,[ebp-10h]

0040115B call@ILT+0(CMyString:SetString)(00401005)

return MyString;

00401160 lea eax,[ebp-10h];获取局部对象的首地址

00401163 push eax;将对象MyString的地址作为参数

;获取参数中保存的this指针。(上一章中讲过,将对象作为返回值时,函数将会隐式传递一个参数,其内容为返回对象的this指针。)

00401164 mov ecx, dword ptr[ebp+8]

;调用隐含的参数对象的拷贝构造函数,以局部对象MyString的地址作为参数

00401167 call@ILT+40(CMyString:CMyString)(0040102d)

0040116C mov ecx, dword ptr[ebp-14h]//标记,不必理会

0040116F or ecx,1

00401172 mov dword ptr[ebp-14h],ecx

;调用局部对象MyString的析构函数

00401175 mov byte ptr[ebp-4],0;对象计数器

00401179 lea ecx,[ebp-10h]

0040117C call@ILT+20(CMyString:~CMyString)(00401019)

00401181 mov eax, dword ptr[ebp+8];将参数作为返回值

}


通过对代码清单10-4的分析可以发现,GetMyString将返回对象的地址作为函数参数。在函数返回之前,利用拷贝构造函数将函数中局部对象的数据复制到参数指向的对象中,起到了返回对象的作用。与其等价的函数原型如下所示:


CMyString*GetMyString(CMyString*pMyString);


虽然编译器会对返回值为对象类型的函数进行调整,修改其参数与返回值,但是它留下了一个与返回指针类型不同的象征,那就是在函数中使用拷贝构造函数。返回值和参数为对象指针类型的函数,不会使用以参数为目标的拷贝构造函数,而是直接使用指针保存对象首地址,如以下代码所示。


//函数的返回类型与参数类型都是对象的指针类型

CMyString*GetMyString(CMyString*pMyString){

CMyString MyString;//定义局部对象

MyString.SetString("World");

pMyString=&MyString;

00401589 lea eax,[ebp-10h];直接保存对象首地址

0040158C mov dword ptr[ebp+8],eax

return&MyString;

0040158F lea ecx,[ebp-10h]

00401592 mov dword ptr[ebp-14h],ecx

00401595 mov dword ptr[ebp-4],0FFFFFFFFh

0040159C lea ecx,[ebp-10h];将局部对象作为返回值

0040159F call@ILT+35(CMyString:~CMyString)(00401028)

004015A4 mov eax, dword ptr[ebp-14h]

}


如以上代码所示,在使用指针作为参数和返回值时,函数内没有对拷贝构造函数的调用。以此为依据,便可以分辨参数或返回值是对象还是对象的指针。如果在函数内为参数指针申请了堆对象,那么此时就会存在new运算和构造函数的调用,因此就更容易分辨参数或返回值。

5.全局对象与静态对象

全局对象与静态对象的构造时机相同,它们的构造函数的调用被隐藏在深处,但识别过程很容易。这似乎是矛盾的,但事实的确如此,因为程序中所有全局对象将会在同一地点调用构造函数以初始化数据。既然调用构造函数被固定在了某一个点上,无论这个点被隐藏得多深,只需找到一次即可。我们在第3章中讲解启动函数时分析过_cinit函数(位于VC 6.0的启动函数mainCRTStartup中)。全局对象的构造函数的初始化就是在此函数中实现的。

在函数_cinit的_initterm函数调用中,初始化了全局对象。_initterm实现的代码片段如下:


while(pfbegin<pfend){//pfbegin==__xc_a pfend==__xc_z

if(*pfbegin!=NULL)

(**pfbegin)();//调用每一个初始化或构造代理函数

++pfbegin;

}


当pfbengin不为NULL时进入if语句块中。执行(**pfbegin)();后并不会进入全局对象的构造函数中,而是进入编译器提供的构造代理函数中,由一个负责全局对象的构造代理函数完成对全局构造函数的调用过程,如代码清单10-5所示。

代码清单10-5 全局对象构造代理函数的分析—Debug版


;全局对象构造代理函数没有源码可对照分析

00401470 push ebp;初始化过程

00401471 mov ebp, esp

00401473 sub esp,40h

00401476 push ebx

00401477 push esi

00401478 push edi

00401479 lea edi,[ebp-40h]

0040147C mov ecx,10h

00401481 mov eax,0CCCCCCCCh

00401486 rep stos dword ptr[edi];Debug下初始化数据0xCC

00401488 call$E6(004013a0);调用构造函数,查看以下代码

0040148D call$E8(0040f110);登记析构函数的地址,后面将详细解释

00401492 pop edi

00401493 pop esi

00401494 pop ebx

00401495 add esp,40h

00401498 cmp ebp, esp

0040149A call__chkesp(00401540)

0040149F mov esp, ebp

004014A1 pop ebp

004014A2 ret

;全局对象CMyString g_MyStringOne的定义处

004013A0 push ebp

004013A1 mov ebp, esp

004013A3 sub esp,40h

004013A6 push ebx

004013A7 push esi

004013A8 push edi

004013A9 lea edi,[ebp-40h]

004013AC mov ecx,10h

004013B1 mov eax,0CCCCCCCCh

004013B6 rep stos dword ptr[edi];以上代码是函数入口

004013B8 mov ecx, offset g_MyStringOne(0042b174);获取this指针

004013BD call@ILT+10(CMyString:CMyString)(0040100f);调用构造函数

004013C2 pop edi

004013C3 pop esi

004013C4 pop ebx

004013C5 add esp,40h

004013C8 cmp ebp, esp

004013CA call__chkesp(00401540)

004013CF mov esp, ebp

004013D1 pop ebp

004013D2 ret


通过对代码清单10-5的分析可了解全局对象的定义过程。由于构造函数需要传递对象的首地址作为this指针,而且构造函数可以带各类参数,因此编译器将为每个全局对象生成一段传递this指针和参数的代码,然后使用无参的代理函数去调用构造函数。

思考题 对于全局对象和静态对象,能不能取消代理函数而直接在main函数前调用其构造函数呢?答案见本章小结。

全局对象构造函数的调用被隐藏在深处,那么在分析的过程中该如何跟踪全局对象的构造函数呢?可使用两种方法:直接定位初始化函数和利用栈回溯。

直接定位初始化函数

先进入mainCRTStartup函数中,然后顺藤摸瓜,找到初始化函数_cinit,在_cinit函数的第二个_initterm处设置断点。运行程序后,进入_initterm的实现代码内,断点在(**pfbegin)();执行处,单步进入代理构造,即可得到全局对象的构造函数。读者可以先在源码环境下单步跟踪,待熟悉后就可以脱离源码,直接在反汇编的条件下利用OllyDbg或者WinDbg等其他调试工具熟悉反汇编代码,尝试用自己的方法总结出快速识别的规律。

利用栈回溯

如果反汇编代码中出现了全局对象,由于全局对象的地址固定(对于有重定位表的执行文件中的全局对象,也可以在执行文件被加载后至执行前计算得到全局对象的地址),因此可以在对象的数据成员中设置读写断点,调试运行程序,等待构造函数调用的到来。利用栈回溯窗口,找到程序的执行流程,依次向上查询即可找到构造函数调用的起始处。

其实,最简单的办法是对atexit设置断点,因为构造代理函数中会注册析构函数,其注册的方式是使用atexit,在讲解虚函数的时候我们会详细介绍。