最近、こことかこことかこことかで、Javaの検査例外に関する議論が話題になっているようだ。検査例外に関しては、自分も以前から一言言いたいと思っていたので、ちょっと書いてみることにする。とはいえ、他の人と同じ論点で書いてもつまらんので、ここではちょっと違った視点から。
まず、意識しなければいけないのは、
- 検査例外という概念そのものが良くない
- Javaの検査例外の仕様、つまり検査例外の特定の実装がマズい
この二つを区別すべきだということだ。実用的に使われている言語で検査例外を実装しているのがJavaしか実質存在しないこともあって、この二つの区別が曖昧になっている場合が多いように思う*1。
このエントリでは、前者についてはとりあえず置いておいて、後者、つまり、Javaの(現在の)検査例外の仕様がイケてない点について述べたいと思う。
- 例外の型を透過的に扱う手段が存在しない
文だけだとわかりにくいと思うので、実際のJavaコードを例にして説明してみよう。
まず、Rubyのopenメソッドのように、
- ファイルをオープンして
- 与えられたブロックを実行して
- 実行が終了したら確実にクローズする
メソッドをライブラリとして提供することを考えてみる。このようなライブラリを現在のJavaで実装しようとすると、次のようになるだろう。
import java.io.*; public class FileUtil { interface Block<A, B, E extends Exception> { B run(A arg) throws E; } public static <A, E extends Exception> A open( String fileName, Block<BufferedReader, A, E> block ) throws FileNotFoundException, IOException, E { BufferedReader reader = new BufferedReader(new FileReader(fileName)); try { return block.run(reader); } finally { reader.close(); } } }
interface Blockの定義がポイントで、このinterfaceは「A型の引数を受け取ってB型の値を返し、E型の例外をthrowsし得る関数」を表現している。利用する側のコードは,例えば以下のようになるだろう。
public static void main(String[] args) throws IOException { open("FileUtil.java", new Block<BufferedReader, Void, IOException>() { public Void run(BufferedReader reader) throws IOException { for(String line; (line = reader.readLine()) != null;){ System.out.println(line); } return null; } } ); }
同じ型を何度も書かなければならないのはウザいが、それはまあいいとしよう。問題は、runが複数種類の例外を投げ得る場合に対応できないということだ。たとえば、runの中でInterruptedExceptionが投げられる可能性のある処理を追加して、かつ、runの中ではそれをハンドルしたくない場合、どうすれば良いだろうか。
とりあえずの回避策として、new Block
実は、この問題点はJava言語の開発者たちには既に認識されていて、Java 7で(一度は却下されたものの)入ることになったクロージャに関する仕様では、型パラメータの部分で、
import java.io.*; public class FileUtil { interface Block<A, B, throws E extends Exception> { B run(A arg) throws E; } public static <A, throws E extends Throwable> A open( String fileName, Block<BufferedReader, A, E> block ) throws FileNotFoundException, IOException, E { BufferedReader reader = new BufferedReader(new FileReader(fileName)); try { return block.run(reader); } finally { reader.close(); } } }
- throwsの型推論が存在しない
たとえば、ある(publicな)メソッドfoo()があって、その中で処理A, B, Cがあったとしよう。
public void foo() { A B C }
このとき、処理Bの中身が膨れ上がって来たので、リファクタリングして別のメソッドbar()にくくり出したいとする。
private void bar() { B } public void foo() { A bar(); C }
このとき、処理Bが何も投げなければそのままくくり出すことができるが、処理Bが例外を投げ得るコードだった場合、いちいち処理Bが投げ得る例外を調べて、bar()のthrows節に列挙しなければいけない。bar()が仮にpublicなメソッドで他のモジュールから利用され得るなら、そのような作業も実際に必要なコストとして正当化できるだろうが、モジュール内部だけで使われるメソッドに対してそのような作業を行うのは徒労でしか無い(どの道、publicなメソッドで重複した処理が必要になるため)。
しかし、Javaの検査例外のメカニズムは、あるメソッドがthrowし得る例外の種類を静的に知っているので、その情報を有効に使えば、本来はそのような作業は不要なはずだ。たとえば、bar()が例外E1, E2, E3をthrowsし得るがthrows節に書かれていない場合、javacはその旨をプログラマに伝えて、bar()の中でE1, E2, E3をcatchするかbar()のthrows節に列挙するかの選択を迫るだろう。つまり、javacはthrowsに明示的に書かずとも、bar()がE1, E2, E3をthrowし得るということを知っていることになる。なら、明示的にthrows節に書かない場合、コンパイラがbar() throws E1, E2, E3と推論してくれてもいいし、実際、可能なはずだ。もちろん、publicなメソッドの場合など、推論されたら困る場合もあるだろうが、その場合はプログラマが明示的にthrowsを書けばいいだけの話だ。検査例外の利点は失われないし、その他にも特にデメリットは無いように思える。
- throwsの指定方法に柔軟性が無い
たとえば、メソッドAはメソッドBと同じ例外をthrowsしたいとか、Bと基本的に同じだけどそれに加えてB_E1をthrowsしたいとか、Bと基本的に同じだけどそれからA_E1を除きたいとか、そういった柔軟な指定をしたい。例外の型推論までいかなくてもこのような機能があるだけで手間が減る場面もあるだろう。このような仕組みに関する提案としては、M. van DoorenらのOOPSLA2006の論文(PDF注意)OOPSLA2005の論文(PDF注意)で提案されているanchored exceptionがあるので、興味のある方は読んでみると良いかも。
まあ、ぐだぐだと色々書いてきたけど、結局言いたかったのは、検査例外の欠点を私的指摘するのも良いけど、それがJavaの検査例外特有の話なのか、検査例外という仕組みそのものが内包している弱点なのかはちゃんと区別した方がいいよね、ということだったのでした。ちゃんちゃん。
*1:[http://www.artima.com/intv/handcuffs.html:title=Hejlsberg氏のインタビュー]では、前半ではこの二つはある程度意識して区別されているようだが、後半はその点が曖昧になっているように思う