Effective Modern C++ 條款14 把不發出異常的函數聲明爲noexcept

把不發出異常的函數聲明爲noexcept

在C++98中,異常規範(exception specification)簡直是隻不靠譜的野獸。你必須總結一個函數可能發出的異常類型,因此如果修改了一個函數的實現,那麼異常規範可能也需要改變。修改了異常執行順序可能會打亂用戶的代碼,因爲用戶是根據異常規範來調用函數的。編譯器通常不會在函數實現、異常規範、用戶代碼之間幫忙維護前後一致。最終結果是,大部分開發者都覺得異常規範(exception specification)不值得使用。

在制定C++11時,大家都同意需要一種明確有意義的通知來說明一個函數是否會發出異常。黑或白,函數要麼可能發出異常,要麼保證不發出異常。這種“可能或決不”兩種情況形成了C++11異常規範的基礎,從根本上替代C++98的異常規範(C++98風格的異常規範依然有效,但不贊成使用)。在C++11中,絕對的noexcept說明函數保證不會發出異常。

一個函數是否應該這樣聲明是接口設計的問題,函數發出異常這個行爲是用戶感興趣的,用戶可以詢問函數的noexcept狀態位,詢問的結果會影響到代碼的異常安全和高效性。一個函數的是否是noexcept的重要性相對於成員函數是否是const。如果你知道你的函數不會發出異常,卻沒有聲明爲noexcept,那麼這個接口設計是不優秀的。

把不發出異常的函數聲明爲noexcpte還有一個額外的動機:它允許編譯器生成更好的目標代碼。想知道爲什麼,這有助於你檢查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函數中,優化器不需要在異常出了函數時持有需展開的棧,也不用在異常離開對象時確保對象析構的順序與構造循序相反。“throw()”函數就沒有這種優化的靈活性,它做的與沒有異常規範的函數一樣,我們可以這樣總結:

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

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

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

這就足夠說服你,當你知道函數不發出異常時,把他們聲明爲noexcept

對於一些函數,更有說服力。移動構造就是一個優秀的例子。例如你在C++98舊代碼中有std::vector<Widget>,通過push_back來添加元素:

std::vector<Widget> vw;
...
Widget w;
...  // 對w進行處理
vw.push_back(w);  // 把w加入vw
...

假定代碼運行得很好,然後你沒有什麼興趣爲C++11進行修改。不過,你肯定知道C++11中使用移動語義可以提供性能。如果你想確保Widget有移動操作,要麼你自己寫,要麼自動生成(請看條款17)。

當把一個新元素加入std::vector,可能std::vector會沒有空間,那麼此時就是std::vector的size與capacity相等啦。當出現這種情況,std::vector就會分配一個新的更大的內存來持有這些元素,然後當然要把元素從舊內存轉移到新內存。在C++98中,這個轉移是把每個元素都拷貝都新內存,然後銷燬舊內存的元素。這種方法確保push_back函數提供了異常安全保證:如果在拷貝元素時拋出異常,std::vector的狀態依舊不變,因爲只有所有元素被拷貝到新內存後,纔會銷燬舊內存中的元素(即拋異常後舊內存仍有那些元素)。

在C++11中,一種很自然的優化就是把std::vector元素的拷貝替換成移動。但不幸的是,這樣做有可能會違反push_back的異常安全保證。如果移動了n個元素,然後再移動第n+1個元素時拋出了異常,push_bcak操作就沒有完成。但是原來的std::vector已經被改變了:n個元素被移動了。恢復成原來的狀態是不可能的,因爲試圖把每個元素移動回舊內存可能還會拋出異常。

這是個嚴峻的問題,因爲舊代碼中的push_back的行爲是具有異常安全保證的。因此,C++11中的push_back不能在暗地裏把拷貝替換成移動,除非它知道移動構造不會發出異常。在這個例子中,使用移動替代拷貝是應該是安全的,但無法提高效率。

