设计模式之美-课程笔记4
理论七:为何说要多用组合少用继承?如何决定该用组合还是继承?
为什么不推荐使用继承
-
继承虽然可以解决代码的复用问题,但是继承层次过深,过复杂也会影响到代码的可维护性。
-
举个例子
-
假设我们要设计一个关于鸟的类。我们将“鸟类”这样一个抽象的事物概念,定义为一个抽象类 AbstractBird。所有更细分的鸟,比如麻雀、鸽子、乌鸦等,都继承这个抽象类。
-
大部分鸟都会飞,那我们可不可以在 AbstractBird 抽象类中,定义一个 fly() 方法呢?答案是否定的。
-
尽管大部分鸟都会飞,但也有特例,比如鸵鸟就不会飞。鸵鸟继承具有 fly() 方法的父类,那鸵鸟就具有“飞”这样的行为,这显然不符合我们对现实世界中事物的认识。当然,你可能会说,我在鸵鸟这个子类中重写(override)fly() 方法,让它抛出 UnSupportedMethodException 呢?
-
能解决问题,不够好。还是有一些鸟不会飞,首先可能都需要给他们重写fly方法,增加编码工作量;另外也违背了之后要讲的最小只是原则,暴露不该暴露的接口给外部,增加了类使用过程中被误用的概率。
-
-
有人会建议,针对抽象鸟类再写两个子类,分会飞和不会飞。那如果还关注鸟会不会叫呢?这时候就要再定义四个抽象类基于上面的两个会飞不会飞的抽象类:
这一看就只适用比较局限的场景
-
更多的feature会导致类继承关系越来越深,代码可读性变差,要搞清楚哪个类有哪些方法属性,必须阅读父类、爷爷类的代码。。。另一方面也破坏了类的封装特性,父类的视线暴露给子类,子类的实现依赖父类的实现。(在实现会飞会叫的鸟类,需要从会飞的鸟类去继承,严重耦合)。父类代码修改,影响子类逻辑(这句话实在针对继承吧 == )
组合的优势
-
组合,接口和委托三个手段可以解决上述问题。
-
接口表示具有某种行为特性。针对“会飞”这样一个行为特性,我们可以定义一个 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 impelents Flyable, Tweetable, EggLayable {//麻雀 //... 省略其他属性和方法... @Override public void fly() { //... } @Override public void tweet() { //... } @Override public void layEgg() { //... } }
-
但是接口没有实现,所以实现类都需要把所有的方法实现一遍。
-
组合和委托。
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(); // 委托 } }
-
我的理解就是:我有一个类,现在不使用继承了(因为有一些弊端),所以现在使用接口+代理+组合来完成它的功能。
- 接口定义他的行为动作,或者上文讲得准则,可以实现类中一个对象的行为;又避免了过度耦合
- 我再拿一个代理类实现同样的接口,当前类可以实例化这个代理类并且调用。这个过程可以让更多的其他类不需要实现,只需要调用代理类的实现方法即可,提升了代码复用,减少了编程量。
- 组合就是说的是代理类和实际类一起用?or多个接口组合在一起。
如何判断用继承还是组合
基于继承关系的复杂度和层级
- 继承改写成组合意味着更细粒度的拆分,定义更多的类和接口,意味着更复杂和更多维护成本。这是一个trade off
- 类之间的继承结构稳定,层级也比较浅,仍然可以选择继承。反之组合可能更好。
- 此外一些设计模式也会使用继承或组合。
基于实际业务意义和对象关系
-
利用继承特性,我们把相同的属性和方法,抽取出来,定义到父类中。子类复用父类中的属性和方法,达到代码复用的目的。
-
但是,有的时候从业务含义上,A 类和 B 类并不一定具有继承关系。比如,Crawler 类和 PageAnalyzer 类,它们都用到了 URL 拼接和分割的功能,但并不具有继承关系(既不是父子关系,也不是兄弟关系)。
-
仅仅为了代码复用,生硬地抽象出一个父类出来,会影响到代码的可读性。如果不熟悉背后设计思路的同事,发现 Crawler 类和 PageAnalyzer 类继承同一个父类,而父类中定义的却只是 URL 相关的操作,会觉得这个代码写得莫名其妙,理解不了。这个时候,使用组合就更加合理、更加灵活。
public class Url { //...省略属性和方法 } public class Crawler { private Url url; // 组合 public Crawler() { this.url = new Url(); } //... } public class PageAnalyzer { private Url url; // 组合 public PageAnalyzer() { this.url = new Url(); } //.. }
-
有些场景我们必须使用继承,比如你不能改变一个函数(demoFunction)的入参类型(FeignClien),而入参又非接口,为了支持多态,只能采用继承来实现。比如下面这样一段代码,其中 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);