読者です 読者をやめる 読者になる 読者になる

【Effective Java】項目16:継承よりコンポジションを選ぶ

継承よりもコンポジションを選ぶべきである。

継承の利用

継承はコードを再利用するための一般的な手法だが、常に最適なものとは限らない。 継承を安全に利用することができるのは以下の場合である。

  • スーパークラスもサブクラスも特定のパッケージ配下にある場合
  • 拡張のために設計・文書化されているクラスを拡張する場合
  • インタフェースをインタフェースが継承するインタフェース継承の場合

上記の場合以外では、継承は安全に利用できない。

継承はカプセル化を破壊する。 そのため、サブクラスはスーパークラスの実装に依存することになり、スーパークラスの実装が変わった場合、意図せずサブクラスの挙動が変わる可能性がある。

継承の問題

サブクラスの危険性を示すため、HashSet を拡張した InstrumentedHashSet クラスを例にとる。 このクラスは、既存の HashSet に追加された要素の数をカウントする機能をつけたクラスである。

public class InstrumentedHashSet<E> extends HashSet<E> {
    private int addCount = 0;

    public InstrumentedHashSet() {
    }

    public InstrumentedHashSet(int initCap, float loadFactor) {
        super(initCap, loadFactor);
    }

    @Override
    public boolean add(E e) {
        addCount++;
        return super.add(e);
    }

    @Override
    public boolean addAll(Collection<? extends E> c) {
        addCount += c.size();
        return super.addAll(c);
    }

    public int getAddCount() {
        return addCount;
    }
}

このクラスは一見、正しく動くように見えるが、現実には正しく機能しない。

InstrumentedHashSet<String> s = new InstrumentedHashSet<String>();
s.addAll(Arrays.asList("Snap", "Crackle", "Pop"));
s.getAddCount(); // => 6。3にならない。

なぜなら、HashSet の addAll は、内部的に add メソッドを利用して実装されているため、InstrumentedHashSet の addAll は以下のように二重カウントしてしまう。

  • InstrumentedHashSet.addAll が呼ばれて addCount が +3 される
  • super.addAll を呼ぶので、HashSet.addAll が呼ばれる
  • HashSet.addAll は this.add を 3 回呼ぶので、InstrumentedHashSet.add が 3 回よばれ、addCount は +3 される
  • addCount は 6 になる

このように自身のメソッドを利用して実装されることを「自己利用」と呼ぶ。

問題の回避方法

addAll メソッドをオーバーライドしないようにすれば、InstrumentedHashSet は正しく動作するようになる。 ただし、これはスーパークラスの実装方法によって影響を受けることを意味している。

一般的に、サブクラスで追加する要素をチェックし、ある不変式を守らせるようような機構を持たせた場合、後でスーパークラスに新たなメソッドが追加された場合には、その不変式を守らない要素が追加されるというセキュリティ上のリスクがある。 実際、Java ライブラリのHashTable と Vector ではこの問題が発生している。

サブクラスでは、スーパークラスメソッドをオーバーライドしないという回避策もある。 ただし、スーパークラスに同じシグネチャメソッドが追加された時にはこの方法はうまくいかない。(メソッドシグネチャは戻り値を含まないことに注意)

//
// A. 追加されたメソッドのシグネチャが戻り値が一致しない場合
//    => Sub で boolean initailize() を追加しないとコンパイルできない
//
class Sub extends Super {
    public void initialize() {
        // 何かの処理
    }
}

class Super {
    // 新たなバージョンで追加されたメソッド。シグネチャは一致するが戻り値が不一致。
    public boolean initialize() {
        // 何かの処理
        return false;
    }
}

//
// B. 追加されたメソッドのシグネチャも戻り値も一致する場合
//    => コンパイル可能で実行できるが、意図しないオーバーライド
//
class Sub extends Super {
    // 意図しないオーバーライド
    public boolean initialize() {
        // 何かの処理
    }
}

class Super {
    // 新たなバージョンで追加されたメソッド。シグネチャも戻り値も一致
    public boolean initialize() {
        // 絶対行ってほしい処理
    }
}

コンポジション

これら継承の持つ問題を回避する方法としてコンポジションと呼ばれる手法が利用できる。 既存のクラスを拡張する代わりに、新たなクラスの private のメンバーとして既存クラスのインスタンスを保持し、対応するメソッドを呼び出して、結果を返す。

これは転送(forwarding)と呼ばれ、そのメソッドは転送メソッドと呼ばれる。 この結果、新たなクラスは既存クラスの実装の詳細に依存しない強固なクラスとなる。 なぜなら、転送元のクラスは転送先の公開インタフェースにのみ依存しているからである。

InstrumentedHashSet でコンポジション・転送を利用すると以下のようになる。

public class ForwardingSet<E> implements Set<E> {
    private final Set<E> set;
    public ForwardingSet(Set<E> set) { this.set = set; }

