歡迎光臨
每天分享高質量文章

Java 異常進階

(點選上方公眾號,可快速關註)


來源:decaywood ,

blog.decaywood.me/2016/09/29/Java-exception-advanced/

在使用Java編寫應用的時候,我們常常需要透過第三方類庫來幫助我們完成所需要的功能。有時候這些類庫所提供的很多API都透過throws宣告了它們所可能丟擲的異常。但是在檢視這些API的檔案時,我們卻沒有辦法找到有關這些異常的詳盡解釋。在這種情況下,我們不能簡單地忽略這些由throws所宣告的異常:

public void shouldNotThrowCheckedException() {

    // 該API呼叫可能丟擲一個不明原因的Checked Exception

    exceptionalAPI();

}

否則Java編譯器會由於shouldNotThrowCheckedException()函式沒有宣告其可能丟擲的Checked Exception而報錯。但是如果透過throws標明瞭該函式所可能丟擲的Checked Exception,那麼其它對shouldNotThrowCheckedException()函式的呼叫同樣需要透過throws標明其可能丟擲該Checked Exception。

那我們應該如何對這些Checked Exception進行處理呢?在本文中,我們將對如何在Java應用中使用及處理Checked Exception進行簡單地介紹。

Java異常簡介

在詳細介紹Checked Exception所導致的問題之前,我們先用一小段篇幅簡單介紹一下Java中的異常。

在Java中,異常主要分為三種:Exception,RuntimeException以及Error。這三類異常都是Throwable的子類。直接從Exception派生的各個異常型別就是我們剛剛提到的Checked Exception。它的一個比較特殊的地方就是強制呼叫方對該異常進行處理。就以我們常見的用於讀取一個檔案內容的FileReader類為例。在該類的建構式宣告中宣告了其可能會丟擲FileNotFoundException:

public FileReader(String fileName) throws FileNotFoundException {

    ……

}

那麼在呼叫該建構式的函式中,我們需要透過try…catch…來處理該異常:

public void processFile() {

    try {

        FileReader fileReader = new FileReader(inFile);

    } catch(FileNotFoundException exception) {

        // 異常處理邏輯

    }

    ……

}

如果我們不透過try…catch…來處理該異常,那麼我們就不得不在函式宣告中透過throws標明該函式會丟擲FileNotFoundException:

public void processFile() throws FileNotFoundException {

    FileReader fileReader = new FileReader(inFile); // 可能丟擲FileNotFoundException

    ……

}

而RuntimeException類的各個派生類則沒有這種強制呼叫方對異常進行處理的需求。為什麼這兩種異常會有如此大的區別呢?因為RuntimeException所表示的是軟體開發人員沒有正確地編寫程式碼所導致的問題,如陣列訪問越界等。而派生自Exception類的各個異常所表示的並不是程式碼本身的不足所導致的非正常狀態,而是一系列應用本身也無法控制的情況。例如一個應用在嘗試開啟一個檔案並寫入的時候,該檔案已經被另外一個應用開啟從而無法寫入。對於這些情況,Java透過Checked Exception來強制軟體開發人員在編寫程式碼的時候就考慮對這些無法避免的情況的處理,從而提高程式碼質量。

而Error則是一系列很難透過程式解決的問題。這些問題基本上是無法恢復的,例如記憶體空間不足等。在這種情況下,我們基本無法使得程式重新回到正常軌道上。因此一般情況下,我們不會對從Error類派生的各個異常進行處理。而且由於其實際上與本文無關,因此我們不再對其進行詳細講解。

天使變惡魔

既然Java中的Checked Exception能夠提高使用者程式碼質量,為什麼還有那麼多人反對它呢?原因很簡單:它太容易被誤用了。而在本節中,我們就將列出這些誤用情況並提出相應的網路上最為推薦的解決方案。

無處不在的throws

第一種誤用的情況就是Checked Exception的廣泛傳播。在前面已經提到過,呼叫一個可能丟擲Checked Exception的API時,軟體開發人員可以有兩種選擇。其中一種選擇就是在對該API進行呼叫的函式上新增throws宣告,並將該Checked Exception向上傳遞:

