云开·全站apply体育官方平台 面试官:Java 设计原则中,为什么反复强调组合要优先于继承?

文章/详细信息/108646872

在面向对象编程中,有一个非常经典的设计原则kaiyun体育登录网页入口,那就是:组合优于继承,多用组合,少用继承。

类似地,阿里巴巴 Java 开发手册中也有一条规定:谨慎使用继承扩展,优先使用组合。(手册电子版可通过回复公众号 SpringForAll 社区:手册获取最新高清完整版 PDF。)

个人简历的顺序_于继承个人简历_被继承人履历表

为什么不建议继承

大家刚开始学习面向对象编程的时候,都认为继承可以实现类的重用。因此很多开发者在需要重用某些代码的时候,会很自然地使用类继承,因为书上是这么说的。继承是面向对象编程的四大特性之一,用来表达类与类之间的 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。

具体的继承关系如下图所示:

个人简历的顺序_被继承人履历表_于继承个人简历

这样,继承关系就变成了三层。但是如果我们不只关注“鸟儿是否会飞”,还继续关注“鸟儿是否会唱歌”,把鸟儿划分成更细的类别会怎么样呢?两种行为可以自由组合,产生能飞会唱歌、不能飞会唱歌、能飞不会唱歌、不能飞不会唱歌四种情况。如果继续沿用之前的设计思路,继承层次又会加深。

被继承人履历表_个人简历的顺序_于继承个人简历

如果继续添加类似“鸟是否下蛋”这样的行为,类的继承层次会越来越深,继承关系也会越来越复杂,一方面这种深而复杂的继承关系会让代码的可读性变差。

因为如果我们想了解一个类有哪些方法和属性,就必须读完父类的代码,再读父类的父类的代码,以此类推一直读到最顶层的父类。另一方面这也破坏了类的封装性,把父类的实现细节暴露给了子类。子类的实现依赖于父类的实现,两者耦合性很强,一旦修改父类代码,就会影响所有子类的逻辑。

继承的最大问题是当继承层次过深或者继承关系过于复杂时,会影响代码的可读性和可维护性。

与继承相比,组合有哪些优点?

可重用性是面向对象技术的巨大潜在优势之一,如果使用得当,它可以帮助我们节省大量开发时间,提高开发效率,但如果滥用kaiyun体育,则可能产生大量难以维护的代码。作为面向对象的开发语言,代码重用是 Java 的一大吸引力。

Java 代码复用的具体实现形式有三种:继承、组合、委托。如果你正在学习 Spring Boot,我推荐一个连载多年并且还在不断更新的免费教程:

上面提到的由于继承而导致的问题,可以通过组合、接口、委托三种技术手段一起解决。

接口代表某种行为,比如“飞翔”这种行为,我们可以定义一个 Flyable 接口,只让会飞的鸟实现这个接口。而像鸣叫、下蛋这种行为,我们可以类似地定义 Tweetable 和 EggLayable 接口。如果把这个设计思路转化成 Java 代码,就是这个样子:

