JavaでVisitorのメソッドのthrows節になんと書くべきか [Java]
Javaでヘテロなリスト、たとえばIntegerかStringのどちらかが入るリストを作っておきたいとする。 これをちゃんと静的に型付けされるように書くとしたらJavaではVisitorを使うしかない。
interface IntegerOrString {
void accept(IntegerOrStringVisitor visitor);
}
class I implements IntegerOrString {
private Integer integer;
public I(Integer integer) { this.integer = integer; }
public void accept(IntegerOrStringVisitor visitor) {
visitor.visitInteger(integer);
}
}
class S implements IntegerOrString {
private String string;
public S(String string) { this.string = string; }
public void accept(IntegerOrStringVisitor visitor) {
visitor.visitString(string);
}
}
interface IntegerOrStringVisitor {
void visitInteger(Integer integer);
void visitString(String string);
}
これを使うときはたとえば次のようなコードを書く。
public class Main {
public static void main(String[] args) {
List<IntegerOrString> list = new ArrayList<IntegerOrString>();
list.add(new I(123));
list.add(new S("hello"));
for (IntegerOrString e : list) {
e.accept(new IntegerOrStringVisitor(){
public void visitInteger(Integer integer) {
/* 整数だった時の処理 */
System.out.println(integer + 1);
}
public void visitString(String string) {
/* 文字列だった時の処理 */
System.out.println(string.toUpperCase());
}
});
}
}
}
ここまでは(Javaの冗長さ以外は)なにも問題ないと思う。 ところでこのリストに対して行いたい処理がチェック例外を投げる処理だったらどうするか。 例えば出力先が標準出力でなくてファイルだったらIOExceptionをどうにかしないといけない。
IOExceptionをmainで捕捉することにすると、 コンパイルを通すためにはまず匿名クラスのvisitIntegerとvisitStringにthrows IOExceptionと書かないといけない。
しかし継承・実装元が投げると宣言していないチェック例外を継承・実装先クラスで投げることはできないから、 IntegerOrStringVisitorの同名メソッドでもthrows IOExceptionと書かないといけない。 IとSのacceptメソッドでそれらのメソッドを呼んでいるからそこにもthrows IOException、IとSの実装元であるIntegerOrStringクラスのacceptメソッドも同様にthrows IOExceptionをつける。
だけどこれは明らかにおかしい。IntegerOrStringというデータ型は、そのデータにどのような操作がなされうるかとは独立に定義されるはずだ。 データと操作をデカップリングするためにVisitorパターンを使ったのだから。 「このデータに何かする操作はIOExceptionを投げうる」ということをどうやってあらかじめ知ることができるというのだろうか。 IntegerOrStringVisitorの実装クラスが増えるたびにIntegerOrStringインターフェイスまで立ち戻ってthrows節にチェック例外を追加するのはナンセンスだ。
いくつか案を検討してみよう。
案1 visitメソッドで例外処理させる
visitメソッド内で例外処理させることを要件にすれば最初のシグニチャのままでどうにかなる。でもこれは明らかに強すぎる制約で、現実的ではないと思う。
案2 visitメソッドで実行時例外に変換する
visitメソッド内でIOExceptionなどのチェック例外を適当な実行時例外に変換して投げるようにすれば、やはりシグニチャを変えずに済む。 この案の問題点は例外を捕捉する側で「より小さい例外を捕捉する」という原則に沿った例外処理ができなくなることだ。あるいは悪い言い方をすると元の例外を覆い隠す。 例えばFileNotFoundExceptionとそれ以外のIOExceptionで例外処理を変えたかったとしても、 捕捉側で受け取るのは変換後の例外なのでcatch節を分けることができない。 いや、正確に言うとgetCauseを使えば処理を変えることはできるが、どっちにしても普通の素直なtry/catchではできない。
もう一つはチェック例外を実行時例外に変換するのはJavaコンパイラが提供する安全機構を外すことであってよろしくない、 という思想なりコーディング規約なりがあるということだ。
案3 throws Exceptionと書く
最初からthrows ExceptionにしておけばVisitorがチェック例外を投げたとしてもデータやVisitorインターフェイスに後から手を入れなくても済む。 このやり方は例外変換を伴わないので案2の最初の問題も存在しない。
問題となりうるのはやはり思想・コーディング規約上のものだろう。 このようなやり方に対しては、どこからともなくチェック例外ポリスが現れてthrows Exceptionはだめだけしからんと言って笛を吹かれる可能性が高い。
また、throws Exceptionが意外に感染力が高いというのもすこしがっかりする点だ。 IntegerOrStringVisitorの実装で実際にはチェック例外を投げない処理だった場合、visitメソッドのthrows節を消すことはできるのだが、 これはあまり意味がない。 acceptメソッドが引数にとるのはあくまでもIntegerOrStringVisitorインターフェイスのvisitメソッドなので、acceptメソッドからthrows Exceptionを消すことはできない。 だから結局全体としてはExceptionを投げうるコードとみなさなければならず、acceptを呼んでいるメソッドで捕捉しないならばそのメソッドもthrows Exceptionにしなければならない。こうして誰かがcatch (Exception e)と書くまでthrows Exceptionが伝搬していく。
案4 visitメソッドで専用のチェック例外に変換する
最後の案は案2のバリエーションで、例えば次のようなチェック例外クラスを定義する。
class IntegerOrStringException extends Exception {...}
visitメソッドやacceptメソッドのシグニチャはthrows IntegerOrStringExceptionにして、visitメソッドの中でチェック例外が発生するときはこれを生成して元の例外をcauseにする。
e.accept(new IntegerOrStringVisitor {
public void visitInteger(I integer) throws IntegerOrStringException {
try {
...
} catch (IOException e) {
throw new IntegerOrStringException(e);
}
}
...
これは例外の捕捉に関して案2と同じ欠点を持つ。一方でチェック例外を実行時例外にロンダリングするわけではない。 また、案3とは異なりExceptionのサブクラスを投げるに過ぎないので例外ポリスをも満足させる。
この種の設計は一般的には「その抽象化に適した例外を投げる」(Effective Java)という考えから行われることがある。 例えばJNDIの実装がファイルを使うにしてもDBを使うにしてもlookupメソッドがIOExceptionやSQLExceptionを投げるのは実装の詳細を曝しすぎているのでNamingExceptionのサブクラスに変換して投げるべきだ、というように。 しかしVisitorの場合、この考えから案4を支持するのは無理がある。Visitorに実装される処理は「そのデータに対する何らかの処理」というだけだ。 これは何か必然性のある抽象だろうか?Visitorパターンを使わなくてすむ言語なら名前を付ける必要すらなかったものだ。
「インターフェイスを定義するときに、どういう実装になるかあらかじめわからないがthrowsをどうするか」というのはVisitorに限らずインターフェイス一般に生じる問題だ。 一般には「その抽象化に適した例外」を投げることにしているのだと思われるが、 それは結局「そのインターフェイス/パッケージ用に新しくチェック例外を定義してインターフェイスの全部のメソッドにthrowsをつけて回る」ということになっていることが多い。 かくしてJNDIのインターフェイスには全部throws NamingExceptioが、JMSのインターフェイスのすべてのメソッドにはthrows JMSExceptionがついている。 それが本当にいい設計なのかはともかくとして、これらのインターフェイスはある種の具体的な処理(名前解決、メッセージング)と結びついているので 「その抽象化に適した例外」を定義したのだと主張することはできる。しかしVisitorは処理自体を切り離したわけだから、具体性のある処理と結びついていない。 (抽象度が上がるほど「例外が表している内容」は苦し紛れになってくる。 例えばjava.util.concurrent.Future#getはExecutionExceptionというチェック例外を投げるが、これは単に任意の非同期処理の中で投げられる例外をラップしているだけだ)
そうして考えると案4は単にコーディング規約を満足させるためだけに不必要な例外クラスを定義したにすぎないと思う。
結局JavaでVisitorをやる場合はコーディング規約がどうこうといわれようとも案3をとり、throws Exceptioの伝搬を受け入れるのが妥当ではないか、と考えている。 この書き方のコードはチェック例外のないJVM言語で実行時に起こっていることと同じ動作をするものでもある。
コメント 0