專題:C++中的異常處理

一篇原來發在網易博客的文章

怎樣才能成爲專家?在我涉足過的所有領域,答案都一樣:

1. 掌握基礎知識。

2. 將相同的內容在學習一遍,但這一次,請將你的注意力集中在細節上

----這些細節的重要性,你頭一次可能並沒有認識到。

Herb   Sutter


理解處理異常的意義,異常出現的幾種主要情形,異常處理的一般做法,幾種表現形式和相關的規範,和如何自定義異常,以及異常對效率的影響。

      以前,我一聽到“異常安全”這幾個字,就想到了異常“免疫”,以爲這是要設計不拋出異常的程序,但是後來才知道不是那麼回事兒,正確的理解應該是:即使程序發生異常(甚至繼而引起程序退出,崩潰),也要保證資源安全。不過這時候的資源安全就有兩種,允許部分資源狀態改變和所謂的commit and rollback;當然最好的異常安全就是沒有異常(那就很安全了)。在繼續往下說之前,我覺得有幾點概念還是要說清楚的,內置類型的賦值,拷貝是不會發生異常的;異常一般發生在因資源不足申請不到資源的時候。

      我們知道C中是沒有異常這個概念的。那麼我們最好先弄清楚爲什麼要引入異常機制,引入之後解決什麼問題,解決問題的方式是什麼,規範是什麼,有什麼技巧,最終的效果如何。

      先看看異常出現的原因,這裏引用[B.3]中的話:

...一個庫的作者可以檢查出運行時錯誤,但一般說卻不知道怎樣去處理他們。庫的用戶知道怎樣對付這些錯誤,但卻又無法去檢查它們--要不然這些錯誤就會在用戶的代碼裏處理了,不會留給庫去發現。提供異常的概念就是爲了有助於處理這類問題。這裏的基本想法是,讓一個函數在發現自己無法處理的錯誤時拋出一個異常,希望它的(直接或者間接)調用者能夠處理這個問題希望處理這類問題的函數可以表明它將要捕捉這個異常。

      我們可以從這段話中知道,異常這個概念出現的原因,不過異常的應用場景可就不止這些了!異常可以連接出現異常和處理異常的兩方的代碼,讓發現異常問題的代碼把問題告訴處理問題的代碼,這樣就可以增強代碼的健壯性,而不至於一碰到異常就退出程序;或者隱藏問題不報以致於出現處理邏輯上,數據上的問題;再或者設置全局的errno,但是一般又沒什麼人去檢測這些錯誤信息。

      除了異常安全,我在讀《Exceptional C++》的時候,還看到另一個概念:異常中立。大概的意思應該是捕捉到異常之後,直接將異常拋出,不隱瞞異常,也不處理異常。這個意思就是我發現異常了,但是這個異常我不處理,直接把異常向外拋,那些個調用者們誰愛處理處理。這個時候異常處理並不是爲了保證代碼安全,而是一種報告錯誤地機制而已。

      如果我們用異常捕捉和異常處理模塊的話,一般的形式有兩種:try-block和function-try-block。下面內容摘自[B.2] 15章:

try-block:

      try compound-statement handler-seq

function-try-block:

      try ctor-initializeropt compound-statement handler-seq 

handler-seq:

      handler handler-seqopt 

 

      可以看出,try-block是用於普通的異常捕捉,而function-try-block是用於構造函數的初值化列表。不過我們很少見過後者:

C::C()

try

    :A(/*...*/)//代表基類

    ,b_(/*...*/){//代表成員

    //......構造函數體

}catch(...){

   //......異常處理

}

      但是這種用法幾乎沒什麼用,[B.2] 15.3.15中說:

15. The currently handled exception is rethrown if control reaches the end of a handler of the function-try-block of a constructor or destructor. Otherwise, a function returns when control reaches the end of a handler for the function-try-block (6.6.3). Flowing off the end of a function-try-block is equivalent to a return with no value; this results in undefined behavior in a value-returning function (6.6.3).

      同時《More Exceptional C++》中條款18中也提到:

