Boost C++ 程式庫

...世界上最受推崇且設計精良的 C++ 程式庫專案之一。 Herb SutterAndrei Alexandrescu,《C++ 程式碼規範

泛型組件的異常安全性 (例外安全性)

從為 C++ 標準程式庫指定異常安全性中汲取的教訓 (從為 C++ 標準函式庫指定例外安全性中學到的經驗)

David Abrahams

dave@boostpro.com

摘要。本文彙整了因應現實需求所累積的知識:C++ 標準樣板程式庫 (STL) 應展現與例外(C++ 核心語言內建的錯誤處理機制)良好且明確的互動。它探討了異常安全性的意義,揭示了關於例外和泛型的一些令人驚訝的迷思,描述了用於推理程式正確性的寶貴工具,並概述了用於驗證異常安全性 (例外安全性) 的自動化測試程序。

關鍵字:異常安全性 (例外安全性), 例外, STL, C++

1 什麼是異常安全性 (例外安全性)?

非正式地說,組件中的異常安全性 (例外安全性) 指的是它在執行期間拋出例外時展現合理的行為。對大多數人來說,「合理」一詞包含所有對錯誤處理的常見期望:資源不應洩漏,程式應保持在明確定義的狀態,以便執行可以繼續。對於大多數組件,它還包含當遇到錯誤時,應將錯誤回報給呼叫者的期望。

更正式地說,如果一個組件在從其內部拋出例外時,其不變量保持完好,我們可以將其描述為最低限度的異常安全 (例外安全)。稍後,我們將看到至少可以有效區分三個不同級別的異常安全性 (例外安全性)。這些區別可以幫助我們描述和推理大型系統的行為。

在泛型組件中,我們通常還期望 _異常中立性 (例外中立性)_,這表示由組件的類型參數拋出的例外應原封不動地傳播到組件的呼叫者。

2 迷思與誤解

到目前為止,異常安全性 (例外安全性) 似乎很簡單:它並未超出我們對使用更傳統的錯誤處理技術的程式碼的期望。然而,從心理學的角度來檢視這個術語可能是有價值的。在 C++ 有例外之前,從來沒有人談論過「錯誤安全性」。

例外幾乎被視為對原本正確的程式碼的 _神秘攻擊_,我們必須保護自己免受其害。不用說,這並不利於與錯誤處理建立健康的關係!在標準化的過程中,一個需要廣泛支持變革的民主過程,我遇到了許多廣泛存在的迷思。為了開始討論泛型組件中的異常安全性 (例外安全性),我們或許有必要先來釐清其中的一些迷思。

_「樣板與例外的互動並未被充分理解。」_ 這個迷思,經常從那些認為這兩者都是新的語言特性的人口中聽到,很容易被駁斥:它們之間根本沒有互動。樣板一旦被實例化,在所有方面都像普通的類別或函式一樣工作。推理樣板在例外情況下的行為的一個簡單方法是考慮該樣板的特定實例是如何工作的。最後,樣板的泛型性質不應引起特別的關注。儘管組件的客戶端提供了部分操作(除非另有規定,否則可能會拋出任意例外),但使用熟悉的虛擬函式或簡單的函式指標的操作也是如此。

「眾所周知,編寫一個例外安全的泛型容器是不可能的。」 這個說法經常與 Tom Cargill 的一篇文章有關 [4],他在文中探討了泛型堆疊模板的例外安全問題。Cargill 在他的文章中提出了許多有用的問題,但遺憾的是未能提出解決方案。1 他在結論中暗示,解決方案可能不存在。不幸的是,他的文章被許多人解讀為該推測的「證據」。自發表以來,已經有許多例外安全的泛型組件的例子,其中包括 C++ 標準函式庫容器。

