1. 程式人生 > >C++異常處理解析: 異常的引發(throw), 捕獲(try catch)、異常安全

C++異常處理解析: 異常的引發(throw), 捕獲(try catch)、異常安全

前言:

C++的異常處理機制是用於將執行時錯誤檢測和錯誤處理功能分離的一 種機制(符合高內聚低耦合的軟體工程設計要求),  這裡主要總結一下C++異常處理的基礎知識, 包括基本的如何引發異常(使用throw)和捕獲異常(try catch)相關使用注意點, 以及C++標準庫提供的一套標準異常類和這些異常類的繼承層級結構以及相關使用方法和常用習慣.

C++異常的引發(throw):

引發C++異常的語法就是使用throw語句: throw object; 注意這裡throw丟擲的是一個物件,也就是說是一個例項. 一旦丟擲, 發生兩件事情: 第一, C++異常機制開始尋找try catch模組, 尋找和丟擲的物件的型別相匹配的catch子句找到處理程式碼進行異常的處理

, 這個過程是一個棧展開的 過程,也就是說C++講先從當前的函式體裡面尋找try catch模組, 如果沒有, 則在呼叫當前函式(比如我們叫當前函式A)的函式(我們叫呼叫A的函式B)尋找處理程式碼(在B裡面尋找), 一直尋找直到找到匹配的catch子句, 然後執行catch裡面的程式碼, 執行完畢以後, 從這個匹配的catch後面的程式碼繼續執行. 第二件事情是, 棧展開前面的所有函式作用域都失效(比如, A呼叫B, B呼叫C, C呼叫D, D呼叫E, E丟擲異常同時在C找到了處理異常的catch子句, 那麼D, E作用域失效, 等效於D, E執行到了函式結尾), 區域性物件(自動釋放記憶體的物件, 而不是那些動態分配記憶體的物件, 這一點和異常安全有關我們後面會提到)都將呼叫解構函式進行銷燬.

注意點:

1. throw丟擲的物件一定要是可以複製的(C++ Primer中的原話是: 異常物件是通過複製被丟擲表示式的結果建立, 該結果必須是可以複製的型別)

2. 不要丟擲(throw)一個數組或者函式, 原因是, 和函式引數一樣, 陣列和函式型別實參, 該實參自動轉換為一個指標.

3. C++異常說明: void func(int) throw(exception type list), 表明函式func會且僅會丟擲list中列舉的異常物件型別, throw()表示不會丟擲任何異常(空異常型別列表)

C++異常的捕獲(try catch):

如果要試圖捕獲C++異常, 那麼將可能丟擲(throw)異常的程式碼塊放到try{}裡面, 在try{} 後面跟上catch(exception e) {}

, 這裡的e是一般的異常物件, C++異常處理通過丟擲物件的型別來判斷決定啟用哪個catch處理程式碼. 具體語法可以參見任何一本C++的書籍. 這裡主要提幾點注意點:

1. 講throw的時候也提到了, catch是一層一層catch(棧展開), 當尋找到main裡面也沒有catch捕獲的時候, C++機制一般將呼叫terminate終止程序(abort)

2.  catch子句列表中, 最特殊的catch必須最先出現, 不然永遠都不可能執行到

3. catch(…) 這個語法表示catch捕獲所有異常

4. 在catch裡面使用throw ;這條語句將重新丟擲異常物件, 改異常物件是和捕獲的一場物件同一個物件(catch中可以修改這個物件)

C++標準異常介紹(繼承層次結構等):

C++標準庫提供了以下的標準異常類, 他們的繼承層次結構如下(參考: Chapter 17: Advanced C++ Topics III). 比較好的寫異常的做法是繼承這些C++標準的異常類, 然後定義一組適合自己應用的異常處理物件集合. 

C++ standard exception classes inheritance diagram

C++的異常處理機制主要用於將錯誤檢測和錯誤處理功能分離, 從而達到低耦合的要求, 這篇文章主要總結了一下C++異常處理的基礎知識, 從如何使用throw引發異常, 使用try catch等捕獲異常到C++標準庫提供的一套標準異常類和這些異常類的繼承層級結構, 主要給出了相關使用方法和注意點以及一些程式設計的良好習慣. 文章全憑本人自己的理解原創行文, 如有不當之處, 在所難免, 還請不吝指正.

異常安全(記憶體洩露, 空指標等問題)

前言:

C++異常安全是針對C++異常處理帶來的可能的隱患(記憶體洩露, 空指標等)而言的, 我們知道異常一旦發生, 程式就會轉移控制權, 如果在轉移控制權的之前, 沒有妥善處理, 比如忘記釋放記憶體, 空指標等, 會造成嚴重的未定義行為或者資源洩露(記憶體洩露, 空指標等). 所謂異常安全, 就是為了保證即使是發生了異常, 這些類似的未定義(記憶體洩露, 空指標等)行為也不會發生.

C++異常安全概念:

我們寫程式的時候往往習慣按照假設程式正常執行的行為寫程式碼, 管理資源等. 有時候也會寫錯誤檢測和處理的程式碼, 但是在這兩個地方重疊時候, 也就是錯誤發生的時候的資源管理往往是容易被忽視的(下面馬上會給出兩個例子, 記憶體洩露問題和空指標未定義行為問題).

