對象的傳值與返回
說起函數,就不免要談談函數的參數和返回值。一般的,我們習慣把函數看作一個處理的封裝(比如黑箱),而參數和返回值一般對應着處理過程的輸入和輸出。這種情況下,參數和返回值都是值類型的,也就是說,函數和它的調用者的信息交流方式是用過數據的拷貝來完成,即我們習慣上稱呼的“值傳遞”。但是自從引入了“引用”的概念后,函數的傳統模型就不再那么“和諧”了。引用的傳遞可以允許函數和調用者共享數據對象,它們之間的信息交流不再使用信息拷貝的方式,而是使用更有效率的信息共享的方式,引用導致函數的參數並有輸入和輸出的雙重功能。然而,事物總有兩面性,信息共享帶來方便的同時也帶來了一定的不安全性。我們這里並不討論函數的使用和設計,我們關注與函數參數和返回值的傳遞方式。
對於內置數據類型的參數和返回值,函數實際參數的傳遞一般是通過壓棧完成,函數執行時會從棧內取出參數的值進行計算。在32處理器上,push指令一次只能壓入4個字節的數據,那么對於long long就需要兩次壓棧指令了,而double類型參數就需要sub esp,8結合mov指令完成參數進棧的操作。函數帶有返回值時,若返回值不大於4字節,則會把返回值存儲在eax寄存器中,而long long類型返回值回保存在edx:eax寄存器中,double類型的數據會被協處理器棧保存。
相對於內置類型的參數傳遞和返回值,對象的傳值和返回可能更復雜一點。當然,如果使用對象的引用或者指針作為參數傳遞和返回值的方式,這里和上述的內置類型並無多大區別,因為指針總是4個字節。如果不使用引用和指針,單純傳遞純粹的對象時,編譯器會如何處理呢?
為此,我們定義一個簡單的類A,為了防止編譯器對我們的代碼優化處理(參考我的前一篇博文),我們自己定義構造函數、復制構造函數和賦值運算符重載函數。
{
int x;
int y;
int z;
public:
A(){}
A( const A&a)
{
x=a.x;
y=a.y;
z=a.z;
}
const A& operator=( const A&a)
{
x=a.x;
y=a.y;
z=a.z;
}
};
定義一個簡單的具有對象參數和返回值的函數,以及測試代碼。
{
return x;
}
A a;
a=fun(a);
試想一下,如果A不是自定義類型,而是int類型的話,這段測試代碼會有怎樣的效果。
push eax ; //a值進棧
call fun ; //調用fun
add esp, 4 ; //恢復棧指針
mov [a],eax ; //返回值寫入a
;//而fun內部無非也是把參數x的值寫入eax,然后返回而已。
mov eax,[a]
ret
事實是這樣的嗎?我們看一下VS2010的反匯編。


和我們的預期完全一致!
現在,我們回到對象的問題上來。由於對象是值傳遞方式,因此,對象傳遞之前需要進行一次對象拷貝(從原對象到實參)。函數調用結束后還需要將返回值對象進行一次拷貝。我們看看VS2010的處理方式。

對象a定義是需要調用它的構造函數A::A(

對象A包含三個整形數據成員,因此它的大小是12(0x
mov ecx,esp記錄了被拷貝的參數對象的地址(this指針),push eax壓入的是a的地址,也就是拷貝構造函數調用時參數對象的地址(引用)。拷貝構造函數(A::A(

push ecx壓入了內存地址ebp-58h,這個地址既不是a的地址,也不是拷貝出參數對象的地址,而是要保存返回對象的地址!調用fun之前將該地址壓棧,就是為了保存fun處理結束后的返回值對象。fun調用結束后將esp指針恢復了16字節,正好是參數對象的大小(12字節)加上返回值對象的地址(4字節)之和!要獲得fun的返回值,直接訪問eax即可,因為它保存着返回值對象的地址(ebp-58h)!

最后一步是對象的賦值,這里需要調用對象的賦值運算符重載函數。而參數正是剛才fun調用結束后eax的值,因為它存儲了返回值對象的地址。ecx記錄this指針,正是被賦值對象的地址(a的地址)。賦值運算符重載函數調用結束后,完成返回值對象的賦值操作。
按照編譯器產生的fun函數的語義,我們使用高級語言可以將它的意思描述如下。
a.A(); // 默認構造
A x; // 開辟x的12字節空間
x.(a); // 對象復制到實際參數
A*pret=&ret; // 取返回值對象地址(已經開辟過了)
fun(pret,x); // 傳遞返回值指針pret和參數對象x
a=*pret; // 把返回值對象賦值給對象a
// 這樣原本fun的函數形式就有所變化了。
void fun(A*pret,A x)
{
pret->A(x); // 將返回值拷貝到返回值對象內
return; // 啥也不返回了
}
我們看一下fun的匯編代碼。

參數對象的地址被x記錄了下來,ebp+8記錄的正是函數第一個參數的內容,即返回值對象的地址!在拷貝構造函數調用之前,ecx保存的this指針正是返回值對象的,進棧的參數是x的地址,和我們預期的一樣!
因此,我們可以針對對象的傳值和返回得出如下結論:
1. 對象參數傳遞之前需要進行一次對象拷貝,將原對象的內容完整的拷貝到參數對象內部,函數執行時訪問的是參數對象,而不是原對象。
2. 對象返回時,也需要將函數處理的結果進行一次對象拷貝,不過被拷貝的返回值對象內存已經在函數調用之前已經開辟出來了,函數只需要記錄它的地址即可,然后調用拷貝構造函數初始化它。
3. 函數調用結束后,eax保存了返回值對象的地址,供調用者使用。
通過本文的描述,相信讀者對對象作為函數參數和返回值時,編譯器的內部處理機制有個更清晰的了解。
