やり直しJava

例外

この章では 始めに基本的な例外処理についてざっと見ていきます。Java SE 7から導入されたtry-with-resource文についても見ていきます。

続いて 例外の種類、例外の特徴やJavaにおける問題点、いつどんな例外を投げるべきか、例外以外のエラー表現といった内容について見て行きます。

例外の基本

例外を受け取って処理する

例外が発生する可能性のある処理は tryブロックで囲み、catchブロックで例外発生時の処理を行います。

try {
    例外が発生しうる処理;
} catch (例外 変数) {
    例外発生時の処理;
} finally {
    後処理;
}
try {
    FileReader reader = new FileReader("notexist.txt");
} catch(FileNotFoundException e) {
    System.out.println(e);
}

これによって、正常処理とエラー処理を分離させることができます

継承関係のある複数の例外が投げられる場合

複数の例外が発生しうる場合は、catchブロックを続けて記述します。catchする例外クラスに継承関係がある場合、サブクラスのcatchを先に記述します。スーパークラスのcatchを先に記述してしまうと、サブクラスのcatch節に処理が行かなくなってしまいます。

try {
    FileReader reader = new FileReader("notexist.txt");
    reader.read();
} catch (FileNotFoundException e) {  // サブクラスを先に書く
    e.printStackTrace();
} catch (IOException e) {  // スーパークラスは後に書く
    e.printStackTrace();
}

例外を無視しない

後で詳しく説明しますが、例外にはチェック例外実行時例外があり、チェック例外が発生しうる箇所では例外処理が強制されます。基本的には チェック例外は呼び出し側でリカバリ処理が行える場合に投げられるため、チェック例外をキャッチした場合は、適切な対処を行う必要があります。しかし、時には適切なリカバリ処理が行えない場合もありますが、そのような場合は 実装漏れなのか処理が不要なのか区別できるように 何もしない場合はコメントを残すべきです

この章のサンプルでは例外をキャッチした箇所で出力のみを行っている場合がほとんどですが、実際のアプリケーション開発においては適切な例外処理(リカバリやログ記録、例外の伝播など)を行う必要があることに注意してください。

抽象的な例外クラスによる包括的な例外のキャッチ

複数の種類の例外が発生するような場合に、Throwable、Exception、RuntimeException等の抽象的な例外クラスで包括的に例外をキャッチすることができます。(Throwable、Exception、RuntimeException等は抽象的な例外クラスではありますが、抽象クラスではありません。)しかし、抽象的な例外クラスによる包括的な例外のキャッチは、バグや障害を隠蔽してしまう可能性もあるため 注意が必要です

抽象的な例外クラスをキャッチすることのメリットは、サブクラスの例外を漏れなくキャッチすることができるため、想定外の障害やバグで例外が発生した場合でも システムやスレッドの停止を防げることです。しかし、想定外の例外であるが故に、適切な対処はできず せいぜいログに記録するぐらいになります。そして ログに記録するだけだと、想定外の例外が発生した事実が見逃されてしまい 異常に気付くことができません。そのため アラームを上げるなどして、想定外の例外が発生したことを検出する仕組みを用意しておくことが重要です

抽象的な例外クラスによる包括的な例外のキャッチが適切な場合もあります。全ての例外をキャッチして それらの例外を形を変えて再度スローするような場合です。下位レイヤの例外を上位の別の概念の例外に置き換えるような場合(後述の例外翻訳)や、例外が信頼境界を越える際に 例外の詳細を隠蔽したい場合などには、Throwableによる 包括的な例外キャッチが適しています。

複数例外のマルチキャッチ(Java SE 7~)

Java SE 7で例外のマルチキャッチが導入され、複数例外を同一のcatchブロックでキャッチすることができるようになりました。

try {
   処理;
} catch (IOException | SQLException e) {
    System.out.println(e);
    throw e;
}

ただし、catchする例外クラスに継承関係がある場合は 同一のcatchブロックでキャッチすることはできません

//} catch (FileNotFoundException | IOException e) {    // 継承関係があるため、コンパイルエラー

例外を投げる

例外を投げる場合は「throw」を使います。チェック例外をメソッドの外に投げる場合は メソッド定義に「throws」で投げる例外を指定します。実行時例外をメソッドの外に投げる場合は メソッド定義のthrowsの指定は不要です。

