Fork me on GitHub

Java的LSP原则

  在课上,老师讲解过Java在软件构造当中的LSP原则,并且强调考试是100%会考的,这就有必要来写一个博客来复习一下LSP原则。

序言:

  在面向对象的语言中,继承是必不可少的、非常优秀的语言机制,它有如下优点:
  ● 代码共享,减少创建类的工作量,每个子类都拥有父类的方法和属性;
  ● 提高代码的重用性;
  ● 子类可以形似父类,但又异于父类,“龙生龙,凤生凤,老鼠生来会打洞”是说子拥有父的“种”,“世界上没有两片完全相同的叶子”是指明子与父的不同;
  ● 提高代码的可扩展性,实现父类的方法就可以“为所欲为”了,君不见很多开源框架的扩展接口都是通过继承父类来完成的;
  ● 提高产品或项目的开放性。
  然而,事物不可能只有优点而没有缺点,继承就有如下的几个缺点:
  ● 继承是侵入性的。只要继承,就必须拥有父类的所有属性和方法;
  ● 降低代码的灵活性。子类必须拥有父类的属性和方法,让子类自由的世界中多了些约束;
  ● 增强了耦合性。当父类的常量、变量和方法被修改时,需要考虑子类的修改,而且在缺乏规范的环境下,这种修改可能带来非常糟糕的结果——大段的代码需要重构。

定义:

  Java使用extends关键字来实现继承,它采用了单一继承的规则,C++则采用了多重继承的规则,一个子类可以继承多个父类。从整体上来看,利大于弊,怎么才能让“利”的因素发挥最大的作用,同时减少“弊”带来的麻烦呢?解决方案是引入里氏替换原则(Liskov Substitution Principle,LSP),什么是里氏替换原则呢?

  里氏代换原则由2008年图灵奖得主、美国第一位计算机科学女博士Barbara Liskov教授和卡内基·梅隆大学Jeannette Wing教授于1994年提出。里氏代换原则的白话翻译是: 一个软件如果使用的是一个父类的话, 那么一定适用于其子类, 而察觉不出父类对象和子类对象的区别。 也即是说,在软件里面, 把父类替换成它的子类, 程序的行为不会有变化, 简单地说, 子类型必须能够替换掉它们的父类型。

  LSP的第二重定义就为:所有引用基类的地方必须能透明地使用其子类的对象。(functions that use pointers or references to base classes must be able to use objects of derived classes without knowing it.)

  简而言之:如果对每一个类型为T1的对象o1,都有类型为T2的对象o2,使得T1定义的所有程序P在所有的对象o1都替换成o2后,程序P的行为没有变化,那么类型T2是类型T1的子类。

含义:

  1.子类可以实现父类的抽象方法,但不能覆盖父类的非抽象方法。

  继承包含这样一层含义:父类中凡是已经实现好的方法(相对于抽象方法而言),实际上是在设定一系列的规范和契约,虽然它不强制要求所有的子类必须遵从这些契约,但是如果子类对这些非抽象方法任意修改,就会对整个继承体系造成破坏。而里氏替换原则就是表达了这一层含义。

  举例说明继承的风险,一个两数相减的功能,由类Father来负责

public class Test {

public static void main (String[] args){
Father f = new Father();
System.out.println("100-50="+f.func1(100, 50));
System.out.println("100-80="+f.func1(100, 80));
}
}

class Father{
public int func1(int a, int b){
return a-b;
}
}
输出

100-50=50
100-80=20

  后来,我们需要增加一个新的功能:完成两数相加,然后再与100求和,由类Son来负责。即类B需要完成两个功能:

  • 两数相减。
  • 两数相加,然后再加100。

  由于类Father已经实现了第一个功能,所以类Son继承类Father后,只需要再完成第二个功能就可以了,代码如下:

 1 public class Test {
 2 
 3 public static void main (String[] args){
 4 Son s = new Son();
 5 System.out.println("100-50="+s.func1(100, 50));
 6 System.out.println("100-80="+s.func1(100, 80));
 7 System.out.println("100+20+100="+s.func2(100, 20));
 8 }
 9 }
10 
11 class Father{
12 public int func1(int a, int b){
13 return a-b;
14 }
15 }
16 
17 class Son extends Father{
18 public int func1(int a, int b){
19 return a+b;
20 }
21 
22 public int func2(int a, int b){
23 return func1(a,b)+100;
24 }
25 }
输出

100-50=150
100-80=180
100+20+100=220

  我们发现原本运行正常的相减功能发生了错误。原因就是类Son 在给方法起名时无意中重写了父类的方法,造成所有运行相减功能的代码全部调用了类Son 重写后的方法,造成原本运行正常的功能出现了错误。在本例中,引用基类Father完成的功能,换成子类Son 之后,发生了异常。在实际编程中,我们常常会通过重写父类的方法来完成新的功能,这样写起来虽然简单,但是整个继承体系的可复用性会比较差,特别是运用多态比较频繁时,程序运行出错的几率非常大。如果非要重写父类的方法,比较通用的做法是:原来的父类和子类都继承一个更通俗的基类,原有的继承关系去掉,采用依赖、聚合,组合等关系代替。  

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

  子类当然可以有自己的行为和外观了,也就是方法和属性,那这里为什么要再提呢?是因为里氏替换原则可以正着用,但是不能反过来用。在子类出现的地方,父类未必就可以胜任。相信很多人都玩过CS,这里就以里面的枪支为例,步枪有几个比较“响亮”的型号,比如AK47、AUG狙击步枪等,把这两个型号的枪引入后的Rifle子类图如下所示:

  很简单,AUG继承了Rifle类,狙击手(Snipper)则直接使用AUG狙击步枪,源代码如代码清单如下所示:

 

public class AUG extends Rifle {
    //狙击枪都携带一个精准的望远镜
    public void zoomOut(){
        System.out.println("通过望远镜察看敌人...");
    }
    public void shoot(){
        System.out.println("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();
        }
    }

  

  在这里,系统直接调用了子类,狙击手是很依赖枪支的,别说换一个型号的枪了,就是换一个同型号的枪也会影响射击,所以这里就直接把子类传递了进来。这个时候,我们能不能直接使用父类传递进来呢?修改一下Client类,如下所示:

  使用父类Rifle作为参数:

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

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

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

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

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

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

作用

  里氏替换法则诞生的目的就是加强程序的健壮性,同时版本升级也可以做到非常好的兼容性,增加子类,原有的子类还可以继续运行。在我们项目实施中就是每个子类对应了不同的业务含义,使用父类作为参数,传递不同的子类完成不同的业务逻辑。

鸣谢

  本博客有如下的参考:

  里氏代换原则_yuyang_1995-CSDN博客_里氏替换

  一文让你搞懂面向对象设计原则(单一职责原则,开闭原则,里氏代换原则,依赖倒转原则,接口隔离原则,合成复用原则,迪米特法则)_小徐的博客-CSDN博客

  Java设计原则之里氏替换原则 - cxks_xu - 博客园 (cnblogs.com)

posted @ 2021-07-05 22:04  牺牲的钢铁侠  阅读(368)  评论(0)    收藏  举报