組合 vs 繼承:「不要使用繼承」這句話的真正含義
學習物件導向時,一定會遇到這句話:「優先使用組合,而不是繼承(Favor composition over inheritance)。」
先說結論,這句話並不是「絕對不要使用繼承」,而是「不要為了重複使用程式碼而濫用繼承」。繼承仍然是有效的工具,但在需要重複使用的情況下,大多數時候組合是更安全的選擇。
本文會結合我的經驗,說明兩者在實際程式碼中的差異,以及什麼時候該選擇哪一種。
繼承與組合有什麼不同?
先用非常簡單的方式區分一下。
繼承是「~是~(is-a)」的關係。比如狗是動物。子類別會直接繼承父類別的功能。
組合則是「~擁有~(has-a)」的關係,例如汽車擁有引擎。它把具備所需功能的物件放在內部,再委派工作給該物件。
用程式碼來看會更容易理解。以下是透過繼承取得功能的方式。
// 繼承: Stack會 NSMutableArray繼承所有方法
class Stack: NSMutableArray {
func push(_ o: Any) { add(o) }
func pop() -> Any {
let o = lastObject!
removeLastObject()
return o
}
}
問題是,這樣一來 Stack 連不希望提供給外部的 insert(_:at:)、removeObject(at:) 等方法也會全部暴露出去。
以下是將同樣的功能改用組合實作的樣子。
// 組合:只挑選必要的功能並進行委派
class Stack {
private var list: [Any] = []
func push(_ o: Any) { list.append(o) }
func pop() -> Any { list.removeLast() }
}
我們把陣列放在內部,只對外開放必要的操作。Stack 真正想做的事就只剩下來了。
「不要使用繼承」這句話的真正含義
這句話之所以出現,是因為繼承有兩個代表性的缺點。
第一,封裝會被破壞。子類別會依賴父類別的內部實作。父類別程式碼一改,原本正常的子類別也可能突然出錯。
第二,耦合度會變得過高。父類別與子類別在編譯時期緊密綁定,之後很難改變兩者的關係。
繼承不是重複使用程式碼的工具,而是定義型別的工具。
這句話就是核心。當你只是因為想重複使用程式碼而使用繼承時,關係就開始變得複雜。
正方形與矩形就是著名的例子。從數學上來說,正方形是矩形,因此看起來可以使用繼承。但一旦繼承了矩形「分別變更寬度與高度」的功能,正方形就不再是正方形了。
即使看起來像 is-a 關係,只要無法連行為都完全替代,繼承就會成為陷阱。
那麼,什麼時候可以使用繼承?
繼承並不是絕對不好。只要同時滿足以下條件,使用繼承反而會很簡潔。
- 是真正的 is-a 關係嗎:子類別永遠都是父類別的一種嗎?
- 遵守里氏替換原則嗎:把子類別放到父類別的位置,是否完全沒有問題?
- 父類別是否以可繼承為前提設計:是否有文件說明,並且為擴充保留開放性?
如果三項都符合,使用繼承也沒問題。擴充 UIViewController 這類以框架為基礎的類別,就是代表性的案例。
相反地,只要有一項不明確,就先考慮組合。
我用表格簡單比較了一下。
| 情況 | 建議 |
|---|---|
| 純粹的 is-a 關係,且可以完全替代 | 繼承 |
| 只想單純重複使用程式碼時 | 組合 |
| 想在執行階段變更行為時 | 組合 |
| 需要組合多項功能時 | 組合 |
可以看到,實務上遇到的大多數情況都偏向組合。「favor composition」這個建議並不是沒有原因的。
我在實務上會這樣判斷
建立新類別時,我習慣先問自己:「這是父類別的一種,還是只是想借用父類別的功能?」
如果只是想借用功能,我幾乎總是選擇組合。
即使觀察設計模式,方向也很明確。策略模式、裝飾器模式等,全部都是以組合為基礎。
尤其是策略模式,它會將行為分離成物件,並在執行階段替換。這種彈性很難透過繼承模擬。
當然,你必須接受程式碼會稍微變長,因為需要自行撰寫委派方法。不過,之後要變更結構會方便得多。
「不要使用繼承」並不是禁止繼承,而是提醒你避免為了重複使用而濫用繼承。is-a 關係明確,就使用繼承;只是想借用功能,就使用組合。只要掌握這一個判準,程式碼就會穩固許多。祝你今天也能設計出好的架構!

