深度探索c++對象模型之臨時對象的探討

      如果我們有一個自定義類類型T,裏面有一個int變量x,再在裏面定義了一個operator+【T operator+( const T& _a, const T& _b)】,然後我們聲明瞭3個T對象a、b、c,當我們寫【c = a+b;】時,編譯器會不會爲我們這個表達式產生一個臨時性T對象呢?

      答案是未必。對於現在的大多數編譯器來說,要看我們怎麼寫,如果我們寫成【T c = a+b;】,那麼編譯器並不會產生臨時對象,而是利用NRV(name return valuie)優化內部代碼:

//T a,b;
//......
//T c = a+b;
//以下是可能的優化【對於operator+】

void T::operator+( T& _c, const T& _a, const T& _b)
{
  _c.x = _a.x+_b.x;
  return;
}

     而如果我們寫成【c = a+b; 】的形式,那麼情況就會有些不同了,它很可能會導致下面的結果:

//c++僞碼
T temp;
temp.operator+(a, b);//......

c.operator=(temp);
temp.T::~T();//在這裏析構掉臨時對象
註釋爲省略號的那一行,未構造的臨時對象被賦值給operator+。這意思要麼是“表達式的結果被拷貝構造至臨時對象中”,要麼是“用臨時對象取代NRV”,在後者中,原本要施行於NRV的constructor,現在將施行於該臨時對象。不管是哪一種情況,直接把c傳遞到operator+中是有問題的,因爲operator+希望的是一塊新鮮的內存,而且它並不打算爲它的外加參數調用destructor,所以必須在operator+的調用之前先調用destructor,於是,轉換語意將會把下面的assignment操作:
c = a+b; //c.operator+(a, b);
替換爲它的copy assignment運算符的隱含調用操作,以及一系列的destructor和copy construction:

//c++僞碼
c.T::~T();
c.T::T(a + b); 

      copy constructor、destructor以及copy assignment constructor都可以由用戶提供,所以不能保證上述兩個操作會導致相同的語意,因此用一連串的destructor和copy constructor來替換assignment,一般而言是不安全的,而且會產生臨時性對象,所以對於我們用戶來說,寫成【T c =a+b;】這種形式要比【c = a+b;】更好。

      接下來讓我們來考慮第三種形式,一個單純的【a+b】,像這種形式肯定會產生一個臨時對象,用來存儲表達式運算結果。比如我們定義了類String,然後寫【String s("hello"), t("world!");】,這樣,當我們【printf("%s\n", s+t);】,就會產生一個與s+t相關聯的臨時對象。像這樣的情況就會帶來一個值得探討的話題,那就是“臨時對象的生命週期”。在c++ standard之前,臨時對象的生命週期並沒有明確規定,而是由各家的編譯器廠商自行決定。  換句話說,上面的printf並不保證安全,它的正確性與產生的那個臨時對象何時被摧毀有關。

      在我們的String定義中,有一個conversion定義如下:

String::operator const char*()
{
  ...
  return _str;
}
其中的_str是一個private member addressing storage【私有的字符串地址指針】,在String object構造時配置,在其destructor時被釋放。

      因此,上面的例子中,如果那個產生的臨時對象在進入printf函數之前就被destructor了,那麼經由conversion運算符函數交給printf的參數地址就是不合法的。真正的結果視底部的delete在釋放相關內存時的具體動作行爲而定:也需要某些編譯器在delete時可能會把那個臨時對象的內存標誌爲free,不會改變其中的內存,在這塊內存在其它地方被宣稱主權之前,只要它還沒有被delete掉,它就可以繼續被使用。但這對於軟件工程而言不足作爲模範,事實上,像這樣的某塊內存被釋放之後又被使用並非罕見!所以許多編譯器提供一個特殊的malloc()調用操作:

malloc(0);
而它就是用來保證上述行爲的。

      例如,下面是對於該算式的一個可能的pre-standard轉換,雖然在pre-standard語言定義中是合法的,但卻可能帶來重大災難:

//c++僞碼:pre-standard的合法轉換,但臨時對象被摧毀的太早了!
String temp1 = operator+(s,t);
const char* temp2 = temp1.operator const char*();

//嗯,這句話是合法的!但也是帶來災難的
temp1.~String();

//這時的temp2指向哪裏??
printf("%s\n",temp2);
另一種正確的轉換方式是在printf調用完成之後在進行String的destructor,在c++ standard標準下,這是該表達式的必須轉換方式,標準上這麼說:臨時對象的被摧毀,應該是對完整表達式求值過程中的最後一個步驟。該完整表達式造成臨時對象的產生。

      那麼什麼是一個完整表達式?它是被涵括在表達式中最外圍的那個,比如下面這個表達式:

