面向对象设计原则(三):里氏替换原则(LSP)

面向对象设计原则(三):里氏替换原则(LSP)

      里氏替换原则(Liskov Substitution Principle,LSP)是面向对象设计(OOD)中比较重要、常见的一种,下面来总结里氏替换原则的知识点,包括:

      1、什么是里氏替换原则?

      2、为什么需要遵守里氏替换原则?

     3、怎么实现里氏替换原则,保证子类能透明的替换父类?

      4、里氏替换原则(LSP)与开闭原则(OCP)的区别与联系;  

      5、里氏替换原则冲突时如何重构,以及违反里氏替换原则的一些情况。

1、什么是里氏替换原则(LSP)

1-1、里氏替换原则的定义

      里氏替换原则(Liskov Substitution Principle,LSP)可以解释为:

     派生类型(子类)必须能够替换掉它们的基类型(父类)。

     Barbara LisKov首次写下这个原则是在1988年。她说道:

     "这里需要如下的替换性质:若对类型S的每一个对象O1,都存在一个类型T的对象O2,使得在所有针对T编写的程序P中,用O1替换O2后,程序P的行为功能不变,则S是T的子类型。"

1-2、定义解释

      可以用简单的伪代码解释:

     Sub extends Base;

     对于所有使用基类的方法:

     method(Base b);

     对于每一个派生类,可以这样使用:

     method(new Sub());

     但反之则不行,定义:

     method(Sub b);

     不可以这样使用:

     method(new Base());

      Andy Hunt和Dave Thomas总结(2000):

     派生类必须能通过基类的接口而被使用,且使用者无须了解两者之间的差异。

2、为什么需要里氏替换原则

     为什么要让所有派生类可以替换基类呢?

      答:

     对于基类中定义的所有子程序,用在它的任何一个派生类中时的含义都应该是相同的。

     这样继承才不会增加复杂度,基类才能真正被复用,而派生类也能够在基类的基础上增加新的行为。

     如果我们必须要不断地思考不同派生类的实现在语义上的差异,继承就只会增加复杂度了。

      最终,还是回到了这个主题:

     软件的首要技术使命--管理复杂度。

     程序遵循Liskov替换原则,就能降低继承带来的复杂度。

3、怎么实现里氏替换原则

     即:如何使用里氏替换原则(LSP),使得继承子类能透明的替换父类?

     首先得理解继承:

     Liskov提出(1998):

     除非派生类是一个"更特殊"的类,否则不应该从基类继承。

     继承是IS-A关系("Sub是一个Base"):

     如果一个新类型的对象被认为和一个已有类的对象之间满足IS-A关系,那么这个新对象的类应该从这个已用对象的类派生。

     LSP清楚的指出:

     IS-A是关于行为的,对象的行为才是客户程序所依赖的,是软件真正所关注的问题。

     而对象的行为方式是可以进行合理假设的,基于契约的设计技术可以使这些合理的假设明确化,从而支持了LSP。

     基于契约设计(Design By Contract,DBC):

     类的编写者显式地规定针对该类的契约;而客户代码的编写者可以通过该契约获悉可以依赖的行为方式。

     契约是通过为每个方法声明前置条件(Precondition) 和后置条件(Postcondition)来指定的。

     前置条件和后置条件:

     要使一个方法得以执行,前置条件必须要为真。执行完毕后,该方法要保证后置条件为真。

     按照Meyer所述,派生类的前置条件和后置条件规则是:

     "在重新声明派生类中的例程时,只能使用相等或者更弱的前置条件来替换原始的前置条件,只能使用相等或者更强的后置条件来替换原始的后置条件。"

     示例说明:

     C++、Java和C#中都没有对前置条件和后置条件的直接支持,如Java可以编写断言assert语句来实现,伪代码示例如下:

     父类的前置条件:

     //用户名不为空,且密码不能为空

     assert((userName != null) && (password != null));

     子类的前置条件(更弱):

     //用户名不为空

     assert((userName != null));

     父类的后置条件:

     //返回结果Token不为空

     assert((result.token != null));

     子类的后置条件(更强):

     //返回结果Token不为空,且需要设置过期时间

     assert((result.token!= null) && (result.expiredTime != null));

     当用户(单元测试)使用父类的时候,只需要知道父类的前置条件和后置条件,就可以替换的使用其所有子类,因为:

     对于前置条件,满足父类的前置条件:用户名不为空,且密码不能为空;必定满足子类的前置条件(更弱):用户名不为空。即:子类必须接受父类可以接受的一切。

     对于后置条件,满足父类的后置条件:返回结果Token不为空;必定满足子类的后置条件(更强):返回结果Token不为空,且需要设置过期时间。即:父类的用户不应被所使用的子类的输出扰乱。

