C#基礎知識梳理系列十:異常處理 System.Exception


摘 要

人非聖賢,孰能無過。代碼是人寫的,當然也不可能不出錯,我們只能期望代碼更健壯,不可能追求完美,能做更多的就是如何從錯誤中恢復或尋找替代方案。CLR提供了異常處理機制,它不僅能讓代碼在出錯的時候更優雅地讓人們去解決異常,也能在必要的時候拋出異常。那么,如何更規范的定義和使用異常消息呢?拋出異常會不會影響性能呢?

第一節 CLR中的異常

在早期的Win32 API設計中是通過返回true/false來表示一個過程(方法、函數)是否執行成功,在COM中是使用HRESULT來表示一個過程是否正確執行,然而這種處理異常的方式使開發人員對哪里出錯,為什么出錯,出什么樣的錯這些問題很難找到明確的答案,再一點,調用者很容易忽略一個過程執行的結果,如果調用者丟棄了過程執行結果,則代碼將“按照期望的狀態正常執行”,這是很危險的。后來,在.NET Framework中,已經不再使用這種簡單的以狀態碼來表示執行結果的處理方式,而是使用拋出異常的方式來告訴調用者:兄弟,出錯了!如果你不趕緊修復,系統將讓你無法進入決賽!

CLR中的異常是基於SEH的結構化異常處理機制構建的,在基礎的SEH機制中是向系統注冊出錯時的回調函數,當在監視區內出錯時,系統會取得當前線程的控制權處理回調函數,處理完畢后,系統釋放控制權給當前線程,當前線程繼續執行,如果未處理異常的代碼段,會導致進程中止。有關SEH的詳細內容請參考MSDN文檔

CLR在對SEH封裝的基礎上以更優雅的方式向開發人員提供良好的編程體驗,它把異常處理分為三塊try、catch、finally。

Try塊是監視區,其內放置一些正常實現編程功能的代碼、資源清除的代碼、狀態維護(狀態改變和狀態恢復)的代碼等。

Catch塊捕獲區,當try塊出現異常時,如果異常類型與該區域期望的類型一致,則執行此區域的代碼,可以進行狀態恢復,也可以重新拋出異常。一個try塊可以個catch塊,也可以無catch塊。

Finally塊作最后清理工作,在一個try/catch 結構中,無論try是否拋出異常,無論catch是否破獲到異常,如果有finally塊,在最后都會執行,通常在這里放置資源清理的代碼。一個try結構可有finally塊,也可以沒有。

如下是一個使用try/catch/finally塊的示例:

FileStream fs = null;
            try
            {

                fs = new FileStream("c:\\file.txt", FileMode.Open);
                fs.ReadByte();
            }
            catch (IOException)
            {

            }
            catch
            { }
            finally
            {
                if (fs != null)
                {
                    fs.Close();
                    fs = null;
                }
            }

需要注意的是,一個try塊必須有一個catch塊或finally塊與其對應,如下幾種使用方式都是可以的:try…catch 、try…finally、try…catch.finally。

 

第二節 CLR處理異常

前面已經說到,一個try塊可以有對應的0個或多個catch塊,如果try塊中無異常拋出,則CLR不會執行任何catch塊。Catch關鍵字后的圓括號中的表達式稱為捕捉類型,每個catch塊只能指定一個捕捉類型,C#要求捕捉類型只能是 System.Ex ception類或其派生類。當try塊出現了異常,CLR會以catch編碼的順序從上向查找與異常類型相匹配的catch 塊,所以“窄”的異常類型應該放在最前面的catch 塊中,最“寬”的異常類型應該放在最后面的catch塊中。假如有如下繼承關系的偽代碼:

Class 類A:類B:類C:System.Exception

我信通常應該如下來放置catch篩選器類型:

try
{
    //
}
catch(A)
{}
catch(B)
{}
catch(C)
{}
finally
{}

如果查找完catch 塊都沒有發現有匹配的類型,CLR會去向更高一層的調用棧查找相匹配的異常類型,如果到了棧的最頂部還未找到,CLR會拋出“未處理異常”。在堆棧上查找的過程中,如果找到了相匹配的catch塊,則執行從拋出異常的try塊開始到匹配異常的catch 塊為止這個范圍內的所有的finally塊(如果有)。注意,此時匹配的catch關聯的finally還未執行,執行完該匹配catch塊內的代碼后才執行此finally塊。

無論拋出哪種異常,其實都是CLR拋出經過包裝后的.NET異常,也就是說CLR已經在內部對這些異常進行過處理,只是以更優雅且強制的形勢拋出來。假如有如下的調用過程:

