1. 程式人生 > >【轉】C++ 異常

【轉】C++ 異常

一、什麼是異常處理

        一句話:異常處理就是處理程式中的錯誤。

二、為什麼需要異常處理,以及異常處理的基本思想

        C++之父Bjarne Stroustrup在《The C++ Programming Language》中講到:一個庫的作者可以檢測出發生了執行時錯誤,但一般不知道怎樣去處理它們(因為和使用者具體的應用有關);另一方面,庫的使用者知道怎樣處理這些錯誤,但卻無法檢查它們何時發生(如果能檢測,就可以再使用者的程式碼裡處理了,不用留給庫去發現)。

        Bjarne Stroustrup說:提供異常基本目的就是為了處理上面的問題。基本思想是:讓一個函式在發現了自己無法處理的錯誤時丟擲(throw)一個異常,然後它的(直接或者間接)呼叫者能夠處理這個問題。 
The fundamental idea is that a function that finds a problem it cannot cope with throws an exception, hoping that its (direct or indirect) caller can handle the problem.

        也就是《C++ primer》中說的:將問題檢測問題處理相分離。 
Exceptions let us separate problem detection from problem resolution

        一種思想:在所有支援異常處理的程式語言中(例如java),要認識到的一個思想:在異常處理過程中,由問題檢測程式碼可以丟擲一個物件給問題處理程式碼,通過這個物件的型別和內容,實際上完成了兩個部分的通訊,通訊的內容是“出現了什麼錯誤”。當然,各種語言對異常的具體實現有著或多或少的區別,但是這個通訊的思想是不變的。

三、異常出現之前處理錯誤的方式

        在C語言的世界中,對錯誤的處理總是圍繞著兩種方法:一是使用整型的返回值標識錯誤;二是使用errno巨集(可以簡單的理解為一個全域性整型變數)去記錄錯誤。當然C++中仍然是可以用這兩種方法的。

        這兩種方法最大的缺陷就是會出現不一致問題。例如有些函式返回1表示成功,返回0表示出錯;而有些函式返回0表示成功,返回非0表示出錯。

        還有一個缺點就是函式的返回值只有一個,你通過函式的返回值表示錯誤程式碼,那麼函式就不能返回其他的值。當然,你也可以通過指標或者C++的引用來返回另外的值,但是這樣可能會令你的程式略微晦澀難懂。

四、異常為什麼好

    在如果使用異常處理的優點有以下幾點:

        1. 函式的返回值可以忽略,但異常不可忽略。如果程式出現異常,但是沒有被捕獲,程式就會終止,這多少會促使程式設計師開發出來的程式更健壯一點。而如果使用C語言的error巨集或者函式返回值,呼叫者都有可能忘記檢查,從而沒有對錯誤進行處理,結果造成程式莫名其面的終止或出現錯誤的結果。

        2. 整型返回值沒有任何語義資訊。而異常卻包含語義資訊,有時你從類名就能夠體現出來。

        3. 整型返回值缺乏相關的上下文資訊。異常作為一個類,可以擁有自己的成員,這些成員就可以傳遞足夠的資訊。

        4. 異常處理可以在呼叫跳級。這是一個程式碼編寫時的問題:假設在有多個函式的呼叫棧中出現了某個錯誤,使用整型返回碼要求你在每一級函式中都要進行處理。而使用異常處理的棧展開機制,只需要在一處進行處理就可以了,不需要每級函式都處理。

五、C++中使用異常時應注意的問題

    任何事情都是兩面性的,異常有好處就有壞處。如果你是C++程式設計師,並且希望在你的程式碼中使用異常,那麼下面的問題是你要注意的。

        1. 效能問題。這個一般不會成為瓶頸,但是如果你編寫的是高效能或者實時性要求比較強的軟體,就需要考慮了。

(如果你像我一樣,曾經是java程式設計師,那麼下面的事情可能會讓你一時迷糊,但是沒辦法,誰叫你現在學的是C++呢。)

       2. 指標和動態分配導致的記憶體回收問題:在C++中,不會自動回收動態分配的記憶體,如果遇到異常就需要考慮是否正確的回收了記憶體。在java中,就基本不需要考慮這個,有垃圾回收機制真好!

        3. 函式的異常丟擲列表:java中是如果一個函式沒有在異常丟擲列表中顯式指定要丟擲的異常,就不允許丟擲;可是在C++中是如果你沒有在函式的異常丟擲列表指定要丟擲的異常,意味著你可以丟擲任何異常

        4. C++中編譯時不會檢查函式的異常丟擲列表。這意味著你在編寫C++程式時,如果在函式中丟擲了沒有在異常丟擲列表中宣告的異常,編譯時是不會報錯的。而在java中,eclipse的提示功能真的好強大啊!

        5. 在java中,丟擲的異常都要是一個異常類;但是在C++中,你可以丟擲任何型別,你甚至可以丟擲一個整型。(當然,在C++中如果你catch中接收時使用的是物件,而不是引用的話,那麼你丟擲的物件必須要是能夠複製的。這是語言的要求,不是異常處理的要求)。

        6. 在C++中是沒有finally關鍵字的。而java和python中都是有finally關鍵字的。

