Javaにおける検査例外の有効性と非検査例外との使い分けについて考えたこと

Java での検査例外と非検査例外の使い分けについて少し考えたときのメモ.どうやら検査例外を使う使わないについては賛否両論あるらしく,ネットで検索してみても,ブログ等でいくらか議論されていることがわかります.それらの記事を眺めながら,検査例外について考えてみます(開発経験の少ないキッズが自分が理解しやすいように勝手な解釈で書いているかもしれないので注意).

また,この記事では,ある状況を例外として表現するかどうか問題については書きません.例外を使うと選択した場合の検査例外と非検査例外のどちらを使うか問題についてだけ検討します.

まず前提として,Java コーディングにおいて,非検査例外は無くせないが検査例外は無くせるもの考えます.これは,非検査例外を無くすとコードがトンデモナク冗長に成り得るので現実的ではないことや,Java 以外の多くの言語が検査例外を採用していない状況に起因します.なので,例外処理の仕組みの基本は非検査例外であり,検査例外というのは非検査例外を使うよりも良いケースで使う発展的な仕組みであると考えます.

一方で,Java の検査例外はいくらか問題(後述)も孕んでいるので,滅多矢鱈に使うのはよろしくなさそうです.したがって,使う場合には「非検査例外を使うよりも良いケース」を見極めることが重要になると思います.ということで,そもそもそのようなケースが存在するのかどうか,存在するのであればどのようなケースか,ということに興味があるわけです.

このメモではまず検査例外の良いところと悪いところを確認してから,検査例外のいくつかの運用方法を紹介し,そのトレードオフについて見ていきます.

先に結論

を書くと,「プログラミングで回避できない」かつ「呼び出し元で例外を処理できる」ケースが,検査例外を使う適切な場面であるということです.勿論これは僕にとってはそうだというだけの話で,絶対的な正解ではありません.そんなものはそもそも存在しないという前提で,自分にとってベストな方法を見つけるための指針を示せればいいかなという感じです.

Java の検査例外と非検査例外の実用上の違い

まず,非検査例外と比較したときの Java の検査例外の特徴を挙げておきます.

  • 検査例外はコード上で文書化(仕様化)され,そのコンパイラにその正当性が保証される
  • 検査例外はメソッドの呼び出し元に例外の対処を強制する(捕捉or伝播)

これらの特徴が検査以外の良い面でもあり悪い面でもある,ということについて次に書きます.

検査例外のいいところ

検査例外を使うにあたっては,その利点難点を理解しておく必要があると思いますので,はじめにそれを書きます.

文書化(仕様化)能力

例外仕様は文書化されていた方が良い,と考えている方は多いのではないかと思います.何らかのメソッドを使う際にはその例外仕様について把握したいところですが,それが何も明文化されていない状態は利用側にとって厄介です.

[1]では,検査例外がコード上で例外の情報(メソッドの振る舞い)を文書化できることを主張しています.この文書化はメソッドの throws 節で表現されます.また,コンパイラがその正当性をチェックします.このため,「throws に書いている検査例外が実際には発生し得ない」もしくは「発生し得る検査例外が throws に書かれていない」という状況が発生しません.これを静的に検査し,保証できるのが Java の検査例外の強みの一つと言えます.

一方で非検査例外はコード上で明文化されないため,Javadoc などの補足資料だけを使って例外仕様を文書化することになります.例外の対処も強制されないので,あるメソッド内で発生し得る非検査例外をすべて把握し,明文化するのは困難です.このため,非検査例外の積極的な使用は,予期せぬ例外によってプログラムが暴走する事態を起こしやすくします.

