SJ blog
backend
A

信頼度ランク

S 公式ソース確認済み
A 成功実績多数・失敗例少数
B 賛否両論
C 動作未確認・セキュリティリスク高
Z 個人所感

継承 vs コンポジション ── 「継承より合成を優先せよ」の意味と実践

なぜ Effective Java は「継承よりコンポジション」と言うのか。is-a と has-a の違い、深い継承ツリーが壊れる理由、コンポジションへの書き換え方を具体例で解説。

一言結論

継承は「is-a 関係(〜は〜である)」のときだけ使う。振る舞いを再利用したいだけなら「has-a 関係(〜は〜を持つ)」= コンポジションの方が変更に強い。

Java を学ぶと「継承でコードを再利用できる」と教わる。しかし Effective Java(Joshua Bloch)は「継承よりコンポジションを優先せよ」と言い、Spring などのフレームワークもコンポジションを多用する。

なぜ継承は問題になるのか。コンポジションとは何か。具体的に見ていく。


1. 継承の正しい使い方 ── is-a 関係

継承が正しく機能するのは is-a(〜は〜である) の関係のときだ。

// ✅ is-a 関係: Dog は Animal である
class Animal {
    void breathe() { System.out.println("breathing"); }
}
class Dog extends Animal {
    void bark() { System.out.println("Woof!"); }
}

Dog d = new Dog();
d.breathe(); // 継承で自然に使える
d.bark();

Dog is an Animal は直感的に正しい。ポリモーフィズムも自然に使える。

問題は「コードを再利用したいだけ」で継承を使うときだ。


2. 継承の濫用が壊れる例

// ❌ コードを再利用したいだけで継承している
class MyList extends ArrayList<String> {
    private int addCount = 0; // 追加された回数を記録したい

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

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

    public int getAddCount() { return addCount; }
}

MyList list = new MyList();
list.addAll(List.of("a", "b", "c"));
System.out.println(list.getAddCount()); // 6 と出力される!(3 のはず)

なぜ 6 になるのか。ArrayList.addAll() の内部実装が add() を呼んでいるからだ。

addAll(["a","b","c"]) が呼ばれる:
→ MyList.addAll() が実行: addCount += 3 → addCount = 3
→ super.addAll() が内部で add() を 3 回呼ぶ
→ 各 add() で MyList.addAll() の addCount++ → addCount = 6

ArrayList の内部実装(addAlladd を呼ぶという詳細)に依存してしまった。スーパークラスの実装が変わったとき、サブクラスが壊れる。


3. コンポジション ── 「持つ」に変える

コンポジションは「継承(is-a)」ではなく「フィールドとして持つ(has-a)」パターンだ。

// ✅ コンポジション: ArrayList を持つ
class CountingList {
    private final ArrayList<String> list = new ArrayList<>(); // 委譲先
    private int addCount = 0;

    public boolean add(String e) {
        addCount++;
        return list.add(e); // 委譲(delegate)
    }

    public boolean addAll(Collection<? extends String> c) {
        addCount += c.size();
        return list.addAll(c); // 委譲
    }

    public int size() { return list.size(); }
    public String get(int i) { return list.get(i); }
    public int getAddCount() { return addCount; }
}

CountingList list = new CountingList();
list.addAll(List.of("a", "b", "c"));
System.out.println(list.getAddCount()); // 3 (正しい)

ArrayList.addAll() が内部で add() を何回呼ぼうと、CountingList.add() は呼ばれない。list.addAll(c)ArrayListaddAll に委譲しているだけで、CountingList.add() には来ない。

addAll(["a","b","c"]) が呼ばれる:
→ CountingList.addAll(): addCount += 3 → addCount = 3
→ list.addAll(c): ArrayList の内部で何をしても CountingList のカウンタに影響なし

スーパークラスの内部実装に依存していないため、ArrayList が将来変わっても壊れない。


4. is-a か has-a か ── 判断の基準

継承を使う前に問う:

「B は A の一種か?(B is an A?)」

  • DogAnimal の一種か? → YES → 継承 OK
  • CountingListArrayList の一種か? → NO(機能拡張したいだけ) → コンポジション
  • SquareRectangle の一種か? → 直感的には YES だが…

正方形・長方形問題(LSP 違反)

class Rectangle {
    private int width, height;
    public void setWidth(int w) { width = w; }
    public void setHeight(int h) { height = h; }
    public int area() { return width * height; }
}

class Square extends Rectangle {
    @Override
    public void setWidth(int w) {
        super.setWidth(w);
        super.setHeight(w); // 正方形は幅=高さ
    }

    @Override
    public void setHeight(int h) {
        super.setHeight(h);
        super.setWidth(h); // 正方形は幅=高さ
    }
}

一見正しそうだが:

Rectangle r = new Square();
r.setWidth(5);
r.setHeight(10);
System.out.println(r.area()); // 100(Square なので 10×10)
// Rectangle として使うと「幅=5,高さ=10 → 面積 50」を期待するが破れる

これが LSP(Liskov Substitution Principle:リスコフの置換原則) の違反だ。「親クラスが使われている場所に子クラスを置き換えても動作が変わらない」べきだが、SquareRectangle の契約(「幅と高さを独立して設定できる」)を破っている。

現実の「正方形は長方形」という直感は正しいが、Java の継承でそれを表現しようとすると壊れる。is-a でも継承が適切でない例だ。


5. 継承が輝く場面

コンポジション万能ではない。継承が正しい場面もある。

フレームワークの拡張ポイントとして設計されている場合:

// Spring の場合: フレームワーク側が継承を前提に設計している
class MyController extends BaseController {
    @Override
    protected void handleRequest() {
        // カスタム処理
    }
}

テンプレートメソッドパターン:

abstract class DataProcessor {
    // 処理の骨格(テンプレートメソッド)
    public final void process() {
        readData();   // 共通処理
        doProcess();  // ← サブクラスが実装する部分
        writeData();  // 共通処理
    }

    protected abstract void doProcess();
}

class CsvProcessor extends DataProcessor {
    @Override
    protected void doProcess() { /* CSV 専用処理 */ }
}

6. まとめ ── 継承を使う前のチェックリスト

□ 本当に is-a 関係か?(has-a なら コンポジション)
□ スーパークラスの内部実装に依存していないか?
□ スーパークラスの変更で意図せず壊れないか?
□ LSP を満たしているか?(親の代わりに子を置いても動くか)
□ 継承ツリーが 2 段より深くなっていないか?

「コードを再利用したい」だけなら、まずコンポジション(委譲)を試す。継承は「is-a 関係が明確で、ポリモーフィズムを意図的に使う」場合に使う。

この判断ができるようになると、Spring の設計(インターフェースによる DI・コンポジション重視)が「なぜこう設計されているか」まで読めるようになる。