六、異常的基本語法

1. 丟擲和捕獲異常

        很簡單,丟擲異常用throw,捕獲用try……catch

        捕獲異常時的注意事項:

             1. catch子句中的異常說明符必須是完全型別,不可以為前置宣告,因為你的異常處理中常常要訪問異常類的成員。例外:只有你的catch子句使用指標或者引用接收引數,並且在catch子句內你不訪問異常類的成員,那麼你的catch子句的異常說明符才可以是前置宣告的型別。

             2. catch的匹配過程是找最先匹配的,不是最佳匹配。

             3. catch的匹配過程中,對型別的要求比較嚴格允許標準算術轉換類型別的轉換。(類型別的轉化包括種:通過建構函式的隱式型別轉化和通過轉化操作符的型別轉化)。

             4. 和函式引數相同的地方有: 
                    ① 如果catch中使用基類物件接收子類物件,那麼會造成子類物件分隔slice)為父類子物件(通過呼叫父類的複製建構函式); 
                    ② 如果catch中使用基類物件的引用接受子類物件,那麼對虛成員的訪問時,會發生動態繫結,即會多型呼叫。 
                    ③ 如果catch中使用基類物件的指標,那麼一定要保證throw語句也要丟擲指標型別,並且該指標所指向的物件,在catch語句執行是還存在(通常是動態分配的物件指標)。

             5. 和函式引數不同的地方有:   
                    ① 如果throw中丟擲一個物件,那麼無論是catch中使用什麼接收(基類物件、引用、指標或者子類物件、引用、指標),在傳遞到catch之前,編譯器都會另外構造一個物件的副本。也就是說,如果你以一個throw語句中丟擲一個物件型別,在catch處通過也是通過一個物件接收,那麼該物件經歷了兩次複製,即呼叫了兩次複製建構函式。一次是在throw時,將“丟擲到物件”複製到一個“臨時物件”(這一步是必須的),然後是因為catch處使用物件接收,那麼需要再從“臨時物件”複製到“catch的形參變數”中; 如果你在catch中使用“引用”來接收引數,那麼不需要第二次複製,即形參的引用指向臨時變數。 
                    ② 該物件的型別與throw語句中體現的靜態型別相同。也就是說,如果你在throw語句中丟擲一個指向子類物件的父類引用,那麼會發生分割現象,即只有子類物件中的父類部分會被丟擲,丟擲物件的型別也是父類型別。(從實現上講,是因為複製到“臨時物件”的時候,使用的是throw語句中型別的(這裡是父類的)複製建構函式)。 
                    ③ 不可以進行標準算術轉換類的自定義轉換:在函式引數匹配的過程中,可以進行很多的型別轉換。但是在異常匹配的過程中,轉換的規則要嚴厲。

                    ④ 異常處理機制的匹配過程是尋找最先匹配(first fit),函式呼叫的過程是尋找最佳匹配(best fit)。

2. 異常型別

        上面已經提到過,在C++中,你可以丟擲任何型別的異常。(哎,竟然可以丟擲任何型別,剛看到到這個的時候,我半天沒反應過來,因為java中這樣是不行的啊)。

         注意:也是上面提到過的,在C++中如果你throw語句中丟擲一個物件,那麼你丟擲的物件必須要是能夠複製的。因為要進行復制副本傳遞,這是語言的要求,不是異常處理的要求。(在上面“和函式引數不同的地方”中也講到了,因為是要複製先到一個臨時變數中)

3. 棧展開

        棧展開指的是:當異常丟擲後,匹配catch的過程。

        丟擲異常時,將暫停當前函式的執行,開始查詢匹配的catch子句。沿著函式的巢狀呼叫鏈向上查詢,直到找到一個匹配的catch子句,或者找不到匹配的catch子句。

        注意事項:

               1. 在棧展開期間,會銷燬區域性物件。

                     ① 如果區域性物件是類物件,那麼通過呼叫它的解構函式銷燬。

                     ② 但是對於通過動態分配得到的物件,編譯器不會自動刪除,所以我們必須手動顯式刪除。(這個問題是如此的常見和重要,以至於會用到一種叫做RAII的方法,詳情見下面講述)

               2. 解構函式應該從不丟擲異常。如果解構函式中需要執行可能會丟擲異常的程式碼,那麼就應該在解構函式內部將這個異常進行處理,而不是將異常丟擲去。

                     原因:在為某個異常進行棧展開時,解構函式如果又丟擲自己的未經處理另一個異常,將會導致呼叫標準庫 terminate 函式。而預設的terminate 函式將呼叫 abort 函式,強制從整個程式非正常退出。

               3. 建構函式中可以丟擲異常。但是要注意到:如果建構函式因為異常而退出,那麼該類的解構函式就得不到執行。所以要手動銷燬在異常丟擲前已經構造的部分。