public void processFile() throws FileNotFoundException {

    FileReader fileReader = new FileReader(inFile); // 可能丟擲FileNotFoundException

    ……

}

而在呼叫processFile()函式的程式碼中,軟體開發人員可能覺得這裡還不是處理異常FileNotFoundException的合適地點,因此他透過throws將該異常再次向上傳遞。但是在一個函式上新增throws意味著其它對該函式進行呼叫的程式碼同樣需要處理該throws宣告。在一個程式碼復用性比較好的系統中,這些throws會非常快速地蔓延開來。如果不去處理Checked Exception,而是將其透過throws丟擲,那麼會有越來越多的函式受到影響。在這種情況下,我們要在多處對該Checked Exception進行處理。

如果在蔓延的過程中所遇到的是一個函式的多載或者介面的實現,那麼事情就會變得更加麻煩了。這是因為一個函式宣告中的throws實際上是函式簽名的一部分。如果在函式多載或介面實現中添加了一個throws,那麼為了保持原有的關係,被多載的函式或被實現的介面中的相應函式同樣需要新增一個throws宣告。而這樣的改動則會導致其它函式多載及介面實現同樣需要更改。

在上圖中,我們顯示了在一個介面宣告中新增throws的嚴重後果。在一開始,我們在應用中實現了介面函式Interface::method()。此時在應用以及第三方應用中擁有六種對它的實現。但是如果A::method()的實現中丟擲了一個Checked Exception,那麼其就會要求介面中的相應函式也新增該throws宣告。一旦在介面中添加了throws宣告,那麼在應用以及第三方應用中的所有對該介面的實現都需要新增該throws宣告,即使在這些實現中並不存在可能丟擲該異常的函式呼叫。

那麼我們應該怎麼解決這個問題呢?首先,我們應該儘早地對Checked Exception進行處理。這是因為隨著Checked Exception沿著函式呼叫的軌跡向上傳遞的過程中,這些被丟擲的Checked Exception的意義將逐漸模糊。例如在startupApplication()函式中,我們可能需要讀取使用者的配置檔案來根據使用者的原有偏好配置應用。由於該段邏輯需要讀取使用者的配置檔案,因此其內部邏輯在執行時將可能丟擲FileNotFoundException。如果這個FileNotFoundException沒有及時地被處理,那麼startupApplication()函式的簽名將如下所示:

public void startupApplication() throws FileNotFoundException {

    ……

}

在啟動一個應用的時候可能會產生一個FileNotFoundException異常?是的,這很容易理解,但是到底哪裡發生了異常?讀取偏好檔案的時候還是載入Dll的時候?應用或使用者需要針對該異常進行什麼樣的處理?此時我們所能做的只能是透過分析該異常實體中所記錄的資訊來判斷到底哪裡有異常。

反過來,如果我們在產生Checked Exception的時候立即對該異常進行處理,那麼此時我們將擁有有關該異常的最為豐富的資訊:

public void readPreference() {

    ……

    try {

        FileReader fileReader = new FileReader(preferenceFile);

    } catch(FileNotFoundException exception) {

        // 在日誌中新增一條記錄並使用預設設定

    }

    ……

}

但是在使用者那裡看來,他曾經所設定的偏好在這次使用時候已經不再有效了。這是我們的程式在執行時所產生的異常情況,因此我們需要通知使用者:因為原來的偏好檔案不再存在了,因此我們將使用預設的應用設定。而這一切則是透過一個在我們的應用中定義的RuntimeException類的派生類來完成的:

public void readPreference() {

    ……

    try {

        FileReader fileReader = new FileReader(preferenceFile);

    } catch(FileNotFoundException exception) {

        logger.log(“Could not find user preference setting file: {0}”, preferenceFile);

        throw ApplicationSpecificException(PREFERENCE_NOT_FOUND, exception);

    }

    ……

}

可以看到,此時在catch塊中所丟擲的ApplicationSpecificException異常中已經包含了足夠多的資訊。這樣,我們的應用就可以透過捕獲ApplicationSpecificException來統一處理它們並將最為詳盡的資訊顯示給使用者,從而通知他因為無法找到偏好檔案而使用預設設定:

