里氏替换原则(Liskov Substitution Principle, LSP)

时间:2022-06-17
本文章向大家介绍里氏替换原则(Liskov Substitution Principle, LSP),主要内容包括其使用实例、应用技巧、基本知识点总结和需要注意事项,具有一定的参考价值,需要的朋友可以参考一下。

以此回顾所读《设计模式之禅》与《高校程序员的45个习惯》中Liskov部分

定义:

第一种:If for each object O1 of type S there is an object O2 fo type T such that for all programs P defined in terms of T, the behavior of P is unchanged when O1 is substitueted for O2 then S is a subtype of T. (对于每一个S类型的对象O1, 都有一个T类型的对象O2,使以T定义的程序P在使用O2替换O1时,行为不发生变化,则S是T的子类)。

第二种:Functions that use pointers or references to base classes must be able to use objects of derived classes without knowing it. (所有引用基类的地方都必须能够使用子类对象,而且使用者不用知道任何差异,不必自己进行任何修改)。

两种定义通俗地讲,就是只要能使用父类对象的地方,就可以使用子类对象,并且这样的替换不会给程序带来任何错误或异常,这是满足“里氏替换原则”的继承。(注意顺序,父类可以用子类来替换,但是子类不一定能被父类替换)

为什么使用里氏替换原则?

(1)如果不保证子类能透明替换父类,程序运行结果可能出现问题

假设某个类中有一个简单的方法,用来对字符串列表进行排序,然后返回一个新的列表。并如下调用:

utils = new BasicUtils();
...
sortedList = utils.sort(aList); 

现在假定开发人员派生了一个BasicUtils的子类,并写了一个新的sort()方法,实现了更快更好的排序算法:

utils = new FasterUtils();
...
sortedList = utils.sort(aList);

注意对sort()的调用是完全一样的,一个FasterUtils对象完美地替换了一个BasicUtils对象,用utils.sort()代码可以处理任何类型的utils对象,而且可以正常工作。

但是如果开发人员派生了一个BasicUtils的子类,并改变了排序的意义,使返回的列表逆序排列,这样子类对象替换父类对象后,程序运行结果发生错误,违反了里氏替换原则。

(2)如果子类替换父类时需要增加额外的判断,即 使用者不能在不了解子类的情况下将父类替换为子类,则将增加维护难度。

试想,每增加一个子类,所有与父类有关的类都必须增加一个判断,显然不合理。例如,代码中在要用某个子类替换父类时使用了if……else,则开发人员必须要了解每个子类,才能进行编码维护,无疑不可取。

if (a instanceof b) {
    //do something
} else {
    //do something
}

因此在设计类的继承层次时,一定要考虑是否能够在不了解子类的情况下自由地替换父类

(3)里氏替换原则可以增强程序的健壮性,版本升级也可以具有很好的兼容性,即使增加子类,原有的子类还是可以继续运行

如何理解和使用里氏替换原则?

1、子类必须完全实现父类的方法。

对于父类中定义的所有方法,在子类里都能不发生改变地实现,例如,步枪、冲锋枪、手枪都是继承自枪类,有一个共同的方法shoot(),如果添加一个新子类玩具枪,而玩具枪不能射击,所以它的shoot()方法中就do nothing,这样当你把枪这个类给士兵,期望他杀敌时,就需要判断给他的枪是玩具枪还是真枪,也就是说,由于玩具枪这个子类没有完整的实现父类的方法,或者说在实现父类的方法时发生了“畸变”,给代码维护带来了困难,违背了里式替换原则,此时建议断开继承,采用依赖、聚集、组合等关系代替继承。

2、子类可以有自己的个性

子类可以有自己不同于父类的属性和行为,里氏替换原则只强调子类可以自由替换父类,并不需要反过来也成立,而且往往用父类替换子类是不安全的。

3、子类中方法的前置条件(传入参数)必须与父类中被覆写的方法的前置条件相同或更宽松

先明白两个概念,覆写和重载,覆写是指方法名和传入参数完全相同,重载是指方法名相同,但传入参数不同。看一个例子:

父类:

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 father = new Father();
        HashMap hashMap = new HashMap();
        father.doSomething(hashMap);
    }
    public static void main(String[] args) {
        invoker();
    }
}

运行的结果:父类被执行。。。

根据里氏替换原则,如果替换父类为子类,即

Son son = new Son();
HashMap hashMap = new HashMap();
son.doSomething(hashMap);

运行的结果:父类被执行。。。

这个结果是正确的,子类的参数范围被放大后,替换父类所得的结果与调用父类的结果相同。但是,如果子类的参数范围小于父类的参数范围会怎样呢?

新父类:

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 father = new Father();
        HashMap hashMap = new HashMap();
        father.doSomething(hashMap);
    }
    public static void main(String[] args) {
        invoker();
    }
}

运行结果:父类被执行。。。

用子类替换父类:

Son son = new Son();
HashMap hashMap = new HashMap();
son.doSomething(hashMap);

运行结果:子类被执行。。。

运行结果出现了错误!子类在没有覆写父类方法的前提下,被执行了,这就会带来逻辑混乱,所以,子类方法中的前置条件必须与父类相同或比父类宽松。

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

如果父类的一个方法的返回类型是T,子类重载或覆写的方法返回类型是S,里氏替换原则要求S必须小于等于T。

如果是覆写,父类和子类传入的参数是相同的,两个方法的返回值S<=T,这是覆写的要求。

如果是重载,里氏替换原则要求子类传入参数范围小于等于父类传入参数范围,所以子类方法是不会被调用的。也可以参考上面的前置条件。