4. 異常重新丟擲

        語法:使用一個空的throw語句。即寫成: throw;   

        注意問題:

                ① throw;  語句出現的位置,只能是catch子句中或者是catch子句呼叫的函式中。 
                ② 重新丟擲的是原來的異常物件,即上面提到的“臨時變數”,不是catch形參。 
                ③ 如果希望在重新丟擲之前修改異常物件,那麼應該在catch中使用引用引數。如果使用物件接收的話,那麼修改異常物件以後,不能通過“重新丟擲”來傳播修改的異常物件,因為重新丟擲不是catch形參,應該使用的是 throw e;  這裡“e”為catch語句中接收的物件引數。

5. 捕獲所有異常(匹配任何異常)

        語法:在catch語句中,使用三個點(…)。即寫成:catch (…)   這裡三個點是“萬用字元”,類似 可變長形式引數。

        常見用法:與“重新丟擲”表示式一起使用,在catch中完成部分工作,然後重新丟擲異常。

6. 未捕獲的異常

        意思是說,如果程式中有丟擲異常的地方,那麼就一定要對其進行捕獲處理。否則,如果程式執行過程中丟擲了一個異常,而又沒有找到相應的catch語句,那麼會和“棧展開過程中解構函式丟擲異常”一樣,會 呼叫terminate 函式,而預設的terminate 函式將呼叫 abort 函式,強制從整個程式非正常退出。

