正文
異常處理是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語句必須包含在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++標准庫定義了一組類,用戶報告標准庫函數遇到的問題。這些標准庫異常類只定義了幾種運算,包括創建或拷貝異常類型對象,以及為異常類型的對象賦值。
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語句捕獲。
回到頂部其實棧展開已經在前面說過,就是從異常拋出點一路向外層函數尋找匹配的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()系統函數終止程序。如果一個析構函數內部拋出了異常,就應該在析構函數的內部捕獲並處理該異常,不能讓異常被拋出析構函數之外。可以如此處理:
關於具體細節,有興趣可以看《Effective C++》條款08:別讓異常逃離析構函數。
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進行聲明。
感謝您的耐心閱讀。