面向对象软件设计原则与建模.docx
《面向对象软件设计原则与建模.docx》由会员分享,可在线阅读,更多相关《面向对象软件设计原则与建模.docx(45页珍藏版)》请在冰豆网上搜索。
面向对象软件设计原则与建模
面向对象软件设计原则
(二)——软件设计的腐化
我们如何知道软件设计的优劣呢?
以下是一些拙劣设计的症状,当软件出现下面任何一种气味时,就表明软件正在腐化。
∙僵化性(Rigidity):
很难对系统进行改动,因为每个改动都会迫使许多对系统其他部分的其他改动。
∙脆弱性(Fragility):
对系统的改动会导致系统中和改动的地方在概念上无关的许多地方出现问题。
∙牢固性(Immobility):
很难解开系统的纠结,使之成为一些可在其他系统中重用的组件。
∙粘滞性(Viscosity):
做正确的事情比做错误的事情要困难。
∙不必要的复杂性(NeedlessComplexity):
设计中包含有不具任何直接好处的基础结构。
∙不必要的重复(NeedlessRepetition):
设计中包含有重复的结构,而该重复的结构本可以使用单一的抽象进行统一。
∙晦涩性(Opacity):
很难阅读、理解。
没有很好地表现出意图。
僵化性
僵化是指难以对软件进行改动,即使是简单的改动。
如果单一的改动会导致有依赖关系的模块中的连锁改动,那么设计就是僵化的。
必须要改动的模块越多,设计就越僵化。
大部分开发人员都遇到这样的情况:
他们对被要求进行一个看似简单的改动,当他实际进行改动时,才发现有许多改动带来的影响自己并没有预测到。
最后,改动所花费的时间要远比初始估算长。
他会重复软件开发人员惯用的悲叹:
“它比我想象的要复杂得多!
”
脆弱性
脆弱性是指,在进行一个改动时,程序的许多地方就可能出现问题。
常常是,出现新问题的地方与改动的地方并没有概念上的关联。
要修正这些问题就又会引出新的问题,从而使软件开发团队就像一只不停追逐自己尾巴的狗一样。
牢固性
牢固性是指,设计中包含了对其他系统有用的部分,但是要把这些部分从系统中分离出来需要的努力和风险是巨大的。
这是一件令人遗憾的事,但却是非常常见。
粘滞性
当面临一个改动时,开发人员常常会发现会有多种改动的方法。
其中,一些会保持设计;而另外一些会破坏设计(也就是生硬的手法)。
当那些可以保持系统设计的方法比那些生硬手法更难应用时,就表明设计具有高的粘滞性。
做错误的事情是容易的,但是做正确的事情却很难。
这样就很难保持项目中的软件设计。
不必要的复杂性
如果设计中包含当前没有用的组成部分,它就含有不必要的复杂性。
当开发人员预测需求的变化,并在软件中放置了处理潜在变化的代码时,常常会出现这种情况。
起初,这样看起来是一件好事。
毕竟,为将来的变化做准备会保持代码的灵活性,而且可以避免以后再进行痛苦的改动。
糟糕的是,结果常常正好相反。
为过多的可能性作准备,致使设计中含有绝不会用到的结构,从而变得混乱。
一些准备也许会带来回报,但是更多的不会。
期间,设计背负着这些不会用到的部分,使软件变得复杂,而且难以理解。
不必要的重复
复制(Copy)和粘贴(paste)也许是有用的文本编辑(text-editing)操作,但是它们却是灾难性的代码编辑(code-editing)操作。
时常,软件系统都是构建于众多的重复代码片断之上。
当系统中有重复代码时,对系统进行改动会变得困难。
在一个重复的代码体中发现的错误必须要在每个重复体中一一修正。
不过,由于每个重复体之间都有细微的差别,所以修正的方式也不总是相同的。
晦涩性
晦涩性是指,代码模块难以理解。
当开发人员最初编写一个模块时,代码对于他们来说看起来也许是清晰的。
这是由于他们使自己专注于代码的编写,并且他们对于代码非常熟识。
在熟识减退以后,他们或许会回过头来再去看那个模块,并想知道他们为什么会编写出如此糟糕的代码。
为了防止这种情况发生,开发人员必须要站在代码阅读者的位置,共同努力对他们的代码进行重构。
1什么激发了软件的腐化
什么激发了软件的腐化?
答案是需求的变化。
由于需求没有按照初始设计预见的方式进行变化,从而导致了设计的退化。
通常,改动都很急迫,并且进行改动的开发人员对原始的设计思路并不熟识。
因而,虽然对设计的改动可以工作,但是它却以某种方式违反了原始的设计。
随着改动的不断进行,这些违反不断地积累,设计开始出现臭味。
然而,我们不能因为设计的退化而责怪需求的变化。
作为开发人员,我们对需求变化有非常好的了解。
事实上,我们中的大多数人都认识到需求是项目中最不稳定的因素。
如果我们的设计由于持续、大量的需求变化而失败,那就表明我们的设计和实践本身是有缺陷的。
我们必须要设法找到一种方法,使得设计对于变化具有弹性,并且应用一些实践来防止设计腐化。
2设计腐化的例子
老板给你的任务。
。
。
。
。
。
老板一大早就来找你,要你务必在三个星期内完成这样一个程序:
从键盘读入字符,并输出到打印机。
你是一个很有效率的开发人员,仅仅用了两个星期就把程序完成了(CopyV1):
voidCopy()
{
intc;
While((c=RdKbd())!
=EOF)
WrtPrt(c);
}
你把程序编译好后,安装在公司里的234个工作站。
你的程序运行良好,3个月内一点问题都没有,于是同事都齐声赞扬你,老板也开始赏识你。
你自己也开始飘飘然了。
需求在变化。
。
。
。
。
。
三个月后的某天的某个上午,老板又来找你,说有时希望能从纸带读入机读入信息。
你咬牙切齿,翻着白眼。
你想知道为何人们总是改变需求。
你的程序不是为纸带读入机设计的!
你警告老板,这样的改变会破坏程序的优雅。
不过老板怒视了你一下,你又立刻低下了头,开始想解决方案了。
因为程序已经安装到数百个工作站,你不能改变Copy程序的接口。
改变接口会导致长时间的重新编译和重新测试。
单单系统测试工程师就会痛恨你,更别提配置控制组的那7个家伙了。
并且过程控制部门会用专门的一天时间来对所有调用了Copy的模块进行各种各样的代码评审。
但是这也难不到你,你巧妙地完成了任务(CopyV2):
//remembertoresetthisflag
boolptFlag=false;
voidvoidCopy()
{
intc;
While((c=(ptFlag?
Rdpt():
RdKbd()))!
=EOF)
WrtPrt(c);
}
想让Copy程序从纸带读入机读入信息的调用者必须把ptFlag设置为true,然后再调用Copy时,它就能正确地从纸带读入机读入信息。
一旦Copy调用返回,调用者必须重新设置ptFlag,否则接下来的调用者就会错误地从纸带读入机而不是键盘读入信息。
为了提醒程序员重设这个标志,你增加了一个适当的注释。
同样,你的程序一发布,就获得了好评。
甚至比以前更成功,一大群渴望的程序员正在等待机会去使用它。
生活是美好的。
得寸进尺。
。
。
。
。
。
美好的日子过得总是太快,几个礼拜后的那天早上老板又来光顾你,他说:
客户有时希望Copy程序可以输出到纸带穿孔机上。
客户!
他们总是毁坏你的设计。
如果没有客户,编写软件会变得容易得多。
你再次警告老板,如果继续以这样可怕的速度变更需求,那么在年底前软件就会变得难以维护了。
老板心照不宣地点点头,接着告诉你无论如何都要进行这次改动。
这次的改动和上次相似,只不过需要另外一个全局变量,下面的程序展示了你努力后的卓越成果(CopyV3):
//remembertoresettheseflags
boolptFlag=false;
boolpunchFlag=false;
voidCopy()
{
intc;
While((c=(ptFlag?
Rdpt():
RdKbd()))!
=EOF))
punchFlag?
WrtPunch(c):
WrtPrc(c);
}
尤其让你感到骄傲的是,你还记得去修改注释。
虽然,你对程序的结构开始变得摇摇欲坠感到担心。
任何对于输入或者输出设备的再次变更肯定会迫使你对while循环的条件判断进行彻底的重新组织。
但是毕竟你的程序还能正常工作。
不过现在已经到达你承受的底线了,如果可恶的客户再次通过改变需求来破坏你的设计你就立刻走人。
你下定了这个决心。
你的崩溃。
。
。
。
。
。
很不幸,没过两个星期。
那天早上你刚到办公室还没坐下,老板又跑了进来,看他焦急的神态你猜得出他已经等了你3个小时了。
老板开门见山地说:
客户有时希望Copy程序可以从文件中输入……
没等他把话说完,你已经冲出了办公室,消失在茫茫的晨曦当中。
2.1运用面向对象设计原则设计Copy程序
让我们换个场景来处理上面的情况如何?
~^_^~
1、 当老板第一次给你任务时,你还没预计到任何需求的变化,所以一开始编写的代码和“CopyV1”完全一样。
2、 在老板要求你使程序可以从纸带读入机中读入信息时,你作出了下列的反应:
classReader
{
public:
virtualintread()=0;
};
classKeyBordreader:
publicReader
{
public:
virtualintread(){returnRdKbd();}
}
KeyBordReaderGdefaultReader;
voidCopy(Reader&reader=GdefaultReader)
{
intc;
While((c=reader.read())!
=EOF)
WrtPrt(c);
}
3、 在老板要求你使程序可以输出到纸带穿孔机时,你作出了下列的反应:
classReader
{
public:
virtualintread()=0;
};
classKeyBordreader:
publicReader
{
public:
virtualintread(){returnRdKbd();}
}
classWriter
{
public:
virtualvoidwrit(intc)=0;
};
classPrinterWriter:
publicWriter
{
public:
virtualvoidwrite(intc){WrtPrc(c);}
}
KeyBordReaderGdefaultReader;
PrinterWriterGdefaultWriter;
voidCopy(Reader&reader=GdefaultReader,Writer&writer)
{
intc;
While((c=reader.read())!
=EOF)
writer.write(c);
}
在要实现新需求时,你抓住这次机会去改进设计,以便设计对于将来的同类变化具有弹性,而不是设法去给设计打补丁。
从第一次改进开始,无论何时老板要求一种新的输入设备,你都能以不导致Copy程序退化的方式作出响应;从第二次改进开始,无论何时老板要求一种新的输入或输出设备,你也能以不导致Copy程序退化的方式作出响应。
但请注意,你不是一开始设计该模块时就试图预测程序将如何变化。
相反,你是以最简单的方式编写的。
直到需求最终确实变化时,你才修改模块的设计,使之对该种变化保持弹性。
注:
你的程序遵守了面向对象程序设计中的开放-封闭原则(OCP)和依赖倒置原则(DIP)。
[见以下章节]
3设计的腐化和设计原则
设计的腐化是一种症状,是可以主观(如果不能客观的话)进行量度的。
腐化常常是由于违法了设计原则中的一个或多个所导致的。
例如,僵化性常常是由于对开放-封闭原则(OCP)不够关注的结果。
开发团队应该运用相应的设计原则来去除腐化。
但当软件还没出现腐化时不应该应用这些原则。
仅仅因为是一个原则就无条件的去遵循它的做法是错误的。
这些原则不是可以随意在系统中到处喷洒的香水。
过分遵循这些原则会导致不必要的复杂性(NeedlessComplexity)的设计臭味,变成另一种腐化。
面向对象软件设计原则(三)——软件实体的设计原则
提起面向对象,大家也许觉得自己已经非常“精通”了,起码也到了“灵活运用”的境界。
面向对象设计不就是OOD吗?
不就是用C++、Java、Smalltalk等面向对象语言写程序吗?
不就是封装+继承+多态吗?
很好!
大家已经掌握了不少对面向对象设计的基本要素:
开发语言、基本概念、机制。
Java是一种纯面向对象语言,是不是用Java写程序就等于面向对象了呢?
我先列举一下面向对象设计的11个原则,测试一下大家对面向对象设计的理解程度~^_^~
▪单一职责原则(TheSingleResponsibilityPrinciple,简称SRP)
▪开放-封闭原则(TheOpen-ClosePrinciple,简称OCP)
▪Liskov替换原则(TheLiskovSubstitution,简称LSP)
▪依赖倒置原则(TheDependencyInversionPrinciple,简称DIP)
▪接口隔离原则(TheInterfaceSegregationPrinciple,简称ISP)
▪重用发布等价原则(TheReuse-ReleaseEquivalencePrinciple,简称REP)
▪共同重用原则(TheCommonReusePrinciple,简称CRP)
▪共同封闭原则(TheCommonClosePrinciple,简称CCP)
▪无环依赖原则(TheNo-AnnulusDependencyPrinciple,简称ADP)
▪稳定依赖原则(TheSteadyDependencyPrinciple,简称SDP)
▪稳定抽象原则(TheSteadyAbstractPrinciple,简称SAP)
其中1-5的原则关注所有软件实体(类、模块、函数等)的结构和耦合性,这些原则能够指导我们设计软件实体和确定软件实体的相互关系;6-8的原则关注包的内聚性,这些原则能够指导我们对类组包;9-11的原则关注包的耦合性,这些原则帮助我们确定包之间的相互关系。
1单一职责原则(SRP)
就一个类而言,应该仅有一个引起它变化的原因。
在SRP中,我们把职责定义为“变化的原因”。
如果你能够想到多于一个动机去改变一个类,那么这个类就具有多于一个的职责。
有时,我们很难注意到这一点,我们习惯于以组的形式去考虑职责。
1.1Rectangle类
例如,图2.1-1,Rectangle类具有两个方法,一个方法把矩形绘制在屏幕上,另一个方法计算矩形面积。
图2.1-1多于一个的职责
有两个不同的应用程序使用Rectangle类。
一个是有关计算几何学方面的,Rectangle类会在几何形状计算方面为它提供帮助,它从来不会在屏幕上绘制矩形。
另一个应用程序是有关图形绘制方面的,它可能进行一些几何学方面的工作,但是它肯定会在屏幕上绘制矩形。
这个设计违反了SRP。
Rectangle类具有两个职责。
第一个职责提供了矩形几何形状数学模型;第二个职责是把矩形在一个图形用户界面上绘制出来。
对于SRP的违反导致了一些严重的问题。
首先,我们必须在计算几何应用程序中包含GUI代码。
如果这是一个C++程序,就必须要把GUI代码链接进来,这会浪费链接时间、编译时间以及内存占用。
如果是一个JAVA程序,GUI的.class文件必须要部署到目标平台。
其次,如果GraphicalApplication的改变由于一些原因导致了Rectangle的改变,那么这个改变会迫使我们重新构建、测试已经部署ComputationalGeometryApplication。
如果忘记了这样作,ComputationalGeometryApplication可能会以不可预测的方式失败。
一个较好的设计是把这两个职责分离到图2.1-2中所示的两个完全不同的类中。
这个设计把Rectangle类中进行计算的部分移到GeometryRectangle类中,现在矩形绘制方式的改变不会对ComputationalGeometryApplication造成影响。
图2.1-2分离的职责
1.2结论
SRP是所有原则中最简单的原则之一,也是最难正确运用的原则之一。
我们会自然地把职责结合在一起。
软件设计真正要做到的许多内容,就是发现职责,并把那些职责相互分离。
事实上,我们要论述的其余原则都会以这样或那样的方式回到这个问题上。
2开放-封闭原则(OCP)
软件实体(类、模块、函数等)应该是可以扩展的,但是不可修改的。
遵循OCP设计出的模块具有两个主要的特征:
1、 对于扩展是开放的(Openforextension)
这意味着模块的行为是可以扩展的。
当应用的需求变化时,我们可以对模块进行扩展,使其具有满足那些改变的新行为。
换句话说,我们可以改变模块的功能。
2、 对于更改是封闭的(Closedformodification)
对模块行为进行扩展时,不必改动模块的源代码或者二进制代码。
模块的二进制可执行版本,无论是共享库、dll或者Java的jar文件,都无需改动。
这两个特征好像是相互矛盾的。
扩展模块行为的通常方式就是修改模块的源代码。
不允许修改的模块常常都被认为是具有固定的行为。
怎样可能在不改动模块源代码的情况下去更改它的行为呢?
怎样才能在无需对模块进行改动的情况下就改变它的功能呢?
——关键是抽象!
2.1Shape应用程序
我们有一个需要在标准GUI上绘制圆和正方形的应用程序。
2.1.1违反OCP
程序2.2.1.1-1Square/Circle问题的过程化解决方案
------------------------------shape.h------------------------------
enumShapeType{circle,square};
structShape
{
ShapeTypeitsType;
}
------------------------------circle.h------------------------------
#includeshape.h
structCircle
{
ShapeTypeitsType;
doubleitsRadius;
PointitsCenter;
};
------------------------------square.h------------------------------
#includeshape.h
structAquare
{
ShapeTypeitsType;
doubleitsSide;
PointitsTopLeft;
};
------------------------------drawAllShapes.c------------------------------
#includeshape.h
#includecircle.h
#includesquare.h
typedefstructShape*ShapePointer;
VoidDrawAllShapes(ShapePointerlist[],intn)
{
inti;
for(i=0;i{
structShape*s=list[i];
switch(s->itsType)
{
casesquare:
DrawSquare((structSquare*)s);
Break;
casecircle:
DrawCircle((structCircle*)s);
Break;
}
}
}
DrawAllShapes函数不符合OCP,因为它对于新的形状类型的添加不是封闭的。
如果希望这个函数能够绘制包含有三角形的列表,就必须更改这个函数。
事实上每增加一种新的形状类型,都必须要更改这个函数。
同样,在进行上述改动时,我们必须要在ShapeTypeenum中添加一个新的成员。
由于所有不同种类的形状都依赖于这个enum的声明,所有我们必须要重新编译所有的形状模块。
并且也必须要重新编译所有依赖于Shape类的模块。
程序2.2.1.1-1中的解决方案是僵化的,这是因为增加Triangle会导致Shape、Square、Circle以及DrawAllShapes的重新编译和重新部署。
该方法是脆弱的,因为很可能在程序的其他地方也存在类似的既难以查找又难以理解的switch/case或者if/else语句。
该方法是牢固的,因为想在另一个程序中复用DrawAllShapes时,都必须附带上Square和Circle,即使那个新程序不需要它们。
因此该程序展示了许多糟糕设计的臭味。
2.1.2遵循OCP
程序2.2.1.2-1Square/Circle问题的OOD解决方案
classShape
{
public:
virtualvoidDraw()const=0;
};
classSquare:
publicShape
{
public:
virtualvoidDraw()const;
};
classCircle:
publicShape
{
public:
virtualvoidDraw()const;
};
voidDrawAllShapes(vector&list)
{
vector:
:
iteratori;
for(i==list.begin();i!
=list.end();i++)
(*i)->Draw();
}
可以看到,如果我们要扩展程序2.2.1.2-1中DrawAllShapes函数的行为,使之能够绘制一种新的形状,我们只需增加一个新的Shape派生类。
DrawAllShapes函数并不需要改动。
这样DrawAllShapes就符合了OCP。
无需改动自身的代码就可以扩展它的行为。
实际上,增加一个Triangle类对于这里展示的任何模块完全没有影响。
很明显,为了能够处理Triangle类,必须改动系统中的某些部分,但是这里展示的所有代码都无需改动。
这个程序是符合OCP的。
对它的改动是通过增加新代码进行的,而不是更改现有的代码。
因此,它就不会引起像不遵循OCP的程序那样的连锁改动。
所需要的改动仅仅是增加新