Item 14: 如果函數不會拋出異常就把它們聲明為noexcept


本文翻譯自modern effective C++,由於水平有限,故無法保證翻譯完全正確,歡迎指出錯誤。謝謝!

博客已經遷移到這里啦

在C++98中,異常規范(exception specifications)是一個不穩定因素。你必須總結出一個函數可能會拋出的異常類型,所以如果函數的實現被修改了,異常規范可能也需要被修正。改變異常規范則又可能影響到客戶代碼,因為調用者可能依賴於原先的異常規范。編譯器通常不會提供幫助來維護“函數實現,異常規范以及客戶代碼”之間的一致性。最終,大多數程序員覺得C++98的異常規范不值得去使用。

C++11中,對於函數的異常拋出行為來說,出現了一種真正有意義的信息,它能說明函數是否有可能拋出異常。是或不是,一個函數可能拋出一個異常或它保證它不會拋出異常。這種“可能或絕不”二分的情況是C++11異常規范的基礎,這種異常規范從本質上替換了C++98的異常規范。(C++98風格的異常規范仍然是有效的,但是它們是被棄用了的。)在C++11中,無條件的noexcept就說明這個函數保證不會拋出異常。

在設計接口的時候,一個函數是不是應該這么聲明(noexcept)是一個需要考慮的問題。函數的異常拋出行為是客戶最感興趣的部分。調用者能詢問一個函數的noexcept狀態,並且這個詢問的結果能影響異常安全(exception safety)或着調用代碼的性能。因此,一個函數是否是noexcept和一個成員函數是否是cosnt,這兩個信息使同樣重要。當你知道一個函數不會拋出異常的時候卻不聲明它為noexcept,就屬於一個不好的接口設計。

但是,這里還有一個額外的動機讓我們把noexcept應用到不會產生異常的函數上:它允許編譯器產生更好的目標代碼。為了理解為什么會這樣,讓我們檢查一下C++98和C++11中,對於一個函數不會拋出異常的不同解釋。考慮一個函數f,它保證調用者永遠不會收到異常。兩種不同的表示方法:

int f(int x) throw();			//C++98風格

int f(int x) noexcept;			//C++11風格

如果,運行時期,一個異常逃離了f,這違反了f的異常規范。在C++98的異常規范下,f的調用者的調用棧被解開了,然后經過一些不相關的動作,程序終止執行。在C++11的異常規范下,運行期行為稍微有些不同:調用棧只有在程序終止前才有可能被解開。

解開調用棧的時機,以及解開的可能性的不同,對於代碼的產生有很大的影響。在一個noexcept函數中,如果一個異常能傳到函數外面去,優化器不需要保持運行期棧為解開的狀態,也不需要確保noexcept函數中的對象銷毀的順序和構造的順序相反(譯注:因為noexcept已經假設了不會拋出異常,所以就算異常被拋出,大不了就是程序終止,而不可能處理異常)。使用“throw()”異常規范的函數,以及沒有異常規范的函數,沒有這樣的優化靈活性。三種情況能這樣總結:

RetType function(params) noexcept;			//優化最好

RetType function(params) throw();			//沒有優化

RetType function(params);					//沒有優化

這種情況就能作為一個充足的理由,讓你在知道函數不會拋出異常的時候,把它聲明為noexcept。

對於一些函數,情況變得更加強烈(更多的優化)。move操作就是一個很好的例子。假設你有一份C++98代碼,它使用了std::vector 。Widget通過一次次push_back來加到std::vector中:

std::vector<Widget> vw;

...

Widget w;

...						//使用w

vw.push_back(w);		//把w加到vw中

...

假設這個代碼工作得很好,然后你也沒有興趣把它改成C++11的版本。但是,基於C++11的move語法能提升原來代碼的性能(當涉及move-enabled類型時)的事實,你想做一些優化,因此你要保證Widget有一個move operation,你要么自己寫一個,要么用函數生成器來實現(看Item 17)。

當一個新的元素被添加到std::vector時,可能std::vector剩下的空間不足了,也就是std::vector的size等於它的capacity(容量)。當發生這種事時,std::vector申請一個新的,更大的內存塊來保存它的元素,然后把原來的內存塊中的元素,轉移到新塊中去。在C++98中,轉移是通過拷貝來完成的,它先把舊內存塊中的所有元素拷貝到新內存塊中,再銷毀舊內存塊中的對象(譯注:再delete舊內存)。這種方法確保push_back能提供強異常安全的保證:如果一個異常在拷貝元素的時候被拋出,std::vector的狀態沒有改變,因為在所有的元素都成功地被拷貝到新內存塊前,舊內存塊中的元素都不會被銷毀。

