1、2、1 定义
Java 使用 extends 关键字来实现继承,它采用了单一继承的规则,C++ 采用了多重继承的规则,一个子类可以继承多个父类。从整体上看,利大于弊,怎么才能让”利”的因素发挥最大的作用,同时减少”弊”带来的麻烦呢?解决方案是引入里氏替换原则(Liskov Substitution Principle,LSP),里氏替换原则定义有如下两种定义:
第一种定义,也是最正宗的定义:If for each object o1 of type S there is an object o2 of type T such that for all program 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.(所有引用基类的地方必须能透明地使用其子类的对象。)
第二个定义是最清晰明确的,通俗点讲,只要父类能出现的地方子类就可以出现,而且替换为子类也不会产生错误或异常,使用者可能根本就不需要知道是父类还是子类。但是,反过来就不行了,有子类出现的地方,父类未必就能适应。
1、2、2 继承的优缺点
优点:
代码共享,减少创建类的工作量,每个子类都拥有父类的方法和属性;
提高代码的重用性;
子类可以形似父类,但又异于父类;
提高代码的可扩展性;
提高产品或项目的开放性。
缺点:
继承是侵入性的。只要继承,就必须拥有父类的所有属性和方法;
降低代码的灵活性。子类必须拥有父类的属性和方法,让子类自由的世界中多了些约束;
增强了耦合性。当父类的常量、变量和方法被修改时,需要考虑子类的修改,而且在缺乏规范的环境下,这种修改可能带来非常糟糕的结果----大段的代码需要重构。
1、2、3LSP 为良好的继承定义的四种规范
(下述图形结构 X 和单例代码 X 是成对的,且编号越高,代码优化程度越高。)
1、2、3、1 子类必须完全实现父类的方法
图形结构 1
案例代码 1
public abstract class AbstractGun {
public abstract void shoot();
}
public class Handgun extends AbstractGun{
public void shoot() {
System.out.println("手枪射击...");
}
}
public class MachineGun extends AbstractGun{
public void shoot() {
System.out.println("机枪扫射...");
}
}
public class Rifle extends AbstractGun{
public void shoot() {
System.out.println("步枪射击...");
}
}
#在类中调用其他类时务必要使用父类或接口,如果不能使用父类或接口,则说明类的设计已经违背了LSP原则。
public class Soldier {
private AbstractGun gun;
public void setGun(AbstractGun _gun){
this.gun = _gun;
}
public void killEnemy(){
System.out.println("士兵开始杀敌...");
gun.shoot();
}
}
public class Client {
public static void main(String[] args) {
Soldier soldier = new Soldier();
soldier.setGun(new Rifle());
soldier.killEnemy();
}
}
图形结构 2
图形结构 3
1、2、3、2 子类可以有自己的个性
#子类可以有自己的行为和外观,也就是方法和属性,这里再次强调的原因是因为里氏替换原则可以正着用,但是不能反过来用,在子类出现的地方,父类未必就可以胜任。
图形结构 1
代码结构 1
#基于“1、2、3、1”中的代码
public class Snipper {
private Aug aug;
public void killEnemy(){
aug.zoomOut();
aug.shoot();
}
public void setGun(Aug aug){
this.aug = aug;
}
}
public class Client {
public static void main(String[] args) {
Soldier soldier = new Soldier();
soldier.setGun(new Rifle());
soldier.killEnemy();
System.out.println("-----------------------");
soldier.setGun(new Aug());
soldier.killEnemy();
}
}
输出:
士兵开始杀敌...
步枪射击...
士兵开始杀敌...
AUG射击...
代码结构 2
#在上述“代码结构 1”中增加 main 方法输出
soldier.setGun((Aug)new Rifle());
soldier.killEnemy();
#在运行时会抛出 ClassCastException 异常,这就是向下转型是不安全导致的,从里式替换原则来看,就是由子类出现的地方父类未必就可以出现。
1、2、3、3 覆盖或实现父类的方法时输入参数可以被放大
Web SERVICE 开发有一个“契约优先”的原则(契约设计),里氏替换原则也要求制定一个契约,就是父类或接口,二者有着异曲同工之妙。
契约制定了,也就同时制定了前置条件和后置条件,前置条件就是你要让我执行,就必须满足我的条件(方法中的输入参数);后置条件就是我执行完了需要反馈,标准是什么。
案例代码 1(非重写)
public class Father {
#父类的前置条件较小
public Collection doSomething(HashMap hashMap){
System.out.println("父类被执行");
return hashMap.values();
}
}
public class Son extends Father{
#子类的前置条件较大
public Collection doSomething(Map map) {
System.out.println("子类被执行");
return map.values();
}
}
public class Client {
public static void invoker(){
Father f = new Father();
//Father f = new Son();
//Son f = new Son();
HashMap hashMap = new HashMap();
f.doSomething(hashMap);
}
public static void main(String[] args) {
invoker();
}
}
上述所有的输出结果:父类被执行。
案例代码 2(非重写,模糊,不推荐)
public class Father {
#父类的前置条件较大
public Collection doSomething(Map map){
System.out.println("父类被执行");
return map.values();
}
}
public class Son extends Father {
#子类的前置条件较小
public Collection doSomething(HashMap hashMap) {
System.out.println("子类被执行");
return hashMap.values();
}
}
public class Client {
public static void invoker(){
Father f = new Father();
#这里与“案例代码3”形成了对比。
//Father f = new Son();
//Son f = new Son();
HashMap hashMap = new HashMap();
f.doSomething(hashMap);
}
public static void main(String[] args) {
invoker();
}
}
#上述输出结果:前两个是“父类被执行”,最后一个是“子类被执行”。
#在参数不一样的情况下,先以变量实例化类型(等号前面的)为主,再以参数类型为主。与案例3不一样。
#上述子类子啊没有覆写/重写父类的方法的前提下,子类方法被执行了,这会引起业务逻辑混乱,因为在实际应用中父类一般都是抽象类,子类是实现类,此时传递一个这样的实现类就会“歪曲”了父类的意图,引起一堆意想不到的业务逻辑混乱,所以子类中方法的前置条件必须与超类中被覆写的方法的前置条件相同或者更宽松(参考“案例代码1”)。
案例代码 3(重写,推荐)
public class Client {
public static void main(String[] args) {
Father f = new Father();
//Father f = new Son();
//Son f = new Son();
HashMap hashMap = new HashMap();
f.doSomething(hashMap);
}
}
public class Father {
#父类前置条件
public void doSomething(HashMap hashMap){
System.out.println("父类被执行。");
}
}
public class Son extends Father{
#子类重写父类方法与父类前置条件相同
@Override
public void doSomething(HashMap hashMap) {
System.out.println("子类被执行。");
}
}
输出结果:第一个输出“父类被执行”,最后两个输出“子类被执行”。
1、2、3、4 覆写或实现父类的方法时输出结果可以被缩小
父类的一个方法的返回值是一个类型 T,子类的相同方法(重载或覆写)的返回值为 S,那么里式替换原则就要求 S 必须小于等于 T,也就是说,要么 S 和 T 是同一个类型,要么 S 是 T 的子类。这里分两种情况:
如果是覆写,父类和子类的同名方法的输入参数是相同的,两个方法的范围值 S 小于等于 T,这是覆写的要求,这才是重中之重,子类覆写父类的方法,天经地义(参考“1、2、3、3 中的案例代码 3”)。
如果是重载,则要求方法的输入参数类型或数量不相同,在里氏替换原则要求下,就是子类的输入参数宽于或等于父类的输入参数,也就是说这个方法是不会被调用的(参考“1、2、3、3 中的案例代码 1”)。
1、2、4 总结
里氏替换原则通俗的来讲就是:子类可以扩展父类的功能,但不能改变父类原有的功能。它包含以下四层含义:
子类可以实现父类的抽象方法,但不能覆盖父类的非抽象方法。
子类中可以增加自己特有的方法。
当子类的方法重载父类的方法时,方法的前置条件(即方法的形参)要比方法的输入参数更宽松(比如子类的方法参数类型为 MAP,父类的方法参数类型为 HASHMAP)。
当子类的方法实现父类的抽象方法时,方法的后置条件(即方法的返回值)要比父类更严格。
标题:设计模式-六大设计原则(二)-里氏替换原则
作者:yazong
地址:https://blog.llyweb.com/articles/2020/03/17/1584452897526.html