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


最近學習和寫了一個 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,你就大概明白了。

參考


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM