面试官:Java 设计原则中,为什么反复强调组合要优先于继承?
关注Java核心技术,推送更多 Java 干货!
来源:blog.csdn.net/fuzhongmin05/article/details/108646872
面向对象编程中,有一条非常经典的设计原则,那就是:组合优于继承,多用组合少用继承。
同样地,在《阿里巴巴Java开发手册》中有一条规定:谨慎使用继承的方式进行扩展,优先使用组合的方式实现。 这个可以在公众号Java核心技术回复:手册,获取最新高清完整版PDF。
为什么不推荐使用继承
public class AbstractBird {
//...省略其他属性和方法...
public void fly() { //... }
}
public class Ostrich extends AbstractBird { //鸵鸟
//...省略其他属性和方法...
public void fly() {
throw new UnSupportedMethodException("I can't fly.'");
}
}
这样一来,继承关系变成了三层。但是如果我们不只关注“鸟会不会飞”,还要继续关注“鸟会不会叫”,将鸟划分得更加细致时呢?两个关注行为自由搭配起来会产生四种情况:会飞会叫、不会飞会叫、会飞不会叫、不会飞不会叫。如果继续沿用刚才的设计思路,继承层次会再次加深。
如果继续增加“鸟会不会下蛋”这样的行为,类的继承层次会越来越深、继承关系会越来越复杂。而这种层次很深、很复杂的继承关系,一方面,会导致代码的可读性变差。23 种设计模式实战(很全)建议学习下。
因为我们要搞清楚某个类具有哪些方法、属性,必须阅读父类的代码、父类的父类的代码……一直追溯到最顶层父类的代码。另一方面,这也破坏了类的封装特性,将父类的实现细节暴露给了子类。子类的实现依赖父类的实现,两者高度耦合,一旦父类代码修改,就会影响所有子类的逻辑。
继承最大的问题就在于:继承层次过深、继承关系过于复杂时会影响到代码的可读性和可维护性。
组合相比继承有哪些优势
复用性是面向对象技术带来的很棒的潜在好处之一。如果运用的好的话可以帮助我们节省很多开发时间,提升开发效率。但是,如果被滥用那么就可能产生很多难以维护的代码。作为一门面向对象开发的语言,代码复用是Java引人注意的功能之一。
Java代码的复用有继承、组合以及委托三种具体的实现形式。23 种设计模式实战(很全)建议学习下。
对于上面提到的继承带来的问题,可以利用组合(composition)、接口、委托(delegation)三个技术手段一块儿来解决。
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() { //... }
}
使用委托
针对三个接口再定义三个实现类,它们分别是:实现了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定义默认接口实现,当然这个默认的方法也可以重写。23 种设计模式实战(很全)建议学习下。
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关系来替代;多态特性我们也可以利用接口来实现;代码复用我们可以通过组合和委托来实现。
所以,从理论上讲,通过组合、接口、委托三个技术手段,我们完全可以替换掉继承,在项目中不用或者少用继承关系,特别是一些复杂的继承关系。关注Java核心技术,推送更多 Java 干货!
如何判断该用组合还是继承
尽管我们鼓励多用组合少用继承,但组合也并不是完美的,继承也并非一无是处。从上面的例子来看,继承改写成组合意味着要做更细粒度的类的拆分。这也就意味着,我们要定义更多的类和接口。类和接口的增多也就或多或少地增加代码的复杂程度和维护成本。
如果类之间的继承结构稳定(不会轻易改变),继承层次比较浅(比如,最多有两层继承关系),继承关系不复杂,我们就可以大胆地使用继承。反之,系统越不稳定,继承层次很深,继承关系复杂,我们就尽量使用组合来替代继承。
除此之外,还有一些设计模式会固定使用继承或者组合。比如,装饰者模式(decorator pattern)、策略模式(strategy pattern)、组合模式(composite pattern)等都使用了组合关系,而模板模式(template pattern)使用了继承关系。
有的地方提到组合优先继承这条软件开发原则时,可能会说成“多用组合,少用继承”。所谓多用与少用,实际指的是要弄清楚在具体的场景下需要哪种。软件开发原则这类问题,不宜死扣字眼。其实在《Thinking in Java》里有提到,当你用继承的时候,肯定是想要使用多态的特性。
比如你要写一个画图系统,画不同的图形,这个时候,你可能考虑到调用相应的函数的时候可以不考虑具体类型,直接画就好了,具体什么图形,交给运行时去判断。这个时候,就要用到多态,就需要有继承关系。一个父类,多个子类。然后用父类的类型去引用具体子类的对象,就可以了。
而用不到多态的时候,使用继承有什么用呢?代码复用?一个继承可以让你少写很多代码,但是用错了场合,后期的维护可能是灾难性的。因为继承关系的耦合度很高,一处改会导致处处需要修改。这个时候就需要组合。
所以我坚持,如果不想使用多态特性,继承关系就是无用的。最新 Java 面试题整理好了,点击小程序在线刷题。
处境尴尬的继承
大家对继承的厌恶主要是因为长期以来程序员过度使用继承,继承并非一无是处。最新 Java 面试题整理好了,点击小程序在线刷题。
在某些特殊场景下,我们必须使用继承。如果你不能改变一个函数的入参类型,而入参又非接口,为了支持多态,只能采用继承来实现。比如下面这样一段代码,其中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);
上面这个例子,举得不是太恰当,更像是一种迫不得已。这恰好反映了继承在面向对象编程的大部分场景下的尴尬处境。
其实我们很难真正使用好继承,根本原因在于,自然界中,代际之间是存在变异的,物种之间也是,而且这种变化是无法做规律化描述的,既伴随着某些功能的增加,也伴随着某些功能的弱化,甚至还有某些功能的改变。
在软件行业最早期,软件功能很贫乏,需要不断增加软件功能来满足需求,这时候继承关系能够体现软件迭代后功能增强的特点。但很快就达到瓶颈期,功能不再是衡量软件好坏的主要指标,各种差异化的体验变得更加重要,此时软件迭代时不再是单纯的功能的累加,甚至于是完全的推倒重来,编程语言上的继承关系也就随之被废弃。
注:以上关于组合及继承的代码例子,出自极客时间王争老师的《设计模式之美》第十讲
最近好文分享
5. 妙用 Java 8 Function 接口,消灭 if...else
一个分享Java核心技术干货的公众号