继承的本质是提高代码的复用。
然而自从继承出世后,被人们过多滥用,以至于像耦合恐惧一样恐惧继承,得了“继承创伤应激障碍”。
是不是有像耦合必然性一样,解决继承的创伤呢?
里氏替换原则
这个原则最早是在1986年由 Barbara Liskov 提出:
若对每个类型T1的对象o1,都存在一个类型T2的对象o2,使得在所有针对T2编写的程序P中,用o1替换o2后,程序P的行为功能不变,则T1是T2的子类型
在1996年,Robert Martin 重新描述了这个原则:
Functions that use pointers of references to base classes must be able tu use objects of derived classes without knowing it
通俗地讲,就是子类型必须能够替换掉它们的基类型,并且保证原来的逻辑行为不变及正确性不被破坏。
比如那个著名的长方形的例子。Rectangle 表示长方形,Square 继承 Rectangle,表示正方形。现在问题就来了,这个关系在数学中是正确的,但表示为代码却不太正确。长方形可以用成员函数单独变更长宽,但正方形却不行,长和宽必须同时变更。
1 | Rectangle rect = new Square(); |
当正方形 Square 替换长方形 Rectangle 时,发现程序不能正确运行,这样就不满足LSP,也就不适合使用继承。
还有那个同样著名的鸟类的例子。基类 Bird 有个 Fly 方法,所有的鸟类都应该继承它。但企鹅、鸵鸟这样的鸟类却不会飞,实现它们就必须改写 Fly 方法
1 |
|
这样的设计
一方面,徒增了编码的工作量
另一方面,也违背了我们之后要讲的最小知识原则(Least Knowledge Principle,也叫最少知识原则或者迪米特法则),暴露不该暴露的接口给外部,增加了类使用过程中被误用的概率。
想验证有没有违背里氏替换原则,主要是两方面:
1、IS-A的判定是基于行为,只有行为相同,才能说是满足IS-A关系。
2、通常说来,子类比父类的契约更严格,都是违反里氏替换原则的。
在类的继承中,如果父类方法的访问控制是 protected,那么子类 override 这个方法的时候,可以改成是 public,但是不能改成 private。因为 private 的访问控制比 protected 更严格,能使用父类 protected 方法的地方,不能用子类的 private 方法替换,否则就是违反里氏替换原则的。相反,如果子类方法的访问控制改成 public 就没问题,即子类可以有比父类更宽松的契约。同样,子类 override 父类方法的时候,不能将父类的 public 方法改成 protected,否则会出现编译错误。
而像长方形例子中,正方形继承了长方形,但是正方形有比长方形更严格的契约,即正方形要求长和宽是一样的。因为正方形有比长方形更严格的契约,那么在使用长方形的地方,正方形因为更严格的契约而无法替换长方形。
多态和里氏替换有点类似,但它们的关注的角度是不一样的。多态是面向对象编程的一大特性,也是面向对象编程语言的一种语法。它是一种代码实现的思路。而里氏替换是一种设计原则,是用来指导继承关系中子类该如何设计的,子类的设计要保证在替换父类的时候,不改变原有程序的逻辑以及不破坏原程序的正确性。
其实还是回归继承本质,就是为了代码复用,子类最好不要覆盖父类的任何方法,只做额外增强
组合优于继承
组合优于继承:如果一个方案既能用组合实现,也能用继承实现,那就选择组合实现。
上面提到的鸟类例子,通过 AbstractBird 类派生出两个更加细分的抽象类:会飞的鸟类 AbstractFlyableBird 和不会飞的鸟类 AbstractUnFlyableBird。当还要关注“鸟会不会叫”的时候,
那就需要再定义四个抽象类(AbstractFlyableTweetableBird、AbstractFlyableUnTweetableBird、AbstractUnFlyableTweetableBird、AbstractUnFlyableUnTweetableBird)
类的继承层次会越来越深、继承关系会越来越复杂。而这种层次很深、很复杂的继承关系
一方面,会导致代码的可读性变差。因为我们要搞清楚某个类具有哪些方法、属性,必须阅读父类代码、父类的父类的代码,一直追溯到最顶层父类的代码。
另一方面,这也破坏了类的封装特性,将父类的实现细节暴露给了子类。子类的实现依赖父类的实现,两者高度耦合,一旦父类代码修改,就会影响所有子类的逻辑。
简而言之,继承最大的问题就在于:继承层次过深、继承关系过于复杂会影响到代码的可读性和可维护性。
而利用组合、接口、委托三个技术手段,重构一下上面的继承问题1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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(); // 委托
}
}
总结
尽管我们鼓励多用组合少用继承,但组合也并不是完美的,继承也并非一无是处。继承改写成组合意味着要做更细粒度的类的拆分。这意味着,我们要定义更多的类和接口。类和接口的增多也就或多或少地增加代码的复杂程度和维护成本。
如果类之间的继承结构稳定,继承层次比较浅(比如,最多有两层继承关系),继承关系不复杂,我们就可以大胆使用继承。反之,系统越不稳定,继承层次很深,继承关系复杂,就尽量使用组合替代继承。