第 10 章 例外處理(Exception Handling)

程式中的臭蟲(Bug)總是無所不在,即使您認為程式中應該沒有錯誤了,臭蟲總會在某個時候鑽出來困擾您,面對程式中各種層出不窮的錯誤,Java 提供了「例外處理」機制來協助開發人員避開可能的錯誤,「例外」(Exception)在 Java 中代表一個錯誤的實例,編譯器會幫您檢查一些可能產生例外(Checked exception)的狀況,並要求您注意並處理,而對於「執行時期例外」(Runtime exception),您也可以嘗試捕捉例外並將程式回復至正常狀況。

這個章節會介紹 Java 的例外處理架構以及斷言(Assertion),雖然就章節內容而言,這章是個相對簡短的章節,但例外處理卻可能是您撰寫程式時最常面對的議題,因為程式中的錯誤無所不在,編譯器會經常提醒您必須作例外處理,而您也經常必須處理執行時期例外。


10.1 例外處理入門

回憶一下 6.2.1 中介紹過的「命令列引數」(Command line argument),在還沒有學會使用Java的例外處理之前,為了確定使用者是否有提供引數,您可能要先檢查引數的陣列長度是否為 0,以免程式存取超過陣列長度而發生錯誤:

  1. if(args.length == 0) {
  2. // 使用者沒有指定引數,顯示引數功能畫面
  3. }
  4. else {
  5. // 執行所指定引數的對應功能
  6. }

利用條件判斷式來避免錯誤的發生,這樣的檢查方式在一些程式語言中經常出現,然而顯然的,處理錯誤的邏輯與處理業務的邏輯混在一起,如果更多的錯誤狀況必須檢查的話,程式會更難以閱讀,且由於使用了一些判斷式,即使發生機率低的錯誤,也都必須一視同仁的進行判斷檢查,這會使得程式的執行效能受到一定程度的影響。

Java 的例外處理機制可以協助您避開或是處理程式可能發生的錯誤,「例外」(Exception)在 Java 中代表一個錯誤的實體物件,在特定錯誤發生時會丟出特定的例外物件,有些預期中可能發生的例外,編譯器會提醒您先行處理,對於一些程式運行時所發生的執行時期例外,您有機會捕捉這些例外,並嘗試將程式回復至正常運作狀態。

在 Java 中如果想嘗試捕捉例外,可以使用 “try”、”catch”、”finally” 三個關鍵字組合的語法來達到,其語法基本結構如下:

  1. try {
  2. // 陳述句
  3. }
  4. catch(例外型態 名稱) {
  5. // 例外處理
  6. }
  7. finally {
  8. // 一定會處理的區塊
  9. }

一個 “try” 語法所包括的區塊,必須有對應的 “catch” 區塊或是 “finally” 區塊,”try” 區塊可以搭配多個 “catch” 區塊,如果有設定 “catch” 區塊,則 “finally” 區塊可有可無,如果沒有定義 “catch” 區塊,則一定要有 “finally” 區塊。

使用實例來說明,您可以使用 try…catch 語法來取代命令列引數的陣列長度檢查動作,如範例 10.1 所示。

範例 10.1 CheckArgsDemo.java

  1. public class CheckArgsDemo {
  2. public static void main(String[] args) {
  3. try {
  4. System.out.printf("執行 %s 功能%n", args[0]);
  5. }
  6. catch(ArrayIndexOutOfBoundsException e) {
  7. System.out.println("沒有指定引數");
  8. e.printStackTrace();
  9. }
  10. }
  11. }

如果在執行程式時沒有指定引數,那麼args陣列的長度是 0,程式中嘗試從 args[0] 取得引數時就會發生錯誤,錯誤的實例是 ArrayIndexOutOfBoundsException,這個實例會在被對應的 “catch” 所捕捉,在範例 10.1 中被捕捉的例外指定給 e 名稱來參考,例外被捕捉後會執行對應的 “catch” 區塊,在範例中是顯示提示訊息,並使用 printStackTrace() 會顯示完整的例外訊息,沒有指定引數時的執行結果如下:

  1. >java CheckArgsDemo
  2. 沒有指定引數
  3. java.lang.ArrayIndexOutOfBoundsException: 0
  4. at CheckArgsDemo.main(CheckArgsDemo.java:4)