また,非検査例外は Javadoc とコードの対応関係は検査されず,Javadoc に書き忘れがあると文書化に漏れが発生することになります(非検査例外での文書化も checkstyle 等である程度はサポート可能[2]らしいですがやはり完璧ではなさそう).併せて,投げる例外が変更された場合は Javadoc も書き換える必要がありますが,ここでも書き換え忘れによる仕様書バグを生む可能性があります.このように,非検査例外ではコードと文書にズレが生じてしまうことも考えられます.検査例外はコード上での文書化が強制され,正当性が検査されるので,その点は健全であると言えます.

ということで,検査例外は非検査例外に比べて文書化能力に長けています.

エラー処理の強制能力

Java の検査例外は,メソッドの呼び出し元にその対処を強制します(catch するか更に上に throw するか).[3]では,メソッドで例外が発生した際の後処理を書き忘れることができなくなることを検査例外の利点としています.ここで「後処理」とは,何らかの例外回復処理や,安全のためのクリーンアップ処理などのこととします.

例えば例外が発生した際に,リソースの close 処理や,バックグラウンドで密かに動いていたスレッドの停止処理などをしないと,リークが発生するかもしれません.デスクトップアプリではプログラムが緊急停止する前に窓を閉じる必要があるかもしれません.回復処理で言えば,何らかの一時的な理由により DB 接続に失敗した場合にはリトライをした方がいいかもしれません.

というように,一般的に例外が発生したら何かしらその対処が必要であると考えると,その処理を強制して忘れられないようにする仕組みは堅牢で良さそうに思えます.また,エラー対処を強制されることで,常に例外発生時の挙動について意識しながらコーディングできるようになります.

一方で,非検査例外では,メソッドが例外を投げる可能性をコード上で明示できないので,例外発生時のハンドリングを忘れやすくなるでしょう.例外仕様について頑張って Javadoc に示しても,読まれなければ効果はありませんし.安全性を考えた非正常時の考慮の強制という意味では,検査例外は Optional とかに近い考え方かもしれません.

ということで,検査例外を使うと基本的には安全性・堅牢性を高められると言えます.

検査例外のよくないところ

仕様変更時に面倒くさいことになる(文書化能力の副作用?)

これはよく「検査例外は開放/閉鎖原則に違反する」とか言われているやつです[4-7].検査例外で必須の throws 節はメソッドシグネチャの一部です.これは例外が伝播する経路上のすべてのメソッドが書くことになります(捕捉しない限り).

このような状況で例外を投げる大本のメソッドに仕様変更があり,投げられる例外が変わったり,新たな例外が投げられることになったりすると,その経路上のメソッドシグネチャすべてに変更を適用しなければなりません.勿論捕捉コードがあればそちらも変更することになるでしょう.

この状況が開放/閉鎖原則に違反していると言えるのかどうかはさておき,仕様変更による修正箇所の範囲がそれなりに広いことは事実だと思います.非検査例外でも捕捉コードがあればそちらに影響があることも事実なのですが(まあこれは一般的な戻り値でも同様),やはり throws を書かなければならない検査例外の方がこの問題が大きくなることは想像が付きます.

これは throws に具体的な型を記述する慣習に起因するかと思います.ぶっちゃけすべて Exception で宣言してしまえば throws 節の変更は必要無くなるわけですから.

変更に強くするためにできるだけ抽象化しようというのがオブジェクト思考プログラミングの基本思想ではありますが,例外処理ではこのような極端な抽象化は悪とされ,そのハンドリングのために具象化が好まれています.しかしながら,具象化しすぎると変更に弱くなりすぎるので,適度に抽象化しようというような話もあります[5,8].このあたりにも検査例外設計の難しさが潜んでいます.

ちなみに Swift にも throws 節があるらしいですが,型の明示は無いっぽいです(現在でもそうなのかは未調査)[9].処理の強制や throws 節はあるみたいです.この辺りの問題の対処(妥協点の一つ)かもしれません.

まとめると,検査例外を使うと変更耐性が落ちる可能性が高まるということです.

冗長化,複雑化する(エラー処理の強制能力の副作用)

