在設計原則中,為什麼反覆強調組合要優於繼承?

Kevin.ZhangCG發表於2021-06-19

物件導向程式設計中,有一條非常經典的設計原則,那就是:組合優於繼承,多用組合少用繼承。同樣地,在《阿里巴巴Java開發手冊》中有一條規定:謹慎使用繼承的方式進行擴充套件,優先使用組合的方式實現。

為什麼不推薦使用繼承

  每個人在剛剛學習物件導向程式設計時都會覺得:繼承可以實現類的複用。所以,很多開發人員在需要複用一些程式碼的時候會很自然的使用類的繼承的方式,因為書上就是這麼寫的。繼承是物件導向的四大特性之一,用來表示類之間的is-a關係,可以解決程式碼複用的問題。雖然繼承有諸多作用,但繼承層次過深、過複雜,也會影響到程式碼的可維護性。

  假設我們要設計一個關於鳥的類。我們將“鳥”這樣一個抽象的事物概念,定義為一個抽象類AbstractBird。所有更細分的鳥,比如麻雀、鴿子、烏鴉等,都繼承這個抽象類。我們知道,大部分鳥都會飛,那我們可不可以在 AbstractBird抽象類中,定義一個fly()方法呢?

  答案是否定的。儘管大部分鳥都會飛,但也有特例,比如鴕鳥就不會飛。鴕鳥繼承具有fly()方法的父類,那鴕鳥就具有“飛”這樣的行為,這顯然不對。如果在鴕鳥這個子類中重寫fly() 方法,讓它丟擲UnSupportedMethodException異常呢?

具體的程式碼實現如下所示:

public class AbstractBird {
  //...省略其他屬性和方法...
  public void fly() { //... }
}

public class Ostrich extends AbstractBird { //鴕鳥
  //...省略其他屬性和方法...
  public void fly() {
    throw new UnSupportedMethodException("I can't fly.'");
  }
}

  這種寫法雖然可以解決問題,但不優雅。因為除了鴕鳥之外,不會飛的鳥還有很多,比如企鵝。對於這些不會飛的鳥來說,全部都去重寫fly()方法,丟擲異常,完全屬於程式碼重複。理論上這些不會飛的鳥根本就不應該擁有fly()方法,讓不會飛的鳥暴露fly()介面給外部,增加了被誤用的概率。

  要解決上面的問題,就得讓AbstractBird類派生出兩個更加細分的抽象類:會飛的鳥類AbstractFlyableBird和不會飛的鳥類AbstractUnFlyableBird,讓麻雀、烏鴉這些會飛的鳥都繼承 AbstractFlyableBird,讓鴕鳥、企鵝這些不會飛的鳥,都繼承 AbstractUnFlyableBird 類。

具體的繼承關係如下圖所示:

  這樣一來,繼承關係變成了三層。但是如果我們不只關注“鳥會不會飛”,還要繼續關注“鳥會不會叫”,將鳥劃分得更加細緻時呢?兩個關注行為自由搭配起來會產生四種情況:會飛會叫、不會飛會叫、會飛不會叫、不會飛不會叫。如果繼續沿用剛才的設計思路,繼承層次會再次加深。

  如果繼續增加“鳥會不會下蛋”這樣的行為,類的繼承層次會越來越深、繼承關係會越來越複雜。而這種層次很深、很複雜的繼承關係,一方面,會導致程式碼的可讀性變差。因為我們要搞清楚某個類具有哪些方法、屬性,必須閱讀父類的程式碼、父類的父類的程式碼……一直追溯到最頂層父類的程式碼。另一方面,這也破壞了類的封裝特性,將父類的實現細節暴露給了子類。子類的實現依賴父類的實現,兩者高度耦合,一旦父類程式碼修改,就會影響所有子類的邏輯。

繼承最大的問題就在於:繼承層次過深、繼承關係過於複雜時會影響到程式碼的可讀性和可維護性。

組合相比繼承有哪些優勢

