天天看点

设计原则之里氏替换原则

作者:五魔纪总

这是一个爱恨纠葛的父子关系的故事。该原则可以理解为:子类可以替换父类。

父子类实在我们学习继承这个知识点的时候学习到的概念。我们先来回忆一下继承的优缺点:

优点:
    1. 代码共享,减少创建类的工作量,每个子类都拥有父类的方法和属性;
    2. 提高代码的重用性;
    3. 提高代码的可扩展性,子类可形似于父类,但异于父类,保留了自己独特的个性;其实很多开源框架的扩展都是通过继承父类实现的。
    4.提供产品或者项目的开放性。
缺点:
    1. 继承是侵入性的,只要继承就必须拥有父类的所有方法和属性;
    2. 降低了代码的灵活性。子类必须拥有父类的属性和方法,让子类中多了约束
    3. 增加了耦合,当父类的常量、变量或者方法被修改了,需要考虑子类的修改,所以一旦父类有了变动,很可能会造成非常糟糕的结果,要重构大量的代码。           

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 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中将所有的对象o2都替 换为o1,而程序P的行为没有发生变化,那么S是T的子类。)。
  • 第二种:Functions that use pointers or references to base classes must be able to use objects of derived classes without knowing it.(所有引用基类的地方必须能透明地使用其子类对象。)

第一个定义看起来有点难,其实意思就是说:在一个程序中,如果可以将一个类T的对象全部替换为另一个类S的对 象,而程序的行为没有发生变化,那么S是T的子类。

第二个定义明显要比第一个定义更容易理解,非常的清晰明确。通俗地讲,就是任何一个使用父类的地方,你都可 以把它替换成它的子类,而不会发生任何错误或异常,使用者可能根本就不需要知道是父类还是子类。但是,反过 来就不行了,有子类出现的地方,父类未必可以替换。

里氏替换原则是继承复用的基石,它为良好的继承定义了一个规范,定义中包含了4层含义:

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

我们以前做过的项目中,经常定义一个接口或者抽象类,然后编码实现,调用类则直接传入接口或者抽象 类,其实这就是已经在使用历史替换原则了。

举个例子:是不是很多人玩过CS(真人版cs或者游戏都算,没玩过也没关系,他就是一个比较火爆的第一人称 射击游戏。你就知道用到了很多枪就行了)?

//枪的抽象类
public abstract class AbstractGun {
    //杀敌
    public abstract void shoot();
}
//手枪:携带方便但是射程短
public class Handgan extends AbstractGun{
    @Override
    public void shoot() {
        System.out.println("手枪射击----------");
    }
}
//步枪:威力大射程远
public class Rifle extends AbstractGun{
    @Override
    public void shoot() {
        System.out.println("步枪射击----------");
    }
}
//机枪:威力更大连续发射
public class MachineGun extends AbstractGun{
    @Override
    public void shoot() {
        System.out.println("机枪射击----------");
    }
}
//士兵:使用枪支
public class Soldier {
    //士兵使用的枪支
    private AbstractGun gun;
    //通过set方法给士兵配枪
    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 xuSanDuo=new Soldier();
        //给许三多配枪:参数可以是任何一把枪:机枪、步枪都可以
        xuSanDuo.setGun(new Handgan());
        xuSanDuo.killEnemy();
    }
}           

运行结果:

士兵杀敌:

​	步枪射击----------           

在场景类中,给士兵配枪的时候可以是三种枪中的任何一个,其实士兵类可以不用知道是哪种的枪(子类) 被传入。

PS:在类中调用其他类时务必要使用父类或接口,如果不能使用父类或者接口,说明类的设计违背了里氏替 换原则。

2、子类中可以增加自己特有的方法

类都有自己的属性和方法,子类当然也不例外。除了从父类继承过来的,可以有自己独有的内容。为什么要 单独列出来,是因为里氏替换原则是不可以反过来用的。也就是子类出现的地方,父类未必可以胜任。

我们继续上面的案例:步枪下面还有几个比较知名的种类:例如AK47和AUG狙击步枪。

