🐧

チェック例外との付き合い方

2021/11/11に公開

はじめに

さまざまな功罪が語られるチェック例外ですが、Java 8 で追加された Stream API 、ラムダ式、メソッド参照などとの相性がよくありません。 そのような状況を踏まえ、改めてチェック例外をどのように扱えば効果的なのかを再考しました。

そこで辿り着いたのが、次のルールです。

  1. 公開メソッドは、(原則として)メソッド外にチェック例外を伝搬させない。
  2. チェック例外は、可能な限り狭い範囲で try-catch する。
  3. catch したチェック例外は、必要な回復処理をしてから、非チェック例外でラップして throw する。
  4. GlobalExceptionHandler ではチェック例外を取り扱わない。

このルールを逸脱した実装は、例外汚染[1] が発生している可能性を疑うようにしています。

チェック例外と非チェック例外

チェック例外のルールの説明をする前に、そもそも何がチェック例外(Exception)で、何が非チェック例外(RuntimeException)なのかについて、認識を整理します。 これについても議論がありますが、私の考えは次の通りです。

  • 非チェック例外は、呼出元が、事前にエラーを回避できるもの。
  • チェック例外は、呼出元が、事前にエラーを回避できないもの。

Java の標準ライブラリにおいても、IOException SQLException がチェック例外で、NullPointerException IllegalStateException が非チェック例外として定義されており、この考え方に整合しています。

横道にそれますが、この考え方では業務処理の例外クラスは、非チェック例外です。

それぞれのルールを決めた理由

公開メソッドは、メソッド外にチェック例外を伝搬させない

可視性が public protected なし(package private) であるメソッドは、クラス外からの利用を想定しています。 このため、Stream API などから、このメソッドをストレスなく利用できるように、公開メソッドではチェック例外を throws しないようにします。 また throws により、呼出元にエラーハンドリングを移譲しても、それが正しく実装される保証はありません。 これらを踏まえると、メソッド内でチェック例外のエラーハンドリングを済ませることが、理にかなっています。

蛇足になりますが、このルールを徹底することで、チェック例外が『開放・閉鎖原則』に違反するという問題を回避することになります。

このルールから外れる公開メソッドは、利用者に try-catch を強制するため、開発者に対してエラーハンドリングが必須であるとの強いメッセージを伝える効果も期待できます。

チェック例外は、可能な限り狭い範囲で try-catch する

『公開メソッドは、メソッド外にチェック例外を伝搬させない』ルールを適用すると、公開メソッド側で Exceptioncatch する実装に仕上がってくる場合があります。

良くない実装.java
class Test {
    public void hoge() {
        try {
            fuga();
        } catch(Exception e) {
            // 全ての例外をキャッチすると、正しいエラーハンドリングを書くことができない。
            ...
        }
    }

    private void fuga() throws IOException {
        ...
    }
}

そもそも、質の高いエラーハンドリングをするには、エラー発生箇所の近くで適切な範囲を処理するのが原則ですので、手を抜かずに基本を守りましょうということです。

catch したチェック例外は、最低限のエラー処理をし、非チェック例外でラップして throw する

これは、private メソッドの中で catch したチェック例外を、どのように処理するかという話です。 もちろん、呼出元にエラー発生を伝える必要がありますので、非チェック例外を throw しますが、そこにも問題が含まれます。 良く見かけるダメな例と、修正方法は次の通りです。

元の例外を残さない.java
private void hoge() {
    try {
        ...
    } catch(SQLException e) {
        // 元の例外を残さないと原因調査できない
        throw new RuntimeException("");throw new RuntimeException(e);
    }
}
用意されている非チェック例外を使っていない.java
private void hoge() {
    try {
        ...
    } catch(IOException e) {
        // IOExceptionには標準で専用の非チェック例外がある
        throw new RuntimeException(e);throw new UncheckedIOException(e);
    }
}
握り潰して隠す.java
private void hoge() {
    for(i = 0; i < 100; i++) {
        try {
            ...
        } catch(SQLException e) {
            // ループを回し切りたいため、メッセージを吐いて、そのままにしている
            log.error("exception: {}, {}", i, e.getMessage());
        }
    }
}private void hoge() {
    boolean hasError = false;
    for(i = 0; i < 100; i++) {
        try {
            ...
        } catch(SQLException e) {
            hasError |= true;
            log.error("exception: {}", e.getMessage());
        }
    }
    if (hasError) {
        throw new RuntimeException("hoge error.");
    }
}

GlobalExceptionHandler ではチェック例外を取り扱わない

ここまでのルールを適用した結果として、GlobalExceptionHandler にチェック例外が登場することはありません。 もし GlobalExceptionHandler にチェック例外が出てきた場合は、ルール違反が発生しているので、ここで気付いて修正しましょうという意味で、最後にこのルールを置いています。

まとめ

嫌われることの多いチェック例外ですが、エラーハンドリングを忘れがちな開発者に、必要な例外ハンドリングを強制できるという意味では、一定の効果があります。 このメリットと、可読性、保守性、生産性、Java 8 以降の言語仕様の追加などのバランスから、効果的と考えられるチェック例外との付き合い方を紹介しました。

『エラーハンドリングが甘いことによるバグに悩んでいる』や『なんでもチェック例外で実装が辛いと感じている』ような人の参考になれば幸いです。

脚注
  1. 不必要なエラーハンドリングに悩まされることを、このように呼んでいます。 ↩︎

Discussion