在C++11中,會進行一個很自然的優化:用move來替換std::vector元素的拷貝。不幸的是,這樣做會違反push_back的強異常安全保證。如果n個元素已經從舊內存塊中move出去了,在move第n+1個元素時,有一個異常拋出,push_back操作不能執行完。但是原來的std::vector已經被修改了:n個元素已經被move出去了。想要恢復到原來的狀態是不太可能的,因為嘗試”把新內存塊中的元素move回舊內存塊中“的操作也可能產生異常。

這是一個嚴重的問題,因為一些歷史遺留代碼的行為可能依賴於push_back的強異常安全的保證。因此,除非知道它不會拋出異常,否則C++11中的push_back的實現不能默默地用move操作替換拷貝操作。在這種情況(不會拋出異常)下,用move替換拷貝操作是安全的,並且唯一的效果就是能提升代碼的性能。

std::vector::push_back采取”如果可以就move,不能就copy“的策略,並且在標准庫中,不只是這個函數這么做。在C++98中,其他提供強異常安全的函數(比如,std::vector::reserve,std::deque::insert等等)也采取這樣的策略。如果知道move操作不會產生異常,所有這些函數都在C++11中使用move操作來替換原先C++98中的拷貝操作。但是一個函數怎么才能知道move操作會不會產生異常呢?回答很明顯:它會檢查這個操作是否被聲明為noexcept。

swap函數特別需要noexcept,swap是實現很多STL算法的關鍵部分,並且它也常常被拷貝賦值操作調用。它的廣泛使用使得noexcept提供的優化特別有價值。有趣的是,標准庫的swap是否是noexcept常常取決於用戶自定義的swap是否是noexcept。舉個例子,標准庫中,array和std::pair的swap這么聲明:

template<class T, size_t N>
void swap(T (&a)[N],
		  T (&a)[N])	noexcept(noexcept(swap(*a, *b)));

template<class T1, class T2>
sturct pair{
	...
	void swap(pair& p) noexcept(noexcept(swap(first, p.first)) && 
								noexcept(swap(second, p.second)));
	...
};

這些函數是條件noexcept(conditionally noexcept):它們是否是noexcept取決於noexcept中的表達式是否是noexcept。舉個例子,給出兩個Widget的數組,只有用數組中的元素來調用的swap是noexcept時(也就是用Widget來調用的swap是noexcept時),用數組調用的swap才是noexcept。反過來,這也決定了Widget的二維數組是否是noexcept。相似地,std::pair<Widget, Widget>對象的swap成員函數是否是noexcept取決於用Widget調用的swap是否是noexcept。事實上,只有低層次數據結構的swap調用是noexcept,才能使得高層次數據結構的swap調用是noexcept。這鼓勵你盡量提供noexcept swap函數。

現在我希望你已經對noexcept提供的優化機會感到興奮了。哎,可是我必須澆滅你的熱情。優化很重要,但是正確性更重要。我記得在這個Item的開始說過,noexcept是函數接口的一部分,所以只有當你願意長期致力於noexcept的實現時,你才應該聲明函數為noexcept。如果你聲明一個函數為noexcept,並且之后對於這個決定后悔了,你的選擇是將是絕望的。1:你能把noexcept從函數聲明中移除(也就是改變函數接口),則客戶代碼會遭受運行期風險。2:你也能改變函數的實現,讓異常能夠逃離函數,但是保持着原來的異常規范(現在,原來的規范聲明是錯誤的)。如果你這么做,當一個異常嘗試逃離函數時,你的程序將會終止。3:或者你可以拋棄一開始想要改變實現的想法,回歸到你現存的實現中去。這些選擇沒有一個是好的選擇。

事實上,很多函數都是異常中立的(exception-neutral)。這些函數自己不拋出異常,但是他們調用的函數可能拋出異常。當發生這樣的事時,異常中立的函數允許異常通過調用鏈傳給處理程序。異常中立的函數永遠不是noexcept,因為他們可能拋出“我只經過一下”(異常產生的地方在別的函數中,但是需要經過我們來傳遞出去)的異常。因此,很大部分函數都不適合設計為noexcept。

然而,一些函數天生就不拋出異常,並且對於一些函數(特別是move操作和swap函數)成為noexcept能有很大的回報,只要有任何可能,它們都值得實現為noexcept。當你能很明確地說一個函數永遠不應該拋出異常的時候,你應該明確地把這個函數聲明為noexcept。

請記住,我說過一些函數天生就適合實現為noexcept。但是如果扭曲一個函數的實現來允許noexcept聲明,這樣是本末倒置的。假設一個簡單的函數實現可能會產生異常(比如,它調用的函數可能拋出異常),如果你想隱藏這樣的調用(比如,在函數內部捕捉所有的異常並且把它們替換成相應的狀態值或者特殊的返回值)不僅將使你的函數實現更加復雜,它還將使你的函數調用變得更加復雜。舉個例子,調用者必須要檢查狀態值或特殊的返回值。同時增加的運行期的費用(比如,額外的分支,以及更大的函數在指令緩存上會增加更大的壓力。等等)會超過你希望通過noexcept來實現的加速,同時,你還要承擔源代碼更加難以理解和維護的負擔。這真是一個糟糕的軟件工程。