エラー処理の強制という安全を意識した思想自体は避難されるようなものではありませんが,代償として副作用が生まれます.で,その問題を象徴するコードに,catch に何も例外の対処処理を書かないというものがあります[11].ここには2つの問題が潜んでいます.

1つは,何らかの対処が必要な例外の握りつぶしです.例えば,catch で例外を対処してプログラムの暴走を止めなければならない状況であるのにも関わらず,catch に何も書かないという場合です.クリーンアップやログ化など,やるべき処理があれば書かなければなりませんが,検査例外は特定の処理の実装までは強制できません.できるのは捕捉か伝播かの選択の強制だけです.

初心者のプログラマがこのような状況で例外を握りつぶしてしまうと,いざ問題が発生した時の原因特定が厄介になったりしそうです.これは結構色々なところで言われている気がします.非検査例外を使っていれば,そもそもその存在に気づかれず,catch されずに最低限のエラーの報告は達成されるはずです.意図的に catch するケースで握りつぶすなんてことはないはずですし.

もう1つの問題は,「必要のない処理の強制」です.上の状況では後処理を書かないプログラマが悪でしょう.しかし,そのような後処理が必要ないのにも関わらず try-catch を迫られるケースというのも少なからず存在するように思います.プログラマの気持ちとしては「別に回復処理なんて要らない状況だけど書かなきゃコンパイルが通らないからとりあえず対処する」という場合です.これは圧倒的無駄です.

必要な処理を迫ることは正しい強制と言えそうですが,この場合はそうではありません.この際,捕捉での握りつぶしか伝播かを選択すると思います.もしくは非検査例外でラップして投げ直すかもしれません.どちらにしても冗長な記述が生まれ,コードの見通しが悪くなります.

このように,検査例外を使うと無駄なエラー処理の強制によってコードが冗長化・複雑化する可能性が高まります.また,そのような処理を面倒臭がったプログラマが後処理が必要な局面で例外を握りつぶしてしまい,問題の原因の特定を難しくすることも想定できます.

検査例外と非検査例外のどちらを使うか

これは結局何を大事にしたいかによるかと思います.文書化を第一に考え,上に挙げた検査例外の難点が気にならないのであれば極力検査例外を使うことになりますし,仕様変更時の影響範囲が大きいことを気にするのであればすべて非検査例外を使えばいいと思います.

もしくは,エラー処理の強制による堅牢性と,その冗長性のトレードオフで考えることもできます.ほぼ検査例外だけを使うようにすれば堅牢なプログラムを作ることができそうですが,コーディングは冗長で面倒なものになりそうです.一方で,非検査例外だけを使えばスムーズなコーディングができそうですが,堅牢性は落ちるでしょう.

が,ここまで書いてきたことを見ると,このトレードオフについてはどちらか一方の極端な選択だけでなく,間を取るようなこともできそうな気がしてきます.検査例外の堅牢性を活かしつつも冗長化は抑えたいという欲求です.検査例外の利点を活かしつつも難点を殺すような使い方ができれば,理想的かもしれません.

以降では,その間を取るような検査例外の使い分け法として有名なものについて書きます.これには2つあって,「プログラミングで回避できるかどうか法」と「呼び出し元で処理できるかどうか法」です.そしてその次に,あまり推されていないこの両方を組み合わせる方法について書きます.ちなみにこれらは文書化や開放閉鎖原則云々よりも,エラー処理の強制の利点を活かしつつも try-catch の冗長性と抑える方法な印象です.

プログラミング(事前処理)で回避できるかどうか法

プログラミングで発生を回避できる例外であれば非検査例外,回避できない例外であれば検査例外とする方法です[12].エラー処理には事前処理(例外が発生する前に対処)と事後処理(例外が発生した後に対処)の2つがあると思いますが,プログラミングで回避できるかどうかということは,つまり事前処理で回避できるかどうかを意味します.

