在开始编写文章前,有几个问题需要思考一下:
- 里氏替换原则的庐山真面目
- 里氏替换原则蕴含的规则
1. 里氏替换原则的庐山真面目
Java 使用 extends 关键字来实现继承,它采用了单一继承的规则,从整体上来看,利大于弊,怎样才能让“利”的因素发挥最大的作用,同时减少“弊”带来的麻烦呢?解决方案是引入里氏替换原则,什么是里氏替换原则呢?它有两种定义:- 第一种定义,也是最正宗的定义:If for each 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 references to base classes must be able to use objects of derived classes without knowing it.(所有引用基类的地方必须能透明地使用其子类的对象。)
第二个定义是最清晰明确的,通俗点讲,只要父类能出现的地方子类就可以出现,而且替换为子类也不会产生任何错误或异常,使用者可能根本就不需要知道是父类还是子类。但是,反过来就不行了,有子类出现的地方,父类未必就能适应。
2. 里氏替换原则蕴含的规则
里氏替换原则为良好的继承定义了一个规范,一句简单的定义包含了 4 层含义。
2.1 子类必须完全实现父类的方法
我们在做系统设计时,经常会定义一个接口或抽象类,然后编码实现,调用类直接传入接口或抽象类,其实这里已经使用了里氏替换原则。
注意:如果子类不能完整地实现父类的方法,或者父类的某些方法在子类中已经发生“畸变”,则建议断开父子继承关系,采用依赖、聚集、组合等关系代替继承。
2.2 子类可以有自己的个性
子类当然可以有自己的行为和外观了,也就是方法和属性,那这里为什么要再提呢?是因为里氏替换原则可以正着用,但是不能反过来用。在子类出现的地方,父类未必就可以胜任。
2.3 覆盖或实现父类的方法时输入参数可以被放大
方法中的输入参数称为前置条件,这是什么意思呢?大家做过 Web Service 开发就应该知道一个“契约优先”的原则,也就是先定义出 WSDL 接口,制定好双方的开发协议,然后各自实现。里氏替换原则也要求制定一个契约,就是父类或接口,这种设计方法也叫做契约设计,与里氏替换原则有着异曲同工之妙。契约制定了,也就是同时制定了前置条件和后置条件,前置条件就是你要让我执行,就必须满足我的条件;后置条件就是我执行了需要反馈,标准是什么。
Father 类源码:
public class Father {
public Collection doSomething(HashMap map) {
System.out.println("父类被执行...");
return map.values();
}
}
在定义一个子类 Son:
public class Son extends Father {
//放大输入参数类型
public Collection doSomething(Map map) {
System.out.println("子类被执行...");
return map.values();
}
}
请看 doSomething 方法,与父类的方法名相同,但又不是覆写(Override)父类的方法。方法名虽然相同,但方法的输入参数不同,就不是覆写,是重载(Overload)。
场景类源代码:
public class Client {
public static void main(String[] args) {
//父类存在的地方,子类就应该能够存在
Father f = new Father();
HashMap map = new HashMap();
f.doSomething(map);
}
}
运行结果如下:
父类被执行...
根据里氏替换原则,父类出现的地方子类就可以出现,我们修改下场景类的代码:
public class Client {
public static void main(String[] args) {
//父类存在的地方,子类就应该能够存在
Son s = new Son();
HashMap map = new HashMap();
s.doSomething(map);
}
}
运行结果还是一样的,看明白是怎么回事了吗?父类方法的输入参数是 HashMap 类型,子类的输入参数是 Map 类型,也就是说子类的输入参数类型的范围扩大了,子类替代父类传递到调用者中,子类的方法永远都不会被执行。这是正确的,如果你想让子类的方法运行,就必须覆写父类的方法。调用一个父类的方法,子类可以覆写这个方法,也可以重载这个方法,前提是要扩大这个前置条件,就是输入参数的类型宽于父类的类型覆盖范围。
父类的前置条件较大:
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 main(String[] args) {
//父类存在的地方,子类就应该能够存在
Father f = new Father();
HashMap map = new HashMap();
f.doSomething(map);
}
}
运行结果:
父类被执行...
采用里氏替换原则后的业务场景类:
public class Client {
public static void main(String[] args) {
//父类存在的地方,子类就应该能够存在
Son s = new Son();
HashMap map = new HashMap();
s.doSomething(map);
}
}
运行结果:
子类被执行...
子类在没有覆写父类的前提下,子类方法被执行了,这会引起业务逻辑混乱,因为在实际应用中父类一般都是抽象类,子类是实现类,你传递一个这样的实现类会“歪曲”了父类的意图,引起一堆意想不到的业务逻辑混乱,所以子类方法的前置条件必须与超类中被覆写的方法的前置条件相同或更宽松。
2.4 覆写或实现父类的方法时输出结果可以被缩小
这是什么意思呢,父类的一个方法的返回值是一个类型 T,子类的相同方法(重载或覆盖)的返回值为 S,那么里氏替换原则就要求 S 必须小于等于 T,也就是说,要么 S 和 T 是同一个类型,要么 S 是 T 的子类,为什么呢?如果是覆写,父类和子类的同名方法的输入参数是相同的,两个方法的范围值 S 小于等于 T,这是覆写的要求,这才是重中之重,子类覆写父类的方法,天经地义。如果是重载,则要求方法的输入参数类型或数量不相同,在里氏替换原则要求下,就是子类的输入参数宽于或等于父类的输入参数,也就是说你写的这个方法是不会被调用的。
采用里氏替换原则的目的就是增强程序的健壮性,版本升级时也可以保持非常好的兼容性。即使增加子类,原有的子类还可以继续运行。在实际项目中,每个子类对应不同的业务含义,使用父类作为参数,传递不同的子类完成不同的业务逻辑,非常完美!
注意:在项目中,采用里氏替换原则时,尽量避免子类的“个性”,一旦子类有“个性”,这个类和父类之间的关系就很难调和了,把子类当做父类使用,子类的“个性”被抹杀 —— 委屈了点;把子类单独作为一个业务来使用,则会让代码间的耦合关系变得扑朔迷离 —— 缺乏类替换的标准。