「處理例外會降低程式碼速度,而模板的使用正是為了獲得最佳效能。」 一個好的 C++ 實作在拋出例外之前不會花費任何一個指令週期來處理例外,而且一旦拋出,它的處理速度可以與呼叫函式相當 [7]。僅此一項就使使用例外的程式效能等同於忽略錯誤可能性的程式。由於其他原因,使用例外可以產生比「傳統」錯誤處理方法更快的程式。首先,catch 區塊向編譯器指示哪些程式碼專用於錯誤處理;然後它可以與通常的執行路徑分開,從而改善參考局部性。其次,使用「傳統」錯誤處理的程式碼通常必須在每次函式呼叫後測試返回值是否有錯誤;使用例外完全消除了這種額外開銷。

「例外使得推斷程式的行為更加困難。」 通常用來支持這種迷思的是堆疊回溯期間「隱藏」執行路徑的追蹤方式。對於任何期望局部變數在函式返回時被銷毀的 C++ 程式設計師來說,隱藏執行路徑並不是什麼新鮮事。

ErrorCode f( int& result )         // 1 
{                                  // 2 
    X x;                           // 3 
    ErrorCode err = x.g( result ); // 4 
    if ( err != kNoError )         // 5 
        return err;                // 6 
    // ...More code here... 
    return kNoError;               // 7 
}

在上面的例子中,在第 6 行和第 7 行有一個對 X::~X() 的「隱藏」呼叫。的確,使用例外時,沒有可見的專用於錯誤處理的程式碼。

int f()                 // 1 
{                       // 2 
    X x;                // 3 
    int result = x.g(); // 4 
    // ...More code here... 
    return result;      // 5 
} 

對於許多更熟悉例外的程式設計師來說,第二個例子實際上比第一個例子更具可讀性和可理解性。 「隱藏」的程式碼路徑包含對局部變數解構函式的相同呼叫。此外,它們遵循一個簡單的模式,其作用*完全*就像在每次函式呼叫後都有一個潛在的 return 陳述式以防例外。可讀性得到增強,因為正常的執行路徑不會被錯誤處理所掩蓋,並且返回值被釋放以供自然使用。

例外可以增強正確性的另一個更重要的方式是:允許簡單的類別不變量。在第一個例子中,如果 x 的建構函式需要配置資源,它沒有辦法回報失敗:在 C++ 中,建構函式沒有返回值。避免例外時通常的結果是,需要資源的類別必須包含一個單獨的初始化函式來完成建構工作。因此,程式設計師在使用 X 類別的物件時永遠無法確定他是在處理一個完整的 X 還是某種建構失敗的嘗試(或者更糟:有人只是忘記呼叫初始化函式!)。

3 例外安全的契約基礎

一個非泛型組件可以被描述為單獨的異常安全,但由於其可由用戶端程式碼配置,泛型組件中的異常安全通常取決於組件及其用戶端之間的契約。例如,泛型組件的設計者可能會要求在組件的解構函式中使用的操作不要拋出任何異常。2 作為回報,泛型組件可能會提供以下保證之一:

  • 基本保證:組件的不變量得以保留,並且沒有資源洩漏。
  • 保證:操作已成功完成或拋出異常,使程式狀態與操作開始前完全相同。
  • 不拋出異常保證:操作不會拋出異常。

基本保證是我們可以對所有組件持有的異常安全性的簡單最低標準。它只是說在異常之後,組件仍然可以像以前一樣使用。重要的是,保留不變量允許銷毀組件,這可能是堆疊回溯的一部分。這個保證實際上不如乍看之下那麼有用。如果一個組件有許多有效狀態,在異常之後,我們不知道組件處於什麼狀態;只知道狀態是有效的。在這種情況下,恢復的選項有限:銷毀組件或在進一步使用之前將組件重置為某個已知狀態。請參考以下範例:

template <class X> 
void print_random_sequence() 
{ 
    std::vector<X> v(10); // A vector of 10 items 
    try { 
        // Provides only the basic guarantee 
        v.insert( v.begin(), X() ); 
    } 
    catch(...) {} // ignore any exceptions above 
    // print the vector's contents 
    std::cout "(" << v.size() << ") "; 
    std::copy( v.begin(), v.end(), 
    std::ostream_iterator<X>( std::cout, " " ) ); 
} 