範例 10.1 中並沒有使用條件判斷式來檢查陣列長度,也就是沒有使用 if 陳述句,例外處理只有在錯誤真正發生,也就是丟出例外時才處理,所以與使用 if 判斷式每次都要進行檢查動作相比,效率上會好一些,要注意的是,例外處理最好只用於錯誤處理,而不應是用於程式業務邏輯的一部份,因為例外的產生要消耗資源,例如以下應用例外處理的方式就不適當:

  1. while(true) {
  2. try {
  3. System.out.println(args[i]);
  4. i++;
  5. }
  6. catch(ArrayIndexOutOfBoundsException e) {
  7. break;
  8. }
  9. }

循序取出陣列值時,最後一定會到達陣列的邊界,檢查邊界是必要的動作,是程式業務邏輯的一部份,而不是錯誤處理邏輯的一部份,您該使用的是 for 迴圈而不是依賴例外處理,例如下面的方式才是正確的:

  1. for(int i = 0; i < args.length; i++) {
  2. System.out.println(args[i]);
  3. }

10.2 受檢例外(Checked Exception)、執行時期例外(Runtime Exception)

在某些情況下例外的發生是可預期的,例如使用輸入輸出功能時,可能會由於硬體環境問題,而使得程式無法正常從硬體取得輸入或進行輸出,這種錯誤是可預期發生的,像這類的例外稱之為「受檢例外」(Checked Exception),對於受檢例外編譯器會要求您進行例外處理,例如在使用 java.io.BufferedReader 的 readLine() 方法取得使用者輸入時,編譯器會要求您於程式碼中明確告知如何處理 java.io.IOException,範例 10.2 是個簡單的示範。

範例 10.2 CheckedExceptionDemo.java

  1. import java.io.*;
  2. public class CheckedExceptionDemo {
  3. public static void main(String[] args) {
  4. try {
  5. BufferedReader buf = new BufferedReader(
  6. new InputStreamReader(System.in));
  7. System.out.print("請輸入整數: ");
  8. int input = Integer.parseInt(buf.readLine());
  9. System.out.println("input x 10 = " + (input*10));
  10. }
  11. catch(IOException e) { // checked exception
  12. System.out.println("I/O錯誤");
  13. }
  14. catch(NumberFormatException e) { // runtime exception
  15. System.out.println("輸入必須為整數");
  16. }
  17. }
  18. }

IOException 是受檢例外,是可預期會發生的例外,編譯器要求您必須處理,如果您不在程式中處理的話,例如將 IOException 的 “catch” 區塊拿掉,編譯器會回報錯誤訊息:

  1. CheckedExceptionDemo.java:9: unreported exception java.io.IOException; must be caught or declared to be thrown

範例 10.2 中試著從使用者輸入取得一個整數值,由 BufferedReader 物件所讀取到的輸入是個字串,您使用 Integer 類別的 parseInt() 方法試著剖析該字串為整數,如果無法剖析,則會發生錯誤並丟出一個 NumberFormatException 例外物件,當這個例外丟出後,程式會離開目前執行的位置,而如果設定的 “catch” 有捕捉這個例外,則會執行對應區塊中的陳述句,注意當例外一但丟出,就不會再回到例外的丟出點了。

像 NumberFortmatException 例外是「執行時期例外」(Runtime exception),也就是例外是發生在程式執行期間,並不一定可預期它的發生,編譯器不要求您一定要處理,對於執行時期例外若沒有處理,則例外會一直往外丟,最後由 JVM 來處理例外,JVM 所作的就是顯示例外堆疊訊息,之後結束程式。

