簡介
Java是唯一(主流)實現了受檢異常概念的編程語言。一開始,受檢異常就是爭議的焦點。在當時被視為一種創新概念(Java於1996年推出),如今卻被視不良實踐。
本文要討論Java中非受檢異常和受檢異常的動機以及它們優缺點。與大多數關注這個主題的人不同,我希望提供一個平衡的觀點,而不僅僅是對受檢異常概念的批評。
我們先深入探討Java中受檢異常和非受檢異常的動機。Java之父詹姆斯·高斯林對這個話題有何看法?接下來,我們要看一下Java中異常的工作原理以及受檢異常存在的問題。我們還將討論在何時應該使用哪種類型的異常。最後,我們將提供一些常見的解決方法,例如使用Lombok的@SneakyThrows註解。
Java和其他編程語言中異常的歷史
在軟件開發中,異常處理可以追溯到20世紀60年代LISP的引入。通過異常,我們可以解決在程序錯誤處理過程中可能遇到的幾個問題。
異常的主要思想是將正常的控制流與錯誤處理分離。讓我們看一個不使用異常的例子:
public void handleBookingWithoutExceptions(String customer, String hotel) {
if (isValidHotel(hotel)) {
int hotelId = getHotelId(hotel);
if (sendBookingToHotel(customer, hotelId)) {
int bookingId = updateDatabase(customer, hotel);
if (bookingId > 0) {
if (sendConfirmationMail(customer, hotel, bookingId)) {
logger.log(Level.INFO, "Booking confirmed");
} else {
logger.log(Level.INFO, "Mail failed");
}
} else {
logger.log(Level.INFO, "Database couldn't be updated");
}
} else {
logger.log(Level.INFO, "Request to hotel failed");
}
} else {
logger.log(Level.INFO, "Invalid data");
}
}
程序的邏輯只佔據了大約5行代碼,其餘的代碼則是用於錯誤處理。這樣,代碼不再關注主要的流程,而是被錯誤檢查所淹沒。
如果我們的編程語言沒有異常機制,我們只能依賴函數的返回值。讓我們使用異常來重寫我們的函數:
public void handleBookingWithExceptions(String customer, String hotel) {
try {
validateHotel(hotel);
sendBookingToHotel(customer, getHotelId(hotel));
int bookingId = updateDatabase(customer, hotel);
sendConfirmationMail(customer, hotel, bookingId);
logger.log(Level.INFO, "Booking confirmed");
} catch(Exception e) {
logger.log(Level.INFO, e.getMessage());
}
}
採用這種方法,我們不需要檢查返回值,而是將控制流轉移到catch塊中。這樣的代碼更易讀。我們有兩個獨立的流程: 正常流程和錯誤處理流程。
除了可讀性之外,異常還解決了"半謂詞問題"(semipredicate problem)。簡而言之,半謂詞問題發生在表示錯誤(或不存在值)的返回值成為有效返回值的情況下。讓我們看幾個示例來説明這個問題:
示例:
int index = "Hello World".indexOf("World");
int value = Integer.parseInt("123");
int freeSeats = getNumberOfAvailableSeatsOfFlight();
indexOf() 方法如果未找到子字符串,將返回 -1。當然,-1 絕對不可能是一個有效的索引,所以這裏沒有問題。然而,parseInt() 方法的所有可能返回值都是有效的整數。這意味着我們沒有一個特殊的返回值來表示錯誤。最後一個方法 getNumberOfAvailableSeatsOfFlight() 可能會導致隱藏的問題。我們可以將 -1 定義為錯誤或沒有可用信息的返回值。乍看起來這似乎是合理的。然而,後來可能發現負數表示等待名單上的人數。異常機制能更優雅地解決這個問題。
Java中異常的工作方式
在討論是否使用受檢異常之前,讓我們簡要回顧一下Java中異常的工作方式。下圖顯示了異常的類層次結構:
RuntimeException繼承自Exception,而Error繼承自Throwable。RuntimeException和Error被稱為非受檢異常,意味着它們不需要由調用代碼處理(即它們不需要被“檢查”)。所有其他繼承自Throwable(通常通過Exception)的類都是受檢異常,這意味着編譯器期望調用代碼處理它們(即它們必須被“檢查”)。
所有繼承自Throwable的異常,無論是受檢的還是非受檢的,都可以在catch塊中捕獲。
最後,值得注意的是,受檢異常和非受檢異常的概念是Java編譯器的特性。JVM本身並不知道這個區別,所有的異常都是非受檢的。這就是為什麼其他JVM語言不需要實現這個特性的原因。
在我們開始討論是否使用受檢異常之前,讓我們簡要回顧一下這兩種異常類型之間的區別。
受檢異常
受檢異常需要被try-catch塊包圍,或者調用方法需要在其簽名中聲明異常。由於Scanner類的構造函數拋出一個FileNotFoundException異常,這是一個受檢異常,所以下面的代碼無法編譯:
public void readFile(String filename) {
Scanner scanner = new Scanner(new File(filename));
}
我們會得到一個編譯錯誤:
Unhandled exception: java.io.FileNotFoundException
我們有兩種選項來解決這個問題。我們可以將異常添加到方法的簽名中:
public void readFile(String filename) throws FileNotFoundException {
Scanner scanner = new Scanner(new File(filename));
}
或者我們可以使用try-catch塊在現場處理異常:
public void readFile(String filename) {
try {
Scanner scanner = new Scanner(new File(filename));
} catch (FileNotFoundException e) {
// handle exception
}
}
非受檢異常
對於非受檢異常,我們不需要做任何處理。由Integer.parseInt引發的NumberFormatException是一個運行時異常,所以下面的代碼可以編譯通過:
public int readNumber(String number) {
return Integer.parseInt(callEndpoint(number));
}
然而,我們仍然可以選擇處理異常,因此以下代碼也可以編譯通過:
public int readNumber(String number) {
try {
return Integer.parseInt(callEndpoint(number));
} catch (NumberFormatException e) {
// handle exception
return 0;
}
}
為什麼我們要使用受檢異常?
如果我們想了解受檢異常背後的動機,我們需要看一下Java的歷史。該語言的創建是以強調健壯性和網絡功能為重點的。
最好用Java創始人詹姆斯·高斯林(James Gosling)自己的一句話來表達:“你不能無意地説,‘我不在乎。’你必須明確地説,‘我不在乎。’”這句話摘自一篇與詹姆斯·高斯林進行的有趣的採訪,在採訪中他詳細討論了受檢異常。
在《編程之父》這本書中,詹姆斯也談到了異常。他説:“人們往往忽略了檢查返回代碼。”
這再次強調了受檢異常的動機。通常情況下,當錯誤是由於編程錯誤或錯誤的輸入時,應該使用非受檢異常。如果在編寫代碼時程序員無法做任何處理,應該使用受檢異常。後一種情況的一個很好的例子是網絡問題。開發人員無法解決這個問題,但程序應該適當地處理這種情況,可以是終止程序、重試操作或簡單地顯示錯誤消息。
受檢異常存在的問題
瞭解了受檢異常和非受檢異常背後的動機,我們再來看看受異常在代碼庫中可能引入的一些問題。
受檢異常不適應規模化
一個主要反對受異常的觀點是代碼的可擴展性和可維護性。當一個方法的異常列表發生變化時,會打破調用鏈中從調用方法開始一直到最終實現try-catch來處理異常的方法的所有方法調用。舉個例子,假設我們調用一個名為libraryMethod()的方法,它是外部庫的一部分:
public void retrieveContent() throws IOException {
libraryMethod();
}
在這裏,方法libraryMethod()本身來自一個依賴項,例如,一個處理對外部系統進行REST調用的庫。其實現可能如下所示:
public void libraryMethod() throws IOException {
// some code
}
在將來,我們決定使用庫的新版本,甚至用另一個庫替換它。儘管功能相似,但新庫中的方法會拋出兩個異常:
public void otherSdkCall() throws IOException, MalformedURLException {
// call method from SDK
}
由於有兩個受檢異常,我們的方法聲明也需要更改:
public void retrieveContent() throws IOException, MalformedURLException {
sdkCall();
}
對於小型代碼庫來説,這可能不是一個大問題,但對於大型代碼庫來説,這將需要進行相當多的重構。當然,我們也可以直接在方法內部處理異常:
public void retrieveContent() throws IOException {
try {
otherSdkCall();
} catch (MalformedURLException e) {
// do something with the exception
}
}
使用這種方法,我們在代碼庫中引入了一種不一致性,因為我們立即處理了一個異常,而推遲了另一個異常的處理。
異常傳播
一個與可擴展性非常相似的論點是受檢異常如何在調用堆棧中傳播。如果我們遵循“儘早拋出,盡晚捕獲”的原則,我們需要在每個調用方法上添加一個throws子句(a):
相反,非受檢異常(b)只需要在實際發生異常的地方聲明一次,並在我們希望處理異常的地方再次聲明。它們會在調用堆棧中自動傳播,直到達到實際處理異常的位置。
不必要的依賴關係
受檢異常還會引入與非受檢異常不必要的依賴關係。讓我們再次看看在場景(a)中我們在三個不同的位置添加了IOException。如果methodA()、methodB()和methodC()位於不同的類中,那麼所有相關類都將對異常類有一個依賴關係。如果我們使用了非受檢異常,我們只需要在methodA()和methodC()中有這個依賴關係。甚至methodB()所在的類或模塊都不需要知道異常的存在。
讓我們用一個例子來説明這個想法。假設你從度假回家。你在酒店前台退房,乘坐公共汽車去火車站,然後換乘一次火車,在回到家鄉後,你又乘坐另一輛公共汽車從車站回家。回到家後,你意識到你把手機忘在了酒店裏。在你開始整理行李之前,你進入了“異常”流程,乘坐公共汽車和火車回到酒店取手機。在這種情況下,你按照之前相反的順序做了所有的事情(就像在Java中發生異常時向上移動堆棧跟蹤一樣),直到你到達酒店。顯然,公共汽車司機和火車操作員不需要知道“異常”,他們只需要按照他們的工作進行。只有在前台,也就是“回家”流程的起點,我們需要詢問是否有人找到了手機。
糟糕的編碼實踐
當然,作為專業的軟件開發人員,我們絕不能在良好的編碼實踐上選擇方便。然而,當涉及到受檢異常時,往往會誘使我們快速引入以下三種模式。通常的想法是以後再處理。我們都知道這樣的結果。另一個常見的説法是“我想為正常流程編寫代碼,不想被異常打擾”。我經常見到以下三種模式。
第一種模式是捕獲所有異常(catch-all exception):
public void retrieveInteger(String endpoint) {
try {
URL url = new URL(endpoint);
int result = Integer.parseInt(callEndpoint(endpoint));
} catch (Exception e) {
// do something with the exception
}
}
我們只是捕獲所有可能的異常,而不是單獨處理不同的異常:
public void retrieveInteger(String endpoint) {
try {
URL url = new URL(endpoint);
int result = Integer.parseInt(callEndpoint(endpoint));
} catch (MalformedURLException e) {
// do something with the exception
} catch (NumberFormatException e) {
// do something with the exception
}
}
當然,在一般情況下,這並不一定是一種糟糕的實踐。如果我們只想記錄異常,或者在Spring Boot的@ExceptionHandler中作為最後的安全機制,這是一種適當的做法。
第二種模式是空的catch塊:
public void myMethod() {
try {
URL url = new URL("malformed url");
} catch (MalformedURLException e) {}
}
這種方法顯然繞過了受檢異常的整個概念。它完全隱藏了異常,使我們的程序在沒有提供任何關於發生了什麼的信息的情況下繼續執行。
第三種模式是簡單地打印堆棧跟蹤並繼續執行,就好像什麼都沒有發生一樣:
public void consumeAndForgetAllExceptions(){
try {
// some code that can throw an exception
} catch (Exception ex){
ex.printStacktrace();
}
}
為了滿足方法簽名而添加額外的代碼
有時我們可以確定除非出現編程錯誤,否則不會拋出異常。讓我們考慮以下示例:
public void readFromUrl(String endpoint) {
try {
URL url = new URL(endpoint);
} catch (MalformedURLException e) {
// do something with the exception
}
}
MalformedURLException是一個受檢異常,當給定的字符串不符合有效的URL格式時,會拋出該異常。需要注意的重要事項是,如果URL格式不正確,就會拋出異常,這並不意味着URL實際上存在並且可以訪問。
即使我們在之前驗證了格式:
public void readFromUrl(@ValidUrl String endpoint)
或者我們已經將其硬編碼:
public static final String endpoint = "http://www.example.com";
編譯器仍然強制我們處理異常。我們需要寫兩行“無用”的代碼,只是因為有一個受檢異常。
如果我們無法編寫代碼來觸發某個異常的拋出,就無法對其進行測試,因此測試覆蓋率將會降低。
有趣的是,當我們想將字符串解析為整數時,並不強制我們處理異常:
Integer.parseInt("123");
parseInt方法在提供的字符串不是有效整數時會拋出NumberFormatException,這是一個非受檢異常。
Lambda表達式和異常
受檢異常並不總是與Lambda表達式很好地配合使用。讓我們來看一個例子:
public class CheckedExceptions {
public static String readFirstLine(String filename) throws FileNotFoundException {
Scanner scanner = new Scanner(new File(filename));
return scanner.next();
}
public void readFile() {
List<String> fileNames = new ArrayList<>();
List<String> lines = fileNames.stream().map(CheckedExceptions::readFirstLine).toList();
}
}
由於我們的readFirstLine()方法拋出了一個受檢異常,所以會導致編譯錯誤:
Unhandled exception: java.io.FileNotFoundException in line 8.
如果我們嘗試使用try-catch塊來修正代碼:
public void readFile() {
List<String> fileNames = new ArrayList<>();
try {
List<String> lines = fileNames.stream()
.map(CheckedExceptions::readFirstLine)
.toList();
} catch (FileNotFoundException e) {
// handle exception
}
}
我們仍然會得到一個編譯錯誤,因為我們無法在lambda內部將受檢異常傳播到外部。我們必須在lambda表達式內部處理異常並拋出一個運行時異常:
public void readFile() {
List<String> lines = fileNames.stream()
.map(filename -> {
try{
return readFirstLine(filename);
} catch(FileNotFoundException e) {
throw new RuntimeException("File not found", e);
}
}).toList();
}
不幸的是,如果靜態方法引用拋出受檢異常,這種方式將變得不可行。或者,我們可以讓lambda表達式返回一個錯誤消息,然後將其添加到結果中:
public void readFile() {
List<String> lines = fileNames.stream()
.map(filename -> {
try{
return readFirstLine(filename);
} catch(FileNotFoundException e) {
return "default value";
}
}).toList();
}
然而,代碼看起來仍然有些雜亂。
我們可以在lambda內部傳遞一個非受檢異常,並在調用方法中捕獲它:
public class UncheckedExceptions {
public static int parseValue(String input) throws NumberFormatException {
return Integer.parseInt(input);
}
public void readNumber() {
try {
List<String> values = new ArrayList<>();
List<Integers> numbers = values.stream()
.map(UncheckedExceptions::parseValue)
.toList();
} catch(NumberFormatException e) {
// handle exception
}
}
}
在這裏,我們需要注意之前使用受檢異常和使用非受檢異常的例子之間的一個關鍵區別。對於非受檢異常,流的處理將繼續到下一個元素,而對於受檢異常,處理將結束,並且不會處理更多的元素。顯然,我們想要哪種行為取決於我們的用例。
處理受檢異常的替代方法
將受檢異常包裝為非受檢異常
我們可以通過將受檢異常包裝為非受檢異常來避免在調用堆棧中的所有方法中添加throws子句。而不是讓我們的方法拋出一個受檢異常:
public void myMethod() throws IOException{}
我們可以將其包裝在一個非受檢異常中:
public void myMethod(){
try {
// some logic
} catch(IOException e) {
throw new MyUnchckedException("A problem occurred", e);
}
}
理想情況下,我們應用異常鏈。這樣可以確保原始異常不會被隱藏。我們可以在第5行看到異常鏈的應用,原始異常作為參數傳遞給新的異常。這種技術在早期版本的Java中幾乎適用於所有核心Java異常。
異常鏈是許多流行框架(如Spring或Hibernate)中常見的一種方法。這兩個框架從受檢異常轉向非受檢異常,並將不屬於框架的受檢異常包裝在自己的運行時異常中。一個很好的例子是Spring的JDBC模板,它將所有與JDBC相關的異常轉換為Spring框架的非受檢異常。
Lombok @SneakyThrows
Project Lombok為我們提供了一個註解,可以消除異常鏈的需要。而不是在我們的方法中添加throws子句:
public void beSneaky() throws MalformedURLException {
URL url = new URL("http://test.example.org");
}
我們可以添加@SneakyThrows 註解,這樣我們的代碼就可以編譯通過:
@SneakyThrows
public void beSneaky() {
URL url = new URL("http://test.example.org");
}
然而,重要的是要理解,@SneakyThrows並不會使MalformedURLException的行為完全像運行時異常一樣。我們將無法再捕獲它,並且以下代碼將無法編譯:
public void callSneaky() {
try {
beSneaky();
} catch (MalformedURLException e) {
// handle exception
}
}
由於@SneakyThrows移除了異常,而MalformedURLException仍然被視為受檢異常,因此我們將在第4行得到編譯器錯誤:
Exception 'java.net.MalformedURLException' is never thrown in the corresponding try block
性能
在我的研究過程中,我遇到了一些關於異常性能的討論。在受檢異常和非受檢異常之間是否存在性能差異?實際上,它們之間沒有性能差異。這是一個在編譯時決定的特性。
然而,是否在異常中包含完整的堆棧跟蹤會導致顯着的性能差異:
public class MyException extends RuntimeException {
public MyException(String message, boolean includeStacktrace) {
super(message, null, !includeStacktrace, includeStacktrace);
}
}
在這裏,我們在自定義異常的構造函數中添加了一個標誌。該標誌指定是否要包含完整的堆棧跟蹤。在拋出異常的情況下,構建堆棧跟蹤會導致程序變慢。因此,如果性能至關重要,則應排除堆棧跟蹤。
一些指南
如何處理軟件中的異常是我們工作的一部分,它高度依賴於具體的用例。在我們結束討論之前,這裏有三個高級指南,我相信它們(幾乎)總是正確的。
- 如果不是編程錯誤,或者程序可以執行一些有用的恢復操作,請使用受檢異常。
- 如果是編程錯誤,或者程序無法進行任何恢復操作,請使用運行時異常。
- 避免空的catch塊。
結論
本文深入探討了Java中的異常。我們講了為什麼要引入異常到語言中,何時應該使用受檢異常和非受檢異常。我們還討論了受檢異常的缺點以及為什麼它們現在被認為是不良實踐 - 儘管也有一些例外情況。
【注】本文譯自: Don't Use Checked Exceptions (reflectoring.io)