架构师之路(7)---里氏代换原则_CyRo的博客-程序员宅基地

技术标签: 架构师  架构师之路  

http://blog.csdn.net/phphot/article/details/4050193 源地址
4 里氏代换原则(Liskov Substitution Principle, LSP)

4.1 什么是里氏代换原则

里氏代换原则是由麻省理工学院(MIT)计算机科学实验室的Liskov女士,在1987年的OOPSLA大会上发表的一篇文章《Data Abstraction and Hierarchy》里面提出来的,主要阐述了有关继承的一些原则,也就是什么时候应该使用继承,什么时候不应该使用继承,以及其中的蕴涵的原理。2002年,我们前面单一职责原则中提到的软件工程大师Robert C. Martin,出版了一本《Agile Software Development Principles Patterns and Practices》,在文中他把里氏代换原则最终简化为一句话:“Subtypes must be substitutable for their base types”。也就是,子类必须能够替换成它们的基类。
我们把里氏代换原则解释得更完整一些:在一个软件系统中,子类应该可以替换任何基类能够出现的地方,并且经过替换以后,代码还能正常工作。

4.2 第一个例子:正方形不是长方形
“正方形不是长方形”是一个理解里氏代换原则的最经典的例子。在数学领域里,正方形毫无疑问是长方形,它是一个长宽相等的长方形。所以,我们开发的一个与几何图形相关的软件系统中,让正方形继承自长方形是顺利成章的事情。现在,我们截取该系统的一个代码片段进行分析:
长方形类Rectangle:
class Rectangle {
double length;
double width;
public double getLength() { return length; }
public void setLength(double height) { this.length = length; }
public double getWidth() { return width; }
public void setWidth(double width) { this.width = width; }
}
正方形类Square:
class Square extends Rectangle {
public void setWidth(double width) {
super.setLength(width);
super.setWidth(width);
}
public void setLength(double length) {
super.setLength(length);
super.setWidth(length);
}
}
由于正方形的度和宽度必须相等,所以在方法setLength和setWidth中,对长度和宽度赋值相同。类TestRectangle是我们的软件系统中的一个组件,它有一个resize方法要用到基类Rectangle,resize方法的功能是模拟长方形宽度逐步增长的效果:
测试类TestRectangle:
class TestRectangle {
public void resize(Rectangle objRect) {
while(objRect.getWidth() <= objRect.getLength() ) {
objRect.setWidth( objRect.getWidth () + 1 );
}
}
}
我们运行一下这段代码就会发现,假如我们把一个普通长方形作为参数传入resize方法,就会看到长方形宽度逐渐增长的效果,当宽度大于长度,代码就会停止,这种行为的结果符合我们的预期;假如我们再把一个正方形作为参数传入resize方法后,就会看到正方形的宽度和长度都在不断增长,代码会一直运行下去,直至系统产生溢出错误。所以,普通的长方形是适合这段代码的,正方形不适合。
我们得出结论:在resize方法中,Rectangle类型的参数是不能被Square类型的参数所代替,如果进行了替换就得不到预期结果。因此,Square类和Rectangle类之间的继承关系违反了里氏代换原则,它们之间的继承关系不成立,正方形不是长方形。

