6大设计原则之里氏替换原则(Liskov Substitution Principle)。

来源:互联网 发布:产品经理与数据分析师 编辑:程序博客网 时间:2024/06/05 15:08

继承的优点:

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

继承的缺点:

  • 继承是侵入性的。只要继承,就必须拥有父类的所有属性和方法;
  • 降低代码的灵活性。子类必须拥有父类的属性和方法,让子类自由的世界中多了些约束;
  • 增强了耦合性。当父类的常量、变量和方法被修改时,需要考虑子类的修改,而且在缺乏规范的环境下,这种修改可能带来非常糟糕的结果——大段的代码需要重构。

里氏替换原则两种定义:

  • 第一种定义:如果对每一个类型为S的对象o1,都有类型为T的对象o2,使得以T定义的所有程序P在所有的对象o1都代换成o2时,程序P的行为没有发生变化,那么类型S是类型T的子类型。
  • 第二种定义:所有引用基类的地方必须能透明地使用其子类的对象。
  • 第一种是最正宗的定义;第二种定义是最清晰明确的,通俗地讲,只要父类能出现的地方子类就可以出现,而且替换为子类也不会产生任何错误或异常,使用者可能根本就不需要知道是父类还是子类。但是反过来就不行了,有子类出现的地方,父类未必就能适应。

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

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

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

枪支的抽象类

public abstract class AbstractGun {

   // 枪用来干什么的?杀敌!

  public abstract void shoot();

}

手枪的实现类

public class Handgun extends AbstractGun {

  // 手枪的特点是携带方便,射程短

  @Override

  public void shoot() {

    System.out.println("手枪射击");

  }

}

步枪、机枪的的实现类与手枪的实现类一致,差异是方法内的内容不一致。

士兵的实现类

public class Soldier {

  // 定义士兵的枪支

  private AbstractGun gun;

  // 给士兵一支枪

  public void setGun(AbstractGun  _gun) {

    this.gun = _gun;

  }

  // 杀人

  public void killEnemy() {

    System.out.println("士兵开始杀敌人...");

    this.gun.shoot();

  }

}

场景类

public class Client {

  public static void main(String[] args) {

    // 产生三毛这个士兵

    Soldier sanMao = new Soldier();

    // 给三毛一支枪

    sanMao.setGun(new Rifle());

    sanMao.killEnemy();

  }

}


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

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


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

子类当然可以有自己的行为和外观了,也就是方法和属性,那这里为什么要再提呢?是因为里氏替换原则可以正着用,但是不能反过来用。在子类出现的地方,父类未必就可以胜任。

AUG阻击枪类

public class AUG extends Rifle {

  // 阻击枪都携带一个精准的望远镜

  public void zoomOut() {

    System.out.println("通过望远镜查看敌人...");

  }

  @Override

  public void shot() {

      System.out.println("AUG射击...");

  }

}

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

狙击手使用AUG杀死敌人

public class Client {

  public static void main(String[] args) {

    // 产生三毛这个狙击手

   Snipper sanMao = new Snipper();

   sanMao.setRifle(new AUG());

   sanMao.killEnemy();

  }

}

注意:如果使用父类作为参数,会在运行期抛出java.lang.ClassCastException异常,这也是大家经常说的向下转型(downcast)是不安全的,从里氏替换原则来看,就是有子类出现的地方父类未必就可以出现。


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

注意:输入参数放大就是重载(Overload)而不是覆写(Override)

Father类

public class Father {

  public Collection doSomething(HashMap map) {

    System.out.println("父类被执行...");

    return map.values();

  }

}

Son类

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();

    HashMap map = new HashMap();

    f.doSomething(map);

  }

  public static void main(Stirng[] args) {

     invoker();

  }

}

子类的输入参数类型的范围扩大了,子类代替父类传递到调用者中,子类的方法永远都不会被执行。如果Father类的输入参数类型宽于子类的输入参数类型,会出现父类存在的地方,子类就未必可以存在,因为一旦把子类作为参数传入,调用者很可能进入子类的方法范围。


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

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

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


最佳实践:

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

阅读全文
0 0