std::vector::push_back採用的策略是“move if you can, but copy if you must”(你可以用移動的話就移動,不行就一定要用拷貝),而且不止一個函數採用這個策略,在C++98中具有異常安全保證的函數也是這樣做(例如,std::vector::reserve, std::deque::insert等)。所有這些函數在知道移動構造不會發出異常時,都會將C++98中調用的拷貝操作替換成C++11的移動操作。但是調用函數怎麼知道它的移動操作不會產生異常呢?答案很明顯啦:它會檢查這個移動操作是否用noexcept聲明。

swap函數也包含在上面的情況。swap是許多STL算法中的關鍵組成部分,它通常使用的是拷貝操作。它的廣泛使用彰示着noexcept帶來的優化十分值得。有趣的是,標準庫中的swap是否是noexcept取決於用戶定義swap是否爲noexcept,例如,下面是標準庫對數組和std::pairswap

template <class T, size_t N>
void swap(T (&a)[N], 
          T (&b)[N]) noexcept(noexcept(swap(*a,*b)));  // 詳情看下面

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

這些函數帶有額外的noexcept:函數是否是noexcept取決於從屬的noexcept表達式是否是noexcept的。例如,有兩個Widget數組,如果交換單個元素的swap操作是noexcept的,那麼交換整個數組也是noexcept的。寫Widget的swap函數的作者決定了交換Widget數組是否爲noexcept。相應地,也決定了一些swapnoexcept的,例如Widget數組的數組的swap。同樣地,交換兩個含有Widget的std::pair對象的swap函數也是noexcept的。交換高層次數據結構是noexcept的話,那麼它們的低層次組成成員的交換肯定是noexcept的,這個事實激勵你儘可能地提供noexceptswap函數。

現在,我希望你能爲noexcept帶來的優化機會感到興奮。不過,我要打擊你的熱情了。優化固然重要,那是正確性纔是最重要的。我在本條款一開始就提到noexcept是函數接口的一部分,所以你只有在你願意長期提供不發出異常的實現時,才把它聲明noexcept。如果你聲明一個函數是noexcept,後來後悔了,你覺得你一開始選擇錯了。那麼你可以從函數聲明那刪除noexcept(就是改變接口),這樣有可能破壞了用戶的代碼。你改變了實現,導致函數有可能會拋出異常,但是用戶還保留着舊的異常規範。如果你真的這樣做,那麼當函數拋出異常時程序會被終止,那麼你還要改代碼嗎。

事實上大部分函數是exception-neutral的。這些函數自身沒有拋任何異常,不過它們調用的函數可能發出異常。如果調用的函數發出異常,那麼exception-neutral函數允許異常沿着調用鏈通過。exception-neutral函數從來都不是noexcept的,因爲它們可能會發出這種“只是通過一下”的異常,因此,大部分函數,普遍不適用noexcept

但是一些函數,有很自然的noexcept實現,有幾個——尤其是移動賦值操作和swap——聲明爲noexcept有重大回報,這值得我們儘可能地把它們聲明爲noexcept。當你能拍着心口說某個函數絕不會發出異常,那麼你應該將它明確地聲明爲noexcept

請留意我說的一些函數有很自然的noexcept實現,扭曲函數的實現來允許noexcept聲明,就是尾巴在搖狗,本末倒置了。如果一個函數實現明確說可能會產生異常(例如它調用的函數可能拋出異常),然後你想向調用者隱藏(捕獲所有的異常,然後換成狀態碼或特殊返回值表示異常),這不僅會讓函數實現變得複雜,還會讓調用者的代碼變得複雜。例如,調用者需要檢查函數返回的狀態碼或者特殊返回值,這裏花費的時間(例如,額外的分支,更大的函數增大指令緩存的壓力)可能已經超過了noexcept優化帶來的加速了,再加上你要承擔更難理解和維護的源碼,這在軟件工程上來說,很搓。

