More Effective C++ 12:理解“拋出一個異常”與“傳遞一個參數”或“調用一個虛函數”間的差異

從語法上看,在函數裏聲明參數與在 catch 子句中聲明參數幾乎沒有什麼差別:

class Widget { ... }; //一個類

void f1(Widget w); // 一些函數,其參數分別爲 
void f2(Widget& w); // Widget, Widget&,或 
void f3(const Widget& w); // Widget* 類型 
void f4(Widget *pw); 
void f5(const Widget *pw); 
catch (Widget w) ... //一些 catch 子句,用來 
catch (Widget& w) ... //捕獲異常,異常的類型爲 
catch (const Widget& w) ... // Widget, Widget&, 或 
catch (Widget *pw) ... // Widget* 
catch (const Widget *pw)

你因此可能會認爲用 throw拋出一個異常到 catch子句中與通過函數調用傳遞一個參數兩者基本相同。這裏面確有一些相同點,但是他們也存在着巨大的差異。

相同點

傳遞函數參數與異常的途徑可以是傳值、傳遞引用或傳遞指針。

不同點

但是當你傳遞參數和異常時,系統所要完成的操作過程則是完全不同的


產生這個差異的原因是:你調用函數時,程序的控制權最終還會返回到函數的調用處,但是當你拋出一個異常時,控制權永遠不會回到拋出異常的地方


考慮下面代碼:

istream operator>>(istream& s, Widget& w); 
void passAndThrowWidget() 
{ 
	 Widget localWidget; 
	 cin >> localWidget; //傳遞 localWidget 到 operator>> 
	 throw localWidget; // 拋出 localWidget 異常 
}

當傳遞 localWidget 到函數 operator>>裏,不用進行拷貝操作,而是把 operator>>內的引用類型變量 w 指向localWidget,任何對 w 的操作實際上都施加到localWidget 上。這與拋出 localWidget 異常有很大不同

不論通過傳值捕獲異常還是通過引用捕獲都將進行 lcalWidget 的拷貝操作,也就說傳遞到catch子句中的是 localWidget 的拷貝。

原因:因爲當 localWidget 離開了生存空間後,其析構函數將被調用。如果把 localWidget 本身(而不是它的拷貝)傳遞給 catch 子句,這個子句接收到的只是一個被析構了的Widget,一個 Widget 的“屍體”。這是無法使用的。因此C++規範要求被做爲異常拋出的對象必須被複制。


即使被拋出的對象不會被釋放,也會進行拷貝操作。例如如果 passAndThrowWidget 函數聲明 localWidget 爲靜態變量(static):

void passAndThrowWidget() 
{ 
	static Widget localWidget; // 現在是靜態變量(static); 
	 //一直存在至程序結束 
	cin >> localWidget; // 像以前那樣運行 
	throw localWidget; // 仍將對 localWidget進行拷貝操作
}

對異常對象進行強制複製拷貝,這個限制有助於我們理解參數傳遞與拋出異常的第二個差異:拋出異常運行速度比參數傳遞要慢。


當異常對象被拷貝時,拷貝操作是由對象的拷貝構造函數完成的。該拷貝構造函數是對象的靜態類型(static type)所對應類的拷貝構造函數,而不是對象的動態類型(dynamictype)對應類的拷貝構造函數。比如以下這經過少許修改的 passAndThrowWidget

class Widget { ... }; 
class SpecialWidget: public Widget { ... }; 
void passAndThrowWidget() 
{ 
	SpecialWidget localSpecialWidget; 
	... 
	Widget& rw = localSpecialWidget; // rw 引用 SpecialWidget  throw rw; //它拋出一個類型爲 Widget 的異常 
}

這裏拋出的異常對象是 Widget,即使 rw 引用的是一個 SpecialWidget。因爲 rw 的靜態類型(static type)是Widget,而不是 SpecialWidget。你的編譯器根本沒有注意到 rw 引用的是一個SpecialWidget編譯器所注意的是 rw 的靜態類型(static type)。這種行爲可能與你所期待的不一樣,但是這與在其他情況下 C++中拷貝構造函數的行爲是一致的



異常是其它對象的拷貝,這個事實影響到你如何在 catch 塊中再拋出一個異常。比如下面這兩個 catch 塊,乍一看好像一樣:

catch (Widget& w) // 捕獲 Widget 異常 
{ 
	 ... // 處理異常 
	 throw; // 重新拋出異常,讓它繼續傳遞 
} 
catch (Widget& w) // 捕獲 Widget 異常 
{ 
	 ... // 處理異常 
	 throw w; // 傳遞被捕獲異常的拷貝
}  

這兩個catch塊的差別在於第一個 catch 塊中重新拋出的是當前捕獲的異常,而第二個catch 塊中重新拋出的是當前捕獲異常的一個新的拷貝。如果忽略生成額外拷貝的系統開銷,這兩種方法還有差異麼?

差異
第一個塊中重新拋出的是當前異常,無論它是什麼類型。特別是如果這個異常開始就是做爲 SpecialWidget 類型拋出的,那麼第一個塊中傳遞出去的還是 SpecialWidget 異常,即使 w 的靜態類型(static type)是 Widget。這是因爲重新拋出異常時沒有進行拷貝操作
第二個 catch 塊重新拋出的是新異常,類型總是 Widget,因爲 w 的靜態類型(static type)是 Widget

