計算機程式的思維邏輯 (25) - 異常 (下)

swiftma發表於2016-09-23

本系列文章經補充和完善,已修訂整理成書《Java程式設計的邏輯》(馬俊昌著),由機械工業出版社華章分社出版,於2018年1月上市熱銷,讀者好評如潮!各大網店和書店有售,歡迎購買:京東自營連結

計算機程式的思維邏輯 (25) - 異常 (下)

上節我們介紹了異常的基本概念和異常類,本節我們進一步介紹對異常的處理,我們先來看Java語言對異常處理的支援,然後探討在實際中到底應該如何處理異常。

異常處理

catch匹配

上節簡單介紹了使用try/catch捕獲異常,其中catch只有一條,其實,catch還可以有多條,每條對應一個異常型別,比如說:

try{
    //可能觸發異常的程式碼
}catch(NumberFormatException e){
    System.out.println("not valid number");
}catch(RuntimeException e){
    System.out.println("runtime exception "+e.getMessage());
}catch(Exception e){
    e.printStackTrace();
}
複製程式碼

異常處理機制將根據丟擲的異常型別找第一個匹配的catch塊,找到後,執行catch塊內的程式碼,其他catch塊就不執行了,如果沒有找到,會繼續到上層方法中查詢。需要注意的是,丟擲的異常型別是catch中宣告異常的子類也算匹配,所以需要將最具體的子類放在前面,如果基類Exception放在前面,則其他更具體的catch程式碼將得不到執行。

示例也演示了對異常資訊的利用,e.getMessage()獲取異常訊息,e.printStackTrace()列印異常棧到標準錯誤輸出流。通過這些資訊有助於理解為什麼會出異常,這是解決程式設計錯誤的常用方法。示例是直接將資訊輸出到標準流上,實際系統中更常用的做法是輸出到專門的日誌中。

重新throw

在catch塊內處理完後,可以重新丟擲異常,異常可以是原來的,也可以是新建的,如下所示:

try{
    //可能觸發異常的程式碼
}catch(NumberFormatException e){
    System.out.println("not valid number");
    throw new AppException("輸入格式不正確", e);
}catch(Exception e){
    e.printStackTrace();
    throw e;
}
複製程式碼

對於Exception,在列印出異常棧後,就通過throw e重新丟擲了。

而對於NumberFormatException,我們重新丟擲了一個AppException,當前Exception作為cause傳遞給了AppException,這樣就形成了一個異常鏈,捕獲到AppException的程式碼可以通過getCause()得到NumberFormatException。

為什麼要重新丟擲呢?因為當前程式碼不能夠完全處理該異常,需要呼叫者進一步處理。

為什麼要丟擲一個新的異常呢?當然是當前異常不太合適,不合適可能是資訊不夠,需要補充一些新資訊,還可能是過於細節,不便於呼叫者理解和使用,如果呼叫者對細節感興趣,還可以繼續通過getCause()獲取到原始異常。

finally

異常機制中還有一個重要的部分,就是finally, catch後面可以跟finally語句,語法如下所示:

try{
    //可能丟擲異常
}catch(Exception e){
    //捕獲異常
}finally{
    //不管有無異常都執行
}
複製程式碼

finally內的程式碼不管有無異常發生,都會執行。具體來說:

  • 如果沒有異常發生,在try內的程式碼執行結束後執行。
  • 如果有異常發生且被catch捕獲,在catch內的程式碼執行結束後執行
  • 如果有異常發生但沒被捕獲,則在異常被拋給上層之前執行。

由於finally的這個特點,它一般用於釋放資源,如資料庫連線、檔案流等。

try/catch/finally語法中,catch不是必需的,也就是可以只有try/finally,表示不捕獲異常,異常自動向上傳遞,但finally中的程式碼在異常發生後也執行。

finally語句有一個執行細節,如果在try或者catch語句內有return語句,則return語句在finally語句執行結束後才執行,但finally並不能改變返回值,我們來看下程式碼:

public static int test(){
    int ret = 0;
    try{
        return ret;
    }finally{
        ret = 2;
    }
}
複製程式碼

這個函式的返回值是0,而不是2,實際執行過程是,在執行到try內的return ret;語句前,會先將返回值ret儲存在一個臨時變數中,然後才執行finally語句,最後try再返回那個臨時變數,finally中對ret的修改不會被返回。

如果在finally中也有return語句呢?try和catch內的return會丟失,實際會返回finally中的返回值。finally中有return不僅會覆蓋try和catch內的返回值,還會掩蓋try和catch內的異常,就像異常沒有發生一樣,比如說:

public static int test(){
    int ret = 0;
    try{
        int a = 5/0;
        return ret;
    }finally{
        return 2;
    }
}
複製程式碼

