X Tutup
# 第 10 章 例外處理(Exception Handling) 程式中的臭蟲(Bug)總是無所不在,即使您認為程式中應該沒有錯誤了,臭蟲總會在某個時候鑽出來困擾您,面對程式中各種層出不窮的錯誤,Java 提供了「例外處理」機制來協助開發人員避開可能的錯誤,「例外」(Exception)在 Java 中代表一個錯誤的實例,編譯器會幫您檢查一些可能產生例外(Checked exception)的狀況,並要求您注意並處理,而對於「執行時期例外」(Runtime exception),您也可以嘗試捕捉例外並將程式回復至正常狀況。 這個章節會介紹 Java 的例外處理架構以及斷言(Assertion),雖然就章節內容而言,這章是個相對簡短的章節,但例外處理卻可能是您撰寫程式時最常面對的議題,因為程式中的錯誤無所不在,編譯器會經常提醒您必須作例外處理,而您也經常必須處理執行時期例外。 -------- ## 10.1 例外處理入門 回憶一下 6.2.1 中介紹過的「命令列引數」(Command line argument),在還沒有學會使用Java的例外處理之前,為了確定使用者是否有提供引數,您可能要先檢查引數的陣列長度是否為 0,以免程式存取超過陣列長度而發生錯誤: if(args.length == 0) { // 使用者沒有指定引數,顯示引數功能畫面 } else { // 執行所指定引數的對應功能 } 利用條件判斷式來避免錯誤的發生,這樣的檢查方式在一些程式語言中經常出現,然而顯然的,處理錯誤的邏輯與處理業務的邏輯混在一起,如果更多的錯誤狀況必須檢查的話,程式會更難以閱讀,且由於使用了一些判斷式,即使發生機率低的錯誤,也都必須一視同仁的進行判斷檢查,這會使得程式的執行效能受到一定程度的影響。 Java 的例外處理機制可以協助您避開或是處理程式可能發生的錯誤,「例外」(Exception)在 Java 中代表一個錯誤的實體物件,在特定錯誤發生時會丟出特定的例外物件,有些預期中可能發生的例外,編譯器會提醒您先行處理,對於一些程式運行時所發生的執行時期例外,您有機會捕捉這些例外,並嘗試將程式回復至正常運作狀態。 在 Java 中如果想嘗試捕捉例外,可以使用 "try"、"catch"、"finally" 三個關鍵字組合的語法來達到,其語法基本結構如下: try { // 陳述句 } catch(例外型態 名稱) { // 例外處理 } finally { // 一定會處理的區塊 } 一個 "try" 語法所包括的區塊,必須有對應的 "catch" 區塊或是 "finally" 區塊,"try" 區塊可以搭配多個 "catch" 區塊,如果有設定 "catch" 區塊,則 "finally" 區塊可有可無,如果沒有定義 "catch" 區塊,則一定要有 "finally" 區塊。 使用實例來說明,您可以使用 try...catch 語法來取代命令列引數的陣列長度檢查動作,如範例 10.1 所示。 #### **範例 10.1 CheckArgsDemo.java** ```java public class CheckArgsDemo { public static void main(String[] args) { try { System.out.printf("執行 %s 功能%n", args[0]); } catch(ArrayIndexOutOfBoundsException e) { System.out.println("沒有指定引數"); e.printStackTrace(); } } } ``` 如果在執行程式時沒有指定引數,那麼args陣列的長度是 0,程式中嘗試從 args[0] 取得引數時就會發生錯誤,錯誤的實例是 ArrayIndexOutOfBoundsException,這個實例會在被對應的 "catch" 所捕捉,在範例 10.1 中被捕捉的例外指定給 e 名稱來參考,例外被捕捉後會執行對應的 "catch" 區塊,在範例中是顯示提示訊息,並使用 printStackTrace() 會顯示完整的例外訊息,沒有指定引數時的執行結果如下: >java CheckArgsDemo 沒有指定引數 java.lang.ArrayIndexOutOfBoundsException: 0 at CheckArgsDemo.main(CheckArgsDemo.java:4) 範例 10.1 中並沒有使用條件判斷式來檢查陣列長度,也就是沒有使用 if 陳述句,例外處理只有在錯誤真正發生,也就是丟出例外時才處理,所以與使用 if 判斷式每次都要進行檢查動作相比,效率上會好一些,要注意的是,例外處理最好只用於錯誤處理,而不應是用於程式業務邏輯的一部份,因為例外的產生要消耗資源,例如以下應用例外處理的方式就不適當: while(true) { try { System.out.println(args[i]); i++; } catch(ArrayIndexOutOfBoundsException e) { break; } } 循序取出陣列值時,最後一定會到達陣列的邊界,檢查邊界是必要的動作,是程式業務邏輯的一部份,而不是錯誤處理邏輯的一部份,您該使用的是 for 迴圈而不是依賴例外處理,例如下面的方式才是正確的: for(int i = 0; i < args.length; i++) { System.out.println(args[i]); } ## 10.2 受檢例外(Checked Exception)、執行時期例外(Runtime Exception) 在某些情況下例外的發生是可預期的,例如使用輸入輸出功能時,可能會由於硬體環境問題,而使得程式無法正常從硬體取得輸入或進行輸出,這種錯誤是可預期發生的,像這類的例外稱之為「受檢例外」(Checked Exception),對於受檢例外編譯器會要求您進行例外處理,例如在使用 java.io.BufferedReader 的 readLine() 方法取得使用者輸入時,編譯器會要求您於程式碼中明確告知如何處理 java.io.IOException,範例 10.2 是個簡單的示範。 #### **範例 10.2 CheckedExceptionDemo.java** ```java import java.io.*; public class CheckedExceptionDemo { public static void main(String[] args) { try { BufferedReader buf = new BufferedReader( new InputStreamReader(System.in)); System.out.print("請輸入整數: "); int input = Integer.parseInt(buf.readLine()); System.out.println("input x 10 = " + (input*10)); } catch(IOException e) { // checked exception System.out.println("I/O錯誤"); } catch(NumberFormatException e) { // runtime exception System.out.println("輸入必須為整數"); } } } ``` IOException 是受檢例外,是可預期會發生的例外,編譯器要求您必須處理,如果您不在程式中處理的話,例如將 IOException 的 "catch" 區塊拿掉,編譯器會回報錯誤訊息: 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" 關鍵字,並生成指定的例外物件,例如: throw new ArithmeticException(); 舉個例子來說明,在 Java 的除法中,允許浮點數運算時除數為 0,所得到的結果是 Infinity,也就是無窮數,如果您想要自行檢驗除零錯誤,可以自行丟出 ArithmeticException 例外,這個例外是在整數除法且為除數 0 時所引發的例外,您可以讓浮點數運算除數為 0 時也丟出這個例外。 #### **範例 10.3 ThrowDemo.java** ```java public class ThrowDemo { public static void main(String[] args) { try { double data = 100 / 0.0; System.out.println("浮點數除以零:" + data); if(String.valueOf(data).equals("Infinity")) throw new ArithmeticException("除零例外"); } catch(ArithmeticException e) { System.out.println(e); } } } ``` 在檢驗運算結果為 Infinity 時,您自行建立 ArithmeticException 實例並使用 "throw" 丟出,產生實例的同時您可以指定訊息,執行結果如下: 浮點數除以零:Infinity java.lang.ArithmeticException: 除零例外 在巢狀的 try...catch 結構時,必須注意該例外是由何者引發並由何者捕捉,例如: #### **範例 10.4 CatchWho.java** ```java public class CatchWho { public static void main(String[] args) { try { try { throw new ArrayIndexOutOfBoundsException(); } catch(ArrayIndexOutOfBoundsException e) { System.out.println( "ArrayIndexOutOfBoundsException" + "/內層try-catch"); } throw new ArithmeticException(); } catch(ArithmeticException e) { System.out.println("發生ArithmeticException"); } catch(ArrayIndexOutOfBoundsException e) { System.out.println( "ArrayIndexOutOfBoundsException" + "/外層try-catch"); } } } ``` 執行結果: ArrayIndexOutOfBoundsException/內層try-catch 發生ArithmeticException 在範例 10.4 中,丟出的 ArrayIndexOutOfBoundsException 由內層的 "catch" 先捕捉到,由於內層已經捕捉了例外,所以外層的 "catch" 並不會捕捉到 ArrayIndexOutOfBoundsException 例外,如果內層的 "catch" 並沒有捕捉到這個例外,則外層的 "catch" 就有機會捕捉這個例外,例如範例 10.5 中 ArrayIndexOutOfBoundsException 就會被外層的 "catch" 捕捉到。 #### **範例 10.5 CatchWho2.java** ```java public class CatchWho2 { public static void main(String[] args) { try { try { throw new ArrayIndexOutOfBoundsException(); } catch(ArithmeticException e) { System.out.println( "ArrayIndexOutOfBoundsException" + "/內層try-catch"); } throw new ArithmeticException(); } catch(ArithmeticException e) { System.out.println("發生ArithmeticException"); } catch(ArrayIndexOutOfBoundsException e) { System.out.println( "ArrayIndexOutOfBoundsException" + "/外層try-catch"); } } } ``` 執行結果: ArrayIndexOutOfBoundsException/外層try-catch 如果您在方法中會有例外的發生,而您並不想在方法中直接處理,而想要由呼叫方法的呼叫者來處理,則您可以使用 "throws" 關鍵字來宣告這個方法將會丟出例外,例如 java.ioBufferedReader 的 readLine() 方法就聲明會丟出 java.io.IOException。使用 "throws" 聲明丟出例外的時機,通常是工具類別的某個工具方法,因為作為被呼叫的工具,本身並不需要將處理例外的方式給定義下來,所以在方法上使用"throws"聲明會丟出例外,由呼叫者自行決定如何處理例外是比較合適的,您可以如下使用 "throws" 來丟出例外: private void someMethod(int[] arr) throws ArrayIndexOutOfBoundsException, ArithmeticException { // 實作 } 注意方法上若會丟出多種可能的例外時,中間是使用逗點分隔;當方法上使用 "throws" 宣告丟出例外時,意味著呼叫該方法的呼叫者必須處理這些例外,範例 10.6 是 "throws" 的簡單示範。 #### **範例 10.6 ThrowsDemo.java** ```java public class ThrowsDemo { public static void main(String[] args) { try { throwsTest(); } catch(ArithmeticException e) { System.out.println("捕捉例外"); } } private static void throwsTest() throws ArithmeticException { System.out.println("這只是一個測試"); // 程式處理過程假設發生例外 throw new ArithmeticException(); } } ``` 執行結果: 這只是一個測試 捕捉例外 簡單的說,您要不就在方法中直接處理例外,要不就在方法上宣告該方法會丟回例外,由呼叫它的呼叫者來處理例外。 > **良葛格的話匣子** 您也可以在定義介面(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 等。以下列出一些重要的例外繼承架構: Throwable   Error(嚴重的系統錯誤)     LinkageError     ThreadDeath     VirtualMachineError     ....   Exception     ClassNotFoundException     CloneNotSupportedException     IllegalAccessException     ....     RuntimeException(執行時期例外)       ArithmeticException       ArrayStoreException       ClassCastException       .... Exception 下非 RuntimeException 衍生之例外類別如果有引發的可能,則您一定要在程式中明確的指定處理才可以通過編譯,因為這些例外是可預期的,編譯器會要求您明確處理,所以才稱之為「受檢例外」(Checked exception),例如當您使用到 BufferedReader 的 readLine() 時,由於有可能引發 IOException 這個受檢例外,您要不就在 try...catch 中處理,要不就在方法上使用 "throws" 表示由呼叫它的呼叫者來處理。 屬於 RuntimeException 衍生出來的類別是「執行時期例外」(Runtime exception),是在執行時期會發生的例外,這個例外不預期它一定會發生,端看程式邏輯寫的如何,因而不需要特別使用 try...catch 或是在方法上使用 "throws" 宣告也可以通過編譯,所以才稱之為「非受檢例外」(Unchecked exception),例如您在使用陣列時,並不一定要處理 ArrayIndexOutOfBoundsException 例外,因為只要程式邏輯寫的正確,這個例外就不會發生。 瞭解例外處理的繼承架構是必要的,例如在捕捉例外物件時,如果父類別例外物件在子類別例外物件之前被捕捉,則 "catch" 子類別例外物件的區塊將永遠不會被執行,事實上編譯器也會幫您檢查這個錯誤,例如: #### **範例 10.7 ExceptionDemo.java** ```java import java.io.*; public class ExceptionDemo { public static void main(String[] args) { try { throw new ArithmeticException("例外測試"); } catch(Exception e) { System.out.println(e.toString()); } catch(ArithmeticException e) { System.out.println(e.toString()); } } } ``` 因為 Exception 是 ArithmeticException 的父類別,所以 ArithmeticException 的實例會先被 Exception 的 "catch" 區塊捕捉到,範例 10.7 在編譯時將會產生以下的錯誤訊息: ExceptionDemo.java:11: exception java.lang.ArithmeticException has already been caught catch(ArithmeticException e) { ^ 1 error 要完成這個程式的編譯,您必須更改例外物件捕捉的順序,如範例 10.8 所示。 #### **範例 10.8 ExceptionDemo2.java** ```java import java.io.*; public class ExceptionDemo2 { public static void main(String[] args) { try { throw new ArithmeticException("例外測試"); } catch(ArithmeticException e) { System.out.println(e.toString()); } catch(Exception e) { System.out.println(e.toString()); } } } ``` 在撰寫程式時,您也可以如範例 10.8 將 Exception 例外物件的捕捉撰寫在最後,以便捕捉到所有您所尚未考慮到的例外,在除蟲(Debug)階段時是很有用的。 如果您要自訂自已的例外類別,您可以繼承 Exception 類別而不是 Error 類別,Error 是屬於嚴重的系統錯誤,程式通常無力從這類的錯誤中回復,所以您不用去處理它,事實上在 Java 程式中也不希望您處理 Error 類別的例外。 如果使用繼承時,父類別的某個方法上宣告了 throws 某些例外,而在子類別中重新定義該方法時,您可以: - 不處理例外(重新定義時不設定 throws) - 可僅 throws 父類別中被重新定義的方法上之某些例外 - 可 throws 被重新定義的方法上之例外之子類別 但是您不可以: - throws 父類別方法中未定義的其它例外 - throws 被重新定義的方法上之例外之父類別 ## 10.5 斷言(Assertion) 例外是程式中非預期的錯誤,例外處理是在這些錯誤發生時所採取的措施。 有些時候,您預期程式中應該會處於何種狀態,例如某些情況下某個值必然是多少,這稱之為一種斷言。斷言有兩種結果:成立或不成立。當預期結果與實際執行相同時,斷言成立,否則斷言不成立。 Java 在 JDK 1.4 之後提供斷言陳述,有兩種使用的語法: assert boolean_expression; assert boolean_expression : detail_expression; boolean_expression 如果為 true,則什麼事都不會發生,如果為 false,則會發生 java.lang.AssertionError,此時若採取的是第二個語法,則會將 detail_expression 的結果顯示出來,如果當中是個物件,則呼叫它的 toString() 顯示文字描述結果。 一個使用斷言的時機是內部不變量(Internal invarant)的判斷,例如在某個時間點上,或某個狀況發生時,您判斷某個變數必然要是某個值,舉個例子來說: #### **範例 10.9 AssertionDemo.java** ```java public class AssertionDemo { public static void main(String[] args) { if(args.length > 0) { System.out.println(args[0]); } else { assert args.length == 0; System.out.println("沒有輸入引數"); } } } ``` 在正常的預期中,陣列長度是不會小於 0 的,所以一但執行至 else 區塊,陣列長度必然只有一個可能,就是等於 0,您斷言 args.length==0 結果 必然成立,else 之中的程式碼也只有在斷言成立的狀況下才能執行,如果不成立,表示程式運行存在錯誤,else 區塊不應被執行,您要停下來檢查程式的錯誤,事實上斷言主要的目的通常是在開發時期測試使用。 斷言功能是在 JDK 1.4 之後提供的,由於斷言使用 assert 作為關鍵字,為了避免您以前在 JDK 1.3 或更早之前版本的程式使用了 assert 作為變數,而導致的名稱衝突問題,預設上執行時是不啟動斷言檢查的,如果您要在執行時啟動斷言檢查,可以使用 -enableassertions 或是 -ea 引數,例如: java –ea AssertionDemo 另一個使用斷言的時機為控制流程不變量(Control flow invariant)的判斷,例如在使用 switch 時: switch(var) { case Constants.Con1: ... break; case Constants.Con2: ... break; case Constants.Con3: ... break; default: assert false : "非定義的常數"; } 假設您已經在 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 或更早版本只使用變數宣告常數更為實用。
X Tutup