UnsupportedOperationExceptionと相続拒否
昨日ご紹介したbaseunitsですが、そのコードを社内コードレビューに掛けた際、id:cobonasからこんな指摘がありました。
package jp.tricreo.baseunits.util; import java.util.Iterator; /** * 明示的に、対象のコレクションに対する操作ができないことを表す反復子。 * * @param <T> 要素の型 */ public abstract class ImmutableIterator<T> implements Iterator<T> { @Override public void remove() { throw new UnsupportedOperationException("sorry, no can do :-("); } }
これって「相続拒否」的な話ですかね?あんまりよろしくないとどこかで見た気がする。
ニュアンスはわかるんですが、「相続拒否」という言葉を技術分野で初めて聞いたので調べてみると、こんなのがありました。コードの不吉な匂い、つまりリファクタリングを検討するケースですね。正直、とても良い質問だと思ったので、詳しく解説してみます。
サブクラスは親の属性と操作を継承するのが普通ですが、ほんの一部しか利用していない場合 があります。
- 不吉な匂い
リファクタリング?
こういった場合、以下のようなリファクタリング案が提示されています。
ImmutableIteratorはIteratorを実装(implements)せず、内包→委譲するようにする。
public abstract class ImmutableIterator<T> { private final Iterator<T> itr; public ImmutableIterator(Iterator<T> itr) { this.itr = itr }; public boolean hasNext() { return itr.hasNext(); } public T next() { return itr.next(); } }
しかし、ImmutableIteratorは、普通にIteratorとしても使いたかったりするので、Iteratorのサブタイプで居てもらいたいと考えています。従って、compositionとするアイデアは採用できません。まぁ、そもそもIteratorの実装を継承したい訳じゃなくて、型を継承したいのです。(Iteratorはinterfaceなので、継承する実装もありませんし…)
メソッドを引き下げる。
public interface Iterator<T> { boolean hasNext(); T next(); // void remove(); } public interface RemovableIterator<T> extends Iterator<T> { void remove(); }
Iteratorは削除の手段を提供しない。削除したい場合はRemovableIteratorを使うべき。という体制にもっていくリファクタリング。しかし、IteratorはJavaAPI仕様の型であるため、我々の制御下にないわけです。というわけで、引き下げもできない。
だから、短絡的に考えると「相続拒否しちゃいけない」、つまり「必ずremove機能を提供すべき」ということになると思います。
ImmutableIterator implements Iteratorであるということは、ImmutableIterator is-a Iterator であって、「hasNextとnextとremoveの機能を提供するのがIteratorである」という定義なのですから、「removeを提供しないImmutableIteratorはIteratorではない」、つまりis-aが成り立たないのでダメ、という論調ですね。私が大好きな、リスコフの置換原則に則るための、大事な考え方です。
しかし、「Iteratorは必ずremoveを提供しなければならない」という強力な縛りを課すと、色々なところに歪みが発生します。
強力すぎる制約による歪み
どのような歪みが発生するか、見ていきましょう。例えば「不変(Immutable)なコレクション(A)」や「要素の追加はできるけど削除はできない、という制限付きのコレクション(B)」等を作った時、これらのコレクションはイテレータを提供できなくなります。
Collectionはiterator()メソッドを宣言しているので、これも「必ず提供しなければならない」、つまり、AとBはCollectionとは認められない。implements Collectionしてはいけないのです。そんなわけで、AとBはCollectionをcomposition(内包)としなければいけなくなりました…。
となると、せっかくAやBのようなクラスをつくっても、Collectionを引数に取るメソッドにImmutableCollectionは渡すことができない。サブタイプじゃないですから。そんなクラスは不便で存在価値が薄いですね。
あくまでも「匂い」
「コードの不吉な匂い」は、あくまでも「予感」であって、「絶対的な悪」ではありません。リファクタリングが必須なわけではなく、検討する必要があるだけです。
検討の結果、リファクタリングが不要だという結論に至ることもあるのです。しかし、ここで甘えてもいけません。何でもかんでもリファクタリング不要、としてしまうのも大問題なので、このような匂いを感じたにも関わらずリファクタリングをしない場合は、正当な理由があるべきです。
例えば、上に説明してきたように「サブタイプである必要があり、かつ、自分の制御下にない型の修正が必要になる」というのは正当な理由です。ただ、個人的にはこの理由も少し「弱い」と感じているので、もう少し根拠を補強したい。
実はリスコフの置換原則に違反していない
「リスコフの置換原則」とはなんでしょう。詳しいことはググってもらうとして。A extends B の時、A型のインスタンスはB型としても何の問題もなく振舞わなくてはならない。つまり、ImmutableIterator型のインスタンスは、Iterator型としても何の問題もなく振舞わなくてはならない、という原則です。
では、ImmutableIterator型のインスタンスは、Iterator型として問題なく振舞うでしょうか? 通常、Iterator#remove()メソッドを呼び出したら、基になるコレクションから、反復子によって最後に返された要素を削除します。しかしImmutableIteratorはUnsupportedOperationExceptionを投げてremoveを拒否します。これは「Iteratorとして問題ない挙動」としていいのでしょうか?
Iteratorの仕様を確認しないまま「remove拒否」の挙動を判断しようとすると「removeだと言っているのにremoveしないのは、Iteratorの責務を果たしていないので、問題である」と考えると思います。しかしここは良く出来ているのです。Iterator#removeの仕様を確認してみましょう。
まず「任意のオペレーション」に注目です。和訳されて、かえって分かりにくくなっていますが、英語では「optional operation」です。つまり、「機能を提供しても提供しなくても、どちらでもよい操作」なのです。また、例外の節でも「Iterator が remove オペレーションをサポートしない場合」はUnsupportedOperationExceptionを投げることが明示してあります。
つーまり。「remove拒否するのもIterator」なのです。
相田みつをが「人間はつまづくこともある」とするのと同じように、Javaは「Iteratorはremoveを拒否することもある」としているのです。
だから「ImmutableIteratorは、removeを拒否してもよい」のです。そもそもIterator#removeしたら必ず削除されるなんて思う方が間違いなんです。仕様に「拒否ることもある」と書いてあるわけだから、Iterator#removeを呼ぶ際は、拒否される可能性を念頭に置いてコーディングしなきゃいけないのです。
っていう論調なのが「契約プログラミング」って考え方。removeが失敗してバグった時、それは「removeを提供しないIteratorが悪い」のか「remove拒否される可能性を考慮しなかった呼び出し側が悪い」のか。そんな責任の所在を明確にする流派です。この考え方も、自分は結構好き。
まぁ、世の中にはいろんな考えがあって。がっちがちでリスコフ&契約原理主義な人もいる。俺のように「基本的にリスコフ&契約信者だけど、まぁアレな時もたまには…」なスタンスもあり。「そもそも契約の為には契約書(Javadoc)をきっちり漏れ無く明示しなきゃいけないけど、いちいち書いてらんないよね、無理だろ」という人もいるはずだ。
どんなスタンスで、どこまで根拠を求めるか。みなさんも、自分のスタンスを検証してみるといいかもしれないです。
余談
ちなみに、例えば Collection#iterator() は optional operation ではないので、必ず真っ当なイテレータを返さなければダメです。
また、 Collection#size() も必ず要素の個数を返さなければいけません*1。こいつもoptionalではないのです。従って「要素の数が不明なコレクション」、たとえば無限に要素を返すコレクションや、ネットの向こうに要素があって、個数はこちら側で把握できないコレクションなどは、コレクションと呼べないのです。sizeをサポートできないから。
しかししかし。Collection#add() や remove() というのは実はoptional operation。add/removeを許さないコレクションであっても、Collectionを名乗って良いです。じゃないとImmutableなCollectionが存在できなくなります。
さらに余談
JavaAPIの設計者は、なぜ「メソッドの引き下げ」をしなかったんでしょうね。removeが無いIteratorと、removeがあるRemovableIteratorの階層にしておけば、このような問題は発生しなかったのに。また、Collectionも、addやremoveを提供しない層とする層に階層化すればよかったのに。
これをし始めると、おそらく過モデリングになります。型の階層構造が深くなって複雑化する。オブジェクト指向原理主義だとUnsupportedOperationExceptionは悪ですが、現実を見ると、良いバランスが取れた結論なんじゃないでしょうか?
Java: The Good Parts: Unearthing the Excellence in Java
- 作者: Jim Waldo
- 出版社/メーカー: O'Reilly Media
- 発売日: 2010/05/09
- メディア: ペーパーバック
- クリック: 1回
- この商品を含むブログ (1件) を見る
という話は、この本に書いてあったよ。洋書なんですが、こちらの本は現在翻訳が進んでいます*2。Javaの良いところが再確認できる良書だと思います。お楽しみに。
追記:和訳本、出たよー。
結論
というわけで、Iteratorのサブタイプがremoveを拒否るのは正当である、という判断です。根拠は上記の通り。