设计模式之美-课程笔记4

理论七:为何说要多用组合少用继承?如何决定该用组合还是继承?

为什么不推荐使用继承

  1. 继承虽然可以解决代码的复用问题,但是继承层次过深,过复杂也会影响到代码的可维护性。

  2. 举个例子

    • 假设我们要设计一个关于鸟的类。我们将“鸟类”这样一个抽象的事物概念,定义为一个抽象类 AbstractBird。所有更细分的鸟,比如麻雀、鸽子、乌鸦等,都继承这个抽象类。

    • 大部分鸟都会飞,那我们可不可以在 AbstractBird 抽象类中,定义一个 fly() 方法呢?答案是否定的。

    • 尽管大部分鸟都会飞,但也有特例,比如鸵鸟就不会飞。鸵鸟继承具有 fly() 方法的父类,那鸵鸟就具有“飞”这样的行为,这显然不符合我们对现实世界中事物的认识。当然,你可能会说,我在鸵鸟这个子类中重写(override)fly() 方法,让它抛出 UnSupportedMethodException 呢?

    • 能解决问题,不够好。还是有一些鸟不会飞,首先可能都需要给他们重写fly方法,增加编码工作量;另外也违背了之后要讲的最小只是原则,暴露不该暴露的接口给外部,增加了类使用过程中被误用的概率。

  3. 有人会建议,针对抽象鸟类再写两个子类,分会飞和不会飞。那如果还关注鸟会不会叫呢?这时候就要再定义四个抽象类基于上面的两个会飞不会飞的抽象类:

    img
    img

    这一看就只适用比较局限的场景

  4. 更多的feature会导致类继承关系越来越深,代码可读性变差,要搞清楚哪个类有哪些方法属性,必须阅读父类、爷爷类的代码。。。另一方面也破坏了类的封装特性,父类的视线暴露给子类,子类的实现依赖父类的实现。(在实现会飞会叫的鸟类,需要从会飞的鸟类去继承,严重耦合)。父类代码修改,影响子类逻辑(这句话实在针对继承吧 == )

组合的优势

  1. 组合,接口和委托三个手段可以解决上述问题。

  2. 接口表示具有某种行为特性。针对“会飞”这样一个行为特性,我们可以定义一个 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 impelents Flyable, Tweetable, EggLayable {//麻雀
      //... 省略其他属性和方法...
      @Override
      public void fly() { //... }
      @Override
      public void tweet() { //... }
      @Override
      public void layEgg() { //... }
    }
    
  3. 但是接口没有实现,所以实现类都需要把所有的方法实现一遍。

  4. 组合和委托。

    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(); // 委托
      }
    }
    
  5. 我的理解就是:我有一个类,现在不使用继承了(因为有一些弊端),所以现在使用接口+代理+组合来完成它的功能。

    1. 接口定义他的行为动作,或者上文讲得准则,可以实现类中一个对象的行为;又避免了过度耦合
    2. 我再拿一个代理类实现同样的接口,当前类可以实例化这个代理类并且调用。这个过程可以让更多的其他类不需要实现,只需要调用代理类的实现方法即可,提升了代码复用,减少了编程量。
    3. 组合就是说的是代理类和实际类一起用?or多个接口组合在一起。

如何判断用继承还是组合

基于继承关系的复杂度和层级

  1. 继承改写成组合意味着更细粒度的拆分,定义更多的类和接口,意味着更复杂和更多维护成本。这是一个trade off
  2. 类之间的继承结构稳定,层级也比较浅,仍然可以选择继承。反之组合可能更好。
  3. 此外一些设计模式也会使用继承或组合。

基于实际业务意义和对象关系

  1. 利用继承特性,我们把相同的属性和方法,抽取出来,定义到父类中。子类复用父类中的属性和方法,达到代码复用的目的。

  2. 但是,有的时候从业务含义上,A 类和 B 类并不一定具有继承关系。比如,Crawler 类和 PageAnalyzer 类,它们都用到了 URL 拼接和分割的功能,但并不具有继承关系(既不是父子关系,也不是兄弟关系)。

  3. 仅仅为了代码复用,生硬地抽象出一个父类出来,会影响到代码的可读性。如果不熟悉背后设计思路的同事,发现 Crawler 类和 PageAnalyzer 类继承同一个父类,而父类中定义的却只是 URL 相关的操作,会觉得这个代码写得莫名其妙,理解不了。这个时候,使用组合就更加合理、更加灵活。

    public class Url {
      //...省略属性和方法
    }
    
    public class Crawler {
      private Url url; // 组合
      public Crawler() {
        this.url = new Url();
      }
      //...
    }
    
    public class PageAnalyzer {
      private Url url; // 组合
      public PageAnalyzer() {
        this.url = new Url();
      }
      //..
    }
    
  4. 有些场景我们必须使用继承,比如你不能改变一个函数(demoFunction)的入参类型(FeignClien),而入参又非接口,为了支持多态,只能采用继承来实现。比如下面这样一段代码,其中 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);