オブジェクト指向のプログラムに込める「意図」

その昔、プログラムを覚えたての頃、プログラムってのは単に「処理」を記述するものだと考えていた。処理を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) を渡されたらどうなるだろうか?

まぁ、インターフェイスの作り方が悪い*2、のだけどね。

*1:例えばCollectionを受け取って、要素の和を求める。

*2:Collectionに対するclearを保証してしまっている。