里氏替换原则

2015/5/21 posted in  设计模式

在面向对象的语言中,无论是C++还是Java,继承是常见的、优秀的语言机制之一。继承的优秀,带来了很多好处,比如代码重用,提高代码拓展性等等。在学C++的时候,刚刚接触继承,便被它的神奇震惊了。既然可以实现父类全部的属性和方法,那么只需要找到几个类的共性岂不是可以少写很多代码? 但是在实际用起来却不是这样,继承中很多不方便或者很多不得不接受的东西,比如如果要继承,你不得不接受父类所有的属性和方法,哪怕有相当一部分是用不到的。还有,如果本来在接受一些用不到的属性和方法的子类突然变了需求,那么如果重构的话,将花费更大的力气。 Java采用了单继承的方式,相对C++比,更少了一些灵活,甚至让人感觉弱化了继承,但我也和大多数人一样认为,利大于弊。但是只是靠这些并不能减少更多的弊端,怎么让利起到更多的作用,而减少弊端?这就是引入里氏替换原则(LSP)的原因。 里氏替换原则有两种定义:

  • 第一种定义:如果对每一个类型为S的对象o1,都有类型为T的对象o2,使得以T定义的所有程序P在所有的对象o1都带换为o2时,程序P的行为没有发生变化,那么类型S就是类型T的子类型。
  • 第二种定义:所有引用基类的地方必须能透明地使用其子类对象。

其实说的通俗一点,就是只要父类出现的地方子类一定就可以出现,而且替换为子类也不会产生任何错误或异常。 为了让良好的继承定义了一个规范,里氏替换原则一句简单的定义包含了4层含义。

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

如果在做系统设计的时候,定义了一个接口或者抽象类,然后编码实现,调用类则直接传入接口或抽象类,其实这里已经使用了里氏替换原则。 如果我们定义了一个枪的抽象类:

abstract class AbstractGun{
    public abstract void shoot();
}

再定义一个士兵类,和他使用枪支的方法:

class Soldier{
    private AbstractGun gun;
    //获得一把枪
    public void setGun(AbstractGun gun){
        this.gun = gun;
    }
    public void killEnemy(){
        System.out.println("士兵开始杀人...");
        gun.shoot();
    }
}

如果我们需要多种枪支,只不过是需要分别继承于枪支抽象类:

class Handgun extends AbstractGun{

    @Override
    public void shoot() {
        System.out.println("手枪射击...");
    }
}

class Rifle extends AbstractGun{

    @Override
    public void shoot() {
        System.out.println("步枪射击...");
    }
}

我们在使用枪*支的时候不需要做什么区分,直接声明出来给士兵就够了:

public void Test(){
    Soldier sanmaoSoldier = new Soldier();
    sanmaoSoldier.setGun(new Handgun());
    sanmaoSoldier.killEnemy();

    sanmaoSoldier.setGun(new Rifle());
    sanmaoSoldier.killEnemy();
}

在这样的程序中,哪怕程序中枪支或士兵的需求发生变化,也不过只是需要修改相应的部分就可以。 在类中调用其他类时务必要使用父类或接口,如果不能使用父类或接口,则说明类的设计已经违背了LSP原则。 如果在增加一个玩具枪会怎么样?

class Toygun extends AbstractGun{

    @Override
    public void shoot() {
        //玩具枪不能射击,这个方法无法实现
    }
}

那么把玩具枪传给士兵会怎么样?因为玩具枪无法射击,那么这个类并没有达到预期的效果,也就是说发生了子类替换父类之后出现错误的情况。 应该怎么解决这个问题,有两种解决方式:

  1. 在士兵类中增加instanceof判断,如果是玩具枪,就不用来kill。
  2. 让玩具枪与抽象枪类脱离关系,建立一个独立的父类,为了实现代码复用,可以与抽象枪类建立关联委托关系。

第一种方法明显的不好,首先他是修改了和枪类无关的士兵了,让需要重写的范围增大了,还有,最要紧的就是如果增加一个需求就要修改一次的花,如果再加几次需求,任务量将比较大。 虽然按道理来说玩具枪继承抽象枪是完全没有问题的,但是要根据实际情况来,如果继承了,就出现了子类不能完整的实现父类的业务的情况。 如果子类不能完整的实现父类的方法,或者父类的某些方法在子类中已经发生“畸变”。则建议断开父子继承关系,采用依赖、聚集、组合等关系代替继承。

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

