EnriqueMark
設計模式與架構

組合和繼承

2026-03 繁體中文

組合優於繼承的關鍵因素是去耦合,或者説,就像積木一樣的模組,而狀態則通過外部因素給入——大部分情況下采用靜態方法就足夠了,但少數情況下可能需要的是一個組合類,下面許多方法共享同樣的狀態——但是,我為什麼不採用完全的DI,讓狀態從外部注入呢?也就是說,更精確的來講:只有所有組合方法都需要共享同樣的單一狀態時,我才需要用類——哪怕這個狀態本身也還是從類外面注入進來的,區別就是一個是DI方法,一個是DI類。對於一般的情況來説,DI方法就夠了。

而組合本身也一直強調,什麼時候用它?組合 裡面提到「有一個」和「是一個」的區別,類本身是字面意義上的「一類」,換言之,他們是一個物種——共享結構上固定的(就像上面所説的)、提前定義好的父類狀態(屬性)或方法。子類確實可以寫,但那也是父類允許的情況,子類本身的範圍和邊界被父類限制。這裡和介面不同的點是,介面雖然也是對結構的約束,但它更專注於結構本身的抽象,而父類是更底一層的抽象,它除了結構之外,還有具體的行為——有具體內容的方法。而這些具體正是保證里氏替換原則(LSP)的基礎——任何子類都可以替代父類,因為他們本身就完全包含(繼承)了父類的一切內容。多餘的那部分只是沒被使用而已。

但是,標準的LSP不是硬約束,假設底層以來了父類的「可覆寫」方法,而子類完全覆寫了,那就會炸。這也是為啥濫用繼承反而容易耦合——不符合LSP原則的繼承就不該出現——或者説,父類應該約定好,其可覆寫方法是「擴展性」的——而且這些擴展部分,本身就可以作為一個約定而存在了,比如叫什麼名字、輸入輸出是什麼,只是邏輯內部可以讓你隨便寫。從這裡看,就會發下轄,哪怕是擴展,設計良好的父類也能保證LSP能運作,因為結構(輸入-輸出)是型別固定的,正常來説哪怕是擴展,用的時候都不會出問題。從一開始就應該保證子類再繼承的時候不會破壞核心功能,這些就是保證LSP的核心骨架邏輯。但是,即便如此,這也是結構約束,萬一子類就是胡搞,導致方法直接出錯,返回null(返回型別系統預設不管這個),或者搞了預期的副作用,那還是會出問題。畢竟你不可能窮盡一切,只要開了擴展點,那什麼牛鬼蛇神都可能出來。最大化保證LSP是可以做到的,但這也是繼承濫用做不到的地方。

如果我需要專注於結構和這些結構的具體實現,且希望後續的依賴方「繼承」(字面意義上)這些內容,那這種情況下用基類和繼承的模式是標準做法,沒什麼冗餘的。畢竟我不能讓人類當一個佛蘭肯斯坦,用一大堆不相干的「積木」去拼湊,就算這樣最終能跑起來,它也會像科學怪人那樣是讓人困惑的——雖然就像上述説的一樣,能動,但我們的印象就是人類有其固有的狀態,這些狀態必須是繼承的而不是拼湊的,人類必須得是「是一個」,所以根本不是標準做法了,而是成了「反模式」。

但有些情況下我們就需要組合:積木、去耦合化的模組。它面向的是「有一個」。我只是想要一個復用的功能,就跟樂高一樣,可以在任何地方拼,除了要求必須有介面外,也不依賴什麼必然的固定狀態,那組合就再合適不過了。你拼成房子還是直升機我不感興趣,只要你能拼,那你就能用。

而且這麼來説,組合本身也是可以和繼承結合使用的——比如一個大型的工具模組——就算是樂高也分各種型別,你是磚塊我是小人,小人之見可以隨便拼,但它(一般而言)也不會和磚塊結合。那這時,功能上的一系列的「有一個」,就要遵循「是一個」的模式來構建自身了。哪怕他們整體仍然是「有一個」的模組,但自成一脈。