要抓還是要拋?
June 19, 2022假設今天你受命開發一個程式庫,其中有個功能是讀取純文字檔案,並以字串傳回所有檔案中所有文字,你也許會這麼撰寫:
public class FileUtil {
public static String readFile(String name) {
var builder = new StringBuilder();
try {
var scanner = new Scanner(new FileInputStream(name));
while(scanner.hasNext()) {
builder.append(scanner.nextLine());
builder.append('\n');
}
} catch (FileNotFoundException ex) {
ex.printStackTrace();
}
return builder.toString();
}
}
Scanner
建構時可以給予 InputStream
實例,而 FileInputStream
可指定檔名來開啟與讀取檔案內容,是 InputStream
的子類別,因此可作為 Scanner
建構之用。由於建構 FileInputStream
時,API 設計者聲明方法實作中會拋出 FileNotFoundException
,根據目前你學到的例外處理語法,於是你捕捉 FileNotFoundException
並在主控台中顯示錯誤訊息。
throws 宣告
主控台?等一下!老闆有說這個程式庫會用在文字模式中嗎?如果這個程式庫是用在 Web 網站上,發生錯誤時顯示在主控台上,Web 使用者怎麼會看得到?你開發的是程式庫,例外發生時如何處理,是程式庫使用者才知道,直接在 catch
中寫死處理例外或輸出錯誤訊息的方式,並不符合需求。
如果方法設計流程中可能引發例外,而你設計時並沒有充足的資訊知道該如何處理(例如不知道程式庫會用在什麼環境),那麼可以拋出例外,讓呼叫方法的客戶端來處理。例如:
public class FileUtil {
public static String readFile(String name)
throws FileNotFoundException {
var builder = new StringBuilder();
var scanner = new Scanner(new FileInputStream(name));
while(scanner.hasNext()) {
builder.append(scanner.nextLine());
builder.append('\n');
}
return builder.toString();
}
}
操作物件的過程中如果會拋出受檢例外,但目前環境資訊不足以處理例外,法使用 try
、catch
處理時,可由方法的客戶端依當時呼叫的環境資訊進行處理。為了告訴編譯器這件事實,必須在方法上使用 throws
宣告此方法會拋出的例外類型或父類型,編譯器才會讓你通過編譯。
拋出受檢例外,表示你認為呼叫方法的客戶端有能力且應該處理例外,throws
宣告部份,會是 API 操作介面的一部份,客戶端不用察看原始碼,從 API 文件上就能直接得知,該方法可能拋出哪些例外。
如果你認為客戶端呼叫方法的時機不當引發了某個錯誤,希望客戶端準備好前置條件,再來呼叫方法,這時可以拋出非受檢例外讓客戶端得知此情況,如果是非受檢例外,編譯器不會要求明確使用 try
、catch
或在方法上使用 throws
宣告,因為 Java 的設計上認為,非受檢例外是程式設計不當引發的臭蟲,例外應自動往外傳播,不應使用 try
、catch
處理,而應改善程式邏輯來避免引發錯誤。
使用 throw
實際上在例外發生時,可使用 try
、catch
處理當時環境可進行的例外處理,當時環境下無法決定如何處理的部份,可以拋出由呼叫方法的客戶端處理。如果想先處理部份事項再拋出,可以如下:
package cc.openhome;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.util.Scanner;
public class FileUtil {
public static String readFile(String name) throws FileNotFoundException {
var builder = new StringBuilder();
try {
var scanner = new Scanner(new FileInputStream(name));
while(scanner.hasNext()) {
builder.append(scanner.nextLine());
builder.append('\n');
}
} catch (FileNotFoundException ex) {
ex.printStackTrace();
throw ex;
}
return builder.toString();
}
}
範例在 catch
區塊進行完部份錯誤處理之後,可以使用 throw
將例外再拋出,實際上,你可以在任何流程中拋出例外,不一定要在 catch
區塊中,在流程中拋出例外,就直接跳離原有的流程,可以拋出受檢或非受檢例外,記得!如果拋出的是受檢例外,表示你認為客戶端有能力且應處理例外,此時必須在方法上使用 throws
宣告,如果拋出的例外是非受檢例外,表示你認為客戶端呼叫方法的時機出錯了,拋出例外是要求客戶端修正這個臭蟲再來呼叫方法,此時也就不用使用 throws
宣告。
貼心還是造成麻煩?
例外處理的本意是,在程式錯誤發生時,能夠有明確的方式通知 API 客戶端,讓客戶端採取進一步的動作修正錯誤,而就撰寫本文的時間點來說,Java 是唯一採用受檢例外(Checked exception)的語言,這有兩個目的:一是文件化,受檢例外宣告會是 API 操作介面的一部份,客戶端只要查閱文件,就可以知道方法可能會引發哪些例外,並事先加以處理,而這是 API 設計者決定是否拋出受檢例外時的考量之一,另一個目的是提供編譯器資訊,讓編譯器能夠在編譯時期就檢查出 API 客戶端沒有處理例外。
問題是有些錯誤發生而引發例外時,你根本無力回復,例如 SQLException
是受檢例外,如果例外的發生原因是資料庫連線異常,而連線異常的原因是由於實體線路問題,那麼無論如何你都不可能使用 try
、catch
回復到正常可運作的情況。
錯誤發生時,如果上下文環境並沒有足夠的資訊讓你處理例外,你可以就現有資訊處理完例外後,重新拋出例外。你也許會這麼寫:
public Customer getCustomer(String id) throws SQLException {
...
}
看起來似乎沒有問題,但假設這個方法是在整個應用程式非常底層被呼叫,在某個 SQLException
發生時,最好的方法是將例外浮現至呈現層,例如網頁技術,將錯誤訊息於網頁上顯示出來給管理人員。
為了讓例外往上浮現,你也許會選擇在每個方法呼叫上都宣告 throws SQLException
,但前面假設,這個方法的呼叫是在整個應用程式的底層,這樣的作法也許會造成許多程式碼的修改(更別說要重新編譯了),另一個問題是,如果你根本無權修改應用程式的其它部份,這樣的作法顯示行不通。
受檢例外本意良好,有助於程式設計人員注意到例外的可能性並加以處理,但在應用程式規模增大時,會對逐漸對維護造成困難,上述情況不一定是你自訂API時發生,也可能是在底層引入了一個會拋出受檢例外的 API 而發生類似情況。
重新拋出例外時,除了將捕捉到的例外直接拋出,也可以考慮為應用程式自訂專屬例外類別,讓例外更能表現應用程式特有的錯誤資訊。自訂例外類別時,可以繼承 Throwable
、Error
或 Exception
的相關子類別,通常建議繼承自 Exception
,如果不是繼承自 Error
或 RuntimeException
,那麼就會是受檢例外。
public class CustomizedException extends Exception { // 自訂受檢例外的一個例子
...
}
錯誤發生時,如果上下文環境並沒有足夠的資訊讓你處理例外,你可以就現有資訊處理完例外後,重新拋出例外,既然你已經針對錯誤做了某些處理,那麼也就可以考慮自訂例外,用以更精確地表示出未處理的錯誤,如果認為呼叫 API 的客戶端應當有能力處理未處理的錯誤,那就自訂受檢例外、填入適當錯誤訊息並重新拋出,並在方法上使用 throws
加以宣告,如果認為呼叫 API 的客戶端沒有準備好就呼叫了方法,才會造成還有未處理的錯誤,那就自訂非受檢例外、填入適當錯誤訊息並重新拋出。
public class CustomizedException extends RuntimeException { // 自訂非受檢例外的一個例子
...
}
一個基本的例子是這樣的:
try {
....
} catch(SomeException ex) {
// 作些可行的處理
// 也許是 Logging 之類的
throw new CustomizedException("error message..."); // Checked 或 Unchecked?
}
類似地,如果流程中要拋出例外,也要思考一下,這是客戶端可以處理的例外嗎?還是客戶端沒有準備好前置條件就呼叫方法,才引發的例外?
if(someCondition) {
throw new CustomizedException("error message"); // Checked 或 Unchecked?
}
無論如何,Java 採用了受檢例外的作法,Java 的標準 API 也打算一直這麼區分下去,只是受檢例外讓開發人員無從選擇,會由編譯器強制性要求處理,確實會在設計上造成麻煩,因而有些開發者在設計程式庫時,乾脆就選擇完全使用非受檢例外,一 些會封裝應用程式底層行為的框架,如 Spring 或 Hibernate,就選擇了讓例外體系是非受檢例外,例如 Spring 的 DataAccessException
,或者是 Hibernate 3 的 HibernateException
,它們選擇給予開發人員較大的彈性來面對例外(也許也需要開發人員更多的經驗)。
隨著應用程式的演化,例外也可以考慮演化,也許一開始是設計為受檢例外,然而隨著應用程式堆疊的加深,受檢例外老是一層一層往外宣告拋出造成麻煩時,這也許代表了,原先認為客戶端可處理的例外,每一層客戶端實際上都無力處理了,每層客戶端都無力處理的例外,也許該視為一種臭蟲,也許客戶端在呼叫時都該準備好前置條件再行呼叫,以避免引發錯誤,這時將受檢例外演化為非受檢例外,也許就有其必要。
實際上確實有這類例子,Hibernate 2 的 HibernateException
是受檢例外,然而 Hibernate 3 的 HibernateException
變成了非受檢例外。
然而,即使不用面對受檢例外與非受檢例外的區別,開發者仍然必須思考,這是客戶端可以處理的例外嗎?還是客戶端沒有準備好前置條件就呼叫方法,才引發的例外?