[翻譯]-異常處理最佳實踐

劉曉日發表於2011-09-05

作者:Gunjan Doshi 譯者:劉曉日

異常處理通常都會遇到這樣一個問題:何時以及如何使用異常。本文主要介紹異常處理的最佳實踐,當然也會針對目前對檢查性異常做一些總結與歸納。

作為開發人員,我們都希望能編寫既具高質量又能解決問題的程式碼,不幸的是,異常對程式碼質量總是會起到負面的影響。沒有哪個程式設計師願意一味的接受這種負面的影響,所以我們總是尋找各種方法避免異常處理。我曾經看過優秀的程式設計師這樣處理異常:

public void consumeAndForgetAllExceptions(){
    try {
        ...some code that throws exceptions
    } catch (Exception ex){
        ex.printStacktrace();
    }
}

這段程式碼有什麼問題嗎?

只是,當有異常被丟擲,當前執行程式被掛起,控制權移交到catch塊中。catch塊除了將異常捕獲什麼也沒做,然後catch塊後面的程式繼續執行,就好像什麼都沒發生一樣。

下面這種方式怎麼樣?

public void someMethod() throws Exception{
}

這就是一個空方法,方法體內根本一行程式碼都沒有,怎麼還要丟擲異常呢?在java裡面,你確實可以這樣幹。我就遇到過在一段很簡單的程式碼中宣告丟擲異常,卻沒有任何一行程式碼會引發異常。當我詢問程式設計師為何要這麼做的時候,他這樣回答我:“我知道這樣使API看起來很糟糕,但是我一直都是這樣做的,而且這樣做也奏效。”

C++社群花了幾年時間研究要怎麼處理異常,然而關於異常處理的討論在java社群也開始了,越來越多的java程式設計師正在與異常處理做鬥爭。如果異常使用不當,會造成程式執行緩慢,因為建立和捕獲異常需要佔用記憶體和CPU。過度使用異常,一方面會造成程式的可讀性極差,另一方面會給呼叫者造成不必要的麻煩。編寫程式碼時,很可能像上面兩個例子一樣,直接將異常丟擲或者忽略。

The Nature of Exceptions

總的來說,三種情況會引發異常:

  • 執行時異常:這種異常,是由程式執行時錯誤引發的,比如NullPointerException、IllegalArgumentException 。對於這種執行時錯誤,我們無能為力,做不了任何處理。

  • 程式碼錯誤引發的異常:呼叫者編碼時,違反API的約定引發的異常。如果在異常中包含著重要的資訊,那麼呼叫者可以採取一些針對該異常的補救方法。比如在解析XML文件的時候,因格式不正確引發異常,異常中會記錄引發異常的位置,這樣,編寫程式碼時,就可以利用它採取補救措施。

  • 資源錯誤引發的異常:當請求資源失敗時,引發的異常。比如記憶體溢位或者網路連線失敗等。針對這種異常的處理要權衡需求,可以超時重新傳送請求,也可以記錄下失敗的資源後停止應用程式。

Types of Exceptions in Java

java中定義了兩類異常:

  • 檢查性異常:檢查性異常繼承自Exception,呼叫者必須在catch塊中捕獲這類異常,或者將異常拋到上層。
  • 非檢查性異常:RuntimeException 也是繼承自Exception,所有繼承自RuntimeException的異常,都不需要進行處理,所以叫做非檢查型異常。

通過舉例的方式,圖一展現了NullPointerException的繼承關係。

enter image description here

圖一:異常繼承關係

圖中NullPointerException繼承自RunTimeException,所以是非檢查性異常。

目前非檢查性異常很少使用,更多的是使用檢查性異常。最近java社群中關於檢查性異常及其價值的爭論異常火熱。這場爭論源自於java是第一個使用檢查性異常的主流物件導向程式語言。C++和C#中沒有檢查性異常一說,全部都是非檢查性異常。

檢查性異常強制要求呼叫者捕獲異常,或向上層丟擲異常。如果呼叫者,無法對檢查性異常做出有效的處理,那麼這種強制性捕獲或丟擲的約定就會變成一種負擔。程式設計人員可能採取偷懶的方式使用空白的catch塊將異常忽略,或者乾脆直接丟擲。事實上,這已經造成了呼叫者的負擔。

檢查性異常還違反封裝性原則。看一下下面這段程式碼:

public List getAllAccounts() throws
    FileNotFoundException, SQLException{
    ...
}

getAllAccounts()方法丟擲兩種檢查性異常。儘管你還不知道getAllAccounts()中呼叫哪個檔案或資料庫失敗,或是不支援檔案系統或資料庫邏輯,但是呼叫getAllAccounts()時必須顯式的處理這兩種異常。所以,檢查性異常迫使方法和它的呼叫者間保持著高度的耦合。

Best Practices for Designing the API

前面已經說了很多,現在我們來看看如何正確設計異常處理的API。

1、當你不知道應該使用檢查性異常還是非檢查型異常時,不妨這樣問自己:當捕獲到異常時,通過編碼我能做些什麼?

如果異常發生時,通過編碼的方式補救異常發生的情況,那麼它就是檢查性異常。當然如果無法通過編碼方式採取任何有用的處理,那麼就是非檢查性異常。這裡的有用性,是指能減少異常發生帶來的“損失”,而不是簡單記錄一下異常資訊。總結如下:

  • 呼叫者什麼也做不了,則使用非檢查型異常。
  • 可根據異常攜帶的資訊做出進一步處理,則使用檢查性異常。

而且,執行時錯誤作為非檢查性異常的優點在於:非檢查性異常不會強制呼叫者顯式的處理異常。可以在需要的時候捕獲非檢查性異常,沒必要時就不進行捕獲,記錄一下就好。 (Moreover, prefer unchecked exceptions for all programming errors: unchecked exceptions have the benefit of not forcing the client API to explicitly deal with them. They propagate to where you want to catch them, or they go all the way out and get reported)。java的API中使用了很多非檢查性異常,比如NullPointException、IllegalArgumentException、IllegalStateException等。本人更傾向於使用java自帶的異常,而不是自定義異常。這些異常可以使我的程式碼更易理解,還可避免因為建立和捕獲自定義異常增加對記憶體的佔用。

2. 捍衛封裝性

不要將特定的異常拋到上層。例如,不要將SQLException從資料訪問層拋到業務物件層,業務物件層不需要知道SQLException的細節。應對這種情況,你可以有兩種選擇:

  • 如果在發生異常時,想通過編碼進行某些處理,那麼就把SQLException轉換成另一種檢查性異常丟擲。
  • 如果不對異常進行處理,那麼就轉換成非檢查性異常丟擲。

大多數情況下,面對SQLException我們無能為力,那麼直接轉化成非檢查性異常丟擲。看看下面一段程式碼:

public void dataAccessCode(){
    try{
        ..some code that throws SQLException
    }catch(SQLException ex){
        ex.printStacktrace();
    }
}

這個catch塊沒做任何處理,將異常忽略掉,這樣做是因為對於SQLException我們做不了任何處理。看看這樣處理如何?

public void dataAccessCode(){
    try{
        ..some code that throws SQLException
    }catch(SQLException ex){
        throw new RuntimeException(ex);
    }
}

這裡將SQLException轉化成RuntimeException丟擲。當SQLException發生時,在catch塊中丟擲一個RuntimeException,然後當前執行緒被掛起,異常資訊被記錄下來。這樣,我沒有在業務物件層新增不必要的異常處理,因為對SQLException什麼也做不了。

如果你確信當發生SQLException時,業務物件層可以進行有用的處理,那麼就可以將SQLException轉化成有意義的檢查性異常。但是多數情況下,丟擲RuntimeException是比較明智的選擇。(哇,很有激情啊,兩點多了。嘿嘿)

注:今天繼續翻譯完。

3、如果沒有特殊需求,不要使用自定義異常

下面這段程式碼有什麼問題嗎?

public class DuplicateUsernameException
    extends Exception {}

這個自定義異常除了一個頗具含義的名稱外,對呼叫者沒提供任何有用的資訊。不要忘記java中的異常也和其他類一樣,可以在其內部為呼叫者提供獲取有價值資訊的方法。

可以在DuplicateUsernameException中新增如下方法:

public class DuplicateUsernameException
    extends Exception {
    public DuplicateUsernameException 
        (String username){....}
    public String requestedUsername(){...}
    public String[] availableNames(){...}
}

加強版本中提供了兩個方法:一個是requestedUsername()方法,用來返回撥用方法的名稱;另一個是availableNames()返回一個與呼叫方法名稱相似的陣列。這樣編碼時就可以指出呼叫方法不可用,以及哪些方法是可用的。如果沒有額外的資訊記錄在異常中,那麼直接像這樣丟擲標準異常即可:

throw new Exception("Username already taken");

甚至,處理異常時,除了將方法名記錄下來外不會做其他處理,那麼像下面一樣丟擲非檢查型異常就好。

throw new RuntimeException("Username already taken");

當然,也可以提供方法用於檢查使用者名稱是否已被佔用。

