C++逆向之构造函数和析构函数

构造函数与析构函数是类的重要组成部分,它们在类中担任着至关重要的工作。构造函数常用来完成对象生成时数据初始化工作,而析构函数则常用于销毁时释放对象中所申请的资源。
当对象生成时,编译器会自动产生调用其类构造函数的代码,在编码过程中可以为类中的数据成员赋予恰当的初始值。当对象销毁时,编译器同样也会产生调用其类析构函数的代码。
构造函数与析构函数都是类中特殊的成员函数,构造函数支持函数重载,而析构函数只能是一个无参函数。它们不可定义放回值,调用构造函数后,返回值为对象首地址,也就是this指针。
在某些情况下,编译器会提供默认的构造函数和析构函数,但并不是任何情况下编译器都会提供。那么在何种情况下编译器会提供默认的构造函数和析构函数?编译器又是如何调用它们的呢?

构造函数出现的时机

对象生成时会自动调用构造函数。只要找到了定义对象的地方就找到了构造函数的调用时机,这看似简单,实际情况却相反,不同作用域的对象生命周期不同,如同局部对象、全局对象、静态对象等的生命周期各不相同,而当对象作为函数参数与返回值时,构造函数的出现时机又会有所不同。
将对象进行分类:不同类型对象的构造函数被调用的时机会发生变化,但都会遵循C++语法:定义的同时调用构造函数。那么,只要知道了对象的生命周期,便可推断出构造函数的调用时机。下面先根据生命周期将对象进行分类,然后分析各类对象的构造函数和析构函数的调用时机

  • 局部对象
  • 堆对象
  • 参数对象
  • 返回对象
  • 全局对象
  • 静态对象

局部对象

局部对象下的构造函数的出现时机比较容易识别。当对象产生时,便有可能引发构造函数的调用。编译器隐藏了构造函数的调用过程,使编码者无法看到调用细节。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class 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,eax

CNumber

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class CNumber