public void method1 throws SomeException {
    throw new SomeException();  // SomeExceptionはチェック例外
}

public void method2 {
    throw new SomeRuntimeException();  // SomeRuntimeExceptionは実行時例外
}

どんな例外をいつ投げるかについては この章の「どんな例外をいつ投げるか」を参照してください。

例外をスルーする

メソッドの中で例外が発生したけれども メソッドの呼び出し元で例外を処理させたい場合は、メソッド定義に「throws」を付けて例外をスルーします。

public void someMethod() throws FileNotFoundException {  // メソッドの外にスルーする。
    FileReader reader = new FileReader("notexist.txt");  // ここで例外が発生する可能性あり。
}

例外翻訳と例外連鎖

上の例では メソッドの中で発生した例外をそのまま投げていますが、メソッドの中で発生した例外とは異なる例外を投げた方が良いケースも多々あります。例えば ユーザが入力した情報を登録するメソッドを提供する場合、情報の保存先がファイルなのかDBなのかあるいはWebサービスなのかで メソッドの中で発生し得る例外は異なります。しかし、このメソッドとしては、登録が失敗した場合は ファイル操作失敗・DB操作失敗・HTTP通信失敗に関する例外を呼び出し元に伝播させるよりも、登録が失敗した旨と取り得る対処に関する情報を例外として伝播させる方が適切です。

このように 実装の詳細に関する下位のレイヤの例外を一旦catchして、上位の概念の別の例外に置き換えて伝播させることを例外翻訳と呼びます。例外翻訳は実装の詳細が変更になった場合に 差異を吸収できるという利点もあります。

また、下位のレイヤの例外と上位のレイヤの例外の間には因果関係がありますので、上位の例外が発生した原因cause)として下位の例外を設定することができます。これを例外連鎖と呼びます。標準的な例外では下位の例外を引数に取るコンストラクタが用意されていて、コンストラクタに渡す例外を 原因に設定することができます。そのようなコンストラクタが用意されていない場合でも、ThrowableクラスのinitCause()メソッドで原因を設定することができます。設定した原因はgetCause()メソッドで取得することができ、原因が設定されるとスタックトレースも連結されます。

finally

例外が発生したかどうかに関わらず実行したい処理がある場合は finallyブロックに記述します。

try{
    処理;
} catch(SomeException e) {
    例外処理;
} finally {
    例外が発生した場合もしない場合も、
    また、補足しない例外が発生した場合も行いたい処理;
}

従来finallyブロックはリソースの解放(ファイルやソケットのクローズ)に使われていましたが、Java SE 7からtry-with-resource文で自動的にリソースの解放が行われるようになったため、finallyブロックの利用シーンがぐっと減りました

ただし、java.util.concurrent.locks.ReentrantLockクラスのようにtry-with-resource文に対応していない(AutoClosableを実装していない)クラスがあったり、リソース解放以外の後処理を行いたい場合もあります。そういった場合には finallyブロックで後処理を行う必要があります。

try-with-resource文

