Design Patterns - II: Richter Substitution Principle



What is the Richter substitution principle? Why Richter substitution principle?

Take a look at the Richter replace defined principles (Liskov Substitution Principle) of:

Functions that use pointers or references to base classes must be able to use objects of derived classes without knowing it.

All references to the local base class can be used transparently object whose subclasses.

In layman's terms, a subclass can extend the parent class functions, but can not change the original function of the parent class.

Here Insert Picture Description

This is a lady Barbara Liskov made in 1988 - this is the most original definition:

“ What is wanted here is something like the following substitution property: 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. ”

If each type of objects o1 S, O2 has an object of type T, and T is defined such that all of the program when all the objects P o1 are replaced by substituting o2, the behavior of the program P has not changed, then the type S is a subtype of type T.


在面向对象的语言里,继承无疑是一个优秀的特性,它有提高代码复用性、扩展性等优点,但是白璧微瑕,它同样也会带来一些不足:
● 继承是侵入性的。只要继承,就必须拥有父类的所有属性和方法;
● 降低了代码的灵活性。子类必须拥有父类的属性和方法,让子类自由的世界中多了些约 束;
● 增强了耦合性。当父类的常量、变量和方法被修改时,需要考虑子类的修改,而且在缺乏规范的环境下,这种修改可能带来非常糟糕的结果——大段的代码需要重构。

为了扬长避短,就有必要引入里氏替换原则。


里氏抽丝剥茧

里氏替换原则包含了四层含义:

* 子类必须完全实现父类的方法

在做系统设计时,经常会定义一个接口或抽象类,然后编码实现,调用类则直接传 入接口或抽象类,其实这里已经使用了里氏替换原则。

举个例子来说明这个原则,以CS游戏为例,来描述一下里面用到的枪,类图如图2-1所示:


2-1:CS游戏中的枪支类图

Here Insert Picture Description


枪的主要职责是射击,如何射击在各个具体的子类中定义,手枪是单发射程比较近,步枪威力大射程远,机枪用于扫射。在士兵类中定义了一个方法killEnemy,使用枪来杀敌人, 具体使用什么枪来杀敌人,调用的时候才知道。

枪支抽象类:

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{ 
  //步枪的特点是射程远,威力大 
  public void shoot(){ 
    System.out.println("步枪射击..."); 
    } 
}


public class MachineGun extends AbstractGun{ 
    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(); 
    } 
}

在这个程序中,给三毛这个士兵一把步枪,然后就开始杀敌了。如果三毛要使用机枪,当然也可以,直接把sanMao.setGun(new Rifle())修改为sanMao.setGun(new MachineGun()) 即可,在编写程序时Solider士兵类根本就不用知道是哪个型号的枪(子类)被传入。

再来想一想,如果有一个玩具手枪,该如何定义呢?我们先在类图2-1上增加 一个类ToyGun,然后继承于AbstractGun类,修改后的类图如图2-2所示:


2-2:枪支类图
Here Insert Picture Description

玩具枪是不能用来射击的,杀不死人的,这个不应该写在shoot方法中。

public class ToyGun extends AbstractGun { 
  //玩具枪是不能射击的,但是编译器又要求实现这个方法,怎么办?虚构一个呗!  
   @Override 
  public void shoot() { 
  //玩具枪不能射击,这个方法就不实现了
   } 
}

由于引入了新的子类,场景类中也使用了该类,Client类稍作修改:

public class Client { 
    public static void main(String[] args) { 
       //产生三毛这个士兵 Soldier sanMao = new Soldier();     
       sanMao.setGun(new ToyGun()); sanMao.killEnemy(); 
     } 
}

结果:

士兵开始杀敌人...

玩具枪开始杀人了!在这种情况下,我们发现业务调用类已 经出现了问题,正常的业务逻辑已经不能运行,那怎么办?有两种办法。


● 在Soldier类中增加instanceof的判断,如果是玩具枪,就不用来杀敌人。这个方法可以 解决问题,但是,在程序中,每增加一个类,所有与这个父类有关系的类都必须修改,显然,这个方案被否定了。

● ToyGun脱离继承,建立一个独立的父类,为了实现代码复用,可以与AbastractGun建 立关联委托关系,如图2-3所示:

2-3:玩具枪与真实枪分离的类图

Here Insert Picture Description
例如,可以在AbstractToy中声明将声音、形状都委托给AbstractGun处理,然后两个基类下的子类自由延展,互不影响。


如果子类不能完整地实现父类的方法,或者父类的某些方法在子类中已经发 生“畸变”,则建议断开父子继承关系,采用依赖、聚集、组合等关系代替继承。


* 子类可以有自己的个性

子类可以有自己的行为和外观了,也就是方法和属性—— 里氏替换原则可以正着用,但是不能反过来用。在子类出现的地方,父类未必就可以胜任。还是以刚才的关于枪支的例子为例,步枪有几个比较“响亮”的型号,比如AK47、AUG 狙击步枪等,把这两个型号的枪引入后的Rifle子类图如图2-4所示:


