Java異常使用原則

JavaDog發表於2019-03-06

本文關注何時使用異常,並舉例演示異常的恰當使用。此外,本文還提供一些異常設計的基本原則。

異常的好處

異常帶來諸多好處。首先,它將錯誤處理程式碼從正常程式碼(normal code)中分離出來。你可以將那些執行概率為99.9%的程式碼封裝在一個try塊內,然後將異常處理程式碼----這些程式碼是不經常執行的----置於catch子句中。這種方式的好處是,正常程式碼因此而更簡潔。
如果你不知道如何處理某個方法中的一個特定錯誤,那麼你可以在方法中丟擲異常,將處理權交給其他人。如果你丟擲一個檢查異常(checked exception),那麼Java編譯器將強制客戶程式設計師(cilent programmer)處理這個潛在異常,或者捕捉之,或者在方法的throws子句中宣告之。Java編譯器確保檢查異常被處理,這使得Java程式更為健壯。

何時丟擲異常

  異常應於何時丟擲?答案歸於一條原則:
  如果方法遇到一個不知道如何處理的意外情況(abnormal condition),那麼它應該丟擲異常。
  不幸的是,雖然這條原則易於記憶和引用,但是它並不十分清晰。實際上,它引出了另一個的問題:什麼是意外情況?
  這是一個價值6.4萬美元的問題。是否視某特殊事件為“意外情況”是一個主觀決定。其依據通常並不明顯。正因為如此,它才價值不菲。
  一個更有用的經驗法則是:
  在有充足理由將某情況視為該方法的典型功能(typical functioning )部分時,避免使用異常。
  因此,意外情況就是指方法的“正常功能”(normal functioning)之外的情況。請允許我通過幾個例子來說明問題。

幾個例子

  第一個示例使用java.io包的FileInputStream類和DataInputStream類。這是使用FileInputStream類將檔案內容傳送到標準輸出(standard output)的程式碼:

public class ExceptionExample {

    public static void main(String[] args)
        throws IOException {

        if (args.length == 0) {
            System.out.println("Must give filename as first arg.");
            return;
        }

        FileInputStream in;
        try {
            in = new FileInputStream(args[0]);
        }
        catch (FileNotFoundException e) {
            System.out.println("Can't find file: " + args[0]);
            return;
        }

        int ch;
        while ((ch = in.read()) != -1) {
            System.out.print((char) ch);
        }
        System.out.println();

        in.close();
    }
}複製程式碼

  在本例中,FileInputStream類的read方法報告了“已到達檔案末尾”的情況,但是,它並沒有採用丟擲異常的方式,而是返回了一個特殊值:-1。在這個方法中,到達檔案末尾被視為方法的“正常”部分,這不是意外情況。讀取位元組流的通常方式是,繼續往下讀直到達位元組流末尾。

  與此不同的是,DataInputStream類採取了另一種方式來報告檔案末尾:

public class ExceptionExample {

    public static void main(String[] args)
        throws IOException {

        if (args.length == 0) {
            System.out.println("Must give filename as first arg.");
            return;
        }

        FileInputStream fin;
        try {
            fin = new FileInputStream(args[0]);
        }
        catch (FileNotFoundException e) {
            System.out.println("Can't find file: " + args[0]);
            return;
        }

        DataInputStream din = new DataInputStream(fin);
        try {
            int i;
            for (;;) {
                i = din.readInt();
                System.out.println(i);
            }
        }
        catch (EOFException e) {
        }

        fin.close();
    }
}複製程式碼

  DataInputStream類的readInt()方法每次讀取四個位元組,然後將其解釋為一個int型資料。當讀到檔案末尾時,readInt()方法將丟擲EOFException。
  這個方法丟擲異常的原因有二。首先,readInt()無法返回一個特殊值來指示已經到達檔案末尾,因為所有可能的返回值都是合法的整型資料。(例如,它不能採用-1這個特殊值來指示檔案末尾,因為-1可能就是流中的正常資料。)其次,如果readInt()在檔案末尾處只讀到一個、兩個、或者三個位元組,那麼,這就可以視為“意外情況”了。本來這個方法是要讀四個位元組的,但只有一到三個位元組可讀。由於該異常是使用這個類時的不可分割的部分,它被設計為檢查型異常(Exception類的子類)。客戶程式設計師被強制要求處理該異常。
  指示“已到達末尾”情況的第三種方式在StringTokenizer類和Stack類中得到演示:

public class ExceptionExample {