仍然要強調一下,檢查性異常使用的場景是:處理異常時,通過異常提供的資訊可以採取進一步處理。執行時錯誤,全部當做非檢查性異常,這樣做會使我們的程式碼更具可讀性。

4、驗證異常

你可以使用javadoc的@throws標註檢查性異常和非檢查型異常。但是我比較傾向於使用單元測試驗證異常。測試環境下可以追蹤異常,因此伺服器被當做可執行文件來使用。不管使用什麼方式,需要讓呼叫者感知異常的發生。下面是一個關於 IndexOutOfBoundsException異常的例子:

public void testIndexOutOfBoundsException() {
    ArrayList blankList = new ArrayList();
    try {
        blankList.get(10);
        fail("Should raise an IndexOutOfBoundsException");
    } catch (IndexOutOfBoundsException success) {}
}

這段程式碼中,當blankList.get(10)被呼叫時,就會丟擲IndexOutOfBoundsException異常。如果沒引發異常,fail("Should raise an IndexOutOfBoundsException")語句會使單元測試失敗。通過為異常編寫單元測試,不僅可以驗證異常是如何執行的,還可以通過測試特定異常,讓程式碼變的更健壯。

Best Practices for Using Exceptions

這一部分,主要圍繞“如何處理異常”。

1、自己釋放資源

當資料庫連線或網路連線資源使用完畢後,記得手動將它們釋放。即使程式碼中只使用了非檢查性異常,也要使用try-finally語句塊來釋放資源。

public void dataAccessCode(){
    Connection conn = null;
    try{
        conn = getConnection();
        ..some code that throws SQLException
    }catch(SQLException ex){
        ex.printStacktrace();
    } finally{
        DBUtil.closeConnection(conn);
    }
}

class DBUtil{
    public static void closeConnection
        (Connection conn){
        try{
            conn.close();
        } catch(SQLException ex){
            logger.error("Cannot close connection");
            throw new RuntimeException(ex);
        }
    }
}

DBUtil是資料庫連線的工具類,這裡的關鍵點是finally塊,不論執行過程中是否發生異常,finally中的程式碼一定會被執行。示例中,在finally塊中關閉資料庫連線,如未正常關閉,則丟擲RuntimeException。

2、流程控制中切勿使用異常

產生棧跟蹤資訊的代價很大,而且只有在除錯的時候這些資訊才有用。由於在流程控制中發生異常時,呼叫者只想知道如何處理,所以棧跟蹤資訊完全可以被忽略。

下面是一個在流程控制中使用自定義MaximumCountReachedException異常的示例:

public void useExceptionsForFlowControl() {
    try {
        while (true) {
            increaseCount();
        }
    } catch (MaximumCountReachedException ex) {
    }
    //Continue execution
}

public void increaseCount()
    throws MaximumCountReachedException {
    if (count >= 5000)
        throw new MaximumCountReachedException();
}

useExceptionsForFlowControl()中是一個丟擲異常才會終止的無限迴圈,這樣做不僅讓程式碼的可讀性變的很差,而且嚴重減低了程式的執行速度。切忌只在適合的場景下使用異常。

3、不要忽略異常

當有異常丟擲,這就是告訴我們需要做出處理的訊號。如果捕獲到的檢查性異常對你毫無用處,那麼直接轉化成非檢查性異常並再次丟擲為妙,而不是使用“{}”將異常忽略,程式就好像什麼都沒發生一樣照常執行。

4、不要捕獲頂級異常

檢查性異常繼承自RuntimeException, RuntimeException還繼承自Exception。和下面的程式碼一樣,捕獲頂級異常Exception,也同樣了捕獲RuntimeException異常。

try{
..
}catch(Exception ex){
}

5、異常只記錄一次

多次在棧中記錄同一個異常資訊,會增加獲取原始異常的難度,所以確保同一個異常資訊只記錄一次。

總結

關於異常處理的最佳方式有很多種。我沒有激起檢查性異常與非檢查型異常之間的爭論的意思,編碼過程中需要根據實際的需求去設計和使用它們。我堅信隨著時間的推移,還會出現更好的異常處理方式。

雖然這篇文章是2003年寫的,但是其價值在今天依然是值得肯定的。(譯者注)

原文地址:http://onjava.com/pub/a/onjava/2003/11/19/exceptions.html

相關資源:

"Does Java need Checked Exceptions?" by Bruce Eckel "Exceptional Java" by Alan Griffiths "The trouble with checked exceptions: A conversation with Anders Hejlsberg, Part II" on Artima.com "Checked exceptions are of dubious value," on C2.com
Conversation with James Gosling by Bill Venners

相關文章