C++11 noexcept 關鍵字用法學習

RioTian發表於2021-08-08

最近學習和寫了一個 mint 的板子 ,其中用到了 noexcept 關鍵字,對這個關鍵字不太熟悉,便學習一下劉毅學長的文章。

C++98 中的異常規範(Exception Specification)

throw 關鍵字除了可以用在函式體中丟擲異常,還可以用在函式頭和函式體之間,指明當前函式能夠丟擲的異常型別,這稱為異常規範,有些教程也稱為異常指示符或異常列表。請看下面的例子:

double func1 (char param) throw(int);

函式 func1 只能丟擲 int 型別的異常。如果丟擲其他型別的異常,try 將無法捕獲,並直接呼叫 std::unexpected

如果函式會丟擲多種型別的異常,那麼可以用逗號隔開,

double func2 (char param) throw(int, char, exception);

如果函式不會丟擲任何異常,那麼只需寫一個空括號即可,

double func3 (char param) throw();

同樣的,如果函式 func3 還是丟擲異常了,try 也會檢測不到,並且也會直接呼叫 std::unexpected

虛擬函式中的異常規範

C++ 規定,派生類虛擬函式的異常規範必須與基類虛擬函式的異常規範一樣嚴格,或者更嚴格。只有這樣,當通過基類指標(或者引用)呼叫派生類虛擬函式時,才能保證不違背基類成員函式的異常規範。請看下面的例子:

class Base {
  public:
    virtual int fun1(int) throw();
    virtual int fun2(int) throw(int);
    virtual string fun3() throw(int, string);
};

class Derived: public Base {
  public:
    int fun1(int) throw(int);    //錯!異常規範不如 throw() 嚴格
    int fun2(int) throw(int);    //對!有相同的異常規範
    string fun3() throw(string); //對!異常規範比 throw(int, string) 更嚴格
}

異常規範與函式定義和函式宣告

C++ 規定,異常規範在函式宣告和函式定義中必須同時指明,並且要嚴格保持一致,不能更加嚴格或者更加寬鬆。請看下面的幾組函式:

// 錯!定義中有異常規範,宣告中沒有
void func1();
void func1() throw(int) { }

// 錯!定義和宣告中的異常規範不一致
void func2() throw(int);
void func2() throw(int, bool) { }

// 對!定義和宣告中的異常規範嚴格一致
void func3() throw(float, char *);
void func3() throw(float, char *) { }

異常規範在 C++11 中被摒棄

異常規範的初衷是好的,它希望讓程式設計師看到函式的定義或宣告後,立馬就知道該函式會丟擲什麼型別的異常,這樣程式設計師就可以使用 try-catch 來捕獲了。如果沒有異常規範,程式設計師必須閱讀函式原始碼才能知道函式會丟擲什麼異常。

不過這有時候也不容易做到。例如,func_outer() 函式可能不會引發異常,但它呼叫了另外一個函式 func_inner(),這個函式可能會引發異常。再如,編寫的一個函式呼叫了老式的一個庫函式,此時不會引發異常,但是老式庫更新以後這個函式卻引發了異常。

其實,不僅僅如此,

  1. 異常規範的檢查是在執行期而不是編譯期,因此程式設計師不能保證所有異常都得到了 catch 處理。

  2. 由於第一點的存在,編譯器需要生成額外的程式碼,在一定程度上妨礙了優化。

  3. 模板函式中無法使用。比如下面的程式碼,

    template<class T>
    void func(T k) {
        T x(k);
        x.do_something();
    }
    

    賦值函式、拷貝建構函式和 do_something() 都有可能丟擲異常,這取決於型別 T 的實現,所以無法給函式 func 指定異常型別。

  4. 實際使用中,我們只需要兩種異常說明:拋異常和不拋異常,也就是 throw(...) 和 throw()。

所以 C++11 摒棄了 throw 異常規範,而引入了新的異常說明符 noexcept。

C++11 noexcept

noexcept 緊跟在函式的引數列表後面,它只用來表明兩種狀態:"不拋異常" 和 "拋異常"。

void func_not_throw() noexcept; // 保證不丟擲異常
void func_not_throw() noexcept(true); // 和上式一個意思

void func_throw() noexcept(false); // 可能會丟擲異常
void func_throw(); // 和上式一個意思,若不顯示說明,預設是會丟擲異常(除了解構函式,詳見下面)

對於一個函式而言,

  1. noexcept 說明符要麼出現在該函式的所有宣告語句和定義語句,要麼一次也不出現。
  2. 函式指標及該指標所指的函式必須具有一致的異常說明。
  3. 在 typedef 或型別別名中則不能出現 noexcept。
  4. 在成員函式中,noexcept 說明符需要跟在 const 及引用限定符之後,而在 final、override 或虛擬函式的 =0 之前。
  5. 如果一個虛擬函式承諾了它不會丟擲異常,則後續派生的虛擬函式也必須做出同樣的承諾;與之相反,如果基類的虛擬函式允許丟擲異常,則派生類的虛擬函式既可以丟擲異常,也可以不允許丟擲異常。