try {

    startApplication();

} catch(ApplicationSpecificException exception) {

    showWarningMessage(exception.getMessage());

}

手足無措的API使用者

另一種和Checked Exception相關的問題就是對它的隨意處理。在前面的講解中您或許已經知道了,如果一個Checked Exception不能在對API進行呼叫的函式中被處理,那麼該函式就需要新增throws宣告,從而導致多處程式碼需要針對該Checked Exception進行修改。那麼好,為了避免這種情況,我們就儘早地對它進行處理。但是在檢視該API檔案的時候,我們卻發現檔案中並沒有新增任何有關該Checked Exception的詳細解釋:

/**

 * ……

 * throws SomeCheckedException

 */

public void someFunction() throws SomeCheckedException {

    ……

}

而且我們也沒有辦法從該函式的簽名中看出到底為什麼這個函式會丟擲該異常,進而也不知道該異常是否需要對使用者可見。在這種情況下,我們只有截獲它併在日誌中新增一條記錄了事:

try {

    someFunction();

} catch(SomeCheckedException exception) {

    // 在日誌中新增一條記錄

}

很顯然,這並不是一種好的做法。而這一切的根本原因則是沒有說清楚到底為什麼函式會丟擲該Checked Exception。因此對於一個API編寫者而言,由於throws也是函式宣告的一部分,因此為一個函式所能丟擲的Checked Exception新增清晰準確的檔案實際上是非常重要的。

疲於應付的API使用者

除了沒有清晰的檔案之外,另一種讓API使用者非常抵觸的就是過度地對Checked Exception進行使用。

或許您已經接觸過類似的情況:一個類庫中用於取得資料的API,如getData(int index),透過throws丟擲一個異常,以表示API使用者所傳入的引數index是一個非法值。可以想象得到的是,由於getData()可能會被非常頻繁地使用,因此軟體開發人員需要在每一處呼叫都使用try … catch …塊來截獲該異常,從而使程式碼顯得凌亂不堪。

如果一個類庫擁有一個這樣的API,那麼該類庫中的這種對Checked Exception的不恰當使用常常不止一個。那麼該類庫的這些API會大量地汙染使用者程式碼,使得這些使用者程式碼中充斥著不必要也沒有任何意義的try…catch…塊,進而讓程式碼邏輯顯得極為晦澀難懂。

Record record = null;

try {

    record = library.getDataAt(2);

} catch(InvalidIndexException exception) {

    …… // 異常處理邏輯

}

record.setIntValue(record.getIntValue() * 2);

try {

    library.setDataAt(2, record);

} catch(InvalidIndexException exception) {

    …… // 異常處理邏輯

}

反過來,如果這些都不是Checked Exception,而且軟體開發人員也能保證傳入的索引是合法的,那麼程式碼會簡化很多:

Record record = library.getDataAt(2);

record.setIntValue(record.getIntValue() * 2);

library.setDataAt(2, record);

那麼我們應該在什麼時候使用Checked Exception呢?就像前面所說的,如果一個異常所表示的並不是程式碼本身的不足所導致的非正常狀態,而是一系列應用本身也無法控制的情況,那麼我們將需要使用Checked Exception。就以前面所列出的FileReader類的建構式為例:

public FileReader(String fileName) throws FileNotFoundException

該建構式的簽名所表示的意義實際上是:

必須透過傳入的引數fileName來標示需要開啟的檔案 如果檔案存在,那麼該建構式將傳回一個FileReader類的實體 對該建構式進行使用的程式碼必須處理由fileName所標示的檔案不存在,進而丟擲FileNotFoundException的情況 也就是說,Checked Exception實際上是API設計中的一部分。在呼叫這個API的時候,你不得不處理標的檔案不存在的情況。而這則是由檔案系統的自身特性所導致的。而之所以Checked Exception導致瞭如此多的爭論和誤用,更多是因為我們在用異常這個用來表示應用中的執行錯誤這個語言組成來通知使用者他所必須處理的應用無法控制的可能情況。也就是說,其為異常賦予了新的含義,使得異常需要表示兩個完全不相干的概念。而在沒有仔細分辨的情況下,這兩個概念是極容易混淆的。因此在嘗試著定義一個Checked Exception之前,API編寫者首先要考慮這個異常所表示的到底是系統自身缺陷所導致的執行錯誤,還是要讓使用者自己來處理的邊緣情況。

