我們在對 vector 做 push 操作的時候,或者對某個指針做 new 操作的時候,如果沒有做異常處理,一旦系統內存不夠用了,程序是會被 terminate 掉的。這就要求我們熟悉 C++ 異常,保證日常開發中能正確處理它。本文主要介紹C++ 異常機制的底層原理與實際應用,通俗易懂,快來讀一讀吧。
以下是正文
C++異常機制概述
異常處理是C++的一項語言機制,用于在程序中處理異常事件。異常事件在 C++ 中表示為 異常對象 。異常事件發生時,程序使用throw關鍵字拋出異常表達式,拋出點稱為異常出現點,由操作系統為程序設置當前異常對象,然后執行程序的當前異常處理代碼塊,在包含了異常出現點的最內層的 try 塊,依次匹配catch語句中的異常對象(只進行類型匹配,catch參數有時在 catch 語句中并不會使用到)。若匹配成功,則執行 catch 塊內的異常處理語句,然后接著執行 try.。.catch.。. 塊之后的代碼。如果在當前的 try.。.catch.。. 塊內找不到 匹配 該異常對象的catch語句,則由更外層的 try.。.catch.。. 塊來處理該異常;如果當前函數內所有的 try.。.catch.。. 塊都不能匹配該異常,則遞歸回退到調用棧的上一層去處理該異常。如果一直退到主函數 main() 都不能處理該異常,則調用系統函數 terminate() 終止程序。
一個最簡單的 try.。.catch.。. 的例子如下所示。我們有個程序用來記班級學生考試成績,考試成績分數的范圍在 0-100 之間,不在此范圍內視為數據異常:
int main(){ int score=0; while (cin 》》 score) { try { if (score 》 100 || score 《 0) { throw score; } //將分數寫入文件或進行其他操作 } catch (int score) { cerr 《《 “你輸入的分數數值有問題,請重新輸入!”; continue; } }}
throw 關鍵字
在上面這個示例中, throw 是個關鍵字,與拋出表達式構成了 throw 語句。 其語法為:
throw 表達式;
throw 語句必須包含在 try 塊中,也可以是被包含在調用棧的外層函數的 try 塊中,如:
//示例代碼:throw包含在外層函數的try塊中void registerScore(int score){ if (score 》 100 || score 《 0) throw score; //throw語句被包含在外層main的try語句塊中 //將分數寫入文件或進行其他操作}int main(){ int score=0; while (cin 》》 score) { try { registerScore(score); } catch (int score) { cerr 《《 “你輸入的分數數值有問題,請重新輸入!”; continue; } }}
執行 throw 語句時,throw 表達式將作為對象被復制構造為一個新的對象,稱為異常對象。 異常對象放在內存的特殊位置,該位置既不是棧也不是堆,在 window 上是放在線程信息塊 TIB 中。 這個構造出來的新對象與本級的 try 所對應的 catch 語句進行 類型匹配 ,類型匹配的原則在下面介紹。
在本例中,依據 score 構造出來的對象類型為 int,與 catch(int score) 匹配上,程序控制權轉交到 catch 的語句塊,進行異常處理代碼的執行。如果在本函數內與 catch 語句的類型匹配不成功,則在調用棧的外層函數繼續匹配,如此遞歸執行直到匹配上 catch 語句,或者直到 main 函數都沒匹配上而調用系統函數 terminate() 終止程序。當執行一個 throw 語句時,跟在 throw 語句之后的語句將不再被執行,throw 語句的語法有點類似于 return,因此導致在調用棧上的函數可能提早退出。
異常對象
異常對象是一種特殊的對象,編譯器依據異常拋出表達式復制構造異常對象,這要求拋出異常表達式不能是一個不完全類型(一個類型在聲明之后定義之前為一個不完全類型。不完全類型意味著該類型沒有完整的數據與操作描述),而且可以進行復制構造,這就要求異常拋出表達式的復制構造函數(或移動構造函數)、析構函數不能是私有的。
異常對象不同于函數的局部對象,局部對象在函數調用結束后就被自動銷毀,而異常對象將駐留在所有可能被激活的 catch 語句都能訪問到的內存空間中,也即上文所說的 TIB。當異常對象與 catch 語句成功匹配上后,在該 catch 語句的結束處被自動析構。在函數中返回局部變量的引用或指針幾乎肯定會造成錯誤,同樣的道理,在 throw 語句中拋出局部變量的指針或引用也幾乎是錯誤的行為。如果指針所指向的變量在執行 catch 語句時已經被銷毀,對指針進行解引用將發生意想不到的后果。throw 出一個表達式時,該表達式的靜態編譯類型將決定異常對象的類型。所以當 throw 出的是基類指針的解引用,而該指針所指向的實際對象是派生類對象,此時將發生派生類對象切割。除了拋出用戶自定義的類型外,C++ 標準庫定義了一組類,用戶報告標準庫函數遇到的問題。這些標準庫異常類只定義了幾種運算,包括創建或拷貝異常類型對象,以及為異常類型的對象賦值。標準異常類描述頭文件
exception最通用的異常類,只報告異常的發生而不提供任何額外的信息exception
runtime_error只有在運行時才能檢測出的錯誤stdexcept
rang_error運行時錯誤:產生了超出有意義值域范圍的結果stdexcept
overflow_error運行時錯誤:計算上溢stdexcept
underflow_error運行時錯誤:計算下溢stdexcept
logic_error程序邏輯錯誤stdexcept
domain_error邏輯錯誤:參數對應的結果值不存在stdexcept
invalid_argument邏輯錯誤:無效參數stdexcept
length_error邏輯錯誤:試圖創建一個超出該類型最大長度的對象stdexcept
out_of_range邏輯錯誤:使用一個超出有效范圍的值stdexcept
bad_alloc內存動態分配錯誤new
bad_castdynamic_cast類型轉換出錯type_info
catch 關鍵字
catch語句匹配被拋出的異常對象。如果 catch 語句的參數是引用類型,則該參數可直接作用于異常對象,即參數的改變也會改變異常對象,而且在 catch 中重新拋出異常時會繼續傳遞這種改變。如果 catch 參數是傳值的,則復制構函數將依據異常對象來構造catch 參數對象。在該 catch 語句結束的時候,先析構 catch 參數對象,然后再析構異常對象。
在進行異常對象的匹配時,編譯器不會做任何的隱式類型轉換或類型提升。除了以下幾種情況外,異常對象的類型必須與 catch 語句的聲明類型完全匹配:
允許從非常量到常量的類型轉換。
允許派生類到基類的類型轉換。
數組被轉換成指向數組(元素)類型的指針。
函數被轉換成指向函數類型的指針。
尋找 catch 語句的過程中,匹配上的未必是類型完全匹配那項,而在是最靠前的第一個匹配上的 catch 語句(我稱它為最先匹配原則)。所以,派生類的處理代碼 catch 語句應該放在基類的處理 catch 語句之前,否則先匹配上的總是參數類型為基類的 catch 語句,而能夠精確匹配的 catch 語句卻不能夠被匹配上。在 catch 塊中,如果在當前函數內無法解決異常,可以繼續向外層拋出異常,讓外層catch 異常處理塊接著處理。此時可以使用不帶表達式的 throw 語句將捕獲的異常重新拋出:
catch(type x){ //做了一部分處理 throw;}
被重新拋出的異常對象為保存在 TIB 中的那個異常對象,與 catch 的參數對象沒有關系,若 catch 參數對象是引用類型,可能在 catch 語句內已經對異常對象進行了修改,那么重新拋出的是修改后的異常對象; 若catch參數對象是非引用類型,則重新拋出的異常對象并沒有受到修改。使用 catch(。..){} 可以捕獲所有類型的異常,根據最先匹配原則,catch(。..){} 應該放在所有 catch 語句的最后面,否則無法讓其他可以精確匹配的 catch 語句得到匹配。通常在catch(。..){} 語句中執行當前可以做的處理,然后再重新拋出異常。注意,catch 中重新拋出的異常只能被外層的 catch 語句捕獲。
棧展開、RAII
其實棧展開已經在前面說過,就是從異常拋出點一路向外層函數尋找匹配的 catch 語句的過程,尋找結束于某個匹配的 catch 語句或標準庫函數 terminate。這里重點要說的是棧展開過程中對局部變量的銷毀問題。我們知道,在函數調用結束時,函數的局部變量會被系統自動銷毀,類似的,throw 可能會導致調用鏈上的語句塊提前退出,此時,語句塊中的局部變量將按照構成生成順序的逆序,依次調用析構函數進行對象的銷毀。例如下面這個例子:
//一個沒有任何意義的類class A{public: A() :a(0){ cout 《《 “A默認構造函數” 《《 endl; } A(const A& rsh){ cout 《《 “A復制構造函數” 《《 endl; } ~A(){ cout 《《 “A析構函數” 《《 endl; }private: int a;};int main(){ try { A a ; throw a; } catch (A a) { ; } return 0;}
程序將輸出:
定義變量 a 時調用了默認構造函數,使用 a 初始化異常變量時調用了復制構造函數,使用異常變量復制構造 catch 參數對象時同樣調用了復制構造函數。三個構造對應三個析構,也即 try 語句塊中局部變量 a 自動被析構了。然而,如果 a 是在自由存儲區上分配的內存時:
int main(){ try { A * a= new A; throw *a; } catch (A a) { ; } getchar(); return 0;}
程序運行結果:
同樣的三次構造,卻只調用了兩次的析構函數!說明 a 的內存在發生異常時并沒有被釋放掉,發生了內存泄漏。
RAII機制有助于解決這個問題,RAII(Resource acquisition is initialization,資源獲取即初始化)。它的思想是以對象管理資源。為了更為方便、魯棒地釋放已獲取的資源,避免資源死鎖,一個辦法是把資源數據用對象封裝起來。程序發生異常,執行棧展開時,封裝了資源的對象會被自動調用其析構函數以釋放資源。C++ 中的智能指針便符合RAII。關于這個問題詳細可以看《Effective C++》條款13.異常機制與構造函數
異常機制的一個合理的使用是在構造函數中。構造函數沒有返回值,所以應該使用異常機制來報告發生的問題。更重要的是,構造函數拋出異常表明構造函數還沒有執行完,其對應的析構函數不會自動被調用,因此析構函數應該先析構所有所有已初始化的基對象,成員對象,再拋出異常。
C++ 類構造函數初始化列表的異常機制,稱為 function-try block。一般形式為:
myClass::myClass(type1 pa1) try: _myClass_val (初始化值){ /*構造函數的函數體 */} catch ( exception& err ){ /* 構造函數的異常處理部分 */};
異常機制與析構函數C++ 不禁止析構函數向外界拋出異常,但析構函數被期望不向外界函數拋出異常。析構函數中向函數外拋出異常,將直接調用 terminator() 系統函數終止程序。如果一個析構函數內部拋出了異常,就應該在析構函數的內部捕獲并處理該異常,不能讓異常被拋出析構函數之外??梢匀绱颂幚恚?/p>
若析構函數拋出異常,調用 std::abort() 來終止程序。
在析構函數中 catch 捕獲異常并作處理。
關于具體細節,有興趣可以看《Effective C++》條款08:別讓異常逃離析構函數。
noexcept修飾符與noexcept操作符
noexcept 修飾符是 C++11 新提供的異常說明符,用于聲明一個函數不會拋出異常。編譯器能夠針對不拋出異常的函數進行優化,另一個顯而易見的好處是你明確了某個函數不會拋出異常,別人調用你的函數時就知道不用針對這個函數進行異常捕獲。在 C++98中關于異常處理的程序中你可能會看到這樣的代碼:
void func() throw(int ,double ) {。..}void func() throw(){。..}
這是 throw 作為函數異常說明,前者表示 func()這個函數可能會拋出 int 或 double 類型的異常,后者表示 func() 函數不會拋出異常。事實上前者很少被使用,在 C++11 這種做法已經被摒棄,而后者則被 C++11 的 noexcept 異常聲明所代替:
void func() noexcept {。..}//等價于void func() throw(){。..}
在 C++11 中,編譯器并不會在編譯期檢查函數的 noexcept 聲明,因此,被聲明為noexcept 的函數若攜帶異常拋出語句還是可以通過編譯的。 在函數運行時若拋出了異常,編譯器可以選擇直接調用 terminate() 函數來終結程序的運行,因此,noexcept 的一個作用是 阻止異常的傳播,提高安全性 。上面一點提到了,我們不能讓異常逃出析構函數,因為那將導致程序的不明確行為或直接終止程序。實際上出于安全的考慮,C++11 標準中讓類的析構函數默認也是 noexcept 的。同樣是為了安全性的考慮,經常被析構函數用于釋放資源的 delete 函數,C++11 也默認將其設置為 noexcept。
noexcept也可以接受一個常量表達式作為參數,例如:
void func() noexcept(常量表達式);
常量表達式的結果會被轉換成 bool 類型,noexcept(bool) 表示函數不會拋出異常,noexcept(false) 則表示函數有可能會拋出異常。 故若你想更改析構函數默認的 noexcept聲明,可以顯式地加上 noexcept(false) 聲明,但這并不會帶給你什么好處。
異常處理的性能分析
異常處理機制的主要環節是運行期類型檢查。當拋出一個異常時,必須確定異常是不是從 try 塊中拋出。異常處理機制為了完善異常和它的處理器之間的匹配,需要存儲每個異常對象的類型信息以及 catch 語句的額外信息。由于異常對象可以是任何類型(如用戶自定義類型),并且也可以是多態的,獲取其動態類型必須要使用運行時類型檢查(RTTI),此外還需要運行期代碼信息和關于每個函數的結構。當異常拋出點所在函數無法解決異常時,異常對象沿著調用鏈被傳遞出去,程序的控制權也發生了轉移。轉移的過程中為了將異常對象的信息攜帶到程序執行處(如對異常對象的復制構造或者 catch 參數的析構),在時間和空間上都要付出一定的代價,本身也有不安全性,特別是異常對象是個復雜的類的時候。異常處理技術在不同平臺以及編譯器下的實現方式都不同,但都會給程序增加額外的負擔,當異常處理被關閉時,額外的數據結構、查找表、一些附加的代碼都不會被生成,正是因為如此,對于明確不拋出異常的函數,我們需要使用 noexcept 進行聲明。
-
內存
+關注
關注
8文章
3047瀏覽量
74207 -
函數
+關注
關注
3文章
4344瀏覽量
62864 -
C++
+關注
關注
22文章
2114瀏覽量
73780
發布評論請先 登錄
相關推薦
評論