skip to content
BlogZzz

[Learn] Java Exception 與 Optional:處理失敗與缺值

/ 11 min read

Updated:
Table of Contents

Java method 不一定每次都能正常回傳資料

有時操作無法完成,有時只是查詢結果不存在,這兩種情況不應使用相同方式處理

輸入不合法或操作失敗 → Exception
查詢結果本來就可能不存在 → Optional
呼叫者要求資料一定存在 → Optional.orElseThrow

這篇會整理 checked exception、unchecked exception、try-catch 與 custom exception,再說明 Optional 適合解決什麼問題

Exception 表示操作無法正常完成

Java exception hierarchy 可以先簡化成以下結構

Throwable
├── Error
└── Exception
├── RuntimeException
└── 其他 checked exception
  • Error 通常表示應用程式難以合理恢復的 JVM 或環境問題
  • checked exception 會由 compiler 要求 catch 或在 method 宣告 throws
  • RuntimeException 與其 subclass 屬於 unchecked exception,compiler 不強制處理

常見的 unchecked exception 包含

  • IllegalArgumentException
  • IllegalStateException
  • NullPointerException
  • IndexOutOfBoundsException
  • NoSuchElementException

常見的 checked exception 包含 IOException 與 SQLException

throw、throws、try 與 catch

throw 用來實際拋出一個 exception object

if (score < 0) {
throw new IllegalArgumentException("score 不可小於 0");
}

throws 寫在 method declaration,表示這個 method 可能把 exception 交給呼叫者處理

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
public static String load(Path path) throws IOException {
return Files.readString(path);
}

呼叫 checked exception method 時,可以選擇 catch

try {
String content = load(Path.of("config.txt"));
System.out.println(content);
} catch (IOException exception) {
System.err.println("讀取設定失敗:" + exception.getMessage());
}

也可以在目前 method 繼續宣告 throws IOException,把決定交給更上層

finally 不論是否發生 exception 都會執行,適合放必要的收尾動作

處理 file、stream 或 connection 等 AutoCloseable resource 時,優先使用 try-with-resources

import java.io.BufferedReader;
try (BufferedReader reader = Files.newBufferedReader(Path.of("config.txt"))) {
System.out.println(reader.readLine());
} catch (IOException exception) {
System.err.println("讀取設定失敗:" + exception.getMessage());
}

離開 try block 時,Java 會自動呼叫 resource 的 close

建立自己的 Exception

當失敗具有明確的 domain 意義,可以建立專用 exception

public final class DuplicateStudentException extends RuntimeException {
public DuplicateStudentException(String id) {
super("學號已存在:" + id);
}
}

呼叫端可以明確 catch 它

try {
registry.register(student);
} catch (DuplicateStudentException exception) {
System.err.println("註冊失敗:" + exception.getMessage());
}

處理 exception 時可以先遵守幾個原則

  • catch 能處理的具體 exception,不要習慣性 catch Exception
  • 不要留下空白 catch block
  • 訊息要提供失敗操作與重要識別資訊
  • 包裝底層 exception 時保留原始 cause
  • 不要用 exception 代替一般流程控制

如果目前這一層不知道如何恢復,通常比起記錄後假裝成功,繼續往上拋會更合理

Optional 表示值可能不存在

如果查詢學生可能找不到,可以回傳 Optional<Student>

public Optional<Student> findById(String id) {
return Optional.ofNullable(studentsById.get(id));
}

回傳型別直接告訴呼叫者,這次查詢可能沒有結果

建立 Optional 的方式如下

Optional<String> name = Optional.of("Amy");
Optional<String> maybeName = Optional.ofNullable(value);
Optional<String> empty = Optional.empty();
  • Optional.of(value) 不接受 null
  • Optional.ofNullable(value) 會把 null 轉成 empty
  • Optional.empty() 明確建立沒有值的 Optional

不要讓 Optional 自己是 null

// 不要這樣做
return null;
// 沒有結果時回傳
return Optional.empty();

使用 Optional 處理結果

只需要在有值時執行動作,可以使用 ifPresent

registry.findById("S001")
.ifPresent(student -> System.out.println(student.getName()));

需要轉換內容,可以使用 map

String name = registry.findById("S001")
.map(student -> student.getName())
.orElse("查無學生");

需要留下符合條件的值,可以使用 filter

Optional<Student> amy = registry.findById("S001")
.filter(student -> student.getName().equals("Amy"));

mapping function 本身就回傳 Optional 時,使用 flatMap 避免得到巢狀的 Optional<Optional<T>>

沒有值時的常見選擇如下

Student fallback = optional.orElse(defaultStudent);
Student lazyFallback = optional.orElseGet(() -> loadDefaultStudent());
Student required = optional.orElseThrow(
() -> new NoSuchElementException("找不到學生"));

orElse 的 argument 會先被計算,即使 Optional 有值也一樣

建立預設值的成本較高或有 side effect 時,使用 lazy 的 orElseGet

不建議把程式重新寫回手動檢查後直接呼叫 get

if (optional.isPresent()) {
Student student = optional.get();
}

這種寫法不是永遠錯誤,但 map、ifPresent、orElseGet 或 orElseThrow 通常更能直接表達意圖

Optional 最適合用在 method return type,表示可能沒有單一結果

Collection 沒有元素時直接回傳 empty collection,通常不需要再包一層 Optional

List<Student> students = List.of();

