神奇的 C++ 傳值——從期末 C++ 考試題說起
(這篇文章主要涉及以下概念:指針、引用、內存地址、棧、堆、彙編、反彙編、寄存器、堆棧空間、賦值構造函數。)
不得不說我們學院的剛哥的 C++ 考試題就是不一樣…考完試之後,有一道題被很多同學議論頗多,這就是寫運行結果的第一題。這道題的題目大約如下。
using namespace std;
class A{
int i;
public:
A(int x=0){
i=x;
cout << "In Constructor" << endl;
}
A(const A& a){
i=a.i;
cout << "In Copy Constructor" << endl;
}
~A(){
cout << "In Destructor" << endl;
}
};
A func(A a){
return a;
}
int main(){
A a;
A b = 1;
A c = func(a);
return 0;
}
這道題目的正確答案是,兩個 In
Constructor,兩個 In Copy
Constructor,四個 In
Destructor。這本身並沒有什麼疑問。
但是,如果我把這個程序給稍微給改一下,就會產生疑惑。
將 main 函數的內容改為如下:
int main(){
A a;
A b;
b = func(a);
return 0;
}
並且在 return 0; 設置斷點,運行,我們會發現輸出結果如下。
In Constructor
In Constructor
In Copy Constructor
In Copy Constructor
In Destructor
In Destructor
我們分析一下:
- A a; 時產生了一次構造函數調用,A b; 時產生第二次。
- func(a) 複製實參時,調用複製構造函數,在func函數返回值的時候,由於返回的是類的對象本身,產生了一次複製構造函數調用。
- b 隨後被賦值為 func(a) 的返回值,所以實參析構,析構函數被調用。
問題是,後面的另一個析構函數調用是如何產生的?
經過分析我們可以發現,func(a) 產生了一個臨時的對象 tmp,隨後 tmp 被賦值給 b,隨後 tmp 被析構。
隨後我們把 main 函數改為如下:
int main(){
A a;
A b = func(a);
return 0;
}
這段代碼和上面代碼的區別是,b 從先定義後賦值改成了直接定義和初始化。
這時候我們會發現,tmp 的析構不存在了,func 返回時產生的 a 的副本是直接產生在 b 的地址上的。也就是說,不存在臨時的對象。
這時我們會很好奇。func 的返回值究竟是如何傳遞的?A b = func(a); 這樣的語句是如何能夠讓 func(a) 的返回值直接寫入到了 b 的地址空間呢?畢竟,返回值所創建的那個對象副本是在 func 作用域和棧空間裡的,而 b 是 main 函數的局部變量,保存在 main 的堆棧空間。
於是我猜測,對 func 的調用壓入了一個隱含參數,這個參數的內容是指向返回值的地址的,func 的返回值就保存在給定的地址中。這樣,在函數內部就可以訪問到這個空間並保存函數的返回值。在 b = func(a); 語句中,b 已經初始化,這時要使用賦值構造函數重新構造 b,所以需要給賦值構造函數傳遞一個參數,就必須存在一個臨時變量 tmp 的地址,於是隱含參數所包含的地址就是編譯器創建的臨時對象的地址。
從此,可以分析出,func 的返回值是保存在一塊新的內存區域的;如果用戶將這個返回值直接用作初始化,那麼這塊區域就是被初始化函數的內存區域;如果不是,那麼編譯器會在調用方所在函數的棧空間裡創建一個臨時的內存區域,用於保存返回值,由於臨時對象的唯一作用是作為賦值構造函數的參數傳遞給operator=函數從而將 b 重寫為 a 的副本,所以這必將導致一個結果,就是當 b 被賦值完畢之後,這個無名的臨時對象就無用了。
那麼,編譯器又如何處理了這個無名的臨時對象呢?正常人的想法是,這個變量應該和其他的同屬於這個棧的變量們一起,在函數作用域結束之前被析構。然而上述的運行結果告訴我們,這個臨時變量 tmp 是在之前就已經析構了的。從以上內容可以得到的結論是,編譯器對於函數調用,應該是在調用之前分配返回值的內存空間,並在調用後將這個空間立即處理掉(直接分配到局部變量的地址上的情況除外)。
事實真的如此嗎?我寫了下面這樣一個程序進行驗證。
using namespace std;
int time = 0;
class TestClass
{
public:
TestClass()
{
thistime = rand() % 1000;
ortime = -1;
cout << "[" << thistime << "] : created " << endl;
}
TestClass(TestClass &a)
{
thistime = rand() % 1000;
ortime = a.thistime;
cout << "[" << thistime << "] : created (from " << ortime << ")" << endl;
}
TestClass& operator= (const TestClass& a)
{
this->thistime = rand() % 1000;
this->ortime = a.thistime;
cout << "[" << thistime << "] : assigned (from " << a.thistime << ")" << endl;
return \*this;
}
~TestClass()
{
cout << "[" << thistime << "] : destroyed " << endl;
}
int thistime;
int ortime;
};
TestClass func(TestClass test)
{
cout << "[" << test.thistime << "] : func called " << endl;
return test;
}
int main()
{
TestClass a;
TestClass b;
b = func(a);
cout << b.thistime << endl;
TestClass c = func(b);
cin.get();
return 0;
}
這個程序的運行結果如下:
[41] : created
[467] : created
[334] : created (from 41)
[334] : func called
[500] : created (from 334)
[334] : destroyed
[169] : assigned (from 500)
[500] : destroyed
169 [724] : created (from 169)
[724] : func called
[478] : created (from 724)
[724] : destroyed
運行結果分析如下:
- 在本程序剛啟動的時候,創建了一個 41 號的類(這個是變量 a)。
- 又創建了一個 467 號類(變量 b)。
- 到了 b = func(a),首先 a 被複制構造函數創建了一個副本到 func 的實參裡,這個是 from 41 的 334 號類產生的原因。這時,程序為 func 的返回值也分配好了一個臨時的內存空間,位於 main 函數的堆棧空間(frame)裡,並通過隱式參數傳遞到 func。
- 然後開始執行 func 函數體。輸出「[334] : func called」,這個函數產生了一個返回值 a。由於是類的對象,所以創建了一個副本,也就是調用複製構造函數,創建了 500 號類(from 334),並保存到通過隱式參數傳遞過來的臨時的內存空間地址中。func 調用結束,334 這個實參沒用了,所以 destroyed 了。
- 臨時空間中的500 號類被傳遞到 operator = (賦值構造函數)中,並用其重新構造了 b,此時 原來的 467 號類 b 變成了 169 號類。但其實還是原來的那個變量,地址不變,只是內容變了。
- 臨時空間用完了,隨即被編譯器生成的代碼釋放,此時調用了 500 號的析構函數(也解釋了最開始的問題)。
- 繼續執行 func(b) ,同理,b (169號)先被複製成 func 的實參 724號類,然後 724 複製了一個副本,產生返回值,這是 478 號。由於變量 c 是直接初始化而不是賦值的, 所以區別就來了,c的地址直接被傳遞給了 func,所以 func 的返回值直接存放到了 c 中,不存在任何複製構造或賦值構造。
- 724 號類隨著 func 的運行結束被銷燬,析構函數被調用。
- 控制權交給 cin.get(); 語句。
為了進一步驗證我們的猜想是否正確,我們現在對這個程序進行反彙編。核心的彙編代碼列舉如下。先來看看賦值構造函數情況下。(其中,ebp 是棧的基地址,eax 是 C/C++ 語言中存放返回值或返回值地址的寄存器的默認約定。)
// 語句 b = func(a);
010C1808 sub esp,8
010C180B mov ecx,esp
010C180D mov dword ptr [ebp-12Ch],esp
010C1813 lea eax,[ebp-18h]
010C1816 push eax // 以上代碼分配返回值的內存空間,位於 main 函數領空的堆棧空間,並壓入棧內作為隱性參數
010C1817 call TestClass::TestClass (10C120Dh) // 調用複製構造函數獲得實參
010C181C mov dword ptr [ebp-134h],eax
010C1822 lea ecx,[ebp-120h]
010C1828 push ecx // 實參壓棧
010C1829 call func (10C104Bh) // 執行 func 函數
010C182E add esp,0Ch
010C1831 mov dword ptr [ebp-138h],eax
010C1837 mov edx,dword ptr [ebp-138h]
010C183D mov dword ptr [ebp-13Ch],edx
010C1843 mov byte ptr [ebp-4],2
010C1847 mov eax,dword ptr [ebp-13Ch]
010C184D push eax // 將臨時內存空間內的類對象的地址傳給 operator= 構造變量 b
010C184E lea ecx,[ebp-28h]
010C1851 call TestClass::operator= (10C11C7h)
010C1856 mov byte ptr [ebp-4],1
010C185A lea ecx,[ebp-120h]
010C1860 call TestClass::~TestClass (10C112Ch) // 調用析構函數,析構臨時內存空間內的類對象
下面是直接初始化的情況。
// 語句 TestClass c = func(b);
010C1895 sub esp,8
010C1898 mov ecx,esp
010C189A mov dword ptr [ebp-110h],esp
010C18A0 lea eax,[ebp-28h]
010C18A3 push eax // 變量 c 的地址
010C18A4 call TestClass::TestClass (10C120Dh) // 複製實參
010C18A9 mov dword ptr [ebp-134h],eax
010C18AF lea ecx,[ebp-38h]
010C18B2 push ecx // 實參入棧
010C18B3 call func (10C104Bh)
010C18B8 add esp,0Ch
010C18BB mov dword ptr [ebp-138h],eax
010C18C1 mov byte ptr [ebp-4],3
明顯比前面的代碼少,返回值被直接寫入了調用方指定的棧中,也就是證明了我們之前的想法。
總結如下:調用函數時,不管參數類型如何,調用者(caller)負責從右至左將參數依次壓棧,最後壓入返回地址並跳轉到被調函數入口處執行。被傳遞的參數和返回地址都是位於調用者的stack frame(堆棧空間)中的。
如果函數的返回值類型是整型(包括char,short,int,long及它們的無符號型)或指針類型的話(它們的長度小於等於 EAX 寄存器的長度(32位機上是 4 個字節)),那麼就利用EAX寄存器來傳回返回值。否則,利用 EAX 寄存器存放返回值的地址。傳入的返回地址可能是一個臨時的地址,這個地址將會在其他變量賦值後被立即回收,也可能是一個局部變量的地址,將在作用域超過時正常回收。
© 轉載需附帶本文連結,依 CC BY-NC-SA 4.0 釋出。