try-with-resource文を使うと、スコープを抜ける際にリソースの解放を自動的に行ってくれます。(PythonのwithやC#のusingと同じような役割を果たします。)

従来のtry-with-resource文を使わない例外処理の例を挙げます。

FileReader reader = null;
try {
    reader = new FileReader("notexist.txt");
    int ch = reader.read();
} catch (FileNotFoundException e) {
    e.printStackTrace();
} catch (IOException e) {
    e.printStackTrace();
} finally {
    if(reader != null) {
        try {
            reader.close();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

リソースの解放をしたいだけなのに、次の点からソースコードが煩雑になり読みづらいです。

  • tryブロックとfinallyブロックで同じ変数readerにアクセスできるようにするため、変数をtry-catch-finallyの外で宣言する必要がある。
  • reader.close()でも例外が発生する可能性があるため、try-catchが二重に必要。

また、根本的なところで finallyブロックを書き忘れてリソースの解放漏れが発生する可能性もあります。

上のコード例をtry-with-resource文を使って書き直すと次のようになります。

try(FileReader reader = new FileReader("notexist.txt")){
    int ch = reader.read();
} catch (FileNotFoundException e) {
    e.printStackTrace();
} catch (IOException e) {
    e.printStackTrace();
}

reader.close()の処理を明示する必要がなく、tryブロックを抜ける際に 暗黙的にreader.close()の処理が呼び出されます。try-with-resource文によりリソースの解放が簡潔に記述できるようになり、解放漏れの心配も無くなります。ただし、try-with-resource文が利用できるのは AutoCloseableインタフェースを実装しているクラスに限られますので、AutoCloseableを実装してないjava.util.concurrent.locks.ReentrantLockクラスなどは finallyブロックでロックの解放を行う必要があります。

リソース解放対象クラスがネストしている場合の注意点

try-with-resource文を使う場合に注意すべき点があります。java.ioパッケージのクラスでリソース解放の対象クラスをネストさせるような場合(Reader/Writer、InputStream/OutputStreamの具象クラス)、例外の発生箇所によってはリソースの解放が行われないケースがあるため記述の仕方に注意が必要です。

// PrintWriterのコンストラクタで例外が発生すると
// BufferedWriterやFileWriterのclose()が呼ばれない。
try(PrintWriter writer = new PrintWriter(new BufferedWriter(new FileWriter(file)));) {

    処理;
    ...
}

// PrintWriterのコンストラクタで例外が発生しても
// BufferedWriterやFileWriterのclose()が呼ばれる。
try(FileWriter fileWriter = new FileWriter(file);
    BufferedWriter buffWriter = new BufferedWriter(fileWriter);
    PrintWriter printWriter = new PrintWriter(buffWriter);) {

    処理;
    ...
}

上の例のように、1つの文でPrintWrterのインスタンスを生成するのではなく、FileWriter・BufferedWriter・PrintWriterを別々の文で生成する必要があります。

try-with-resources文の基」(Qiita)
try-with-resourcesでリース解放されないパターン」(Qiita)

伝播する例外が置き換えられてしまう問題の対応(例外抑制)

try-with-resource文は 従来のtry-finallyブロックで発生しうる「finallyブロックにおいて 伝播する例外が置き換えられてしまう」という問題に対応しています。そのような問題の例を次に示します。

public void someMethod() throws IOException {
    FileReader reader = null;
    try {
        reader = new FileReader("readme.txt");
        int ch = reader.read();    // ①read()でIOException発生
    } finally {
        reader.close();    // ②close()でIOException発生
    }
}

上のsomeMethod()は 例外が発生してもそのままスルーし、呼び出し元に例外処理を強制します。ただし、リソースの解放を行う必要があるため、finallyブロックでreader.close()を行います。ここで次のような障害状況を考えます。ファイルは開くことはできたけれども、直後の読み出し中にストレージに障害が発生したり、(ネットワークストレージ等で)ストレージとの接続に障害が発生して ①の箇所でIOExceptionが発生したとします。このような障害の場合は ②のclose()メソッドも同様にIOExceptionが発生する可能性があります。

そうすると、本来呼び出し側には①で発生した例外を伝播させたいところを、実際には②で発生した例外が伝播してしまい、メソッドの呼び出し元を混乱させてしまいます。この例外が置き換えられてしまう問題は 1つの例外しか伝播できないことに起因しています。try-with-resource文はこの問題にもうまく対応しています。try-with-resource文で書き直すと次のようになります。

public void someMethod() throws IOException {
    try (FileReader reader = new FileReader("readme.txt")) {
        int ch = reader.read();    // ①reader.read()でIOException発生
    }   // 内部的に②reader.close()が呼び出され、IOException発生
}

上の例では tryブロックを抜けるときに 内部的にreader.close()メソッドが呼び出されます。①と②でIOExceptionが発生した場合、呼び出し側には①の例外が伝播されます。その際に②の例外の情報も付加されるため、呼び出し側では②の例外情報を取得することもできます。呼び出し元で②の例外情報を取得するには、Java SE 7からThrowableクラスに追加されたgetSuppressed()メソッドを使います。

例外の詳細

例外クラスの分類

例外クラスは大きく分けてエラーErrorおよび派生クラス)と例外Exceptionおよび派生クラス)に分けられます。エラーと例外のスーパークラスはThrowableクラスです。例外はさらに実行時例外(RuntimeException および派生クラス)とチェック例外(例外から実行時例外を除いたもの)とに分けられます。チェック例外に対して エラーと実行時例外は非チェック例外と呼ばれます。

エラー

ErrorクラスとErrorの派生クラスで、主にシステム的な障害を扱うクラスです。エラーは慣例的にJVMが発生させる物であり、ユーザが発生させるべき物ではありません。OutOfMemoryError、InternalErrorなどがあり、発生してもアプリケーション側では対処できない内容になります。(アプリケーションではログ記録を行うぐらいのことしかできません。)

チェック例外

アプリケーションの通常運用の中で発生しうる例外で、発生時に呼び出し元で適切なリカバリ処理が可能な例外です。そのような例外をここでは回復可能な例外と呼ぶことにします。チェック例外にはFileNotFoundeException、SQLExceptionなどがあります。チェック例外は一般的には補足すべき例外とされていて コンパイラで例外処理の有無をチェックし、例外処理がなされていないとコンパイルエラーとなります。(言い換えると、例外処理が強制されます。

しかし、チェック例外でも特定の条件下では発生し得ない場合があります。その場合でも 例外に対する処理が強制されることになり、チェック例外は時に煩わしいものになります

そういった例の一つとして Thread.sleep()メソッドのInterruptedExceptionが挙げられます。これはマルチスレッドにおいて 別スレッドからの割り込みを検出するために必要な手段ではありますが、シングルスレッドで別スレッドから割り込まれることがない状況においてもInterruptedExceptionに対する処理を記述しなければならず、大抵は無視するか意味のない処理を記述することになってしまいます。

また、別の例では DateFormat.parse()メソッドのParseExceptionが挙げられます。カレンダーのようなUIを提供している場合は、決められた形式の日付文字列以外を渡すことはないため parse()メソッドでは例外は発生し得ません。しかし 使う状況によっては例外が発生し得ないことが明らかな場合でも、ParseExceptionに対する処理を記述しなければなりません。

コンパイラによるチェック例外に対する例外処理の有無のチェックは、システムの堅牢性を高める上では有用な仕組みではありますが、呼び出し元の状況によっては 発生し得ない例外に対しても例外処理を強制されることから、時に煩わしいものになります

実行時例外

アプリケーションのバグや設定ミスによって発生しうる例外で、多くはメソッドの使用条件を誤った場合に発生します。そのような例外を ここではプログラミングエラーと呼ぶこととします。例えば NullPointerExceptionはnullが許容されていない引数にnullを指定した場合に発生し、IndexOutOfBoundsExceptionは配列やコレクションの指定可能なインデックスの範囲外を指定した場合に発生します。

実行時例外はチェック例外と違い 一般的には補足すべきでない(補足しても適切な処理が行えない)例外とされていて、コンパイラは例外処理の有無をチェックしません。そのため、実行時例外を投げる場合は ドキュメントに明記することが重要になります。

標準ライブラリを眺めると気付くと思いますが、実行時例外は必ずしもプログラミングエラーであるとは限らず、補足すべき状況も多々あります。例えば文字列をint値に変換するInteger.parseInt()メソッドでは、引数の文字列をint値に変換できなければ 実行時例外NumberFormatExceptionが投げられます。parseInt()メソッドの使用条件が「int値に変換可能な文字列を渡すこと」であるという解釈もできますが、parseInt()メソッドの大部分がint値に変換可能な文字列かどうかのチェックを行っているため、チェック処理がユーザ側の責任とするとparseInt()に近い処理をユーザ側で実装しないといけないことになってしまいます。実際にこのような場合には、parseInt()にはユーザが入力した内容を直接渡して、NumberFormatExceptionが発生したら再入力を促すというロジックが採用されることも多々あります。この場合 NumberFormatExceptionはプログラミングエラーではなく、補足すべき例外になります。

実行時例外は 多くはプログラミングエラーではありますが、同じ例外であっても 呼び出し元の状況によっては 回復可能な例外となる場合もあります。そのような場合は 補足すべき例外ではありますが、コンパイラによる例外処理の有無がチェックされず、堅牢性を上げる仕組みの恩恵を享受することができません。

例外の特徴とJavaにおける問題点

例外はJavaに限らず 他の言語でも取り入れられているエラー処理の一つです。Javaでは例外がエラー処理の中心になります。例外の仕組みは言語によって差がありますが、Javaの例外は 使い勝手が良いものではありませんSwiftも例外を採用していますが、Javaの例外の使い勝手の悪さを改善したような内容になっています。

ここでは、例外の特徴とJavaにおける例外の問題点を見ていきます。また、各問題点についてSwiftでどのように改善しているかについても触れていきます。

正常処理とエラー処理の分離

例外を用いると tryブロックに正常処理が catchブロックにエラー処理が記述されるため、正常処理とエラー処理が分離され コードの可読性が上がります

try {
    // 正常処理
    処理1;
    処理2;
    処理3;
} catch(SomeException e) {
    // エラー処理
    リカバリ処理1;
    リカバリ処理2;
}

一方で、正常処理のうち どこで例外が発生しうるのか、一目で見分けることができません。そのため 例えば処理1と処理3で同じ例外が発生し得る場合に、処理1が失敗した場合のエラー処理しか記述しておらず 処理3が失敗した場合のエラー処理が適切に行われないというようなバグを引き起こす可能性があります。

Swiftでは 例外が発生し得る処理の前に”try”を明示することにより、この問題に対処しています。処理1と処理3で例外が発生し得るのであれば、それぞれの先頭に”try”を明記します。(SwiftではJavaの”try”に相当するキーワードは”do”になります。)

複数スタックに渡る例外の伝播

例外をスルーしたり翻訳することによって 複数のスタックに渡って例外を伝播させることができ、大元の呼び出し元で一括してエラー処理を行うといったこともできます。これは便利ではありますが、処理の流れが追いづらくなり コードの可読性や保守性を落とす要因にもなり得ます。

コンパイラによるチェック例外の検査

チェック例外を使うと コンパイラによって例外処理が強制されるため 堅牢性を上げることができます。しかし、「チェック例外」で述べた通り、特定の条件下では発生し得ない例外に対する処理も強制されることになり、チェック例外は時に煩わしいものになります

例外処理の強制が煩わしいからと言う理由で 本来チェック例外にすべき例外を実行時例外にしてしまうと、コンパイラのチェックが行われなくなり、必要な例外処理が漏れてしまう可能性を残してしまいます。

逆に「実行時例外」で説明した通り、実行時例外でも 呼び出し元の状況によっては回復可能な例外となる場合もあり、そのような場合は 例外を補足すべきで コンパイラによるチェックを行って欲しいところです

つまり、チェック例外も実行時例外も 呼び出し元の状況によって 補足すべきかどうかが異なるのにも関わらず、クラス定義の時点で補足すべき(チェック例外)か、補足する必要はない(実行時例外)かが決まってしまうため、このような歪みが生じてしまうと考えられます。

Swiftでもチェック例外のような仕組みを採用していますが、補足する必要がない場合は メソッド呼び出しの前に “try!” を付けることによって コンパイラのチェックを抑制することができます。つまり 呼び出し元の状況によって コンパイラによる例外処理の有無のチェックを強制するかどうかを切替えられることができます。これによって、チェック例外のような仕組みを採用しつつも 堅牢性と柔軟性の両立を実現しています。

Javaの例外には残念ながらこのような柔軟性がないため、クラス定義の時点で 補足されるべき例外か補足される必要のない例外かを決めないといけません。

高階関数におけるチェック例外

コレクションやストリームのmap()やforeach()といった 関数型オブジェクト(ラムダ式や匿名クラス)を引数とするメソッドを、ここでは便宜上 高階関数と呼ぶこととします。(Javaは「関数」ではなく「メソッド」なのですが、関数型言語ではこのような関数は「高階関数」と呼ばれるため それに合わせています。)

Java標準ライブラリの高階関数の引数の型には、java.util.functionパッケージに用意されている標準的な関数型インタフェースを使用しています。Javaの標準的な関数型インタフェースの抽象メソッドは例外を投げるような形になっていません。そのため、それらの高階関数に渡す関数オブジェクトのメソッドがチェック例外を投げると コンパイルエラーになってしまいます。したがって、それらの高階関数に渡す関数オブジェクトから例外を投げる場合は実行時例外を投げる必要があります。

例外を投げる抽象メソッドを持つ関数型インタフェースを定義することはできますし、そのような関数型インタフェースを引数に持つ高階関数を定義することも可能です。しかし、Java標準ライブラリの高階関数では チェック例外を投げない関数オブジェクトを引数に取るため、チェック例外は高階関数と相性が悪いと言われる所以と考えられます。

Swiftではこの問題に対してrethrowsという仕組みで対応しています。関数オブジェクトがチェック例外を投げる場合は 高階関数もその例外を投げるように振舞います。そのため、同じ高階関数に チェック例外を投げない関数オブジェクトを渡すこともできますし、チェック例外を投げる関数オブジェクトを渡すこともできます。チェック例外を投げる関数オブジェクトを渡す場合は、高階関数に対する例外処理が必要になります。

実行時例外の安全性を上げるパターン

実行時例外が発生し得る箇所では コンパイラによる例外処理のチェックが行われません。そのため、コードの見た目が煩雑にならない代わりに 必要な例外処理が漏れてしまう可能性を残してしまいます。Effective Javaでは、実行時例外の安全性を上げるために次のようなパターンが提案されています。

実行時例外が発生する可能性のあるメソッドを呼び出す前に、そのメソッドを呼び出すと正常な値を返すのか例外が投げられるのか検査してboolean値を返すメソッドを提供します。ここでは便宜上、実行時例外が発生し得るメソッドをFailableメソッド、事前検査のためのメソッドを事前検査メソッドと呼ぶこととします。これによって 事前検査メソッドで検査した後であれば、(他のスレッドに割り込まれない限り)Failableメソッドが例外を投げないことが保証されます。また、文脈からFailableメソッドが例外を投げないことが明らかな場合は 事前検査メソッドを省略することも可能で、堅牢性と柔軟性の両立を図ることができます

Iteratorのnext()とhasNext()がちょうどそのような構成になっています。hasNext()がtrueを返すのであれば、(他のスレッドに割り込まれない限り)next()が例外を投げないことが保証されます。また、文脈からnext()が例外を投げないことが分かっていればhasNext()を省略することもできます。

ただし、このパターンには3点 注意事項があります。

  1. 事前検査メソッドとFailableメソッドの間に別スレッドが割り込めないようにする必要がある。
  2. 事前検査メソッドではFailableメソッドと同じような検査処理を行う必要があるため、Failableメソッドだけを呼び出す場合に比べて 最悪の場合で2倍に近いコストが掛かる可能性がある
  3. Failableメソッドが例外を投げないことが文脈上明らかでない場合は、必ず事前検査メソッドとセットで使わないといけないことをドキュメントに明記する必要がある。

特に3点目は重要で、安全性を上げると言っても 使い方を間違えると全く意味の無い物になってしまいます。チェック例外のような例外処理の強制力があるわけでははいことに注意が必要です。

どんな例外をいつ投げるか

これまで見てきたように、メソッドの呼び出し元の状況によって補足すべき例外かどうかが変わってきますので、メソッド側でチェック例外を投げるか実行時例外を投げるかを決めても 呼び出し元の状況と合わない場合はどうしてもでてきてしまいます。そのため、メソッド側では良く利用されるシーンを想定してチェック例外を投げるのか実行時例外を投げるのかを決めることになります。

チェック例外を投げるのか、実行時例外を投げるのかについての おおまかなガイドラインは次のようになります。

  • メソッドの引数の条件(nullを許容しない、値範囲など)に違反する場合には実行時例外を投げる。
  • 事前検査メソッドを提供する場合は Failableメソッドでは実行時例外を投げる。
  • 呼び出し側で回復可能な場合にはチェック例外を投げる。

繰り返しになりますが、ガイドラインに沿って決めても 呼び出し側の状況によって合致しない場合はどうしてもでてきてしまいますので、あくまでも目安です。

標準的な例外を使う

Java標準ライブラリには標準的に使うことができる例外クラスが用意されています。各例外クラスの意味はJavaDocに記載されていますが、意味的に一致する例外クラスがある場合は 極力標準ライブラリの例外クラスを使うようにします。そうすることによって、冗長なコーディングを減らすことができますし、コードが理解し易くなります。

良く使われる標準的な例外クラスは次のようなものがあります。

  • 引数が適切でない場合:IllegalArgumentException
    • 特にnullの場合:NullPointerException
    • 値範囲外の場合:IndexOutOfBoundsException
  • メソッドを呼び出すオブジェクトの状態が適切でない場合:IllegalStateException
  • 適切に同期されずに並列で変更された場合:ConcurrentModificationException

Throwable、Error、Exception、RuntimeExceptionは 直接投げないようにします。これらは個別の例外クラスのスーパークラスに当るため、例外をキャッチする箇所で これらの例外が投げられたのか サブクラスの例外が投げられたのか いちいち判断しなくてはならないからです。

エラーアトミック性の確保

メソッドが失敗して例外を投げる場合、オブジェクトの状態を中途半端に変更したままにしておくべきではありません。メソッドが失敗しても オブジェクトの状態をメソッド呼び出し前と同じにしておくべきで、そのような性質をエラーアトミックであると言います。

エラーアトミック性を確保するためには次のような方法が挙げられます。

  • 可能であればオブジェクトを不変にします。不変オブジェクトであれば 状態が変更されることがないため エラーアトミックの問題を考慮する必要がありません。
  • オブジェクトの状態を変更する前に、失敗する可能性のある処理を先に行います。次のような場合にメソッドが失敗する可能性があり、オブジェクトの状態変更の前にこれらの検査や処理を行うようにします。
    • 引数が不正な場合や、オブジェクトの状態が不正な場合。
    • DBアクセスやネットワーク通信など、メモリ操作以外の処理を行う場合。
  • オブジェクトの状態の複製を作成して それに対して操作を行い、メソッドが正常終了する際に オブジェクトの状態を複製の状態で置き換えます。
  • 途中で失敗した場合は、反対の操作を行って状態を元に戻します(ロールバック)。しかし、ロールバックはロールバック自体が失敗した場合の救済処理まで考えないといけないため 複雑になります。また、回復できない状況(ロールバックのロールバックが失敗するような場合)も出てきてしまうため あまり有用な手段ではありません。

エラーアトミック性を確保することは大事ですが、必ずしもエラーアトミックを実現できない状況も出てきます。そのような状況が起こりうる場合には、オブジェクトの状態がどうなってしまうのか ドキュメントに明示するのが望ましいです。

例外の文書化

クラス」の章の「ドキュメントコメント」で説明しますが、メソッドが例外を投げる場合は

@throwsタグを使ってドキュメントコメントに文書化します。チェック例外だけでなく 実行時例外についても文書化します。実行時例外についての説明は メソッドの事前条件としての役割を果たします

チェック例外も実行時例外も@throwsタグで文書化されると、どれがチェック例外でどれが実行時例外か分からなくなります。そこで、メソッドシグニチャのthrows節で判別することになるため、実行時例外についてはメソッド宣言のthrows節に列挙しないようにするべきです。

例外以外のエラー表現

Javaでは例外がエラー処理の中心となりますが、例外だけがエラーを表現する方法ではありません。メソッドの戻り値として結果を表すデータ型を返すことによってエラーを表現することもできます。例外は高階関数と相性が良くないため、Scalaのような関数型プログラミング言語では 結果を表すデータ型を返すことでエラーを表現する方法が採られます(例外も使えます)。Scalaにおいて結果を表すデータ型にはOptionEitherTryがあります。OptionはJavaのOptionalクラスと同じで 正常な結果か空のどちらかを持つデータ型です。Eitherは正常な結果かエラー情報のどちらかを持つデータ型です。Tryは正常な結果か例外のどちらかを持つデータ型です。

JavaにはEither、Tryはありませんが、Optionに相当するOptionalクラスを利用することができます。ただし、Optionalは有効な結果か 空の結果かの表現しかできないため、エラーの種別を表現することができません。したがって エラーの発生原因ごとに回復処理を分岐させる必要がなく、成功のときはその値を、失敗のときは返すべき値が無いことだけが分かれば良い状況であれば Optionalで結果を表現することができます。

例えば 文字列を数字に変換するメソッドで、変換に成功した場合は変換した数字を返し、失敗した場合は失敗の理由(数字形式でない、値範囲外など)に関わらず再入力を促すような場合には、Optionalで結果を表現することができます。

また、Optionalはモナドであるため、値を取り出したり処理を分岐することなく メソッドチェーンで結果に対して関数オブジェクトを適用していくことができるという利点もあります。