{
public:
CNumber()
{
m_nNumber = 1;
}
int m_nNumber;
};
int main(int argc, char* argv[])
{
CNumber *pNumber = NULL;
pNumber = new CNumber;
pNumber->m_nNumber = 2;
return 0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
0040104D    C745 F0 0000000>mov dword ptr ss:[ebp-0x10],0x0          ; 指针在ebp-10h,初始化为0(NULL)
00401054 6A 04 push 0x4 ; 压入类的大小,用于堆内存的申请
00401056 E8 55010000 call test.operator newnryreshold_pages
0040105B 83C4 04 add esp,0x4
0040105E 8945 E8 mov dword ptr ss:[ebp-0x18],eax ; 使用临时变量保存new返回值
00401061 C745 FC 0000000>mov dword ptr ss:[ebp-0x4],0x0 ; ebp-4保存申请堆空间的次数
00401068 837D E8 00 cmp dword ptr ss:[ebp-0x18],0x0 ; 检测堆内存是否申请成功
0040106C 74 0D je short test.0040107B
0040106E 8B4D E8 mov ecx,dword ptr ss:[ebp-0x18] ; 申请成功,将对象首地址传入ecx中
00401071 E8 8FFFFFFF call test.00401005 ; 调用构造函数
00401076 8945 E4 mov dword ptr ss:[ebp-0x1C],eax ; 构造函数返回this指针,保存到临时变量ebp-1Ch
;结合0040106C处je指令和下面的jmp指令,可发现编译器在这里产生了一个双分支结构,用于检查new运算。如果执行成功,则调用构造函数,this指针保存在ebp-1Ch中,否则避开构造函数,将ebp-1Ch设为0
00401079 EB 07 jmp short test.00401082
0040107B C745 E4 0000000>mov dword ptr ss:[ebp-0x1C],0x0 ; 申请堆空间失败,设置指针值为NULL
;在没有打开O2时,对象地址将在几个临时变量中倒换,最终保存到ebp-10h中,即指针变量pNumber
00401082 8B45 E4 mov eax,dword ptr ss:[ebp-0x1C]
00401085 8945 EC mov dword ptr ss:[ebp-0x14],eax
00401088 C745 FC FFFFFFF>mov dword ptr ss:[ebp-0x4],-0x1
0040108F 8B4D EC mov ecx,dword ptr ss:[ebp-0x14]
00401092 894D F0 mov dword ptr ss:[ebp-0x10],ecx
00401095 8B55 F0 mov edx,dword ptr ss:[ebp-0x10] ; edx得到this指针
00401098 C702 02000000 mov dword ptr ds:[edx],0x2 ; 为成员变量m_nNUmber赋值2

在使用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
46
class 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
0040113D    8D4D F0         lea ecx,dword ptr ss:[ebp-0x10]
00401140 E8 CAFEFFFF call test.0040100F ; 无参构造函数
00401145 C745 FC 0000000>mov dword ptr ss:[ebp-0x4],0x0
0040114C 68 1C504200 push test.0042501C ; ASCII "Hello"
00401151 8D4D F0 lea ecx,dword ptr ss:[ebp-0x10]
00401154 E8 ACFEFFFF call test.00401005 ; 调用成员函数
;这里的“push ecx”等价于“sub esp,4”,但是“push ecx”的机器码更短,效率更高。CMyString的类型长度为4字节,所以传递参数对象时需要
;在栈顶留下4字节,以作为参数对象的空间,此时esp保存的内容就是参数对象的地址。
00401159 51 push ecx
0040115A 8BCC mov ecx,esp ; 获取参数对象的地址,保存到ecx中
0040115C 8965 EC mov dword ptr ss:[ebp-0x14],esp ; ebp-14中保存参数对象的地址
0040115F 8D45 F0 lea eax,dword ptr ss:[ebp-0x10] ; 获取对象MyString的地址并保存到eax中
00401162 50 push eax ; 将MyString地址作为参数,调用拷贝构造函数
00401163 E8 ACFEFFFF call test.00401014
00401168 8945 E4 mov dword ptr ss:[ebp-0x1C],eax ; ebp-1Ch保存了拷贝构造函数返回的this指针
0040116B E8 9AFEFFFF call test.0040100A ; 此时栈顶的参数对象传递完毕,开始函数调用
00401170 83C4 04 add esp,0x4
00401173 C745 E8 0000000>mov dword ptr ss:[ebp-0x18],0x0
0040117A C745 FC FFFFFFF>mov dword ptr ss:[ebp-0x4],-0x1
00401181 8D4D F0 lea ecx,dword ptr ss:[ebp-0x10]
;调用对象CMyString的析构函数
00401184 E8 95FEFFFF call test.0040101E
00401189 8B45 E8 mov eax,dword ptr ss:[ebp-0x18]

CMyString(CMyString& obj)

1
2
;其余代码不再赘述,关键看返回值
0040126C 8B45 FC mov eax,dword ptr ss:[ebp-0x4] ; 将this指针作为返回值

show(CMyString MyString)

1
2
3
4
5
;获取参数对象的数据成员,并作为printf参数使用
00401068 8B45 08 mov eax,dword ptr ss:[ebp+0x8]
0040106B 50 push eax
0040106C E8 EF020000 call test.printfgvdbgind_blockeressges
00401071 83C4 04 add esp,0x4

在执行函数show之前,先进入到CMyString的拷贝构造函数中。在拷贝构造函数中,我们使用深拷贝方式。这时数据成员this->m_pString和obj.m_pString所保存的地址不同,但其中的数据内容却是相同的。
由于使用了深拷贝方式,对对象中的数据成员所指向的堆空间数据也进行了数据复制,因此当参数对象被销毁时,释放的堆空间数据是拷贝对象所制作的数据副本,对源对象没有任何影响。

返回对象

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

1
2
3
4
5
6
7
8
9
10
11
12
CMyString GetString()         //返回类型为对象
{
CMyString MyString;
MyString.SetString("World");
return MyString;
}

int main(int argc, char* argv[])
{
CMyString MyString = GetString();
return 0;
}

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

1
2
3
4
004012B8    8D45 FC         lea eax,dword ptr ss:[ebp-0x4]           ; 取对象MyString的首地址
004012BB 50 push eax ; 将对象的首地址作为参数传递
004012BC E8 62FDFFFF call test.00401023
004012C1 83C4 04 add esp,0x4 ; 参数平衡

CMyString GetString()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
0040EE7D    C745 EC 0000000>mov dword ptr ss:[ebp-0x14],0x0
0040EE84 8D4D F0 lea ecx,dword ptr ss:[ebp-0x10] ; 传递this指针,调用构造函数
0040EE87 E8 8321FFFF call test.0040100F
0040EE8C C745 FC 0100000>mov dword ptr ss:[ebp-0x4],0x1 ; 对象技术器,调试中作为参考,这里不必理会
0040EE93 68 1C504200 push test.0042501C ; ASCII "World"
0040EE98 8D4D F0 lea ecx,dword ptr ss:[ebp-0x10]
0040EE9B E8 6521FFFF call test.00401005
0040EEA0 8D45 F0 lea eax,dword ptr ss:[ebp-0x10] ; 获取局部对象的首地址
0040EEA3 50 push eax ; 将对象MyString的地址作为参数
0040EEA4 8B4D 08 mov ecx,dword ptr ss:[ebp+0x8] ; 获取参数中保存的this指针
0040EEA7 E8 6821FFFF call test.00401014 ; 调用拷贝构造函数,以局部对象MyString的地址作为参数
0040EEAC 8B4D EC mov ecx,dword ptr ss:[ebp-0x14]
0040EEAF 83C9 01 or ecx,0x1
0040EEB2 894D EC mov dword ptr ss:[ebp-0x14],ecx
0040EEB5 C645 FC 00 mov byte ptr ss:[ebp-0x4],0x0 ; 对象计数器
0040EEB9 8D4D F0 lea ecx,dword ptr ss:[ebp-0x10]
0040EEBC E8 5D21FFFF call test.0040101E ; 调用局部对象MyString的析构函数
0040EEC1 8B45 08 mov eax,dword ptr ss:[ebp+0x8] ; 将参数作为返回值

GetMyString将返回对象的地址作为函数参数。在函数返回之前,利用拷贝构造函数将函数中局部对象的数据复制到参数指向的对象中,起到了返回对象的作用。与其等价的函数原型如下:

CMyString GetMyString(CMyString pMyString)

虽然编译器会对返回值为对象类型的函数进行调整,修改其参数与返回值,但是它留下了一个与返回指针类型不同的特征,那就是在函数中使用拷贝构造函数。返回值和参数为对象指针类型的函数,不会使用以参数为目标的拷贝构造函数,而是直接使用指针保存对象首地址。
在使用指针作为参数和返回值时,函数内没有对拷贝构造函数的调用。以此为依据,便可以分辨参数或返回值是对象还是对象的指针。如果在函数内参数指针申请了堆对象,那么此时就会存在new运算和构造函数的调用,因此就更容易分辨参数或返回值。

全部对象与静态对象

全局对象与静态对象的构造时机相同,它们的构造函数的调用被隐藏在深处,但识别过程很容易。因为程序中所有全局对象将会在同一地点调用构造函数以初始化数据。既然调用构造函数被固定在了某一个点上,无论这个点被隐藏得多深,只需找到一次即可。
全局对象构造函数的调用被隐藏在深处,在分析过程中可使用两种方法跟踪全局对象的构造函数:

  • 直接定位初始化函数
    先进入mainCRTStartup函数中,然后顺藤摸瓜,找到初始化函数_cinii,在_cinit函数的第二个_initterm处设置断点。运行程序后,进入_initterm的实现代码内,断点在(**pfbegin)();执行处,单步进入代理构造,即可得到全局对象的构造函数。
  • 利用栈回溯
    如果反汇编代码中出现了全局变量,由于全局对象的地址固定,因此可以在对象的数据成员中设置读写断点,调试运行程序,等待构造函数调用的到来。利用栈回溯窗口,找到程序的执行流程,依次向上查询即可找到构造函数调用的起始处。

其实,最简单的办法是对atexit设置断点,因为构造代理函数中会注册析构函数,其注册的方式是使用atexit。

每个对象都有默认的构造函数吗

有些C++类图书在介绍构造函数时会提及,当没有定义构造函数时,编译器会提供默认的构造函数,这个函数什么事情都不做,其内容类似于“{}”的形式。但是在许多情况下,编译器并没有提供默认的构造函数,而且进过O2选项优化编译后,某些结构简单的类会被转化为几个连续定义的变量,哪里还会需要构造函数呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class CInit
{
public:
void SetNumber(int nNumber)
{
m_nNumber = nNumber;
}
int GetNumber()
{
return m_nNumber;
}
private:
int m_nNumber;
};
int main(int argc, char* argv[])
{
CInit Init;
Init.SetNumber(5);
printf("%d",Init.GetNumber());
return 0;
}

在图中,对象Init的定义处没有任何对象的汇编代码,也没有构造函数的调用过程,可见编译器并没有为其提供默认的构造函数。那么,在何种情况下编译器会提供默认的构造函数呢?

  • 本类、本来定义的成员对象或父类中有虚函数存在
    由于需要初始化虚表,且这个工作理应在构造函数中隐式完成,因此在没有定义构造函数的情况下,编译器会添加默认的构造函数用于隐式完成虚表的初始化工作
  • 父类或本类中定义的成员对象带有构造函数
    在对象被定义时,由于对象本身为派生类,因此构造顺序是先构造父类再构造自身。当父类中带有构造函数时,将会调用父类构造函数,而这个调用过程需要在构造函数内完成,因此编译器添加了默的构造函数来完成这个调用过程。成员对象带有构造含糊的情况与此相同。

在没有定义构造函数的情况下,当类中没有虚函数存在,父类和成员对象也没有定义构造函数时,提供默认的构造函数已没有任何意义,只会降低程序的执行效率。

析构函数的出现时机

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

  • 局部对象:作用域结束前调用析构函数
  • 堆对象:释放堆空间前调用析构函数
  • 参数对象:退出函数前,调用参数对象的析构函数
  • 返回对象:如无对象引用定义,退出函数后,调用返回对象的析构函数,否则与对象引用的作用域一致
  • 全局对象:main函数退出后调用析构函数
  • 静态对象:main函数退出后调用析构函数

局部对象

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include"stdafx.h"
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
2
3
4
5
00401048    8D4D FC         lea ecx,dword ptr ss:[ebp-0x4]           ; 获取对象首地址,作为this指针
0040104B E8 B5FFFFFF call test.00401005 ; 调用构造函数
00401050 C745 F8 0000000>mov dword ptr ss:[ebp-0x8],0x0
00401057 8D4D FC lea ecx,dword ptr ss:[ebp-0x4] ; 获取对象首地址,作为this指针
0040105A E8 B0FFFFFF call test.0040100F ; 调用析构函数

~CNumber()

1
2
3
4
004010E9    59              pop ecx                                  ; 还原this指针到ecx中
004010EA 894D FC mov dword ptr ss:[ebp-0x4],ecx ; 使用临时空间保存ecx
004010ED 68 1C204200 push test.0042201C ; ASCII "~CNumber\n"
004010F2 E8 69000000 call test.printfgvdbgind_blockeressges

类CNumber提供了析构函数,在对象Number所在的作用域结束处,调用了析构函数~CNumber。析构函数同样属于成员函数,因此在调用过程中也需要传递this指针。
析构函数与构造函数有所不同,析构函数不支持函数重载,并且只有一个参数,即this指针,而且编译器隐藏了这个参数的传递过程,对于开发者而言,它是一个隐藏了this指针的无参函数。

堆对象

堆对象比较特殊,编译器将它的生杀大权交给了使用者。一些粗心的使用者只知道创造堆对象,而忘记了销毁,导致程序中永远存在一些无用的堆对象,其他堆类型数据也是如此。程序中的资源是有限的,只申请资源而不释放资源会造成内存泄漏,这点在设计服务器端程序时尤其要注意。
使用new申请了堆对象空间以后,何时释放对象要看开发者在哪里调用了delete来释放对象所在的空间。delete的使用便是找到堆对象调用析构函数的关键点。先来看一个调用过程。

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
#include"stdafx.h"
class CNumber
{
public:
CNumber()
{
m_nNumber=1;
}
~CNumber()
{
printf("~CNumber\n");
}
int m_nNumber;
};
int main(int argc,char* argv[])
{
CNumber *pNumber = NULL;
pNumber = new CNumber();
pNumber->m_nNumber = 2;
printf("%d\n", pNumber->m_nNumber);
if(pNumber != NULL)
{
delete pNumber;
pNumber = NULL;
}
return 0;
}

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

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
0040105D   .  C745 F0 00000>mov dword ptr ss:[ebp-0x10],0x0          ;  指针变量所在的地址为ebp-10处
00401064 . 6A 04 push 0x4
00401066 . E8 F5020000 call test.operator newnryreshold_pages ; new
0040106B . 83C4 04 add esp,0x4
0040106E . 8945 E8 mov dword ptr ss:[ebp-0x18],eax
00401071 . C745 FC 00000>mov dword ptr ss:[ebp-0x4],0x0
00401078 . 837D E8 00 cmp dword ptr ss:[ebp-0x18],0x0
0040107C . 74 0D je short test.0040108B
0040107E . 8B4D E8 mov ecx,dword ptr ss:[ebp-0x18]
00401081 . E8 7FFFFFFF call test.00401005
00401086 . 8945 DC mov dword ptr ss:[ebp-0x24],eax
00401089 . EB 07 jmp short test.00401092
0040108B > C745 DC 00000>mov dword ptr ss:[ebp-0x24],0x0
00401092 > 8B45 DC mov eax,dword ptr ss:[ebp-0x24]
00401095 . 8945 EC mov dword ptr ss:[ebp-0x14],eax
00401098 . C745 FC FFFFF>mov dword ptr ss:[ebp-0x4],-0x1
0040109F . 8B4D EC mov ecx,dword ptr ss:[ebp-0x14]
004010A2 . 894D F0 mov dword ptr ss:[ebp-0x10],ecx
004010A5 . 8B55 F0 mov edx,dword ptr ss:[ebp-0x10]
004010A8 . C702 02000000 mov dword ptr ds:[edx],0x2
004010AE . 8B45 F0 mov eax,dword ptr ss:[ebp-0x10]
004010B1 . 8B08 mov ecx,dword ptr ds:[eax]
004010B3 . 51 push ecx
004010B4 . 68 1C504200 push test.0042501C ; ASCII "%d\n"
004010B9 . E8 22020000 call test.printfgvdbgind_blockeressges
004010BE . 83C4 08 add esp,0x8
004010C1 . 837D F0 00 cmp dword ptr ss:[ebp-0x10],0x0 ; if(pNUmber != NULL)
004010C5 . 74 2F je short test.004010F6
004010C7 . 8B55 F0 mov edx,dword ptr ss:[ebp-0x10] ; 获取指针变量
004010CA . 8955 E0 mov dword ptr ss:[ebp-0x20],edx
004010CD . 8B45 E0 mov eax,dword ptr ss:[ebp-0x20]
004010D0 . 8945 E4 mov dword ptr ss:[ebp-0x1C],eax ; eax保存了指针变量中的数据
004010D3 . 837D E4 00 cmp dword ptr ss:[ebp-0x1C],0x0 ; 编译器的指针检查
004010D7 . 74 0F je short test.004010E8 ; 如果为空则跳过析构函数调用
004010D9 . 6A 01 push 0x1 ; 标记,在多重继承时会详细介绍
004010DB . 8B4D E4 mov ecx,dword ptr ss:[ebp-0x1C] ; 传递this指针
004010DE . E8 31FFFFFF call test.00401014 ; 调用析构代理函数
004010E3 . 8945 D8 mov dword ptr ss:[ebp-0x28],eax
004010E6 . EB 07 jmp short test.004010EF ; 释放空间成功,跳过失败处理
004010E8 > C745 D8 00000>mov dword ptr ss:[ebp-0x28],0x0
004010EF > C745 F0 00000>mov dword ptr ss:[ebp-0x10],0x0 ; pNumber=0

析构代理函数的实现分析

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
00401190 >  55              push ebp
00401191 8BEC mov ebp,esp
00401193 83EC 44 sub esp,0x44
00401196 53 push ebx
00401197 56 push esi ; test.<ModuleEntryPoint>
00401198 57 push edi
00401199 51 push ecx ; test.00427CD0
0040119A 8D7D BC lea edi,dword ptr ss:[ebp-0x44]
0040119D B9 11000000 mov ecx,0x11
004011A2 B8 CCCCCCCC mov eax,0xCCCCCCCC ; Debug初始化数据部分
004011A7 F3:AB rep stos dword ptr es:[edi]
004011A9 59 pop ecx ; 还原this指针
004011AA 894D FC mov dword ptr ss:[ebp-0x4],ecx ; test.00427CD0
004011AD 8B4D FC mov ecx,dword ptr ss:[ebp-0x4] ; 传入this指针
004011B0 E8 5AFEFFFF call test.0040100F ; 调用析构函数
004011B5 8B45 08 mov eax,dword ptr ss:[ebp+0x8]
004011B8 83E0 01 and eax,0x1 ; 检查析构函数标记,介绍多重继承时详细介绍
004011BB 85C0 test eax,eax
004011BD 74 0C je short test.004011CB
004011BF 8B4D FC mov ecx,dword ptr ss:[ebp-0x4]
004011C2 51 push ecx ; 压入堆空间的首地址
004011C3 E8 88000000 call test.operator deletePointeressges ; 释放堆空间
004011C8 83C4 04 add esp,0x4
004011CB 8B45 FC mov eax,dword ptr ss:[ebp-0x4]
004011CE 5F pop edi ; 0019FF34
004011CF 5E pop esi ; 0019FF34
004011D0 5B pop ebx ; 0019FF34
004011D1 83C4 44 add esp,0x44
004011D4 3BEC cmp ebp,esp
004011D6 E8 85050000 call test._chkespetUnhandledExceptionFilterter'
004011DB 8BE5 mov esp,ebp
004011DD 5D pop ebp ; 0019FF34
004011DE C2 0400 retn 0x4

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

1
2
3
4
5
6
CNumber * 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指针
  • 没有返回值
文章目录
  1. 1. 构造函数出现的时机
    1. 1.1. 局部对象
    2. 1.2. 堆对象
    3. 1.3. 参数对象
    4. 1.4. 返回对象
    5. 1.5. 全部对象与静态对象
  2. 2. 每个对象都有默认的构造函数吗
  3. 3. 析构函数的出现时机
    1. 3.1. 局部对象
    2. 3.2. 堆对象
    3. 3.3. 参数对象和返回对象
    4. 3.4. 全局对象与静态对象
  4. 4. 小结