    public static void main(String[] args)
        throws IOException {

        if (args.length == 0) {
            System.out.println("Must give filename as first arg.");
            return;
        }

        FileInputStream in = null;
        try {
            in = new FileInputStream(args[0]);
        }
        catch (FileNotFoundException e) {
            System.out.println("Can't find file: " + args[0]);
            return;
        }

        // Read file into a StringBuffer
        StringBuffer buf = new StringBuffer();
        try {
            int ch;
            while ((ch = in.read()) != -1) {
                buf.append((char) ch);
            }
        }
        finally {
            in.close();
        }

        // Separate StringBuffer into tokens and
        // push each token into a Stack
        StringTokenizer tok = new StringTokenizer(buf.toString());
        Stack stack = new Stack();
        while (tok.hasMoreTokens()) {
            stack.push(tok.nextToken());
        }

        // Print out tokens in reverse order.
        while (!stack.empty()) {
            System.out.println((String) stack.pop());
        }
    }
}複製程式碼

  上面的程式逐位元組讀取檔案,將位元組資料轉換為字元資料,然後將字元資料放到StringBuffer中。它使用StringTokenizer類提取以空白字元為分隔符的token(這裡是一個字串),每次提取一個並壓入Stack中。最後,所有token都被從Stack中彈出並列印,每行列印一個。因為Stack類實現的是後進先出(LIFO)棧,所以,列印出來的資料順序和檔案中的資料順序剛好相反。
  StringTokenizer類和Stack類都必須能夠指示“已到達末尾”情況。StringTokenizer的構造方法接納源字串。每一次呼叫nextToken()方法都將返回一個字串,它是源字串的下一個token。源字串的所有token都必然會被消耗掉,StringTokenizer類必須通過某種方式指示已經沒有更多的token供返回了。這種情況下,本來是可以用一個特殊的值null來指示沒有更多token的。但是,此類的設計者採用了另一個辦法。他提供了一個額外的方法hasMoreTokens(),該方法返回一個布林值來指示是否已到達末尾。每次呼叫nextToken()方法之前,你必須先呼叫hasMoreTokens()。
  這種方法表明設計者並不認為到達token流的末尾是意外情況。相反,它是使用這個類的常規情況。然而,如果你在呼叫nextToken()之前不檢查hasMoreTokens(),那麼你最後會得到一個異常NoSuchElementException。雖然該異常在到達token流末尾時丟擲,但它卻是一個非檢查異常(RuntimeException的子類)。該異常的丟擲不是為了指示“已到達末尾”,而是指示一個軟體缺陷----你並沒有正確地使用該類。
  與此類似,Stack類有一個類似的方法empty(),這個方法返回一個布林值指示棧已經為空。每次呼叫pop()之前,你都必須先呼叫empty()方法。如果你忘了呼叫empty()方法,而直接在一個空棧上呼叫pop()方法,那麼,你將得到一個異常EmptyStackException。雖然該異常是棧已經為空的情況下丟擲的,但它也是一個非檢查異常。它的作用不是檢測空棧,而是指示客戶程式碼中的一個軟體缺陷(Stack類的不恰當使用)。

異常表示沒有遵守契約

  通過上面的例子,你應該已經初步瞭解到,何時應丟擲異常而不是使用其他方法進行通訊。若從另一個角度來看待異常,視之為“沒有遵守契約”,你可能對應當怎樣使用異常有更深層的理解。
  物件導向程式設計中經常討論的一個設計方法是契約設計,它指出方法是客戶(方法的呼叫者)和宣告方法的類之間的契約。這個契約包括客戶必須滿足的前置條件(precondition)和方法本身必須滿足的後置條件(postcondition)。
  前置條件
  String類的charAt(int index)方法是一個帶有前置條件的方法。這個方法規定客戶傳入的index引數的最小取值是0,最大取值是在該String物件上呼叫length()方法的結果減去1。也就是說,如果字串長度為5,那麼index引數的取值限於0、1、2、3、4。
  後置條件
  String類的charAt(int index)方法的後置條件要求返回值必須是該字串物件在index位置上的字元資料,而且該字串物件必須保持不變。
  如果客戶呼叫charAt()並傳入-1、和length()一樣大或者更大的值,那就認為客戶沒有遵守契約。這種情況下,charAt()方法是不能正確執行的,它將丟擲異常StringIndexOutOfBoundsException。該異常指出客戶程式中存在某種缺陷或String類使用不當。
  如果charAt()方法接收的輸入沒有問題(客戶遵守了契約),但是由於某種原因它無法返回指定的索引上的字元資料(沒有滿足後置條件),它將丟擲異常來指示這種情況。這種異常指出方法的實現中包含缺陷或者方法在獲得執行時資源上存在問題。
  因此,如果一個事件表示了“異常條件”或者“沒有遵守契約”,那麼,Java程式所要做的就是丟擲異常。

丟擲什麼?

  一旦你決定丟擲異常,你就要決定丟擲什麼異常。你可以丟擲Throwable或其子類的物件。你可以丟擲Java API中定義的、或者自定義的Throwable物件。那麼,如何決定?
  通常,你只需要丟擲異常,而非錯誤。Error是Throwable的子類,它用於指示災難性的錯誤,比如OutOfMemoryError,這個錯誤將由JVM報告。有時一個錯誤也可以被Java API丟擲,如java.awt.AWTError。然而,在你的程式碼中,你應該嚴格限制自己只丟擲異常(Exception的子類)。把錯誤的丟擲留給那些大牛人。

檢查型異常和非檢查型異常

  現在,主要問題就是丟擲檢查型異常還是非檢查型異常了。檢查型異常是Exception的子類(或者Exception類本身),但不包括RuntimeException和它的子類。非檢查型異常是RuntimeException和它的任何子類。Error類及其子類也是檢查型的,但是你應該僅著眼於異常,你所做的應該是決定丟擲RuntimeException的子類(非檢查異常)還是Exception的子類(檢查異常)。
  如果丟擲了檢查型異常(而沒有捕獲它),那麼你需要在方法的throws子句中宣告該異常。客戶程式設計師使用這個方法,他要麼在其方法內捕獲並處理這個異常,要麼還在throws子句中丟擲。檢查型異常強制客戶程式設計師對可能丟擲的異常採取措施。
  如果你丟擲的是非檢查型異常,那麼客戶程式設計師可以決定捕獲與否。然而,編譯器並不強制客戶程式設計師對非檢查型異常採取措施。事實上,他們甚至不知道可能這些異常。顯然,在非檢查型異常上客戶程式設計師會少費些腦筋。
  有一個簡單的原則是:
  如果希望客戶程式設計師有意識地採取措施,那麼丟擲檢查型異常。
  一般而言,表示類的誤用的異常應該是非檢查型異常。String類的chartAt()方法丟擲的StringIndexOutOfBoundsException就是一個非檢查型異常。String類的設計者並不打算強制客戶程式設計師每次呼叫charAt(int index)時都檢查index引數的合法性。
  另一方面,java.io.FileInputStream類的read()方法丟擲的是IOException,這是一個檢查異常。這個異常表明嘗試讀取檔案時出錯了。這並不意味著客戶程式設計師錯誤地使用了FileInputStream類,而是說這個方法無法履行它地職責,即從檔案中讀出下一個位元組。FileInputStream類地設計者認為這個意外情況很普遍,也很重要,因而強制客戶程式設計師處理之。
  這就是竅門所在。如果意外情況是方法無法履行職責,而你又認為它很普遍或很重要,客戶程式設計師必須採取措施,那麼丟擲檢查型異常。否則,丟擲非檢查型異常。

自定義異常類

  最後,你決定例項化一個異常類,然後丟擲這個異常類的例項。這裡沒有具體的規則。不要丟擲用一條字串資訊指出意外情況的Exception類,而是自定義一個異常類或者從已有異常類中選出一個合適的。那麼,客戶程式設計師就可以分別為不同的異常定義相應的catch語句,或者只捕獲一部分。
  你可能希望在異常物件中嵌入一些資訊,從而告訴catch子句該異常的更詳細資訊。但是,你並不僅僅依賴嵌入的資訊來區別不同的異常。例如,你並不希望客戶程式設計師查詢異常物件來決定問題發生在I/O上還是非法引數。
  注意,String.charAt(int index)接收一個非法輸入時,它丟擲的不是RuntimeException,甚至也不是IllegalArgumentException,而是StringIndexOutOfBoundsException。這個型別名指出問題來自字串索引,而且這個非法索引可以通過查詢這個異常物件而找出。

結論

  本文的要點是,異常就是意外情況,而不該用於報告那些可以作為方法的正常功能的情況。雖然使用異常可以分離常規程式碼和錯誤處理程式碼,從而提高程式碼的可讀性,但是,異常的不恰當使用會降低程式碼的可讀性。

  以下是本文提出的異常設計原則:

  • 如果方法遭遇了一個無法處理的意外情況,那麼丟擲一個異常。
  • 避免使用異常來指出可以視為方法的常用功能的情況。
  • 如果發現客戶違反了契約(例如,傳入非法輸入引數),那麼丟擲非檢查型異常。
  • 如果方法無法履型契約,那麼丟擲檢查型異常,也可以丟擲非檢查型異常。
  • 如果你認為客戶程式設計師需要有意識地採取措施,那麼丟擲檢查型異常。

Java異常使用原則


相關文章