    @Override public int size() { return set.size(); }
    @Override public boolean isEmpty() { return set.isEmpty(); }
    @Override public boolean contains(Object o) { return set.contains(o); }
    @Override public Iterator<E> iterator() { return set.iterator(); }
    @Override public Object[] toArray() { return set.toArray(); }
    @Override public <T> T[] toArray(T[] a) { return set.toArray(a); }
    @Override public boolean add(E e) { return set.add(e); }
    @Override public boolean remove(Object o) { return set.remove(o); }
    @Override public boolean containsAll(Collection<?> c) { return set.containsAll(c); }
    @Override public boolean addAll(Collection<? extends E> c) { return set.addAll(c); }
    @Override public boolean retainAll(Collection<?> c) { return set.retainAll(c); }
    @Override public boolean removeAll(Collection<?> c) {return set.remove(c); }
    @Override public void clear() { set.clear(); }
}

public class InstrumentedHashSet<E> extends ForwardingSet<E> {
    private int addCount = 0;

    public InstrumentedHashSet(Set<E> s) {
        super(s);
    }

    @Override
    public boolean add(E e) {
        addCount++;
        return super.add(e);
    }

    @Override
    public boolean addAll(Collection<? extends E> c) {
        addCount += c.size();
        return super.addAll(c);
    }

    public int getAddCount() {
        return addCount;
    }
}

この設計は柔軟なものになっている。 継承による InstrumentedHashSet は HashSet を拡張したため、HashSet 実装しか利用できなかった。 一方、このラッパークラスはどの Set 実装も計測することができる。

Set<Date> s = new InstrumentedSet<Date>(new TreeSet<Date>(x));
Set<E> s2 = new InsrumentedSet<E>(new HashSet<E>(capacity));

ラッパークラス

個々の InstrumentedSet は Set インスタンスをラップするので、ラッパーとも呼ばれる。 また、IntrumentedSet クラスは計測の機能を追加し、Set を装飾するためデコレータパターンとも呼ばれる。

時折、この手法が委譲とよばれることがあるが、技術的にはラッパーオブジェクトがラップしているオブジェクトに自分自身のインスタンスを渡さない限り、委譲ではない。

このラッパークラス手法のデメリットはほとんどないが、他のオブジェクトに渡すコールバックフレームワークで利用するには向かない。 ラップされたオブジェクトはラッパーを知らないため、ラップされたオブジェクトが自分自身(this)をコールバックに登録すると、ラッパーを経由せずにラップされたオブジェクトを呼びだす。 これは一般的に SELF 問題として知られている。

転送のパフォーマンスへの影響やメモリへの影響は大きなものではない。 転送メソッドを書くのは面倒ですが、個々のインタフェースに対して一度だけ書けばよい。

継承を利用するべき箇所

一般的に継承は、スーパークラスのサブタイプである場合だけ利用するべきである。 すなわち、すべてのクラス B がすべての A である(B is a A)場合のみに継承するべきだ。

Java のライブラリはこの原則を明らかに破っているものが多くある。 例えば、スタックはベクターではないため、Stack は Vector を拡張するべきではなかったが、そうなっている。 同じく、プロパティリストはハッシュテーブルではないため、Properties は HashTable を拡張するべきではないが、そうなっている。 どちらの場合もコンポジションが適切な選択である。

継承は API の欠陥をサブクラスに伝播させる一方、コンポジションはそのような欠陥を隠蔽することができる。

感想

Properties って始めて見聞きしたから調べてみた。

Properties (Java Platform SE 7)

Properties クラスは、プロパティーの永続セットを表します。 Properties を、ストリームへ保管したり、ストリームからロードしたりできます。 プロパティーリストの各キー、およびそれに対応する値は文字列です。

うーん、ちょっと抽象的だ。

6. ロケール/プロパティ/リソースバンドル (2) | TECHSCORE(テックスコア)

Javaはアプリケーションでプロパティ(属性値・設定値)を容易に扱えるようにするため、「java.util.Properties」クラスを提供しています。 このクラスはプロパティの管理をするだけでなく、ストリーム(特にファイルを想定)との入出力をサポートしています。

要するにキー・バリューの設定ファイルを扱いやすい形式にしたクラスって感じかな。 定義を見ると、 HashSet<Object, Object> を拡張している。

たしかに、HashSet<Object, Object> を拡張するのはなんか違うかなーって感覚はある。 実際、セキュリティ上の問題があるようだ。

Properties (Java Platform SE 7)

Properties は Hashtable を継承するので、Properties オブジェクトに対して put メソッドおよび putAll メソッドを適用できます。 しかし、これらのメソッドを使用することは推奨されません。 これらのメソッドを使うと、呼び出し側はキーまたは値が Strings ではないエントリを挿入できるからです。

継承を用いることで、ドキュメントでカバーしないといけない項目がまだある。

Properties (Java Platform SE 7)

setProperty メソッドを代わりに使用してください。 String 以外のキーまたは値を格納する「安全性の低い」 Properties オブジェクトで store メソッドまたは save メソッドが呼び出されると、その呼び出しは失敗します。 同様に、String 以外のキーを格納する「安全性の低い」 Properties オブジェクトで propertyNames または list メソッドが呼び出されると、その呼び出しは失敗します。

うん、なんかいろいろめんどくさい自体になってるね。 継承はなるべく使わないようにしよう。

とは思うんだけど、Android の UI の Framework とか使いまくりだと思うんだけど、そういうところは例外なのかなー?よーわからん。