在 Java 開發中,受檢異常(checked exception)雖然強制開發者處理錯誤情況,但有時會遇到無法在方法簽名中拋出它們的窘境。特別是當覆寫父類方法或實作介面方法時,若原方法未宣告任何受檢異常,新實作也就不能隨意添加。此時該如何處理?本文將透過具體案例深入探討這個經典問題,並提供優雅的解決方案。
假設我們有一組 File 物件,需依其標準路徑(canonical path,即解析別名、符號連結、/../ 及 /./ 後的完整絕對路徑)進行字典序排序。直覺寫法如下:
import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
public class FileComparator implements Comparator<File> {
public int compare(File f1, File f2) {
return f1.getCanonicalPath().compareTo(f2.getCanonicalPath());
}
// ... main 方法省略
}
可惜這段碼無法編譯:getCanonicalPath() 拋出 IOException,而 Comparator.compare() 方法簽名不允許拋出受檢異常。
一般處理受檢異常有兩條路:
compare() 中回傳固定值(如 -1)會破壞比較器的穩定性契約,導致排序結果錯誤。無奈之下,許多開發者選擇「轉換為執行時異常」:
public int compare(File f1, File f2) {
try {
return f1.getCanonicalPath().compareTo(f2.getCanonicalPath());
} catch (IOException ex) {
throw new RuntimeException(ex);
}
}
雖然能編譯通過,但這違反了 Comparator 介面的契約——它不應拋出執行時異常。調用端合理預期 compare() 不會拋出異常,一旦拋出會導致調用碼措手不及,甚至破壞資料結構。這只是逃避問題,而非真正解決。
核心觀念:比較字串本身不會拋出異常,取得 canonical path 才會。因此可先將所有檔案轉為路徑字串(此步驟可拋出異常),再對字串排序,最後映射回檔案列表。範例:
import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
public class FileComparator {
private static ArrayList<String> getCanonicalPaths(ArrayList<File> files) throws IOException {
ArrayList<String> paths = new ArrayList<String>();
for (File file : files) paths.add(file.getCanonicalPath());
return paths;
}
public static void main(String[] args) throws IOException {
ArrayList<File> files = new ArrayList<File>();
for (String arg : args) files.add(new File(arg));
ArrayList<String> paths = getCanonicalPaths(files);
// 維持原始對應關係
HashMap<String, File> map = new HashMap<String, File>();
int i = 0;
for (String path : paths) {
map.put(path, files.get(i));
i++;
}
Collections.sort(paths);
files.clear();
for (String path : paths) {
files.add(map.get(path));
}
}
}
優點:異常發生在列表修改前,不會造成資料損壞,調用端可自行決定如何處理 IOException。
若排序邏輯本身涉及 I/O(如比較檔案內容),常用演算法(如 Collections.sort())會反覆呼叫比較器,導致大量重複 I/O。此時可定義專屬介面:
import java.io.IOException;
public interface IOComparator<T> {
int compare(T o1, T o2) throws IOException;
}
再實作一個可處理異常的排序工具(如氣泡排序示範),在臨時副本上操作,確保無異常時才寫回原列表:
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
public class IOSorter {
public static <T> void sort(List<T> list, IOComparator<? super T> comparator) throws IOException {
List<T> temp = new ArrayList<T>(list.size());
temp.addAll(list);
bubblesort(temp, comparator);
// 無異常時才複製回原列表
list.clear();
list.addAll(temp);
}
private static <T> void bubblesort(List<T> list, IOComparator<? super T> comparator) throws IOException {
for (int i = 1; i < list.size(); i++) {
for (int j = 0; j < list.size() - i; j++) {
if (comparator.compare(list.get(j), list.get(j + 1)) > 0) {
swap(list, j);
}
}
}
}
private static <T> void swap(List<T> list, int j) {
T temp = list.get(j);
list.set(j, list.get(j + 1));
list.set(j + 1, temp);
}
}
更進階地,比較器可加入重試機制應對暫時性 I/O 錯誤:
import java.io.File;
import java.io.IOException;
public class CanonicalPathComparator implements IOComparator<File> {
@Override
public int compare(File f1, File f2) throws IOException {
for (int i = 0; i < 3; i++) {
try {
return f1.getCanonicalPath().compareTo(f2.getCanonicalPath());
} catch (IOException ex) {
continue;
}
}
// 最後一次嘗試,若仍失敗則拋出
return f1.getCanonicalPath().compareTo(f2.getCanonicalPath());
}
}
若 IOException 是非受檢異常,問題會好轉嗎?不會。那樣反而容易寫出忽略 I/O 錯誤、運行時才驚覺失敗的代碼。受檢異常強制我們正視環境錯誤(I/O 錯誤是常態),主動處理遠勝視而不見。
當發現自己想拋出受檢異常卻不被允許時,別急著用 RuntimeException 包裝或吞掉異常。請退一步思考:為何要覆寫這個方法?或許根本該換個設計——將易發生異常的操作提前、分離,或引入支援異常的專用介面。這才是受檢異常存在的意義:引導我們寫出更健壯、誠實的代碼。