Throwableについて本気出して考えてみた

Throwable、Exception、RuntimeException(RTE)、Errorあたりを整理しながら、色々考えてみた。私見に基づくので、間違っているかもしれないけれど、自分としては頭が整理できたかな、と感じたので晒してみる。異論があったらコメントください。

まず、一番基礎的なところで、継承関係の整理から。こんなツリーになっています。

  • Throwable
    • Error
    • Exception
      • RuntimeException

そして、本稿での用語の定義。caller=呼出す側のコード callee=呼出される側(throwする側)のコードとします。

Throwable

Throwableは「throw文に指定できる何か」という意味ですね。

Instances of two subclasses, Error and Exception, are conventionally used to indicate that exceptional situations have occurred.
通常、Error および Exception の 2 つのサブクラスのインスタンスは例外的な状況が発生したことを示すために使用されます。

Throwable (Java 2 Platform SE 5.0)

揚げ足みたいだが、Throwableが例外的な状況を表す訳でなく、ErrorとExceptionがそうであるだけ。throwは異常時にのみ使う、というのはまた別の問題領域の話であって、Throwableは単純に「throwできる」という特性のみを表す型であると考えた*1

2種類のthrow

少し話題は逸れますが、ここで一つ確認しておきたいことが。Throwableが投げられる時、投げられ方には2種類ある。アプリケーションがthrow文で投げるケースと、JVMが投げるケース。

  1. throw new NullPointerException();
  2. Object foo = null; foo.toString();

上記、どちらもNullPointerExceptionがスローされるが、意味合いが異なることに注目。前者(1)は「アプリケーション」が「nullではいけない変数にnullが入っている」事を伝える為であり、後者(2)は「JVM」が「null参照に対するメンバ呼び出しを行った」事を伝える為のスロー。

まぁ、とりあえず「アプリがthrow文で投げる場合」と「JVMが内部から投げる場合」がある、つまりアプリケーションコードだけではなくJVMがcalleeに当たるケースもある、ということが整理できれば。ちなみに、後者は必ずRTE or Errorです。JVMが勝手に投げるので、アプリケーションのレイヤでthrows句を管理できません。

Error, Exception, RuntimeExceptionの対比

that indicates serious problems that a reasonable application should not try to catch.
通常のアプリケーションであればキャッチすべきではない重大な問題を示します。

Error (Java 2 Platform SE 5.0)

that indicates conditions that a reasonable application might want to catch.
通常のアプリケーションでキャッチされる可能性のある状態を示す

Exception (Java 2 Platform SE 5.0)

Javadocによれば、ErrorとExceptionは、通常のアプリケーションがキャッチするかしないか、で概念分けがされているようですね。まぁ「通常のアプリケーション」とは一体何か?は後で論じます。

A method is not required to declare in its throws clause any subclasses of RuntimeException that might be thrown during the execution of the method but not caught.
メソッドの実行中にスローされるがキャッチされない RuntimeException のサブクラスについては、メソッドの throws 節でそれらを宣言する必要はありません。

RuntimeException (Java 2 Platform SE 5.0)

ここまでのドキュメントの引用について、Sun時代の日本語ドキュメントへのリンクが全部切れていたので、Oracleの英語ドキュメントにリンクを貼り直しました。

RuntimeExceptionは、Exceptionの中でもthrows節による宣言が必要ないもの、という定義です。

さて、定義だけじゃ、どう使うと良いのか良く分かりませんね。良い解説ページがありましたので、ご紹介。下記ページの中ではcallerは「クライアント」「アプリケーション」等と呼ばれており、calleeは「サプライヤ」と呼ばれています。

http://www.asahi-net.or.jp/~dp8t-asm/java/tips/ExceptionAgainstErrorKind.html

追記:↑は消滅しちゃってるので、internet archiveのURL載せておきます。 http://web.archive.org/web/20010727151831/http://www.asahi-net.or.jp/~DP8T-ASM/java/tips/ExceptionAgainstErrorKind.html

ここで挙げられた異常の種類を、意訳・追記した上でまとめると、以下のようになります。

種類 意味 クラス 理由
defect 欠陥 calleeのバグ or 責任所在不明 Error callerによるリカバリ不能(原因はcalleeにある or 特定不能)である為。
fault 違反 callerのバグ RuntimeException リカバリの可否はcalleeに判断不能である為。callerはリカバリ可能ならばcatchすれば良いし、不可能ならばスルーすれば良い。
failure 失敗 三者の異常 Exception(非RTE) 三者異常を想定することは必須。callerにも同様の想定を強制させ、リカバリ処理を書かせなければならない為。

ところで、上記サイトにこんな記述があります。

