继承与里氏替换原则
继承的优点:
- 代码共享,减少创建类的工作量,每个子类都拥有父类的方法和属性;
- 提高代码的重用性;
- 提高代码可扩展性,实现父类的方法后就可以“为所欲为”了;
- 提高产品和项目的开放性。
继承的缺点:
- 继承是入侵性的,只要继承就必须拥有父类的所有属性和方法;
- 降低代码的灵活性,子类必须拥有父类的属性和方法,收到了约束;
- 增强了耦合性,父类的常量、变量和方法被修改,要考虑子类的修改,可能导致大量的代码重构。
引入 里氏替换原则(Liskov Substitution Principle,LSP) 可以发挥继承的利,减少弊带来的麻烦。
里氏替换原则的两种定义:
- If for each object o1 of type S there is an object o2 of type T such that for all programs P defined in terms of T, the behavior of P is unchanged when o1 is substituted for o2 then S is a subtype of T. (如果对每一个类型为S的对象o1,都有类型为T的对象o2,使得T定义的所有程序P在所有的对象o1替代o2时,程序P的行为没有发生变化,那么类型S是类型T的子类型)(注:笔者认为此处原文有误并予以修正)
- Functions that use pointers or refernences to base classes must be able to use objects of derived classes without knowing it.(所有引用基类的地方必须能透明地使用其子类的对象)
通俗地说,只要父类能出现的地方子类就可以出现,而且替换为子类也不会产生任何错误或异常,反过来则不成立。
里氏替换原则的规则
子类必须完全实现父类的方法
对于描述一款FPS游戏中的抢,其类图如下:
枪的主要职责是设计,具体如何射击在子类中定义。士兵类中定义了killEnemy方法击杀敌人,具体使用什么强击杀敌人调用时才知道。 AbstractGun类 的源程序代码如下:
// 枪支的抽象类
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 gun;
// 给士兵一支枪
public void setGun(AbstractGun _gun) {
this.gun = _gun;
}
public void killEnemy() {
System.out.println("士兵开始射击敌人");
gun.shoot();
}
}
定义士兵使用枪支杀敌,但这把枪是抽象的,具体是什么强还需要在场景中通过setGun方法确定。 场景类Client 的源代码如下:
public class Client {
public static void main(String[] args) {
// 产生三毛这个士兵
Soldier sanMao = new Soldier();
// 给三毛一支枪
sanMao.setGun(new Rifle());
sanMao.killEnemy();
}
}
在这个程序中,直接传入需要给三毛的配枪即可,编写程序时Soldier士兵类无需知道哪个型号的枪被传入。
注意 在类中调用其他类时务必使用父类或接口,如果不能使用父类或接口,则说明类的射击已经违背了LSP原则。
如果新增一个玩具手枪ToyGun类,类图如下:
玩具枪是不能用来射击的,杀不死人,因此不应该实现shoot方法,代码如下:
public class ToyGun extends AbstractGun {
@Override
public void shoot() {
// 玩具枪不能设计,因此不实现
}
}
但此时在Client类中的main函数中,会出现将玩具枪分配给三毛,但是不能射击的情况,即业务逻辑已经出现问题不能运行。有两种解决办法:
- 在Solider类汇总增加
instanceof
来判断是否是玩具枪。但这样解决,每增加一个类,所有与这个父类有关系的类都需要修改。 - 令ToyGun脱离继承,建立一个独立的父类。为了实现代码复用可以与AbstractGun建立关联关系,类图如下:
例如,可以在AbstractToy中关联AbstractGun,将声音、形状等都委托给AbstractGun处理,两个基类下的子类自由延展互不影响。代码如下(笔者自己理解):
// 枪的抽象类
public abstract class AbstractGun {
public abstract void shoot();
public abstract void sound();
}
// 玩具的抽象类
public abstract class AbstractToy {
// 关联AbstractGun
public AbstractGun gun;
public abstract void play();
}
// 手枪类
public class HandGun extends AbstractGun {
@Override
public void shoot() {
System.out.println("shootshootshoot");
}
@Override
public void sound() {
System.out.println("Bangabngabng");
}
}
// 玩具枪类
public class ToyGun extends AbstractToy {
public ToyGun(AbstractGun _gun) {
gun = _gun;
}
@Override
public void play() {
System.out.println("Play Toy ~~");
gun.sound();
}
}
// 场景类
public class Client {
public static void main(String[] args) {
AbstractToy toyGun = new ToyGun(new HandGun());
toyGun.play();
}
}
运行结果:
Play Toy ~~
Bangabngabng
具体应用场景中,需要考虑子类是否能够完整地实现父类的业务,否则会导致业务逻辑出错。
注意 如果子类不能完整地实现父类的方法,或者父类的某些方法在子类中发生“畸变”,建议断开父子继承关系,采用依赖、聚集、组合等关系替代继承。
子类可以有自己的方法和属性
子类可以有自己的方法和属性,这导致里氏替换原则可以正向使用,不能反向使用。即在子类出现的地方,父类未必适用。例如对步枪Rifle类,有AK47、AUG狙击步枪等子类,类图如下:
AUG继承Rifle类,Snipper直接使用AUG步枪。AUG类代码如下:
public class AUG extends Rifle {
// 狙击镜
public void zoomOut() {
System.out.println("观察敌人");
}
public void shoot() {
System.out.println("AUG shoot");
}
}
狙击手Snipper类如下:
public class Snipper {
public void killEnemy(AUG aug) {
aug.zoomOut();
aug.shoot();
}
}
业务场景类Client源代码如下:
public class Client {
public static void main(String[] args) {
Snipper sanMao = new Snipper();
sanMao.setRifle(new AUG());
sanMao.killEnemy();
}
}
这里直接调用了子类AUG。如果传入父类,Client类修改如下:
public class Client {
public static void main(String[] args) {
Snipper sanMao = new Snipper();
// 此处传入父类,然后强转
sanMao.setRifle((AUG)new Rifle());
sanMao.killEnemy();
}
}
会抛出java.lang.ClassCastException异常,即向下转型(downcast)是不安全的,即里氏替换原则中,有子类出现的地方父类未必可以出现。
重写或实现父类的方法时输入参数可以被放大
对于父类和子类的Java代码:
public class Father {
public Collection doSomething(HashMap map) {
System.out.println("父类被执行");
return map.values();
}
}
public class Son extends Father {
// 放大输入参数
public Collection doSomething(Map map) {
System.out.println("子类被执行");
return map.values();
}
}
doSomething
方法子类与父类方法名相同,但参数由HashMap放大为了Map,属于重载(Overload)。
对于场景类代码:
public class Client {
public static void invoker() {
// 父类存在的地方,子类就应该能够存在
Father f = new Father();
HahsMap map = new HashMap();
f.doSomething(map);
}
public static void main(String[] args) {
invoker();
}
}
运行结果为:
父类被执行
根据里氏替换原则,用子类替换父类:
public class Client {
public static void invoker() {
// 父类存在的地方,子类就应该能够存在
Son f = new Son();
HahsMap map = new HashMap();
f.doSomething(map);
}
public static void main(String[] args) {
invoker();
}
}
运行结果与之前相同。 原因是子类的输入参数扩大了,为Map类型,子类代替父类传递到调用者中,子类方法永远不会执行, 这样的业务逻辑是正确的 。如果想让子类的方法运行,就 必须 重写父类的方法。
子类可以重载父类的方法,但前提是输入参数必须宽于父类的类型覆盖范围。如果Father类的输入参数类型宽于Son类,则会出现父类存在的地方子类未必可以存在的情况。一旦把子类作为参数传入,调用者就很可能进入子类方法的范畴。
如果扩大父类的前置条件,使父类前置条件较大,子类前置条件较小,代码如下:
public class Father {
public Collection doSomething(Map map) {
System.out.println("父类被执行");
return map.values();
}
}
public class Son extends Father {
// 缩小输入参数
public Collection doSomething(HashMap map) {
System.out.println("子类被执行");
return map.values();
}
}
父类前置条件大雨子类的情况下,业务场景代码:
public class Client {
public static void invoker() {
// 父类存在的地方,子类就应该能够存在
Father f = new Father();
HahsMap map = new HashMap();
f.doSomething(map);
}
public static void main(String[] args) {
invoker();
}
}
运行结果:
父类被执行
引入里氏替换原则,修改业务场景:
public class Client {
public static void invoker() {
// 父类存在的地方,子类就应该能够存在
Son f = new Son();
HahsMap map = new HashMap();
f.doSomething(map);
}
public static void main(String[] args) {
invoker();
}
}
运行结果:
子类被执行
出现了没有重写父类方法的前提下,子类方法被执行了的情况,会引起业务混乱。
重写或实现父类的方法时输出结果可以被缩小
里氏替换原则要求,子类重写或重载父类的方法时,返回类型的范围必须小于等于父类的返回值类型范围。
最佳实践
实践中采用里氏替换原则时:
- 子类符合相关规范,否则会造成子类和父类之间关系难以调和;
- 避免把子类单独作为一个业务使用,这会让代码间的耦合关系变得扑朔迷离,缺乏类替换的标准。