簡介
Java是唯一(主流)實(shí)現(xiàn)了受檢異常概念的編程語言九孩。一開始搅荞,受檢異常就是爭議的焦點(diǎn)躏惋。在當(dāng)時(shí)被視為一種創(chuàng)新概念(Java于1996年推出)幽污,如今卻被視不良實(shí)踐。
本文要討論Java中非受檢異常和受檢異常的動(dòng)機(jī)以及它們優(yōu)缺點(diǎn)簿姨。與大多數(shù)關(guān)注這個(gè)主題的人不同距误,我希望提供一個(gè)平衡的觀點(diǎn),而不僅僅是對受檢異常概念的批評扁位。
我們先深入探討Java中受檢異常和非受檢異常的動(dòng)機(jī)准潭。Java之父詹姆斯·高斯林對這個(gè)話題有何看法?接下來域仇,我們要看一下Java中異常的工作原理以及受檢異常存在的問題刑然。我們還將討論在何時(shí)應(yīng)該使用哪種類型的異常。最后暇务,我們將提供一些常見的解決方法泼掠,例如使用Lombok的@SneakyThrows注解。
Java和其他編程語言中異常的歷史
在軟件開發(fā)中垦细,異常處理可以追溯到20世紀(jì)60年代LISP的引入择镇。通過異常,我們可以解決在程序錯(cuò)誤處理過程中可能遇到的幾個(gè)問題括改。
異常的主要思想是將正常的控制流與錯(cuò)誤處理分離腻豌。讓我們看一個(gè)不使用異常的例子:
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");
}
}
程序的邏輯只占據(jù)了大約5行代碼,其余的代碼則是用于錯(cuò)誤處理嘱能。這樣饲梭,代碼不再關(guān)注主要的流程,而是被錯(cuò)誤檢查所淹沒焰檩。
如果我們的編程語言沒有異常機(jī)制憔涉,我們只能依賴函數(shù)的返回值。讓我們使用異常來重寫我們的函數(shù):
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());
}
}
采用這種方法析苫,我們不需要檢查返回值兜叨,而是將控制流轉(zhuǎn)移到catch塊中。這樣的代碼更易讀衩侥。我們有兩個(gè)獨(dú)立的流程: 正常流程和錯(cuò)誤處理流程国旷。
除了可讀性之外,異常還解決了"半謂詞問題"(semipredicate problem)茫死。簡而言之跪但,半謂詞問題發(fā)生在表示錯(cuò)誤(或不存在值)的返回值成為有效返回值的情況下。讓我們看幾個(gè)示例來說明這個(gè)問題:
示例:
int index = "Hello World".indexOf("World");
int value = Integer.parseInt("123");
int freeSeats = getNumberOfAvailableSeatsOfFlight();
indexOf() 方法如果未找到子字符串峦萎,將返回 -1屡久。當(dāng)然忆首,-1 絕對不可能是一個(gè)有效的索引,所以這里沒有問題被环。然而糙及,parseInt() 方法的所有可能返回值都是有效的整數(shù)。這意味著我們沒有一個(gè)特殊的返回值來表示錯(cuò)誤筛欢。最后一個(gè)方法 getNumberOfAvailableSeatsOfFlight() 可能會(huì)導(dǎo)致隱藏的問題浸锨。我們可以將 -1 定義為錯(cuò)誤或沒有可用信息的返回值。乍看起來這似乎是合理的版姑。然而柱搜,后來可能發(fā)現(xiàn)負(fù)數(shù)表示等待名單上的人數(shù)。異常機(jī)制能更優(yōu)雅地解決這個(gè)問題剥险。
Java中異常的工作方式
在討論是否使用受檢異常之前聪蘸,讓我們簡要回顧一下Java中異常的工作方式。下圖顯示了異常的類層次結(jié)構(gòu):
RuntimeException繼承自Exception炒嘲,而Error繼承自Throwable宇姚。RuntimeException和Error被稱為非受檢異常匈庭,意味著它們不需要由調(diào)用代碼處理(即它們不需要被“檢查”)夫凸。所有其他繼承自Throwable(通常通過Exception)的類都是受檢異常,這意味著編譯器期望調(diào)用代碼處理它們(即它們必須被“檢查”)阱持。
所有繼承自Throwable的異常夭拌,無論是受檢的還是非受檢的,都可以在catch塊中捕獲衷咽。
最后鸽扁,值得注意的是,受檢異常和非受檢異常的概念是Java編譯器的特性镶骗。JVM本身并不知道這個(gè)區(qū)別桶现,所有的異常都是非受檢的。這就是為什么其他JVM語言不需要實(shí)現(xiàn)這個(gè)特性的原因鼎姊。
在我們開始討論是否使用受檢異常之前骡和,讓我們簡要回顧一下這兩種異常類型之間的區(qū)別。
受檢異常
受檢異常需要被try-catch塊包圍相寇,或者調(diào)用方法需要在其簽名中聲明異常慰于。由于Scanner類的構(gòu)造函數(shù)拋出一個(gè)FileNotFoundException異常,這是一個(gè)受檢異常唤衫,所以下面的代碼無法編譯:
public void readFile(String filename) {
Scanner scanner = new Scanner(new File(filename));
}
我們會(huì)得到一個(gè)編譯錯(cuò)誤:
Unhandled exception: java.io.FileNotFoundException
我們有兩種選項(xiàng)來解決這個(gè)問題婆赠。我們可以將異常添加到方法的簽名中:
public void readFile(String filename) throws FileNotFoundException {
Scanner scanner = new Scanner(new File(filename));
}
或者我們可以使用try-catch塊在現(xiàn)場處理異常:
public void readFile(String filename) {
try {
Scanner scanner = new Scanner(new File(filename));
} catch (FileNotFoundException e) {
// handle exception
}
}
非受檢異常
對于非受檢異常,我們不需要做任何處理佳励。由Integer.parseInt引發(fā)的NumberFormatException是一個(gè)運(yùn)行時(shí)異常休里,所以下面的代碼可以編譯通過:
public int readNumber(String number) {
return Integer.parseInt(callEndpoint(number));
}
然而蛆挫,我們?nèi)匀豢梢赃x擇處理異常,因此以下代碼也可以編譯通過:
public int readNumber(String number) {
try {
return Integer.parseInt(callEndpoint(number));
} catch (NumberFormatException e) {
// handle exception
return 0;
}
}
為什么我們要使用受檢異常份帐?
如果我們想了解受檢異常背后的動(dòng)機(jī)璃吧,我們需要看一下Java的歷史。該語言的創(chuàng)建是以強(qiáng)調(diào)健壯性和網(wǎng)絡(luò)功能為重點(diǎn)的废境。
最好用Java創(chuàng)始人詹姆斯·高斯林(James Gosling)自己的一句話來表達(dá):“你不能無意地說畜挨,‘我不在乎∝迹’你必須明確地說巴元,‘我不在乎⊥匝纾’”這句話摘自一篇與詹姆斯·高斯林進(jìn)行的有趣的采訪逮刨,在采訪中他詳細(xì)討論了受檢異常。
在《編程之父》這本書中堵泽,詹姆斯也談到了異常修己。他說:“人們往往忽略了檢查返回代碼∮蓿”
這再次強(qiáng)調(diào)了受檢異常的動(dòng)機(jī)睬愤。通常情況下,當(dāng)錯(cuò)誤是由于編程錯(cuò)誤或錯(cuò)誤的輸入時(shí)纹安,應(yīng)該使用非受檢異常尤辱。如果在編寫代碼時(shí)程序員無法做任何處理,應(yīng)該使用受檢異常厢岂。后一種情況的一個(gè)很好的例子是網(wǎng)絡(luò)問題光督。開發(fā)人員無法解決這個(gè)問題,但程序應(yīng)該適當(dāng)?shù)靥幚磉@種情況塔粒,可以是終止程序结借、重試操作或簡單地顯示錯(cuò)誤消息。
受檢異常存在的問題
了解了受檢異常和非受檢異常背后的動(dòng)機(jī)卒茬,我們再來看看受異常在代碼庫中可能引入的一些問題船老。
受檢異常不適應(yīng)規(guī)模化
一個(gè)主要反對受異常的觀點(diǎn)是代碼的可擴(kuò)展性和可維護(hù)性扬虚。當(dāng)一個(gè)方法的異常列表發(fā)生變化時(shí)努隙,會(huì)打破調(diào)用鏈中從調(diào)用方法開始一直到最終實(shí)現(xiàn)try-catch來處理異常的方法的所有方法調(diào)用。舉個(gè)例子辜昵,假設(shè)我們調(diào)用一個(gè)名為libraryMethod()的方法荸镊,它是外部庫的一部分:
public void retrieveContent() throws IOException {
libraryMethod();
}
在這里,方法libraryMethod()本身來自一個(gè)依賴項(xiàng),例如躬存,一個(gè)處理對外部系統(tǒng)進(jìn)行REST調(diào)用的庫张惹。其實(shí)現(xiàn)可能如下所示:
public void libraryMethod() throws IOException {
// some code
}
在將來,我們決定使用庫的新版本岭洲,甚至用另一個(gè)庫替換它宛逗。盡管功能相似,但新庫中的方法會(huì)拋出兩個(gè)異常:
public void otherSdkCall() throws IOException, MalformedURLException {
// call method from SDK
}
由于有兩個(gè)受檢異常盾剩,我們的方法聲明也需要更改:
public void retrieveContent() throws IOException, MalformedURLException {
sdkCall();
}
對于小型代碼庫來說雷激,這可能不是一個(gè)大問題,但對于大型代碼庫來說告私,這將需要進(jìn)行相當(dāng)多的重構(gòu)屎暇。當(dāng)然,我們也可以直接在方法內(nèi)部處理異常:
public void retrieveContent() throws IOException {
try {
otherSdkCall();
} catch (MalformedURLException e) {
// do something with the exception
}
}
使用這種方法驻粟,我們在代碼庫中引入了一種不一致性根悼,因?yàn)槲覀兞⒓刺幚砹艘粋€(gè)異常,而推遲了另一個(gè)異常的處理蜀撑。
異常傳播
一個(gè)與可擴(kuò)展性非常相似的論點(diǎn)是受檢異常如何在調(diào)用堆棧中傳播挤巡。如果我們遵循“盡早拋出,盡晚捕獲”的原則酷麦,我們需要在每個(gè)調(diào)用方法上添加一個(gè)throws子句(a):
相反矿卑,非受檢異常(b)只需要在實(shí)際發(fā)生異常的地方聲明一次,并在我們希望處理異常的地方再次聲明贴铜。它們會(huì)在調(diào)用堆棧中自動(dòng)傳播粪摘,直到達(dá)到實(shí)際處理異常的位置瀑晒。
不必要的依賴關(guān)系
受檢異常還會(huì)引入與非受檢異常不必要的依賴關(guān)系绍坝。讓我們再次看看在場景(a)中我們在三個(gè)不同的位置添加了IOException。如果methodA()苔悦、methodB()和methodC()位于不同的類中轩褐,那么所有相關(guān)類都將對異常類有一個(gè)依賴關(guān)系。如果我們使用了非受檢異常玖详,我們只需要在methodA()和methodC()中有這個(gè)依賴關(guān)系把介。甚至methodB()所在的類或模塊都不需要知道異常的存在。
讓我們用一個(gè)例子來說明這個(gè)想法蟋座。假設(shè)你從度假回家拗踢。你在酒店前臺退房,乘坐公共汽車去火車站向臀,然后換乘一次火車巢墅,在回到家鄉(xiāng)后在孝,你又乘坐另一輛公共汽車從車站回家∈释撸回到家后土全,你意識到你把手機(jī)忘在了酒店里。在你開始整理行李之前蓄髓,你進(jìn)入了“異巢媛”流程,乘坐公共汽車和火車回到酒店取手機(jī)会喝。在這種情況下陡叠,你按照之前相反的順序做了所有的事情(就像在Java中發(fā)生異常時(shí)向上移動(dòng)堆棧跟蹤一樣),直到你到達(dá)酒店肢执。顯然匾竿,公共汽車司機(jī)和火車操作員不需要知道“異常”蔚万,他們只需要按照他們的工作進(jìn)行岭妖。只有在前臺,也就是“回家”流程的起點(diǎn)反璃,我們需要詢問是否有人找到了手機(jī)昵慌。
糟糕的編碼實(shí)踐
當(dāng)然,作為專業(yè)的軟件開發(fā)人員淮蜈,我們絕不能在良好的編碼實(shí)踐上選擇方便斋攀。然而,當(dāng)涉及到受檢異常時(shí)梧田,往往會(huì)誘使我們快速引入以下三種模式淳蔼。通常的想法是以后再處理。我們都知道這樣的結(jié)果裁眯。另一個(gè)常見的說法是“我想為正常流程編寫代碼鹉梨,不想被異常打擾”。我經(jīng)常見到以下三種模式穿稳。
第一種模式是捕獲所有異常(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
}
}
我們只是捕獲所有可能的異常存皂,而不是單獨(dú)處理不同的異常:
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
}
}
當(dāng)然,在一般情況下逢艘,這并不一定是一種糟糕的實(shí)踐旦袋。如果我們只想記錄異常,或者在Spring Boot的@ExceptionHandler中作為最后的安全機(jī)制它改,這是一種適當(dāng)?shù)淖龇ā?/p>
第二種模式是空的catch塊:
public void myMethod() {
try {
URL url = new URL("malformed url");
} catch (MalformedURLException e) {}
}
這種方法顯然繞過了受檢異常的整個(gè)概念疤孕。它完全隱藏了異常,使我們的程序在沒有提供任何關(guān)于發(fā)生了什么的信息的情況下繼續(xù)執(zhí)行央拖。
第三種模式是簡單地打印堆棧跟蹤并繼續(xù)執(zhí)行祭阀,就好像什么都沒有發(fā)生一樣:
public void consumeAndForgetAllExceptions(){
try {
// some code that can throw an exception
} catch (Exception ex){
ex.printStacktrace();
}
}
為了滿足方法簽名而添加額外的代碼
有時(shí)我們可以確定除非出現(xiàn)編程錯(cuò)誤截亦,否則不會(huì)拋出異常。讓我們考慮以下示例:
public void readFromUrl(String endpoint) {
try {
URL url = new URL(endpoint);
} catch (MalformedURLException e) {
// do something with the exception
}
}
MalformedURLException是一個(gè)受檢異常柬讨,當(dāng)給定的字符串不符合有效的URL格式時(shí)崩瓤,會(huì)拋出該異常。需要注意的重要事項(xiàng)是踩官,如果URL格式不正確却桶,就會(huì)拋出異常,這并不意味著URL實(shí)際上存在并且可以訪問蔗牡。
即使我們在之前驗(yàn)證了格式:
public void readFromUrl(@ValidUrl String endpoint)
或者我們已經(jīng)將其硬編碼:
public static final String endpoint = "http://www.example.com";
編譯器仍然強(qiáng)制我們處理異常颖系。我們需要寫兩行“無用”的代碼,只是因?yàn)橛幸粋€(gè)受檢異常辩越。
如果我們無法編寫代碼來觸發(fā)某個(gè)異常的拋出嘁扼,就無法對其進(jìn)行測試,因此測試覆蓋率將會(huì)降低黔攒。
有趣的是趁啸,當(dāng)我們想將字符串解析為整數(shù)時(shí),并不強(qiáng)制我們處理異常:
Integer.parseInt("123");
parseInt方法在提供的字符串不是有效整數(shù)時(shí)會(huì)拋出NumberFormatException督惰,這是一個(gè)非受檢異常不傅。
Lambda表達(dá)式和異常
受檢異常并不總是與Lambda表達(dá)式很好地配合使用。讓我們來看一個(gè)例子:
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()方法拋出了一個(gè)受檢異常赏胚,所以會(huì)導(dǎo)致編譯錯(cuò)誤:
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
}
}
我們?nèi)匀粫?huì)得到一個(gè)編譯錯(cuò)誤访娶,因?yàn)槲覀儫o法在lambda內(nèi)部將受檢異常傳播到外部。我們必須在lambda表達(dá)式內(nèi)部處理異常并拋出一個(gè)運(yùn)行時(shí)異常:
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();
}
不幸的是觉阅,如果靜態(tài)方法引用拋出受檢異常崖疤,這種方式將變得不可行〉溆拢或者劫哼,我們可以讓lambda表達(dá)式返回一個(gè)錯(cuò)誤消息,然后將其添加到結(jié)果中:
public void readFile() {
List<String> lines = fileNames.stream()
.map(filename -> {
try{
return readFirstLine(filename);
} catch(FileNotFoundException e) {
return "default value";
}
}).toList();
}
然而痴柔,代碼看起來仍然有些雜亂沦偎。
我們可以在lambda內(nèi)部傳遞一個(gè)非受檢異常疫向,并在調(diào)用方法中捕獲它:
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
}
}
}
在這里咳蔚,我們需要注意之前使用受檢異常和使用非受檢異常的例子之間的一個(gè)關(guān)鍵區(qū)別。對于非受檢異常搔驼,流的處理將繼續(xù)到下一個(gè)元素谈火,而對于受檢異常,處理將結(jié)束舌涨,并且不會(huì)處理更多的元素糯耍。顯然,我們想要哪種行為取決于我們的用例。
處理受檢異常的替代方法
將受檢異常包裝為非受檢異常
我們可以通過將受檢異常包裝為非受檢異常來避免在調(diào)用堆棧中的所有方法中添加throws子句温技。而不是讓我們的方法拋出一個(gè)受檢異常:
public void myMethod() throws IOException{}
我們可以將其包裝在一個(gè)非受檢異常中:
public void myMethod(){
try {
// some logic
} catch(IOException e) {
throw new MyUnchckedException("A problem occurred", e);
}
}
理想情況下革为,我們應(yīng)用異常鏈。這樣可以確保原始異常不會(huì)被隱藏舵鳞。我們可以在第5行看到異常鏈的應(yīng)用震檩,原始異常作為參數(shù)傳遞給新的異常。這種技術(shù)在早期版本的Java中幾乎適用于所有核心Java異常蜓堕。
異常鏈?zhǔn)窃S多流行框架(如Spring或Hibernate)中常見的一種方法抛虏。這兩個(gè)框架從受檢異常轉(zhuǎn)向非受檢異常,并將不屬于框架的受檢異常包裝在自己的運(yùn)行時(shí)異常中套才。一個(gè)很好的例子是Spring的JDBC模板迂猴,它將所有與JDBC相關(guān)的異常轉(zhuǎn)換為Spring框架的非受檢異常。
Lombok @SneakyThrows
Project Lombok為我們提供了一個(gè)注解背伴,可以消除異常鏈的需要沸毁。而不是在我們的方法中添加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并不會(huì)使MalformedURLException的行為完全像運(yùn)行時(shí)異常一樣。我們將無法再捕獲它崎逃,并且以下代碼將無法編譯:
public void callSneaky() {
try {
beSneaky();
} catch (MalformedURLException e) {
// handle exception
}
}
由于@SneakyThrows移除了異常掷倔,而MalformedURLException仍然被視為受檢異常,因此我們將在第4行得到編譯器錯(cuò)誤:
Exception 'java.net.MalformedURLException' is never thrown in the corresponding try block
性能
在我的研究過程中个绍,我遇到了一些關(guān)于異常性能的討論勒葱。在受檢異常和非受檢異常之間是否存在性能差異?實(shí)際上巴柿,它們之間沒有性能差異凛虽。這是一個(gè)在編譯時(shí)決定的特性。
然而广恢,是否在異常中包含完整的堆棧跟蹤會(huì)導(dǎo)致顯著的性能差異:
public class MyException extends RuntimeException {
public MyException(String message, boolean includeStacktrace) {
super(message, null, !includeStacktrace, includeStacktrace);
}
}
在這里凯旋,我們在自定義異常的構(gòu)造函數(shù)中添加了一個(gè)標(biāo)志。該標(biāo)志指定是否要包含完整的堆棧跟蹤钉迷。在拋出異常的情況下至非,構(gòu)建堆棧跟蹤會(huì)導(dǎo)致程序變慢。因此糠聪,如果性能至關(guān)重要荒椭,則應(yīng)排除堆棧跟蹤。
一些指南
如何處理軟件中的異常是我們工作的一部分舰蟆,它高度依賴于具體的用例趣惠。在我們結(jié)束討論之前狸棍,這里有三個(gè)高級指南,我相信它們(幾乎)總是正確的味悄。
- 如果不是編程錯(cuò)誤草戈,或者程序可以執(zhí)行一些有用的恢復(fù)操作,請使用受檢異常侍瑟。
- 如果是編程錯(cuò)誤猾瘸,或者程序無法進(jìn)行任何恢復(fù)操作,請使用運(yùn)行時(shí)異常丢习。
- 避免空的catch塊牵触。
結(jié)論
本文深入探討了Java中的異常。我們講了為什么要引入異常到語言中咐低,何時(shí)應(yīng)該使用受檢異常和非受檢異常揽思。我們還討論了受檢異常的缺點(diǎn)以及為什么它們現(xiàn)在被認(rèn)為是不良實(shí)踐 - 盡管也有一些例外情況。