...如果處理程序沒有以拋出異常的方式退出(既沒有重新拋出最初的異常,也沒有拋出什麼新的東西),那麼,在控制抵達構造函數或者析構函數的catch block的末尾時,最初的異常也會被自動地重新拋出,就像處理程序的最後一個語句是“throw”一樣。

      所以,最終是我們無法在構造函數和析構函數中隱藏異常不報讓構造函數繼續運行的。在構造函數的function-try-block中我們唯一可以做的就是轉化基類或成員子對象拋出的異常;而析構函數是不應該拋出異常的。

      從這裏我們可以看得出一個事實,就是編譯器的運行規則是我們可以利用以達到異常安全的唯一依賴。所以,還是得研讀C++標準,"以"臻化境。

      如果異常是發生在構造函數中,那麼這個對象就不會被構造出來,同時也就不會有對應的析構函數被調用。所以,如果在構造函數中發生了無法釋放的資源的話,以後就沒有機會釋放內存了,我們要想盡辦法把發生異常的構造函數中的資源泄露處理好,後面會介紹幾種防止泄露的方法。

      前面說明了一下C++引入異常的原因,以及捕獲異常的類型,那麼下面看一下捕獲到異常之後的處理模塊。

handler:

      catch ( exception-declaration ) compound-statement 

exception-declaration:

      attribute-specifier-seqopt type-specifier-seq declarator 

      attribute-specifier-seqopt type-specifier-seq abstract-declaratoropt 

      ... 

      關於處理異常的異常對象聲明要注意下面的內容。接收異常對象的數據類型可以有值傳遞,指針和引用。如果是使用值傳遞的(注意這樣會發生兩次拷貝),那麼要防止出現異常對象的切割現象(書面語“切片”)的發生。如果要使用指針或者引用類型的話,一定要保證這個數據在進入catch模塊中時還是有效的,否則會發生錯誤;如果在堆上創建了這個拋出的異常對象,那麼要在catch中釋放資源,不過即使這樣,也不符合C++的標準異常(bad_alloc,bad_cast……)數據是對象而不是指向對象的指針這一規範,所以最好不要使用指針的方式。最後也是最好的方式就是使用引用的方式,並且也可以減少對象拷貝的次數。還有一點要注意的是在catch中的異常聲明要和throw出來的數據類型嚴格匹配,不允許發生隱式轉換(比如char到int的轉換之類的)。

      最後是主動(重新)拋出異常的模塊。