一些函數聲明爲noexcept是很重要的,因此它們在默認情況下就是noexcept了。在C++98中,釋放內存的函數和析構函數(例如,operator delete和operator delete[])發出異常被認爲是一種糟糕的風格,而在C++11中,這風格依舊但上升成了語言規則。默認地,所有的釋放內存函數和析構函數——不管是用戶自定義還是編譯器生成的——都是隱式noexcept的,因此不需把它們聲明爲noexcept(聲明瞭也沒事,不過不地道)。析構函數沒有隱式noexcept的唯一可能,是類中成員變量(包括繼承來的和成員變量自身包含的)的析構函數表明可能會發出異常(例如,該成員變量的析構函數聲明爲“noexcept(false)”)。這種析構函數太反常了,標準庫根本沒有,然後如果標準庫使用了這種對象的析構(例如,該對象被放入容器或者傳遞給某種算法),程序的行爲是未定義的。

值得注意的是一些庫接口的設計者以wide contractnarrow contract區分函數。wide contract函數沒有前提條件,不管程序處於什麼狀態都可以調用,也不會限制調用者傳遞給它的參數,而且wide contract函數從不呈現未定義行爲。

沒有wide contract的函數是narrow contract函數,這種函數如果違反了前提條件,結果將是未定義的。

如果你寫的是wide contract函數,而且你知道它不會發出異常,根據本條款的建議,你可以很容易地把它聲明瞭noexcept。對於narrow contract函數就比較複雜了。例如,你在寫一個接受std::string作爲參數的函數f,假如f的自然實現不會產生異常,那麼建議把f聲明爲noexcept

現在假如f函數有一個前提條件:參數std::string的長度不能超過32。如果f的參數std::string長度大於32,那麼函數行爲是未定義的,因爲根據定義,違反前提條件會導致未定義行爲。f沒有義務檢查參數是否符合前提條件,因爲函數假定它們的前提條件是滿足的(調用者有責任確保這個假定有效)。然後呢,就算有前提條件,把f聲明爲noexcept看起來也是合適的:
void f(const std::string& s) noexcept; // 前提條件:s.length() <= 32

但是我們又假定f的實現者會去檢查前提條件。雖然檢查不是必需的,但它不會被禁止,然後檢查前提條件也會有用,例如在測試系統的時候。調試一個被拋出的異常通常比追查未定義行爲的原因要簡單。但是違反前提條件應該怎樣報告出來,以至於測試工具或者用戶的錯誤回調函數發現它們呢?一個最直接的辦法就是拋一個“違反前提條件”異常,但是f是用noexcept聲明的,所以這個辦法不行,拋出異常會導致程序終止。因爲這個原因,區分wide contractnarrow contract函數的庫設計者只會爲wide contract保留noexcept

最後一點,我會簡單又詳盡的說明,編譯器通常不會幫助你識別函數實現和異常規範之間的不一致。看下面的代碼,它是完全合法的:

void setup();
void cleanup();

void doWork() noexcept
{
    setup();
    ...
    cleanup();
}

在這裏,doWork函數聲明爲noexcept,儘管它它用了不是noexcept的setup和cleanup函數。這看起來很矛盾,但是setup和cleanup的文檔說明它們從不發出異常,儘管沒聲明爲noexcept。例如,它們是C語言庫的一部分(C標準庫的代碼移到std命名空間後都沒有異常規範,例如,std::strlen沒有聲明爲noexcept)。又或者它們是C++98庫的一部分,它們沒有使用C++98的異常規範,但又沒有爲C++11進行修正。

因爲有合法的理由讓noexcept函數調用沒有noexcept的函數,所以C++允許這樣的代碼存在,然後編譯器通常也不會對此發出警告。

總結

需要記住的4點:

  • noexcept是函數接口的一部分,這意味着調用者可能會依賴它。
  • noexcept函數會比非noexcept函數優化得更多。
  • noexcept對於移動賦值操作、swap、內存釋放函數和析構函數具有重大的價值。
  • 大部分函數是exception-neutral函數,而不是noexcept函數。
發佈了18 篇原創文章 · 獲贊 43 · 訪問量 8萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章