面向对象软件设计原则(二) —— 软件设计的腐化
我们如何知道软件设计的优劣呢?以下是一些拙劣设计的症状,当软件出现下面任何一种气味时,就表明软件正在腐化。
- 僵化性(Rigidity):很难对系统进行改动,因为每个改动都会迫使许多对系统其他部分的其他改动。
- 脆弱性(Fragility):对系统的改动会导致系统中和改动的地方在概念上无关的许多地方出现问题。
- 牢固性(Immobility):很难解开系统的纠结,使之成为一些可在其他系统中重用的组件。
- 粘滞性(Viscosity):做正确的事情比做错误的事情要困难。
- 不必要的复杂性(Needless Complexity):设计中包含有不具任何直接好处的基础结构。
- 不必要的重复(Needless Repetition):设计中包含有重复的结构,而该重复的结构本可以使用单一的抽象进行统一。
- 晦涩性(Opacity):很难阅读、理解。没有很好地表现出意图。
僵化性
僵化是指难以对软件进行改动,即使是简单的改动。如果单一的改动会导致有依赖关系的模块中的连锁改动,那么设计就是僵化的。必须要改动的模块越多,设计就越僵化。
大部分开发人员都遇到这样的情况:他们对被要求进行一个看似简单的改动,当他实际进行改动时,才发现有许多改动带来的影响自己并没有预测到。最后,改动所花费的时间要远比初始估算长。他会重复软件开发人员惯用的悲叹:“它比我想象的要复杂得多!”
脆弱性
脆弱性是指,在进行一个改动时,程序的许多地方就可能出现问题。常常是,出现新问题的地方与改动的地方并没有概念上的关联。要修正这些问题就又会引出新的问题,从而使软件开发团队就像一只不停追逐自己尾巴的狗一样。
牢固性
牢固性是指,设计中包含了对其他系统有用的部分,但是要把这些部分从系统中分离出来需要的努力和风险是巨大的。这是一件令人遗憾的事,但却是非常常见。
粘滞性
当面临一个改动时,开发人员常常会发现会有多种改动的方法。其中,一些会保持设计;而另外一些会破坏设计(也就是生硬的手法)。当那些可以保持系统设计 的方法比那些生硬手法更难应用时,就表明设计具有高的粘滞性。做错误的事情是容易的,但是做正确的事情却很难。这样就很难保持项目中的软件设计。
不必要的复杂性
如果设计中包含当前没有用的组成部分,它就含有不必要的复杂性。当开发人员预测需求的变化,并在软件中放置了处理潜在变化的代码时,常常会出现这种情况。起初,这样看起来是一件好事。毕竟,为将来的变化做准备会保持代码的灵活性,而且可以避免以后再进行痛苦的改动。
糟糕的是,结果常常正好相反。为过多的可能性作准备,致使设计中含有绝不会用到的结构,从而变得混乱。一些准备也许会带来回报,但是更多的不会。期间,设计背负着这些不会用到的部分,使软件变得复杂,而且难以理解。
不必要的重复
复制(Copy)和粘贴(paste)也许是有用的文本编辑(text-editing)操作,但是它们却是灾难性的代码编辑(code-editing)操作。时常,软件系统都是构建于众多的重复代码片断之上。
当系统中有重复代码时,对系统进行改动会变得困难。在一个重复的代码体中发现的错误必须要在每个重复体中一一修正。不过,由于每个重复体之间都有细微的差别,所以修正的方式也不总是相同的。
晦涩性
晦涩性是指,代码模块难以理解。当开发人员最初编写一个模块时,代码对于他们来说看起来也许是清晰的。这是由于他们使自己专注于代码的编写,并且他们对 于代码非常熟识。在熟识减退以后,他们或许会回过头来再去看那个模块,并想知道他们为什么会编写出如此糟糕的代码。为了防止这种情况发生,开发人员必须要 站在代码阅读者的位置,共同努力对他们的代码进行重构。
1 什么激发了软件的腐化
什么激发了软件的腐化?答案是需求的变化。由于需求没有按照初始设计预见的方式进行变化,从而导致了设计的退化。通常,改动都很急迫,并且进行改动的开发人员对原始的设计思路并不熟识。因而,虽然对设计的改动可以工作,但是它却以某种方式违反了原始的设计。随着改动的不断进行,这些违反不断地积累,设计开始出现臭味。
然而,我们不能因为设计的退化而责怪需求的变化。作为开发人员,我们对需求变化有非常好的了解。事实上,我们中的大多数人都认识到需求是项目中最不稳定的因 素。如果我们的设计由于持续、大量的需求变化而失败,那就表明我们的设计和实践本身是有缺陷的。我们必须要设法找到一种方法,使得设计对于变化具有弹性, 并且应用一些实践来防止设计腐化。
2 设计腐化的例子
老板给你的任务。。。。。。
老板一大早就来找你,要你务必在三个星期内完成这样一个程序:从键盘读入字符,并输出到打印机。
你是一个很有效率的开发人员,仅仅用了两个星期就把程序完成了(Copy V1):
void Copy()
{
int c;
While ((c = RdKbd()) !=EOF)
WrtPrt(c);
}
你把程序编译好后,安装在公司里的234个工作站。你的程序运行良好,3个月内一点问题都没有,于是同事都齐声赞扬你,老板也开始赏识你。你自己也开始飘飘然了。
需求在变化。。。。。。
三个月后的某天的某个上午,老板又来找你,说有时希望能从纸带读入机读入信息。你咬牙切齿,翻着白眼。你想知道为何人们总是改变需求。你的程序不是为纸 带读入机设计的!你警告老板,这样的改变会破坏程序的优雅。不过老板怒视了你一下,你又立刻低下了头,开始想解决方案了。
因为程序已经 安装到数百个工作站,你不能改变Copy程序的接口。改变接口会导致长时间的重新编译和重新测试。单单系统测试工程师就会痛恨你,更别提配置控制组的那7 个家伙了。并且过程控制部门会用专门的一天时间来对所有调用了Copy的模块进行各种各样的代码评审。但是这也难不到你,你巧妙地完成了任务(Copy V2):
// remember to reset this flag
bool ptFlag = false;
void void Copy()
{
int c;
While ((c = (ptFlag ? Rdpt() : RdKbd())) !=EOF)
WrtPrt(c);
}
想让Copy程序从纸带读入机读入信息的调用者必须把ptFlag设置为true,然后再调用Copy时,它就能正确地从纸带读入机读入信息。一旦 Copy调用返回,调用者必须重新设置ptFlag,否则接下来的调用者就会错误地从纸带读入机而不是键盘读入信息。为了提醒程序员重设这个标志,你增加 了一个适当的注释。
同样,你的程序一发布,就获得了好评。甚至比以前更成功,一大群渴望的程序员正在等待机会去使用它。生活是美好的。
得寸进尺。。。。。。
美好的日子过得总是太快,几个礼拜后的那天早上老板又来光顾你,他说:客户有时希望Copy程序可以输出到纸带穿孔机上。
客户!他们总是毁坏你的设计。如果没有客户,编写软件会变得容易得多。
你再次警告老板,如果继续以这样可怕的速度变更需求,那么在年底前软件就会变得难以维护了。老板心照不宣地点点头,接着告诉你无论如何都要进行这次改动。
这次的改动和上次相似,只不过需要另外一个全局变量,下面的程序展示了你努力后的卓越成果(Copy V3):
// remember to reset these flags
bool ptFlag = false;
bool punchFlag = false;
void Copy()
{
int c;
While ((c = (ptFlag ? Rdpt() : RdKbd())) != EOF))
punchFlag ? WrtPunch(c) : WrtPrc(c);
}
尤其让你感到骄傲的是,你还记得去修改注释。虽然,你对程序的结构开始变得摇摇欲坠感到担心。任何对于输入或者输出设备的再次变更肯定会迫使你对 while循环的条件判断进行彻底的重新组织。但是毕竟你的程序还能正常工作。不过现在已经到达你承受的底线了,如果可恶的客户再次通过改变需求来破坏你 的设计你就立刻走人。你下定了这个决心。
你的崩溃。。。。。。
很不幸,没过两个星期。那天早上你刚到办公室还没坐下,老板又跑了进来,看他焦急的神态你猜得出他已经等了你3个小时了。老板开门见山地说:客户有时希望Copy程序可以从文件中输入……
没等他把话说完,你已经冲出了办公室,消失在茫茫的晨曦当中。
2.1 运用面向对象设计原则设计Copy程序
让我们换个场景来处理上面的情况如何?~^_^~
1、 当老板第一次给你任务时,你还没预计到任何需求的变化,所以一开始编写的代码和“Copy V1”完全一样。
2、 在老板要求你使程序可以从纸带读入机中读入信息时,你作出了下列的反应:
class Reader
{
public:
virtual int read() = 0;
};
class KeyBordreader : public Reader
{
public:
virtual int read() { return RdKbd();}
}
KeyBordReader GdefaultReader;
void Copy(Reader& reader = GdefaultReader)
{
int c;
While((c = reader.read()) != EOF)
WrtPrt(c);
}
3、 在老板要求你使程序可以输出到纸带穿孔机时,你作出了下列的反应:
class Reader
{
public:
virtual int read() = 0;
};
class KeyBordreader : public Reader
{
public:
virtual int read() { return RdKbd();}
}
class Writer
{
public:
virtual void writ(int c) = 0;
};
class PrinterWriter : public Writer
{
public:
virtual void write(int c) { WrtPrc(c);}
}
KeyBordReader GdefaultReader;
PrinterWriter GdefaultWriter;
void Copy(Reader& reader = GdefaultReader, Writer& writer)
{
int c;
While((c = reader.read()) != EOF)
writer.write(c);
}
在要实现新需求时,你抓住这次机会去改进设计,以便设计对于将来的同类变化具有弹性,而不是设法去给设计打补丁。从第一次改进开始,无论何时老板要求一种 新的输入设备,你都能以不导致Copy程序退化的方式作出响应;从第二次改进开始,无论何时老板要求一种新的输入或输出设备,你也能以不导致Copy程序 退化的方式作出响应。
但请注意,你不是一开始设计该模块时就试图预测程序将如何变化。相反,你是以最简单的方式编写的。直到需求最终确实变化时,你才修改模块的设计,使之对该种变化保持弹性。
注:你的程序遵守了面向对象程序设计中的开放-封闭原则(OCP)和依赖倒置原则(DIP)。[见以下章节]
3 设计的腐化和设计原则
设计的腐化是一种症状,是可以主观(如果不能客观的话)进行量度的。腐化常常是由于违法了设计原则中的一个或多个所导致的。例如,僵化性常常是由于对开放-封闭原则(OCP)不够关注的结果。
开发团队应该运用相应的设计原则来去除腐化。但当软件还没出现腐化时不应该应用这些原则。仅仅因为是一个原则就无条件的去遵循它的做法是错误的。这些原 则不是可以随意在系统中到处喷洒的香水。过分遵循这些原则会导致不必要的复杂性(Needless Complexity)的设计臭味,变成另一种腐化。
下一章:面向对象软件设计原则(三) —— 软件实体的设计原则