专栏首页Kevin-ZhangCG在设计原则中,为什么反复强调组合要优于继承?

在设计原则中,为什么反复强调组合要优于继承?

面向对象编程中,有一条非常经典的设计原则,那就是:组合优于继承,多用组合少用继承。同样地,在《阿里巴巴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知音】

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

我来说两句

0 条评论
登录 后参与评论

相关文章

  • 最近项目重构的一些感想

    1. 缘起2. 面向对象仍然是主流的设计风格3. 世界是有状态的,导致我们的代码也是充满了各种各样的状态4. 什么是『组合』,什么是『组合优于继承』5. 『继承...

    芋道源码
  • 最近项目重构的一些感想

    来源:https://juejin.im/post/5b46121f5188251ac446d0b4

    程序猿DD
  • 最近项目重构的一些感想

    最近,因为多个因素综合作用的情况下,我有幸得以负责一个项目的重构事项,并且时间/空间上都是相当宽松。而且由于系统较为复杂,需要对接多个业务开发部门,导致各种大需...

    Java团长
  • 设计模式之美笔记(一)

    1.“基于接口而非实现编程”,这条原则的另一个表述方式,是“基于抽象而非实现编程”。后者的表述方式其实更能体现这条原则的设计初衷。我们在做软件开发的时候,一定要...

    居士
  • 代码设计原则

    设计模式是针对软件开发过程中遇到的一些设计问题,总结出来的一套解决方案或者设计思路。

    王小明_HIT
  • 作为一名合格的开发者,必须了解的编程原则有哪些?

    关注点分离是一种将计算机程序分离成不同部分的设计原则,以便每个部分专注于单个关注点。例如,应用程序的业务逻辑是一个关注点而用户界面是另一个关注点。更改用户界面不...

    程序员白楠楠
  • 无用的设计模式-上篇

    本文目的,通过对设计模式的本质进行探讨剖析,建立起更为高效的认知模式。最终可以灵活运用设计模式到日常工作中,产出稳定、高效、灵活的业务实现。

    有赞coder
  • Gof23设计模式趣味详解(三分钟上手系列)

    学习设计模式可以提高程序员的思维能力、编程能力和设计能力,从而使你写的代码可重用性更高、可读性更强、可靠性更高、灵活性更好、可维护性更强

    bboy枫亭
  • 常见代码重构技巧(非常实用)

    项目在不断演进过程中,代码不停地在堆砌。如果没有人为代码的质量负责,代码总是会往越来越混乱的方向演进。当混乱到一定程度之后,量变引起质变,项目的维护成本已经高过...

    路人甲Java
  • Java面试:2021.05.16(周末愉快!)

    1、try{}里有一个return语句,那么紧跟在这个try后的finally{}里的代码会不会被执行,什么时候被执行,在return前还是后? 答:会执行...

    夕梦
  • JavaSE语法

    支持国产的话可以考虑阿里的dragonwell:https://cn.aliyun.com/product/dragonwell

    Noneplus
  • PHP核心技术与最佳实践(一)

    1.面向对象思想的核心思想是对象、封装、可重用性和可扩展性。面向对象是建立在面向过程之上的更高层次的抽象。

    硬核项目经理
  • 简述一下面向对象的”六原则一法则”

    唐怀瑟
  • 面向对象的"六原则一法则"

    唐怀瑟
  • 代码重构的艺术

    所谓重构是这样一个过程:在不改变代码外在行为的前提下,对源代码做出修改,以改进程序的内部结构,从而使代码变得易于理解,可维护和可扩展。本质上来说重构就是在代码写...

    coder_koala
  • 前端-一篇文章理解 JS 继承

    说实在话,以前我只需要知道“寄生组合继承”是最好的,有个祖传代码模版用就行。最近因为一些事情,几个星期以来一直心心念念想整理出来。本文以《JavaScript高...

    grain先森
  • 【技术创作101训练营】代码设计与单元测试

    我们本次分享的主题是“设计原则与单元测试”。这两个概念对于每个开发人员都耳熟能详,但有多少同学可以在实际开发中真正落地并有效提高研发质量呢?代码的设计原则关注点...

    大耳鼠
  • 我们在开发中需要遵循的几个设计原则!

    在软件开发中,前人对软件系统的设计和开发总结了一些原则和模式, 不管用什么语言做开发,都将对我们系统设计和开发提供指导意义。本文主要将总结这些常见的原则和具体阐...

    用户5224393
  • Java基础题

    不能。在Java中,只要是被定义为final的类,也可以说是被final修饰的类,就是不能被继承的。

    用户3467126

扫码关注云+社区

领取腾讯云代金券