((objA > 1024) && (objB > 1024)) ? objA+objB : foo(objA, objB);
在上面的表達式中,一共五個子表達式,其中任何一個sub-expressions所產生出來的臨時對象,都應該在完整表達式真個執行完畢後,才應該被摧毀。

      
      仔細看上面那個表達式,由條件測試那裏讓我們來聯想一個更復雜的情況:那就是當臨時對象是根據程序的執行期語意有條件地產生出來時,它的生命週期規則就變得更難以捉摸了。比如【if( s+t || s+v )...】,裏面的“s+v”是否執行是根據前面的“s+t”結果值評估而來的,當“s+t”評估爲假時,纔會爲“s+v”產生一個臨時對象,所以當我們最後要摧毀這個臨時對象時,情況要變得棘手一點了,因爲我們首先得確定這個臨時對象是否真的產生了,只有當臨時對象實實切切產生的時候,我們才應該去摧毀它。

      所以,讓我們用下面這個例子,來講解以前的標準編譯器是如何應對上面的問題的:

class X
{
public:
X(){...};
~X(){...};
operator int(){...};
X foo(){...};

private:
int val;
}
這是我們的類定義,然後在下面的代碼中對兩個X object做條件測試:

int main()
{
X xx;
X yy;

if( xx.foo() || yy.foo() )
...;

return 0;
}
其中main中的代碼,經過cfront的編譯,會轉換成如下的代碼(僞代碼):

int main(void)
{
	struct X _1xx;
	struct X _1yy;
	_ct_1xFv(&_1xx);//這個是X的構造函數
	_ct_1xFv(&_1yy);//同上

	/****上面代碼中的if語句塊,會被轉換成下列形式:***/
	{
		//首先產生兩個臨時對象
		struct X _t1;
		struct X _t2;

		int n;//這個n用來臨時記錄if判斷括號裏面的兩個表達式的值


		/***********************************
		接下來請注意,下面的_opi_1xFv是我們代碼
		中的X::operator int函數,而_foo_1xFv則是
		X::foo函數,_dt_1xFv則是X::~X()
		***********************************/
		if (
			(n = _opi_1xFv((_t1 = _foo_1xFv(&_1xx), &_t1)), _dt_1xFv(&_t1, 2), n)
			||
			(n = _opi_1xFv((_t2 = _foo_1xFv(&_1yy), &_t2)), _dt_1xFv(&_t2, 2), n)
			)
		{
			...
		}
	}
	return 0;
}
      把臨時對象的destructor放在每一個子算式的求值過程中,可以免除“努力追蹤第二個子算式是否真的需要被評估”。然而在c++ standard的臨時對象生命規則中,這樣的策略不再被允許:現在的臨時對象在完整表達式尚未執行完畢之前,不能被摧毀!所以某些形式的條件測試必須被插入進來,用來決定是否摧毀我們那個和第二個子算式有關的臨時對象。

      臨時對象的生命規則有兩個例外,第一個例外發生在表達式被用來初始化一個object時,例如:

bool verbose;
...
String progNameVersion = !verbose ? 0 : progName+progVersion;
其中的progName和progVersion都是我們自定義類類型String的對象,這時候會生成一個臨時對象,放置progName和progVersion的相加結果:

String operator+(const String&, const String&);

臨時對象必須根據verbose的測試結果有條件的析構,在臨時對象的生命規則下,它應該在【 ?: 】完整表達式結束運行之後儘快被摧毀。然而,如果progNameVersion的拷貝賦值需要調用一個copy constructor:

//c++僞碼
progNameVersion.String::String(temp);
那麼就算我們在【 ?: 】表達式完整求值過後,也不應該對臨時對象temp解構,這顯然不是我們希望的。c++ standard的要求是【。。。。。。但凡含有表達式執行結果的臨時性對象,應該留到object對象的初始化操作完整爲止。】

      c++中的臨時對象的生命規則坑實在是太隱蔽,即使我們再努力謹慎,還是難以提防。比如有時候當我們的行爲有着明確的定義,就像下面這個初始化操作:

//這是一個註定失敗的初始化操作!
const char *progNameVersion = progName+progVersion;
上面的progName和progVersion都是String類型,它們經過編譯器的處理會變成這樣:

String temp = new String;
temp.String::operator+(temp, progName, progVersion);
progNameVersion = temp.String::const char*();
temp.String::~String(); //在這裏編譯器析構了我們的臨時對象,但其實我們的progNameVersion和temp是處於同一塊地皮,但此時我們的progNameVersion已經指向未定義的heap內存!

      再比如下面這個例外:

//當一個臨時對象被一個reference綁定時。。。
const String &space = "aaa";

/*上面的代碼經過編譯器處理,變成這種形式*/
//c++僞碼
String temp;
temp.String::String("aaa");
const String &space = temp;
temp.String::~String(); //......
很明顯,如果臨時對象temp被摧毀,那我們那個space還能靠得住嗎?

      最後讓我們來看一下c++ standard的要求:【如果一個臨時對象被綁定在一個reference上,對象將殘留,要麼殘留到相關的那個reference引用聲明週期結束,要麼殘留到臨時對象的生命範疇(scope)結束,看哪一種情況先到達而定!】











發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章