《Effective C#》筆記(5) - 異常處理

語言: CN / TW / HK

程式總是會出錯的,因為即便開發者做得再仔細,也還是會有預料不到的情況發生。令程式碼在發生異常時依然能夠保持穩定是每一位C#程式設計師所應掌握的關鍵技能。 .NET Framework Design Guidelines建議,如果方法不能完成呼叫者所請求的操作,那就可以考慮丟擲異常,此時必須提供各種資訊,使得呼叫者能夠據此診斷問題。 此外,還必須保證如果應用程式能夠從錯誤中恢復,那麼必須處在某種已知的狀態。

考慮在方法約定遭到違背時丟擲異常

如果方法不能夠履行它與呼叫者所訂立的契約,那就應該讓它其丟擲異常。這些無法履約的情況都應該通過異常來表示。然而要注意,由於異常並不適合當作控制程式流程的常規手段,因為丟擲異常開銷很大,而且會導致程式碼中很多try-catch。因此,還應該同時提供另外一套方法,使得開發者可以在執行操作之前先判斷該操作能否順利執行,以便在無法順利執行的情況下采取相應的措施,而不是等到丟擲了異常之後再去處理。 用類庫的的File.Open來舉例,它在無法完成操作時會丟擲異常;但同時也提供了File.Exists來判斷檔案是否存在。所以呼叫者可以在Open前先判斷Exists,當然除了檔案不存在,檔案被佔用、沒有許可權等也會導致Open失敗,這是檔案操作的細節問題,但這種設計思路是可以借鑑的。 假設提供DoWork方法,按照前面的思路,可以這樣實現

public bool TryDoWork()
{
  if(!TestConditions())
    return false;
  DoWork();
  return true;
}

public void DoWork(){...}

public bool TestConditions()
{
  ...
}

專門針對應用程式建立異常

異常是一種用來報告錯誤的機制,有時需要建立自定義的異常。但首先要明確,並不是所有錯誤都必須表示成異常,至於哪些錯誤才需要用異常來表示,並沒有固定的規律可循。一般來說,如果某種狀況必須立刻得到處理或彙報,否則將長期影響應用程式,那麼就應該丟擲異常,比如資料庫發生了資料完整性問題,就需要立刻丟擲異常;但如果只是無法把某個試圖的摺疊、開啟狀態記錄下來,因為不會造成嚴重的影響,則可以考慮只返回錯誤碼。

然後,也不需要為所有的throw語句都新建一種異常類,但統統用Exception基類來丟擲也不合適。 之所以要建立不同的異常類,主要原因就是為了令呼叫端能夠通過不同的catch子句去捕獲那些狀況,從而採用不同的處理方式,所以可以基於這一點來判斷要新建異常類,還是複用已有的類。

一旦決定自己來建立異常類,就必須遵循相應的原則:

  • 繼承Exception基類
  • 子類應該提供與Exception基類相同的建構函式過載,然後把相應的工作委託基類完成

優先考慮做出強異常保證

某個操作在丟擲異常的時候,要負責把自身的狀態管理好,這將直接關係到捕獲異常的人有沒有較大的餘地來處理該異常。

針對異常所做的保證分成三種:

  • 基本保證(basic guarantee),確保當異常離開了產生該異常的函式後,程式中的資源不會洩漏,而且所有的物件都處在有效狀態。這相當於規定了丟擲異常的那個方法在執行完其finally子句之後所必須達成的效果。
  • 強保證(strong guarantee),強保證是在基本保證的基礎上做出的,它要求整個程式的狀態不能因為某操作丟擲異常而有所變化。
  • no-throw保證,執行該操作的那個方法絕對不會丟擲異常。

.NET CLR做出了一些基本的保證,例如會在發生異常時把記憶體管理好。除非你的資源實現了IDisposable介面,否則不太會在這種情況下出現資源洩漏問題。

no-throw保證的例子有finalizer、Dispose方法、catch的when子句,此外編寫委託目標方法時也應對遵守no-throw保證,在這些場合,絕對不應該令任何異常脫離其範圍。

在這三種態度中,強保證是較為折中的,它既允許程式丟擲異常並從中恢復,又使得開發者能夠較為簡便地處理該異常。

在強異常保證下,如果某操作丟擲異常,那麼應用程式的狀態必須和執行該操作之前相同。這項操作要麼完全成功,要麼徹底失敗。如果失敗,那麼程式的狀態應與執行操作之前一模一樣,而不會出現部分成功的情形。 比如在修改集合資料時,為了實現強異常保證,可以考慮先對有待修改的資料做防禦式的拷貝(defensive copy),然後在拷貝出來的資料上面執行操作。如果該操作順利執行而沒有丟擲異常,那麼就用這份資料把原資料替換掉,令程式的狀態得以改變;在發生異常時,原資料還是完整的。 從上面的例子也可知,要想做到強異常保證,往往會降低程式的效能,不過很多時候,從錯誤中恢復的能力,要比效能稍稍得到提升更為重要。

考慮用異常篩選器來改寫先捕獲異常再重新丟擲的邏輯

在catch異常時,有時需要先判斷程式狀態、物件狀態或異常中的屬性,然後再加以處理。通常會想到在catch塊進行判斷,最後再把這個異常重新丟擲。 但更推薦異常篩選器來做,因為使用異常篩選器後,編譯器所生成的程式碼會先評判異常篩選器的值,然後再考慮要不要執行棧展開(stackunwinding),因此,發生異常的原始位置能夠保留下來,而且呼叫棧中的所有資訊(包括區域性變數的值)也可以保持不變。

與之相對的,如果在catch塊中使用throw e重新丟擲,那麼系統所報告的異常發生地點就是throw語句所在的位置,這會導致丟失異常的堆疊資訊,直接throw雖然可以保留原始堆疊的資訊,但這種在catch塊中處理的寫法,每次都會進入catch塊、發生棧展開,這會產生較大的執行開銷。

合理利用異常篩選器的副作用

一般來說,異常篩選器中的條件總是應該能在某些情況下得以滿足,如果永遠都無法滿足,那麼這個篩選器就失去了意義。然而有的時候,為了能監控程式中所發生的異常,還是可以考慮編寫這種永遠返回false的篩選器,此時呼叫棧還沒有真正展開,但卻可以獲取到異常的資訊。 比如可以用於異常的記錄:

public static void Filter()
{
  try
  {
    // ...
  }
  catch (Exception e) when (ForWhen(e)) { }
  catch (FormatException e)
  {
    // handle exception
  }
}

public static bool ForWhen(Exception e)
{
  Console.WriteLine($"captured in when, msg:{e.Message}");
  return false;
}

catch (Exception e) when (ForWhen(e)) { }放到所有的catch之前,可以將所有的異常記錄下來,但這裡也需要注意:

  • 這行程式碼catch的應該是Exception基類,除非有特殊目的只catch某些異常
  • when條件始終返回false
  • 執行when條件判斷的程式碼應做no-throw保證

參考書籍

《Effective C#:改善C#程式碼的50個有效方法(原書第3版)》 比爾·瓦格納

分享到: