【Guava 教學】(5)程式 90% 比率在管理與處理錯誤



這篇是要來談談 Guava 的 Throwables,不過正如標題寫的,程式 90% 的比率在管理與處理錯誤,既然如此,那我們還是從頭來認識一些錯誤處理的基礎好了。
假設你有個提款方法原先是這麼寫的:
public boolean withdraw(int money) {
    if(money >= 0 && this.balance >= money) {
        this.balance -= money;
        return true;
    }
    retrun false;
}
withdraw 在提款成功時會傳回 true,失敗時傳回 false,為了要確認提款是否成功,客戶端必須主動檢查傳回值,萬一忘記了,執行時也沒有任何機制可以主動通知客戶端,會有什麼結果無從得知。如果使用的是 Java,可以在執行不成功時拋出(throw)錯誤。例如:
public void withdraw(int money) throws Throwable {
    if(money < 0 || this.balance < money) {
        throw new Throwable("提款失敗");
    }
    this.balance -= money;
}
可以搭配 throw 語法的物件,必須是 Throwable 的實例,方法上可以使用 throws 聲明會有 Throwable 實例拋出,這會是方法簽署的一部份,如此客戶端只要查看文件,無需查閱原始碼,就知道該用 try-catch 語法處理,實際上編譯器也會強制客戶端必須處理。
不過,參數 money 似乎不應該傳入負數,這一開始就要規範,如果都規範好不準傳入負數,而客戶端還傳入負數,那一定是客戶端的 Bug,客戶端應該停下來修改他的程式;另外,餘額不足是商務上的邏輯考量,這是客戶端可以處理的錯誤,嗯!至少 Java 當初設計時是這麼想的啦!總之,應該可以將 withdraw 設計成:
public void withdraw(int money) throws Exception {
    if(money < 0) throw new IllegalArgumentException("提款額不能傳入負數");
    if(this.balance < money) {
        throw new Exception("餘額不足");
    }
    this.balance -= money;
}
Java 為 Throwable 定義了兩個 ErrorException 兩個子類別。Error 的實例是指那些發生了你也不能做什麼補救措施的錯誤,Exception 則是 Java 為那些能補救的錯誤而設計的類別,等等!為什麼 IllegalArgumentException 不用使用 throws 宣告在方法上?
前面講過,編譯器會強制客戶端要處理例外,除了那些 RuntimeExceptionError 的實例之外。Error 可以理解,都歸類在不可能有補救措施的錯誤了,編譯器還強迫處理就沒有道理,至於 RuntimeException,則是用來通知客戶端他的程式有 Bug 了,才導致方法中會拋出例外,IllegalArgumentException 就是一種 RuntimeException
其實學過 Java 的你一定知道,那些編譯器強制檢查的 Exception 叫 Checked Exception,那些編譯器不強制檢查的 RuntimeException 叫 Unchecked Exception。Java 當初想得很美好,也是唯一有 Checked Exception、Unchecked Exception 分別的語言,不過顯然開發者不領情的多,唔!篇幅有限,有興趣可以看看 貼心還是造成麻煩? 這篇文章。
程式中可能會捕捉例外,對例外做些能做的處理,再重新拋出例外,像是 要抓還是要丟? 中提到的,因此,像這樣的程式碼倒蠻常見的:
public void doSomething() throws IOException, SQLException {
    try {
        someMethodThatCouldThrowAnything();
    } catch(IKnowWhatToDoWithThisException e) {
        handle(e);
    } catch(SQLException e) {
        log(e);
        throw e;
    } catch(IOException e) {
        log(e);
        throw e;
    } catch(Throwable t) {
        log(t);
        throw new RuntimeException(t);
    }  
}
最後一個 catch 照單全收,防禦式地處理其他沒考量到的可能錯誤,因為方法上宣告會拋出 IOExceptonSQLException,為了傳播 Throwable 實例,程式中將之包裹在 RuntimeException 中,編譯器會很高興地忽略它。不過 catchSQLExceptionIOException 的兩個區塊程式碼重複了。如果你使用 JDK7,那可以用新語法改寫為:
public void doSomething() throws IOException, SQLException {
    try {
        someMethodThatCouldThrowAnything();
    } catch(IKnowWhatToDoWithThisException e) {
        handle(e);
    } catch(SQLException | IOException e) { // JDK7 multi-catch 語法
        log(e);
        throw e;
    } catch(Throwable t) {
        log(t);
        throw new RuntimeException(t);
    }  
}
那麼如果是 JDK6 呢?可以使用 Guava 的 Throwables 來改寫為更簡潔的方式:
public void doSomething() throws IOException, SQLException {
    try {
        someMethodThatCouldThrowAnything();
    } catch(IKnowWhatToDoWithThisException e) {
        handle(e);
    } catch(Throwable t) {
        log(t);
        Throwables.propagateIfInstanceOf(t, IOException.class);
        Throwables.propagateIfInstanceOf(t, SQLException.class);
        throw Throwables.propagate(t);
    }  
}
Throwables.propagateIfInstanceOf 作了什麼事?其實就是將 t 轉型為指定的類型並重新拋出:
...
  public static <X extends Throwable> void propagateIfInstanceOf(
      @Nullable Throwable throwable, Class<X> declaredType) throws X {
    if (throwable != null && declaredType.isInstance(throwable)) {
      throw declaredType.cast(throwable);
    }
  }
...
Throwables.propagate 就是將傳入的 t 包裹為 RuntimeException 傳回罷了:
...
  public static RuntimeException propagate(Throwable throwable) {
    propagateIfPossible(checkNotNull(throwable));
    throw new RuntimeException(throwable);
  }
...
Guava 的 Throwables 使用上很簡單,你可以再看看 ThrowablesExplained  的說明,應該就清楚多了。
我這邊想再多談一下 JDK7 的 multi-catch,在 multi-catch 時,catch 中的例外型態,不能有上下繼承關係,有沒有想過為什麼呢?catch(IOException | FileNotFoundException e) 或許還可以理解,因為展開為不使用 multi-catch 的寫法的話會變成:
...
} catch(IOException e) {
    handle(e);
} catch(FileNotFoundException e) {
    handle(e);
}
...
只是為什麼 catch(FileNotFoundException | IOException e) 也不行呢?想想看如果不用 multi-catch 的寫法的話會如何?
...
} catch(FileNotFoundException e) {
    handle(e);
} catch(IOException e) {
    handle(e);
}
...
好像合理耶!其實是因為,還沒使用 multi-catch 前的寫法本來就沒必要,想想你在 FileNotFoundExceptionIOException 的處理本來就相同,那為什麼不直接寫成以下就好?
...
} catch(IOException e) {
    handle(e);
}
...
你知道,Java 的編譯器蠻雞婆的,因此在 catch(FileNotFoundException | IOException e) 時就不讓你過關了。還有一個情況是,有人想在 multi-catch 後,使用 instanceof 判斷是不是某個特定例外型態之實例,像是:
...
} catch(IOException | SQLException e) {
    if(e instanceof FileNotFoundException) {
        doSomethingWhenFileNotFoundException(e);
    }
    handle(e);
}
...
這完全是不建議,multi-catch 本來就是讓你可以整理相同的例外處理程式碼,如果你用了 instanceof 針對特定型態做特定處理,就表示這個型態不該待在 multi-catch 中,也就是你沒用 multi-catch 的情況下,應該會像是:
...
} catch(FileNotFoundException e) {
    doSomethingWhenFileNotFoundException(e);
} catch(IOException e) {
    handle(e);
} catch(SQLException e) {
    handle(e);
}
...
那麼用 multi-catch 整理後,應該要是這樣才對:
...
} catch(FileNotFoundException e) {
    doSomethingWhenFileNotFoundException(e);
} catch(IOException | SQLException e) {
    handle(e);
}
...
這邊想表達的是,語法的簡便,其實只是讓你省去少打幾個字之類的麻煩,不過如果你不知道本質上該如何處理,那麼也是會發生濫用的情況。同樣地,Guava 的 Throwables 看來是省了一些功夫,不過也別亂用, ThrowablesExplained  的說明中,有一些不建議的使用方式,建議你瞭解一下,看看原因,最終其實都是有關於,例外或錯誤該怎麼處理與對待的問題。