第 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
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
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
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
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
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
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
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
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
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 或更早版本只使用變數宣告常數更為實用。