4、里氏替换原则需要注意什么

4-1、里氏替换原则(LSP)与开闭原则(OCP)的区别与联系

     在上一篇文章《面向对象设计原则(二):开放-封闭原则(OCP)》我们知道:

     开放-封闭原则(OCP):

     软件实体(类、模块、函数等)应该是可以扩展的,但是不可修改的。

     即:

     软件质量的下降,来源于修改;

     替换整个实现类,而不是修改其中的某行;

     替换的扩展类可以从抽象类继承、或实现抽象接口。

     提倡使用实现抽象接口的方式。

     OCP扩展类可以继承或实现接口两种方式,而里氏替换原则(LSP)主要针对继承的,它能降低继承带来的复杂度,所以可以说:LSP是OCP的重要方式之一。

     当然,有很多文章说到LSP与OCP第二种"实现接口来扩展(替换)"相关,个人理解:面向接口编程,替换使用的时候,是需要考虑每个实现类的细节的;而LSP强调的是透明的替换使用。

4-2、里氏替换原则与重构

     当继承不能满足里氏替换原则时,应该进行重构,两种重构方法:

1、重新提取公共部分的方法

     把冲突的派生类与基类的共部分提取出来作为一个抽象基类,然后分别继承这个类。

     对于提取公共部分:

     提取公共部分是一个设计工具,最好在代码不是很多的时候应用。

     因为如果两个派生类中具有一些公共的特性,那么很可能稍后出现的其他类也会需要这些特性。

2、改变继承关系

     即:从父子关系变为委派关系或兄弟关系。

     可以把它们的一些公有特性提取到一个抽象接口,再分别实现。

4-3、常见违反里氏替换原则的情况

     违反LSP一般都是派生类以某种方式从其基类中去除一些功能有关,完成的功能少于其基类的派生类通常是不能替换其基类的,因为违反了LSP。

1、派生类中的退化函数

     基类一个函数提供默认实现,派生类中重写却什么都不做,这很可能违反LSP。

2、从派生类中抛出新异常

     从派生类的方法中抛出了其基类不会抛出的异常。

     因为如果基类使用都不期望这些异常,这些派生类的方法就会导致不可替换性。

5、总结

      里氏替换原则(LSP):

     派生类型(子类)必须能够替换掉它们的基类型(父类)。

      即:

     派生类必须能通过基类的接口而被使用,且使用者无须了解两者之间的差异。

    LSP主要针对继承,能降低继承带来的复杂度。

    可以通过基于契约设计的前置条件和后置条件来保证透明替换。

     LSP是开闭原则(OCP)的重要方式之一。

      到这里,我们对里氏替换原则有了一个大体的了解,还需要平时开发中多注意,后面我们将了解其他的面向对象设计原则......

【参考资料】

1、《敏捷软件开发:原则、模式与实践》第10章 Liskov替换原则(LSP)

2、维基百科"Liskov substitution principle"

3、《代码大全》第二版 第5章 软件构件中的设计

4、《Java与模式》第7章 里氏代换原则(LSP)

猜你喜欢

转载自blog.csdn.net/tjiyu/article/details/76551307