検査例外はJavaで導入された例外に対する興味深い機構です。Javaで導入された当初、検査例外は多くの人々に素晴らしい解決策だと思われました。しかし、最近のメジャーなJVM言語で検査例外をサポートする言語はありません。JVM言語でなくとも、メジャー言語で検査例外をサポートするプログラミング言語は2014年現在1つとして存在しません*1。
何故検査例外は普及しなかったのでしょうか。結論から言えば、Javaの検査例外を用いた例外設計があまりにも難しく、ほとんどの組織にとって設計コストがペイ出来なかったためだと思われます。Javaの検査例外は例外というレイヤーで各々の処理を結びます。この時、別々の抽象度をある単一の例外で結んでしまうと、問題が発生します。つまり、抽象度の異なる処理同士を結びつけるたびに、その差異を吸収する独自例外表現が必要になります。この設計判断は不可能ではないものの、かなり難しくコストがかかると言わざるを得ません。
先程からJavaのと付けているように、この記事ではJavaで実装されている検査例外と、より一般化した検査例外の話を明確に区別しています。ここではまずJavaの検査例外の問題点を列挙し、いかにJavaの検査例外がプログラマに負担をかけているかを明らかにします。その上で、検査例外一般の特徴、Javaの検査例外との差異を考えます。最後に、検査例外が今まで考えてきた例外に関する諸概念とどのような関係にあるかを考えます。
Javaの検査例外
Javaの検査例外は例外を処理の仕様として扱うことから出発しています。そのため、処理には発生しうる例外の種類がthrows句にて宣言されています。さらに進んでいる点として、宣言された例外が本当にその処理から発生するのか、宣言された例外以外の例外は発生しないのかをJavaコンパイラは静的に検査します。
この時の例外検査アルゴリズムは今までに述べた例外回復の考えが現れています。つまり、例外が発生した場合、回復するか通知するかのどちらかです。Javaでは例外が発生しうる処理を呼び出した時、try/catch構文を用いて例外を回復するか、thorws句にてその例外を上位レイヤーに通知するかのどちらかをプログラマに求めます。
Javaの検査例外のメリットは何と言っても静的に検査されるという点です。多くの言語や機構は例外を実行時のものとして扱っており、実際に発生するまではどのような例外が発生するのか、そもそもこの処理から例外が発生しうるのかがわかりません。特に、Javaの場合は例外の種類までわかるため、例外回復に多くの期待が持てます。
ここまで見ると、Javaの検査例外は良い物のように見えます。以下ではJavaの検査例外に対する代表的な批判を見ていきましょう。
開放/閉鎖原則
Javaの検査例外に関する批判としては、開放/閉鎖原則に関するものが目立ちます。そのため、まずは開放/閉鎖原則について考えましょう。
開放/閉鎖原則は『オブジェクト指向入門』[1]にてBertrand Meyer氏が提言した、ソフトウェアの機能拡張に対してコードの変更を最小化しつつ、その他の変更は容易に行えないようにすべきという原則です。これはソフトウェアは変更に対して安定しているべきだが、ユーザーの利益となる変更の受け入れは積極的になされるべきだというジレンマの現れです。Meyer氏は開放/閉鎖原則を支援する機構として多重継承を提案しましたが、今日では多重継承はあまり使われていません。そのため、現在ではインターフェイスを用いた多相的な抽象化を指す言葉として用いられているようです。しかし、開放/閉鎖原則は原理原則でしかなく、それを支援する機構とは別である事に注意しなければなりません*2。
検査例外が開放/閉鎖原則と衝突する典型的な例として、Javaで書かれたデータストレージクラスを考えてみましょう。
class DataStorage {
File storage = File.initialize();
public Data read(string key) throws FileException {
return storage.find(key);
}
}
あるチームはとても小規模で、初めはゆっくりサービスを展開し、ユーザーもそれほど積極的に増やすつもりがありませんでした。コードにもそれほど品質を求める余力がなく、ストレージはローカルのファイルシステムが使われていました。しかし、幸か不幸か、そのチームが開発したサービスが大流行してしまったので流石にファイルだと色々と問題が起き始めました。そこで、チームはストレージをファイルからデータベースへと移行する事にしました。DataStorage
クラス、バージョン2です。
class DataStorage {
DB storage = DB.initialize();
public Data read(string key) throws DBException {
return storage.find(key);
}
}
おっと、thorws句の例外が変わってしまいました。これではDataStorage
クラスを使っていた全ての場所で変更が必要です。
このような変更について、Robert C. Martin氏は以下のようにJavaの検査例外を批判しています。
チェック例外(筆者注:この記事での検査例外のこと)の代償は、開放/閉鎖原則に違反する点です。もしもあるメソッドでチェック例外をスローし、3レベル上の呼び出し元でキャッチした場合、そのキャッチとの間にあるメソッドすべてのシグネチャに例外を追加しなければなりません。これは下層の変更が、より高いレベルのシグネチャ変更を強制することになります。 [2]
しかし、設計の変更をもって開放/閉鎖原則に違反しているというのは乱暴です。例えばDataStorage
クラスの場合は、元々の設計がそもそも拡張に対して開いていません。DataStorage
クラスはデータストレージに関する知識と実際のストレージ先の知識(例えばファイルシステムだとか)を吸収し、上位層に対して実際のストレージ技術の知識を知らずともデータストレージが行えるためのものだと考えられます。にもかかわらず、例外の表現が実際のストレージ技術に依存していては、せっかくの抽象化が台無しです。結局のところ、処理だけ抽象化しても例外表現が抽象化されていなければ意味がありません。
今回の変更は、設計が開放/閉鎖原則に違反していたことが元々の問題です。開放/閉鎖原則に違反していたのはJavaの検査例外ではなく、設計そのものです。それをもってJavaの検査例外を批判することは筋が通りません。
DataStorage
クラスは以下のように記述するべきでした。
class DataNotFoundException implements Exception {
public DataNotFoundException(String message) {
super(message);
}
}
class DataStorage {
DB storage = DB.initialize();
public Data read(string key) throws DataNotFoundException {
try {
return storage.find(key);
} catch (DBException e) {
if (e.canResolve) {
/* ストレージ固有のエラー回復が可能であれば行う */
/* DataStorageクラスはストレージの知識を有しているのでこのcatchは問題ない */
} else {
/* エラー回復が困難な場合、ストレージを適切に抽象化した後、例外を投げる */
/* この例外を投げてもエラー回復は望めないが、
ストレージについて知っているDataStorageが回復できないエラーを上位層が回復出来るはずがない */
/* エラー回復が出来ない場合は、例外中立に基づいて適切な例外を投げなければならない
たとえ上位層にこの例外のエラー回復が見込めなかったとしても例外中立は達成されなければならない */
throw new DataNotFoundException(key);
}
}
}
}
これで、処理と例外に対する抽象化の非対称性は解決されます。コメントにも書いたように、例外中立の保証とエラー回復の見込みは独立です。さらに言えば、DataStorage
が解決できなかったストレージ固有の問題を、より上位層が解決できた場合、設計レベルで問題があります。それは、DataStorage
がストレージを完全に抽象化出来ておらず、抽象化が漏れている証拠に他ならないからです。つまり、DataStorage
クラスから発生する例外は原則として通知のみを目的としており、例外回復は目的とされません。
Javaの検査例外は開発の全てのフェーズにおいてこのレベルの設計判断を要求します。このような単純な例の場合はそれほど難しくもありませんが、開発途中であり今後どのような機能が提供されるかわからない状況でも適切な抽象化を行わなければならないことは容易ではありません。もし抽象化に失敗した場合、拡張に対してコードは破壊的に変更されます。
さらに、この例の場合はストレージを変更する必要にかられましたが、本当にストレージが変更されるかどうかは関係ありません。Javaの検査例外は変更の可能性のある全ての構造に対して、外部との結合の抽象化を要求します。Javaの例外機構を使用する限り、例外の抽象化は常に必要です。それが本当に組織に利益をもたらす抽象化になるかとは独立です。
設計は進化する
Javaの検査例外は既に開発が終了された、枯れたソフトウェアとの相性は最高です。つまり、Javaの検査例外はこれ以上になく閉じています。しかし、抽象化レベルを誤ると、破壊的な変更なしには2度と開いてくれません。
残念ながらソフトウェア開発の多くは開発中に設計が進化します。これは要求そのものが開発中に進化しているからです。ソフトウェアの要求が進化すること自体は一般に良い事です。それは、ソフトウェアがユーザーに使われている何よりの証拠だからです。プログラミング言語の機構がソフトウェアの進化との相性が悪い事は本末転倒です。
さらに、市場の変化に伴い、ソフトウェアの要求は進化の速度が速く、ある要求と次の要求との期間が短くなっている傾向にあります。この時、1つ1つの要求に対応するための設計にコストが掛かってしまえば競合サービスとの競争に負けてしまうかもしれません。
当然のコスト
しかし、こうも考えられます。元々例外機構とはこのようなコストを前提にしています。C++でもC#でもPythonでもRubyでもOCamlでも、この適切な抽象化の問題は例外機構を兼ね備えた言語全てに言える問題です。ただ、Javaの検査例外はその当然払ってしかるべきコストを本当に払うよう要求しているだけです。繰り返しになりますが、Javaの検査例外を用いて開放/閉鎖原則が破られたとしたら、開放/閉鎖原則を破ったのはJavaの検査例外ではなくあなたの誤った抽象化によるずさんな設計です。Javaの検査例外は、そのずさんさを教えてくれただけに他なりません。
こう考えた場合、本当に設計コストを圧迫しているのは検査例外ではなく例外機構そのものであると考えることが出来ます。これは非常に重要な考えです。検査例外に関する既存の議論では、どこからが検査例外固有の複雑さで、どこまでが例外機構そのものの複雑さかをあまり明確に区別せずに論じているものが多くありました。例外機構そのものの問題についてはここでは深く追求しませんが、非常に重要なテーマですので日を改めて考えましょう。
最後に、このコストは静的型付け/動的型付け論争に似ている部分があります。静的型付け陣営は型と設計の関係を指し、型を考えることは払うべき当然のコストだと主張します。動的型付け陣営は型の表現力や、静的に型を付けなくとも動作するソフトウェアを指し必ずしも必要とは限らないと反論します。しかし、型の論争とは違い、検査例外陣営は現在一方的に劣勢です。
Java固有の問題
今までの批判はJava固有の問題と相互に関係している部分も数多くあります。独自例外の作成が簡単ではない点です。
Javaでは独自例外を作成する際にクラスを定義する必要があります。そして、Javaではクラスを作成すると新たにファイルを作成する必要があります。これまでに見たように、Javaでは独自例外は異なる抽象の境界面において必然的に必要になります。その度に新しくファイルを作り新しく例外クラスを作るとなると、より重要な他のクラスが埋もれてしまうと感じるプログラマが居ることは不思議ではありません[3]。
また、記法の冗長性についても問題にされる事があります。Javaの検査例外のアルゴリズムを考えるに、throws句の宣言は省略可能です。何故なら、Javaはその関数内で呼ばれる関数を静的に知ることが出来、呼ばれる関数の全てのthorws句を知ることが出来るためです[4]。
検査例外
ここまでJavaで実装された検査例外について見てきました。ここではJavaの検査例外のどこまでが検査例外として必要不可欠な機能で、どこまでがJava固有の実装であり本質的に検査例外機構として必要ないものなのかについて考えます。そこで、検査例外として必要不可欠な機構を考える際に、検査例外の目標を達成しうるかどうかを観点に考えたいと思います。そのため、まずは検査例外の目的についてはっきりさせておきましょう。
また一般化した検査例外において、今まで考えてきた例外に関する諸概念との関係を考えます。
検査例外の目的
検査例外機構の目的は、大きく2つに分けることができます。
1つ目は「発生しうる例外の表明」です。もし発生しうる例外の種類が表明されていれば、例外回復の精度や可能性の向上が見込めます。また、発生しうる例外が1つも表明されていなければ、その処理がno-fail保証を満たしていることが静的に判断可能です。
2つ目は「発生しうる例外の保証」です。例外の表明のみが目的であれば、コメント等で記述するだけで十分可能です。しかし、そのような自己申告スタイルは信頼性に難があります。そのため、ある処理が発生しうる例外として表明された例外は全て発生しうるのか、そして、それ以外の例外は決して発生しないのかを保証する機構が求められます。この保証機構こそが、検査例外をユニークなものにしています。
つまり、それ以外の機能は個別の実装問題だと考えることが出来ます。例えば、次に述べるJavaの検査例外の「検査例外と非検査例外の区別」は検査例外一般の問題ではありません。また、例外がクラス機構に立脚している必要性もありません。さらに、throws句を明示的に記述させる必要も無ければ、検査例外の対処としてcatchもしくはthrows句への明示の2択を迫る必要もありません。例外回復か通知かという選択さえ迫れれば、実際の方法は実装の問題だと考えられます。
非検査例外
Javaの検査例外と一般化された検査例外の最大の差として、Javaでは非検査例外が認められています。非検査例外とはその名の通り、検査例外での検査アルゴリズムの対象外となる例外です。これはJava固有の問題です。Javaではいくつかの非検査例外が定義されており、プログラマも自ら非検査例外を定義可能です。
何故Javaは非検査例外を導入したのでしょうか。例えば、ヒープメモリ不足により、オブジェクトにメモリを割り当てることが出来ない場合に発生するjava.lang.OutOfMemoryError
を検査例外だと考えてしまえば、あらゆる関数のthrows句にjava.lang.OutOfMemoryError
を明示する必要があるからです。しかし、Javaが非検査例外の作成をプログラマに開放した事はどうでしょう。
非検査例外としての独自例外の使用例として、java.lang.AssertionError
があります。名前の通り、アサーションに失敗した時に投げる例外ですが、これが検査例外だとアサーションの意味がなくなってしまいます。アサーションは実装が仕様を正しく表現している事の表明であり、前日までの契約も一種のアサーションです。そのため、実行時にアサーションによる検査をオプショナルなものに出来る必要があります。java.lang.AssertionError
自体は独自例外ではありませんが、応用例の1つとして考える事が出来ます。もしJavaがアサーションを提供していなくとも、非検査例外さえ独自に定義できればアサーションが実現できます。
では、java.lang.AssertionError
や、その他考えうる限りの非検査例外を言語が事前に提供した場合、それでも非検査例外は必要なのでしょうか。言い換えると、言語が考えうる全ての非検査例外を事前に想定する事は可能なのでしょうか。答えはわかりません。少なくとも、Javaは不可能だと考えたようです。
未来の検査例外
さて、検査例外を一般化した上で、Javaの検査例外で問題とされた、大き過ぎる設計コストをどうすれば良いかについて考えてみましょう。とは言え、先程も少し触れたように、設計コストが大きくなる要因は検査例外というよりもむしろ例外機構そのものにあります。そのため、検査例外が既存の例外機構の上に乗っかり続ける限りにおいては、根本的な解決には至らないと考えます。しかし、根本的な解決ではないもののプログラマの負担を軽減させる検査例外があれば現実的には十分かもしれません。
throws句の省略は手っ取り早い負担軽減策です。前述のとおり、throws句はコンパイラが完全に推論可能です。しかし、問題もあります。異なる抽象化レイヤーではアプリケーションレベルの独自例外を持つ必要があります。throws句が明示されていればわかりやすいですが、省略されていれば見落としやすくなるかもしれません。throws句の省略についてはいくつかのツールアシストが考えられます。IDEの様な、言語を意味レベルで理解するツールの場合、省略されたthrows句を表示する事は可能でしょう。読み手が得られる情報をそのままに、書き手の負担を下げる仕組みとしての「省略」は、ML由来の型推論を筆頭に近年注目されています。新しく検査例外を搭載した言語を開発する場合、throws句の省略は候補に上がるものとしては自然に思えます。
また、独自例外の作成コストを下げる事も考えられます。Javaの場合は1クラス1ファイル制約により極端に作成コストが大きかった事があります。一行程度で簡単に独自例外が作成可能なら、より積極的に抽象度を分離しようとするバイアスを与える事になるかもしれません。
より発展的な仕組みとして、異なる抽象化の境界面を言語が認識するのはどうでしょう。大抵の場合、アプリケーションレイヤーとIOは抽象度が異なります。その考えで行けば、クラスがIOを直接保つ場合は、先ほどのDataStorage
クラスの様なIOそのものを抽象化したい一部のクラスを除けば、異なる抽象度の境界面だと自動的に考えることができます。そうすれば、アプリケーションレイヤー内でIO系の検査例外を投げる事そのものをコンパイラが警告ないしエラーに出来ます。DataStorage
クラスのような特別なクラスには特別さを表明する何らかのアノテーションを指定を義務付ければ良さそうです。しかし、IOのようなわかりやすい例以外には適用できない方法です。
例外安全
既に述べたように、検査例外においては、例外を1つも表明しない処理はno-fail
保証を満たしていると考えることができます。そして、基本保証は契約の考えで言う不変条件の保証ですので、不変条件を満たすようプログラマが努力すればそれほど難しい問題ではありません。さて、強い保証はどうでしょう。
残念ながら検査例外でも強い保証に対しては無力です。しかし、ここである取り決めを考えます。「いくつかの例外は、その例外が発生した際に強い保証を満たさなければならない」と決めておきます。そうすれば、そのような例外が表明された処理は強い保証が満たされます。しかし、1つでも強い保証を約束しない例外が表明されていると、その処理は強い保証を約束することはできません。
ここで、Javaを題材にそのような強い保証を約束する例外としてStrongException
クラスを仮定します。強い保証を満たすべき例外は全てStrongException
クラスを継承する必要があります。もちろんこの取り決めはプログラマの誠意に依存しています。また、強い保証は簡単に満たせないため、StrongException
に対する設計コストは大きなものになります。これでは検査例外がますます非現実的なものとなってしまう恐れがあります。
さて、StrongException
を仮定すれば、検査例外と契約や例外安全は以下の様な関係にあります。
例外安全 | 満たされている状況 |
---|---|
基本保証 | 事前条件が満たされている |
強い保証 | 表明された全ての例外がStrongException を継承している |
no-fail保証 | 表明された例外が1つもない |
契約による設計
契約による設計では、例外とは契約の違反を意味していました。つまり、契約と検査例外を同時に導入すれば、どの契約を違反すればどういった例外を発生するかを表現できます。
しかし、例外が適切に表現されていれば、このような対応関係を処理の呼び出し側が知る実際上の利益があるのかは疑問です。ただ、実装側は実装の目安になるでしょう。処理を実装する前にその処理の契約を考えます。そして、どの契約からどういった例外が発生するのかを考えます。この時、契約が表現している抽象と例外が表現している抽象が大きく異なっていれば、いずれかの抽象化に問題があるサインです。あとはその契約を満たすように処理を実装していくだけです。
もっとも、契約と例外の関係は一対一に対応するとは限りません。いくつかの契約がある1つの例外を発生させることは十分考えられます。
まとめ
ここではJavaの検査例外から始まり、その意義と難しさについて考えました。さらに、検査例外を一般化し、検査例外の果たすべく目標とそのための機構について整理しました。最後に、例外安全や契約との対応関係について見てきました。
検査例外は理念そのものを批判されることがありますが、そうではありません。検査例外そのものが開放/閉鎖原則を違反することはありません。検査例外は開放/閉鎖原則を文字通り検査しているに過ぎません。しかし、そのコストの大きさからJava以外では全くと言って良いほど使われていません。
検査例外機構そのものを使用するかどうかに関せず、検査例外について考えることは有益だと思います。検査例外機構のないプログラミング言語を使用していても、例外が適切に抽象化されているかどうかを考えることは重要だからです。
しかし、ソフトウェアには様々な要求があります。本当に全ての範囲で例外を適切に抽象化しなければならないのかどうかは筆者にはわかりません。もしかしたら、その抽象化は組織に実際的な利益を何ももたらさないかもしれないのです。例外設計の軽視はリスクの軽視だ、と言うこともできます。しかし、現に検査例外は普及したとは言い難い状況です。
最後に、C#のメイン設計者Anders Hejlsberg氏は検査例外に対して以下のように述べています。
検査例外はすばらしいものだ。だけどJavaみたいな実装のしかたでは、いくつかの問題をなくすかわりに別の問題を持ち込んだだけだ。もっとよい方法が見つかればC#にも検査例外を入れるだろう。 [5]*3
注釈
*1: ここでいう「メジャー言語」とは、言語作成者や言語作成者に関係のある人以外の人々に認知、利用され、実用利用を目的とされて開発されている言語、程度の意味合いです。つまり、研究用途や、ほとんど非公開の趣味言語を除いています。もしここでいうメジャー言語の中で検査例外を備えた言語をご存じの方がいらっしゃれば、ご教授頂ければ幸いです。
*2: この記事の趣旨とはズレるものの、Meyer氏が開放/閉鎖原則の支援機構として多重継承を提案した理由は興味深いものがあるので簡単に紹介します。ここでの多重継承のキモは、クラスという単一機構に全ての表現を落としこむことにあります。例えば多重継承のないJavaやC#でも、引数や戻り値にインターフェイスを使えば確かに開放/閉鎖原則を満たすことは可能です。しかし、クラスを使えばどうでしょう。もちろんそのクラスを継承しなければなりませんが、他にクラスを継承する必要に迫られていた場合には問題です。さらに、多重継承の道に進まなかったJavaやC#等はfinal
やsealed
といったキーワードで、特定クラスの継承を禁止する機能さえあります。
Meyer氏の主張はこうです。もし外部との接着剤となる引数や戻り値にクラスが使われていたら問題になる。にも関わらずそれらの言語はクラスを接着剤として使うことを許している。閉鎖しているが開放されていない。多重継承機構、そしてクラスのみの単一機構が提供された世界なら強制的に開放/閉鎖原則が満たされるのに!
*3: 原文は英語であり、この翻訳は西尾泰和氏の『コーディングを支える技術』[6]によるものです。
参考文献
[1]: 『オブジェクト指向入門 第2版 原則・コンセプト』 Bertrand Meyre, 翔泳社 2007
[2]: 『Clean Code アジャイルソフトウェア達人の技』 Robert C. Martin, アスキー・メディアワークス 2009
[3]: 『独自例外 - ぐるぐる~』 http://bleis-tift.hatenablog.com/entry/20080110/1199931607, bleis-tift 2008
[4]: 『Javaの検査例外の欠点について - kmizuの日記』 http://kmizu.hatenablog.com/entry/20100111/1263225681, kmizushima 2010
[5]: 『The Trouble with Checked Exceptions』 http://www.artima.com/intv/handcuffs.html, Bill Venners with Bruce Eckel 2003
[6]: 『コーディングを支える技術 ~成り立ちから学ぶプログラミング作法』 西尾泰和, 技術評論社 2013