//AUG狙击枪
public class AUG extends Rifle{
    //狙击枪都携带了精准望远镜
    public void zoomOut(){
        System.out.println("通过望远镜观察敌人:");
    }
    @Override
    public void shoot() {
        System.out.println("AUG射击--------");
    }
}
//狙击手
public class Snipper extends Soldier{
    public void killEnemy(AUG aug) {
        //先观察
        aug.zoomOut();
        //射击
        aug.shoot();
    }
}
//场景类:
public class Client {
    public static void main(String[] args){
        //定义一个狙击手韩光
        Snipper hanGuang=new Snipper();
        //给韩光配枪
        hanGuang.setGun(new AUG());
        hanGuang.killEnemy();
    }
}           

运行结果:

​	士兵杀敌:

​	AUG射击--------           

场景类中我们可以直接使用子类,狙击手是依赖枪支的,别说换一个型号的枪,就是同一个型号的枪都会影 响射击,所以这里直接传递子类。

如果我们直接使用父类传递进来可以吗?

//使用父类作为参数
public class Client {
    public static void main(String[] args){
        //定义一个狙击手韩光
        Snipper hanGuang=new Snipper();
        //给韩光配枪
        hanGuang.setGun((AUG)new Rifle());
        hanGuang.killEnemy();
    }
}           

运行结果:抛出异常

会在运行的时候抛出异常,这就是我们经常说的额向下转型是不安全的。从里氏替换原则来看:子类出现的 地方,父类未必可以出现。

3、当子类覆盖或实现父类的方法时,方法的输入参数(方法的形参)要比父类方法的输入参数更宽松

public class LSP {
    class Parent {
        public void fun(HashMap map){
            System.out.println("父类被执行...");
        }
    }
    class Sub extends Parent{
        public void fun(Map map){
            System.out.println("子类被执行...");
        }
    }
    public static void main(String[] args){
        System.out.print("父类的运行结果:");
        LSP lsp =new LSP();
        Parent a= lsp.new Parent();
        HashMap<Object, Object> map=new HashMap<Object, Object>();
        a.fun(map);
        //父类存在的地方,可以用子类替代
        //子类B替代父类A
        System.out.print("子类替代父类后的运行结果:");
        LSP.Sub b=lsp.new Sub();
        b.fun(map);
    }
}           

运行结果:

父类的运行结果:
父类被执行...
子类替代父类后的运行结果:
父类被执行...           

ps:这里子类并非重写了父类的方法,而是重载了父类的方法。因为子类和父类的方法的输入参数是不同的。 子类方法的参数Map比父类方法的参数HashMap的范围要大,所以当参数输入为HashMap类型时,只会执行父类的方法,不会执行子类的重载方法。这符合里氏替换原则。

4、当子类的方法实现父类的抽象方法时,方法的后置条件(即方法的返回值)要比父类更严格

public class LSP1 {
    abstract class Parent {
        public abstract Map fun();
    }
    class Sub extends Parent{
        @Override
        public HashMap fun(){
            HashMap b=new HashMap();
            b.put("b","子类被执行...");
            return b;
        }
    }
    public static void main(String[] args){
        LSP1 lsp =new LSP1();
        LSP1.Parent a=lsp.new Sub();
        System.out.println(a.fun());
    }
}           

运行结果:

{“b","子类被执行..."}           

二、作用

里氏替换原则的主要作用如下:

  • 里氏替换原则是实现开闭原则的重要方式之一。
  • 它克服了继承中重写父类造成的可复用性变差的缺点。
  • 它是动作正确性的保证。即类的扩展不会给已有的系统引入新的错误,降低了代码出错的可能性。

里氏替换原则通俗来讲就是:子类可以扩展父类的功能,但不能改变父类原有的功能。也就是说:子类继承父类 时,除添加新的方法完成新增功能外,尽量不要重写父类的方法。

如果通过重写父类的方法来完成新的功能,这样写起来虽然简单,但是整个继承体系的可复用性会比较差,特别是 运用多态比较频繁时,程序运行出错的概率会非常大。

如果程序违背了里氏替换原则,则继承类的对象在父类出现的地方会出现运行错误。这时其修正方法是:取消原来的继承关系,重新设计它们之间的关系。

继续阅读