で,事前処理で回避できない問題は必ず発生し得えます.例えば FileNotFoundException は事前にファイルの存在チェックをやれば回避できるわけではありません.チェック後にファイルが消える可能性があるので,いくら入念にチェックしても発生する可能性があります.

すなわち,このような例外は事後処理でしか対処できません.よって,もし事後処理を書いていなければ,エラー処理をしていないと断定できます.「エラー処理は絶対しなければならないものである」という前提で考えると,これをプログラミングミス=コンパイルエラーとし try-catch による事後処理を強制するのは妥当だ(無駄ではない),という考え方の方法です.

一方で,事前処理で回避できる例外までも検査例外にするとどうなるでしょうか.事前処理で簡単に例外を回避できるケースや,例外が発生しないことが自明なケースでも,事後処理を強制されます.したがって,無駄な記述が増えたり分岐の複雑性が増したりしてしまいます.つまり,すべての例外を事後処理で対処するのは問題なので,事前処理によるハンドリングの道を残しつつ,その中で確実にエラー処理をしていないと断定できるケースでのみ事後処理を強制しようという話です.

事前処理による例外回避等のハンドリングはコンパイラがチェックできないので,強制されることはありません.なので,書き忘れが発生する可能性があります.このため,極力検査例外を使う方法に比べると堅牢性に欠けるかもしれませんが,冗長性は減らせるはずです.また,非検査例外しか使わない方法に比べると,検査例外の安全性を活かせる方法と言えます.

呼び出し元で処理できるかどうか法

呼び出し元が投げた例外を呼び出し元が処理できるなら検査例外,できないなら非検査例外とする方法です.try-catch による冗長性は「必要のない処理を迫ること」から生まれます.したがって,呼び出し元が処理を必要とするかどうかで決めれば,無駄がなくなるという考え方です.

「プログラミングで回避できるかどうか法」はエラー処理は絶対必要であるという前提で考えるのに対し,こちらの方法ではエラー処理は不要かもしれないという前提で考えます.つまり,この方法での関心事は「後処理の強制が無駄になるかどうか」だけです.なので,「プログラミングで回避できるかどうか法」よりも更に try-catch の冗長性を抑えたいという志向の方法と言えるかもしれません.

この基準で判断する場合には,呼び出し元が後処理を必要とするかどうかを想像しなければいけません.しかし,呼び出し元が後処理を必要とするかどうかの知識を例外を投げる側が持っているとは限りません.また,状況によっては後処理が必要だろうけど不要なケースもある,というような中途半端な場合もありそうです.

すなわち,「プログラミングで回避できるかどうか法」よりも選択基準が曖昧になりがちで,設計者によって判断が変わる可能性が高いです.どんな状況でも確実に後処理が必要だと思える例外だけ検査例外にする方法や,回復の可能性が少しでもあれば検査例外にする方法など,色々考えられます.

このようなことから,考え方は単純でも実際やろうとすると難しい方法と言えます.このため,判断ミスにより,検査例外を使っておけば後処理を書き忘れなかったであろう状況で非検査例外を選択してしまい,何らかの予期せぬプログラムの停止が発生したりする可能性は高まりそうです.ただ,この例外が出たら後処理をしても無駄だろう,という状況で検査例外を選択しなくなるので,堅牢性は落ちるが冗長性は抑えられる判断基準だと言えます.

しかし,この方法では先の方法と違い,事前処理で対処可能な例外も検査例外とする可能性があります.事前処理で対処可能なのに後処理を強制される状況というのは,前述のとおり冗長化に繋がります.したがって,この方法には冗長性を抑える部分と増加させる部分が存在するということです.

上記2つの組み合わせ法

上に書いた2つの方法は排他的ではなく,両方の篩で判断する方法もあります.まず事前処理で対処可能かどうかを見て,可能であればそれはプログラマの責務とする.可能でなければ後処理が必要かどうかを考え,必要なら検査例外にしてそれを強制する.必要なければ非検査例外とする.というようなフローです.

