9.5 对象作为返回值
对象作为函数的返回值时,与基本的数据类型不同。基本数据类型(双精度浮点类型以及非标准的__int64类型除外)作为返回值时,通过寄存器eax来保存返回的数据,而对象属于自定义类型,寄存器eax无法保存对象中的所有数据,所以在函数返回时,寄存器eax不能满足需求。
对象作为返回值与对象作为参数的处理方式非常类似。对象作为参数时,进入函数前预先将对象使用的栈空间保留出来,并将实参对象中的数据复制到栈空间中。该栈空间作为函数参数,用于函数内部使用。同理,对象作为返回值时,进入函数后将申请返回对象使用的栈空间,在退出函数时,将返回对象中的数据复制到临时的栈空间中,以这个临时栈空间的首地址作为返回值。
先由简单的类对象作为返回值入手,由浅入深地学习。先来看一个例子,如代码清单9-8所示。
代码清单9-8 对象作为返回值—Debug版
//C++源码说明:在函数内定义对象并将其作为返回值
class CReturn{
public:
int m_nNumber;
int m_nArry[10];//定义两个数据成员,该类的大小为44字节
};
CReturn GetCReturn(){
CReturn RetObj;
RetObj.m_nNumber=0;
for(int i=0;i<10;i++){
RetObj.m_nArry[i]=i+1;
}
return RetObj;//返回局部对象
}
void main(int argc, char*argv[]){
CReturn objA;
objA=GetCReturn();
printf("%d%d%d",objA.m_nNumber, objA.m_nArry[0],objA.m_nArry[9]);
}
//构造函数与析构函数讲解略
//main函数代码分析
void main(int argc, char*argv[]){
00401290 push ebp
00401291 mov ebp, esp
00401293 sub esp,0C4h;预留返回对象的栈空间
00401299 push ebx
0040129A push esi
0040129B push edi
0040129C lea edi,[ebp-0C4h]
004012A2 mov ecx,31h
004012A7 mov eax,0CCCCCCCCh
004012AC rep stos dword ptr[edi]
CReturn objA;
objA=GetCReturn();
004012AE lea eax,[ebp-84h];获取返回对象的栈空间首地址
;将返回对象的首地址压入栈中,用于保存返回对象的数据
004012B4 push eax
;调用函数GetCReturn,见下文对GetCReturn的实现过程的分析
004012B5 call@ILT+45(GetCReturn)(00401032)
004012BA add esp,4
;函数调用结束后,eax中保存着地址ebp-84h,即返回对象的首地址
004012BD mov esi, eax;将返回对象的首地址存入esi中
004012BF mov ecx,0Bh;设置循环次数
004012C4 lea edi,[ebp-58h];获取临时对象的首地址
;每次从返回对象中复制4字节数据到临时对象的地址中,共复制11次
004012 C7 rep movs dword ptr[edi],dword ptr[esi]
004012C9 mov ecx,0Bh;重新设置复制次数
004012CE lea esi,[ebp-58h];获取临时对象的首地址
004012D1 lea edi,[ebp-2Ch];获取对象objA的首地址
;将数据复制到对象objA中
004012D4 rep movs dword ptr[edi],dword ptr[esi]
printf("%d%d%d",objA.m_nNumber, objA.m_nArry[0],objA.m_nArry[9]);
}
//GetCReturn的实现过程分析
CReturn GetCReturn(){
0040CE90 push ebp
0040CE91 mov ebp, esp
0040CE93 sub esp,70h;调整栈空间,预留临时返回对象与局部对象的内存空间
0040CE96 push ebx
0040CE97 push esi
0040CE98 push edi
0040CE99 lea edi,[ebp-70h]
0040CE9C mov ecx,1Ch
0040CEA1 mov eax,0CCCCCCCCh
0040CEA6 rep stos dword ptr[edi]
CReturn RetObj;
RetObj.m_nNumber=0;
;为数据成员nNumber赋值0,地址ebp-2Ch便是对象RetObj的首地址
0040CEA8 mov dword ptr[ebp-2Ch],0
for(int i=0;i<10;i++){
RetObj.m_nArry[i]=i+1;
}
0040CED4 jmp GetCReturn+28h(0040ceb8);for循环分析略,直接看退出函
;数时的处理
return RetObj;
0040CED6 mov ecx,0Bh;设置循环次数为11次
0040CEDB lea esi,[ebp-2Ch];获取局部对象的首地址
0040CEDE mov edi, dword ptr[ebp+8];获取返回对象的首地址
;将局部对象RetObj中的数据复制到返回对象中
0040CEE1 rep movs dword ptr[edi],dword ptr[esi]
0040CEE3 mov eax, dword ptr[ebp+8];获取返回对象的首地址并保存到eax中,
;作为返回值
}
代码清单9-8演示了函数返回对象的全过程。在调用GetCReturn前,编译器将在main函数中申请的返回对象的首地址作为参数压栈,在函数GetCReturn调用结束后进行了数据复制,将GetCReturn函数中定义的局部对象RetObj的数据复制到这个返回对象的空间中,再将这个返回的对象复制给目标对象objA,从而达到返回对象的目的。因为在这个示例中不存在函数返回后为对象的引用赋值,所以这里的返回对象是临时存在的,也就是C++中的临时对象,作用域仅限于单条语句。
为什么会产生这个临时对象呢?因为调用返回对象的函数时,C++程序员可能采用这类写法,如GetCReturn().m_nNumber,这只是针对返回对象的操作,而此时函数已经退出,其栈帧也被关闭。函数退出后去操作局部对象显然不合适,因此只能由函数的调用方准备空间,建立临时对象,然后将函数中的局部对象复制给临时对象,再把这个临时对象交给调用方去操作。本例中的objA=GetCReturn();是个赋值运算,由于赋值时GetCReturn函数已经退出,其栈空间已经关闭,同理objA不能直接和函数内局部对象做赋值运算,因此需要临时对象记录返回值以后再来参与赋值。
虽然使用临时对象进行了数据复制,但是同样存在出错的风险。这与对象作为参数时遇到的情况一样,由于使用了临时对象进行数据复制,当临时对象被销毁时,会执行析构函数。如果析构函数中有对资源释放的处理,就有可能造成同一个资源多次释放的错误发生。
这个错误与对象作为函数参数时的错误在原理上是一样的,也是临时对象被析构造成的,因此两者的解决方案也相同。对于复制对象的资源释放错误,我们会在第10章中给出详细的解决方案,并分析错误的处理过程。
当对象作为函数的参数时,可以传递指针;当对象作为返回值时,如果对象在函数内部被定义为局部变量,则不可返回此对象的首地址或引用,以避免返回已经被释放的局部变量,如以下代码所示。
class CTest{
public:
int m_nOne;
int m_nTwo;
};
//错误1:返回局部对象的首地址
CTest*GetTest(){
CTest test;
return&test;
}
//错误2:返回局部对象的引用,等同于返回局部对象的首地址
CTest&GetTest(){
CTest test;
return test;
}
由于函数退出后栈内对象的空间将被释放,因此无法保证返回值所指向地址的数据的正确性。引用返回值后,如果运气好,会导致数据访问错误,程序当场出错;如果运气再好一点,就会直接崩溃,这样就能在调试的时候发现错误。如果运气实在很差,在开发时数据访问正常,程序也工作正常,这个问题将可能会成为一个隐藏很深的错误。不过不用太担心,只要你在VC++工程中设置的警告级别(Warning level)不是None,这个问题在编译检查时就会被警告,只要你不漠视编译器的每个警告就行,最好把Warnings as errors打上钩。要解决此类错误,只能避免返回函数内局部变量的地址,但可以返回堆地址,还可以使用返回对象的办法来代替。由此可见,使用返回值为类对象的情况具有特殊的意义。
编译器在处理简单的结构体和类结构时,当二者经过O2选项的编译优化后,将难以识别出它们和局部变量之间的区别,但仍可根据数据的访问过程来还原相应的数据,如代码清单9-9所示。
代码清单9-9 还原对象数据—Release版
;main函数实现
;int__cdecl main(int argc, const char**argv, const char**envp)
sub esp,8
lea eax,[esp+8+var_8];获取局部变量的地址并存入eax中
mov[esp+8+var_8],3;赋值局部变量1
push eax;将局部变量的地址作为参数传递
mov[esp+0Ch+var_4],2;赋值局部变量2
call sub_401000;调用函数sub_401000
add esp,0Ch
retn
main endp
sub_401000 proc near
arg_0=dword ptr 4;有一个参数
mov eax,[esp+arg_0];获取参数并保存到eax中
;从eax保存的地址中取出2字节数据,结合后面一条指令可推断这是对象成员的寻址,因为参数指针指向的数据类型不一致
movsx ecx, word ptr[eax]
mov edx,[eax+4];寄存器相对间接寻址方式,这是对象成员的寻址
push ecx;将获取数据作为参数压栈
push edx
push offset aDD;"%d%d\r\n"
call printf;调用printf函数
add esp,0Ch
retn
sub_401000 endp
根据代码清单9-9中main函数的参数传递,以及函数sub_401000中对参数的使用过程,可以判断出函数sub_401000的参数为一个对象指针。根据使用的过程得知,该对象中定义了两个数据成员,它们分别占2字节和4字节的内存大小。可将此对象还原成结构体,代码如下所示。
struct tagUnknow{
short m_sShort;//占2字节
int m_nInt;//占4字节
};
相对而言,复杂对象的分析过程更为复杂,但可找到的特征信息也更多。在函数的调用过程中,当第一个参数为该对象首地址时,可怀疑这是this指针,按此函数的功能酌情还原为此类对象的成员函数。
在通常情况下,VC++6.0编译的代码默认以thiscall方式调用成员函数,因此会使用ecx来保存this指针,从而进行参数传递,但并非具有ecx传参的函数就一定是成员函数。当使用__fastcall时,同样可以在反汇编代码中体现出ecx传参。因此,在分析时不可将ecx传参作为识别this指针的唯一特征。那么类对象还具备哪些特征呢?通过下一章的学习,我们将发现它有更多与众不同的特性。