如果你看過《C++ 學習筆記》, 那麼裡面的例外安全一定沒有引起閣下的注意. 學過其它高級語言的話, 你會發現 C++ 裡面的例外處理是非常簡單的. 一個 try, 一個 throw, 一個 catch 連帶著萬能的捕獲列表 catch(...) 就組成了 C++ 的例外處理

然而從網路上大家對於 C++ 的討論, 可以看到 C++ 的例外處理非常不受人喜歡. 最大的原因就是 C++ 並不是一個自帶垃圾回收的程式設計語言, 而例外的擲出是可能會造成資源流失的. 如果你還沒有明白什麼意思, 那麼下面的示例應該可以幫到你 :

template <typename T>

class dynamic_array {

private:

    T *array;

public:

    dynamic_array(unsigned long size, const T &value) : array {new T[size]} {

        for(auto i {0}; i < size; ++i) {

            this->array[i] = value;

        }

    }

    //...

};

一般來說, 上面的程式碼並沒有什麼問題. 但是從例外安全的角度來說, 上述程式碼是非常糟糕的. 試想 : 如若 this->array[i] = value 擲出例外情況, 如果這個例外沒有被客端所捕獲, 那麼程式會直接被中斷, 然後作業系統會直接回收所有資源 (程式退出之後, 沒有回收的記憶體或者其它資源由作業系統負責回收), 在這種情況之下沒有任何問題; 但是如果這個例外情況被客端捕獲, 程式回溯到例外情況發生之前, 也就是 dynamic_array 的建構子被呼叫之前. 此時, 之前使用 newdynamic_array 中的成員 array 申請的記憶體並沒有回收, 也就是發生了記憶體流失的情況. 如果在一台記憶體非常有限的裝置上, 不斷地出現記憶體流失, 結局自然是裝置的資源全被這個程式佔據

C++ 的記憶體流失有的是在非常複雜的情況之下才會發生, 所以這才是大家對 C++ 中的例外情況嗤之以鼻的最大原因, 它本身的設計沒有什麼太大問題. 倒是 C# 這些程式設計語言, 把例外情況搞的過於複雜了

《資料結構》系列的文章, 至今都沒有更新, 這是因為我之前被例外安全這個問題困擾了非常長的時間. 在雙向佇列之後, 我開始注重程式碼的例外安全, 這導致我需要重構這些程式碼. 期間一直在重寫 vector, 直到現在, 總算是有了一些進展. 於是, 分享一些我在這方面的經歷以及經驗給大家

C++ 程式的例外安全指的是一段具有例外安全的程式碼, 它應該具備以下兩個屬性 :

  • 當有例外情況被擲出, 程式碼應保證不會流失任何資源
  • 當程式試圖從例外情況中恢復, 不允許存在任何數據敗壞

第一點非常好理解, 就像之前所說的情況那樣, 當程式從例外情況中試圖恢復, 之前申請的資源不應該出現流失的情況 (這有兩種情況 : 一種是有一些東西指向了這些資源, 那麼這些資源在程式從例外情況中恢復之後, 還可以手動管理這些資源, 這種不算流失; 另外一種情況是在程式試圖恢復的時候, 回收這些資源, 通常這些資源在例外擲出之後沒有任何東西指向它們, 並且無法再獲得它們). 第二點說的是當程式從例外情況恢復之後, 不應該出現虛吊的實體 :

#include <exception>



char *func(int *p) {

    try {

        auto str {new char[*p]};

        return str;

    }catch(std::exception &) {

        delete p;

    }

    return nullptr;

}



int main(int argc, char *argv[]) {

    int *i {new int {42}};

    auto str {func(i)};

    ++(*i);

}

func 函式中, 如若 str 的記憶體申請擲出例外情況, 那麼連帶會回收 p 引數的記憶體. 當從例外情況恢復之後, main 函式中讓 *i 的值增加 1. 此時, i 對應的記憶體已經被回收, 這個操作是對虛吊指標的操作, 這是一種未定行為. 第二點的意思就是說不應該讓這種情況發生, 於是我們修改程式碼 :

#include <exception>



char *func(int *&p) {

    try {

        auto str {new char[*p]};

        return str;

    }catch(std::exception &) {

        delete p;

        p = new auto(0);

    }

    return nullptr;

}



int main(int argc, char *argv[]) {

    int *i {new int {42}};

    auto str {func(i)};

    ++(*i);

}

此時, 再有例外情況發生, 當從例外情況恢復之後, 並不會出現敗壞的數據