メソッドのインタフェースはクライアントとサプライヤの間の契約である

この文言、結構大事です。言い換えれば、calleeはcallerが契約を守る前提で動くのです。さらに深く解釈すれば、インターフェイス契約を守らないのは、callerのバグであるとまで言ってもいいかもしれません。

余談ですが、法律の世界には「人は法律を守るべきですが、他人による不法行為の可能性をも考慮する義務はない」という原則があるそうです*2。例えば善意取得。AさんはBさんからXを買いました。しかし、XはBさんがCさんから騙し取ったものであり、Bさんは有罪となりました。この場合Cさんは、AさんからXを返してもらえるでしょうか? ちなみに、AさんはBさんの不法行為(詐取)を知りませんでした。 → 返してもらえません。Aさんは「善意(詐取について知らないという意味)」であり、AさんはX取得時に "XがBさんによって誰かから詐取されたものである可能性を考慮しなかった" という責任はありません。

閑話休題インターフェイスによる契約では、callerは、必ず正しいルールに従ってcalleeを呼び出すはずだ、と信じていいのです。calleeはcallerのバグ(間違った呼び方をされる可能性)を考慮する必要は無いんですね。先ほどの「AさんはBさんの不法行為を考慮する必要がない」という話に似ていると感じました。だから何だ、というほどの話ではないですが。

まぁつまり、calleeはcallerのバグをリカバリしません*3。failureはcallerのバグを含みません。そして、callee自身は自分の実装が正しいと信じている為、failureの原因はcaller, callee以外の第三者が原因です。

言い換えれば、第三者が原因である例外はfailureであり、calleeは第三者原因の異常を常に考慮する必要があるということになるのではないでしょうか。さらに深く解釈すれば三者原因の異常を考慮しないの事は、calleeのバグである*4とまで言ってもいいかもしれません。

ここで軽くまとめ。

  • calleeのバグ → Error
  • callerのバグ → RuntimeException
  • 三者の異常 → Exception(非RTE)

具体的にExceptionを見てみる

  • IllegalArgumentException
    • 渡された引数が不正である
    • callerによるcalleeの呼び方が間違っている
    • callerのバグである → RuntimeException
  • IllegalStateException
    • calleeの状態が不正である
    • calleeは、このメソッドを呼ばれる前提条件を満たしていない
    • callerによるcalleeの呼び方が間違っている
    • callerのバグである → RuntimeException
  • ArrayIndexOutOfBoundsException(通常JVMが投げるため、このcalleeはJVMである)
    • そんなインデックスの配列要素は無い
    • index値を事前チェックすることにより回避可能なのに、回避処理を行っていない
    • O <= index < length の範囲外のindexを呼んではならない、というJava仕様に準拠していない
    • callerのバグ → RuntimeException
  • NullPointerException(通常JVMが投げるため、このcalleeはJVMである)
    • メンバ呼び出しを行った変数にnullが入ってた
    • nullチェックすることにより回避可能なのに、回避処理を行っていない
    • nullに対するメンバを呼んではならない、というJava仕様に準拠していない
    • callerのバグ → RuntimeException

ここまでは順当ですね。

  • FileNotFoundException
    • 開こうとしたファイルが見つからなかった
    • 悪いのはcallerでもcalleeでもない
    • 三者の責任 → Exception


ただし、ここでは例えば以下のような状況がありえます。

  1. ファイルを開こうとする前に、callee自身がファイルを作っているハズであり、ここでファイルが存在しないのは明らかにcalleeのバグである
  2. このメソッドを呼ぶ前提条件は、このファイルが存在することであり、ファイルが存在しないままメソッドコールを行ったのは明らかにcallerのバグである

このような時に、例外のwrap throwが行われるべきです。それぞれ、下記のようになると思います。

void foo(...) {
  create(file); // ここで必ずファイルが作成される
  try {
   file.open();
  } catch (FileNotFoundException e) {
    // にも関わらず、ファイルが見つからない!
   throw new SomeError(e); // 上記1のケース
  }
}
// fileが存在する場合しかbarを呼んではならない
void bar(...) {
  try {
   file.open();
  } catch (FileNotFoundException e) {
   throw new SomeRuntimeException(e); // 上記2のケース
  }
}

具体的にErrorを見てみる

なんか、みんな変に「Errorは投げない、キャッチしない」という印象を持ちすぎてるのではないか、と感じるなぁ。もしかしたら「ErrorはJVM以外が投げるモンじゃない」と思ってるかも?

