摘要訊息 : 例外情況是 C++ 程式設計師必須要注意又容易忽略的點.

0. 前言

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

然而從網路上大家對於 C++ 的討論, 可以看到 C++ 的例外處理非常不受人喜歡. 最大的原因就是 C++ 並不是一個自帶垃圾回收的程式設計語言, 而例外的擲出是可能會造成資源流失的.

更新紀錄 :

  • 2022 年 4 月 18 日進行第一次更新和修正.

1. 資源因為例外情況而流失

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;
        }
    }
    // other functions...
};

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

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

2. 具有例外安全的程式碼

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

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

第一點非常好理解, 就像之前所說的情況那樣, 當程式從例外情況中試圖恢復, 之前申請的資源不應該出現流失的情況. 這裡又分為兩種情況. 一種是有某些變數指向了這些資源, 那麼這些資源在程式從例外情況中恢復之後, 變數不消失且還可以通過變數來手動管理這些資源, 這種就不算資源流失; 另外一種情況是在程式試圖恢復的時候, 可能導致流失資源被回收, 通常這些資源在例外擲出之後沒有任何東西指向它們, 並且無法再獲得它們. 第二點類似於第一點中第一種情況. 部分資源在例外情況發生之後, 被程式回收了, 導致之前指向這些資源的變數變成了虛吊的 :

float *allocate(int *p) {
    float *result {};
    try {
        result = new float[*p];
    }catch(...) {
        delete p;
    }
    return result;
}
int main(int argc, char *argv[]) {
    auto p {new auto(42)};
    auto float_ptr {allocate(p)};
    // ...
}

當函式 allocate 發生例外情況, 那麼 p 指向的記憶體會被 allocate 函式回收. 接下來, 如果再對 p 進行解參考並且使用, 就會產生未定行為. 因為 p 已經是一個虛吊指標.

3. 例外安全的三個保證

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

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

在實作泛型的時候, 不拋擲保證其實是不現實的. 因為對於未知的型別來說, 我們的操作並不能保證它不擲出任何例外情況. 那麼此時我們可能會想到使用強烈保證. 這確實是可行的. 但是強烈保證是以程式效能的犧牲為前提的, 這表示極度追求性能的地方 (絕大多數的 C++ 程式庫都極度追求性能), 犧牲程式效能是不可接受的. 最終, 對於一個程式庫而言, 基本承諾是必須的, 否則使用你的程式庫會抱怨你根本不至於達到寫程式庫的水平. 而且強烈保證會使得程式庫的程式碼非常複雜, 難以維護.

4. 回傳一個物件

假設我們正在實作 stack :

#include <utility>

template <typename T>
class stack {
private:
    T *begin;
    T *end;     // end - begin = the size of stack
    T *cursor;      // cursor - begin = the number of elements in stack
public:
    T pop() {
        --this->cursor;
        T value {std::move(*this->cursor)};
        this->cursor->~T();
        return value;
    }
    // other functions...
};

注意 stack 中的 pop 函式. 它除了移除堆疊中的尾部元素 (頂部元素) 之外, 還負責回傳這個元素. 此時我們想像, 在複製或者這個元素的時候, 沒有發生例外情況, 反而在回傳這個元素的時候發生例外情況. 這個例外情況是程式庫作者無法捕獲的, 因為這個複製行為並不是發生在函式體內部. 此時, 我們想到是否可以在 pop 內部配置一個記憶體, 把本來的 value 複製到記憶體中, 然後把指標回傳就不會發生例外情況了. 沒錯, 這確實是可行的. 但是首先, 為了回傳副本而配置記憶體, 這是沒有必要的. 其次, 如果回傳的不是智慧指標 (這可能是為了保證程式的運作效率), 這塊記憶體由程式碼使用者負責回收, 這並不符合 C++ RAII 的思想. 況且, 程式碼使用者萬一忘記了回收這部分記憶體, 那還不如直接像上面這樣寫. 因此, 如果你閱讀過 C++ 標準樣板程式庫 (C++ STL), 你會發現所有的容器都具有類似於此的設計 (這只是簡化版本, 實際的實作要更加複雜) :

#include <utility>

template <typename T>
class stack {
private:
    T *begin;
    T *end;     // end - begin = the size of stack
    T *cursor;      // cursor - begin = the number of elements in stack
public:
    void pop() noexcept {
        --this->cursor;
        this->cursor->~T();
    }
    T &top() noexcept {
        return *(this->cursor - 1);
    }
    const T &top() const noexcept {
        return *(this->cursor - 1);
    }
    // other functions...
};

其實, Code 3 中的程式碼問題並不像上面所說的那麼簡單. 了解 C++ 標準的人可能知道, 對於 auto v {s.pop()}; 這樣的宣告, 成員函式 pop 中的 value 會直接在 v 所屬的記憶體中進行複製建構, 而不是先進行複製, 然後回傳, 再利用回傳值進行複製建構. 真正的問題在於複製建構的時候, 如果發生例外情況並且恢復了, stack 中成員變數的狀態還正常嗎? 不正常, 因為我們已經讓指標 this->cursor 向前移動了一步. 根據上面的設計, this->cursor 中的記憶體必須保持未建構僅配置. 然而, 在例外恢復的過程中, 我們並沒有解構 this->cursor 中的物件. 因此, 可以寫成這樣 :

T pop() {
    T value {std::move(*(this->cursor - 1))};
    --this->cursor;
    this->cursor->~T();
    return value;
}

這樣儘管正確, 但是會多一次指標加減法操作. 所以 Code 4 中的解決方案才是最好的, 畢竟它才是被 C++ 標準樣板程式庫採用的方案.