Optional 與 Exception 如何選

兩者不是互相替代的工具

查詢結果本來就可能不存在
→ Optional
呼叫者要求資料一定存在,但實際找不到
→ Exception
輸入不合法或系統無法完成操作
→ Exception

同一個 repository 或 registry 可以同時提供兩種 API

public Optional<Student> findById(String id) {
return Optional.ofNullable(studentsById.get(id));
}
public Student getRequiredById(String id) {
return findById(id).orElseThrow(
() -> new NoSuchElementException("找不到學生:" + id));
}

findById 讓呼叫者處理正常的沒有結果

getRequiredById 則表達找不到就是目前操作的失敗

完整範例:StudentRegistry

建立以下目錄結構

src/
└── com/
└── example/
├── app/
│ └── Main.java
└── student/
├── Student.java
├── StudentRegistry.java
└── DuplicateStudentException.java

建立 Student.java

package com.example.student;
public final class Student {
private final String id;
private final String name;
public Student(String id, String name) {
if (id == null || id.isBlank()) {
throw new IllegalArgumentException("id 不可為空");
}
if (name == null || name.isBlank()) {
throw new IllegalArgumentException("name 不可為空");
}
this.id = id;
this.name = name;
}
public String getId() {
return id;
}
public String getName() {
return name;
}
}

沿用前面的 DuplicateStudentException.java

package com.example.student;
public final class DuplicateStudentException extends RuntimeException {
public DuplicateStudentException(String id) {
super("學號已存在:" + id);
}
}

建立 StudentRegistry.java

package com.example.student;
import java.util.HashMap;
import java.util.Map;
import java.util.NoSuchElementException;
import java.util.Optional;
public final class StudentRegistry {
private final Map<String, Student> studentsById = new HashMap<>();
public void register(Student student) {
if (studentsById.containsKey(student.getId())) {
throw new DuplicateStudentException(student.getId());
}
studentsById.put(student.getId(), student);
}
public Optional<Student> findById(String id) {
return Optional.ofNullable(studentsById.get(id));
}
public Student getRequiredById(String id) {
return findById(id).orElseThrow(
() -> new NoSuchElementException("找不到學生:" + id));
}
}

最後建立 Main.java

package com.example.app;
import com.example.student.DuplicateStudentException;
import com.example.student.Student;
import com.example.student.StudentRegistry;
import java.util.NoSuchElementException;
public class Main {
public static void main(String[] args) {
StudentRegistry registry = new StudentRegistry();
registry.register(new Student("S001", "Amy"));
registry.register(new Student("S002", "Ben"));
String foundName = registry.findById("S002")
.map(student -> student.getName())
.orElse("查無學生");
String missingName = registry.findById("S999")
.map(student -> student.getName())
.orElse("查無學生");
System.out.println("找到:" + foundName);
System.out.println("查詢 S999:" + missingName);
try {
registry.register(new Student("S001", "Alice"));
} catch (DuplicateStudentException exception) {
System.out.println("註冊失敗:" + exception.getMessage());
}
try {
registry.getRequiredById("S999");
} catch (NoSuchElementException exception) {
System.out.println("查詢失敗:" + exception.getMessage());
}
}
}

編譯並執行

Terminal window
javac -d out \
src/com/example/student/*.java \
src/com/example/app/Main.java
java -cp out com.example.app.Main

執行結果如下

找到:Ben
查詢 S999:查無學生
註冊失敗:學號已存在:S001
查詢失敗:找不到學生:S999

同一個 registry 提供兩種查詢語意

  • findById 表示沒有結果是正常情況
  • getRequiredById 表示目前操作要求學生一定存在
  • register 表示重複學號違反註冊規則

常見錯誤:catch 後什麼都不做

try {
performOperation();
} catch (Exception exception) {
}

這會讓失敗消失,呼叫端可能誤以為操作成功

應該在能恢復的層級處理,否則保留 context 後繼續拋出

常見錯誤:catch 過度寬廣的 Exception

如果真正能處理的是 IOException,就不應順便攔截其他 RuntimeException

優先 catch 具體型別,也讓不屬於目前責任的失敗繼續往上傳遞

常見錯誤:對 Optional 直接呼叫 get

Student student = registry.findById("S999").get();

Optional 為 empty 時會拋出 NoSuchElementException

改用能表達意圖的 orElse、orElseGet、orElseThrow 或 ifPresent

常見錯誤:回傳 null Optional

Optional 本身不應該是 null

沒有值時回傳 Optional.empty(),讓呼叫者只處理一種缺值表示方式

常見錯誤:用 Exception 表示正常的查無結果

如果查無資料是預期情況,讓呼叫者每次都 catch exception 會讓正常流程難以閱讀

此時回傳 Optional 通常更能表達 API 語意

結論

  • checked exception 必須 catch 或宣告 throws,unchecked exception 不受 compiler 強制
  • throw 用來拋出 exception object,throws 用來宣告 method 可能傳遞的 exception
  • 能恢復時才 catch,否則應保留 context 並交給合適的上層處理
  • try-with-resources 適合管理 AutoCloseable resource
  • Optional 表示單一結果可能不存在,不應回傳 null Optional
  • orElse、orElseGet 與 orElseThrow 分別表達預設值、延遲建立與缺值失敗
  • 預期中的查無結果適合 Optional,違反規則或操作失敗適合 Exception

參考資料