9.4 对象作为函数参数
对象作为函数的参数时,其传递过程较为复杂,传递方式比较独特。其传参过程与数组不同:数组变量的名称代表数组的首地址,而对象的变量名称却不能代表对象的首地址。传参时不会像数组那样以首地址作为参数传递,而是先将对象中的所有数据进行备份(复制),将复制的数据作为形参传递到调用函数中使用。
在基本的数据类型中,除双精度浮点类型外,其他所有数据类型在32位下都不超过4字节大小,使用一个栈元素即可完成数据的复制和传递。而类对象是自定义数据类型,是除自身外的所有数据类型的集合,各个对象的长度不定。对象在传参的过程中是如何被复制和传递的呢?我们来分析一下代码清单9-5。
代码清单9-5 对象作为函数的参数—Debug版
//C++源码说明:参数为对象的函数调用
class CFunTest{
public:
int m_nOne;
int m_nTwo;
};
void ShowFunTest(CFunTest FunTest){//参数为类CFunTest的对象
printf("%d%d\r\n",FunTest.m_nOne, FunTest.m_nTwo);
}
void main(){
CFunTest FunTest;
FunTest.m_nOne=1;
FunTest.m_nTwo=2;
ShowFunTest(FunTest);
}
//C++源码与对应汇编代码讲解
//main函数实现
void main(){
CFunTest FunTest;
;注意,这里没有任何调用默认构造函数的汇编代码
FunTest.m_nOne=1;
00401098 mov dword ptr[ebp-8],1;数据成员m_nOne所在地址为ebp-8
FunTest.m_nTwo=2;
0040109F mov dword ptr[ebp-4],2;数据成员m_nTwo所在地址ebp-4
ShowFunTest(FunTest);
004010A6 mov eax, dword ptr[ebp-4]
004010A9 push eax;传入数据成员m_nTwo
004010AA mov ecx, dword ptr[ebp-8]
004010AD push ecx;传入数据成员m_nOne
004010AE call@ILT+10(ShowFunTest)(0040100f)
004010B3 add esp,8
}
void ShowFunTest(CFunTest FunTest){
printf("%d%d\r\n",FunTest.m_nOne, FunTest.m_nTwo);
;取出数据成员m_nTwo作为printf函数的第三个参数
00401038 mov eax, dword ptr[ebp+0Ch]
0040103B push eax
;取出数据成员m_nOne作为printf函数的第二个参数
0040103C mov ecx, dword ptr[ebp+8]
0040103F push ecx
00401040 push offset string"%d%d\r\n"(0042001c)
00401045 call printf(00401120)
0040104A add esp,0Ch
}
在代码清单9-5中,类CFunTest的体积不大,只有两个数据成员,编译器在调用函数传参的过程中分别将对象的两个成员依次压栈,也就是直接将两个数据成员当成两个int类型数据,并将它们当做printf函数的参数。同理,它们也是一份复制数据,除数据相同外,与对象中的两个数据成员没有关系。
类对象中的数据成员的传参顺序为:最先定义的数据成员最后压栈,最后定义的数据成员最先压栈。当类的体积过大,或者其中定义有数组类型的数据成员时,会将数组的首地址作为参数压栈吗?我们来看看代码清单9-6。
代码清单9-6 含有数组数据成员的对象传参—Debug版
//C++源码说明:此代码为代码清单9-5的修改版,添加了数组成员char m_szName[32]
class CFunTest{
public:
int m_nOne;
int m_nTwo;
char m_szName[32];//定义数组类型的数据成员
};
void ShowFunTest(CFunTest FunTest){
//显示对象中各数据成员的信息
printf("%d%d%s\r\n",FunTest.m_nOne, FunTest.m_nTwo, FunTest.m_szName);
}
void main(){
CFunTest FunTest;
FunTest.m_nOne=1;
FunTest.m_nTwo=2;
strcpy(FunTest.m_szName,"Name");//赋值数据成员数组
ShowFunTest(FunTest);
}
//C++源码与对应汇编代码讲解
void ShowFunTest(CFunTest FunTest){
;初始化部分略
printf("%d%d%s\r\n",FunTest.m_nOne, FunTest.m_nTwo, FunTest.m_szName);
00401038 lea eax,[ebp+10h];取成员m_szName的地址
0040103B push eax;将成员m_szName的地址作为参数压栈
0040103C mov ecx, dword ptr[ebp+0Ch];取成员m_nTwo中的数据
0040103F push ecx
00401040 mov edx, dword ptr[ebp+8];取成员m_nOne中的数据
00401043 push edx
00401044 push offset string"%d%d%s\r\n"(0042002c)
00401049 call printf(00401120)
0040104E add esp,10h
}
//C++源码对照,main函数分析
void main(){
CFunTest FunTest;
;没有任何调用默认构造函数的汇编代码
FunTest.m_nOne=1;
0040B7E8 mov dword ptr[ebp-28h],1;数据成员m_nOne所在地址为ebp-28h
FunTest.m_nTwo=2;
0040B7EF mov dword ptr[ebp-24h],2;数据成员m_nTwo所在地址为ebp-24h
strcpy(FunTest.m_szName,"Name");
0040B7F1 push offset string"Name"(0041302c)
0040B7F6 lea eax,[ebp-20h];数组成员m_szName所在地址为ebp-20h
0040B7FE push eax
0040B7FF call strcpy(00404650)
ShowFunTest(FunTest);
0040B804 add esp,0FFFFFFE0h;调整栈顶,抬高32字节
0040B807 mov ecx,0Ah;设置循环次数为10
0040B80C lea esi,[ebp-28h];获取对象的首地址并保存到esi中
0040B80F mov edi, esp;设置edi为当前栈顶
;执行10次4字节内存复制,将esi所指向的数据复制到edi中,类似memcpy的内联方式
0040B811 rep movs dword ptr[edi],dword ptr[esi]
0040B813 call@ILT+10(ShowFunTest)(0040100f)
0040B818 add esp,28h
}
在代码清单9-6中,在传递类对象的过程中使用了“add esp,0FFFFFFE0h”来调整栈顶指针esp,0FFFFFFE0h是补码,转换后为-20h,等同于esp-20h。6.1节中讲过,参数变量在传递时,需要向低地址调整栈顶指针esp,此处申请的32字节栈空间,加上strcpy未平衡的8字节参数空间,都用于存放参数对象FunTest的数据。将对象FunTest中的数据依次复制到申请的栈空间中,对象FunTest的内存布局如图9-5所示。
图 9-5 对象FunTest的内存布局
在图9-5中,0x0012FF58为对象FunTest的首地址,第一个4字节的数据为数据成员m_nOne,由此向后,第二个4字节的数据为数据成员m_nTwo,以0x0012FF60为起始地址,后面的第一个32字节数据为数组成员m_szName。对象FunTest占用的内存大小为40字节,而代码清单9-6却只为栈空间申请了32字节大小。以对象的首地址为起始点,使用指令“rep movs”复制了40字节的数据,比栈空间申请的大小多出了8字节。为什么申请栈空间时少了8字节呢?数据复制完成后会不会造成越界访问呢?
先看一下之前所调用的函数strcpy,该函数的调用方式为__cdecl方式,当函数调用结束后,并没有平衡参数使用的栈顶。函数strcpy有两个参数,正好使用了8字节的栈空间。在函数ShowFunTest的调用过程中,重新利用这8字节的栈空间,完成了对对象FunTest中的数据的复制。当函数ShowFunTest调用结束后,调用指令“add esp,28h”平衡了该函数参数所使用的40字节的栈空间。
代码清单9-4和代码清单9-5中定义的类都没有定义构造函数和析构函数。由于对象作为参数在传递过程中会制作一份对象的复制数据,当向对象分配内存时,如果有构造函数,编译器会再调用一次构造函数,并做一些初始化工作。当代码执行到作用域结束时,局部对象将被销毁,而对象中可能会涉及资源释放的问题,同样,编译器也会再调用一次局部对象的析构函数,从而完成资源数据的释放。
有参考资料中提到,当类中没有定义构造函数和析构函数时,编译器会添加默认的构造函数和析构函数。根据代码清单9-4和代码清单9-5中的分析得知,在定义类对象时,编译器根本没有做任何处理,可见编译器并没有添加默认的构造函数。其原因涉及更多构造函数与析构函数的知识,详见第10章。
当对象作为函数的参数时,由于重新复制了对象,等同于又定义了一个对象,在某些情况下会调用特殊的构造函数—拷贝构造函数,详见第10章。当函数退出时,复制的对象作为函数内的局部变量,将会被销毁。当析构函数存在时,则会调用析构函数,这时问题便会出现了,如代码清单9-7所示。
代码清单9-7 对象作为参数的资源释放错误—Debug版
//C++源码说明:涉及资源申请与释放的类对象
class CMyString{
public:
CMyString(){
m_pString=new char[10];//申请堆空间,只要不释放,进程退出前将一直存在
if(m_pString==NULL){//堆空间申请成功与否
return;
}
strcpy(m_pString,"Hello");
}
~CMyString(){
if(m_pString!=NULL){//检查资源
delete m_pString;//释放堆空间
m_pString=NULL;
}
}
char*GetString(){
return m_pString;//获取数据成员
}
private:
char*m_pString;//数据成员定义,保存堆的首地址
};
//参数为CMyString类对象的函数
void ShowMyString(CMyString MyStringCpy){
printf(MyStringCpy.GetString());
}
//main函数实现
void main(){
CMyString MyString;//类对象定义
ShowMyString(MyString);
}
//C++源码与对应汇编代码讲解
//C++源码对照,main函数分析
void main(){
CMyString MyString;
;获取对象的首地址,放入ecx中作为this指针
0040121D lea ecx,[ebp-10h]
;调用构造函数
00401220 call@ILT+5(CMyString:CMyString)(0040100a)
;记录同一作用域内该类的对象个数,详见第10章
00401225 mov dword ptr[ebp-4],0
ShowMyString(MyString);
;MyString对象长度为4,一个寄存器单元刚好能存放
;于是eax获取对象首地址处4字节的数据,即数据成员m_pString
0040122C mov eax, dword ptr[ebp-10h]
0040122F push eax
00401230 call@ILT+15(ShowMyString)(00401014)
00401235 add esp,4
}//main函数结束处
;由于对象被释放,修改对象个数
00401238 mov dword ptr[ebp-4],0FFFFFFFFh
;获取对象首地址,传入ecx作为this指针
0040123F lea ecx,[ebp-10h]
;调用析构函数
00401242 call@ILT+20(CMyString:~CMyString)(00401019)
;部分代码讲解略
0040111E ret
//构造函数与析构函数讲解略
//ShowMyString函数的实现过程分析
void ShowMyString(CMyString MyStringCpy){
004010B0 push ebp
004010B1 mov ebp, esp
;======================异常链处理过程=========================
004010B3 push 0FFh
004010B5 push offset__ehhandler$?ShowMyString@@YAXVCMyString@@@Z(00410d39)
004010BA mov eax, fs:[00000000]
004010C0 push eax
004010C1 mov dword ptr fs:[0],esp
;====================异常链处理过程见第13章====================
004010C8 sub esp,40h
004010CB push ebx
004010CC push esi
004010CD push edi
004010CE lea edi,[ebp-4Ch]
004010D1 mov ecx,10h
004010D6 mov eax,0CCCCCCCCh
004010DB rep stos dword ptr[edi]
004010DD mov dword ptr[ebp-4],0;作用域内的对象个数
printf(MyStringCpy.GetString());
;取参数1的数据成员m_pString的地址(即对象首地址)并保存到ecx中作为this指针
;注意,此m_pString地址非main函数中的对象MyString的首地址
004010E4 lea ecx,[ebp+8];取参数1的地址
;调用成员函数GetString,该方法的讲解略
004010E7 call@ILT+0(CMyString:GetString)(00401005)
004010EC push eax;将返回eax中保存的字符串的首地址作为参数压栈
004010ED call printf(00401310)
004010F2 add esp,4
}//ShowMyString函数的结尾处
;由于对象被释放,修改对象个数
004010F5 mov dword ptr[ebp-4],0FFFFFFFFh
;取参数1的地址,作为this指针调用析构函数
004010FC lea ecx,[ebp+8]
004010FF call@ILT+20(CMyString:~CMyString)(00401019)
;部分代码讲解略
0040111E ret
在代码清单9-7中,当对象作为参数被传递时,参数MyStringCpy复制了对象MyString中的数据成员m_pString,产生了两个CMyString类的对象。由于没有编写拷贝构造函数,因此在传参的时候就没有被调用,这个时候编译器以浅拷贝处理,它们的数据成员m_pString都指向了同一个堆地址,如图9-6所示。
图 9-6 复制对象与原对象对比
根据图9-6所示,两个对象中的数据成员m_pString指向了相同地址,当函数ShowMyString调用结束后,便会释放对象MyStringCpy,以对象MyStringCpy的首地址作为this指针调用析构函数。在析构函数中,调用delete函数来释放掉对象MyStringCpy的数据成员m_pString所保存的堆空间的首地址。但对象MyStringCpy是MyString的复制品,真正的MyString还存在,而数据成员m_pString所保存的堆空间的首地址却被释放,如果出现以下代码便会产生错误:
CMyString MyString;
//当该函数调用结束后,对象MyString中的数据成员m_pString所保存的堆空间已经
//被释放掉,再次使用此对象中的数据成员m_pString便无法得到堆空间的数据
ShowMyString(MyString);
ShowMyString(MyString);显示地址中为错误数据
这个错误在ZI选项中会被触发,因为使用delete后,堆空间被置为某个标记值;而在O2选项中,并不会对释放堆中的数据进行检查。如果没有再次申请堆空间,则此地址中的数据仍然存在,会导致错误被隐蔽,为程序埋下隐患。
有两种解决方案可以修正这个错误:深拷贝数据和设置引用计数,这两种解决方案都需要拷贝构造函数的配合。本节中只做简单的讲解,详见第10章。
深拷贝数据:在复制对象时,编译器会调用一次该类的拷贝构造函数,给编码者一次机会。深拷贝利用这次机会将原对象的数据成员所保存的资源信息也制作一份副本。这样,当销毁复制对象时,销毁的资源是复制对象在拷贝构造函数中制作的副本,而非原对象中保存的资源信息。
设置引用计数:在进入拷贝构造函数时,记录类对象被复制引用的次数。当对象被销毁时,检查这个引用计数中保存的引用复制次数是否为0。如果是,则释放掉申请的资源,否则引用计数减1。
当参数为对象的指针类型时,则不存在这种错误。传递的数据是指针类型,在函数内的操作都是针对原对象的,不存在对象被复制的问题。由于没有副本,因此在函数进入和退出时不会调用构造函数和析构函数,也就不存在资源释放的错误隐患。在使用类对象作为参数时,如无特殊需求,应尽量使用指针或引用。这样做不但可以避免资源释放的错误隐患,还可以在函数调用过程中避免复制操作,提升程序运行的效率。
由于目前所学知识还无法修正这个错误,因此暂且将其搁置。虽然错误没有解决,但并不影响后面的学习。学习了构造函数和析构函数的相关知识后,这个问题便会迎刃而解。
笔者并不赞成在设计软件时将申请资源的工作交给构造函数来完成,此处仅仅是讲解实例。