そうではない! と主張したい。ErrorかExceptionかは、重大かどうかに視点が向いてしまっているように思えるが、より考慮すべきは、「バグを通知するものなのか、そうであれば誰のバグなのか」ではないか。

  • OutOfMemoryError
    • メモリが足りねぇ。メモリリークしてねえ?
    • とは言えメモリリークとは限らない。単にheapやpermが小さ過ぎるだけかも。
    • calleeのコード内で発生したわけだが、calleeが悪いとは限らない。
    • stackTraceにも問題点が含まれていない可能性がある。
    • 責任所在不明 → Error
  • StackOverflowError
    • スタック使い切った。無限ループしてねえ?
    • とは言え無限ループとは限らない。純粋にアプリのコールスタックが深いだけかもしれない*5
    • 無限ループだとしたら、ほぼ確実にstackTrace内に問題となる無限ループが含まれているハズではあるが、throwが起こったポイントは別のクラス内かもしれない。
    • calleeのバグ (若しくは責任所在不明) → どちらにせよError
  • AssertionError
    • assert文の評価に失敗した。
    • そもそもassert文ってのはcalleeのバグをチェックする目的以外に使うべきではない。
    • calleeのバグ → Error

以上が「JVMが投げるError」の例です。続きまして「throwで投げるErrorの例」。この例が少ないから「Errorはthrowしない」という印象になっちゃってるんだと思う。

  • HttpClientError (commons-httpclientのEncodingUtil
    • コメントにある通り、should never happen.
    • ISO-8859-1をサポートしないというのは、Java自体がJavaの仕様に準拠していないとしか考えられない。Javaではなく、Javaのオモシロ実装使ってたりしない?
    • ってそれどうしようもないやろw 具体的な原因特定できんw
    • 責任所在不明 → Error
  • SWTError (SWTのTableTree
    • 例えばaddItemメソッドはpackage privateなので内部APIである
    • calleeの内部APIを呼ぶのはcallee自身だけである
    • 内部APIを呼ぶ際の引数が間違っている=内部のバグである
    • calleeのバグ → Error

以上のように、真っ当にErrorをthrowしている例も少なからず存在します。というわけで、私は特定の条件さえ満たせば、Errorをthrowすることも問題がないと考えています。

ちなみに、以上の例を探すにあたって、様々なコードを調べましたが、特定の条件を満たさないError throwingも散見されました*6

この理屈の上では、IllegalArgumentExceptionが適切であると考えられるところでErrorをthrowしていたり、ErrorであるべきものがException(非RTE)で定義されていたり…。

まぁ、世のコードが全て完璧な訳がない、ということですよね…。

また別の視点から見てみる

Effective Java 第2版 (The Java Series)の項目62には「RTEに関しても、投げる可能性がある例外をJavadocの@throwsで明示する」という指針があります。

私は「Javadocは仕様である」と考えている為、@throwsに書いた時点でそれも仕様となります。RTEはcallerのバグですから、「callerがバグっていたらこういう応答をするよ」という仕様です。

ちなみに、callerもcalleeも、両者が完全に仕様に準拠してる場合(=バグが存在しない場合)、calleeは、Error及びRTEを絶対にthrowしません*7。仕様にないRTEがthrowされた時点で、バグですので、前提が崩れます。

つまり、仕様に違反していた場合(=バグ)に対してthrowされるのがErrorやRTEです。

では、ErrorとRTEの違いは何でしょうか。私が思うに、「どんな仕様か」によって分けられるのではないか、と考えています。

「メモリが足りなくなったらOutOfMemoryErrorを投げるよ」というのは、calleeの仕様ではなく、JVMの仕様です。また、「引数がオカシかったらIllegalArgumentExceptionを投げるよ」というのは、calleeの仕様です。

Errorはクラスの仕様で決まっているものじゃなくて、もっとメタな仕様で定められたものなんじゃないだろうか。

calleeのバグについても、(このcalleeが所属する)コンポーネント全体の仕様として「もし実装間違っちゃったらゴメン、Error投げるね」があるのではないか。

うほー 長くなったけど中間まとめ

  • Error
    • calleeのバグ発生を通知するもの
    • コンポーネントの仕様として、「自分がバグってたら投げるよ」の明示が必須
    • なぜなら、仕様外の動きをされた上に、Errorのthrowが明示されていなかったらcallerが究極に困るからw
  • RuntimeException
    • callerのバグ発生を通知するもの
    • クラスの仕様として、「変な呼び方したら投げるよ」の明示が必須
    • callerとしてはcallerのバグだが、calleeとしては仕様の範囲内の動作である
    • なぜなら、Javadocに挙動が明示されているから
  • Exception(非RTE)
    • 三者の異常であり、当事者(caller, callee)には責任が無い
    • バグ発生を通知するものではない
    • caller, callee両者にとって、仕様の範囲内の動作である
    • なぜなら、Javadocに挙動が明示されていて、かつ、catchによる処理が保証されるから

「通常のアプリケーション」とは一体何か?

まとめておいてナンですが、まだまだ続きますw ここまで考察したところで、JavaAPIのJavadocまで原点回帰します。

「通常のアプリケーション」とはなんだろうか。Errorをキャッチしないアプリな訳だが、それはどんなものだろうか。

Throwableがcatchされない場合、メッセージは次々とコールスタックを駆け降り、最終的にはmainメソッドにたどり着き、VMの終了につながる。このコールスタック上のコンポーネントの関係を「プラットホーム」と「クライアント」という視点で考えてみると、よりmainに近い側(下と定義する)がプラットホームであり、遠い側(上と定義する)がクライアントとなる。(このコンポーネントがプラットホームで、このコンポーネントがクライアントである、という定義ではなく、2つのコンポーネントの関係を表すものとします。)

catchされないErrorは、上から順に、コンポーネントを「殺し」ていきます。Errorが起きていますので、もう存続不可能なコンポーネントは是非殺すべきです。

しかし、Errorとは言えど、コンポーネントを殺しながらコールスタックを駆け降りる過程で、リカバリ可能なコンポーネントが現れる可能性があります。そのタイミングは、恐らく「クライアントを呼んだプラットホーム」に限られます。

プラットホームは、もし「障害リカバリが可能=自分以下のコンポーネントを殺す必要がない」と判断した場合は、Errorをcatchしても構わないと考えます。

例えば、Servletコンテナはプラットホーム、Servletはクライアントです。Servlet内でServletのバグを通知するErrorがthrowされました。Errorはスタックを駆け降り、Servletコンテナまでたどり着きます。この時点で、問題のあるServletは既に殺されています。

この状況で、Servletコンテナ及びそのプラットホーム(JVM)を殺す必要はあるでしょうか? Servletが異常終了したことを記録するなりユーザに通知するなりして、他のServletを生かすことも出来るんじゃないでしょうか。むしろ生かすべきではないでしょうか。

同じ例で、Eclipseはプラットホーム、EclipsePluginはクライアントです。EclipsePlugin内でpluginのバグを通知するErrorが(ry

かといって、プラットホームは全てのErrorをcatchすべき、という訳ではありません。

Servletの例で言えば、投げられたのがInternalErrorやUnknownErrorだったらどうでしょう。このErrorがthrowされた場合、JVMは既に破壊されている可能性があり、影響範囲はServletに限定できません。

また、OutOfMemoryErrorはどうでしょう。JVMの破壊こそないものの、Errorの原因は該当Servletであるとは限らず(他Servletメモリリークしていて、該当Servletはたまたま貧乏クジを引いただけの可能性がある)、やはり原因の特定はできません。(しかしまぁ、ここでコンテナを殺すかどうかは判断が微妙ですが…)

このように、ある種のプラットホームはErrorをcatchすべき状況がありえますが、その場合でも、全てのErrorをcatchしようとするのは不適切であると考えられます。

「通常のアプリケーション」とは「クライアントの異常終了をリカバリできる可能性のないプラットホーム」であり、「通常ではないアプリケーション」とは(追記)「クライアントの異常終了をリカバリできる可能性のあるプラットホーム」と言えるかもしれません。

どうだ。

ぴんぽーん(薬)

えーと、以上の考察は「Javaの仕様といくつかの実装」から導き出した主張です。しかし、例えばこの考察が正しかったとしても、考え方が一般に広く知られているとは言えない状況です。

例えばライブラリなどの「(上記で言う)クライアント」を設計するにあたって、以上の理屈に沿っていれば気軽にErrorをホイホイと投げても構わないか、というと、上記考え方としては正しいが、現実問題プラットホーム(ライブラリのユーザ)はしっかりこの考え方に則ったコーディングをしてくれるのか…? という話はあると思います。

それは、また別の次元のお話、ということでご理解ください。

いじょ。

*1:まぁ、ThrowableのJavadocを見ると、かなり「異常時」の説明がされているので、実質異常時しか想定されていないのだろうが…。まぁ、ここは「Throwableは異常を表す訳ではない」ということを主張したいんじゃなくて、概念をシンプルに整理したい、という意図です。

*2:超専門外なので、厳密な話ではありません。

*3:calleeは、自分の身を切ってまで(リカバリコードを記述してまで)callerによる "間違った呼び方" をフォローしなくて良いのです。

*4:この点に関しては、善意取得の例と矛盾しますねw

*5:まぁ、スタック使い切る程深いってのは設計として異常だが。

*6:しかもJavaAPIの中にさえも!!

*7:仕様バグがない前提で