異常安全是這麼一個概念: 這個是指, 即使發生異常, 程式也能正確操作(異常發生以後要杜絕一切未定義的行為, 包括空指標, 記憶體洩露等, 即使異常發生, 那麼相關例項還是應該保持有效的狀態).

C++異常安全要求:

C++異常安全一般有四個等級的要求(異常安全等級由低到高): 1. 沒有任何異常安全保證, 也就是異常一旦發生, 可能造成程式行為的未定義; 2. 基本保證, 也就是異常發生的時候, 程式的行為還是合法的, 狀態也都是有效的, 行為是有定義的, 但是程式例項的狀態有可能改變(仍舊合法) 3. 強保證(回滾保證), 這個等級就要求異常一旦發生然後進行處理了以後, 要麼一次性全部成功, 要麼就回滾到異常錢的原始狀態(程式狀態和異常發生以前一模一樣). 4. 保證不會有任何一方的發生.

這裡面1是最不安全的, 不可取. 4基本上等級最強, 但是一般情況下不可能滿足. 所以異常安全往往在2和3這兩個等級間取捨. 等級3有可能會有額外的負擔, 資源消耗等. 具體情況根據程式邏輯和實際情況判斷取捨.

C++異常安全舉例, 避免記憶體洩露:

C++異常安全其中一條重要的慣例, 是需要保證 如果發生異常, 被分配到的任何資源都適當地得到釋放.  這個情況一般發生在動態分配記憶體的時候, 比如我程式裡面有一段程式碼, 在第20行的時候首先動態分配了記憶體給一個指標p, 正常執行的話, 中間有一些處理程式碼, 然後到第40行delete [] p 釋放記憶體, 程式正常執行的話沒有問題, 但是要是在第20行到40行之間的程式碼出現了異常, 程式控制權轉移給上級呼叫程式的時候, 這樣的程式碼就有問題了, 此時, 作用域等效於已經到達了當前函式的結束, 所有區域性變數或者實力都會呼叫自身的解構函式進行釋放資源, 但是對動態分配記憶體的例項來講, 因為是直接異常跳轉, 雖然作用域結束, 但是沒有執行到delete進行手動釋放, 這塊動態記憶體將造成記憶體洩露.

那麼比較好的保證這一類記憶體資源不洩露的異常安全的技術成為“資源分配即初始化”(參考RAII). 對於這句話“資源分配即初始化”我自己是這麼理解的, 我們要進行資源分配, 保證異常安全的做法不是普通的動態分配一塊記憶體, 而是等效的初始化一個資源管理類的例項. 這就是所謂的“資源分配即初始化”, 也就是把資源分配等效的用初始化資源管理類來替代. 那麼這裡又提到了資源管理類, 我們解釋一下資源管理類以及“資源分配即初始化”到底好處在哪裡. 基本上這點要求我們設計一個資源管理類統一的管理資源的分配和釋放, 更具體的, 利用建構函式分配資源, 利用析構行數釋放資源. 這樣做的好處呢, 是資源管理類本身是一個自動的區域性物件, 不管是因為異常發生還是正常的程式執行到了改區域性物件的作用域的結束的時候, 這個類的解構函式都會被呼叫從而保證了資源的釋放, 避免了記憶體洩露問題. C++裡面提供了RAII的auto_ptr類, 就是一個資源管理類, 行為雷係指針. 我們這裡就不深入研究它了.

C++異常安全舉例, 避免空指標:

C++異常安全的另一個常見的管理就是需要避免空指標. 這個情況的發生往往是我們在動態分配記憶體的時候發生了異常. 比如我們要分配p = new int[100], 這個時候要是記憶體不夠, 那麼就發生bad_alloc異常, p指標是空的NULL. 這個時候如果後面的程式碼依賴於p的未定義行為, 這樣很容易導致程式的崩潰. 一個有效的避免空指標的做法就是, 在賦值之前就知道記憶體的分配是成功還是失敗, 同樣可以利用我們的資源管理類. 管理動態分配的記憶體, 如果分配成功, 那麼將記憶體塊的指標賦值給p, 如果失敗, 那麼丟擲異常, 程式在p賦值前轉移了控制權,此時p的值是不會改變的. 這樣做就使得程式更加魯棒(異常發生的時候, p的狀態沒有改變, 也沒有產生未定義行為).

錯誤處理(返回值, 錯誤標誌變數, 異常)

前言:

程式設計裡面至關重要的一塊就是錯誤處理, C++異常處理是一種面向物件的機制, 期望將錯誤處理和錯誤檢測分離. 這裡我們結合其他兩種錯誤處理方式(返回值, 錯誤標誌變數)來分析一下不同的錯誤處理(包括返回值判斷,  錯誤標誌變數, 異常處理機制)各有什麼優缺點以及各自的適用環境.

函式返回值判斷錯誤處理:

