云开·全站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 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(); // 委托
}
}
使用 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 Tweetable, EggLayable {//鸵鸟
//... 省略其他属性和方法...
}
public class Sparrow implements Flayable, Tweetable, EggLayable {//麻雀
//... 省略其他属性和方法...
}
继承主要有三个作用:表示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简历模板!
我要评论