以上程式碼中,5/0會觸發ArithmeticException,但是finally中有return語句,這個方法就會返回2,而不再向上傳遞異常了。

finally中不僅return語句會掩蓋異常,如果finally中丟擲了異常,則原異常就會被掩蓋,看下面程式碼:

public static void test(){
    try{
        int a = 5/0;
    }finally{
        throw new RuntimeException("hello");
    }
}
複製程式碼

finally中丟擲了RuntimeException,則原異常ArithmeticException就丟失了。

所以,一般而言,為避免混淆,應該避免在finally中使用return語句或者丟擲異常,如果呼叫的其他程式碼可能丟擲異常,則應該捕獲異常並進行處理。

throws

異常機制中,還有一個和throw很像的關鍵字throws,用於宣告一個方法可能丟擲的異常,語法如下所示:

public void test() throws AppException, SQLException, NumberFormatException {
    //....
}
複製程式碼

throws跟在方法的括號後面,可以宣告多個異常,以逗號分隔。這個宣告的含義是說,我這個方法內可能丟擲這些異常,我沒有進行處理,至少沒有處理完,呼叫者必須進行處理。這個宣告沒有說明,具體什麼情況會丟擲什麼異常,作為一個良好的實踐,應該將這些資訊用註釋的方式進行說明,這樣呼叫者才能更好的處理異常。

對於RuntimeException(unchecked exception),是不要求使用throws進行宣告的,但對於checked exception,則必須進行宣告,換句話說,如果沒有宣告,則不能丟擲。

對於checked exception,不可以丟擲而不宣告,但可以宣告丟擲但實際不丟擲,不丟擲宣告它幹嘛?主要用於在父類方法中宣告,父類方法內可能沒有丟擲,但子類重寫方法後可能就丟擲了,子類不能丟擲父類方法中沒有宣告的checked exception,所以就將所有可能丟擲的異常都寫到父類上了。

如果一個方法內呼叫了另一個宣告丟擲checked exception的方法,則必須處理這些checked exception,不過,處理的方式既可以是catch,也可以是繼續使用throws,如下程式碼所示:

public void tester() throws AppException {
    try {
        test();
    }  catch (SQLException e) {
        e.printStackTrace();
    }
} 
複製程式碼

對於test丟擲的SQLException,這裡使用了catch,而對於AppException,則將其新增到了自己方法的throws語句中,表示當前方法也處理不了,還是由上層處理吧。

Checked對比Unchecked Exception

以上,可以看出RuntimeException(unchecked exception)和checked exception的區別,checked exception必須出現在throws語句中,呼叫者必須處理,Java編譯器會強制這一點,而RuntimeException則沒有這個要求。

為什麼要有這個區分呢?我們自己定義異常的時候應該使用checked還是unchecked exception啊?對於這個問題,業界有各種各樣的觀點和爭論,沒有特別一致的結論。

一種普遍的說法是,RuntimeException(unchecked)表示程式設計的邏輯錯誤,程式設計時應該檢查以避免這些錯誤,比如說像空指標異常,如果真的出現了這些異常,程式退出也是正常的,程式設計師應該檢查程式程式碼的bug而不是想辦法處理這種異常。Checked exception表示程式本身沒問題,但由於I/O、網路、資料庫等其他不可預測的錯誤導致的異常,呼叫者應該進行適當處理。

但其實程式設計錯誤也是應該進行處理的,尤其是,Java被廣泛應用於伺服器程式中,不能因為一個邏輯錯誤就使程式退出。所以,目前一種更被認同的觀點是,Java中的這個區分是沒有太大意義的,可以統一使用RuntimeException即unchcked exception來代替。

這個觀點的基本理由是,無論是checked還是unchecked異常,無論是否出現在throws宣告中,我們都應該在合適的地方以適當的方式進行處理,而不是隻為了滿足編譯器的要求,盲目處理異常,既然都要進行處理異常,checked exception的強制宣告和處理就顯得囉嗦,尤其是在呼叫層次比較深的情況下。

其實觀點本身並不太重要,更重要的是一致性,一個專案中,應該對如何使用異常達成一致,按照約定使用即可。Java中已有的異常和類庫也已經在哪裡,我們還是要按照他們的要求進行使用。

如何使用異常

針對異常,我們介紹了try/catch/finally, catch匹配、重新丟擲、throws、checked/unchecked exception,那到底該如何使用異常呢?

異常應該且僅用於異常情況

這個含義是說,異常不能代替正常的條件判斷。比如說,迴圈處理陣列元素的時候,你應該先檢查索引是否有效再進行處理,而不是等著丟擲索引異常再結束迴圈。對於一個引用變數,如果正常情況下它的值也可能為null,那就應該先檢查是不是null,不為null的情況下再進行呼叫。