如果 try…catch 後設定有 “finally” 區塊,則無論例外是否有發生,都一定會執行 “finally” 區塊。

10.3 throw、throws

當程式發生錯誤而無法處理的時候,會丟出對應的例外物件,除此之外,在某些時刻,您可能會想要自行丟出例外,例如在捕捉例外並處理結束後,再將例外丟出,讓下一層例外處理區塊來捕捉;另一個狀況是重新包裝例外,將捕捉到的例外以您自己定義的例外物件加以包裝丟出。若想要自行丟出例外,您可以使用 “throw” 關鍵字,並生成指定的例外物件,例如:

  1. throw new ArithmeticException();

舉個例子來說明,在 Java 的除法中,允許浮點數運算時除數為 0,所得到的結果是 Infinity,也就是無窮數,如果您想要自行檢驗除零錯誤,可以自行丟出 ArithmeticException 例外,這個例外是在整數除法且為除數 0 時所引發的例外,您可以讓浮點數運算除數為 0 時也丟出這個例外。

範例 10.3 ThrowDemo.java

  1. public class ThrowDemo {
  2. public static void main(String[] args) {
  3. try {
  4. double data = 100 / 0.0;
  5. System.out.println("浮點數除以零:" + data);
  6. if(String.valueOf(data).equals("Infinity"))
  7. throw new ArithmeticException("除零例外");
  8. }
  9. catch(ArithmeticException e) {
  10. System.out.println(e);
  11. }
  12. }
  13. }

在檢驗運算結果為 Infinity 時,您自行建立 ArithmeticException 實例並使用 “throw” 丟出,產生實例的同時您可以指定訊息,執行結果如下:

  1. 浮點數除以零:Infinity
  2. java.lang.ArithmeticException: 除零例外

在巢狀的 try…catch 結構時,必須注意該例外是由何者引發並由何者捕捉,例如:

範例 10.4 CatchWho.java

  1. public class CatchWho {
  2. public static void main(String[] args) {
  3. try {
  4. try {
  5. throw new ArrayIndexOutOfBoundsException();
  6. }
  7. catch(ArrayIndexOutOfBoundsException e) {
  8. System.out.println(
  9. "ArrayIndexOutOfBoundsException" +
  10. "/內層try-catch");
  11. }
  12. throw new ArithmeticException();
  13. }
  14. catch(ArithmeticException e) {
  15. System.out.println("發生ArithmeticException");
  16. }
  17. catch(ArrayIndexOutOfBoundsException e) {
  18. System.out.println(
  19. "ArrayIndexOutOfBoundsException" +
  20. "/外層try-catch");
  21. }
  22. }
  23. }

執行結果:

  1. ArrayIndexOutOfBoundsException/內層try-catch
  2. 發生ArithmeticException

在範例 10.4 中,丟出的 ArrayIndexOutOfBoundsException 由內層的 “catch” 先捕捉到,由於內層已經捕捉了例外,所以外層的 “catch” 並不會捕捉到 ArrayIndexOutOfBoundsException 例外,如果內層的 “catch” 並沒有捕捉到這個例外,則外層的 “catch” 就有機會捕捉這個例外,例如範例 10.5 中 ArrayIndexOutOfBoundsException 就會被外層的 “catch” 捕捉到。

範例 10.5 CatchWho2.java

  1. public class CatchWho2 {
  2. public static void main(String[] args) {
  3. try {
  4. try {
  5. throw new ArrayIndexOutOfBoundsException();
  6. }
  7. catch(ArithmeticException e) {
  8. System.out.println(
  9. "ArrayIndexOutOfBoundsException" +
  10. "/內層try-catch");
  11. }
  12. throw new ArithmeticException();
  13. }
  14. catch(ArithmeticException e) {
  15. System.out.println("發生ArithmeticException");
  16. }
  17. catch(ArrayIndexOutOfBoundsException e) {
  18. System.out.println(
  19. "ArrayIndexOutOfBoundsException" +
  20. "/外層try-catch");
  21. }
  22. }
  23. }