需要注意的是,編譯器不會檢查帶有 noexcept 說明符的函式是否有 throw

void func_not_throw() noexcept {
    throw 1; // 編譯通過,不會報錯(可能會有警告)
}

這會發生什麼呢?程式會直接呼叫 std::terminate,並且不會棧展開(Stack Unwinding)(也可能會呼叫或部分呼叫,取決於編譯器的實現)。另外,即使你有使用 try-catch,也無法捕獲這個異常。

#include <iostream>
using namespace std;

void func_not_throw() noexcept {
    throw 1;
}

int main() {
    try {
        func_not_throw(); // 直接 terminate,不會被 catch
    } catch (int) {
        cout << "catch int" << endl;
    }
    return 0;
}

所以程式設計師在 noexcept 的使用上要格外小心!

noexcept 除了可以用作說明符(Specifier),也可以用作運算子(Operator)。noexcept 運算子是一個一元運算子,它的返回值是一個 bool 型別的右值常量表示式,用於表示給定的表示式是否會丟擲異常。例如,

void f() noexcept {
}

void g() noexcept(noexcept(f)) { // g() 是否是 noexcept 取決於 f()
    f();
}

其中 noexcept(f) 返回 true,則上式就相當於 void g() noexcept(true)

解構函式預設都是 noexcept 的。C++ 11 標準規定,類的解構函式都是 noexcept 的,除非顯示指定為 noexcept(false)

class A {
  public:
    A() {}
    ~A() {} // 預設不丟擲異常
};

class B {
  public:
    B() {}
    ~B() noexcept(false) {} // 可能會丟擲異常
};

在為某個異常進行棧展開的時候,會依次呼叫當前作用域下每個區域性物件的解構函式,如果這個時候解構函式又丟擲自己的未經處理的另一個異常,將會導致 std::terminate。所以解構函式應該從不丟擲異常。

顯示指定異常說明符的益處

  1. 語義

    從語義上,noexcept 對於程式設計師之間的交流是有利的,就像 const 限定符一樣。

  2. 顯示指定 noexcept 的函式,編譯器會進行優化

    因為在呼叫 noexcept 函式時不需要記錄 exception handler,所以編譯器可以生成更高效的二進位制碼(編譯器是否優化不一定,但理論上 noexcept 給了編譯器更多優化的機會)。另外編譯器在編譯一個 noexcept(false) 的函式時可能會生成很多冗餘的程式碼,這些程式碼雖然只在出錯的時候執行,但還是會對 Instruction Cache 造成影響,進而影響程式整體的效能。

  3. 容器操作針對 std::move 的優化

    舉個例子,一個 std::vector<T>,若要進行 reserve 操作,一個可能的情況是,需要重新分配記憶體,並把之前原有的資料拷貝(copy)過去,但如果 T 的移動建構函式是 noexcept 的,則可以移動(move)過去,大大地提高了效率。

    #include <iostream>
    #include <vector>
    
    using namespace std;
    
    class A {
      public:
        A(int value) {
        }
    
        A(const A &other) {
            std::cout << "copy constructor\n";
        }
    
        A(A &&other) noexcept {
            std::cout << "move constructor\n";
        }
    };
    
    int main() {
        std::vector<A> a;
        a.emplace_back(1);
        a.emplace_back(2);
    
        return 0;
    }
    

    上述程式碼可能輸出:

    move constructor
    

    但如果把移動建構函式的 noexcept 說明符去掉,則會輸出:

    copy constructor
    

    你可能會問,為什麼在移動建構函式是 noexcept 時才能使用?這是因為它執行的是 Strong Exception Guarantee,發生異常時需要還原,也就是說,你呼叫它之前是什麼樣,丟擲異常後,你就得恢復成啥樣。但對於移動建構函式發生異常,是很難恢復回去的,如果在恢復移動(move)的時候發生異常了呢?但複製建構函式就不同了,它發生異常直接呼叫它的解構函式就行了。

使用建議

我們所編寫的函式預設都不使用,只有遇到以下的情況你再思考是否需要使用,

  1. 解構函式

    這不用多說,必須也應該為 noexcept。

  2. 建構函式(普通、複製、移動),賦值運算子過載函式

    儘量讓上面的函式都是 noexcept,這可能會給你的程式碼帶來一定的執行期執行效率。

  3. 還有那些你可以 100% 保證不會 throw 的函式

    比如像是 int,pointer 這類的 getter,setter 都可以用 noexcept。因為不可能出錯。但請一定要注意,不能保證的地方請不要用,否則會害人害己!切記!如果你還是不知道該在哪裡用,可以看下準標準庫 Boost 的原始碼,全域性搜尋 BOOST_NOEXCEPT,你就大概明白了。

參考

相關文章