设计模式之禅:里氏替换原则

    技术2022-05-14  3

    一、单一职责原则(Single Responsiblility,SRP)

    单一职责原则的定义是:应该有且仅有一个原因引起类的变更。但是,在实际中,单一职责原则很难在项目中得到体现。是的,类的单一职责确实受到非常因素的制约,纯理论的讲,这个原则是非常优秀的,但是现实有现实的难处,你必须去考虑项目工期、成本、人员技术水平、硬件情况、网络情况甚至有时候还要考虑政府政策、垄断协议等因素。因此,对于单一职责原则,我的建议是借口一定要做到单一职责,类的设计尽量做到只有一个原因引起变化。

    二、里氏替换原则(Livkov Substitution Principle,LSP)

    定义:如果对每一个类型为S的对象o1,都有类型为T的对象o2,使得程序P在所有的对象o1都换成o2时,程序p的行为没有发生变化,那么类型S就是类型T的子类型。通俗地讲,只要父类能出现的地方子类就可以出现,而且替换为子类也不会产生任何错误或者异常,使用者可能根本就不需要知道是父类还是子类。但是,反过来就不行,有子类出现的地方,父类未必适应,里氏替换原则包含了四层意思:

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

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

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

    手枪,步枪,机枪的实现类如下:

      

    再来看士兵的源码:

    注意看黄色部分,我们要求传进来的是一个抽象的枪,具体是手枪还是步枪需要在上战场前(也就是场景中)通过setGun方法确定。场景类Client的源码如下所示:

    在这个程序中,我们给三毛这个士兵一把步枪,然后就开始杀敌了。如果三毛要使用机枪,当然也可以,直接把sanMao.killEnemy(new (Rifle))修改为sanMao.killEnemy(new MachineGun())即可,在编写程序时Solider士兵类根本不知道是那个型号的枪(子类)被传入。说明这样就符合LSP原则。

    我们再来想一想,如果我们有一个玩具手枪,该怎么去定义呢?我们现在类图上添加一个类ToyGun,如图:

    首先我们想,玩具枪是不能用来射击的,杀不死人,这个不应写在shoot方法中,如图:

    修改Client,如图:

    运行结果:

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

    A、在Soldier类中增加instanceof的判断,如果是ToyGun,就不能用来杀敌人。但是,如这样就要求所有与这个父类有关系的类都必须增加一个判断(也就是其他调用AbstranceGun都要做这个判断)。很显然,这个方法是不现实的。所以,这个方案被否定。(这段话需要细细体味一下,其实很有道理

    B、ToyGun脱离继承,建立一个独立的父类,为了实现代码复用,可以与AbstractGun关联委托关系(具体怎么实现没看懂,标记一下),如图:

    例如:可以在AbstractToy中声明将声音、形状都委托给AbstractGun处理,仿真枪嘛,形状和声音都要和真实的枪一样了,然后两个基类下的子类自由延展,互不影响。

    在类的基础知识中都会讲到继承,类的三大特征,继承、封装、多态。继承就是告诉你拥有父类的方法和属性,然后你就可以重写父类的方法。按照继承原则,我们上面的玩具枪继承AbstractGun是绝对没问题的,玩具枪也是枪嘛,但是在具体应用场景中就要考虑下面这个问题了:子类是否能够完整地实现父类的业务,否则就会出现像上面的拿枪杀敌人确发现是玩具枪的笑话

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

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

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

    方法的输入参数称为前置条件。我们来看个例子,我们定义一个Father类,如图:

    然后我们再定义一个子类:

    注意看黄色部分,与父类的方法名相同,但输入参数不同,这不是覆盖,是重载(OverLoad)。场景类调用如下:

    运行结果:

    根据里氏替换原则,父类出现的地方子类都可以出现,我们修改上面黄色部分为子类:

    运行结果还是一样,也就是调用的仍然是父类的方法。这是正确的,但如果反过来想一下,如果Father类的输入参数类型宽于子类的输入参数类型,会出现什么问题呢?会出现父类存在的地方,子类就未必可以存在,因为一旦把子类作为参数传入,调用者就很可能进入子类的方法范畴。我们修改一下上面的例子,扩大父类的前置条件,如图:

     

    然后缩小子类的前置条件:

    业务场景的代码如图:

    执行结果为:

      我们再把里氏替换原则引入进来会出现什么问题呢?有父类的地方子类就可以使用,我们用子类替换一下父类,如图:

    执行结果为:

     

    完蛋了吧,子类在没有覆盖父类方法的的前提下,子类方法被执行了,这会引起业务逻辑混乱,因为在实际应用中,父类一般是抽象类,子类是实现类,你传递一个这样的实现类就会“歪曲”了父类的意图,引起一堆意想不到的业务逻辑混乱,所以子类中方法的前置条件必须比父类中被覆盖的方法的前置条件相同或者更宽松。(这段话需要细细体味一下,其实很有道理

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

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

    //以上内容皆选自秦小波所著的< <设计模式之禅> >


    最新回复(0)