執行結果:

  1. ArrayIndexOutOfBoundsException/外層try-catch

如果您在方法中會有例外的發生,而您並不想在方法中直接處理,而想要由呼叫方法的呼叫者來處理,則您可以使用 “throws” 關鍵字來宣告這個方法將會丟出例外,例如 java.ioBufferedReader 的 readLine() 方法就聲明會丟出 java.io.IOException。使用 “throws” 聲明丟出例外的時機,通常是工具類別的某個工具方法,因為作為被呼叫的工具,本身並不需要將處理例外的方式給定義下來,所以在方法上使用”throws”聲明會丟出例外,由呼叫者自行決定如何處理例外是比較合適的,您可以如下使用 “throws” 來丟出例外:

  1. private void someMethod(int[] arr) throws
  2. ArrayIndexOutOfBoundsException,
  3. ArithmeticException {
  4. // 實作
  5. }

注意方法上若會丟出多種可能的例外時,中間是使用逗點分隔;當方法上使用 “throws” 宣告丟出例外時,意味著呼叫該方法的呼叫者必須處理這些例外,範例 10.6 是 “throws” 的簡單示範。

範例 10.6 ThrowsDemo.java

  1. public class ThrowsDemo {
  2. public static void main(String[] args) {
  3. try {
  4. throwsTest();
  5. }
  6. catch(ArithmeticException e) {
  7. System.out.println("捕捉例外");
  8. }
  9. }
  10. private static void throwsTest()
  11. throws ArithmeticException {
  12. System.out.println("這只是一個測試");
  13. // 程式處理過程假設發生例外
  14. throw new ArithmeticException();
  15. }
  16. }

執行結果:

  1. 這只是一個測試
  2. 捕捉例外

簡單的說,您要不就在方法中直接處理例外,要不就在方法上宣告該方法會丟回例外,由呼叫它的呼叫者來處理例外。

良葛格的話匣子 您也可以在定義介面(interface)時於方法上聲明”throws”某些類型的例外,然而要小心使用,因為若您在這些方法中發生了某些不是方法聲明的例外,您就無法將之”throw”,只能自行撰寫一些try..catch來暗自處理掉,或者是重新包裝例外為”throws”上所聲明的例外,或者是將該例外包裝為RuntimeException然後再丟出。

10.4 例外的繼承架構

Java 所處理的例外主要可分為兩大類:一種是嚴重的錯誤,例如硬體錯誤或記憶體不足等問題,與此相關的類別是位於 java.lang 下的 Error 類別及其子類別,對於這類的錯誤通常程式是無力自行回復;另一種是非嚴重的錯誤,代表可以處理的狀況,例如使用者輸入了不合格式的資料,這種錯誤程式有機會回復至正常運作狀況,與這類錯誤相關的類別是位於 java.lang 下的 Exception 類別及其子類別。

Error 類別與 Exception 類別都繼承自 Throwable 類別,Throwable 類別擁有幾個取得相關例外訊息的方法。

  • getLocalizedMessage()

    取得例外物件的區域化訊息描述

  • getMessage()

    取得例外物件的訊息描述

  • printStackTrace()

    顯示例外的堆疊訊息,這個方法在追蹤例外發生的根源時相當的有用,簡單的說若 A 方法中呼叫了 B 方法,而 B 方法中呼叫了 C 方法,C 方法產生了例外,則在處理這個例外時呼叫 printStackTrace() 可以得知整個方法呼叫的過程,由此得知例外是如何被層層丟出的。

除了使用這些方法之外,您也可以簡單的利用例外物件 toString() 方法取得例外的簡單訊息描述。