對於一個函式而言, 如果函式提供了例外安全的保證, 那麼這個函式至少符合下列三個保證的其中一個 :

  • 基本承諾 (Basic Guarantee) : 當有例外情況被擲出, 程式內的所有物件以及物件對應的狀態都保持有效, 沒有任何物件或者資料結構會因為例外情況的擲出而遭到任何破壞. 但是, 對於物件的狀態, 只需保證物件仍然有效即可, 物件的具體狀態應該如何, 這恐怕無法預料. 比如當我們正在實作一個 vectorinsert 成員函式. 一般它只保證例外安全的基本承諾, 因此當有例外情況被擲出的時候, insert 函式可以保證 vector 內部申請的記憶體仍然有效, 並且 vector 內部狀況良好 (所有成員變數都是有效的), 但是 vector 可能會發生改變. 原來 vector 保存了 {1, 2, 3, 4, 5, 6, 7}, 現在可能直接被完全清空了; 又或者, 插入位置之後被全部清空, 而插入位置之前的元素保持良好. 而 vector 具體的情況如何, 這恐怕要看這個 vector 作者給出的 vector 使用說明之後才會知道
  • 強烈保證 (Strong Guarantee) : 當有例外情況被擲出的時候, 程式內的所有物件以及物件對應的狀態都會隨著程式從例外情況中恢復而恢復. 也就是程式從例外情況恢復之後, 所有的物件狀態都會恢復到例外被擲出之前, 並且和例外擲出之前的狀態保持完全一致. 還是拿 vectorinsert 函式來說, 擲出例外之前, vector 保存了 {1, 2, 3, 4, 5, 6, 7}, 擲出例外之後, vector 保存的還是 {1, 2, 3, 4, 5, 6, 7}, 並且無論是什麼樣的例外情況, 只要從例外情況中恢復了, 那麼 vector 內部保存的元素就會保持不變. 這和基本承諾中 vector 內部保存的元素可能會改變有些不同
  • 不拋擲保證 (Nothrow Guarantee) : 函式保證不擲出任何例外情況, 也就是函式被 noexcept 或者 throw() 所標識

現在回到《資料結構》(大家可以把這個系列看作是一個程式庫的實作過程), 如果使用泛型實作資料結構中的結構, 大家可能會發現, 不拋擲保證完全不切實際. 因為對於未知的型別來說, 我們的操作並不能保證它不擲出任何例外情況. 那麼此時我們會想到使用強烈保證. 這確實是可行的, 但是強烈保證是以性能的犧牲為前提的, 這表示極度追求性能的地方 (絕大多數的 C++ 程式庫都極度追求性能), 那些人可能會放棄使用你的程式碼. 最終, 對於一個程式庫而言, 基本承諾是必須的, 否則使用你的程式庫會抱怨你根本不至於達到寫程式庫的水平

大家可以去我的 GitHub 上看看程式碼的歷史提交, 我之前所寫的程式碼都會存在例外安全問題. 當我開始注意到例外安全這個問題的時候, 我首先想要使用強烈保證, 保證我所寫的程式碼的例外安全性. 但是後來我發現, 這會使我的程式碼變得非常複雜, 並且難以維護. 除此之外, 過度注重例外安全, 導致了我的程式碼的運作速度緩慢, 這並不是我想要的結果. 因此, 我選擇了只為我的程式碼提供基本承諾. 大家之後如果需要自己寫一個泛型的程式庫, 那麼如果你追求性能的話, 無需思考, 提供基本的例外安全承諾即可

在寫泛型程式庫的時候, 還有一種情況大家是需要注意的, 就是回傳一個物件的副本問題, 假設我們正在實作 stack :

template <typename T>

class stack {

private:

    T *start;       //button of stack

    T *cursor;      //top of stack

    T *end;     //(end - start) = (stack's size)

public:

    //...

    T pop() {

        return this->cursor--;

    }

    //...

};

注意到 stack 中的 pop 函式, 它除了彈出堆疊中的頂部元素之外, 還負責回傳這個元素. 此時我們想像, 在複製這個元素的時候, 發生了例外情況. 這個例外情況是程式庫作者無法捕獲的, 因為這個複製行為並不是發生在函式體內部. 你可能會想, 我先行在函式體內複製一份, 然後回傳? 首先你要瞭解, 函式體內部的任何變數都不能以參考的形式傳出函式, 因為函式結束之後, 變數的生命週期也已經結束, 變數已經被回收了, 如果再讓這個行為發生會導致未定行為. 那麼, 你又會想, 我配置一塊記憶體, 然後將副本保存在那塊記憶體中, 然後回傳一個指標或者智慧指標不就行了? 沒錯, 這確實是可行的. 但是首先, 為了回傳副本而配置記憶體, 這是沒有必要的. 其次, 如果回傳的不是智慧指標 (這可能是為了保證程式的運作效率), 這塊記憶體由用戶負責回收, 這並不符合 C++ RAII 的思想. 因此, 如果你閱讀過 C++ 標準樣板程式庫 (C++ STL), 你會發現所有的容器都具有類似於此的設計 (這只是簡化版本, 實際的實作要更加複雜) :

template <typename T>

class stack {

private:

    T *start;       //button of stack

    T *cursor;      //top of stack

    T *end;     //(end - start) = (stack's size)

public:

    //...

    void pop() {

        --this->cursor;

    }


    T &back() {

        return *(this->cursor);

    }

    //...

};

以參考的形式傳出函式之外, 就不存在任何例外安全的問題了, 程式庫作者以及客端都無需對因為複製而導致的例外情況擔心

然而, 真正的情況遠比我們想像的要複雜, 因為例外情況除了配合記憶體分配, 還有會其它的應用場景也會出現類似的問題, 例如執行緒中等等. 只有真的遇到並且解決過, 才會對這一類問題得心應手吧