设计模式的设计原则(二)

里氏替换原则(LSP)

说里氏替换原则之前,还得让我们回顾一个java类的特性之一:继承、

继承的优点:

代码共享,减少创建类的工作量,每个子类都拥有父类的方法和属性。
提高代码的重用性。
子类可以形似父类,但又异于父类。
提高代码的可扩展性,只需实现父类的方法。
提高产品或项目的开放性。

继承的缺点:

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

里氏替换原则的定义:
第一种定义,也是最正宗的定义:If for each object o1 of type S there is an object o2 of type T such that for all programs P defined in terms of T, the behavior of P is unchanged when o1 is substituted for o2 then S is a subtype of T.(如果对每一个类型为S的对象o1,都有类型为T的对象o2,使得以T定义的所有程序P在所在的对象o1都代换为o2时,程序P的行为没有发生变化,那么类型S是类型T的字类型。)
第二种定义:Functions that use pointers or references to base classes must be able to use objects of derived classes without knowing it.**(所有引用基类的地方必须能透明地使用其子类的对象。) **

里氏替换原则包含了4层含义:

  1. 子类必须完全实现父类的方法
    如果子类不能完整地实现父类的方法,或者父类的某些方法在子类中已经发生"畸变",则建议断开父子继承关系,采用依赖、聚集、组合等关系代替继承。
  2. 子类可以有自己的个性
    子类可以有自己的行为和外观,也就是方法和属性。
  3. 覆盖或实现父类的方法时输入参数可以被放大
    子类的方法可以重载(Overload)父类的方法,并把输入参数设置成为父类的方法的输入参数的父类(即把输入参数放大)。这时,通过父类的引用调用这个方法,实际调用的还是父类的方法,子类的方法由于只是重载而不是覆写(Override),会被隐藏掉。子类可以覆写(Override)父类的方法。
    例如:父类Father中有方法"Collection doSomething(HashMap map)",同时子类Son中有方法"Collection doSomething(Map map)",由于Map是HashMap的父类,即把map这个输入参数"放大"了。代码:
public class Father { 
public Collection doSomething(HashMap map) { 
System.out.println("父类被执行"); 
return map.values(); 
} 
} 
public class Son extends Father { 
public Collection doSomething(Map map) { 
System.out.println("子类被执行"); 
return map.values(); 
       
} 
} 

执行:

Son f = new Son(); 
HashMap map = new HashMap(); 
f.doSomething(); 

结果:
父类被执行
这是我们想要的结果。
如果父类方法的输入参数类型宽于子类方法的输入参数类型,一旦把子类作为参数传入,调用者就很可能进入子类的方法,从而违背了本意。
如:父类Father中有方法"Collection doSomething(Map map)",同时子类Son中有方法"Collection doSomething(HashMap map)"。代码:

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

执行:

Son f = new Son(); 
HashMap map = new HashMap(); 
f.doSomething(); 

结果:
子类被执行
这不是我们想要的结果。
所以,子类中方法的前置条件必须与超类中被覆写的方法的前置条件相同或者更宽松。

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

采用里氏替换原则的目的就是增强程序的健壮性,版本升级时也可以保持非常好的兼容性。即使增加子类,原有的子类还可以继续运行。

在项目中,采用里氏替换原则时,尽量避免子类的"个性",一旦子类有"个性",这个子类和父类之间的关系就很难调和了,把子类当作父类使用,子类的"个性"会被抹杀。

我的解释:

理论讲完了,是不是听的一头雾水?怎么父类被执行就是我想要的结果了?怎么子类被执行就不是我想要的结果了?那接下来,我将用你能听懂的话,来解释这个问题。
举一个例子:
生物学的分类体系中把企鹅归属为鸟类。我们模仿这个体系,设计出这样的类和关系。
类"鸟"中有个方法fly,企鹅自然也继承了这个方法,可是企鹅不能飞阿,于是,我们在企鹅的类中覆盖了fly方法,告诉方法的调用者:企鹅是不会飞的。这完全符合常理。但是,这违反了LSP,企鹅是鸟的子类,可是企鹅却不能飞!需要注意的是,此处的"鸟"已经不再是生物学中的鸟了,它是软件中的一个类、一个抽象。
有人会说,企鹅不能飞很正常啊,而且这样编写代码也能正常编译,只要在使用这个类的客户代码中加一句判断就行了。但是,这就是问题所在!首先,客户代码和"企鹅"的代码很有可能不是同时设计的,在当今软件外包一层又一层的开发模式下,你甚至根本不知道两个模块的原产地是哪里,也就谈不上去修改客户代码了。客户程序很可能是遗留系统的一部分,很可能已经不再维护,如果因为设计出这么一个"企鹅"而导致必须修改客户代码,谁应该承担这部分责任呢?修改客户代码"直接违反了OCP。违反LSP将使既有的设计不能封闭!
在简单来说,我们在设计继承树的时候,一定要保证,子类一定完全能实现父类的所有方法,且参数范围大于等于父类。(就相当于我们写出的鸟类如果要继承自抽象鸟的话,一定要实现fly方法。你不能飞,就不要来继承。)

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

相关阅读更多精彩内容

友情链接更多精彩内容