異常系の判断基準

id:daisuke-m と話してたことをまとめる。
元ネタはThrowableについて本気出して考えてみた - 都元ダイスケ IT-PRESS

異常が起こったとき、だいたい下の方法で対処してます。

  • RuntimeException系をスロー
  • Exception系をスロー
  • AssertionErrorをスロー (またはassert文の利用)
  • Error系をスロー
  • 特殊値(null, -1, など)を返す

で、次のようなものがあるという想定です。

  • 自分が作っているAPI (API)
  • APIを利用するアプリケーション (アプリケーション)
  • 環境 (環境)
    • Javaランタイムライブラリ
    • APIが利用する外部ライブラリ
    • APIが利用するフレームワーク
    • 外部デバイス (人間の入力も含む)
    • アプリケーションへのコールバック
    • など、APIから完全な制御ができないもの

RuntimeException系をスロー

たぶん一番使うのがこいつ。

  • アプリケーションが前提条件を守らなかった場合

処理がうまくいかない理由がアプリケーション側に100%責任がある場合、かならずRuntimeException系をスローしてます。逆に、これ以外の場合ではRuntimeException系をスローしないように心掛けています。

メソッドを書くときは、できる限り下の項目を守るようにしています。

  • Javadocに事前条件を明記し、守らなかった場合にスローするRuntimeExceptionをすべて明記する
  • 事前条件の確認だけを行う方法を分離して提供する
    • obj != null とか a < 5 みたいな単純なやつは勝手にやってもらう
    • 事前条件の確認処理をメソッドとして分離すると、本体のメソッドからも再利用が効く

よくできてるなーと感じたのはjava.util.Iteratorで、これには次のメソッドがあります。

  • boolean hasNext()
    • 次の要素がある場合にのみtrueをかえす
  • T next()
    • 次の要素を返す
    • なければthrows NoSuchElementException

このように確認方法を分離することで、「next()が次の値を返せないけど、hasNext()で事前にチェックしなかったアプリケーションの責任」というような責任転嫁ができます。
これを守っていないのがNumberFormatExceptionをスローするInteger.parseInt(String)で、渡す文字列が数値かどうかをチェックする仕組みが提供されていない*1ため、アプリケーションへの責任転嫁がうまくいってないように思えます。

ただし、RuntimeExceptionはcatch漏れとcatchを書く労力のトレードオフな部分が多いと思いました*2。Exceptionをスローするのがよさそうな場面で、「どうせ復帰してもしょうがないから」という理由でRuntimeExceptionをスローするライブラリをよく見かけます。

Exception系をスロー

次のような場面で使います。

  • 環境から例外が返されて、その例外をAPIが処理しきれない場合
    • 環境からExceptionがスローされて、API単体では復帰できない場合
    • 環境から特殊値が返されて、API単体では解釈できない場合
  • 事前条件の確認処理を分離するのが難しい場合
    • 同じ値を2回作るのが難しい (InputStreamを渡す場合など)
    • 事前条件の検査と実際の処理がどちらも同じくらい複雑な処理

環境の例を挙げると、下記ではファイルが存在するかチェックした後に、そのファイルの入力ストリームを開いています。

File file = ...;
if (file.exists() == false) {
  ...
}
else {
  InputStream input = new FileInputStream(file);
  ...
}

ただし、最初のfile.exists()からnew FileInputStream...までの一瞬にファイルが消された場合などに、Exception系のFileNotFoundExceptionをスローすることがあります。これはアプリケーションの責任ではなく、環境の責任にあたるはず。

事前条件確認の分離が困難な例は、たとえば下のような感じ。

String source =
  "public static void main(java.lang.String[] args) {\n" +
  "  System.out.println(\"Hello, world!\");\n" +
  "}";
return Compiler.compileMethod(source);

こんな感じのAPIがあったとして、たとえばCompiler#canCompileMethod(String)とかに分離することも可能だとは思いますが、そこまで検査したならついでにコンパイルもしてくれよ、と。

AssertionErrorをスロー (またはassert文の利用)

意見が分かれるところですが、結構AssertionErrorも使います。

  • 環境が仕様と異なる動きをする場合
  • 俺の中では動いてる、けど実際は動かなくなる場面

こんな感じ。

int result = env.getValue(); // 「1~3を返す」という仕様
if (result == 1) {
  // 1の時の処理
}
else if (result == 2) {
  // 2の時の処理
}
else if (result == 3) {
  // 3の時の処理
}
else {
  throw new AssertionError(result);
}

環境を信じれば、else if (result == 3) という部分を else に置き換えることもできます。

ただし、次のような場合に困る。

  • env.getValue()はバージョンが上がって4を返すようになった
    • 4のときに3の動きをする+正常に動いているように見える ので見つけにくくなる
  • env.getValue()は「10~30を返す」という仕様なのに読み間違えてた
    • 常に3の動きをする+正常に動いているように見える ので見つけにくくなる

こういう場合、責任がアプリケーション側に存在しないため、RuntimeExceptionではなくわざわざAssertionErrorにして明確にしています。ついでに言うと、catch(Exception e)を潜り抜けられるというのも◎。

ちゃんと毎回-eaを付ける方々は下記でもいいと思います。ただ、テスタが-eaつけ忘れると残念な動きをします。

int result = env.getValue(); // 「1~3を返す」という仕様
assert 1 <= result && result <= 3 : result;
if (result == 1) {
  // 1の時の処理
}
else if (result == 2) {
  // 2の時の処理
}
else {
  // 3の時の処理
}

あとは、switch-defaultで適切な処理が思いつかなかった場合や、文字エンコーディングに"UTF-8"ってリテラルで指定したのにUnsupportedEncodingExceptionをキャッチしなきゃいけない時など。これらはあまりいい使い方ではないかも。

Error系をスロー

あまりError系(AssertionErrorを除く)をスローしたことはありません。

  • 環境からErrorがスローされた場合
    • URLClassLoaderでロードしたクラスのLinkageErrorを拾うときくらい?
  • catch(Exception)とか書いちゃうアプリケーション開発者への嫌がらせ
  • System.exit(1); の書き換え

特殊値(null, -1, など)を返す

最後に、例外をスローするのではなく、正常でないを返すパターン。

  • アプリケーションに正しい手順で要求された値が存在しない場合
    • get*, find*, lookup* みたいなメソッドが返す

こいつは、「存在しない」ということが正常な状態である場合に、それをアプリケーションが取得しようとしたけど返す値がないので代わりに特殊値を返しておきますね、と言った感じ。

運用ルールはこんな感じでやってます。

  • 特殊値を返す条件は必ず仕様として明記
    • 「@return 〜、ただし〜の場合はnull」
  • 特殊値を返す条件がアプリケーションから事前に100%予測できるなら、事前条件エラーとしてRuntimeExceptionをスロー
  • 特殊値を返す条件は1つのみ
    • 「〜または〜の場合はnullを返す」みたいなのはアウト
  • リストを返す場面で一つも見つからなければ、空のリストを返す

*1:見つからないだけかもしれないけど、parseIntの仕様には全力で言語仕様だけが書いてある

*2:ただし、どんなときでもJavadocには@throwsを書いてくれないと困る