對於一些函數來說,聲明為noexcept不是如此重要,它們在默認情況下就是noexcept了。在C++98中,允許內存釋放函數(比如operator delete和operator delete[])和析構函數拋出異常是很糟糕的設計風格,在C++11中,這種設計風格已經在語言規則的層次上得到了改進。默認情況下,所有的內存釋放函數和所有的析構函數(包括用戶自定義的和編譯器自動生成的)都隱式成為noexcept。因此我們不需要把它們聲明成noexcept的(這么做不會造成任何問題,但是不尋常。)只有一種情況析構函數不是隱式noexcept,就是當類中的一個成員變量(包括繼承來和被包含在成員變量中的成員變量)的析構函數聲明表示了它可能會拋出異常(比如,聲明這個析構函數為“noexcept(false)”)。這樣的聲明是不尋常的,標准庫中就沒有。如果把一個帶有能拋出異常的析構函數的對象用在標准庫中(比如,這個對象在一個容器中或者這個對象被傳給一個算法),那么程序的行為是未定義的。

我們值得去注意一些庫的接口設計區分了寬接口(wide contract)和窄接口(narrow contract)。一個帶寬接口的函數沒有前提條件。這樣的函數被調用時不需要注意程序的狀態,它在傳入的參數方面沒有限制。帶寬接口的函數永遠不會展現未定義行為。

不帶寬接口條件的函數就是窄接口函數。對這些函數來說,如果傳入的參數違反了前提條件,結果將是未定義的。

如果你在寫一個寬接口的函數,並且你知道你不會拋出一個異常,那就遵循本Item的建議,把它聲明為noexcept。對於那些窄接口的函數,情況將變得很棘手。舉個例子,假設你正在寫一個函數f,這個函數接受一個std::string參數,並且它假設f的實現永遠不會產生一個異常。這個假設建議我們把f聲明為noexcept。

現在假設f有一個前提條件:std::string參數的數據長度不會超過32個字節。如果用一個超過32字節的std::string來調用f,f的行為將是未定義的,因為一個不符合前提條件的參數會導致未定義行為。f沒有義務去檢查前提條件,因為函數假設它們的前提條件是被滿足的(調用者有責任確保這些假設是有效的)。由於前提條件的存在,把f聲明為noexcept看起來是合理的。

void f(const std::string& s) noexcept;		//前提條件:s.length() <= 32

但是假設f的實現選擇檢查前提條件是否被違反了。檢查本不是必須的,但是它也不是被禁止的,並且檢查一下前提條件是有用的(比如,在進行系統測試的時候)。調試時,捕捉一個拋出的異常總是比嘗試找出未定義行為的原因要簡單很多。但是要怎么報道出前提條件被違反了呢?只有報道了才能讓測試工具或客戶端的錯誤處理機制來捕捉到它。一個直接的方法就是拋出一個“前提條件被違反”的異常,但是如果f被聲明為noexcept,那么這個方法就不可行了,拋出一個異常就會導致程序終止。因此,區分寬接口和窄接口的庫設計者通常只為寬接口函數提供noexcept聲明。

最后還有一點,讓我完善一下我之前的觀點(編譯器常常無法對“找出函數實現和它們的異常規范之間的矛盾”提供幫助)。考慮一下下面的代碼,這段代碼是完全合法的:

void setup();			//在別處定義的函數
void cleanup();			

void doWork() noexcept
{
	setup();			//做設置工作

	...					//做實際的工作

	cleanup();			//做清理工作
}

在這里,盡管doWork調用了non-noexcept函數(setup和cleanup),doWork還是被聲明為noexcept。這看起來很矛盾,但是有可能setup和cleanup在說明文檔中說了它們永遠不會拋出異常。就算它們沒有在說明文檔中說明,我們 還是有多理由來解釋他們的聲明式為什么是non-noexcept。舉個例子,它們可能是用C寫的。(也可能是從C標准庫移動到std命名空間但缺少異常規范的函數,比如,std::strlen沒有聲明為noexcept)或者它們可能是C++98標准庫的一部分,沒有使用C++98的異常規范,並且到目前為止還沒有被修改成C++11的版本。

因為這里有很多合適的理由來解釋為什么noexcept函數可以調用缺乏noexcept保證的函數,所以C++允許這樣的代碼,並且編譯器通常不會對此發出警告。

你要記住的事
  • noexcept是函數接口的一部分,並且調用者可能會依賴這個接口。
  • 比起non-noexcept函數,noexcept函數可以更好地被優化。
  • noexcept對於move操作,swap,內存釋放函數和析構函數是特別有價值的,
  • 大部分函數是異常中立的而不是noexcept。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM