里氏替换原则

爱恨纠葛的父子关系

在面向对象的语言中, 继承是必不可少的、非常优秀的语言机制,它有如下优点:

  1. 代码共享,减少创建类的工作量,每个子类都拥有父类的方法和属性;
  2. 提高代码的重用性;
  3. 子类可以形似父类,但又异于父类,”龙生龙,凤生凤,老鼠生来会打洞“是说子拥有父的”种“,”世界上没有两片完全相同的叶子“是指明子与父的不同;
  4. 提高代码的可扩展性,实现父类的方法就可以”为所欲为“了,很多开源框架的扩展接口都是通过继承父类来完成的;
  5. 提高产品或项目的开放性。

自然界的所有事物都是优点和缺点并存的,即使是鸡蛋,有时候也能跳出骨头来,继承的缺点如下:

  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 of T such that for all programs P defined in term s of T,the behavior of P is unchanged when o1 is subsituted for o2 then S is a subtype of T. (如果对每一个类型为S的对象o1,都有类型为T的对象o2,使得以T定义的所有程序P在所有的对象o1都代替为o2时,程序P的行为没有发生变化,那么类型S是类型T的子类型。)
  • 第二种定义:Function that use pointers or reference to base classes must be able to use objects of derived classes without knowing it.(所有引用基类的地方必须能透明地使用其子类的对象。)

第二中定义是最清晰明确的,通俗点讲,只要父类能出现的地方子类就可以出现,而且替换为子类也不会产生任何错误和异常,使用者可能根本就不需要知道是父类还是子类。但是,反过来就不行,有子类出现的地方,父类未必就能适应。

纠纷不断,规则压制

里氏替换原则为良好的继承定义了一个规范,一句简单的定义包含了4层含义。

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

我们在做系统设计时,经常会定义一个借口或抽象类,然后编码实现,调用类则直接传入接口或抽象类,其实这里已经使用了里氏替换原则。下面以经典的第一人称射击游戏CS为例进行描述。

Main

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

代码清单1:AbstractGun.java (枪支的抽象类)

/**
 * @see 定义所有枪的抽象属性
 */
public abstract class AbstractGun {
    
    /**枪是用来干什么的,射击杀戮!*/
    public abstract void shoot();
}

具体有什么枪,从父类中继承。

代码清单2:HandGun.java (手枪的实现类)

/**
 * @see 手枪
 */
public class HandGun extends AbstractGun {

    /**
     * 手枪的特点是携带方便,射程远
     */
    @Override
    public void shoot() {
        System.out.println("手枪射击...");
    }
}

代码清单3:Rifle.java (步枪的实现类)

/**
 * @see 步枪
 */
public class Rifle extends AbstractGun{

    /**步枪的特点是射程远,威力大*/
    @Override
    public void shoot() {
        System.out.println("步枪射击...");
    }
    
}

代码清单4:MachineGun.java (机关枪的实现类)

/**
 * @see 机关枪
 */
public class MachineGun extends AbstractGun{

    /**机关枪可以连续扫射*/
    @Override
    public void shoot() {
        System.out.println("机枪扫射...");
    }
    
}

有了这些枪支,还要有使用这些枪支的士兵。

代码清单5:Soldier.java (士兵的实现类)

/**
 * @see 什么是士兵,拿枪杀人
 */
public class Solider {

    //定义士兵的枪支
    private AbstractGun gun;

    /**
     * 给士兵一把枪
     */
    public void setGun(AbstractGun gun) {
        this.gun = gun;
    }

    public void killEnemy() {
        System.out.println("士兵开始杀人..");
        gun.shoot();
    }
}

模拟场景。

代码清单6:Client.java (场景实现类)

/**
 * @see 业务场景模拟
 */
public class Client {
    public static void main(String[] args) {
        //产生三毛这个士兵
        Solider sanMao = new Solider();
        
        //给三毛一把步枪
        sanMao.setGun(new Rifle());
        sanMao.killEnemy();
    }
}

在类中调用其他类时务必要使用父类或接口,如果不能使用父类或接口,则说明类的设计已经违背了LSP原则。

现在的问题是,如果我们有一个玩具手枪,该如何定义?我们在原来的基础上增加一个ToyGun,然后继承于AbstractGun类,如图:

Main

首先我们想,玩具枪是不能用来射击的,杀不死人的,这个shoot()就让它空实现。

代码清单7:ToyGun.java (玩具枪实现类)

/**
 * @see 玩具枪
 */
public class ToyGun extends AbstractToy {

    @Override
    public void shoot() {
    }
}

代码清单8:Client.java (修改之前的场景类)

/**
 * @see 业务场景模拟
 */
public class Client {

    public static void main(String[] args) {
        //产生三毛这个士兵
        Solider sanMao = new Solider();

        //给三毛一把步枪
        sanMao.setGun(new ToyGun());
        sanMao.killEnemy();
    }
}

运行结果为:

士兵开始杀人…

坏了,士兵拿着玩具枪来杀敌人,射不出子弹啊!在这种情况下,我们发现业务调用类已经出现了问题,正常的业务逻辑已经不能运行,怎么办?有两种解决方法:

  1. 在Soldier类中增加instanceof的判断,如果是玩具枪,就不能用来杀敌。这个方法可以解决问题,但是你要知道,在程序中,每增加一个类,所有与这个父类有关系的类都必须修改,显然不行。如果你的产品出现了这个问题,因为修正个这个Bug,就要求所有与这个父类有关系的类都增加一个判断。显然这个方案不行。
  2. ToyGun脱离继承,建立一个独立的父类,为了实现代码复用,可以与AbstractGun建立关联委托关系,如图:

Main

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

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

子类当然可以有自己的行为和外观了,也就是方法和属性,那这里为什么要再提呢?是因为里氏替换原则可以正着用,但不能反过来用。在子类出现的地方,父类未必就可以胜任。这里以刚才的例子为例,步枪有几种型号,如AK47、AUG阻击步枪等,把这两种枪引入Rifle的子类,如图:

Main

代码清单9:AUG.java (AUG阻击枪源代码)

/**
 * @see G3阻击步枪
 */
public class AUG extends Rifle {

    //阻击步枪都携带一个精准的望远镜
    public void zoomOut() {
        System.out.println("通过望远镜观看敌人...");
    }

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

有阻击枪就有阻击手。

代码清单10:Snipper.java (AUG阻击手源代码)

/**
 * 狙击手,为什么叫Snipper?snipe翻译过来就是鹬,就是鹬蚌相争,
 * 渔翁得利中的那个动物 英国贵族到印度打猎, 发现这个鹬很聪明,人一靠近就飞走了,
 * 没办法就开始伪装、远程精准射击,于是乎
 * snipper就诞生了
 *
 */
public class Snipper {

    //定义一个阻击枪
    private AUG aug;

    //给阻击手一把阻击枪
    public void setRifle(AUG aug) {
        this.aug = aug;
    }

    public void killEnemy() {
        //首先看看敌人的情况,别杀死敌人,自己也别被人干掉
        aug.zoomOut();
        //开始射击
        aug.shoot();
    }
}

模拟场景。

代码清单11:Client.java

/**
 * @see 业务场景模拟类
 */
public class Client {

    public static void main(String[] args) {
        //产生三毛这个阻击手
        Snipper sanMao = new Snipper();
        sanMao.setRifle(new AUG());
        sanMao.killEnemy();
    }
}

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

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

上面说得比较复杂,其实实现起来很简单,可以通过子类重载父类的方法来实现。

Main

代码清单12:Father.java

import java.util.Collection;
import java.util.HashMap;

/**
 * @see 父类
 */
public class Father {
    public Collection doSomething(HashMap map){
        System.out.println("父类被执行...");
        return map.values();
    }
}

代码清单13:Son.java

import java.util.Collection;
import java.util.Map;

/**
 * @see 子类
 */
public class Son extends Father {
    //放大输入参数类型
    public Collection doSomething(Map map) {
        System.out.println("子类被执行...");
        return map.values();
    }
}

代码清单14:Client.java

import java.util.HashMap;

/**
 * @see 场景类
 */
public class Client {

    public static void invoker() {
        //父类存在的地方,子类就应该存在
        Son s = new Son();
        HashMap map = new HashMap();
        s.doSomething(map);
    }

    public static void main(String[] args) {
        invoker();
    }
}

在子类中方法的前置条件必须与父类中被覆写的方法的前置条件相同或者更宽松。

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

这是什么意思呢,父类的一个方法的返回值是一个类型T,子类的系统方法(重载或覆写)的返回值为S,那么里氏替换原则就要求S必须小于等于T,也就是说,要么S和T是同一个类型,要么S是T的子类,为什么呢?分两种情况,如果是覆写,父类和子类的同名方法的输入参数是相同的,两个方法的范围值S小于等于T,这是覆写的要求,这才是重中之重,子类覆写父类的方法,天经地义。如果是重载,则要求方法的输入参数类型或数量不相同,在里氏替换原则要求下,就是子类的输入参数宽于或等于父类的输入参数,也就是说你写的这个方法是不会被调用的,参考上面讲的前置条件。

采用里氏替换的原则就是增强程序的健壮性,版本升级时也可以保持非常好的兼容性。即使增加子类,原有的子类还可以继续运行。在实际项目中,每个子类对应不同的项目含义,使用父类作为参数,传递不同子类完成不同逻辑,非常完美!

 

总结:在项目中,采用里氏替换原则时,尽量避免子类的“个性”,一旦子类有“个性”,这个子类和父类之间的关系就很难调和了,把子类当做父类使用,子类的“个性”被抹杀——委屈了点;把子类单独作为一个业务来使用,则会让代码间的耦合关系变得扑朔迷离——缺乏类替换的标准。

 

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值