一般來說,你應該用 throw 來重新拋出當前的異常,因爲這樣不會改變被傳遞出去的異常類型,而且更有效率,因爲不用生成一個新拷貝

catch (Widget w) ... // 通過傳值捕獲異常 
catch (Widget& w) ... // 通過傳遞引用捕獲  異常  
catch (const Widget& w) ... //通過傳遞指向 const 的引用捕獲異常

可以注意到傳遞參數與傳遞異常的另一個差異:一個被異常拋出的對象可以通過普通的引用捕獲;它不需要通過指向 const 對象的引用捕獲。在函數調用中不允許轉遞一個臨時對象到一個非 const 引用類型的參數裏,但是在異常中卻被允許。

當我們這樣聲明一個 catch 子句時:

catch (Widget w) ... // 通過傳值捕獲

會建立兩個被拋出對象的拷貝,一個是所有異常都必須建立的臨時對象,第二個是把臨時對象拷貝進 w 中。
而下面這樣聲明:

catch (Widget& w) ... // 通過引用捕獲 
catch (const Widget& w) ... //也通過引用捕獲

這仍舊會建立一個被拋出對象的拷貝:拷貝同樣是一個臨時對象。相反當我們通過引用傳遞函數參數時,沒有進行對象拷貝。當拋出一個異常時,系統構造的(以後會析構掉)被拋出對象的拷貝數比以相同對象做爲參數傳遞給函數時構造的拷貝數要多一個。

這只是參數傳遞與異常傳遞的區別的一個方面;第二個差異是在函數調用者或拋出異常者與被調用者或異常捕獲者之間的類型匹配的過程不同

比如在標準數學庫中的sqrt 函數:

double sqrt(double); // from <cmath> or <math.h>

我們能這樣計算一個整數的平方根,如下所示:

int i;
double sqrtOfi = sqrt(i);
C++允許進行從 intdouble 的隱式類型轉換,所以在 sqrt 的調用中,i 被悄悄地轉變爲 double 類型,並且其返回值也是 double

一般來說,catch 子句匹配異常類型時不會進行這樣的轉換

void f(int value) 
{ 
	try 
	{ 
		if (someFunction()) // 如果 someFunction()返回真 
		{ 
			throw value; //拋出一個整形值 
			...} 
	catch (double d) // 只處理 double 類型的異常 
	{ 
		... 
	} 
	 ...

在 try 塊中拋出的 int 異常不會被處理 double 異常的 catch 子句捕獲。該子句只能捕獲類型真真正正爲 double 的異常,不進行類型轉換。因此如果要想捕獲 int 異常,必須使用帶有 intint&參數的 catch 子句


不過在 catch 子句中進行異常匹配時可以進行兩種類型轉換:
第一種是繼承類與基類間的轉換。一個用來捕獲基類的 catch 子句也可以處理派生類類型的異常,比如:捕獲 runtime_errors 異常的 Catch 子句可以捕獲 range_error 類型和 overflow_error類型的異常;可以接收根類 exception 異常的 catch 子句能捕獲其任意派生類異常

第二種是允許從一個類型化指針轉變成無類型指針),所以帶有 const void* 指針的 catch 子句能捕獲任何類型的指針類型異常:

catch (const void*) ... //捕獲任何指針類型異常

傳遞參數和傳遞異常間最後一點差別是 catch 子句匹配順序總是取決於它們在程序中出現的順序。
因此一個派生類異常可能被處理其基類異常的 catch 子句捕獲,即使同時存在有能直接處理該派生類異常的 catch 子句,與相同的 try 塊相對應。例如:

try 
{ 
	 ... 
} 
catch (logic_error& ex) // 這個 catch 塊 將捕獲所有的 
//logic_error異常, 包括它的派生類 
{ 
	...
} 
 
catch (invalid_argument& ex) { // 這個塊永遠不會被執行 
 ... //因爲所有的 
} // invalid_argument 
 // 異常 都被上面的 
 // catch 子句捕獲。

與上面這種行爲相反,當你調用一個虛擬函數時,被調用的函數位於與發出函數調用的對象的動態類型最相近的類裏。你可以這樣說虛擬函數採用最優適合法,而異常處理採用的是最先適合法
如果一個處理派生類異常的 catch 子句位於處理基類異常
catch 子句後面,編譯器會發出警告.
像上面那個例子,應該這樣寫:

try 
{ 
	 ... 
} 
catch (invalid_argument& ex) 
{ // 處理 invalid_argument 
	 ... //異常 
} 
catch (logic_error& ex) 
{ // 處理所有其它的 
	 ... // logic_errors 異常 
}

總結

把一個對象傳遞給函數或一個對象調用虛擬函數與把一個對象做爲異常拋出,這之間有三個主要區別:
1.異常對象在傳遞時總被進行拷貝;當通過傳值方式捕獲時,異常對象被拷貝了兩次。對象做爲參數傳遞給函數時不一定需要被拷貝

2.對象做爲異常被拋出與做爲參數傳遞給函數相比,前者類型轉換比後者要少(前者只有兩種轉換形式)

3.catch 子句進行異常類型匹配的順序是它們在源代碼中出現的順序,第一個類型匹配成功的 catch 將被用來執行。當一個對象調用一個虛擬函數時,被選擇的函數位於與對象類型匹配最佳的類裏,即使該類不是在源代碼的最前頭。

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