  複用性是物件導向技術帶來的很棒的潛在好處之一。如果運用的好的話可以幫助我們節省很多開發時間,提升開發效率。但是,如果被濫用那麼就可能產生很多難以維護的程式碼。作為一門物件導向開發的語言,程式碼複用是Java引人注意的功能之一。Java程式碼的複用有繼承、組合以及委託三種具體的實現形式。

  對於上面提到的繼承帶來的問題,可以利用組合(composition)、介面、委託(delegation)三個技術手段一塊兒來解決。

  介面表示具有某種行為特性。針對“會飛”這樣一個行為特性,我們可以定義一個Flyable介面,只讓會飛的鳥去實現這個介面。對於會叫、會下蛋這些行為特性,我們可以類似地定義Tweetable介面、EggLayable介面。我們將這個設計思路翻譯成Java程式碼的話,就是下面這個樣子:

public interface Flyable {
  void fly();
}
public interface Tweetable {
  void tweet();
}
public interface EggLayable {
  void layEgg();
}
public class Ostrich implements Tweetable, EggLayable {//鴕鳥
  //... 省略其他屬性和方法...
  @Override
  public void tweet() { //... }
  @Override
  public void layEgg() { //... }
}
public class Sparrow implements Flayable, Tweetable, EggLayable {//麻雀
  //... 省略其他屬性和方法...
  @Override
  public void fly() { //... }
  @Override
  public void tweet() { //... }
  @Override
  public void layEgg() { //... }
}

  不過,介面只宣告方法,不定義實現。也就是說,每個會下蛋的鳥都要實現一遍layEgg()方法,並且實現邏輯幾乎是一樣的(可能極少場景下會不一樣),這就會導致程式碼重複的問題。那這個問題又該如何解決呢?有以下兩種方法。

使用委託

  針對三個介面再定義三個實現類,它們分別是:實現了fly()方法的 FlyAbility類、實現了tweet()方法的TweetAbility類、實現了layEgg()方法的 EggLayAbility類。然後,通過組合和委託技術來消除程式碼重複。

public interface Flyable {
  void fly();
}
public class FlyAbility implements Flyable {
  @Override
  public void fly() { //... }
}
//省略Tweetable/TweetAbility/EggLayable/EggLayAbility

public class Ostrich implements Tweetable, EggLayable {//鴕鳥
  private TweetAbility tweetAbility = new TweetAbility(); //組合
  private EggLayAbility eggLayAbility = new EggLayAbility(); //組合
  //... 省略其他屬性和方法...
  @Override
  public void tweet() {
    tweetAbility.tweet(); // 委託
  }
  @Override
  public void layEgg() {
    eggLayAbility.layEgg(); // 委託
  }
}

使用Java8的介面預設方法

在Java8中,我們可以在介面中寫預設實現方法。使用關鍵字default定義預設介面實現,當然這個預設的方法也可以重寫。

public interface Flyable {
  default void fly() {
    //預設實現... 
  }
}


public interface Flyable {
  default void fly() {
    //預設實現... 
  }
}

public interface Tweetable {
  default void tweet() {
    //預設實現... 
  }
}

public interface EggLayable {
  default void layEgg() {
    //預設實現... 
  }
}

public class Ostrich implements Tweetable, EggLayable {//鴕鳥
  //... 省略其他屬性和方法...
}
public class Sparrow implements Flayable, Tweetable, EggLayable {//麻雀
  //... 省略其他屬性和方法...
}

  繼承主要有三個作用:表示is-a關係、支援多型特性、程式碼複用。而這三個作用都可以通過其他技術手段來達成。比如is-a關係,我們可以通過組合和介面的has-a關係來替代;多型特性我們也可以利用介面來實現;程式碼複用我們可以通過組合和委託來實現。所以,從理論上講,通過組合、介面、委託三個技術手段,我們完全可以替換掉繼承,在專案中不用或者少用繼承關係,特別是一些複雜的繼承關係。

如何判斷該用組合還是繼承

  儘管我們鼓勵多用組合少用繼承,但組合也並不是完美的,繼承也並非一無是處。從上面的例子來看,繼承改寫成組合意味著要做更細粒度的類的拆分。這也就意味著,我們要定義更多的類和介面。類和介面的增多也就或多或少地增加程式碼的複雜程度和維護成本。如果類之間的繼承結構穩定(不會輕易改變),繼承層次比較淺(比如,最多有兩層繼承關係),繼承關係不復雜,我們就可以大膽地使用繼承。反之,系統越不穩定,繼承層次很深,繼承關係複雜,我們就儘量使用組合來替代繼承。

  除此之外,還有一些設計模式會固定使用繼承或者組合。比如,裝飾者模式(decorator pattern)、策略模式(strategy pattern)、組合模式(composite pattern)等都使用了組合關係,而模板模式(template pattern)使用了繼承關係。

  有的地方提到組合優先繼承這條軟體開發原則時,可能會說成“多用組合,少用繼承”。所謂多用與少用,實際指的是要弄清楚在具體的場景下需要哪種。軟體開發原則這類問題,不宜死扣字眼。其實在《Thinking in Java》裡有提到,當你用繼承的時候,肯定是想要使用多型的特性。

  比如你要寫一個畫圖系統,畫不同的圖形,這個時候,你可能考慮到呼叫相應的函式的時候可以不考慮具體型別,直接畫就好了,具體什麼圖形,交給執行時去判斷。這個時候,就要用到多型,就需要有繼承關係。一個父類,多個子類。然後用父類的型別去引用具體子類的物件,就可以了。往期面試題彙總:250期面試資料

  而用不到多型的時候,使用繼承有什麼用呢?程式碼複用?一個繼承可以讓你少寫很多程式碼,但是用錯了場合,後期的維護可能是災難性的。因為繼承關係的耦合度很高,一處改會導致處處需要修改。這個時候就需要組合。

所以我堅持,如果不想使用多型特性,繼承關係就是無用的。

處境尷尬的繼承

  大家對繼承的厭惡主要是因為長期以來程式設計師過度使用繼承,繼承並非一無是處。

  在某些特殊場景下,我們必須使用繼承。如果你不能改變一個函式的入參型別,而入參又非介面,為了支援多型,只能採用繼承來實現。比如下面這樣一段程式碼,其中FeignClient是一個外部類,我們無法修改這個外部類,但是我們希望能重寫這個類在執行時執行的encode() 函式。這個時候,我們只能採用繼承來實現了。

public class FeignClient { // Feign Client框架程式碼,只讀不能修改
  //...省略其他程式碼...
  public void encode(String url) { //... }
}

public void demofunction(FeignClient feignClient) {
  //...
  feignClient.encode(url);
  //...
}

public class CustomizedFeignClient extends FeignClient {
  @Override
  public void encode(String url) { //...重寫encode的實現...}
}

// 呼叫
FeignClient client = new CustomizedFeignClient();
demofunction(client);

  上面這個例子,舉得不是太恰當,更像是一種迫不得已。這恰好反映了繼承在物件導向程式設計的大部分場景下的尷尬處境。

  其實我們很難真正使用好繼承,根本原因在於,自然界中,代際之間是存在變異的,物種之間也是,而且這種變化是無法做規律化描述的,既伴隨著某些功能的增加,也伴隨著某些功能的弱化,甚至還有某些功能的改變。

  在軟體行業最早期,軟體功能很貧乏,需要不斷增加軟體功能來滿足需求,這時候繼承關係能夠體現軟體迭代後功能增強的特點。但很快就達到瓶頸期,功能不再是衡量軟體好壞的主要指標,各種差異化的體驗變得更加重要,此時軟體迭代時不再是單純的功能的累加,甚至於是完全的推倒重來,程式語言上的繼承關係也就隨之被廢棄。

注:以上關於組合及繼承的程式碼例子,出自極客時間王爭老師的《設計模式之美》第十講

 

 

 

原文參考公眾號【Java知音】

相關文章