由於我們對異常後 v 的唯一了解是它是有效的,因此該函式可以列印任何隨機的 X 序列。3 它的「安全」之處在於它不允許崩潰,但它的輸出可能是不可預測的。

保證提供完整的「提交或回滾」語義。就 C++ 標準容器而言,這意味著,例如,如果拋出異常,所有迭代器仍然有效。我們也知道容器的元素與拋出異常之前相同。如果交易失敗則沒有任何影響,這具有明顯的好處:在發生異常的情況下,程式狀態簡單且可預測。在 C++ 標準函式庫中,基於節點的容器 list、set、multiset、map 和 multimap 的幾乎所有操作都提供保證。4)。

不拋出異常保證是最強的,它表示保證操作不會拋出異常:它總是成功完成。大多數解構函式都需要此保證,實際上 C++ 標準函式庫組件的解構函式都保證不會拋出異常。正如我們將看到的,不拋出異常保證由於其他原因也很重要。5

4 法律爭論

合約難免會變得更複雜:交換條件的安排是有可能的。C++ 標準函式庫中的某些元件對任意類型參數提供一種保證,但如果用戶端類型額外承諾不會拋出例外,則會提供更強的保證。例如,標準容器操作 vector<T>::erase 對任何 T 都提供 *基本* 保證,但對於拷貝建構函式和拷貝賦值運算子不會拋出異常的類型,它提供 *不拋出異常* 的保證。6

5 元件應該指定哪種等級的異常安全性?

從用戶端的角度來看,最強等級的安全性是理想的。當然,對於許多操作來說,*不拋出異常* 的保證根本不可能,但是 *強* 保證呢?例如,假設我們希望 vector<T>::insert 具有原子性行為。插入到向量中間需要將插入點之後的元素複製到後面的位置,以便為新元素騰出空間。如果複製元素失敗,回滾操作將需要「撤銷」之前的複製…這又取決於再次複製。如果複製回來失敗(很可能如此),我們就沒有達到我們的保證。

一種可能的替代方案是重新定義 insert,每次都在新的記憶體區塊中建立新的陣列內容,並且只有在成功後才銷毀舊內容。遺憾的是,如果採用這種方法,會產生相當大的成本:向量末尾附近的插入操作以前可能只會導致少量複製,現在會導致每個元素都被複製。*基本* 保證是此操作的「自然」安全等級,它可以在不違反其效能保證的情況下提供此等級。事實上,函式庫中的所有操作似乎都具有這樣的「自然」安全等級。

由於效能要求已經是標準草案中既定的部分,而且效能是 STL 的主要目標,因此沒有嘗試指定超出這些要求所能提供的更多安全性。雖然並非所有函式庫都提供 *強* 保證,但標準容器上幾乎所有提供 *基本* 保證的操作都可以使用上述「建立新副本」策略使其變為 *強* 保證。

template <class Container, class BasicOp> 
void MakeOperationStrong( Container& c, const BasicOp& op ) 
{ 
    Container tmp(c); // Copy c 
    op(tmp); // Work on the copy 
    c.swap(tmp); // Cannot fail7
}

這種技術可以整合到一個包裝類別中,以建立一個提供更強保證(以及不同效能特性)的類似容器。8

6 我們應該盡可能地獲取一切嗎?

通過考慮特定的實作,我們可以期望辨別出一個自然的安全性等級。使用它來建立元件需求的危險在於,實作可能會受到限制。如果有人提出了一個我們想要使用的更有效率的實作,我們可能會發現它與我們的異常安全需求不相容。人們可能認為在 STL 涵蓋的資料結構和演算法這些經過充分探索的領域中,這無關緊要,但即使在那裡,也正在取得進展。一個很好的例子是最近的 *內省排序* 演算法 [6],它代表著在最壞情況複雜度方面比成熟的 *快速排序* 有了顯著的改進。

