第7章 什么是敏捷设计
可以使用许多不同的媒介描述设计,但是设计最终体现为源代码。从根本上讲,源代码就是设计。
7.1 设计臭味
如果幸运,你会在项目开始时就想到了系统的清晰图像。系统设计是存在你脑中的一幅至关重要的图像。如果幸运一点,在首次发布时,设计依然保持清楚。
接着,事情开始变糟。软件像一片坏面包一样开始腐化。随着时间流逝,腐化蔓延、增长。丑陋腐烂的痛处和疖子在代码中积累,使它变得越来越难以维护。最后,仅仅进行最简单的更改,也需要花费巨大的努力,以至于开发人员和一线管理人员强烈要求重新设计。
这样的重新设计很少会成功。虽然设计人员开始时的意图是好的,但是他们发现自己正朝一个移动目标设计。老系统不断发展变化,而新的设计必须跟得上这些变化。这样,甚至在第一次发布前,新的设计中就积累了很多瑕疵和弊病。
7.1.1 设计臭味——腐化软件的气味
当软件出现下面任何一种气味时,就表明软件正在腐化。
僵化性
脆弱性
顽固性
粘滞性
不必要的复杂性
不必要的重复
晦涩性
7.1.2 僵化性
僵化性是指难以对软件进行改动,即使是简单的改动。如果单一的改动会导致有依赖关系的模块中的连锁改动,那么设计就是僵化的。必须要改动的模块越多,设计就越僵化。
大部分开发人员都会以这样或那样的方式遇到过这种情况。他们会被要求做一个看起来简单的改动。他们仔细检查这个改动并对所需的工作做出了一个估算。但是过了一会儿,当他们实际进行改动时,会发现许多改动带来的影响自己并没有预测到。他们发现自己要在庞大的代码中搜寻这个变动,要改的模块数要远远超出最初估算,并且不断发现其他一些必须要记得做的更改。最后,改动所花费的时间比初始估算要长。当问他们为何估算得如此不准确时,他们会重复开发人员惯用的悲叹,“它比我想象的要复杂得多!”
7.1.3 脆弱性
脆弱性是指,在进行一个改动时,可能会导致程序的许多地方出现问题。常常是,出现新问题的地方与改动的地方并没有概念上的关联。要修正这些问题又会引出更多的问题,从而开发团队就像一只不停追逐自己尾巴的狗一样忙得团团转。
随着模块脆弱性增加,改动会引出意想不到的问题的可能性就越来越大。这看起来很荒谬,但是这样的模块非常常见。这些模块需要不断地修补——它们从来不会从错误列表中去掉。开发人员知道需要对它进行重新设计,但是谁都不愿意去面对重新设计中的难以琢磨性,你越是修改它们,它们举变得越糟。
7.1.4 顽固性
顽固性是指,设计中包含了对其他系统有用的部分,但是要把这些部分从系统中分离出来所需的努力和风险却是巨大的。这是一种令人遗憾,但非常常见的情形。
7.1.5 粘滞性
粘滞性有两种形式:软件的粘滞性和环境的粘滞性。当面临一个改动时,开发人员常常会有多种改动方法。其中,一些方法会保持设计;而另一些会破坏设计(也就是拼凑的方法)。当可以保持系统设计的方法比拼凑手法更难应用时,就表明设计具有高的粘滞性。
当开发环境迟钝、抵消时,就会产生环境的粘滞性。例如,如果编译所花费的时间很长,那么开发人员就会被引诱去做不会导致大规模编译的改动,即使那些改动不再保持设计。如果源代码控制系统要几个小时去签入仅仅几个文件,那么开发人员就会被引诱去做那些尽可能少签入的改动,而不管改动是否保持设计。
无论项目具有哪种粘滞性,都很难保持项目中的软件设计。我们希望创建易于保持和改进设计的系统以及项目环境。
7.1.6 不必要的复杂性
如果设计中包含了当前没有用的组件部分,它就含有不必要的复杂性。当开发人员预测需求的变化,并在软件中放置了处理潜在变化的代码时,常常会出现这种情况。起初,这样看起来像是一件好事。毕竟,为将来的变化做准备会保持代码的灵活性,并且可以避免以后再进行痛苦的改动。
糟糕的是,结果常常正好相反。为过多的可能性做准备,致使设计中含有绝不会用到的结构,从而变得混乱。一些准备也许会带来回报,但是更多的不会。同时,设计背负着这些不会用到的部分,使软件变得复杂,并且难以理解。
7.1.7 不必要的重复
复制和粘贴是灾难性的代码编辑操作。时常会看到一些构建于许多重复代码片段之上的软件系统。同样的代码以稍微不同的形式一再出现时,就表示开发人员忽略了抽象。对于他们来说,发现所有的重复并通过适当的抽象去消除它们的做法可能没有高的优先级别,但是这样做非常有助于使系统更加易于理解和维护。
当系统中有重复的代码时,对系统进行改动会变得困难。在一个重复的代码体系中发现错误必须要在每个重复体中一一修正。不过,由于每个重复体之间都有细微的差别,所以修正的方式也不总是相同的。
7.1.8 晦涩性
晦涩性是指模块难以理解。代码可以用清晰、富有表达力的方式编写,也可以用晦涩、费解的方式编写。代码随着时间的演化,往往会变得越来越晦涩。为了使代码的晦涩性保持最低,就需要保持地保持代码清新和富有表达力。
当开发人员最初编写一个模块是,代码对于他们来说看起来也许是清晰的。毕竟,他们专注于代码的编写,并且熟悉代码的细节。在对代码的熟悉程度减退以后,他们或许会回过头来再去看那个模块,并想知道他们会怎么编写如此糟糕的代码。为了防止这种情况的发生,开发人员必须要站在代码阅读者的位置,努力对它们的代码进行重构,这样代码阅读者就可以理解代码。他们的代码也需要被其他人评审。
7.2 软件为何会腐化
在非敏捷环境中,由于需求没有按照初始设计预期的方式进行变化,从而导致了设计的退化。通常,改动都很急迫,并且进行改动的开发人员对于原始设计思路并不熟悉。因而,虽然可以对设计进行改动,但是却在某种程度上违反了原始设计。随着改动的不断进行,这些违反渐渐地积累,直至恶性肿瘤出现。
然而,我们不能因为设计退化而去责怪需求的变化。作为软件开发人员,我们非常了解需求会变化。事实上,我们中的大多数人都认识到需求是项目中最不稳定的要素。如果我们的设计由于持续、大量的需求变化而失败,那就表明我们的设计和实践本身是有缺陷的。我们必须要设法找到一种方法,使得设计对于这种变化具有弹性,并且应用一些实践来防止腐化。
敏捷团队依靠变化来获取活力。团队几乎不进行预先设计,因此,不需要一个成熟的初始设计。他们更愿意保持系统尽可能的干净简单,并且用许多单元测试和验收测试作为支援。这保持了设计的灵活性、易更改性。团队利用这种灵活性,持续地改进设计,以便于每次迭代结束所产生的系统都具有最适合于那次迭代中需求的设计。
7.3 Copy程序(一个输入输出程序的例子)
7.3.1 熟悉的场景
需求在变化
得寸进尺
期望变化
我们生活在一个需求不断变化的世界中,我们的工作是要保证我们的软件能够经受得住那些变化。
7.3.2 Copy程序(一个输入输出程序的例子)的敏捷设计
在要实现新需求时,团队抓住这次机会去进行设计,以便设计对于将来同类变化具有弹性,而不是设法去给设计打补丁。
团队遵循了开放-封闭原则(Open-Closed Principle,OCP)。这个原则指导我们设计出无需修改即可扩展的模块。
团队不是一开始设计时就试图预测程序将如何变化。团队以最简单的方法编写模块。仅当需求最终却是变化时,团队才修改模块的设计,使之对该种变化具有弹性。
7.4 结论
敏捷设计是一个过程,不是一个事件。它是一个持续的应用原则、模式以及实践来改进软件的结构和可持续性的过程。它致力于保持系统设计在任何时间都尽可能的简单、干净以及富有表达力。
第8章 SRP:单一职责原则
一个类应该只有一个发生变化的原因。
8.1 定义职责
在SRP中我们把职责定义为变化的原因。如果你想到多于一个的动机去改变一个类,那么这个类就具有多于一个的职责。同时,我们很难注意到这一点。我们习惯于以组的形式去考虑职责。违反SRP的示例代码:
publicinterface Modem
{
publicvoidDial(string pno);
publicvoid Hangup();
publicvoidSend(char c);
publicchar Recv();
}
大多数人会认为这个接口看起来非常合理。该接口所声明的4个函数确实是调制解调器所具有的功能。
然而接口中却显示出两个职责。第一个职责是链接管理;第二个职责是数据通信。dial和hangup函数进行调制解调器的连接处理,而send和recv函数进行数据通信。
这两个职责应该分开吗?这依赖于应用程序变化的方式。如果应用程序的变化会影响连接函数的签名(signature),那么这个设计就具有僵化性的臭味,因为调用send和recv的类必须要重新编译、部署的次数常常会超出我们希望的次数。在这种情况下,这两个职责应该被分离。这样做避免了客户应用程序和这两个职责耦合在一起。
另一方面,如果应用程序的变化方式总是导致这两个职责同时变化,那么就不必分离它们。实际上,分离它们就会有不必要的复杂性的臭味。
在此还有一个推论。仅当变化发生时,变化的轴线才具有实际意义。如果没有前兆,那么应用SRP或者任何其他原则都是不明智的。
8.2 分离耦合的职责
上图中,我把两个职责都耦合进了ModemImplementation类中。这不是所希望的,但是或许是必要的。常常会有一些和硬件或者操作系统的细节有关的原因,迫使我们把不愿耦合在一起的东西耦合在一起。然而,应用的其余部分来说,通过分离它们的接口我们已经解耦了概念。
我们可以把ModemImplementation类看作是一个杂凑物,或者有缺陷的类。然而,请注意所有的依赖关系都是从它发出的。谁也不需要依赖于它。除了main外,谁也不需要知道它的存在。因此,我们已经把丑陋的部分隐藏起来了。其丑陋性不会泄漏出来,污染应用的其他部分。
8.3 持久化
上图展示了一种常见的违反SRP的情形。Employee类包含了业务规则和对于持久化的控制。这两个职责在大多数情况下绝不应该混合在一起。业务规则往往会频繁地变化,而持久化的方式却不会如此频繁地变化,并且变化的原因也是完全不同的。把业务规则和持久化自系统绑定在一起的做法是自讨苦吃。
幸运的是,测试驱动的开发实践常常会远在设计出现臭味之前就迫使我们分离这两个职责。然而,如果测试没有迫使这种分离,而僵化性和脆弱性的臭味又很强烈,那么就应该使用FACADE(外观)、DAO(数据访问对象)或者PROXY(代理)模式对设计进行重构,分离这两个职责。
8.4 结论
SRP是所有原则中最简单的原则之一,也是最难正确运用的原则之一。我们会自然地把职责结合在一起。软件设计真正要做的许多工作,就是发现职责并把那些职责相互分离。事实上,我们将要论述的其余原则都会以这样或那样的方式回到这个问题上。
第9章 OCP:开放-封闭原则
软件实体(类、模块、函数等)应该是可以扩展的,但是不可修改。
9.1 OCP概述
遵循开放-封闭原则设计出的模块具有两个主要特征:
(1)对于扩展是开放的(open for extension)。这意味着模块的行为是可以扩展的。当应用的需求改变时,我们可以对模块进行扩展,使其具有满足那些改变的新行为。
(2)对于修改是封闭的(closed for modification)。对模块进行扩展时,不必改动模块的源代码或者二进制代码。模块的二进制可执行版本,无论是可链接的库、DLL或者.EXE文件,都无需改动。
在C#或者其他任何OOPL(面向对象程序设计语言)中,可以创建出固定却能够描述一组任意个可能行为的抽象体。这个抽象体就是抽象基类。而这一组任意个可能的行为则表现为可能的派生类。
模块可能对抽象体进行操作。由于模块依赖于一个固定的抽象体,所以它对于更改可以是封闭的。同时,通过从这个抽象体派生,可以扩展此模块的行为。
9.2 Shape应用程序
9.2.1 违反OCP
查看如下代码:
//--shape.h---------------------------------------enum ShapeType
{
circle,
square
};
struct Shape
{
ShapeType itsType;
};//--circle.h---------------------------------------struct Circle
{
ShapeType itsType;
double itsRadius;
Point itsCenter;
};
voidDrawCircle(structCircle*);//--square.h---------------------------------------struct Square
{
ShapeType itsType;
double itsSide;
Point itsTopLeft;
};
voidDrawSquare(structSquare*);//--drawAllShapes.cc-------------------------------typedefstructShape *private ShapePointer ;
voidDrawAllShapes(ShapePointer list[]private,privateint n )
{
int i;
for(i =0; i < n; i++)
{
struct Shape* s = list[i];
switch(s->itsType)
{
case square:
DrawSquare((structSquare* )s);
break;
case circle:
DrawCircle((structCircle* )s);
break;
}
}
}
DrawAllShapes函数不符合OCP,因为它对于新的形状类型的添加不是封闭的。如果希望这个函数能够绘制包含三角形的列表,就必须变更这个函数。事实上,每增加一种新的形状类型,都必须要更改这个函数。
9.2.2 遵循OCP
查看如下Square/Circle问题的OOD解决方案
publicinterface Shape
{
void Draw();
}
publicclass Square : Shape
{
publicvoid Draw()
{
//draw a square }
}
publicclass Circle : Shape
{
publicvoid Draw()
{
//draw a circle }
}
publicvoid DrawAllShapes(IList shapes)
{
foreach(Shape shapein shapes)
shape.Draw();
}
9.2.3 预测变化和“贴切的”结构
一般而言,无论模块是多么的“封闭”,都会存在一些无法对之封闭的变化。没有对于所有的情况都贴切的模型。
既然不能完全封闭,那么就必须有策略的对待这个问题。也就是说,设计人员必须对于他设计的模块应该对哪种变化封装做出选择。他必须先猜测出最有可能发生变化的类,然后构造抽象来隔离那些变化。
这需要设计人员具有一些从经验中获得的预测能力。有经验的设计人员希望自己对用户和应用领域很了解,能够以此来判断各种变化的可能性。然后,它可以让设计对于最有可能发生的变化遵循OCP原则。
这一点不容易做到。并且在大多数情况下,他们都会猜测错误。
遵循OCP的代价也是昂贵的。创建适当的抽象是要花费开发时间和精力的。同时,那些抽象也增加了软件设计的复杂性。
最终,我们会一直等到变化发生时才采取行动!
9.2.4 放置吊钩
在上世纪,我们会在我们认为可能发生变化的地方“放置吊钩”(hook)。我们觉得这样会使软件灵活一些。
然而,我们放置的吊钩常常是错误的。更糟的是,即使不使用这些吊钩,也必须要去支持和维护它们,从而就有了不必要的复杂性的臭味。通常,我们更愿意一直等到却是需要那些抽象时再把它放置进去。
9.2.5 使用抽象获得显式封闭
封闭是建立在抽象的基础上的。因此,为了让DrawAllShapes对于绘制顺序的变化是封闭的。我们需要一种“顺序抽象体”。这个抽象体定义了一个抽象接口,通过这个接口可以表示任何可能的排序策略。
一个排序策略意味着,给定两个对象,可以推导出先绘制哪一个。C#提供了这样的抽象。IComparable是一个接口,它只提供一个方法:CompareTo。这个方法以一个对象作为输入参数,当接受消息的对象小于、等于、大于参数数对象时,该方法分别返回-1,0,1 。
如果希望Circle先于Square绘制,查看如下代码:
publicinterface Shape : IComparable
{
void Draw();
}
publicclass Square : Shape
{
publicvoid Draw()
{
//draw a square }
publicintCompareTo(object obj)
{
if(objis Circle)
{
return1;
}
else {
return0;
}
}
}
publicclass Circle : Shape
{
publicvoid Draw()
{
//draw a circle }
publicintCompareTo(object obj)
{
if(objis Square)
{
return-1;
}
else {
return0;
}
}
}
publicvoid DrawAllShapes(ArrayList shapes)
{
shapes.Sort();
foreach(Shape shapein shapes)
shape.Draw();
}
对于这样的代码:
publicintCompareTo(object obj)
{
if(objis Square)
{
return-1;
}
else {
return0;
}
}
显然不符合OCP。每次创建一个新的Shape类的派生类时,所有的CompareTo()函数都需要改动。
9.2.6 使用“数据驱动”的方法获取封闭性
如果我们不要使Shape类的各个派生类之间互不知晓,可以使用表格驱动的方法。表格驱动的形状排序机制:
///<summary>/// This comparer will search the priorities/// hashtable for a shape's type. The priorities/// table defines the odering of shapes. Shapes/// that are not found precede shapes that are found.///</summary>publicclass ShapeComparer : IComparer
{
privatestaticHashtable priorities =new Hashtable();
static ShapeComparer()
{
priorities.Add(typeof(Circle),1);
priorities.Add(typeof(Square),2);
}
privateint PriorityFor(Type type)
{
if(priorities.Contains(type))
return(int)priorities[type];
elsereturn0;
}
publicintCompare(objecto1,object o2)
{
intpriority1 = PriorityFor(o1.GetType());
intpriority2 = PriorityFor(o2.GetType());
return priority1.CompareTo(priority2);
}
}
修改DrawAllShapes方法:
publicvoid DrawAllShapes(ArrayList shapes)
{
shapes.Sort(new ShapeComparer());
foreach(Shape shapein shapes)
shape.Draw();
}
9.3 结论
在许多方面,OCP都是面向对象设计的核心所在。遵循这个原则可以带来面向对象技术所声称的巨大好处:灵活性、可重用性以及灵活性。然而,并不是说使用一种面向对象的语言就是遵循了这个原则。对于应用程序中的每个部分都肆意地进行抽象同样不是一个好主意。正确的做法是,开发人员仅仅对程序中出现频繁变化的那些部分作出抽象。拒绝不成熟的抽象和抽象本身一样重要。
网友评论