2-4:增加AK47和AUG后的Rifle子类图
Here Insert Picture Description

AUG继承了Rifle类,狙击手(Snipper)则直接使用AUG狙击步枪。

AUG狙击枪类代码:

public class AUG extends Rifle { 
 //狙击枪都携带一个精准的望远镜
  public void zoomOut(){ 
   System.out.println("通过望远镜察看敌人...");
 }
 public void shoot(){ 
   System.out.println("AUG射击..."); 
 }
 
}

狙击手类:

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)是不安全的,从里氏替换原则来看,就是有子类出现的地方父类未必就可以出现。


* 覆盖或实现父类的方法时输入参数可以被放大

方法中的输入参数称为前置条件,这是什么意思呢?大家做过Web Service开发就应该知道有一个“契约优先”的原则,也就是先定义出WSDL接口,制定好双方的开发协议,然后再各自实现。里氏替换原则也要求制定一个契约,就是父类或接口,这种设计方法也叫做 Design by Contract(契约设计),与里氏替换原则有着异曲同工之妙。契约制定了,也就同 时制定了前置条件和后置条件,前置条件就是你要让我执行,就必须满足我的条件;后置条 件就是我执行完了需要反馈,标准是什么。这个比较难理解,来看一个例子。

先定义一个Father类:

public class Father { 
  //把HashMap转换为Collection集合类型
  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方法与父类的方法名相同,但又不是覆写(Override)父类的方法。方法名虽然相同,但方法的输入参数不同,就不是覆写,是重载(Overload)!——继承,子类拥有父类的所有属性和方法,方法名相同,输入参数类型又不 相同,当然是重载。

场景类的调用如下:

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();
 }
}

运行结果:

父类被执行...

根据里氏替换原则,父类出现的地方子类就可以出现,修改场景类:
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();
  }
  
}

运行结果还是一样。父类方法的输入参数是HashMap类型,子类的输入参数是Map类型,也就是说子类的输入参数类型的范围扩大了,子类代替父类传递到调用者中,子类的方法永远都不会被执行。这是正确的,如果你想让子类的方法运行,就必须覆写父类的方法。大家可以这样想,在一个Invoker类中关联了一个父类,调用了一个父 类的方法,子类可以覆写这个方法,也可以重载这个方法,前提是要扩大这个前置条件,就是输入参数的类型宽于父类的类型覆盖范围。这样说可能比较难理解,我们再反过来想一 下,如果Father类的输入参数类型宽于子类的输入参数类型,会出现什么问题呢?会出现父类存在的地方,子类就未必可以存在,因为一旦把子类作为参数传入,调用者就很可能进入 子类的方法范畴。

把上面的例子修改一下,扩大父类的前置条件:

public class Father { 
    public Collection doSomething(Map map){
      System.out.println("父类被执行..."); 
      return map.values(); 
     }
 }

把父类的前置条件修改为Map类型,再修改一下子类方法的输入参数,相对父类缩小输入参数的类型范围,也就是缩小前置条件:

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(); 
     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(); 
 } 

}

运行结果:

子类被执行...

子类在没有覆写父类的方法的前提下,子类方法被执行了,这会引起业务 逻辑混乱,因为在实际应用中父类一般都是抽象类,子类是实现类,你传递一个这样的实现 类就会“歪曲”了父类的意图,引起一堆意想不到的业务逻辑混乱,所以子类中方法的前置条 件必须与超类中被覆写的方法的前置条件相同或者更宽松。

* 覆写或实现父类的方法时输出结果可以被缩小

A return value of the method of the parent class is a type T, the same subclass (heavy duty or override) the return value of S, then the principles Richter requires replacement must be less than equal to T S, that is, either S and T are the same type, either S is a subclass of T, and why? Two cases, if the override is, the input parameters of the method of the parent class and subclass of the same name is the same, the value range of the two methods is less than or equal T S, which is overwritten requirement, this is the most important , subclass override the parent class, unalterable. If it is overloaded, the method requires input parameters are not the same type or quantity, in Richter replacement requirements under the principle, that is, the input parameters subclass wider than or equal to the input parameters of the parent class, which means that you write this method is , speaking with reference to the above pre-conditions it will not be called.

The purpose of using the Richter substitution principle is to enhance the robustness of the program, when the upgrade can be maintained very good compatibility. Even if the increase subclass, the original sub-class can continue to run. In a real project, each corresponding to different service subclasses meanings, using the parent class passed as a parameter different subclasses different services logic.



⇐⇐ design patterns - a: Single Responsibility Principle



reference:

[1]: "The Zen of Design Patterns"
[2]: Richter Substitution Principle (The Liskov Substitution Principle)
[3]: Design Patterns six principles (2): Richter Substitution Principle
[4]: "Westward Design Patterns"

Published 145 original articles · won praise 71 · views 50000 +

Guess you like

Origin blog.csdn.net/sinat_40770656/article/details/104188033