為了確切地決定標準組件的需求量,我研究了一個典型的實際案例。所選的測試案例是一個「複合容器」。這種由兩個或多個標準容器組件構建的容器,不僅是常見的需求,而且可以作為在較大系統中維護不變量的簡單代表性案例。

// SearchableStack - A stack which can be efficiently searched 
// for any value. 
template <class T> 
class SearchableStack 
{ 
 public: 
    void push(const T& t); // O(log n) 
    void pop(); // O(log n) 
    bool contains(const T& t) const; // O(log n) 
    const T& top() const; // O(1) 
 private: 
    std::set<T> set_impl; 
    std::list<std::set<T>::iterator> list_impl; 
}; 

其概念是將列表作為一疊集合迭代器:每個元素先進入集合,然後將結果位置推入列表。不變量很簡單:集合和列表應始終具有相同數量的元素,並且集合的每個元素都應由列表的一個元素引用。以下 push 函式的實作旨在在集合和列表提供的自然安全級別內提供*強*保證。

template <class T>                                // 1 
void SearchableStack<T>::push(const T& t)         // 2 
{                                                       // 3 
    set<T>::iterator i = set_impl.insert(t);      // 4 
    try                                                 // 5 
    {                                                   // 6 
        list_impl.push_back(i);                         // 7 
    }                                                   // 8 
    catch(...)                                          // 9 
    {                                                   // 10 
        set_impl.erase(i);                              // 11 
        throw;                                          // 12 
    }                                                   // 13 
}                                                       // 14 

我們的程式碼對程式庫有什麼要求?我們需要檢查發生非常數操作的行。

  • 第 4 行:如果插入失敗,但在過程中修改了 set_impl,則會違反我們的不變量。我們需要能夠依賴 set<T>::insert 的*強*保證。
  • 第 7 行:同樣地,如果 push_back 失敗,但在過程中修改了 list_impl,則會違反我們的不變量,因此我們需要能夠依賴 list<T>::insert 的*強*保證。
  • 第 11 行:這裡我們正在「回滾」第 4 行的插入。如果此操作失敗,我們將無法恢復不變量。我們絕對依賴 set<T>::erase 的*無異常*保證。9
  • 第 11 行:出於同樣的原因,我們還依賴於能夠將 i 傳遞給 erase 函式:我們需要 set<T>::iterator 的複製建構函式的*無異常*保證。

在標準化過程中,我通過這種方式處理這個問題學到了很多東西。首先,為複合容器指定的保證取決於其組件的更強保證(第 11 行中的*無異常*保證)。此外,我利用了所有自然安全級別來實作這個簡單的範例。最後,分析揭示了迭代器的一個需求,我以前在單獨考慮操作時忽略了這個需求。結論是,我們應該盡可能多地提供自然安全級別。速度更快但安全性較低的實作始終可以作為標準組件的擴展提供。10

7 自動測試異常安全性

作為標準化流程的一部分,我製作了一個異常安全的 STL 參考實作。錯誤處理程式碼在現實生活中很少經過嚴格測試,部分原因是很難造成錯誤條件發生。很常見的情況是,錯誤處理程式碼在第一次執行時就崩潰……而且是在已發行的產品中!為了增強對實作如預期運作的信心,我設計了一套自動化測試套件,基於我的同事 Matt Arnold 所提出的一種窮盡測試技術。

測試程式從基礎開始:強化和檢測,尤其是全域運算子 newdelete11建立了組件(容器和演算法)的實例,並選擇類型參數以揭露盡可能多的潛在問題。例如,所有類型參數都被賦予一個指向堆積配置記憶體的指標,以便將包含物件的洩漏檢測為記憶體洩漏。

