異常和錯誤,乍一看往往會以為是一個事情。為了區分通用的概念,首先要定義本文中的「異常」和「錯誤」。
* 在程序運行中發生了問題,但是這個問題可以通過增加相應的程序邏輯恢復的叫做異常。
*因為程序邏輯問題引起的不可恢復的異常叫錯誤(BUG)。
定義好這兩個概念後,大家可能會疑問為什麼要這樣定義,異常和錯誤在我們通用理解的意義上是一個同義詞,沒有本質的區別。
其實在程序設計的發展過程中,最開始這兩個概念也是沒什麼區別的,但是隨著軟體設計越來越複雜慢慢衍生出來這兩個概念。
首先看下比較早期的C語言。
Assert 這個關鍵字在C語言中很常見,本質上是用來處理程序設計中的錯誤,一旦出現斷言執行,函數提供者是沒有能力恢復這個問題的,需要調用方檢查問題,後面如果程序繼續運行下去,可能發生不可預知的問題。
一旦執行到 Assert ,說明函數的調用方的程序是有bug的,沒有正確的使用函數,顯然是符合我們上面定義的錯誤概念。
再來說下異常,如何在C語言中處理異常的情況?
在調用C函數的時候,如果發生異常,往往使用函數返回值表示:
bool createMap(int num) { if (num > 0) { ... return true; } return false;}調用者看到這種類型的API,往往需要增加 if-else 的判斷,來讓程序正確的執行,其實這就是早期異常處理的方式。
現在很多語言也是用這種方式來處理,但是缺點也很明顯,程序中需要寫大量的 if-else 語句,不利於代碼的閱讀和維護。
為了保證程序的健壯性,同時保證代碼書寫的便捷性。很多高級語言開始定義了異常的概念,例如C++是很早就在語法中定義了exception。
try { createMap(); userMap(); releaseMap();} catch( ExceptionName e ) { handlerExcepiton();}這樣try模塊中,所有可能異常的函數都可以用一行代碼調用,不用像C語言這樣寫大量的 if-else 處理,這對於代碼的閱讀性自然很好,但是同樣存在一個問題—性能。
為什麼大量使用異常處理代碼會引起性能問題呢?
這個就涉及到異常實現的邏輯了,在函數中為了捕獲異常,需要額外的開闢一些空間給異常對象使用。同時異常一旦發生需要中斷函數的調用堆棧,指向異常處理函數,這個過程被稱為堆棧展開。
「當我們調用某些函數時,它將地址存儲到調用堆棧中,從函數返回後,需要彈出該地址以開始其剩餘的工作。堆棧展開是一個在運行時刪除函數調用堆棧條目的過程。要刪除堆棧元素,我們可以使用異常。如果內部函數引發異常,則將刪除堆棧的所有條目,並返回到主調用程序函數。」
除了上述這個開銷,還有一個開銷是因為異常出現的函數,可能很早就被調用了,但是很晚才開始使用catch捕獲,這時候就需要把異常數據層層傳遞給需要處理的函數。
所以從上面來看這個異常的處理過程,對比一個 if-else 簡單的語句,必然是很消耗性能的。對於C++這種性能要求比較高的程序語言,異常這個性能問題一直被詬病。
所以C++在使用異常的時候有很多約束,也造成了異常處理在C++語法中,很難廣泛的使用。下面列下微軟對C++異常處理使用的建議:
使用斷言來檢查絕不應發生的錯誤: 使用異常來檢查可能出現的錯誤,例如,公共函數參數的輸入驗證中的錯誤。
當處理錯誤的代碼與通過一個或多個幹預函數調用檢測到錯誤的代碼分離時,使用異常。當處理錯誤的代碼與檢測到錯誤的代碼緊密耦合時,考慮是否使用錯誤代碼而不是在性能關鍵循環中。
對於可能引發或傳播異常的每個函數,請提供以下三種異常保證之一:強保障、基本保證或 nothrow (noexcept) 保證。
按值引發異常,按引用來捕獲異常, 不要捕獲無法處理的內容
應用使用標準庫異常類型, 從 exception 類層次結構派生自定義異常類型。
並且C++是兼容C語言的,C++裡一些庫是用C實現的,如果引入異常的語法,是需要兼容很多C的庫,可以看出來異常在C++中使用的難處了。
引用一下C++創始人施特勞斯的原話,可以看出異常處理的難言之隱。
「對於異常處理的性能問題,其實是一個很有爭議的問題,有人覺得異常處理是多做了一些工作,肯定對性能是有影響的。但是也有人覺得異常處理的影響,和增加一個 if-else 屬於同種量級,對性能的影響其實微乎其微,是在可以接受的範圍內的。強大的錯誤處理對於任何程式語言都很有挑戰性。儘管異常提供了多個支持良好錯誤處理的功能,但它們無法為你完成所有工作。若要實現異常機制的優點,請在設計代碼時記住異常。」
雖然通用的異常處理對於性能有一定消耗,但是它的優點還是很誘人的,尤其對於不依賴C語言的高級語言,沒有了兼容性的負擔,好處更是大於缺點。
下面列出來對於很多高級語言,需要異常處理語法的理由:
# 代碼的閱讀更順利。
# 如果不用通用的異常處理,構造函數初始化異常必須要開發者處理。
# 可以在運行時,減少錯誤崩潰的發生。
# 沒有通用異常處理,API需要寫大量的處理函數返回值的邏輯,如果是異步的需要大量的callback。
# 沒有兼容C語言的負擔。
基於上面的優點像java,C#,swift這些強類型語言都有異常處理語法,其中java算是比較早的完善了異常處理的語法。但是各個語言異常處理的邏輯還是有細微的差別,這裡拿java和swift語言對比下各自的異常處理。
try { int[] array = new int[] { 1, 2, 3 }; System.out.println(array[3]);} catch (Exception e) { e.printStackTrace();}在java中如果執行這段代碼會列印出 ArrayIndexOutOfBoundsException 異常,異常可以被捕獲不會崩潰。
下面看下swift同樣想捕獲數組越界的異常
let arrayList = [0]do { print(arrayList[1])} catch { print("Array out of bounds")}編譯時會拋出來 『catch』 block is unreachable because no errors are thrown in 『do』 block 這個警告,本質上編譯器是不處理這個異常的。然而運行時,會崩潰拋出 Fatal error: Index out of range 。
因為swift語言的設計理念認為數組越界是程序編寫的邏輯錯誤,一旦出現就無法恢復程序正常的邏輯,所以理應崩潰,便於開發者發現問題。
而java語言是認為這個異常,如果程序捕獲了就代表有能力處理恢復這個異常。所以相對來講java的異常的範圍更寬泛,而swift相對比較苛刻。
從上面的對比可以看出,java語言設計更傾向於程序的開發便捷性和安全性,可以適當犧牲性能的開銷。
還有一個明顯的例子在java中,如果一個函數做了如下的異常定義:
public static void createMap(int x) throws Exception{ if(x<0) throw new Exception("Map must be greater than zero"); else...}函數調用者必須使用如下的方式處理,否則編譯會報錯:
try { createMap();} catch (Exception e) { System.out.println(e);}java語言設計者認為:如果函數拋出了異常,那麼代表著調用者有能力去恢復這個異常,這保證了程序的健壯性。
雖然swift設計和java這點類似,函數拋異常不處理就會編譯錯誤。但是可以使用 try! 這個便捷的語法忽略異常,這就代表swift還是要考慮異常性能的開銷。
不過事物都有兩面性,使用了 try!一旦出現異常程序就要崩潰,這會對程式設計師的代碼質量進行嚴格考驗。
最後想說下Rust語言異常處理的邏輯,和大多數 try-catch 設計方式還是不一樣的,下面是官方的解釋:
「Rust 有一套獨特的處理異常情況的機制,它並不像其它語言中的 try 機制那樣簡單。首先,程序中一般會出現兩種錯誤:可恢復錯誤和不可恢復錯誤。可恢復錯誤的典型案例是文件訪問錯誤,如果訪問一個文件失敗,有可能是因為它正在被佔用,是正常的,我們可以通過等待來解決。但還有一種錯誤是由編程中無法解決的邏輯錯誤導致的,例如訪問數組末尾以外的位置。大多數程式語言不區分這兩種錯誤,並用 Exception (異常)類來表示錯誤。在 Rust 中沒有 Exception。對於可恢復錯誤用 Result類來處理,對於不可恢復錯誤使用 panic! 宏來處理。」
所以rust語言對異常處理感覺更符合異常定義的本質,只是對於開發者來講可能會麻煩些,寫之前要分清楚異常和錯誤這個概念了,不能無腦的寫 try-catch 了。不過這樣也會讓程序的安全性、健壯性、性能開銷等方面更加優秀。
所以我們在寫代碼的時候,尤其是寫對外使用的API時,一定要先搞清楚這個API的使用過程中可能產生的異常和錯誤。這裡我列出來幾個日常設計的點。
因為接口設計時,往往對數據的要求是寬進嚴出,以方便其他程序將輸出作為輸入,說白了就是「把複雜留給自己,把簡單留給別人」,但是哪些參數是需要處理為異常呢 ? 這裡舉個簡單的例子:
void answerCall(Person person) {
if (person isNot Person) { Assert("error"); } if (!person.iscomming) { return; }
...}可以看出來上面的接聽電話的邏輯,首先接收的參數不符合函數的定義那肯定是調用方的bug,應該拋出錯誤。
不過現在很多程式語言基本都會做類型檢測,不符合就會編譯出錯或者警告,所以這種防禦不太會寫。
再看第二個條件判斷,接聽的電話不是正在來電的用戶,那說明這個參數也是有問題的,需要告訴調用者這個異常。
在這裡 return 就顯得不是很合理,因為這個異常其實被隱藏了,上層感知不到就無法排查錯誤。
如果按照C異常的常用寫法就需要給answerCall增加一個是否成功的返回值。
如果是java等高級語言只要如下寫:
void answerCall(Person person) throws Exception{
if (!person.iscomming) { throw new Exception(); return; }
...}這個原則說白了,就是不要出現中間狀態,保證出現異常後所有的狀態恢復成原始狀態。
我們繼續看上面的例子。接聽的參數檢測成功後,進入接聽狀態,首先改變接聽者本地的狀態,然後請求網絡,最後接聽成功。
假如說上層的API連續調用兩次,為了防止頻繁做網絡請求做了下面的防禦編程。
void answerCall(Person person) throws Exception{
if (!person.iscomming) { throw new Exception(); return; }
... changeStatus(); if (self.isAnsering) { return; } requestAccept(); reportAccept();}這種方式其實就違背了「不要增加無用的輸出原則」。因為changeStatus()被調用了兩次,一旦有地方監聽這個狀態變化可能就造成未知的問題。
這時候最好的選擇,就是和參數攔截一樣,在最開始的位置就拋出異常。
void answerCall(Person person) throws Exception{
if (!person.iscomming) { throw new Exception(); }
if (self.isAnsering) { throw new Exception(); }
changeStatus(); requestAccept(); reportAccept();}當函數的提供方認為多次調用這種異常,自己可以處理,比如直接返回就代表處理了這個異常,上層可以不感知:
void answerCall(Person person) throws Exception{
if (!person.iscomming) { throw new Exception(); }
if (self.isAnsering) { return; }
changeStatus(); requestAccept(); reportAccept();}其實函數的冪等性要求,本質上就是對多次輸入這種異常處理的過程。這種寫法代表接收方處理了這種錯誤,不需要調用方關心,並且保證每次調用都返回同樣的結果。
假如一些程序由於特殊狀態的原因,無法應用冪等性原理就應該及時拋出異常,告訴上層的調用者出了問題,讓上層調用者來恢復這個操作。
記住錯誤是讓程序以最低成本恢復正常的一種方式。如果你定義的API有嚴格的狀態維護,讓程序及時的崩潰比拋出異常更容易發現問題
斷言是一種出現嚴重錯誤才會使用的方式,一旦使用斷言就代表調用方的程序一定是出了bug,並且接收方無法處理這種情況,需要及時的終止程序以便於調用方修改此處的bug。
不過現在很多軟體為了用戶體驗,減少閃退的次數,往往斷言在release版本中會移除掉。帶來的後果是出問題的代碼被隱藏了,一些奇怪的問題就很難及時發現。所以經常有開發者在爭論斷言是否應該使用在release版本。
據說NASA和很多銀行系統的軟體,斷言都會在release版本中,因為這些系統對安全性要求極高,一旦出了bug需要立即終止,否則可能造成不可估量的災難。
而對於安全性要求不高的系統,為了獲得更高的用戶體驗往往release版本會移除斷言。不過孰對孰錯怕是永遠沒有完美的答案。
上面分析了這麼多,那我們實際編碼中,如何對異常處理呢?是否需要寫大量的 if-else 或 try-catch語句呢?
其實本質上來講,可以按照下面的原則:
1.首要要看產生的問題是因為調用者不正確的使用API造成的?如果是就用斷言攔截。
2.如果調用一個API,某些異常無法避免(比如很多IO設備的讀取,很可能因為硬體不可用的問題造成。這種往往無法避免),這時候遵循的原則:看是否應該讓調用者來感知這個異常?並有能力恢復這個問題?如果是就果斷拋出異常。
3.如果異常,函數提供者本身可以處理,那麼就處理掉,不用再拋給上層。比如接口冪等性的設計,就是處理這種異常。