另一方面,真正出現異常的時候,應該丟擲異常,而不是返回特殊值,比如說,我們看String的substring方法,它返回一個子字串,它的程式碼如下:

public String substring(int beginIndex) {
    if (beginIndex < 0) {
        throw new StringIndexOutOfBoundsException(beginIndex);
    }
    int subLen = value.length - beginIndex;
    if (subLen < 0) {
        throw new StringIndexOutOfBoundsException(subLen);
    }
    return (beginIndex == 0) ? this : new String(value, beginIndex, subLen);
}
複製程式碼

程式碼會檢查beginIndex的有效性,如果無效,會丟擲StringIndexOutOfBoundsException。純技術上一種可能的替代方法是不拋異常而返回特殊值null,但beginIndex無效是異常情況,異常不能假裝當正常處理

異常處理的目標

異常大概可以分為三個來源:使用者、程式設計師、第三方。使用者是指使用者的輸入有問題,程式設計師是指程式設計錯誤,第三方泛指其他情況如I/O錯誤、網路、資料庫、第三方服務等。每種異常都應該進行適當的處理。

處理的目標可以分為報告和恢復。恢復是指通過程式自動解決問題。報告的最終物件可能是使用者,即程式使用者,也可能是系統運維人員或程式設計師。報告的目的也是為了恢復,但這個恢復經常需要人的參與。

對使用者,如果使用者輸入不對,可能提示使用者具體哪裡輸入不對,如果是程式設計錯誤,可能提示使用者系統錯誤、建議聯絡客服,如果是第三方連線問題,可能提示使用者稍後重試。

對系統運維人員或程式設計師,他們一般不關心使用者輸入錯誤,而關注程式設計錯誤或第三方錯誤,對於這些錯誤,需要報告儘量完整的細節,包括異常鏈、異常棧等,以便儘快定位和解決問題。

對於使用者輸入或程式設計錯誤,一般都是難以通過程式自動解決的,第三方錯誤則可能可以,甚至很多時候,程式都不應該假定第三方是可靠的,應該有容錯機制。比如說,某個第三方服務連線不上(比如發簡訊),可能的容錯機制是,換另一個提供同樣功能的第三方試試,還可能是,間隔一段時間進行重試,在多次失敗之後再報告錯誤。

異常處理的一般邏輯

如果自己知道怎麼處理異常,就進行處理,如果可以通過程式自動解決,就自動解決,如果異常可以被自己解決,就不需要再向上報告。

如果自己不能完全解決,就應該向上報告。如果自己有額外資訊可以提供,有助於分析和解決問題,就應該提供,可以以原異常為cause重新丟擲一個異常。

總有一層程式碼需要為異常負責,可能是知道如何處理該異常的程式碼,可能是面對使用者的程式碼,也可能是主程式。如果異常不能自動解決,對於使用者,應該根據異常資訊提供使用者能理解和對使用者有幫助的資訊,對運維和程式設計師,則應該輸出詳細的異常鏈和異常棧到日誌。

這個邏輯與在公司中處理問題的邏輯是類似的,每個級別都有自己應該解決的問題,自己能處理的自己處理,不能處理的就應該報告上級,把下級告訴他的,和他自己知道的,一併告訴上級,最終,公司老闆必須要為所有問題負責。每個級別既不應該掩蓋問題,也不應該逃避責任。

小結

上節和本節介紹了Java中的異常機制。在沒有異常機制的情況下,唯一的退出機制是return,判斷是否異常的方法就是返回值。

方法根據是否異常返回不同的返回值,呼叫者根據不同返回值進行判斷,並進行相應處理。每一層方法都需要對呼叫的方法的每個不同返回值進行檢查和處理,程式的正常邏輯和異常邏輯混雜在一起,程式碼往往難以閱讀理解和維護。

另外,因為異常畢竟是少數情況,程式設計師經常偷懶,假定異常不會發生,而忽略對異常返回值的檢查,降低了程式的可靠性。

在有了異常機制後,程式的正常邏輯與異常邏輯可以相分離,異常情況可以集中進行處理,異常還可以自動向上傳遞,不再需要每層方法都進行處理,異常也不再可能被自動忽略,從而,處理異常情況的程式碼可以大大減少,程式碼的可讀性、可靠性、可維護性也都可以得到提高。

至此,關於Java語言本身的主要概念我們就介紹的差不多了,接下來的幾節中,我們介紹Java中一些常用的類及其操作,從包裝類開始。


未完待續,檢視最新文章,敬請關注微信公眾號“老馬說程式設計”(掃描下方二維碼),深入淺出,老馬和你一起探索Java程式設計及計算機技術的本質。用心原創,保留所有版權。

計算機程式的思維邏輯 (25) - 異常 (下)

相關文章