4.3 第二个例子:鸵鸟不是鸟
“鸵鸟非鸟”也是一个理解里氏代换原则的经典的例子。“鸵鸟非鸟”的另一个版本是“企鹅非鸟”,这两种说法本质上没有区别,前提条件都是这种鸟不会飞。生物学中对于鸟类的定义:“恒温动物,卵生,全身披有羽毛,身体呈流线形,有角质的喙,眼在头的两侧。前肢退化成翼,后肢有鳞状外皮,有四趾”。所以,从生物学角度来看,鸵鸟肯定是一种鸟。
我们设计一个与鸟有关的系统,鸵鸟类顺理成章地由鸟类派生,鸟类所有的特性和行为都被鸵鸟类继承。大多数的鸟类在人们的印象中都是会飞的,所以,我们给鸟类设计了一个名字为fly的方法,还给出了与飞行相关的一些属性,比如飞行速度(velocity)。
鸟类Bird:
class Bird {
double velocity;
public fly() { //I am flying; };

public setVelocity(double velocity) { this.velocity = velocity; };
public getVelocity() { return this.velocity; };
}
鸵鸟不会飞怎么办?我们就让它扇扇翅膀表示一下吧,在fly方法里什么都不做。至于它的飞行速度,不会飞就只能设定为0了,于是我们就有了鸵鸟类的设计。
鸵鸟类Ostrich:
class Ostrich extends Bird {
public fly() { //I do nothing; };
public setVelocity(double velocity) { this.velocity = 0; };
public getVelocity() { return 0; };
}
好了,所有的类都设计完成,我们把类Bird提供给了其它的代码(消费者)使用。现在,消费者使用Bird类完成这样一个需求:计算鸟飞越黄河所需的时间。
对于Bird类的消费者而言,它只看到了Bird类中有fly和getVelocity两个方法,至于里面的实现细节,它不关心,而且也无需关心,于是给出了实现代码:
测试类TestBird:
class TestBird {
public calcFlyTime(Bird bird) {
try{
double riverWidth = 3000;
System.out.println(riverWidth / bird.getVelocity());
}catch(Exception err){
System.out.println(“An error occured!”);
}
};
}
如果我们拿一种飞鸟来测试这段代码,没有问题,结果正确,符合我们的预期,系统输出了飞鸟飞越黄河的所需要的时间;如果我们再拿鸵鸟来测试这段代码,结果代码发生了系统除零的异常,明显不符合我们的预期。
对于TestBird类而言,它只是Bird类的一个消费者,它在使用Bird类的时候,只需要根据Bird类提供的方法进行相应的使用,根本不会关心鸵鸟会不会飞这样的问题,而且也无须知道。它就是要按照“所需时间 = 黄河的宽度 / 鸟的飞行速度”的规则来计算鸟飞越黄河所需要的时间。
我们得出结论:在calcFlyTime方法中,Bird类型的参数是不能被Ostrich类型的参数所代替,如果进行了替换就得不到预期结果。因此,Ostrich类和Bird类之间的继承关系违反了里氏代换原则,它们之间的继承关系不成立,鸵鸟不是鸟。

4.4 鸵鸟到底是不是鸟?
“鸵鸟到底是不是鸟”,鸵鸟是鸟也不是鸟,这个结论似乎就是个悖论。产生这种混乱有两方面的原因:
原因一:对类的继承关系的定义没有搞清楚。
面向对象的设计关注的是对象的行为,它是使用“行为”来对对象进行分类的,只有行为一致的对象才能抽象出一个类来。我经常说类的继承关系就是一种“Is-A”关系,实际上指的是行为上的“Is-A”关系,可以把它描述为“Act-As”。关于类的继承的细节,我们可以单独再讲。
我们再来看“正方形不是长方形”这个例子,正方形在设置长度和宽度这两个行为上,与长方形显然是不同的。长方形的行为:设置长方形的长度的时候,它的宽度保持不变,设置宽度的时候,长度保持不变。正方形的行为:设置正方形的长度的时候,宽度随之改变;设置宽度的时候,长度随之改变。所以,如果我们把这种行为加到基类长方形的时候,就导致了正方形无法继承这种行为。我们“强行”把正方形从长方形继承过来,就造成无法达到预期的结果。
“鸵鸟非鸟”基本上也是同样的道理。我们一讲到鸟,就认为它能飞,有的鸟确实能飞,但不是所有的鸟都能飞。问题就是出在这里。如果以“飞”的行为作为衡量“鸟”的标准的话,鸵鸟显然不是鸟;如果按照生物学的划分标准:有翅膀、有羽毛等特性作为衡量“鸟”的标准的话,鸵鸟理所当然就是鸟了。鸵鸟没有“飞”的行为,我们强行给它加上了这个行为,所以在面对“飞越黄河”的需求时,代码就会出现运行期故障。

原因二:设计要依赖于用户要求和具体环境。
继承关系要求子类要具有基类全部的行为。这里的行为是指落在需求范围内的行为。图中鸟类具有4个对外的行为,其中2个行为分别落在A和B系统需求中:

系统需求和对象关系示意图

A需求期望鸟类提供与飞翔有关的行为,即使鸵鸟跟普通的鸟在外观上就是100%的相像,但在A需求范围内,鸵鸟在飞翔这一点上跟其它普通的鸟是不一致的,它没有这个能力,所以,鸵鸟类无法从鸟类派生,鸵鸟不是鸟。
B需求期望鸟类提供与羽毛有关的行为,那么鸵鸟在这一点上跟其它普通的鸟一致的。虽然它不会飞,但是这一点不在B需求范围内,所以,它具备了鸟类全部的行为特征,鸵鸟类就能够从鸟类派生,鸵鸟就是鸟。

所有派生类的行为功能必须和使用者对其基类的期望保持一致,如果派生类达不到这一点,那么必然违反里氏替换原则。在实际的开发过程中,不正确的派生关系是非常有害的。伴随着软件开发规模的扩大,参与的开发人员也越来越多,每个人都在使用别人提供的组件,也会为别人提供组件。最终,所有人的开发的组件经过层层包装和不断组合,被集成为一个完整的系统。每个开发人员在使用别人的组件时,只需知道组件的对外裸露的接口,那就是它全部行为的集合,至于内部到底是怎么实现的,无法知道,也无须知道。所以,对于使用者而言,它只能通过接口实现自己的预期,如果组件接口提供的行为与使用者的预期不符,错误便产生了。里氏代换原则就是在设计时避免出现派生类与基类不一致的行为。

4.5 如何正确地运用里氏代换原则
里氏代换原则目的就是要保证继承关系的正确性。我们在实际的项目中,是不是对于每一个继承关系都得费这么大劲去斟酌?不需要,大多数情况下按照“Is-A”去设计继承关系是没有问题的,只有极少的情况下,需要你仔细处理一下,这类情况对于有点开发经验的人,一般都会觉察到,是有规律可循的。最典型的就是使用者的代码中必须包含依据子类类型执行相应的动作的代码:
动物类Animal:
public class Animal{
String name;
public Animal(String name) {
this.name = name;
}
public void printName(){
try{
System.out.println(“I am a ” + name + “!”);
}catch(Exception err){
System.out.println(“An error occured!”);
}
}
}
猫类Cat:
public class Cat extends Animal{
public Cat(String name){
super(name);
}
public void Mew(){
try{
System.out.println(“Mew~~~ “);
}catch(Exception err){
System.out.println(“An error occured!”);
}
}
}
狗类Dog:
public class Dog extends Animal {
public Dog(String name) {
super(name);
}
public void Bark(){
try{
System.out.println(“Bark~~~ “);
}catch(Exception err){
System.out.println(“An error occured!”);
}
}
}
测试类:TestAnimal
public class TestAnimal {
public void TestLSP(Animal animal){
if (animal instanceof Cat ){
Cat cat = (Cat)animal;
cat.printName();
cat.Mew();
}
if (animal instanceof Dog ){

   Dog dog = (Dog)animal;
   dog.printName();
   dog.Bark();
 }

}
}
象这种代码是明显不符合里氏代换原则的,它给使用者使用造成很大的麻烦,甚至无法使用,对于以后的维护和扩展带来巨大的隐患。实现开闭原则的关键步骤是抽象化,基类与子类之间的继承关系就是一种抽象化的体现。因此,里氏代换原则是实现抽象化的一种规范。违反里氏代换原则意味着违反了开闭原则,反之未必。里氏代换原则是使代码符合开闭原则的一个重要保证。

我们常见这样的代码,至少我以前的Java和php项目中就出现过。比如有一个网页,要实现对于客户资料的查看、增加、修改、删除功能,一般Server端对应的处理类中都有这么一段:

if(action.Equals(“add”)){
//do add action
}
else if(action.Equals(“view”)){
//do view action
}
else if(action.Equals(“delete”)){
//do delete action
}
else if(action.Equals(“modify”)){
//do modify action
}
大家都很熟悉吧,其实这是违背里氏代换原则的,结果就是可维护性和可扩展性会变差。有人说:我这么用,效果好像不错,干嘛讲究那么多呢,实现需求是第一位的。另外,这种写法看起来很很直观的,有利于维护。其实,每个人所处的环境不同,对具体问题的理解不同,难免局限在自己的领域内思考问题。对于这个说法,我觉得应该这么解释:作为一个设计原则,是人们经过很多的项目实践,最终提炼出来的指导性的内容。如果对于你的项目来讲,显著增加了工作量和复杂度,那我觉得适度的违反并不为过。做任何事情都是个度的问题,过犹不及都不好。在大中型的项目中,是一定要讲究软件工程的思想,讲究规范和流程的,否则人员协作和后期维护将会是非常困难的。对于小型的项目可能相应的要简化很多,可能取决于时间、资源、商业等各种因素,但是多从软件工程的角度去思考问题,对于系统的健壮性、可维护性等性能指标的提高是非常有益的。像生命周期只有一个月的系统,你还去考虑一大堆原则,除非脑袋被驴踢了。
实现开闭原则的关键步骤是抽象化,基类与子类之间的继承关系就是一种抽象化的体现。因此,里氏代换原则是实现抽象化的一种规范。违反里氏代换原则意味着违反了开闭原则,反之未必。里氏代换原则是使代码符合开闭原则的一个重要保证。

通过里氏代换原则给我们带来了什么样的启示?
类的继承原则:如果一个继承类的对象可能会在基类出现的地方出现运行错误,则该子类不应该从该基类继承,或者说,应该重新设计它们之间的关系。
动作正确性保证:符合里氏代换原则的类扩展不会给已有的系统引入新的错误。

版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。
本文链接:https://blog.csdn.net/u012487582/article/details/48129839

智能推荐

极致用云,数智护航_阿里云云栖号的博客-程序员宅基地

简介:我们邀请到了阿里云混合云监控平台(Sunfire)团队负责人王肇刚来给我们分析下阿里背后的数字化业务运维安全工程标准及解决方案。 本次分享涵盖了全新发布的数字化业务运维安全工程标准、安全生产解决方案,以及全新升级的产品能力:包括了全栈统一运维、全景监控和全周期安全工程相关产品能力的介绍,也包含了对产品解决方案在客户侧落地的最佳实践分享。我们邀请到了阿里云混合云监控平台(Sunfire)团队负责人王肇刚来给我们分析下阿里背后的数字化业务运维安全工程标准及解决方案。本次分享涵盖了全新发布的数字

Python Scrapy框架学习---入门-Spider-Tencent案例(五)_空山老师的博客-程序员宅基地

Spider类定义了如何爬取某个(或某些)网站。包括了爬取的动作(例如:是否跟进链接)以及如何从网页的内容中提取结构化数据(爬取item)。 换句话说,Spider就是您定义爬取的动作及分析某个网页(或者是有些网页)的地方。class scrapy.Spider是最基本的类,所有编写的爬虫必须继承这个类。主要用到的函数及调用顺序为:__init__() : 初始化爬虫名字和start...

spring security配置构建分析_起风哥的博客-程序员宅基地

先看一张大图从官网给的图中我们可以知道启动是通过封装一个FilterChainProxy 到servlet的filter体系中,来完成安全配置的全套操作。记住这个点,先放着,接着我们来了解spring security 配置模块中几个顶层接口org.springframework.security.config.annotation.SecurityBuilderorg.springframework.security.config.annotation.SecurityConfigureror

>> 算数右移和>>>逻辑右移_机灵小桶桶的博客-程序员宅基地

先说下原码和补码, 只说负数  以-2 为列  int  32位-2 的原码为   10000000  00000000 00000000 00000010原码转换补码   原码 符号位不变  其余全部取反  然后+1  即 -2 的补码为 11111111 11111111 11111111 11111110计算机中数字采用补码表示,原因    0的表示和运算等原因>

什么是能力成熟度模型 (Capability Maturity Model)CMM_jardownload的博客-程序员宅基地

能力成熟度模型 (Capability Maturity Model)CMM是对于软件组织在定义、实施、度量、控制和改善其软件过程的实践中各个发展阶段的描述。CMM的核心是把软件开发视为一个过程,并根据这一原则对软件开发和维护进行过程监控和研究,以使其更加科学化、标准化、使企业能够更好地实现商业目标。CMM是一种用于评价软件承包能力并帮助其改善软件质量的方法,侧重于软件开发过程的管理及工程能力...

随便推点

rabbit死信队列出现TTL时间超过但是进入不了死信队列情况_做一个坏人的博客-程序员宅基地_死信队列+ttl过期之后没有进入死信队列

原因是线上一场时间不精准问题导致的。总的来说,为了让消息队列消息更加健壮,于是配置了超时时间和死信队列。但是出现的问题是,配置队列的TTL,总有一些消息在超过TTL时间后,进入不了死信队列,影响及时的业务通知系统。问题在什么地方呢?prefetch: 1属性配置上。以下是问题重现,与解决过程1.环境搭建1.1rabbit服务器略1.2springboot工程略...

dockerfile 指令与.dockerignore_weixin_34049948的博客-程序员宅基地

.dockerignore文件dockerfile文件格式dockerfile常用指令 .dockerignore 忽略文件夹# comment注释*/temp* 忽略以temp开头的二级目录*/*/temp* 忽略以temp开头的任意层级子目录temp? 忽略以temp开头的当前目录中目录dockerfile文件...

人工智能、神经网络、机器学习、深度学习的区别_井口者的博客-程序员宅基地_神经网络属于人工智能吗

人工智能、神经网络、机器学习、深度学习是学习神经网络的时候经常会遇到的专业词汇,但是很多人可能不太了解他们的区别。人工智能人工智能是一个比较大的概念,相信有很多人看过一部电影《人工智能》,它里面的人工智能指的是计算机有了自我意识,控制大量的机器人。由此看来人工智能指的是能够有自我学习能力的计算机,它能够通过自我学习,最终有自己的判断能力,类似人类的大脑。神经网络神经网络是人工智能实现方式的一种,它的灵感来自于人类神经网络。人的大脑由大量的神经元构成,他们之间由突触连接,突触的强健与否,关系到连接的神

Xadmin修改图标_之言的博客-程序员宅基地_xadmin怎么加图标

1. 图标库 图标库(Font Awesome)网址 :http://fontawesome.dashgame.com/2.下载最新的图标库拷贝两个目录3.粘贴到项目里4.在Xadmin中加入完成!!!

软件测试工程师必知试题 | 根基_软件测试小黑屋的博客-程序员宅基地

01 什么是软件测试?软件测试的目的与原则是什么?在规定的条件下对程序进行操作,以发现程序错误,衡量软件质量,并对其是否能满足设计要求进行评估的过程。1)软件测试的目的:想以最少的人力、...

推荐文章

热门文章

相关标签