您所接觸的例外通常都是衍生自 Exception 類別,其中是有些「受檢例外」(Checked exception),例如 ClassNotFoundException(嘗試載入類別時失敗所引發,例如類別檔案不存在)、InterruptedException(執行緒非執行中而嘗試中斷所引發的例外)等,而有些是「執行時期例外」(Runtime exception),也稱「非受檢例外」(Unckecked exception),例如 ArithmeticException、ArrayIndexOutOfBoundsException 等。以下列出一些重要的例外繼承架構:

  1. Throwable
  2.   Error(嚴重的系統錯誤)
  3.     LinkageError
  4.     ThreadDeath
  5.     VirtualMachineError
  6.     ....
  7.   Exception
  8.     ClassNotFoundException
  9.     CloneNotSupportedException
  10.     IllegalAccessException
  11.     ....
  12.     RuntimeException(執行時期例外)
  13.       ArithmeticException
  14.       ArrayStoreException
  15.       ClassCastException
  16.       ....

Exception 下非 RuntimeException 衍生之例外類別如果有引發的可能,則您一定要在程式中明確的指定處理才可以通過編譯,因為這些例外是可預期的,編譯器會要求您明確處理,所以才稱之為「受檢例外」(Checked exception),例如當您使用到 BufferedReader 的 readLine() 時,由於有可能引發 IOException 這個受檢例外,您要不就在 try…catch 中處理,要不就在方法上使用 “throws” 表示由呼叫它的呼叫者來處理。

屬於 RuntimeException 衍生出來的類別是「執行時期例外」(Runtime exception),是在執行時期會發生的例外,這個例外不預期它一定會發生,端看程式邏輯寫的如何,因而不需要特別使用 try…catch 或是在方法上使用 “throws” 宣告也可以通過編譯,所以才稱之為「非受檢例外」(Unchecked exception),例如您在使用陣列時,並不一定要處理 ArrayIndexOutOfBoundsException 例外,因為只要程式邏輯寫的正確,這個例外就不會發生。

瞭解例外處理的繼承架構是必要的,例如在捕捉例外物件時,如果父類別例外物件在子類別例外物件之前被捕捉,則 “catch” 子類別例外物件的區塊將永遠不會被執行,事實上編譯器也會幫您檢查這個錯誤,例如:

範例 10.7 ExceptionDemo.java

  1. import java.io.*;
  2. public class ExceptionDemo {
  3. public static void main(String[] args) {
  4. try {
  5. throw new ArithmeticException("例外測試");
  6. }
  7. catch(Exception e) {
  8. System.out.println(e.toString());
  9. }
  10. catch(ArithmeticException e) {
  11. System.out.println(e.toString());
  12. }
  13. }
  14. }