方法Method0內調用方法Method1,方法Method1內調用方法Method2。如果在方法Method2方法內拋出一個異常,且無與此異常類型匹配的catch塊,則CLR會回溯調用堆棧向Method2、Method1、Method0查找匹配的異常類型,如果找到了則執行相關的finally和catch塊, 如果還未找到,則拋出未處理異常,如下代碼:

        public void Method0()
        {
            try
            {
                Method1();
            }
            catch
            {
                Debug.WriteLine("Method0 catch");
            }
            finally
            {
                Debug.WriteLine("Method0 finally");
            }
        }
        public void Method1()
        {
            try
            {
                Method2();
            }
            catch (NullReferenceException)
            {
                Debug.WriteLine("Method1 catch NullReferenceException");
            }
            catch (FileNotFoundException fileNot)
            {
                Debug.WriteLine("Method1 catch FileNotFoundException");
                throw fileNot;
            }
            finally
            {
                Debug.WriteLine("Method1 finally");
            }
        }
        public void Method2()
        {
            FileStream fs = null;
            try
            {
                //假如c:\\file.txt不存在,這里一定會拋出文件未找到異常
                fs = new FileStream("c:\\file.txt", FileMode.Open);
                fs.ReadByte();
            }
            catch (ArgumentException)
            {
                Debug.WriteLine("Method2 ArgumentException");
            }
            finally
            {
                Debug.WriteLine("Method2 finally");
                if (fs != null)
                    fs.Close();
                fs = null;
            }
        }

在方法Method2內我們僅僅期望捕獲 ArgumentException類型異常,很顯然逮不到任何東西。在方法Method1內我們先期望捕獲NullReferenceException類型異常,如果未逮到,我們期望捕獲FileNotFoundException類型異常,這時可能真的逮到了,接着我們又將該異常拋出,而在上一級調用中,Method0方法內我們使用了異常的基類捕獲范圍超級廣的異常!在APP內調用Method0方法通過打印出來的記錄來看一下執行流程:

        public void DoWork()
        {
            Method0();
        }

打印:

Method2 finally
Method1 catch FileNotFoundException
Method1 finally
Method0 catch
Method0 finally

結果驗證了CLR對異常處理的回溯過程。為了減少回溯的“長度”,建議在方法Method2內趕緊有目的地捕獲可能的異常類型,這樣讓CLR少走點路。

 

第三節 定義異常

在上面我們已經講到,CLR是以拋出異常的方式來報告程序出現問題。早期微軟定義了一個異常的基類:System.Exception,並且還定義了兩個派生類:System.SystemException和System.ApplicationException。它們希望所有系統異常(CLR拋出)都必須派生於System.SystemException類,所有應用程序拋出的異常必須派生於System.ApplicationException類,但后來地方人員不聽從中央人員的管理,結果從上到下都沒很好的遵從這個原則,以至於我們后來在定義自己的異常類型時,直接從System.Exception類派生。大勢已成定局,至於你喜歡從哪個類派生,看你的愛好了,其實在MSDN文檔中已經建議我們應該從System.Exception類派生我們自定義的異常類型。

CLR要求自定義的異常類型必須從System.Exception繼承。

FCL已經定義了很多可能用在各種情景下的異常類型,如常用的:

System. ArgumentException 參數無效時的異常
System. FileNotFoundException 訪問磁盤上不存在的文件時引用的異常
System. IndexOutOfRangeException試圖訪問索引超出數組界限的數組元素時引發的異常

盡管FCL還有很多的異常類型,但這些不一定適合我們開發的需要,比如方便記錄日志、方便調查項目層次、WCF中的異常消息等等,這些特殊的要求可能需要我們定義自己的異常類型。

關於異常System.Exception類本身的信息(如:Message、TargetSite、InnerException等成員)這里就不再描述,可查詢MSDN文檔獲取更多信息。除了自定義異常類必須繼承於System.Exception類以外,這里還給出一些自定義異常類型的建議:

(1) 所有的自定義異常類型名稱應該以Exception后綴;

(2) 類型及其成員據,應該支持可序列化(實際上System.Exception類型是支持序列化的);

(3) 要提供以下三個構造函數:

    public MyException()
    {
    }
    public MyException(string message)
    {
    }
    public MyException(string message, Exception inner)
    {
}

(4) 建議重寫ToString()方法來獲取異常的格式化信息。

(5) 在跨越應用程序邊界的開發環境中,如面向服務開發環境,應該考慮異常的兼容性。

 

第四節 合理地拋出異常

