面向对象设计原则(三):里氏替换原则(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)