物件導向的繼承理論,源自於生物學家林奈的分類法。
界、門、綱、目、科、屬、種。Dog
is-an Animal
。
這在上課時候聽好像不錯,但用在軟體開發中,這套思想可能會讓你崩潰。
為什麼?
因為你想要的通常不是一整隻「動物」,你只是想要它「會叫」這個行為。
這就是著名的「香蕉-猩猩問題」:你想得到一根香蕉,結果卻得到一隻拿著香蕉的大猩猩,以及整片叢林。
繼承讓你被綁死在一個僵化的家族樹上。
組合則給你一盒樂高積木,讓你自由地拼裝出你需要的任何東西。
先做小能力,再用組合把它們接起來。
什麼是組合?
什麼時候選組合而非繼承?
// 🔴 臭味開始:你建立了一個無法輕易擺脫的血緣關係
class Animal {
breathe() { ... }
eat() { ... }
speak(){ return '...'; }
}
class Dog extends Animal {
speak(){ return 'woof'; }
}
它很難用: 想建立一個「玩具狗」(ToyDog)。它也會叫,但它不會呼吸、不會吃東西。要怎麼辦?
讓 ToyDog
也繼承 Animal
,然後覆寫掉 breathe
和 eat
讓它們丟出例外?這方法不行,在分類體系遇到第一個現實世界的例外時,就已經發臭了。
它造成了緊耦合: Dog
現在與 Animal
的所有內部實作都是緊耦合。
如果 Animal
的某個基礎方法(breathe
)改變了,所有繼承它的子類都可能在你意想不到的地方壞掉。這叫「脆弱基底類別問題」。
你得到了一整隻猩猩: 當你只需要 speak
這個「香蕉」時,繼承把 breathe
和 eat
這些你可能根本不需要的東西(猩猩和叢林)也一併塞給了你。
像玩樂高一樣組裝你的物件與行為。
// 🟢 好味道:這不是繼承,這是組裝。
// 這些是你的樂高積木:一個個獨立的能力。
const canSpeak = (saying) => ({ speak: () => saying });
const canMove = (speed) => ({ move: () => `moves at ${speed}` });
const canBreathe = () => ({ breathe: () => 'breathing...' });
// 這是一個「組裝線」或「工廠」。
function createDog() {
// 按需取用你需要的積木,把它們組合起來。
return {
...canSpeak('woof'),
...canMove('fast'),
...canBreathe()
};
}
function createToyDog() {
// 玩具狗只需要「叫」的能力。很好,只給它這個。
return {
...canSpeak('electronic woof')
};
}
const realDog = createDog();
const toyDog = createToyDog();
依賴更單純: 組合讓你徹底擺脫了分類的束縛。把對應的能力組裝起來就行了。
零耦合: canSpeak
和 canMove
這兩個能力之間,以及它們和最終的物件之間,沒有任何耦合。你可以獨立修改、測試、替換任何一個能力,而不會影響到其他部分。
找出變化點,打包成能力:首先,找到那些在不同子類中一直被修改的方法,把它們抽出來,變成一個個獨立的「能力函式」。
用工廠取代 new
:接著,建立一個專門的「工廠」或「產生器」。它的任務就是像組裝樂高一樣,把需要的能力組合起來,回傳最終的物件。這樣就不再需要 new Subclass()
這種寫法。
新舊並行,安全過渡:先讓新的組合路徑和舊的繼承路徑同時存在。觀察一段時間,確認兩者輸出的結果完全一致後,再放心移除舊的繼承程式碼。
在手癢想打出 extends
關鍵字之前,停下來問自己:
我需要的是一個嚴格的「is-a」父子關係(會繼承父層所有),還是一個「has-a」或「can-do」的能力工具箱?(99% 的情況下,是後者)。
我能否把這個「行為」抽成一個獨立的、可被任何物件使用的零件?
如果我用了繼承,未來出現一個不完全符合父類別定義的例外情況時,該怎麼辦?
優先採用組合,而非繼承 (Favor Composition over Inheritance)。
行為模組化 (Modularize Behaviors): 將複雜的行為分解為獨立、可重用的「能力單元」,在需要時動態組合。
提升程式碼品質: 降低耦合,提升替換與測試性。
用「組合」取代不必要的「繼承」,你不是在寫程式,你是在設計一個更除臭的生態系。