C++對象模型之編譯器如何處理函數返回一個對象
1、與經驗不符的輸出我們知道,當發生以下三種情況之一時,對象對應的類的復制構造函數將會被調用:1)對一個對象做顯示的初始化操作時
2)當對象被當作參數傳遞給某個函數時3)當函數返回一個類的對象時
所以,當我們設計一個函數(普通或成員函數)時,經驗告訴我們,出於效率的考慮,應該盡可能返回一個對象的指針或引用,而不是直接返回一個對象。因為在直接返回一個對象可能會引起對象的復制構造過程,這意味著會發生一定量的內存復制和對象創建的動作,從而降低了程序的效率。這個設計的想法是正確的,但是實際上,當函數返回一個對象時,上述的復制構造過程一定會發生嗎?
例如,對於如下的代碼:
class X
{
public:
X()
{
mData = 100;
cout << "X::X()" << endl;
}
X(const X& rhs)
{
mData = rhs.mData;
cout << "X::X(const X&rhs)" << endl;
}
void setData(int n)
{
mData = n;
}
void print()
{
cout << "X::mData == " << mData << endl;
}
private:
int mData;
};
X func()
{
X xx;
xx.setData(101);
return xx;
}
int main()
{
X xx = func();
return 0;
}
看了上面的代碼片斷,你認為這個程序應該輸出什麼呢?若按照書本上的說法進行分析,在func函數中,定義了類X的一個局部對象xx,所以類X的構造函數會被調用;在func函數返回的返回值是一個對象,那麼該函數將返回對象xx的一個副本,所以類X的復制構造函數會被調用;在main函數中,同樣定義了類X的一個局部對象xx,而該對象是通過函數func返回的對象作為初值進行構造的,所以類X的復制因構造該副本而被調用。也就是說,根據這個分析,輸入結果應該是:X::X()
X::X(const X&rhs)
X::X(const X&rhs)
原本我也認為輸出的應該是上面的三行,但是實際的運行結果如下圖所示,它完全出乎我的意料:
從運行結果來看,只輸出了一行“X::X()”,也就是說它只構造出了一個類X對象,而且沒有發生任何的復制構造過程。我們的分析依據都是正確的,程序代碼非常簡單,分析的流程也正確,但是程序的行為究竟為什麼與我們的分析不符呢?其實一切都是編譯器出於優化的考慮,暗中修改了我們程序的代碼。下面先介紹編譯器處理返回對象的一種方法,再介紹編譯器的優化究竟對我們的代碼動了什麼手腳。
2、編譯器處理返回對象的一種方法當函數調用完畢後,會銷毀其局部對象,若函數返回一個局部對象,編譯器如何把這個局部對象復制出來呢?方法如下:1)首先為函數加上一個額外的參數,類型是類對象的引用。這個參數將用來存放被“復制建構”而得的返回值2)在return指令之前安插一個復制構造調用操作,以便將欲傳回的對象的內容當做上述新參數的初值。
該方法的兩個操作會重新改寫函數,使它不用返回任何值。根據這個方法,func函數的操作可能轉換為如下的偽代碼:
// C++偽代碼,模擬構造函數和復制構造函數的調用
void func(X &__result)
{
X xx;
xx.X::X(); // 調用類X的默認構造函數
xx.setData(101);
__result.X::X(xx); // 調用類X的復制構造函數
return;
}
現在編譯器必須轉換每一個func()調用操作,以符合其新的定義,即X xx = func();
會被轉換成為下列語句:X xx; // 並不調用類X的構造函數func(xx);
所以,main函數會被轉換成如下偽代碼
// C++偽代碼,模擬構造函數和復制構造函數的調用
int main()
{
X xx; // 並不調用類X的構造函數
func(xx);
return 0;
}
根據上述編譯器的操作,可以得到如下的輸出:X::X()
X::X(const X&rhs)第一行為func函數中局部對象xx的構造,第二行為為了達到返回的目的而發生的復制構造操作。這個結果雖然與第1節中的分析有所不同,從編譯使用這個方法卻能減少一次復制構造函數的調用,提高了效率,畢竟對同一個對象復制兩次也沒有什麼好處。而且編譯對這個程序還會做進一步的優化,在第3節會詳細講述。
考慮函數func的另一種使用情況,就是直接使用函數func的返回值,而不將其賦給一個變量。把main函數修改成如下所示:
int main()
{
func().print();
return 0;
}
當遇到上述情況時,為使代碼正確運行,編譯器可能會進行如下轉換:
// C++偽代碼,模擬編譯器的相關處理操作
int main()
{
{
X __temp;
func(__temp);
__temp.print();
}
}
在《深度探索C++對象模型》一書中的例子,對於這種情況,轉換後的代碼沒有放在一個花括號內,但是我個人認為這樣做更合理。因為根據C++的定義,func函數返回的是一個臨時對象,所以當語句func().print();
運行結束後,該臨時變量應該被銷毀。把轉換後的語句放在一對花括號中,當運行完轉換後的語句後,__temp臨時變量就會因出了作用域而被銷毀。但是編譯器是否這樣做,我就不知道了。
同理,如果程序中定義了一個函數指針變量,並指向了該函數,則編譯器還需要改寫該函數指針的定義。
3、NRV優化在第2節中已經分析了編譯器如何處理返回一個對象的函數,但是其結果與我們程序的輸出還是不一樣,這是因為編譯器對程序做了進一步的優化,方法就是以增加的類對象引用的參數(result參數)取代返回值的名字(named return value)。
使用此策略,func函數轉換成如下的偽代碼:
// C++偽代碼,模擬構造函數和復制構造函數的優化
void func(X &__result)
{
__result.X::X(); // 調用類X的默認構造函數
__result.setData(101);
return;
}
main函數轉換後的偽代碼不變,如下:
int main()
{
X xx; // 並不調用類X的構造函數
func(xx);
return 0;
}
通過這個優化,可以看在在main函數的調用過程中,只會調用一次構造函數,且不會調用復制構造函數。這樣的編譯優化操作,被稱為Named Return Value(NRV)優化。NRV優化如今被視為標准C++編譯器的一個義不容辭的優化操作。所以第1節中產生的出人意料的輸出,正是NRV優化的結果。
雖然NRV優化提供了重要的效率改善,但是優化是由編譯器默默完成的,而它是否真的被完成,並不十分清楚,而且一旦函數變得比較復雜,優化也難以施行。