最後,設計了一個方案,可以在每個可能的失敗點觸發一個操作拋出異常。在每個允許拋出異常的用戶端提供的操作開始時,都添加了對 ThisCanThrow 的呼叫。在被測試的泛型操作可能拋出異常的任何地方,也必須添加對 ThisCanThrow 的呼叫,例如在全域運算子 new 中,為此提供了一個檢測過的替代方案。

// Use this as a type parameter, e.g. vector<TestClass> 
struct TestClass 
{ 
    TestClass( int v = 0 ) 
        : p( ThisCanThrow(), new int( v ) ) {} 
    TestClass( const TestClass& rhs ) 
        : p( ThisCanThrow(), new int( *rhs.p ) ) {} 
    const TestClass& operator=( const TestClass& rhs ) 
        { ThisCanThrow(); *p = *rhs.p; } 
    bool operator==( const TestClass& rhs ) const
        { ThisCanThrow(); return *p == *rhs.p; } 
    ...etc... 
    ~TestClass() { delete p; } 
};

ThisCanThrow 只是將「拋出計數器」遞減,如果它達到零,則拋出異常。每個測試都採用一種形式,在外層迴圈中將計數器以遞增的值開始,並重複嘗試完成被測試的操作。結果是,操作在其執行路徑上每個可能失敗的連續步驟中拋出異常。例如,以下是用於測試*強*保證的函數的簡化版本:12

extern int gThrowCounter; // The throw counter
void ThisCanThrow() 
{ 
    if (gThrowCounter-- == 0) 
        throw 0; 
} 
 
template <class Value, class Operation> 
void StrongCheck(const Value& v, const Operation& op) 
{ 
    bool succeeded = false; 
    for (long nextThrowCount = 0; !succeeded; ++nextThrowCount) 
    { 
        Value duplicate = v; 
        try 
        { 
            gThrowCounter = nextThrowCount; 
            op( duplicate ); // Try the operation 
            succeeded = true; 
        } 
        catch(...) // Catch all exceptions 
        { 
            bool unchanged = duplicate == v; // Test strong guarantee 
            assert( unchanged ); 
        } 
        // Specialize as desired for each container type, to check 
        // integrity. For example, size() == distance(begin(),end()) 
        CheckInvariant(v); // Check any invariant 
    } 
}

值得注意的是,對於泛型組件,這種測試比非泛型組件更容易且侵入性更小,因為可以使用特定於測試的類型參數,而無需修改被測試組件的原始碼。此外,像上面的 StrongCheck 這樣的泛型函數有助於對各種值和操作執行測試。

8 進一步閱讀

據我所知,目前只有兩個關於 STL 異常安全的描述。STL 參考異常安全實作的原始規範[2]是一個非正式的規範,簡單易懂(也很冗長),並使用了本文概述的*基本*和*強*保證區分。它明確禁止洩漏,並且在其提供的保證方面與最終的 C++ 標準存在實質性差異,儘管它們在很大程度上是相同的。我希望很快能製作一份更新版本的文檔。

C++ 標準[1]中關於異常安全性的描述僅稍微正式一些,但卻依賴難以理解的「標準用語」以及偶爾出現的微妙暗示網絡。13尤其,記憶體洩漏根本沒有被直接處理。它的優點是它就是標準。

異常安全 STL 的原始參考實作[5]是舊版 SGI STL 的改編版本,專為功能有限的 C++ 編譯器設計。雖然它不是完整的 STL 實作,但程式碼可能更容易閱讀,並且它說明了一種有用的基底類別技術,可以在建構函式中消除異常處理程式碼。用於驗證參考實作的完整測試套件[3]已成功用於驗證所有最新版本的 SGI STL,並且已改編用於測試另一家供應商的實作(測試失敗)。如說明文件頁面所述,它似乎也有能力揭示隱藏的編譯器錯誤,尤其是在最佳化器與異常處理程式碼互動的情況下。

