引入里氏替换原则(LiskovSubstitution Principle,LSP)的目的就是为了解决继承中父子类之间产生的强耦合性。
2.1 爱恨纠葛的父子关系
继承
在面向对象的语言中,继承是必不可少的、非常优秀的语言机制
继承的优点
● 代码共享,减少创建类的工作量,每个子类都拥有父类的方法和属性;
● 提高代码的重用性;
● 子类可以形似父类,但又异于父类,"龙生龙,凤生凤,老鼠生来会打洞"是说子拥有父的"种","世界上没有两片完全相同的叶子"是指明子与父的不同;
● 提高代码的可扩展性,实现父类的方法就可以"为所欲为"了,君不见很多开源框架的扩展接口都是通过继承父类来完成的;
● 提高产品或项目的开放性。
继承的缺点
● 继承是侵入性的。只要继承,就必须拥有父类的所有属性和方法;
● 降低代码的灵活性。子类必须拥有父类的属性和方法,让子类自由的世界中多了些约束;
● 增强了耦合性。当父类的常量、变量和方法被修改时,需要考虑子类的修改,而且在缺乏规范的环境下,这种修改可能带来非常糟糕的结果——大段的代码需要重构。
Java使用extends关键字来实现继承,它采用了单一继承的规则,C++则采用了多重继承的规则,一个子类可以继承多个父类。
从整体上来看,利大于弊,怎么才能让"利"的因素发挥最大的作用,同时减少"弊"带来的麻烦呢?
解决方案是引入里氏替换原则(LiskovSubstitution Principle,LSP)。
里氏替换原则
里氏替换原则(Liskov Substitution Principle,LSP)
- 第一种定义:
虽然比较正宗,但比较晦涩,了解即可
- 第二种定义:
所有引用基类的地方必须能透明地使用其子类的对象。
解释:【】
只要父类能出现的地方子类就可以出现,而且替换为子类也不会产生任何错误或异常,使用者可能根本就不需要知道是父类还是子类。
但是,反过来就不行了,有子类出现的地方,父类未必就能适应。
2.2 纠纷不断,规则压制
里氏替换原则为良好的继承定义了一个规范,一句简单的定义包含了4层含义。
1.子类必须完全实现父类的方法
我们在做系统设计时,经常会定义一个接口或抽象类,然后编码实现,调用类则直接传入接口或抽象类,其实这里已经使用了里氏替换原则。
- // 抽象枪支
- public
abstract
class AbstractGun { - public
abstract
void shoot(); - }
- public
class HandGun extends AbstractGun { - @Override
- public
void shoot() { - System.out.println("手枪开枪:啪啪啪");
- }
- }
- public
class Rifle extends AbstractGun { - @Override
- public
void shoot() { - System.out.println("步枪开枪:叭叭叭");
- }
- }
- public
class MachineGun extends AbstractGun { - @Override
- public
void shoot() { - System.out.println("机枪开枪:哒哒哒");
- }
- }
- public
class Soldier { - // 有一把枪
- private AbstractGun ag;
- public AbstractGun getAg() {
- return ag;
- }
- public
void setAg(AbstractGun ag) { - this.ag = ag;
- }
- // 杀敌
- public
void killEnemy(){ - System.out.println("士兵上战场杀敌");
- ag.shoot();
- }
- }
- public
class Client { - public
static
void main(String[] args) { - // 创建士兵
- Soldier s = new Soldier();
- // 给一支枪
- //s.setAg(new HandGun());
- //s.setAg(new Rifle());
- s.setAg(new MachineGun());
- // 上阵杀敌
- s.killEnemy();
- System.out.println("--------------");
- // 创建狙击手对象
- Snipper sanMao = new Snipper();
- // 给狙击手狙击步枪
- sanMao.setAug(new AUG());
- // 创建父类向下转型作为参数
- //sanMao.setAug((AUG)(new Rifle())); // java.lang.ClassCastException:根本就没有多态何来向下转型之说呢
- // 杀死敌人
- sanMao.killEnemy();
- }
- }
在编写程序时Solider士兵类根本就不用知道是哪个型号的枪(子类)被传入。
注意事项1
在类中调用其他类时务必要使用父类或接口,如果不能使用父类或接口,则说明类的设计已经违背了LSP原则。
继承就是告诉你拥有父类的方法和属性,然后你就可以重写父类的方法。
但是在具体应用场景中就要考虑下面这个问题了:子类是否能够完整地实现父类的业务。
注意事项2
如果子类不能完整地实现父类的方法,或者父类的某些方法在子类中已经发生"畸变",则建议断开父子继承关系,采用依赖、聚集、组合等关系代替继承。
2.子类可以有自己的个性
子类当然可以有自己的行为和外观了,也就是方法和属性。
有时候需要直接方法上的参数需要直接传递子类进来,可能不需要多态,不过这种情况比较少。
从里氏替换原则来看,就是有子类出现的地方父类未必就可以出现。
3.覆盖或实现父类的方法时输入参数可以被放大
方法中的输入参数称为前置条件,"契约优先"的原则。
子类中方法的前置条件必须与超类中被覆写的方法的前置条件相同(重写)或者更宽松(重载)。
4. 覆写或实现父类的方法时输出结果可以被缩小
父类的一个方法的返回值是一个类型T,子类的相同方法(重载或覆写)的返回值为S,
那么里氏替换原则就要求S必须小于等于T,也就是说,要么S和T是同一个类型,要么S是T的子类
重写
父类和子类的同名方法的输入参数是相同的,两个方法的范围值子类方法返回值S小于等于父类方法返回值T。
重载
要求方法的输入参数类型或数量不相同,子类的输入参数宽于或等于父类的输入参数
保证父类的地方替换成子类,不会调用子类与父类的同名方法。
总结
采用里氏替换原则的目的就是增强程序的健壮性,版本升级时也可以保持非常好的兼容性。即使增加子类,原有的子类还可以继续运行。
在实际项目中,每个子类对应不同的业务含义,使用父类作为参数,传递不同的子类完成不同的业务逻辑,非常完美!
2.3 最佳实践
在项目中,采用里氏替换原则时,尽量避免子类的"个性",一旦子类有"个性",
这个子类和父类之间的关系就很难调和了,把子类当做父类使用,子类的"个性"被抹杀——委屈了点;
把子类单独作为一个业务来使用,则会让代码间的耦合关系变得扑朔迷离——缺乏类替换的标准。