這種錯誤處理和判斷的方法基本上是使用一組錯誤處理的常量, 然後通過函式返回值, 把錯誤資訊返回給函式呼叫者. 比如如下簡單的程式碼:

const int invalidPara = -2;

const int outOfRange = -3;
 const int other = -4;
int func(int para)
{
   if(invalid parameter)
       return invalidPara;
   do something here;
   if(out of range)
       return outOfRange;
   if(other error)
       return other;
}

這樣的返回值判斷的好處在於和系統API統一, 我們知道WinAPI以及Linux下面的系統函式都是以返回0(零)表示程式正常執行, 返回非零值表示不同的錯誤. 所以如果我們也採用這樣的返回值判斷的話可以和系統呼叫統一起來.

但是返回值判斷錯誤的限制以及缺點也是很明顯的(個人不是很推崇用返回值, 但是也還是要看具體情況). 首先呢, 返回值判斷錯誤會破壞正常的返回值的作用, 使得函式呼叫不能被充分利用, 函式返回值不能作為其他表示式的組成部分, 因為這個返回值已經用來指示錯誤了而不是用來返回其他正常的計算結果, 即使可以既用於正常值計算又用於返回錯誤, 比如正常值都是正數, 錯誤值都是負數, 那這個結果還是不能直接被用作任何計算, 首先還是要判斷這個是正常計算結果呢還是一個錯誤資訊, 這就造成了計算的不方便.

其次很多時候其實是沒辦法使用返回值來判斷錯誤資訊的. 比如 1) 當func()返回型別是int的時候, 而且正常的結果的返回就是所有int型的值都有可能, 這個時候我們其實沒法找到一個很好的int value 作為indicatro來指示這是個錯誤返回還不是一個正常的結果. 2) 編寫範型的時候比如return T, 那怎麼利用返回值來判斷? 這個時候因為我們不明確T的型別, 所以也沒有很好的辦法利用一個明確的返回值來判斷或者給出錯誤資訊. 在這些情況下, 異常處理應該是更為合理的錯誤處理的方式. 我們後面第三條會再講到。接下來可以看看第二種錯誤處理機制.

錯誤標誌變數判斷:

這個型別的錯誤判斷基本上可以用下面的這段程式表示. 也就是設定一個錯誤標誌變數, 然後通過引用或者指標的形式傳遞給被呼叫的函式, 函式一旦發現錯誤就設定這個標誌, 上層呼叫者通過檢查這個標誌變數來判斷是否有錯誤發生.

int funcCallee(int para, int &errorFlag) {
    if(invalid parameter)
        set errorFlag and return;
    do something here;
    if(out of range)
        set errorFlag and return;
    if(other error)
        set errorFlag and return;
}

int funcCaller(int para) {
    int errorFlag = 0;
    int ret = funcCallee(1, errorFlag);
    check errorFlag;
}

這個方法的好處在於現在我們的返回值值表示正常計算結果, 可以被方便的利用起來, 比起第一種利用返回值判斷的話是一個比較明顯的優勢, 而且前面提到的兩種不能使用返回值判斷錯誤的情況(泛型, 正常結果返回涵蓋所有整型), 我們也可以使用標誌位. 因為表示為總是可以保證是int型的, 而且是不受函式的程式碼邏輯影響的, 基本上是一個獨立的錯誤標誌. 在我看來這種方法似乎並沒有明顯的缺陷. 我個人比較推重.

C++異常處理機制:

其實我覺得C++的異常處理就是我們這裡說的第二種利用標誌變數的面向物件版本的錯誤處理機制, 本質上似乎沒有太大區別. 當然異常處理還有複雜的多精細的多. 兩者都是統一的獨立於程式業務邏輯的錯誤處理機制. 比如不管程式幹什麼(泛型也好, 其他什麼也好), 我們遇到錯誤總是能夠丟擲一個異常, 終止當前函式, 把控制權轉移給上層呼叫函式進行處理. 對應到我們的第二種錯誤標誌變數的話, 就是檢測到異常或者錯誤的時候, 正確設定標識變數, 然後return, 控制權也轉移給上層呼叫函式, 上層呼叫函式通過判斷標誌變數的值來進行處理. 從這個角度來講, 似乎兩者也沒有太大區別.

另一方面呢, 異常機制作為C++的一種語言級別的機制, 其實會有比較大的開銷, 包括控制權的轉移等等, 他的好處在於錯誤處理和錯誤邏輯分離的很清楚, 而且強制使用者一定要處理異常, 否則程式將最終終止. 但是方法二呢, 要是我忘記去檢查那個錯誤標誌變量了怎麼辦? 回答是不怎麼辦. 因為這個僅僅是程式碼級別的判斷, 沒有任何強制措施去要求一定要處理。 這個就是很危險的了. 所以異常機制(語言級別的判斷)從這個角度來講也是比較好的一種錯誤處理的選擇.

結束語:

這篇文章我們還是解析C++的異常處理機制, 這裡我們結合其他兩種錯誤處理方式(返回值, 錯誤標誌變數)分析了這些不同的錯誤處理, 即返回值判斷,  錯誤標誌變數, 異常處理機制各有什麼優缺點以及各自的適用環境.