Java 異常處理的最佳實踐
譯者注:這是一篇 2003 年的文章,因為時間久遠,可能有些觀點已經過時,但里面討論的大部分方法如今仍能適用。如若有其他好的錯誤處理的方法,歡迎留言。
異常處理的關鍵就在于知道何時處理異常以及如何使用異常。這篇文章,我會提到一些最佳的異常處理方法。我也會總結 checked exception 的用法。
我們程序員都想寫出高質量的代碼來解決問題。但是,異常有時會給我們的代碼帶來副作用。沒有人喜歡副作用,所以我們很快找到了方法來改善它們。我看見過許多聰明的程序員這樣來處理異常:
public void consumeAndForgetAllExceptions (){ try { ...some code that throws exceptions } catch (Exception ex){ ex.printStacktrace (); } }
上面的代碼有什么錯誤?
當異常被拋出后,正常的程序執行過程中斷,控制權交給 catch 段,catch 段會 catch 異常,然后抑制異常的進一步擴大。然后接著 catch 段之后程序繼續執行,好像什么都沒發生過一樣。
下面的代碼呢?
public void someMethod () throws Exception{ }
這個方法內沒有代碼,是個空方法。一個空方法怎么能拋出異常呢?Java 并沒有說不讓這么做。最近,我遇到過類似的代碼,方法拋出了異常,而其中的代碼實際上并不產生那個異常。當我問這個程序員為何要這么做,他回答道“我知 道,雖然這樣做破壞了 API,但我習慣這么做,而且這樣也可行。”
C++社區用了許多年才確定如何使用異常機制。這個爭論剛剛在 Java 社區展開。我見到一些 Java 程序員正在和異常進行頑強抗爭。如果用法不當的話,會拖慢程序,因為創建、拋出和接住異常都會占用內存。如果過多的使用異常的話,代碼會變得很難閱讀,對 要使用 API 的程序員來說無疑會增加挫敗感。我們知道挫敗感會令我們寫出很爛的代碼。有的程序員會刻意回避這個問題,忽略異常或隨意拋出異常,就像上面的兩個例子一 樣。
異常的本質
廣義的講,拋出異常分三種不同的情況:
- 編程錯誤導致的異常:在這個類別里,異常的出現是由于代碼的錯誤(譬如 NullPointerException 和 IllegalArgumentException)。代碼通常對編程錯誤沒有什么對策。
- 客戶端的錯誤導致的異常:客戶端代碼試圖違背制定的規則,調用 API 不支持的資源。如果在異常中顯示有效信息的話,客戶端可以采取其他的補救方法。例如:解析一個格式不正確的 XML 文檔時會拋出異常,異常中含有有效的信息。客戶端可以利用這個有效信息來采取恢復的步驟。
- 資源錯誤導致的異常:當獲取資源錯誤時引發的異常。例如,系統內存不足,或者網絡連接失敗。客戶端對于資源錯誤的反應是視情況而定的。客戶端可能一段時間之后重試或者僅僅記錄失敗然后將程序掛起
Java 異常的類型
Java 定義了兩種異常
- Checked exception: 繼承自 Exception 類是 checked exception。代碼需要處理 API 拋出的 checked exception,要么用 catch 語句,要么直接用 throws 語句拋出去。
- Unchecked exception: 也稱 RuntimeException,它也是繼承自 Exception。但所有 RuntimeException 的子類都有個特點,就是代碼不需要處理它們的異常也能通過編譯,所以它們稱作 unchecked exception。
圖 1 顯示了 NullpointerException 的繼承級別。
圖 1 異常等級實例
NullpointerException 繼承自 RuntimeException,所以它是個 unchecked exception。
我看到人們大量使用 checked exception 的,而很少看到 unchecked exception 的使用。近來,在 Java 社區里對 checked exception 和它的真正價值的爭論愈演愈烈。這主要因為 Java 是第一個使用 checked exception 的主流面向對象語言。C++和 C# 都沒有 checked exception,所有的異常都是 unchecked。
低層次拋出的 checked exception 對高層次來說,必須要 catch 或者 throw 它們。這樣如果不能有效處理異常的話,checked exception 就在 API 和代碼之間造成了一直負擔。程序員就開始寫一些空的 catch 代碼段,或者僅僅拋出異常,實際上,給客戶端的觸發者來說增加了負擔。
Checked exception 也被詬病破壞了封裝性。看看下面的代碼:
public List getAllAccounts () throws
FileNotFoundException, SQLException{
...
}
getAllAccounts ()拋出了兩個 checked exception。這個方法的調用者就必須處理這兩個異常,盡管它也不知道在 getAllAccounts 中什么文件找不到以及什么數據庫語句失敗,也不知道該提供什么文件系統或者數據庫的事務層邏輯。這樣,異常處理就在方法調用者和方法之間形成了一個不恰當 的緊耦合。
設計 API 的最佳實踐
說了這么多,讓我們來說說如何設計一個好的 API,能夠正確拋出異常的。
1. 當要確定是使用 checked exception 還是 unchecked exception 時,首先問問自己,當異常發生時客戶端如何應對?
如果客戶端可以從異常中采取行動進行恢復的,就使用 checked exception,如果客戶什么也做不了,就用 unchecked exception。我指的是,不僅僅是記錄異常,還要采取措施來恢復。
還有,我更喜歡 unchecked exception,因為不需要強迫客戶端 API 必須處理它們。它們會進一步擴散,直到你想 catch 它們,或者它們會繼續擴散爆出。Java API 有許多 unchecked exception 如 NullPointerException, IllegalArgumentException 和 IllegalStateException。我更愿意用這些 Java 定義好的異常類,而非我們自己創建的異常類。它們使我們的代碼易讀,也避免代碼消耗更多內存。
2. 保持封裝性
不要將針對某特定實現的 checked exception 用到更高的層次中去。例如,不要讓 SQLException 擴散到邏輯層去。因為邏輯層是不需要知道 SQLException。你有兩種選擇:
- 如果你的客戶端有應對措施的話,將 SQLException 轉化成另一個 checked exception。
- 如果你的客戶端什么也做不了的話,將 SQLException 轉化成一個 unchecked exception。
但大部分情況是,客戶端對 SQLException 無能為力。那請將 SQLException 轉換成 unchecked exception 吧。來看下面的代碼:
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。如果 catch 語句需要知道異常發生的根源,我可以用 getCause ()方法,這個方法在 JDK1.4 中所有異常類中都有。
如果你確信邏輯層可以采取某些恢復措施來應對 SQLException 時,你可以將它轉換成更有意義的 checked exception。但我發現僅僅拋出 RuntimeException,大部分時間里都管用。
3. 如果自定義的異常沒有提供有用的信息的話,請不要創建它們。
下面的代碼有什么錯誤?
public class DuplicateUsernameException extends Exception {}
它沒有給出任何有效的信息,除了提供一個異常名字意外。不要忘了 Java 異常類就像其他的類一樣,當你在其中增加方法時,你也可以調用這些方法來獲得更多信息。
我們可以在 DuplicateUsernameException 中增加有效的方法,例如:
public class DuplicateUsernameException extends Exception { public DuplicateUsernameException (String username){....} public String requestedUsername (){...} public String[] availableNames (){...} }
新版本的 DuplicateUsernameException 提供兩個方法:requestedUsername ()返回請求的姓名,availableNames ()返回與請求姓名相類似的所有姓名的一個數組。客戶端可以知道被請求的姓名已經不可用了,以及其他可用的姓名。如果你不想獲得其他的信息,僅僅拋出一個 標準的異常即可:
throw new Exception ("Username already taken");
如果你認為客戶端不會采取任何措施,僅僅只是寫日志說明用戶名已存在的話,拋出一個 unchecked exception:
throw new RuntimeException ("Username already taken");
另外,你甚至可以寫一個判斷用戶名是否已經存在的方法。
還是要重復一遍,當客戶端的 API 可以根據異常的信息采取有效措施的話,我們可以使用 checked exception。但對于所有的編程錯誤,我更傾向于 unchecked exception。它們讓你的代碼可讀性更高。
4. 將異常文檔化
你可以采用 Javadoc’s @throws 標簽將你的 API 拋出的 checked 和 unchecked exception 都文檔化。然而,我更喜歡寫單元測試。單元測試可看作可執行的文檔。無論你選擇哪一種方式,都要讓客戶端使用你的 API 時清楚知道你的 API 拋出哪些異常。下面是針對 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”)會顯式的讓測試失敗。通過寫單元測試,你不僅記錄了異常如何運作,也讓你的代碼變得更健壯。
使用異常的最佳實踐
下面的部分我們列出了客戶端代碼處理 API 拋出異常的一些最佳實現方法。
1. 記得釋放資源
如果你正在用數據庫或網絡連接的資源,要記得釋放它們。如果你使用的 API 僅僅使用 unchecked exception,你應該用完后釋放它們,使用 try-final。
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 關閉了連接,如果關閉過程中有問題發生的話,會拋出一個 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. 不要忽略異常
當一個 API 方法拋出 checked exception 時,它是要試圖告訴你你需要采取某些行動處理它。如果它對你來說沒什么意義,不要猶豫,直接轉換成 unchecked exception 拋出,千萬不要僅僅用空的{}catch 它,然后當沒事發生一樣忽略它。
4. 不要 catch 最高層次的 exception
Unchecked exception 是繼承自 RuntimeException 類的,而 RuntimeException 繼承自 Exception。如果 catch Exception 的話,你也會 catch RuntimeException。
try{ .. }catch(Exception ex){ }
上面的代碼會忽略掉 unchecked exception。
5. 僅記錄 exception 一次
對同一個錯誤的棧回溯(stack trace)記錄多次的話,會讓程序員搞不清楚錯誤的原始來源。所以僅僅記錄一次就夠了。
總結
這里是我總結出的一些異常處理最佳實施方法。我并不想引起關于 checked exception 和 unchecked exception 的激烈爭論。你可以根據你的需要來設計代碼。我相信,隨著時間的推移,我們會找到些更好的異常處理的方法的。
原文: onjava.com 編譯:伯樂在線 – 唐小娟