異常機制概述
異常處理是C++的一項語言機制,用於在程序中處理異常事件。異常事件在C++中表示為異常對象(主要針對類來說)。
1. 基本概述
首先try塊試圖運行代碼,若該代碼出現異常,這時異常事件發生,程序使用throw關鍵字拋出異常表達式,拋出點稱為異常出現點,由操作系統為程序設置當前異常對象,然后執行程序的當前異常處理代碼塊,依次匹配與發生異常的try塊相對應的catch語句中的異常對象(只進行類型匹配,catch參數有時在catch語句中並不會使用到,比如throw數值時)。
若匹配成功,則執行catch塊內的異常處理語句,然后接着執行try...catch...塊之后的代碼。如果在當前的try...catch...塊內找不到匹配該異常對象的catch語句,則由更外層的try...catch...塊來處理該異常;如果當前函數內所有的try...catch...塊都不能匹配該異常,則遞歸回退到調用棧的上一層去處理該異常。如果一直退到主函數main()都不能處理該異常,則調用系統函數terminate()終止程序。
1 int myDevide(int a, int b){ 2 if (b == 0){ 3 4 //C++處理異常 5 //throw 1; 6 throw 1.1; 7 8 //C語言返回異常 9 //return -1; 當a = -b會誤報錯 10 } 11 return a / b; 12 } 13 14 void test02(){ 15 int a = 10; 16 int b = 0; 17 18 try{ //試一試 19 myDevide(a, b); 20 } 21 catch (int){ //捕獲異常, 這種情況的catch參數在代碼塊中不會使用到 22 cout << "int類型異常" << endl; 23 } 24 catch (double){ 25 throw; //如果當前代碼塊不想處理該異常,可以通過throw向上拋出,當前代碼塊不能處理,也會向上拋出,調用上一層處理 26 cout << "double類型異常" << endl; 27 } 28 catch (...){ 29 cout << "其他類型異常" << endl; 30 } 31 } 32 33 int main(){ 34 35 try{ 36 test02(); 37 } 38 catch (char){ //上層異常處理,如果這里仍然不處理,會調用terminate函數,使程序中斷 39 cout << "" << endl; 40 } 41 42 43 system("pause"); 44 return 0; 45 }
2. 自定義異常類
自定義的異常類進行異常拋出,這時會涉及到異常對象的復制構造,也會出現對象的構造、析構、銷毀等一系列問題,這部分會在棧展開部分討論。
1 class myException{ 2 public: 3 void printError(){ 4 cout << "自定義類拋出異常" << endl; 5 } 6 }; 7 8 void test02(){ 9 10 int a = 10; 11 int b = 0; 12 13 myException e; 14 15 try{ //試一試 16 if (b == 0){ 17 throw e; 18 } 19 } 20 catch (myException e){ //捕獲異常, 這種情況的catch參數會在代碼塊中使用到 21 e.printError(); 22 } 23 }
throw關鍵字
throw語句必須包含在try塊中,也可以是被包含在調用棧的外層函數的try塊中,如上述代碼中的myDevide()函數。
執行throw語句時,throw的表達式將作為對象被復制構造為一個新的對象,稱為異常對象。異常對象放在內存的特殊位置,該位置既不是棧也不是堆,在window上是放在線程信息塊TIB中。這個構造出來的新對象與本級的try所對應的catch語句進行類型匹配,類型匹配的原則在下面介紹。
在本例中,依據score構造出來的對象類型為int,與catch(int score)匹配上,程序控制權轉交到catch的語句塊,進行異常處理代碼的執行。如果在本函數內與catch語句的類型匹配不成功,則在調用棧的外層函數繼續匹配,如此遞歸執行直到匹配上catch語句,或者直到main函數都沒匹配上而調用系統函數terminate()終止程序。
注意:當執行一個throw語句時,跟在throw語句之后的語句將不再被執行,throw語句的語法有點類似於return,因此導致在調用棧上的函數可能提早退出。
catch關鍵字
catch語句匹配被拋出的異常對象。
1. catch語句參數
如果catch語句的參數是引用類型,則該參數可直接作用於異常對象,即參數的改變也會改變異常對象,而且在catch中重新拋出異常時會繼續傳遞這種改變。如果catch參數是傳值的,則復制構函數將依據異常對象來構造catch參數對象。在該catch語句結束的時候,先析構catch參數對象,然后再析構異常對象。
在進行異常對象的匹配時,編譯器不會做任何的隱式類型轉換或類型提升。除了以下幾種情況外,異常對象的類型必須與catch語句的聲明類型完全匹配:
(1)允許從非常量到常量的類型轉換(const,非const互轉);
(2)允許派生類到基類的類型轉換;
(3)數組被轉換成指向數組(元素)類型的指針;
(4)函數被轉換成指向函數類型的指針;
1 class myException{ 2 public: 3 void printError(){ 4 cout << "自定義類拋出異常" << endl; 5 } 6 }; 7 8 class sonException:public myException{ 9 public: 10 void printError_(){ 11 cout << "son自定義類拋出異常" << endl; 12 } 13 }; 14 15 void test02(){ 16 17 int a = 10; 18 int b = 0; 19 20 sonException e; 21 22 try{ //試一試 23 if (b == 0){ 24 throw e; //拋出的為子類異常,但父類捕獲在子類捕獲前,因此顯示的為父類捕獲 25 } 26 } 27 catch (myException e){ //父類捕獲異常 28 e.printError(); 29 } 30 catch (sonException e0){ //子類捕獲異常 31 e0.printError_(); 32 } 33 }
尋找catch語句的過程中,匹配上的未必是類型完全匹配那項,而在是最靠前的第一個匹配上的catch語句(我稱它為最先匹配原則)。
注意:(1)派生類的處理代碼catch語句應該放在基類的處理catch語句之前,否則先匹配上的總是參數類型為基類的catch語句,而能夠精確匹配的catch語句卻不能夠被匹配上;
(2)使用catch(...){}可以捕獲所有類型的異常,根據最先匹配原則,catch(...){}應該放在所有catch語句的最后面,否則無法讓其他可以精確匹配的catch語句得到匹配。
2. catch拋出異常
在catch塊中,如果在當前函數內無法解決異常,可以繼續向外層拋出異常,讓外層catch異常處理塊接着處理。此時可以使用不帶表達式的throw語句將捕獲的異常重新拋出。
被重新拋出的異常對象為保存在TIB中的那個異常對象,與catch的參數對象沒有關系,若catch參數對象是引用類型,可能在catch語句內已經對異常對象進行了修改,那么重新拋出的是修改后的異常對象;若catch參數對象是非引用類型,則重新拋出的異常對象並沒有受到修改。
棧解旋、異常變量生命周期、RAII
1. 棧解旋
棧解旋,從try開始,到throw拋出異常之前,所有在棧上的對象都會被釋放。
1 class Person 2 { 3 public: 4 Person() :m_A(0){ cout << "調用默認構造函數" << endl; } 5 Person(const Person&){ cout << "調用復制構造函數" << endl; } 6 ~Person(){ cout << "調用析構函數" << endl; } 7 private: 8 int m_A; 9 }; 10 void test02() 11 { 12 try 13 { 14 Person p; 15 throw 1; 16 } 17 catch (int) 18 { 19 cout << "int類異常"<< endl; 20 } 21 }
上述程序代碼輸出:
2. 異常變量生命周期
我們知道,在函數調用結束時,函數的局部變量會被系統自動銷毀。另外,由於棧解旋,throw可能會導致調用鏈上的語句塊提前退出,此時,語句塊中的局部變量將按照構成生成順序的逆序,依次調用析構函數進行對象的銷毀,為此我們討論一下異常變量的生命周期。
(1)如果catch參數為Person p,會調用拷貝構造函數,多一分開銷。
1 class Person 2 { 3 public: 4 Person() :m_A(0){ cout << "調用默認構造函數" << endl; } 5 Person(const Person&){ cout << "調用復制構造函數" << endl; } 6 ~Person(){ cout << "調用析構函數" << endl; } 7 private: 8 int m_A; 9 }; 10 11 12 void test020() 13 { 14 try 15 { 16 Person p; //調用默認構造,throw前析構掉 17 throw p; //throw表達式p作為對象被復制構造為一個新的對象,又叫初始化異常對象 18 } 19 catch (Person p) //類對象作形參,調用拷貝構造函數,這里異常變量復制構造catch參數對象 20 { 21 cout << "Person類異常"<< endl; 22 } 23 }
(2)若catch參數為Person &p,則只有一份數據
1 void test021() 2 { 3 try 4 { 5 Person p; //調用默認構造,throw前析構掉 6 throw p; //throw表達式p作為對象被復制構造為一個新的對象,又叫初始化異常對象 7 } 8 catch (Person &p) //這里是引用,直接是異常對象 9 { 10 p.test(); 11 cout << "Person類異常" << endl; 12 } 13 }
(3)如果catch參數為Person *p,若在棧上開辟則提前釋放掉,若在自由存儲區開辟(new)則需手動釋放。
1 void test022() 2 { 3 try 4 { 5 Person *p = NULL; //棧上開辟,沒有調用任何構造函數和析構函數,throw自動銷毀變量p,同時還有對應開辟的內存 6 throw p; //僅僅是復制地址 7 } 8 catch (Person *p) //棧上創建指針,匹配上述地址 9 { 10 p->test(); //雖然這里沒報錯,但是地址對應的內存已經銷毀掉,這里相當於野指針 11 cout << "Person類異常" << endl; 12 } 13 }
1 void test023() 2 { 3 try 4 { 5 Person *p = new Person(); //自由存儲區開辟,不會自動析構,要手動釋放,調用默認構造 6 throw p; //throw地址時,不會調用構造和析構函數 7 } 8 catch (Person *p) //棧上創建指針,匹配上述地址 9 { 10 p->test(); 11 cout << "Person類異常" << endl; 12 delete p; //自覺釋放對象 13 } 14 }
RAII機制有助於解決這個問題,RAII(Resource acquisition is initialization,資源獲取即初始化)。它的思想是以對象管理資源。為了更為方便、魯棒地釋放已獲取的資源,避免資源死鎖,一個辦法是把資源數據用對象封裝起來。程序發生異常,執行棧展開時,封裝了資源的對象會被自動調用其析構函數以釋放資源。C++中的智能指針便符合RAII。關於這個問題詳細可以看《Effective C++》條款13.
異常多態引出標准庫異常
利用多態實現printError同一個接口的調用,拋出不同的錯誤對象顯示不同的錯誤提示。
1 class myException{ 2 public: 3 void printError(){ 4 cout << "自定義類拋出異常" << endl; 5 } 6 }; 7 8 class sonException:public myException{ 9 public: 10 void printError_(){ 11 cout << "son自定義類拋出異常" << endl; 12 } 13 }; 14 15 void test02(){ 16 17 int a = 10; 18 int b = 0; 19 20 sonException e; 21 22 try{ 23 if (b == 0){ 24 throw e; 25 } 26 } 27 catch (sonException e0){ //子類捕獲異常 28 e0.printError_(); 29 } 30 catch (myException e){ //父類捕獲異常 31 e.printError(); 32 } 33 }
使用標准庫異常
標准庫異常頭文件: #include <stdexcept> ,拋出異常: throw out_of_range("越界"); ,異常捕獲: catch(out_of_range& e){ e.what(); }
其中,標准庫中的異常函數有很多,全部繼承於基類exception,其中的what方法是將我們拋出異常的輸入字符串輸出。
異常機制與構造函數
構造函數可以通過函數體進行對象初始化,也可以通過初始化列表進行對象初始化。
(1)對象生命周期何時開始
一個構造函數成功執行完畢,並成功返回之時,也就是構造函數成功執行到函數體尾,沒有發生異常。
(2)對象生命周期何時結束
當一個對像的析構函數開始執行,也就是達到析構函數開始指出,這里暫且不討論析構函數是否發生異常,只要進入析構函數體,該對象生命周期就已經結束
(3)在生命周期開始之前,與生命結束之后,對象處於什么狀態
這時候“對象”已不是對象。理論上“它”根本就不存在
(4)接着第三個答案,如果構造函數異常,對象處於什么狀態?
構造函數異常,即構造函數甚至沒有到達函數體的尾部,即對象的生命周期還沒有開始,所以他根本不是一個的對象,或者說它什么都不是,所以更不會執行析構函數了。
1. 構造函數可以拋出異常嗎,有什么問題?
構造函數中應該避免拋出異常。
(1)構造函數中拋出異常后,對象的析構函數將不會被執行;(2)構造函數拋出異常時,本應該在析構函數中被delete的對象沒有被delete,會導致內存泄露;(3)當對象發生部分構造時,已經構造完畢的子對象(非動態分配)將會逆序地被析構。
注意:構造函數中如果發生異常,必須在構造函數拋出異常之前,把系統資源釋放掉,以防止內存泄露。
對於函數體初始化對象的內存泄漏可以重新設計構造函數,捕獲所有異常,釋放掉申請的所有內存空間,或者使用智能指針;如果利用初始化列表初始化對象的內存泄漏請看下一小節。
1 class C{ 2 public: 3 //利用函數體初始化對象,發生異常時,析構函數不能調用,無法釋放掉動態分配的內存 4 //C(){ 5 // this->m_Data = new char[1]; 6 // cout << "construct C default" << endl; 7 // throw 1.1; 8 //} 9 10 11 //對函數體中的異常進行捕獲,處理,必須將動態分配內存先釋放掉 12 C(){ 13 try{ 14 this->m_Data = new char[1]; 15 cout << "調用C類構造函數" << endl; 16 throw 1.1; //故意在默認構造函數中拋出異常 17 } 18 catch (double){ 19 if (this->m_Data != NULL){ 20 delete this->m_Data; 21 this->m_Data = NULL; 22 } 23 24 cout << "C類構造異常" << endl; 25 } 26 } 27 28 ~C(){ 29 cout << "調用C類析構函數" << endl; 30 if (this->m_Data != NULL){ 31 delete this->m_Data; 32 this->m_Data = NULL; 33 } 34 } 35 36 char *m_Data; 37 }; 38 39 void test01(){ 40 C c; 41 }
2. 初始化列表的異常怎么捕獲?
初始化列表構造,當初始化列表出現異常時,程序還未進入函數體,因此函數體中的try-catch不能執行,catch也無法處理異常。可以通過函數try塊解決該問題。
注意:構造函數的function try block處理初始化列表異常,主要用於轉化(translate)從基類或成員子對象的構造函數拋出異常。
1 //B作為A的成員對象, B中構造函數拋出異常 2 //此時A中的構造函數和B構造函數為同一層,最外層為A a;語句 3 //如果B構造函數單獨處理異常,則可以不向上拋出異常 4 //如果A構造函數單獨處理異常,采用函數try塊,必須向上拋出 5 //如果采用普通try塊可以不用向上拋出,將指針先置空,再采用普通try塊 6 class B{ 7 public: 8 B(){ 9 try{ 10 cout << "construct B default" << endl; 11 throw 1.1; //故意在默認構造函數中拋出異常 12 } 13 catch (double){ cout << "B自己捕獲異常" << endl; } 14 } 15 16 B(int num){ 17 age = num; 18 cout << "constructor B ,age =" << num << endl; 19 } 20 ~B(){ 21 cout << "destructor B ,age=" << age << endl; 22 } 23 private: 24 int age; 25 }; 26 27 class A{ 28 public: 29 30 //指針置空,普通try塊 31 A() :_data(new char[1]), b(B(10)), bp(NULL){ 32 33 try{ 34 this->bp = new B(); 35 cout << "construct A " << endl; 36 *_data = '\0'; 37 38 } 39 catch (double){ 40 if(_data != NULL){ 41 cout << "釋放_data" << endl; 42 delete[] _data; 43 _data = NULL; 44 } 45 cout << "B構造異常" << endl; 46 //throw; 47 } 48 49 50 } 51 52 //函數try塊 53 //A() try :_data(new char[1]), b(B(10)), bp(new B()){ 54 // cout << "construct A " << endl; 55 // 56 // *_data = '\0'; 57 // 58 //} 59 //catch (double){ 60 // if (_data != NULL){ 61 // cout << "釋放_data" << endl; 62 // delete[] _data; 63 // _data = NULL; 64 // } 65 // 66 // cout << "B構造異常" << endl; 67 // throw; 68 //} 69 70 ~A(){ 71 cout << "destructor A" << endl; 72 delete[] _data; 73 delete bp; 74 75 } 76 private: 77 char *_data; 78 B b; 79 B *bp; 80 }; 81 82 83 int main(){ 84 85 try{ A a; } 86 catch (double){ 87 cout << "B" << endl; 88 } 89 system("pause"); 90 return 0; 91 }
注意:函數try塊中的try出現在表示構造函數初始值列表的冒號以及表示構造函數體的花括號之前,與這個try關聯的catch既能處理構造函數體拋出的異常,也能處理成員初始化列表拋出的異常。如果函數try塊中單獨處理異常,則需向上拋出異常,單獨處理還是會調用terminate函數終止。
異常機制與析構函數
1. 析構函數可以拋出異常嗎,有什么問題?
C++標准指明析構函數不禁止、不應該拋出異常。如果對象在運行期間出現了異常,C++異常處理模型有責任清除那些由於出現異常所導致的已經失效了的對象,並釋放對象原來所分配的資源, 這就是調用這些對象的析構函數來完成釋放資源的任務,所以從這個意義上說,析構函數已經變成了異常處理的一部分。
(1)其他正常,僅析構函數異常。 如果析構函數拋出異常,則異常點之后的程序不會執行,如果析構函數在異常點之后執行了某些必要的動作比如釋放某些資源,則這些動作不會執行,會造成諸如資源泄漏的問題。
(2)其他異常,且析構函數異常。 通常異常發生時,c++的機制會調用已經構造對象的析構函數來釋放資源,此時若析構函數本身也拋出異常,則前一個異常尚未處理,又有新的異常,會造成程序崩潰的問題。
2. 析構函數如何處理異常?
(1)若析構函數拋出異常,調用std::abort()來終止程序;
(2)在析構函數中catch捕獲異常並作處理,吞下異常;
(3)如果客戶需要對某個操作函數運次期間拋出的異常做出反應,class應該提供普通函數執行該操作,而非在析構函數中。
noexcept修飾符和noexcept操作符