try/catch 例外處理

June 15, 2022

來看一個簡單的程式,使用者可以連續輸入整數,最後輸入0結束後會顯示輸入數的平均值:

package cc.openhome;

import java.util.Scanner;

public class Average {
    public static void main(String[] args) {
        var scanner = new Scanner(System.in);
        var sum = 0.0;
        var count = 0;

        while(true) {
            var number = scanner.nextInt();
            if(number == 0) {
                break;
            }
            sum += number;
            count++;
        }
        System.out.printf("平均 %.2f%n", sum / count);
    }
}

如果使用者正確地輸入每個整數,程式會如預期地顯示平均:

10 20 30 40 0
平均 25.00

使用 try/catch

如果使用者不小心輸入錯誤,那就會出現奇怪的訊息,例如第三個數輸入為 3o,而不是 30 了:

10 20 3o 40 0
Exception in thread "main" java.util.InputMismatchException
    at java.util.Scanner.throwFor(Scanner.java:909)
    at java.util.Scanner.next(Scanner.java:1530)
    at java.util.Scanner.nextInt(Scanner.java:2160)
    at java.util.Scanner.nextInt(Scanner.java:2119)
    at cc.openhome.Average.main(Average.java:11)
Java Result: 1

這段錯誤訊息對除錯是很有價值的,不過先看到錯誤訊息的第一行:

Exception in thread "main" java.util.InputMismatchException

Scanner 物件的 nextInt 方法,可以將使用者輸入的下一個字串剖析為 int 值,如果出現 InputMismatchException 錯誤訊息,表示不符合 Scanner 物件預期,因為 Scanner 物件預期下一個字串本身要代表數字。

Java 所有錯誤都會被包裹為物件,如果你願意,可以嘗試(try)捕捉(catch)代表錯誤的物件後作一些處理。例如:

package cc.openhome;

import java.util.*;

public class Average2 {
    public static void main(String[] args) {
        try {
            var scanner = new Scanner(System.in);
            var sum = 0.0;
            var count = 0;

            while (true) {
                var number = scanner.nextInt();
                if (number == 0) {
                    break;
                }
                sum += number;
                count++;
            }
            System.out.printf("平均 %.2f%n", sum / count);
        } catch (InputMismatchException ex) {
            System.out.println("必須輸入整數");
        }
    }
}

這邊使用了 trycatch 語法,JVM 會嘗試執行 try 區塊的程式碼,如果發生錯誤,執行流程會跳離錯誤發生點,然後比對 catch 括號中宣告的型態,是否符合被拋出的錯誤物件型態,如果是的話,就執行 catch 區塊的程式碼。

一個執行無誤的範例如下所示:

10 20 30 40 0
平均 25.00

範例中如果 nextInt 發生 InputMismatchException,流程就會跳到型態宣告為 InputMismatchExceptioncatch 區塊,執行完 catch 區塊後,之後沒有其它程式碼了,所以程式就結束了。一個執行時輸入有誤的範例如下所示:

10 20 3o 40 0
必須輸入整數

這個範例示範了如何運用 trycatch,在錯誤發生時顯示更友善錯誤訊息。有時錯誤可以在捕捉處理之後,嘗試回復程式正常執行流程。例如:

package cc.openhome;

import java.util.*;

public class Average3 {
    public static void main(String[] args) {
        var scanner = new Scanner(System.in);
        var sum = 0.0;
        int count = 0;

        while (true) {
            try {
                var number = scanner.nextInt();
                if (number == 0) {
                    break;
                }
                sum += number;
                count++;
            } catch (InputMismatchException ex) {
                System.out.printf("略過非整數輸入:%s%n", scanner.next());
            }
        }
        System.out.printf("平均 %.2f%n", sum / count);
    }
}

如果 nextInt 發生了 InputMismatchException 錯誤,執行流程就會跳到 catch 區塊,執行完 catch 區塊之後,由於還在 while 迴圈中,還可繼續下一個迴圈流程。

一個輸入錯誤時的結果示範如下,對於正確的整數輸入予以加總,對於錯誤的輸入則顯示略過,最後顯示平均值:

10 20 3o 40 0
略過非整數輸入:3o
平均 23.33

例外繼承架構

不過就 Java 在例外處理的設計上,並不鼓勵捕捉 InputMismatchException 並重新恢復執行流程,這跟例外繼承架構有關。

例如,Average 範例中,雖然沒有撰寫 trycatch 語句,照樣可以編譯執行,Java 初學者往往不理解的是,如果如下撰寫,編譯卻會錯誤?

try/catch 例外處理

單純就語法上想解決這個錯誤訊息有兩種方式,一是使用 trycatch 包裹 System.in.read,二是在 main 方法旁宣告 throws java.io.IOException

簡單來說,API 設計者在 System.in.read 的實作中,某些條件成立時會引發錯誤,而且認為呼叫 System.in.read 方法的客戶端有能力處理錯誤,要求編譯器提醒客戶端必須明確處理錯誤,不然不可通過編譯。例如,若如下撰寫就可以通過編譯:

try {
    int ch = System.in.read();
    System.out.println(ch);
} catch(java.io.IOException ex) {
    ex.printStackTrace() ;
}

Average 範例與這邊的例子,程式都有可能發生錯誤,為什麼編譯器單單就只要求這邊的範例,一定要處理錯誤呢?要瞭解這個問題,得先瞭解那些錯誤物件的繼承架構:

try/catch 例外處理

首先要瞭解錯誤會被包裝為物件,這些物件都是可拋出的(稍後會介紹 throw 語法,就會瞭解如何拋出錯誤物件),因此可拋出的物件都繼承自 java.lang.Throwable 類別,並可使用 trycatch 進行捕捉,Throwable 定義了取得訊息、堆疊追蹤(Stack Trace)等方法,它有兩個子類別:java.lang.Errorjava.lang.Exception