throw-expression: 

      throw assignment-expressionopt

      C++11之前,對於throw還有一種用法,就是在函數參數列表後面跟上一個可能拋出的異常列表,但是C++11中不推薦這種做法,代之以乾淨簡潔的noexcept。所以不推薦使用throw(異常列表)的方法來聲明函數,不過throw()的用法卻可以用來聲明此函數做了不拋出異常的承諾,new/delete的一種形式就使用了這種用法,用來兼容C運行時庫。但事實是你還是可以拋出異常,畢竟承諾只是承諾……關於noexcept可以參考這篇文章。

      《C++編程剖析》第11、12、13節中還提到了throw聲明對於函數原型的影響,以及聲明瞭throw列表之後卻拋出(或者不在聲明列表中的)異常之後,程序的行爲。當然結果是聲明也沒有用。因爲如果這樣的話,最終程序會將異常拋到最外層的unexpected exception處理函數中,這個函數也是直接terminate掉程序而已。所以C++中這個異常設施,可用性較差,只是能用而已;而且,異常列表的聲明會讓編譯器生成一些類似try-catch的代碼,進而導致性能的下降。所以,目前C++的異常規格在實用的時候就變成了:1. 不要爲函數加上異常規格,2.空異常規格列表也可以省略。

      如果發生異常,但是沒有捕獲到該異常,那麼程序會調用terminate()立即結束程序。

      下面看一下如何防止發生異常之後造成資源泄露,也就是異常安全的措施。同時也提前說明下,要達到異常安全並不一定要使用try-catch。(至此,就已經說明,異常安全不是“異常免疫”了,只能是異常之後不出現更大的損失)

      首先是(類、函數)設計,《Exceptional C++》中說道,“異常不安全”和“拙劣的設計”通常是密切相關的。一個很典型的例子就是stack的接口設計中pop()的實現。如果讓pop()同時實現彈出對象和刪除棧頂對象,那是不可能異常安全的。唯一的解決方案就是把這個操作分開爲pop()和top()兩者來實現,同時也體現了一個函數只完成一種操作的原則(SOLID原則之S)。同時也應該儘量減少不必要的繼承,以減少基類的異常對子類異常的影響,當然了最好還要減少成員對象,因爲沒有對象就不會異常 :-)。

      處理好構造和析構若干函數

      構造函數中一般會發生成員變量初始化,基類初始化,這個叫做資源獲取即初始化RAII,要保證基類異常安全,成員變量也要異常安全。做到這一點是不容易的,這裏我們可以使用的工具有:使用臨時對象(編譯器會保證異常退出的時候釋放臨時對象的資源),使用智能指針(簡化try-catch的代碼,實現自動釋放資源的功能),以及配合swap方法(參考《Effective C++》中的條款25)。當然這就要求這些對象的析構函數不能拋出異常。關於這一點在《More Effective C++》和《Exceptional C++》,《More Exceptional C++》中有相關的講述。

      成對定義new/delete

      其實如果使用new操作符,但是忘記了delete,就程序員編程的角度來說,這不能算做異常,這應該是bug,或者錯誤,是程序員犯的錯誤;而異常是邏輯上,理論上沒有錯誤,結果運行出現不受控制的情況。但是這裏還要提一下,如果我們要定義自己的new/delete操作符的時候,要注意成對出現,否則很容易以爲使用自定義的new,而後可以使用系統的標準delete就可以;而是事實,編譯器會調用形式匹配的對應的delete,如果找不到,就沒有delete被調用。關於這一點《Effective C++》中有敘述。

      理解對象的生命期

      對象的生命期的起始點應該是從構造函數成功執行完畢之後到析構函數開始之前。在這兩個點以外的時間段中,如果位於構造函數成功之前,對象不存在,如果構造函數中發生異常,不會調用對象的析構函數,因爲沒有對象;如果位於析構函數中,對象析構到什麼程序,我們無法得知,一些虛函數也不能調用。關於這一點可以參考《Effective C++》中的條款9。

      使用pimpl技術

      使用pimpl技術,不僅可以降低改變代碼引起的編譯時間,還可以爲使用pimpl對象的類提供滿足單一原則的成員初始化。爲了加強代碼的異常管理,可以使用智能指針來管理這個pimpl成員。不過這種辦法不能消除異常,只是從結構上對異常有所改善。

      

      使用異常的try-throw-catch結構會增大程序體積和降低運行效率,因爲編譯器要生成維護這種結構的代碼。

      總之,如果要想寫異常安全的代碼,你要了解編譯器讓你寫的代碼到底做了什麼,以及用什麼方法可以按照編譯器可以處理的方式來達到異常安全。

 

      我之前說過異常安全的應用場景不止於防止資源泄露,起碼我看到過使用throw異常來主動中斷程序,跳到接收異常的地方,做消息循環的用法。所以我們應該從另一種角度理解異常,並使用這種機制做一些應用。

 

      在STL中有一些已經定義好的異常類,我們可以通過繼承這些類得到自定義的異常;當然也可以自定義不繼承這些類的類,異常類跟普通的類並無差別。

 

      最後得聲明一下,儘管寫了這些內容,我還是不太瞭解異常這傢伙,始終感覺它就像是在代碼之間遊蕩的幽靈……要控制這個幽靈是很困難的事情!

 

參考資料

Book

[B.1] Stanley B. Lippman,《C++ Primer 5th》中文版/英文版中18.1 Exception Handing。

[B.2] C++標準文檔,ISO/IEC 14882,第15章,18.8。

[B.3] Bjarne Stroustrup,《C++程序設計語言(特別版)》第14章,附錄E 標準庫的異常時安全性;《The C++ Programming Language 4th Edition》第13章。

[B.4] Scott Meyers, 《Effective C++》條款8,29;《More Effective C++》第五章。

[B.5] Herb Sutter,《Exceptional C++》第二章 Exception-Safety Issues and Techniques;《More Exceptional C++》第三章 Exception-Safety Issues and Techniques。《C++編程剖析》第11、12、13、22、23條。

 

Doc

[D.1] Bjarne Stroustrup,noexcept -- preventing exception propagation

 

Wiki

[W.1] SOLID面向對象設計

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章