子类当然可以有自己独特的属性和方法。但是,里氏替换原则可以正着用,但不能反过来,也就是说,有父类的地方一定可以用子类代替,但有子类的地方不一定可以能用父类替换。 比如在步枪类下有两种枪,一个是AK47,一个是狙击步枪AWP:

class AK47 extends Rifle{

    public void shoot() {
        System.out.println("AK射击...");
    }
}

class AWP extends Rifle{

    public void zoomOut(){
        System.out.println("通过望远镜观察敌人...");
    }

    public void shoot() {
        System.out.println("AWP射击...");
    }
}

我们还可以定义一个狙击手类:

class Snipper{
    private void killEnemy(AWP awp){
        awp.zoomOut();
        awp.shoot();
    }
}

在这个类下,狙击手可以使用狙击枪,进行观察和射击,而之前定义的士兵类也可以使用狙击枪射击,但不能观察。这就说明有父类的地方完全可以用子类替换,当然,士兵类使用狙击枪是不会观察的,但依然可以保证枪能用。 但是如果把一个步枪传递给狙击手会怎么样?

public void Test(){
    Snipper sanmaoSnipper = new Snipper();
    sanmaoSnipper.setGun((AWP)new Rifle());
    sanmaoSnipper.killEnemy();
}

因为步枪没有狙击镜,不能观察,所以在向下转型是不安全的。从里氏替换原则看,就是有子类出现的地方父类未必就可以出现。

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

如果我们需要调用子类时是使用父类的方法,但是还要有子类独特的方法存在,也就是说子类不能覆写父类的方法,因为在一些情况下需要调用父类的方法,但是子类还需要有自己的方法,因为在某些情况下子类还需要调用自己的方法。 其实很简单也不过就是重载么,重载判断的特征值是参数,所以在子类重载父类的方法的时候需要把参数的范围放大,举个例子,有这么一个父类:

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

然后有一个子类继承了他:

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

当我们执行父类的方法的时候,

public void text(){
    Father father = new Father();
    HashMap map = new HashMap<>();
    father.soSomething(map);
}

可以很明确,就是父类的方法得到了执行,而且,如果换做子类执行:

public void text(){
    Son son = new Son();
    HashMap map = new HashMap<>();
    son.soSomething(map);
}

因为重载的优先级问题,子类还是会执行父类的方法,而如果子类想执行自己的方法的时候,只需要吧HashMap换做Map。 如果没有按照放大的规则来,

class Father{
    public Collection soSomething(hMap map){
        System.out.println("父类被执行...");
        return map.values();
    }
}
class Son extends Father{
    public Collection doSomething(HashMap map){
        System.out.println("子类被执行...");
        return map.values();
    }
}

那么在把HashMap传递给父类的时候执行的父类的方法,而传递给子类的时候执行的却是子类的方法。而我们的本意却是执行父类继承来的方法。所以子类中方法的前置条件必须与超类中被覆写的方法的前置条件相同或更宽松

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

第三点讲的主要是重载,而已经明确子类是要覆写父类地方方法,假设父类的一个方法返回值是T,子类的同名方法返回值是S,父类和子类的同名方法的输入参数应该是相同的,两个方法的范围之S(子类)小于等于T。这是对覆写的要求,是重中之重。这样的目的是为了保证有父类的地方子类一定适用。 采用里氏替换原则的目的就是增强程序的健壮性,版本升级时也可以保持非常好的兼容性,即使增加子类,原有的子类还可以继续运行。在实际项目中,每个子类对应不同的业务含义,使用父类作为参数,传递不同的子类完成不同的业务逻辑。 在项目中,应该尽量避免子类的“个性”,一旦子类有“个性”,这个子类和父类之间的关系就很难调和了,把子类当作父类使用,子类的“个性”被抹杀;把子类单独作为一个业务使用,则会让代码间的耦合关系变的扑朔迷离(缺乏类替换的标准)。