正確地使用Checked Exception

實際上,如何正確地使用Checked Exception已經在前面的各章節講解中進行了詳細地說明。在這裡我們再次做一個總結,同時也用來加深一下印象。

從API編寫者的角度來講,他所需要考慮的就是在何時使用一個Checked Exception。

首先,Checked Exception應當只在異常情況對於API以及API的使用者都無法避免的情況下被使用。例如在開啟一個檔案的時候,API以及API的使用者都沒有辦法保證該檔案一定存在。反過來,在透過索引訪問資料的時候,如果API的使用者對引數index傳入的是-1,那麼這就是一個程式碼上的錯誤,是完全可以避免的。因此對於index引數值不對的情況,我們應該使用Unchecked Exception。

其次,Checked Exception不應該被廣泛呼叫的API所丟擲。這一方面是基於程式碼整潔性的考慮,另一方面則是因為Checked Exception本身的實際意義是API以及API的使用者都無法避免的情況。如果一個應用有太多處這種“無法避免的異常”,那麼這個程式是否擁有足夠的質量也是一個很值得考慮的問題。而就API提供者而言,在一個主要的被廣泛使用的功能上丟擲這種異常,也是對其自身API的一種否定。

再次,一個Checked Exception應該有明確的意義。這種明確意義的標準則是需要讓API使用者能夠看到這個Checked Exception所對應的異常類,該異常類所包含的各個域,並閱讀相應的API檔案以後就能夠瞭解到底哪裡出現了問題,進而向用戶提供準確的有關該異常的解釋。

而對於API的使用者而言,一旦遇到了一個API會丟擲Checked Exception,那麼他就需要考慮使用一個Wrapped Exception來將該Checked Exception包裝起來。那什麼是Wrapped Exception呢?

簡單地說,Wrapped Exception就是將一個異常包裝起來的異常。在try…catch…塊捕獲到一個異常的時候,該異常內部所記錄的訊息可能並不合適。就以前面我們已經舉過的載入偏好的示例為例。在啟動時,應用會嘗試讀取使用者的偏好設定。這些偏好設定記錄在了一個檔案中,卻可能已經被誤刪除。在這種情況下,對該偏好檔案的讀取會導致一個FileNotFoundException丟擲。但是在該異常中所記錄的資訊對於使用者,甚至應用編寫者而言沒有任何價值:“Could not find file preference.xml while opening file”。在這種情況下,我們就需要構造一個新的異常,在該異常中標示準確的錯誤資訊,並將FileNotFoundException作為新異常的原因:

public void readPreference() {

    ……

    try {

        FileReader fileReader = new FileReader(preferenceFile);

    } catch(FileNotFoundException exception) {

        logger.log(“Could not find user preference setting file: {0}” preferenceFile);

        throw ApplicationSpecificException(PREFERENCE_NOT_FOUND, exception);

    }

    ……

}

上面的示例程式碼中重新丟擲了一個ApplicationSpecificException型別的異常。從它的名字就可以看出,其應該是API使用者在應用實現中所新增的應用特有的異常。為了避免呼叫棧中的每一個函式都需要新增throws宣告,該異常需要從RuntimeException派生。這樣應用就可以透過在呼叫棧的最底層捕捉這些異常並對這些異常進行處理:在系統日誌中新增一條異常記錄,只對使用者顯示異常中的訊息,以防止異常內部的呼叫棧資訊暴露過多的實現細節等:

try {

    ……

} catch(ApplicationSpecificException exception) {

    logger.log(exception.getLevel(), exception.getMessage(), exception);

    // 將exception內部記錄的資訊顯示給使用者(或新增到請求的響應中傳回)

    // 如showWarningMessage(exception.getMessage());

}

看完本文有收穫?請轉發分享給更多人

關註「ImportNew」,提升Java技能

贊(0)

分享創造快樂