因為 Exception 是 ArithmeticException 的父類別,所以 ArithmeticException 的實例會先被 Exception 的 “catch” 區塊捕捉到,範例 10.7 在編譯時將會產生以下的錯誤訊息:

  1. ExceptionDemo.java:11: exception java.lang.ArithmeticException has already been caught
  2. catch(ArithmeticException e) {
  3. ^
  4. 1 error

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

範例 10.8 ExceptionDemo2.java

  1. import java.io.*;
  2. public class ExceptionDemo2 {
  3. public static void main(String[] args) {
  4. try {
  5. throw new ArithmeticException("例外測試");
  6. }
  7. catch(ArithmeticException e) {
  8. System.out.println(e.toString());
  9. }
  10. catch(Exception e) {
  11. System.out.println(e.toString());
  12. }
  13. }
  14. }

在撰寫程式時,您也可以如範例 10.8 將 Exception 例外物件的捕捉撰寫在最後,以便捕捉到所有您所尚未考慮到的例外,在除蟲(Debug)階段時是很有用的。

如果您要自訂自已的例外類別,您可以繼承 Exception 類別而不是 Error 類別,Error 是屬於嚴重的系統錯誤,程式通常無力從這類的錯誤中回復,所以您不用去處理它,事實上在 Java 程式中也不希望您處理 Error 類別的例外。

如果使用繼承時,父類別的某個方法上宣告了 throws 某些例外,而在子類別中重新定義該方法時,您可以:

  • 不處理例外(重新定義時不設定 throws)
  • 可僅 throws 父類別中被重新定義的方法上之某些例外
  • 可 throws 被重新定義的方法上之例外之子類別

但是您不可以:

  • throws 父類別方法中未定義的其它例外
  • throws 被重新定義的方法上之例外之父類別

10.5 斷言(Assertion)

例外是程式中非預期的錯誤,例外處理是在這些錯誤發生時所採取的措施。
有些時候,您預期程式中應該會處於何種狀態,例如某些情況下某個值必然是多少,這稱之為一種斷言。斷言有兩種結果:成立或不成立。當預期結果與實際執行相同時,斷言成立,否則斷言不成立。

Java 在 JDK 1.4 之後提供斷言陳述,有兩種使用的語法:

  1. assert boolean_expression;
  2. assert boolean_expression : detail_expression;

boolean_expression 如果為 true,則什麼事都不會發生,如果為 false,則會發生 java.lang.AssertionError,此時若採取的是第二個語法,則會將 detail_expression 的結果顯示出來,如果當中是個物件,則呼叫它的 toString() 顯示文字描述結果。

一個使用斷言的時機是內部不變量(Internal invarant)的判斷,例如在某個時間點上,或某個狀況發生時,您判斷某個變數必然要是某個值,舉個例子來說:

範例 10.9 AssertionDemo.java

  1. public class AssertionDemo {
  2. public static void main(String[] args) {
  3. if(args.length > 0) {
  4. System.out.println(args[0]);
  5. }
  6. else {
  7. assert args.length == 0;
  8. System.out.println("沒有輸入引數");
  9. }
  10. }
  11. }

在正常的預期中,陣列長度是不會小於 0 的,所以一但執行至 else 區塊,陣列長度必然只有一個可能,就是等於 0,您斷言 args.length==0 結果 必然成立,else 之中的程式碼也只有在斷言成立的狀況下才能執行,如果不成立,表示程式運行存在錯誤,else 區塊不應被執行,您要停下來檢查程式的錯誤,事實上斷言主要的目的通常是在開發時期測試使用。

斷言功能是在 JDK 1.4 之後提供的,由於斷言使用 assert 作為關鍵字,為了避免您以前在 JDK 1.3 或更早之前版本的程式使用了 assert 作為變數,而導致的名稱衝突問題,預設上執行時是不啟動斷言檢查的,如果您要在執行時啟動斷言檢查,可以使用 -enableassertions 或是 -ea 引數,例如:

  1. java ea AssertionDemo

另一個使用斷言的時機為控制流程不變量(Control flow invariant)的判斷,例如在使用 switch 時:

  1. switch(var) {
  2. case Constants.Con1:
  3. ...
  4. break;
  5. case Constants.Con2:
  6. ...
  7. break;
  8. case Constants.Con3:
  9. ...
  10. break;
  11. default:
  12. assert false : "非定義的常數";
  13. }

假設您已經在 switch 中列出了所有的常數,即 var 不該出現 Constants.Con1、Constants.Con2、 Constants.Con3 以外的常數,則如果發生 default 被執行的情況,表示程式的狀態與預期不符,此時由於 assert false,所以必然斷言失敗。

簡單的說,斷言是判定程式中的某個執行點必然是某個狀態,所以它不能當作像 if 之類的判斷式來使用,Assertion 不應被當做程式執行流程的一部份。

10.6 接下來的主題

每一個章節的內容由淺至深,初學者該掌握的深度要到哪呢?在這個章節中,對於初學者我建議至少掌握以下幾點內容:

  • 會使用 try…catch…finally 語法
  • 會使用 throw、throws 語法
  • 瞭解受檢例外(Checked exception)與非受檢例外(Unchecked exception)的差別
  • 知道例外的繼承架構與 try…catch 的捕捉順序

下一個章節要來看看 J2SE 5.0 新增的功能:列舉型態(Enumerated Types)。列舉型態可以解決常數設定的問題,並提供更多編譯時期的檢查,比 J2SE 1.4 或更早版本只使用變數宣告常數更為實用。