面向对象编程03:组合、继承与委托

多用组合少用继承

我们再来讲讲另外一个原则:组合优于继承,多用组合少用继承。

为什么这么说呢?

1.为什么不推荐使用继承?

由于继承层次过深、过复杂,很多人觉得继承是一种反模式,应该尽量少用,甚至不用。为什么会有这样的争议?我们举个例子来解释一下。

假设我们要设计一个关于鸟的类,我们定义了一个抽象类AbstractBird,比如麻雀、鸽子、乌鸦等,都继承这个抽象类。

大部分鸟都会飞,所以我们定义了一个fly()方法,但是凡事有例外,比如鸵鸟是不会飞的,所以我们让鸵鸟类重写了fly()方法,让它抛出UnSupportedMethodException异常:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
public class AbstractBird {
  //...省略其他属性和方法...
  public void fly() { //... }
}

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

这种设计思路虽然可以解决问题,但是不够优美。因为还有很多其他鸟也不会飞,这样写增加了代码的维护量,也不符合最小知识原则。

所以我们提出另外一种方案,我们把AbstractBird派生出另外两个细分的抽象类:会飞的鸟类 AbstractFlyableBird 和不会飞的鸟类 AbstractUnFlyableBird,继承关系如下图所示:

img

整体来讲,目前的继承关系还比较简洁,层次也不深,可以接受。但是实际情况中随着业务的深入,我们的实现会越来越复杂。比如说现在又增加了”鸟会不会叫“这个关注点,这时候我们又该如何设计呢?

按照刚才的设计思路,我们需要定义四个抽象类,如果再考虑”鸟会不会下蛋“,那这个抽象类的个数是指数级上升的。

类的继承层次越深,继承关系越复杂,代码的可读性就会越差。子类的实现依赖父类的实现,两者高度耦合,一旦父类代码修改就会影响所有子类的逻辑。

2.组合相比继承有哪些优势?

实际上,我们可以使用**组合(composition)、接口(interface)、委托(delegation)**三个技术手段,来解决刚刚继承存在的问题。

我们知道接口具有行为特性,针对”会飞“这样一个行为特性,我们定义一个Flyable接口,所有会飞的鸟都实现这个接口,对于会叫、下蛋这些行为,也是如此。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
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() { //... }
}

但是接口只声明方法,不定义实现,也就是说每个会下蛋的鸟都要事先下蛋的方法。这样就会导致代码重复问题,那么这个问题又该如何解决呢??

我们针对这三个接口,再定义三个实现类,这三个实现类分别实现了fly()、tweet()和layEgg()方法,然后在具体的”鸟“对象中委托这三个实现类来实现相应的功能,通过组合和委托技术来消除代码重复,伪代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
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(); // 委托
  }
}

小结

我们知道继承主要有三个作用:表示 is-a 关系,支持多态特性,代码复用。而这三个作用都可以通过其他技术手段来达成。比如 is-a 关系,我们可以通过组合和接口的 has-a 关系来替代;多态特性我们可以利用接口来实现;代码复用我们可以通过组合和委托来实现。所以,从理论上讲,通过组合、接口、委托三个技术手段,我们完全可以替换掉继承,在项目中不用或者少用继承关系,特别是一些复杂的继承关系。

3.如何判断该用组合还是继承?

尽管我们鼓励多用组合少用继承,但组合也并不是完美的,继承也并非一无是处。

基本原则

如果类之间的继承结构稳定,继承层次较浅。继承关系不复杂,我们可以大胆地使用继承;反之则尽量用组合来替代继承。

设计模式中的固定用法

除此以外,还有一些设计模式会固定使用继承或者组合,比如,装饰者模式(decorator pattern)、策略模式(strategy pattern)、组合模式(composite pattern)等都使用了组合关系,而模板模式(template pattern)使用了继承关系。

其他特殊场景

  • 有的时候,A和B有共同的代码但是并不具有继承关系(既不是父子也不是兄弟),这时候应该是用组合更加合理;

  • 如果你不能改变一个函数的入参类型,而入参又非接口,为了支持多态,只能采用继承来实现。比如下面这样一段代码,其中 FeignClient 是一个外部类,我们没有权限去修改这部分代码,但是我们希望能重写这个类在运行时执行的 encode() 函数。这个时候,我们只能采用继承来实现了。

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    
    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);
    
updatedupdated2023-06-032023-06-03
加载评论