Error 與其子類別實例代表嚴重系統錯誤,例如硬體層面錯誤、JVM錯誤或記憶體不足等問題。雖然也可以使用 trycatch 來處理 Error 物件,但並不建議,發生嚴重系統錯誤時,Java 應用程式本身是無力回復的。舉例來說,若 JVM 所需記憶體不足,如何撰寫程式要求作業系統給予 JVM 更多記憶體呢?Error 物件拋出時,基本上不用處理,任其傳播至 JVM 為止,或者是最多留下日誌訊息,供開發者除錯時作為修正程式時的線索。

如果拋出了 Throwable 物件,而程式中沒有任何 catch 捕捉到物件,最後由 JVM 捕捉到的話,那 JVM 基本處理就是顯示錯誤物件包裹之訊息並中斷程式。

程式設計本身的錯誤,建議使用 Exception 或其子類別實例來表現,單就語法與繼承架構上來說,如果某個方法宣告會拋出ThrowableException 或子類別實例,但又不屬於 Errorjava.lang.RuntimeException 或其子類別實例,你就必須明確使用 trycatch 語法加以處理,或者在方法用 throws 宣告這個方法會拋出例外,否則會編譯失敗。

例如,先前呼叫 System.in.read 時,in 其實是 System 的靜態成員,其型態為 java.io.InputStream,如果你查詢 API 文件,可以看到 InputStreamread 方法被 API 設計者宣告為:

try/catch 例外處理

IOExceptionException 的直接子類別,所以編譯器要求你明確使用語法加以處理。Exception 或其子物件,但非屬於RuntimeException 或其子物件,稱為受檢例外(Checked Exception),受誰檢查?受編譯器檢查!受檢例外存在之目的,在於API設計者實作某方法時,某些條件成立時會引發錯誤,而且認為呼叫方法的客戶端有能力處理錯誤,要求編譯器提醒客戶端必須明確處理錯誤,不然不可通過編譯,API 客戶端無權選擇要不要處理。

屬於 RuntimeException 衍生出來的類別實例,代表 API 設計者實作某方法時,某些條件成立時會引發錯誤,而且認為 API 客戶端應該在呼叫方法前做好檢查,以避免引發錯誤,之所以命名為執行時期例外,是因為編譯器不會強迫一定得在語法上加以處理,亦稱為非受檢例外(Unchecked Exception)。

例如使用陣列時,若存取超出索引就會拋出 ArrayIndexOutOfBoundsException,但編譯器並沒有強迫你在語法上加以處理,這是因為 ArrayIndexOutOfBoundsException 是一種 RuntimeException,可以在 API 文件的開頭找到繼承架構圖:

try/catch 例外處理

Average 範例中,因為 InputMismatchException 設計為一種 RuntimeException

try/catch 例外處理

因此即使實際上還是有可能引發例外,但編譯器並不強迫你得處理例外,你有權利選擇是否使用 trycatch 處理,Java 對於 RuntimeException 的態度是,這是一種臭蟲,也就是呼叫方法前沒有做好前置檢查,應該予以修正,使得呼叫方法時不會引發錯誤,如果真要以 trycatch 處理,建議是日誌或呈現友善訊息,像是使用 trycatchAverage2 的作法就是個例子。

雖然有些小題大作,不過 使用 trycatchAverage3 若要避免出現 InputMismatchException,應該是取得使用者的字串輸入之後,檢查是否為數字格式,若是再轉換為 int 整數,若否則提醒使用者做正確格式輸入,例如:

package cc.openhome;

import java.util.Scanner;

public class Average4 {
    public static void main(String[] args) {
        var sum = 0.0;
        var count = 0;
        while(true) {
            var number = nextInt();
            if(number == 0) {
                break;
            }
            sum += number;
            count++;
        }
        System.out.printf("平均 %.2f%n", sum / count);
    }

    static Scanner scanner = new Scanner(System.in);

    static int nextInt() {
        String input = scanner.next();
        while(!input.matches("\\d*")) {
            System.out.println("請輸入數字");
            input = scanner.next();
        }
        return Integer.parseInt(input);
    }
}

除了瞭解 ErrorException 的區別,以及 ExceptionRuntimeException 的分別之外,使用 trycatch 捕捉例外物件時也要注意,如果父類別例外物件在子類別例外物件前被捕捉,則 catch 子類別例外物件的區塊將永遠不會被執行,編譯器會檢查出這個錯誤。例如:

try/catch 例外處理

要完成這個程式的編譯,必須更改例外物件捕捉的順序。例如:

try {
    System.in.read();
} catch(java.io.IOException e) {
    e.printStackTrace();
} catch(Exception e) {
    e.printStackTrace();
}

經常地,你會發現到數個型態的 catch 區塊在作相同的事情,這種情況常發生在某些例外都要進行日誌記錄的情況。例如:

try {
    作一些事...
} catch(IOException e) {
    e.printStackTrace();
} catch(InterruptedException e) {
    e.printStackTrace();
} catch(ClassCastException e) {
    e.printStackTrace();
}

然而 catch 例外後的區塊內容重複了,撰寫時不僅無趣且對維護並沒有幫助。可以如下使用多重捕捉(multi-cath)語法:

try {
    作一些事...
} catch(IOException | InterruptedException | ClassCastException e) {
    e.printStackTrace();
}

這樣的撰寫方式簡潔許多,catch 區塊會在發生 IOExceptionInterruptedExceptionClassCastException 時執行,不過仍得注意例外的繼承,catch 括號中列出的例外不得有繼承關係,否則會發生編譯錯誤。

分享到 LinkedIn 分享到 Facebook 分享到 Twitter