これにより,「プログラミングで回避できるかどうか法」の持つ問題である「無駄な処理を強制してしまう冗長性」と,「呼び出し元で処理できるかどうか法」の持つ問題である「事前処理可能な例外に対する後処理の強制による冗長性」が解消されます.

トレードオフとしては,「プログラミングで回避できるかどうか法」を①,「呼び出し元で処理できるかどうか法」を②とすると,
←堅牢 ①,②,①+② 非冗長→
という感じでしょうか.①+②の方法はここまで書いた理由から,かなり無駄のない選択と言えるでしょう.で,Effective Java には以下のようなことが書いてあります.

API の適切な使用では例外状態を防ぐことができなく、かつ、その API を使用しているプログラマが例外に直面した時になんらかの有用な処理をできる場合に、その負荷は正当化されます。この両方が満たされない限り、チェックされない例外の方が適切です。

ここで言う「負荷」とは,例外処理の強制,捕捉もしくは伝播させなければならない状況によるもののことです.また,「APIの適切な使用で例外状態を防ぐ」という部分は,この記事で言うところの①の話で,「その API を使用しているプログラマが例外に直面した時になんらかの有用な処理をできる場合」というとは,この記事で言うところの②の話です.

つまりこれは,①+②が検査例外を使う最も納得感のある方法であると言っています.無駄が無く,かつ検査例外の強みも活かしている方法です.大袈裟な言い方をすれば,その冗長性が正当化され,検査例外がプラスにしか働かない状況ということです.検査例外の難点を最大まで抑え,利点を活かせる状況なのです.

実際には,先に上げた開放閉鎖原則の話や,「API を使用しているプログラマが例外に直面した時になんらかの有用な処理をできる場合」を判断する難しさがあるので,絶対に上手くいくとは限らないのですが,少なくとも考え方としては一番妥当だと感じます.

結論

徒然なるままにだらだらと書いていたら何が言いたいのかよくわからない感じになってしましましたが,最後に書いたように,僕が思う検査例外を使ったほうがいいケースは,①+②の篩を通った場合だということです.[13]にも同じような基準が書いてあります.検査例外を使い分けようとしている時点で主な関心事は「冗長性や複雑性の排除」なはずなので,であれば一番それを実現できそうなケースで使うのが妥当かなという印象です.

ただ,トレードオフの問題でもありますし,変更耐性という少し別の観点での問題も存在するので,これが正しいと言える理由はありません.個人的に一番納得感のある方法だと思っただけです.ぶっちゃけ万人にとって最も優れている方法なんていうのは存在しないはずなので,自分が一番納得できる方法を使うのが一番かなあと思います.

契約による設計とかの話はよく理解していないので,そのあたりを理解すればもう少し別の視点で書けたのかもなあと思います.だいぶ長くなってしまって推敲する気も起きないので色々矛盾とかあったらサーセンという言い訳を書いておわりにします.

参考

[1] : https://www.ibm.com/developerworks/jp/java/library/j-jtp05254/
[2] : http://checkstyle.sourceforge.net/config_javadoc.html
[4] : https://qiita.com/irxground/items/614cac8e6c50d0a80652
[5] : http://bleis-tift.hatenablog.com/entry/20090809/1249825777
[6] : http://d.hatena.ne.jp/SiroKuro/20090809/1249838522
[7] : http://d.hatena.ne.jp/j5ik2o/20091017/1255799827
[8] : http://d.hatena.ne.jp/j5ik2o/20100116/1263658938
[9] : https://qiita.com/Kokudori/items/0fe9181d8eec8d933c98
[10] : https://qiita.com/koher/items/e4c1d88981291c35d571
[11] : http://www.mindview.net/Etc/Discussions/CheckedExceptions
[12] : https://qiita.com/yuba/items/d41290eca726559cd743
[13] : http://d.hatena.ne.jp/kmaebashi/20100114/p1