public interface Flyable {
  void fly();
}
public interface Tweetable {
  void tweet();
}
public interface EggLayable {
  void layEgg();
}
public class Ostrich implements TweetableEggLayable {//鸵鸟
  //... 省略其他属性和方法...
  @Override
  public void tweet() //... }
  @Override
  public void layEgg() //... }
}
public class Sparrow implements FlayableTweetableEggLayable {//麻雀
  //... 省略其他属性和方法...
  @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 TweetableEggLayable {//鸵鸟
  private TweetAbility tweetAbility = new TweetAbility(); //组合
  private EggLayAbility eggLayAbility = new EggLayAbility(); //组合
  //... 省略其他属性和方法...
  @Override
  public void tweet() {
    tweetAbility.tweet(); // 委托
  }
  @Override
  public void layEgg() {
    eggLayAbility.layEgg(); // 委托
  }
}

使用 Java 8 接口默认方法

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 TweetableEggLayable {//鸵鸟
  //... 省略其他属性和方法...
}
public class Sparrow implements FlayableTweetableEggLayable {//麻雀
  //... 省略其他属性和方法...
}

继承主要有三个作用:表示is-a关系、支持多态、代码重用。这三个作用都可以通过其他技术手段实现,比如可以用组合、接口的has-a关系代替is-a关系;利用接口也可以实现多态;通过组合、委托可以实现代码重用。

所以从理论上来说,通过组合、接口、委托这三种技术手段,我们完全可以取代继承,在项目中不用或者少用继承关系,特别是一些复杂的继承关系。

如何决定使用组合还是继承

虽然我们提倡多用组合,少用继承,但是组合并不完美,继承也并非完全没用。从上面的例子看,将继承重写为组合,意味着在更细的粒度上拆分类,这就意味着我们需要定义更多的类和接口,类和接口的增加或多或少都会增加代码的复杂度和维护成本。

如果类之间的继承结构比较稳定(不会轻易改变),继承层次比较浅(比如最多只有两层继承),继承关系也不复杂,就可以大胆使用继承。反之,系统越不稳定,继承层次越深,继承关系复杂,就应该尽量使用组合,而不要使用继承。

另外,还有一些设计模式使用继承或者组合,比如装饰器模式、策略模式、组合模式等都是使用组合,而模板模式则使用继承。

有些地方提到组合优于继承的软件开发原则时,可能会说“多用组合,少用继承”。所谓多用少用,其实就是搞清楚具体场景下需要用哪个。软件开发原则不宜拘泥于文字。其实《Thinking in Java》里就提到,用继承的时候,肯定要用多态的特性。(电子书可通过回复公众号SpringForAll社区:编程获取最新高清完整版PDF)

比如你要写一个绘图系统来绘制不同的图形,你可能会考虑不用考虑具体的类型,直接调用对应的函数就可以了,具体的图形会由运行时来决定。这时候就需要多态了,需要继承。一个父类,多个子类。然后使用父类类型来引用具体的子类对象。

当不需要多态的时候,继承有什么用?代码重用?继承可以为你节省很多代码,但是如果用错了场合,后续的维护可能就是灾难性的。因为继承关系耦合度很高,一处改动就会导致处处都改动。这时候就需要组合了。

所以我坚持认为如果你不想使用多态,继承就是没用的。

尴尬的继承

被继承人履历表_于继承个人简历_个人简历的顺序

人们之所以厌恶继承,主要是因为程序员长期以来过度使用了继承。继承并不是完全没用的。

在一些特殊场景下,我们必须使用继承。如果无法改变一个函数的入参类型,并且入参不是接口,那么为了支持多态,就只能使用继承来实现。比如下面的代码中,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);

上面的例子并不是很恰当,而更像是不得已而为之。这恰恰体现了继承在面向对象编程大多数场景下的尴尬处境。

其实,我们很难利用好遗传,根本原因是自然界中,代与代之间、物种与物种之间存在变异,而且这种变化不能用规律性来描述,伴随有某些功能的增强,某些功能的减弱,甚至某些功能的改变。

在软件行业早期,软件功能非常有限,需要不断添加以满足需求。此时继承关系可以体现出软件经过迭代后增强的特性。但很快就到了瓶颈期云开·全站APP登录入口,功能不再是衡量软件质量的主要指标。各种差异化的体验变得更加重要。此时软件迭代不再是功能的简单堆积,甚至是彻底的重新设计。编程语言中的继承关系也被抛弃。

注:以上关于组合与继承的代码示例出自极客时光王政老师《设计模式之美》第十讲

过去的

预计

精制

颜色

重要提醒:关注我并回复【简历】即可限时免费领取高质量word版Java简历模板!

个人简历的顺序_被继承人履历表_于继承个人简历

关键词:

客户评论

我要评论