整理 Effective Java 書中 Item 18: Favor composition over inheritance 心得筆記

主旨

繼承常被拿來重用程式碼,但其實風險也很高,尤其當你繼承的類別不是為了擴充而設計。這篇重點是:「與其繼承一個現成的類別,不如把它包進一個欄位(組合)來用」,這樣可以避開繼承帶來的封裝破壞與潛在 bug,讓設計更穩健。

點出問題:繼承容易踩雷

先來看一個直覺但容易出錯的範例:

你想要擴充一個 ArrayList,加上一個功能來統計 add() 被呼叫幾次。很自然地你可能會寫這樣的繼承:

// 問題示範:繼承 ArrayList
public class CountingList<E> extends ArrayList<E> {
    private int addCount = 0;

    @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;
    }
}

乍看合理,但實際上可能會出問題。

原因是 ArrayListaddAll() 裡面會呼叫 add()。也就是說當你呼叫 addAll(3個元素)addCount 不只加了 3,還多加了每個元素呼叫 add() 的次數,結果會是 6,而不是你想要的 3

這就是繼承破壞封裝的問題 —— 你依賴了一個你不該知道的內部實作細節(addAll() 呼叫 add()),當這個實作被改變,你的子類也跟著壞掉。

範例重寫:用組合+轉發解決問題

改寫方式:不要繼承 ArrayList,而是讓你的類別裡面包一個 List,然後手動「轉發」呼叫。

// 改用組合實作
public class CountingList<E> {
    private final List<E> list = new ArrayList<>();
    private int addCount = 0;

    public boolean add(E e) {
        addCount++;
        return list.add(e);
    }

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

    public int getAddCount() {
        return addCount;
    }

    // 提供其他必要方法
    public E get(int index) { return list.get(index); }

    public int size() { return list.size(); }

    public String toString() { return list.toString(); }
}

這樣做的好處是:

  • 不會因為 List 的實作方式改變而壞掉
  • 加什麼功能都自己控制
  • 更容易測試、更符合封裝原則

小結

在 Java 中,繼承是一種強耦合設計。當你繼承某個類別,就等於綁住它的行為與未來的變化,這會讓程式容易壞、難測試、難維護。

請記得這句原則:

❗ 如果你只是想要「使用功能」,就用組合;
✅ 只有當你能說出「X is-a Y」,才考慮繼承。

從今天開始,用更穩健的思維寫程式吧。