1. 程式人生 > >應該丟擲什麼異常?不應該丟擲什麼異常?(.NET/C#)

應該丟擲什麼異常?不應該丟擲什麼異常?(.NET/C#)

我在 .NET/C# 建議的異常處理原則 中描述瞭如何 catch 異常以及重新 throw。然而何時應該 throw 異常,以及應該 throw 什麼異常呢?

究竟是誰錯了?

程式碼中從上到下從裡到外都是在執行一個個的包含某種目的的程式碼,我們將其稱之為“任務”。當需要完成某項任務時,任務的完成情況只有兩種結果:

  1. 成功完成
  2. 失敗

異常處理機制就是處理上面的第 2 種情況。這裡我們不談論錯誤碼系統,那麼,異常便應該在任務執行失敗時丟擲異常。

丟擲異常後,報告錯誤只是手段,真正要做的是幫助開發者修復錯誤。於是,第一個要做的就是區分到底——誰錯了!

  • 任務的使用者用錯了
  • 任務的執行程式碼寫錯了
  • 任務執行時所在的環境不符合預期

簡單說來,就是:使用錯誤,實現錯誤、環境錯誤。

讓我們把異常歸類到這些錯誤中

本文的重點在於指導我們何時應該丟擲什麼異常,也就是說——我們的角色是——任務的編寫者。那麼,編寫者有責任編寫出一段沒有錯誤的程式碼。這就說明——永遠不應該丟擲表示自己寫錯了的異常

那麼,我們對常見的異常進行分類。

使用錯誤

  • ArgumentException 表示引數使用錯了
    • ArgumentNullException 表示引數不應該傳入 null
    • ArgumentOutOfRangeException 表示引數中的序號超出了範圍
    • InvalidEnumArgumentException
      表示引數中的列舉值不正確
  • InvalidOperationException 表示當前狀態下不允許進行此操作(也就是說存在著允許進行此操作的另一種狀態)
    • ObjectDisposedException 表示物件已經 Dispose 過了,不能再使用了
  • NotSupportedException 表示不支援進行此操作(這是在說不要再試圖對這種型別的物件呼叫此方法了,不支援)
    • PlatformNotSupportedException 表示在此平臺下不支援(如果程式跨平臺的話)

實現錯誤

  • NullReferenceException 試圖在空引用上執行某些方法,除了告訴實現者出現了意料之外的 null
    之外,沒有什麼其它價值了
  • IndexOutOfRangeException 使用索引的時候超出了邊界
  • InvalidCastException 表示試圖對某個型別進行強轉但型別不匹配
  • StackOverflow 表示棧溢位,這通常說明實現程式碼的時候寫了不正確的顯式或隱式的遞迴
  • OutOfMemoryException 表示託管堆中已無法分出期望的記憶體空間,或程式已經沒有更多記憶體可用了
  • AccessViolationException 這說明使用非託管記憶體時發生了錯誤
  • BadImageFormatException 這說明了載入的 dll 並不是期望中的託管 dll
  • TypeLoadException 表示型別初始化的時候發生了錯誤

環境錯誤

  • IOException 下的各種子類
  • Win32Exception 下的各種子類
  • ……

無法歸類

不應該丟擲,卻又不得不丟擲的異常:

  • NotImplementedException 這隻能說明此功能還在開發中,一旦進入正式環境,不要丟擲此異常(如果那時真的沒有完成,這個方法就應該刪除)
  • AggregateException 如果可能,真的不要丟擲此異常,因為它本身不包含異常資訊,讓使用者很難正確 catch 這樣的異常。如果內部只有一個異常,應該使用 ExceptionDispatchInfo 將內部異常合併(請參閱 使用 ExceptionDispatchInfo 捕捉並重新丟擲異常 - 呂毅)(Task 在執行多個任務後,如果多個任務都發生了異常,就丟擲了 AggregateException,但這已經是沒有辦法的事情了,因為沒有辦法將兩個可能不是同類的異常合併成一個)

永遠都不應該丟擲異常:

  • FormatException 這算是 .NET 設計上的失誤吧……因為當它丟擲來時無法準確描述到底什麼錯了
  • ApplicationException 這是各種異常的基類,本身並沒有明確的意義
  • SystemException 這是各種異常的基類,本身並沒有明確的意義
  • Exception 這可是頂級基類,這都丟擲來了,使用者再也無法正確地處理此異常了

是時候該決定拋什麼異常了

對於使用錯誤,應該在第一時間丟擲

既然對方已經用錯了,那麼程式碼繼續執行也只會錯上加錯。

public string Foo(Bar demo)
{
    demo.Output("Walterlv");
    return _anotherDemo.ToString();
}

例如上面的方法中使用者傳入了一個 null 引數後,方法必然執行失敗 —— 丟擲了一個 NullReferenceException。但是,當拿著這樣的異常去調查哪裡錯了的時候,我們會發現 demoanotherDemo 都可能為 null。

然而很明顯,這時使用者的錯,使用者確保傳入的引數不為 null,方法就可以繼續執行。

如果在方法的一開始就丟擲使用異常 ArgumentNullException,那麼就可以向使用者報告這樣的引數使用錯誤。

另外的情況,_anotherDemo 是此型別中的另一個欄位,此時也要求必須非 null。而要確保非 null,使用者必須使用其它方式隱式初始化這個欄位,那麼應該丟擲 InvalidOperationException,告訴使用者應該先呼叫其他的某個方法。

那麼,應該改成:

public string Foo([NotNull] Bar demo)
{
    if (demo == null)
        throw new ArgumentNullException(nameof(demo));
    if (_anotherDemo == null)
        throw new InvalidOperationException("必須使用 XXX 設定某個值之後才能使用 Foo 方法。");

    demo.Output("Walterlv");
    return _anotherDemo.ToString();
}

當然,不像 ArgumentNullExceptionInvalidOperationException 通常並不一定能在開始就確定是否滿足狀態要求,但最好能儘可能在第一時間丟擲,避免錯誤蔓延。

做到了第一時間丟擲使用錯誤,就能讓使用者明確知道自己用錯了,需要修改使用程式碼。(這正是被另外一項事實所逼——典型的程式設計師是不看文件的,“使用異常”代替了一部分文件。)

永遠不應該讓實現錯誤丟擲

這一節的標題其實說了三件事情:

  1. 永遠不應該主動用 throw 句式丟擲“實現錯誤”章節中提到的任何異常
  2. 如果你在呼叫某個別人實現的程式碼時遇到了“實現錯誤”章節中提到的異常,那說明“那個人”的程式碼寫出 BUG 了,確信無疑。
  3. 如果自己寫的程式碼發現丟擲了這些異常,那就說明自己寫出了 BUG,需要第一時解決 BUG(是解決,不是逃避)

我們假設實現了這段程式碼:

var button = (Button) sender;
button.Content = "Clicked";

如果在執行到第一句時發生了 InvalidCastException,說明實現程式碼編寫是不正確的。

為了防止發生異常,可能有些人會改成這樣:

// 請注意:這段示例是錯誤的!
if (sender is Button button)
    button.Content = "Clicked";

這是在逃避問題,而不是解決問題!

這是一段典型的事件處理函式程式碼,sender 通常是事件的引發者。寫這段程式碼的人並沒有調查 sender 不是 Button 型別的原因,到底是因為在 Grid 上監聽了路由事件的 Click,還是因為多個控制元件都把事件處理函式設為了這個方法。如果是前者,這樣的改法會讓這段程式碼的全部邏輯失效;如果是後者,這樣的改法會讓部分邏輯失效。

更應該去做的,是去檢查 += 的左邊是否亂入了非 Button 的事件引發者。

grid.Click += OnButtonClick
button.Click += OnButtonClick;

修改這些源頭上就已經不正確的程式碼才是真正解決問題

另一個角度,如果事件的引發者確實可能有多種,那麼事件處理函式就應該加上 else 邏輯,或者不要再使用 sender,或者強制轉換時使用基型別。這也是在真正的解決問題。

額外的,對於 OutOfMemoryException,這通常意味著“實現”部分的程式碼存在著效能問題,應該著手解決。

對於環境錯誤,關注於規避和恢復

環境錯誤是難以提前預估的;或者說預估的成本太高,不值得去預估。於是,當發生了環境錯誤,我們更加關注於這樣的環境中是什麼導致了異常,以及程式是否正確處理了這樣的異常並恢復錯誤

.NET 中已經為我們準備了很多場景下的多套環境異常,例如 IO 相關的異常,網路連線相關的異常。這些異常都不是我們應該丟擲的。

程式中的異常

在異常處理中,每一位開發者應該從根源上在自己的程式碼中消滅“實現異常”(而不是“逃避”),同時在“使用異常”的幫助下正確呼叫其他方法,那麼程式碼中將只剩下“環境異常”(和小部分效能導致的“實現異常”)。

此時,開發者們將有更多的精力關注在“解決的具體業務”上面,而不是不停地解決編碼上的 BUG。

特別的,“實現異常”可以被單元測試進行有效的檢測。