オブジェクト指向のプログラムに込める「意図」
その昔、プログラムを覚えたての頃、プログラムってのは単に「処理」を記述するものだと考えていた。処理を1ステップごとに記述し、場合によってはサブルーチンに切り出し、再利用する。
今振り返ると、オブジェクト指向を覚え始めてしばらくして、その意識は変わっていた。当然「処理」を落とし込まなければプログラムは動かない。だから「処理」はプログラムに込める。ただ、オブジェクト指向言語を使うと、これに加えて「意図」を落とし込むことができる。
オブジェクト指向を学び始めた当初、Javaのインターフェイスの存在意義がわからなかった。プログラムは「処理」を記述するものだという視点で見ると、インターフェイスには「処理」を書くことができない。インターフェイスだけでは何も起こらないからだった。
さらに、IDEを使ってコードを追っていると、途中でインターフェイスのソースを開くことになり、「なんだよ、中で何やってっかわかんねーよ、読みづれー」という感覚を覚える。処理を追うには実装クラスを探さなければならない。なんで存在意義がわからない上に、敢えてコードを読みづらくするようなインターフェイスを使うんだ? と、さらにインターフェイス嫌いが加速するw
この視点にとどまっていると、インターフェイスを書く理由も理解できないし、自ら書くモチベーションも得られない。ただ、いつからか、コードには「処理」だけじゃなくて「意図」が記述されている、と考えるようになる。インターフェイスは「責務(意図)」を示しているだけで「実装(処理)」は示さない。「なんだかわからんけど、とある責任を果たしてくれるものだ」と理解するようになる。
class Client { void process(Adder a) { System.out.println(a.execute(2, 3)); } } interface Adder { /** * 正の整数leftとrightの和を計算し、返す。 * @param left 正の整数 * @param right 正の整数 * @throws IllegalArgumentException 引数に負の数を与えた場合 */ int execute(int left, int right); } class AdderA implements Adder { public int execute(int left, int right) { if (left < 0 || right < 0) { throw new IllegalArgumentException(); } return left + right; } } class AdderB implements Adder { public int execute(int left, int right) { if (left < 0 || right < 0) { throw new IllegalArgumentException(); } int result = 0; for (int i = 0; i < left; i++) { result++; } for (int i = 0; i < right; i++) { result++; } return result; } }
大げさな例だが、Adderは和を返してくれる。とにかく「和を返す」ということだけを保証する。書いた通り、複数の実装があるが、どちらの処理が使われても、使う側(client)は我関せず。とにかく、和さえ返ってくれば、「処理」の内容は知ったことではないのだ。
今思えば、コードを追っている時にインターフェイスにぶつかった時は「この先は、細かいことを知りたいんじゃない限り、処理を追わなくてもいいよ」という意思表示ともとることができる。インターフェイスは「和を返す」ということだけを明示し、実際にどのように和を計算しているのかは「わざと隠し」ていて、「よっぽどの事がなければ、この先は読まなくてよいよ」という意図を示している。
clientは「どのように和を計算しているのか(つまり処理)」に興味はない。ただし「負の数を渡したらどうなるのか(責務)」は知らなければならない。これを知らなければ、clientは何も気にせず、負の値をAdderに渡してしまうことだろう。そこで例外が発生し、結局は実装クラスを読むハメになるのだ。
つまり、この「意図の落とし込み」が有効に作用するためには「ドキュメンテーションコメント(Javadoc)がきっちり書かれている」場合に限る。このインターフェイスの「責務」を明示する必要がある。
例えば、上のコードにもしもJavadocコメントが無かったら…、というのを想像してみてほしい。Adder#execute(int left, int right):int だけでは、責務が明確ではない。足し算してくれるんだな、程度は分かるかもしれないが、負の数を与えた時は例外が飛ぶ、という情報を得ることができない。結局は実装を読むハメになるのだ。
昔から高凝集低結合というスローガンがあるが、誤解を恐れずにに言えば「モジュールを超えて実装を追う必要がない状態」が低結合だ。Javadocがないだけで、結合度を高めてしまうことになる。気をつけよう。
インターフェイスの存在意義がなかなか理解できない原因は、おそらくこんか感じかなぁ。
- 「処理」だけでなく「意図」を落とし込んである、というパラダイムチェンジができていない
- 「処理を分割する」だけではなく「責任を切り分けている」というパラダイムチェンジができていない(上に似てるが)
- 読む対象のコードに、Javadocコメントとして責務が明記されてないから「結局は実装を読まないと責務が分からない」ケースが多い
まぁ、Javadoc書こうよ、という結論になるw
追記
ブコメに反応。
これたぶん悪い例じゃないか。"引数に負の数を与えた場合" というのはインタフェース上では自然言語でしか記述されていない。負の値を処理できるAdderを作れてしまう。
はてなブックマーク - fuktommyのブックマーク / 2009年10月15日
んー。インターフェイスが示す仕様に違反した実装は、作れるケースが多々あるものだと思う。ただ、「作ってはいけない」という規則、つまりリスコフの置換原則(LSP)を遵守することによって防ぐことができる。
例えば、java.util.Collection インターフェイスに、こんなメソッドがある。
public interface Collection { /** * Returns the number of elements in this collection. If this collection * contains more than <tt>Integer.MAX_VALUE</tt> elements, returns * <tt>Integer.MAX_VALUE</tt>. * * @return the number of elements in this collection */ int size(); // 略 }
このコレクションが含む要素の数を返す、もしInteger.MAX_VALUE個以上の要素を持つのであれば、Integer.MAX_VALUEを返す。というインターフェイスだ。個数を返すということは、絶対に負の数を返してはならない。しかしこれは、自然言語でしか記述されておらず、負の値を返すCollectionを作れてしまう、というのと同じじゃないかなぁ。
真のOO厨はそこで、負の値をとらない「自然数」クラス(orインタフェース)も必要じゃないかという方向に暴走していくのだ!!
はてなブックマーク - yojikのブックマーク / 2009年10月15日
本当に「負の値を処理できないAdder」や「負の値を返せないCollection#size()」を作るのであれば、↑こういうことになるんじゃないかな。何事もほどほどがいいんだよね〜w
追記2
ちなみに、Collection#size() では「要素の個数が不明な場合」について記述がない。つまり、不明な場合を許していない。要素の数が分からないものはCollectionとして扱ってはいけないことを表している。
つまり、以下のようなコードを書いても無限ループに陥る心配をする必要がない。仕様を信用する前提だけど。
for (int i = collection.size(); i >= 0; i--) { // ... }
これに対して、Collection#clear() は「optional operation」であり、空にすることができない特性を持つ実装の場合は UnsupportedOperationException をスローする、という仕様が明示されており、この仕様に従う限り、「クリアできないもの」もCollectionとして扱える、ということを表している。
public interface Collection { /** * Removes all of the elements from this collection (optional operation). * This collection will be empty after this method returns unless it * throws an exception. * * @throws UnsupportedOperationException if the <tt>clear</tt> method is * not supported by this collection. */ void clear(); // 略 }
つまり、Collection#clear()は常に使う事ができる、という前提でコードを書くと、契約に違反する。「clearをサポートしないCollectionを食わせた場合は止まってもよい」という仕様にするか、もしくは「UnsupportedOperationException をキャッチして適切なリカバリー処理を書く」というのが、本来あるべき "ガチガチ" の書き方。(現実的かどうかは置いといてw
(あんまり無いとは思うけど)「Collectionを受け取って、要素に対して何らかの処理*1をしつつ結果を返す。最終的にCollectionはカラになる。(カラにできない場合についての言及なし、つまり処理後は例外なく必ずCollectionがカラになる。)」という仕様のインターフェイスを作ったとする。そこに Collections.unmodifiableCollection(c) を渡されたらどうなるだろうか?