參考文獻

  1. 國際標準 ISO/IEC 14882,《資訊科技—程式語言—C++》,文件編號 ISO/IEC 14882-1998,可從 http://webstore.ansi.org/ansidocstore/default.asp 取得。
  2. D. Abrahams,《STLport 中的異常安全性》,可從 http://www.stlport.org/doc/exception_safety.html 取得。
  3. D. Abrahams 和 B. Fomitchev,《異常處理測試套件》,可從 http://www.stlport.org/doc/eh_testsuite.html 取得。
  4. Tom Cargill,「異常處理:虛假的安全感」,C++ Report,1994 年 11-12 月,也可從 http://www.awprofessional.com/content/images/020163371x/supplements/Exception_Handling_Article.html 取得。
  5. B. Fomitchev,《改編的 SGI STL 版本 1.0》,異常處理程式碼由 D. Abrahams 撰寫,可從 http://www.stlport.org 取得。
  6. D. R. Musser,「內省式排序和選擇演算法」,《軟體實務與經驗》27(8):983-993,1997。
  7. Bjarne Stroustrup,《C++ 的設計與演化》。Addison Wesley,Reading,MA,1995,ISBN 0-201-54330-3,第 16.9.1 節。

註腳

1 在 Cargill 的案例中,解決方案的最大障礙可能是他做出的選擇的不幸組合:他為其容器選擇的介面與他對安全性的特定要求不相容。只要改變其中一個,他或許就能解決問題。

2 在 C++ 中,通常不建議從解構函式中擲出異常,因為解構函式本身可能在另一個異常引起的堆疊回溯期間被呼叫。如果允許第二個異常從解構函式傳播出去,程式將立即終止。

3 當然,在實務中,這個函式會是一個非常糟糕的隨機序列產生器!

4 值得注意的是,變動型演算法通常無法提供*強*保證:要回復範圍中已修改的元素,必須使用 operator= 將其設定回先前的值,而此運算子本身可能會拋出例外。在 C++ 標準函式庫中,有一些例外情況,它們的回復行為僅包含解構:uninitialized_copyuninitialized_filluninitialized_fill_n

5 C++ 標準函式庫的所有使用者提供的型別參數都不得在其解構子中拋出例外。相對地,C++ 標準函式庫的所有組件至少提供*基本*保證。

6 在 C++ 標準中,許多變動型演算法本來可以做出類似的安排,但由於標準化過程的時間限制,這些安排從未被考慮過。

7Compare 物件在複製時可能會拋出例外的關聯式容器無法使用此技術,因為交換函式可能會失敗。

8 這暗示了人們經常希望擁有但尚未出現的 container_traits<> 模板的另一個潛在用途:自動選擇容器以滿足例外安全性約束。

9 有人可能會嘗試用 try/catch 區塊圍繞 erase 操作,以減少對 set<T> 的要求以及在發生例外時出現的問題,但最終這只是迴避問題。首先,erase 失敗了,在這種情況下,沒有其他可行的替代方法來產生必要的結果。其次,更普遍地說,由於其型別參數的可變性,泛型組件很少能確保任何替代方案都會成功。

10 STL 設計中普遍的哲學是,只要可以透過調整基本組件來獲得非必要的功能,就應該為了效率而將其省略。這與該哲學有所不同,但如果基本組件本身不具備*基本*保證,則很難或不可能透過調整基本組件來獲得它。

11 關於如何強化記憶體子系統的精彩討論,請參見:Steve Maguire,《Writing Solid Code》,Microsoft Press,Redmond,WA,1993,ISBN 1-55615-551-4。

12 請注意,此技術要求被測試的操作必須是例外中性。如果操作嘗試從例外中恢復並繼續執行,則拋出計數器將為負數,並且後續可能失敗的操作將不會進行例外安全測試。

13 在標準草案中引入例外安全性的變更是在流程後期進行的,當時修正案很可能僅僅因為更改的字數而被拒絕。遺憾的是,為了簡潔起見,結果在一定程度上犧牲了清晰度。Greg Colvin 負責巧妙的法律語言技巧,以最大限度地減少這些變更的範圍。