定義異常的目的是在合適的時候拋異常來告訴客戶程序:這里出異常了。通常是在一個方法內拋異常,當方法無法完成預定任務時應該拋出異常,拋出異常時應該拋出明確的異常類型,而永遠不要拋出異常的基類型System.Exception、System.SystemException和System.ApplicationException。在第二節中,我們已經描述過,CLR是自上而下的尋找catch塊,所以我們拋出異常的時候,也應該拋出意義明確的“窄”且“淺”的異常類型,這樣可能會讓CLR盡快找到。另外,在拋出異常類型時,我們應該詳細地描述出現異常的原因、狀況、可能的修復措施等,這樣有利於調用者盡快定位問題,當然在面向服務開發的時候,可能出於安全考慮而不願意詳細描述,此時可以以提前約定的編碼形式拋出異常碼,此時可能需要你向客戶程序提供一個與異常碼對應的描述信息列表。盡量不要使用返回錯誤碼的方式來代替異常,前面已經講過,客戶程序很可能忽略你的返回結果。如程序遇到了致命性的錯誤,請大膽地使用System.Environment.FailFast()方法來毫不留情的終止程序,否則程序以錯誤的狀態運行可能會帶來災難,比如你的無人駕駛飛機可能去火星上找“好奇號”談戀愛。

以下幾個系統保留的異常類型,應該盡量避免拋出:StackOverflowException、OutOfMemoryException、ComException 和 SEHException、ExecutionEngineException。

 

第五節 合理地處理異常

catch塊是專為處理異常而生的,CLR賦予它很強悍無比但不是至高無上的權力,所以我們應該使用catch塊合理恰當的捕獲我們有能力處理的異常,這里給一些建議或許能更好地讓代碼從異常中恢復:

(1) 如果你開發的是基礎類庫,出現異常時,哪怕是客戶程序提供的數據無法讓你完成功能流程時,要雄赳赳氣昂昂地拋出異常,不要忍氣吞聲地吃掉它;

(2) 不要在catch塊內編寫可能再出異常的代碼,除非是拋出異常,也要盡量保證不在finally塊內編寫可能出新異常的代碼;

(3) 不要捕獲你沒能力處理的異常,關閉你的大門,讓CLR盡快點離開向上回溯找到那個像鐵籠賽中拍打着籠門急等着出來拼戰的猛士,他或許能拯救這個世界;

(4) 盡量不要使用catch(Exception)撒天網,它將死的很慘;

(5) 捕獲了異常后,你應該盡快讓代碼數據從異常中恢復,如果不能回復,你應該想辦法讓狀態回滾,否則,要么你就繼續拋出異常,要么就拿出尚方寶劍System.Environment.FailFast();

(6) 如果在捕獲異常后出於某種目的,想再次拋出異常,請保持堆棧信息,這樣方便在上層排查異常;

 

第六節 幾個易混淆的地方

(1) catch塊和catch(Exception)塊

CLS要求所有面向CLR的編程語言都必須拋出繼承於System.Exception的異常類型。在CLR 2.0以前的版本中,catch塊只能捕獲與CLS相容的異常,它無法捕獲與CLS不相容的任何異常(包括其他面向CLR的編程語言拋出的異常)。在CLR2.0中,微軟有了一個新的類型System.Runtime.CompilerServices.RuntimeWrappedException,當一個與CLS不相容的類型拋出時,CLR會實例化一個RuntimeWrappedException類型的對象,將在其私有字段WrappedException中放置非CLS相容的異常,接着拋出RuntimeWrappedException。

catch(Exception e){}塊,在CLR2.0以前可以捕獲所有與CLS相容和不相容的異常,但CLR2.0及以后的版本中,只能捕獲與CLS相容的異常。

Catch{}塊在所有版本中可以捕獲所有與CLS相容和不相容的異常。

(2) throw和throw ex

在前面我們講過,CLR有可能通過回溯堆棧來查找與異常類型一致的catch塊,System.Exception類有一個屬性StackTrace,它記錄了發生異常的堆棧跟蹤信息,它描述的是異常發生前調用的方法。當一個異常拋出時,CLR會記錄拋出異常(throw)的位置,經過CLR回溯找到對應的catch塊后,CLR會再記錄catch的位置,然后在內部會使用StackTrace記錄這兩個起止位置之間的調用(方法)過程。我們對第2節中的方法Method1進行改造,在捕獲了FileNotFoundException類型的異常后,重新拋出該異常:

            catch (FileNotFoundException fileNot)
            {
                Debug.WriteLine("Method1 catch FileNotFoundException");
                throw fileNot;
                //throw;
            }

先用throw fileNot;看看在Method0方法中捕獲到的異常堆棧信息:

再來看看使用throw;拋出異常的堆棧信息:

可以看到,使用throw fileNot;后的堆棧信息是從那個方法為異常堆棧信息的起點,使用throw;后的規模信息是從System.IO.__Error.WinIOError方法為起點,二者無非就是CLR確定異常的起始位置不一樣。

最后要明確一點的是,無論在什么情景下拋出什么樣的異常,Windows都會重置堆棧的起點,我們所拿到的堆棧信息都是最新的起止方法調用記錄信息。

 

小 結


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM