最近學習和寫了一個
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(),這個函數可能會引發異常。再如,編寫的一個函數調用了老式的一個庫函數,此時不會引發異常,但是老式庫更新以后這個函數卻引發了異常。
其實,不僅僅如此,
-
異常規范的檢查是在運行期而不是編譯期,因此程序員不能保證所有異常都得到了 catch 處理。
-
由於第一點的存在,編譯器需要生成額外的代碼,在一定程度上妨礙了優化。
-
模板函數中無法使用。比如下面的代碼,
template<class T> void func(T k) { T x(k); x.do_something(); }
賦值函數、拷貝構造函數和 do_something() 都有可能拋出異常,這取決於類型 T 的實現,所以無法給函數 func 指定異常類型。
-
實際使用中,我們只需要兩種異常說明:拋異常和不拋異常,也就是 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(); // 和上式一個意思,若不顯示說明,默認是會拋出異常(除了析構函數,詳見下面)
對於一個函數而言,
- noexcept 說明符要么出現在該函數的所有聲明語句和定義語句,要么一次也不出現。
- 函數指針及該指針所指的函數必須具有一致的異常說明。
- 在 typedef 或類型別名中則不能出現 noexcept。
- 在成員函數中,noexcept 說明符需要跟在 const 及引用限定符之后,而在 final、override 或虛函數的 =0 之前。
- 如果一個虛函數承諾了它不會拋出異常,則后續派生的虛函數也必須做出同樣的承諾;與之相反,如果基類的虛函數允許拋出異常,則派生類的虛函數既可以拋出異常,也可以不允許拋出異常。
需要注意的是,編譯器不會檢查帶有 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
。所以析構函數應該從不拋出異常。
顯示指定異常說明符的益處
-
語義
從語義上,noexcept 對於程序員之間的交流是有利的,就像 const 限定符一樣。
-
顯示指定 noexcept 的函數,編譯器會進行優化
因為在調用 noexcept 函數時不需要記錄 exception handler,所以編譯器可以生成更高效的二進制碼(編譯器是否優化不一定,但理論上 noexcept 給了編譯器更多優化的機會)。另外編譯器在編譯一個
noexcept(false)
的函數時可能會生成很多冗余的代碼,這些代碼雖然只在出錯的時候執行,但還是會對 Instruction Cache 造成影響,進而影響程序整體的性能。 -
容器操作針對
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)的時候發生異常了呢?但復制構造函數就不同了,它發生異常直接調用它的析構函數就行了。
使用建議
我們所編寫的函數默認都不使用,只有遇到以下的情況你再思考是否需要使用,
-
析構函數
這不用多說,必須也應該為 noexcept。
-
構造函數(普通、復制、移動),賦值運算符重載函數
盡量讓上面的函數都是 noexcept,這可能會給你的代碼帶來一定的運行期執行效率。
-
還有那些你可以 100% 保證不會 throw 的函數
比如像是 int,pointer 這類的 getter,setter 都可以用 noexcept。因為不可能出錯。但請一定要注意,不能保證的地方請不要用,否則會害人害己!切記!如果你還是不知道該在哪里用,可以看下准標准庫 Boost 的源碼,全局搜索
BOOST_NOEXCEPT
,你就大概明白了。