我的观点是:在看设计模式时首先要看类图,从类与类之间的关系分析。
类与类之间的关系请看这篇文章
第一个定义
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.
如果对每一个类型为 T1 的对象 o1,都有类型为 T2 的对象 o2,使得以 T1 定义的所有程序 P 在所有的对象 o1 都代换成 o2 时,程序 P 的行为没有发生变化,那么类型 T2 是类型 T1 的子类型。
第二个定义
functions that use pointers or references to base classes must be able to use objects of derived classes without knowing it.
所有引用基类的地方必须能透明地使用其子类的对象。
第二个定义是最明确,通俗的将只要是父类出现的地方子类都可以出现,而且调用子类还不产生任何的错误或异常,调用者可能根本就不需要知道是父类还是子类。但是反过来就不成了,有子类出现的地方,父类未必就能适应。
子类必须完全的实现父类的方法。我们在做系统设计时,经常会定义一个接口或者抽象类,然后编写实现,调用类则直接传入接口或抽象类,其实这里已经使用了里氏替换法则。我们举个例子还说明这个法则,大家都打过 CS 吧,非常经典的 FPS 类游戏,我们描述一下里面用到的枪,先看类图:
枪的主要职责就是射击,怎么射击就是在各个具体的子类中定义了,手枪是单发射程比较近,步枪威力大射程远,机枪用于扫射,然后在士兵类中定义了一个方法 killEnemy 杀敌人,使用枪来杀,具体使用什么枪来杀敌人,调用的时候才知道,我先看 AbstractGun 类的程序
class abstract class abstractGun{
//抽象射击方法
public void shoot();
}
class Rifle extends abstractGun{
//设计射击方法实现
public void shoot(){
System.out.println("步枪射击");
}
}
class Soldier{
public void killEnemy(abstractGun gun){
System.out.println("士兵开始杀人");
gun.shoot();
}
}
class client{
public static void main(String [] args){
Soldier solder=new Soldier();
solder.killEnemy(new Rifle());
}
}
//士兵开始杀人。。。
//步枪射击。。。
在这个程序中,我们给三毛这个士兵一把步枪,然后就开始杀敌了,如果三毛要使用机枪当然也可以,直接把 sanMao.killEnemy(new Rifle())
修改为 sanMao.killEnemy(new MachineGun())
就可以了,Soldier 根本就不用知道是哪个子类。我们在类中调用其他类是务必要使用父类或接口,如果不能使用父类或接口,则说明类的设计已经违背了 LSP 原则。
子类当然可以有自己的行为和外观了,也就是方法和属性,那这里为什么要
再提呢?是因为里氏替换法则可以正着用,但是不能反过来用。在子类出现的地方,父类未必就可以胜任。还是以刚才的那个枪械的例子为来说明,步枪有几个比较响亮的型号比如 AK47、G3 狙击步枪等,我们来看类图:
很简单,G3 继承了 Rifle 类,狙击手(Snipper)则直接使用 G3 狙击步枪,我们来看一下程序:
public class G3 extends Rifle{
//狙击枪需要有个瞄准镜
void zoom(){
System.out.println("使用瞄准镜看敌人");
}
void shoot(){
System.out.println("开抢杀人");
}
}
class Snipper{
public void killEnemy(G3 g3){
//通过瞄准镜看敌人
g3.zoom();
//开枪射击
g3.shoot();
}
}
class client{
public static void main(String [] args){
Snipper snipper=new Snipper();
snipper.killEnemy(new G3());
}
}
//通过望远镜观看敌人
//G3射击
在这里我们直接调用了子类,一个狙击手是很依赖枪支的,别说换一个型号的枪了,就是换一个同型号的枪也会影响射击的,所以这里就直接传递进来了子类。那这个时候,我们能不能直接使用父类传递进来呢?修改一下 Client 类:
class client{
public static void main(String [] args){
Snipper snipper=new Snipper();
//在这个地方我们传递的父类的参数 这时候会抛出异常 ClassCastException
//也就是说向下转型是不安全的,从里氏替换原则看,有子类出现的地方父类都可以出现
Rifle rifle=new Rifle();
snipper.killEnemy((G3)rifle);
}
}
覆盖或实现父类的方法时输入参数可以被放大。方法中的输入参数叫做前置条件,这是什么意思呢?大家做过 Web Service 开发就应该知道有一个“契约优先”的原则,也就是先定义出 WSDL 接口,制定好双方的开发协议,然后再各自实现。里氏替换法则也要求制定了一个契约,就是父类或接口,这种设计方法也叫做 Design by Contract,契约优先设计,是和里氏替换法则融合在一起的。契约制定了,但是契约有前置条件和后置条件,前置条件就是你要让我执行,就必须满足我的条件;后置条件就是我执行完了,必须符合规定的契约。这个比较难理解,我们来看一个例子,我们先定义个 Father 类:
public class Father{
public Collection doSomething(HashMap map){
System.out.println("父类方法被执行");
return map.values();
}
}
class son extends Father{
//这里我们使用了方法重载,相同的方法名,不同的参数类型叫做方法重载
//在不同的类中也可以使用重载
//区分重写与重载 重写是指方法名和参数都相同,但是方法体不同,一般在使用时会在方法签添加@Overide 编译器复制检查代码规范
public Collection doSomething(Map map){
System.out.println("子类方法被执行");
return map.values();
}
}
//业务逻辑
public class client{
static void invoker(){
//父类存在的地方子类都可以存在
Father f=new Father();
HashMap map=new HashMap();
f.doSomething(map);
}
public static void main(String [] args){
invoker();
}
}
//父类被执行
里氏替换法则说是父类出现的地方子类就能出现,我们把Father f=new Father();修改,程序如下:
public class client{
static void invoker(){
//父类存在的地方子类都可以存在
son f=new son()
HashMap map=new HashMap();
f.doSomething(map);
}
public static void main(String [] args){
invoker();
}
}
运行结果还是一样,看明白是怎么回事了吗?父类方法的输入参数是 HashMap 类型,子类的输入参数是 Map 类型,也就是说子类的输入参数类型的范围扩大了,子类代替父类传递到调用类用,子类的方法永远都不回被执行,这是正确的,如果你想让子类的方法运行,你就必须重写父类的方法。大家可以这样想想看,在一个 Invoker 类中关联了一个父类,调用了一个父类的方法,子类可以重写这个方法,也可以重载这个方法,前提是要扩大这个前置条件,就是输入参数的类型大于父类的类型覆盖范围。可能比较理难理解,那我们再反过来想一下,如果 Father 类的输入参数类型大于子类的输入参数类型,会出现什么问题?就会出现父类存在的地方,子类就未必可以存在,因为一旦把子类作为参数传入进去,调用者就很可能进入子类的方法范畴。我们把上面的例子修改一下,先看父类:
public class Father {
public Collection doSomething(Map map){
System.out.println("Map 转Collection被执行");
}
}
把父类的前置条件修改为 Map 类型,我们再修改一下子类方法的输入参数,相对父类缩小输入参数的类型范围,也就是缩小前置条件:
ublic class Son extends Father {
//缩小输入参数范围
public Collection doSomething(HashMap map){
System.out.println("HashMap转Collection被执行...");
return map.values();
}
}
再来看业务场景类
public class Client {
public static void invoker(){
//有父类的地方就有子类
Father f= new Father();
HashMap map = new HashMap();
f.doSomething(map);
}
public static void main(String[] args) {
invoker();
}
}
//父类被执行
那我们再把里氏替换法则引入进来会有什么问题?有父类的地方子类就可以使用,好,我们把这个Client 类修改一下,程序如下:
public class Client {
public static void invoker(){
//有父类的地方就有子类
Son f =new Son();
HashMap map = new HashMap();
f.doSomething(map);
}
public static void main(String[] args) {
invoker();
}
}
//子类被执行
完蛋了吧?!子类在没有重写父类的方法的前提下,子类方法被执行了,这个绝对会引起以后的业务逻辑混乱,因为在项目的应用中父类一般都是抽象类,子类是实现类,你传递一个这样的实现类就会引起一堆意想不到的业务逻辑混乱,所以子类中方法的前置条件必须与超类中被覆盖的方法的前置条件相同或者更宽松。
覆盖或实现父类的方法是输出结果可以被缩小。这个是什么意思呢,父类的一个方法返回值是一个类型 T,子类相同方法(重载或重写)返回值为 S,那么里氏替换法则就要求 S 必须小于等于 T,也就是说要么S 和 T 是同一个类型,要么 S 是 T 的子类,为什么呢?分两种情况,如果是重写,方法的输入参数父类子类是相同的,两个方法的范围值 S 小于等于 T,这个是重写的要求,这个才是重中之重,子类重写父类的方法,天经地义;如果是重载,则要求方法的输入参数不相同,在里氏替换法则要求下就是子类的输入参数大于等于父类的输入参数,那就是说你写的这个方法是不会被调用到的,参考上面讲的前置条件。
里氏替换法则诞生的目的就是加强程序的健壮性,同时版本升级也可以做到非常好的兼容性,增加子类,原有的子类还可以继续运行。在我们项目实施中就是每个子类对应了不同的业务含义,使用父类作为参数,传递不同的子类完成不同的业务逻辑,非常完美!