7. 建構函式的函式測試塊

        對於在建構函式的初始化列表中丟擲的異常,必須使用函式測試塊(function try block)來進行捕捉。語法型別下面的形式:

  1. MyClass::MyClass(int i)  
  2. try :member(i) {  
  3.     //函式體
  4. catch(異常引數) {  
  5.     //異常處理程式碼
  6. }  

        注意事項:在函式測試塊中捕獲的異常,在catch語句中可以執行一個記憶體釋放操作,然後異常仍然會再次丟擲到使用者程式碼中。

8. 異常丟擲列表(異常說明 exception specification)

        就是在函式的形參表之後(如果是const成員函式,那麼在const之後),使用關鍵字throw宣告一個帶著括號的、可能為空的 異常型別列表。形如:throw ()  或者 throw (runtime_error, bad_alloc)   。

        含義:表示該函式只能丟擲 在列表中的異常型別。例如:throw() 表示不丟擲任何異常。而throw (runtime_error, bad_alloc)表示只能丟擲runtime_error 或bad_alloc兩種異常。

        注意事項:(以前學java的尤其要注意,和java中不太一樣)

                ① 如果函式沒有顯式的宣告 丟擲列表,表示異常可以丟擲任意列表。(在java中,如果沒有異常丟擲列表,那麼是不能丟擲任何異常的)。

                ② C++的 “throw()”相當於java的不宣告丟擲列表。都表示不丟擲任何異常。

                ③ 在C++中,編譯的時候,編譯器不會對異常丟擲列表進行檢查。也就是說,如果你聲明瞭丟擲列表,即使你的函式程式碼中丟擲了沒有在丟擲列表中指定的異常,你的程式依然可以通過編譯,到執行時才會出錯,對於這樣的異常,在C++中稱為“意外異常”(unexpeced exception)。(這點和java又不相同,在java中,是要進行嚴格的檢查的)。 

        意外異常的處理: 
                如果程式中出現了意外異常,那麼程式就會呼叫函式unexpected()。這個函式的預設實現是呼叫terminate函式,即預設最終會終止程式。

        虛擬函式過載方法時異常丟擲列表的限制 
                在子類中過載時,函式的異常說明 必須要比父類中要同樣嚴格,或者更嚴格。換句話說,在子類中相應函式的異常說明不能增加新的異常。或者再換句話說:父類中異常丟擲列表是該虛擬函式的子類過載版本可以丟擲異常列表的 超集

函式指標中異常丟擲列表的限制 
                 異常丟擲列表是函式型別的一部分,在函式指標中也可以指定異常丟擲列表。但是在函式指標初始化或者賦值時,除了要檢查返回值形式引數外,還要注意異常丟擲列表的限制:源指標的異常說明必須至少和目標指標的一樣嚴格。比較拗口,換句話說,就是宣告函式指標時指定的異常丟擲列表,一定要實際函式的異常丟擲列表的超集。 如果定義函式指標時不提供異常丟擲列表,那麼可以指向能夠丟擲任意型別異常的函式。                

        丟擲列表是否有用   
                 在《More effective C++》第14條,Scott Meyers指出“要謹慎的使用異常說明”(Use exception specifications judiciously)。“異常說明”,就是我們所有的“異常丟擲列表”。之所以要謹慎,根本原因是因為C++編譯器不會檢查異常丟擲列表,這樣就可能在函式程式碼中、或者呼叫的函式中丟擲了沒有在丟擲列表中指定的異常,從而導致程式呼叫unexpected函式,造成程式提前終止。同時他給出了三條要考慮的事情: 
                         ① 在模板不要使用異常丟擲列表。(原因很簡單,連用來例項模板的型別都不知道,也就無法確定該函式是否應該丟擲異常,丟擲什麼異常)。  
                         ② 如果A函式內呼叫了B函式,而B函式沒有宣告異常丟擲列表,那麼A函式本身也不應該設定異常丟擲列表。(原因是,B函式可能丟擲沒有在A函式的異常丟擲列表中宣告的異常,會導致呼叫unex函式); 
                         ③ 通過set_unexpected函式指定一個新的unexpected函式,在該函式中捕獲異常,並丟擲一個統一型別的異常。

                 另外,在《C++ Primer》4th 中指出,雖然異常說明應用有限,但是如果能夠確定該函式不會丟擲異常,那麼顯式宣告其不丟擲任何異常 有好處。通過語句:"throw ()"。這樣的好處是:對於程式設計師,當呼叫這樣的函式時,不需要擔心異常。對於編譯器,可以執行被可能丟擲異常所抑制的優化。

七、標準庫中的異常類

        和java一樣,標準庫中也提供了很多的異常類,它們是通過類繼承組織起來的。標準異常被組織成八個

        異常類繼承層級結構圖如下: 
C++  標準庫異常類繼承層次圖

    每個類所在的標頭檔案在圖下方標識出來.

    標準異常類的成員: 
        ① 在上述繼承體系中,每個類都有提供了建構函式、複製建構函式、和賦值操作符過載。 
        ② logic_error類及其子類、runtime_error類及其子類,它們的建構函式是接受一個string型別的形式引數,用於異常資訊的描述; 
        ③ 所有的異常類都有一個what()方法,返回const char* 型別(C風格字串)的值,描述異常資訊。

    標準異常類的具體描述 

異常名稱

描述

exception 所有標準異常類的父類
bad_alloc 當operator new and operator new[],請求分配記憶體失敗時
bad_exception 這是個特殊的異常,如果函式的異常丟擲列表裡聲明瞭bad_exception異常,當函式內部丟擲了異常丟擲列表中沒有的異常,這是呼叫的unexpected函式中若丟擲異常,不論什麼型別,都會被替換為bad_exception型別
bad_typeid 使用typeid操作符,操作一個NULL指標,而該指標是帶有虛擬函式的類,這時丟擲bad_typeid異常
bad_cast 使用dynamic_cast轉換引用失敗的時候
ios_base::failure io操作過程出現錯誤
logic_error 邏輯錯誤,可以在執行前檢測的錯誤
runtime_error 執行時錯誤,僅在執行時才可以檢測的錯誤

        logic_error的子類: 

異常名稱

描述

length_error 試圖生成一個超出該型別最大長度的物件時,例如vector的resize操作
domain_error 引數的值域錯誤,主要用在數學函式中。例如使用一個負值呼叫只能操作非負數的函式
out_of_range 超出有效範圍
invalid_argument 引數不合適。在標準庫中,當利用string物件構造bitset時,而string中的字元不是’0’或’1’的時候,丟擲該異常

        runtime_error的子類: 

異常名稱

描述

range_error 計算結果超出了有意義的值域範圍
overflow_error 算術計算上溢
underflow_error 算術計算下溢

八、編寫自己的異常類

        1. 為什麼要編寫自己的異常類? 
                ① 標準庫中的異常是有限的; 
                ② 在自己的異常類中,可以新增自己的資訊。(標準庫中的異常類值允許設定一個用來描述異常的字串)。

2. 如何編寫自己的異常類? 
                ① 建議自己的異常類要繼承標準異常類。因為C++中可以丟擲任何型別的異常,所以我們的異常類可以不繼承自標準異常,但是這樣可能會導致程式混亂,尤其是當我們多人協同開發時。 
                ② 當繼承標準異常類時,應該過載父類的what函式虛解構函式。 
                ③ 因為棧展開的過程中,要複製異常型別,那麼要根據你在類中新增的成員考慮是否提供自己的複製建構函式

九、用類來封裝資源分配和釋放