目录
1:PDF上传链接
9.1 开放-封闭原则(OCP)
9.2 描述
9.3 关键是抽象
9.3.1 shape应用程序
9.3.2 违反OCP
糟糕的设计
9.3.3 遵循OCP
9.3.4 是的,我说谎了
9.3.5 预测变化和“贴切的”结构
9.3.6 放置吊钩
1.只受一次愚弄
2.刺激变化
9.3.7 使用抽象获得显示封闭
9.3.8 使用“数据驱动”的方法获取封闭性
9.4 结论
1:PDF上传链接
【免费】敏捷软件开发(原则模式与实践)资源-CSDN文库
Ivar Jacobson曾说过,“任何系统在其生命周期中都会发生变化。如果我们期望开发出的系统不会再第一版后就被抛弃你就必须牢牢记住一点”。Bertrand Meyer在1988年提出著名的开发-封闭原则(The Open - closed Principle,简称OCP)为我们提供了指引。
9.1 开放-封闭原则(OCP)
软件实体(类、模块、函数等等)应该是可以扩展的,但是不可修改的。
如果程序中的一处改动就会产生连锁反应,导致一系列相关模块的改动,那么设计就具有僵化性的臭味。OCP建议我们应该对系统进行重构,这样以后对系统在进行那样的改动时,就不会导致更多的修改。如果正确地应用OCP,那么以后再进行同样的改动,就只需要添加新的代码,而不必改动已经正常运行的代码。
也许,这看起来像是重所周知的可望而不可及的美好理想-----然后,事实上却有一些相对简单并且有效的策略可以帮助接近这个理想。
9.2 描述
遵循开放-封闭原则设计出的模块具有两个主要的特征。它们是:
1:对于扩展是开放的(Open for extension)
这意味着模块的行为是可以扩展的,当应用的需求改变时,我们可以对模块进行扩展,使其具有满足那些改变的新行为,换句话说,我们可以改变模块的功能。
2:对更改是封闭的(closed for modification)
对模块行为进行扩展时,不必改动模块的源代码或者二进制代码。模块的二进制可执行版本,无论是可链接的库、dll或者Java的jar文件都无需改动。
这个两个特征好像是相互矛盾的,扩展模块行为的通常方式就是修改该模块的源代码。不允许修改的模块常常都被认为具有固定的行为。
怎么可能在不改动模块源代码的情况下,去更改它的行为呢?怎么才能在无需对模块进行改动情况下就改变它的功能呢?
9.3 关键是抽象
在C++、Java或者其他任何的OOPL(面向对象编程语言)中,可以创建出固定去能够描述一组任意个可能行为的抽象体。这个抽象体就是抽象类。而这一组任意个可能得行为则表现为可能得派生类。
模块可以操作一个抽象体。由于模块依赖于一个固定的抽象体,所以他对于更改可以是关闭的,同时,通过从这个抽象体派生,也可以扩展此模块的行为。
9.3.1 shape应用程序
下面的例子在许多讲述OOD(面向对象的设计)的书中都提过。他就是声名狼藉的“shape”样列。它常常被用来展示多态的工作原理。不过,这次我们将使用它来阐明OCP。
我们有一个需要再标准的GUI上面绘制圆和正方形的应用程序。圆和正方形必须要按照特定的顺序绘制。我们将创建一个列表,列表由按照适当的顺序排列的圆和正方形组成,程序遍历该列表,依次绘制出每个圆和正方形。
9.3.2 违反OCP
如果使用C语言,并采用不遵循OCP的过程化方法,我们也许会得到程序9.1中所示的解决方法。其中,我们看到了一组的数据结构,它们的第一个成员都相同,但是其余的成员都不同。每个结构中的第一个成员都是一个用来标识该结构是代表圆或者正方形的类型码。DrawAllShapes函数遍历一个数组,该数组的元素是指向这些数据结构的指针,DrawAllShapes函数先检查类型码,然后根据类型码调用对应的函数(DrawCircle或者DrawSquare)。
程序9.1 Square/Circle问题的过程化解决方案
--shape.h --
enum ShapeType {
circle,
square
};
struct Shape {
ShapeType itsType;
}
-circle.h ---
struct Circle {
ShapeType itsType;
double itsRadius;
Point itsCenter;
}
-square.h ----
struct Square {
ShapeType itsType;
double itsside;
Point itsTopLeft;
}:
--drawA11 Shapes.cc----------------
typedef struct Shape *Shapepointer;
void DrawAllShapes(ShapePointer list[], int n)
{
int i;
for (i = 0; i < n; i++){
struct Shape *s = list[i];
switch (s->itsType) {
case square:
DrawSquare((struct Square*)s);
break;
case circle:
Drawcircle((struct circle*)s);
Break;
default:
break;
}
}
}
DrawAllShapes函数不符合OCP,因为它对于新的形状类型的添加不是封闭的。如果希望这个函数能够绘制包含有三角形的列表,就必须得更改这个函数。事实上,每增加一种新的形状类型,都必须要更改这个函数。
当然这只是一个简单的例子。在实际程序中,类似DrawAllShapes函数中的switch语句会在应用程序的各个函数中重复不断地出现,每个函数中switch语句负责完成的工作差别甚微。这些函数中,可能有负责拖曳形状对象的,有负责拉伸形状对象的,有负责移动形状对象的,有负责删除形状对象的,等等。在这样的应用程序中增加一种新的形状类型,就意味着要找出所有包含上述switch语句(或者链式if/else语句)的函数,并在每一处都添加对新增的形状类型的判断。
更糟的是,并不是所有的switch语句和if/else链都像DrawAllShapes中的那样有比较好的结构。更有可能的情形是,if语句中的判断条件由逻辑操作符组合而成,或者是处理方式相同的case语句被成组处理。在一些极端错误的实现中,会有一些函数对于Square的处理竞然和对于circle的处理一样。在这样的函数中,甚至根本就没有switch/case语句或者if/else链。这样,要发现和理解所有的需要增加对新的形状类型进行判断的地方,恐怕就非常的困难了。
同样,在进行上述改动时,我们必须要在ShapeType enum中添加·个新的成员。由于所有不同种类的形状都依赖于这个eum的声明,所以我们必须要重新编译所有的形状模块。并且也必须要重新编译所有依赖于Shape类的模块。
糟糕的设计
再来回顾一下。程序9.1中的解决方法是僵化的,这是因为增加Triangle会导致Shape、Square、Circle以及DrawAllShapes的重新编译和重新部署。该方法是脆弱的,因为有许多其他的即难以查找又难以理解的switch/case或者lse语句。该方法是牢固的,因为想在另一个程序中复用DrawAllShapes时,都必须要附带上Square和Circle,即使那个新程序不需要它们。因此,在程序9.1中展示了许多糟糕设计的臭味。
9.3.3 遵循OCP
程序9.2中展示了一个square/circle问题的符合OCP的解决方案。在这个方案中,我们编写了一个名为Shape的抽象类。这个抽象类仅有一个名为Draw的抽象方法。Circle和Square都从Shape类派生。
程序9.2问题的OoD解决方案
class Shape {
public:
virtual void Draw () const = 0;
};
class Square: public Shape {
public:
virtual void Draw() const = 0;
};
class circle: public Shape{
public:
virtual void Draw () const = 0;
};
void DrawAllShapes(vector<Shape*> & list)
{
vector<Shape*>::iterator I;
for (i = list.begin(); i != list.end(); i++) {
(*i)->Draw ();
}
}
可以看到,如果我们想要扩展程序9.2中DrawAllShapes函数的行为,使之能够绘制一种新的形状,我们只需要增加一个新的Shape类的派生类.DrawAllShapes函数并不需要改变,这样DrawAllShapes就符合了OCP。无需改动自身代码,就可以扩展它的行为。实际上,增加一个Triangle类对于这里展示的任何模块完全没有影响。很明显,为了能够处理Triangle类,必须要改动系统中的某些部分,但是这里展示的所有代码都无需改动。
在实际的应用程序中,Sape类可能会有更多的方法。但是在应用程序中增加一种新的形状类型依然非常简单,因为所需要做的工作只是创建Sape类的新的派生类,并实现它的所有函数。再也不需要为了找出需要更改的地方而在应用程序的所有地方进行搜寻。这个解决方案不再是脆弱的。
同时,这个方案也不再是僵化的。在增加一个新的形状类型时,现有的所有模块的源码都无需改动,并且现有的所有二进制模块都无需进行重新构建(rebuild)。只有一个例外,那就是实际创建Shape类新的派生类实例的模块必须被改动。通常情况下,创建Shape类新的派生类实例的工作要么是在main中或者被main调用的一些函数中完成,要么是在被main创建的一些对象的方法中完成。
最后,这个方案也不再是牢固的。现在,在任何应用程序中重用DrawAllShapes时,都无需再附带上Square和Circle。因而,这个解决方案就不再具有前面提及的任何糟糕设计的特征。
这个程序是符合OCP的。对它的改动是通过增加新代码进行的,而不是更改现有的代码。因此,它就不会引起像不遵循OCP的程序那样的连锁改动。所需要的改动仅仅是增加新的模块,以及为了能够实例化新类型的对象而进行的围绕main的改动。
9.3.4 是的,我说谎了
上面的例子其实并非是100%封闭的!如果我们要求所有的圆必须在正方形之前绘制,那么程序9.2中的DrawAllShapes函数会怎样呢?DrawAllShapes函数无法对这种变化做到封闭。要实现这个需求,我们必须要修改DrawAllShapes的实现,使它首先扫描列表中所有的圆,然后再扫描所有的正方形。
9.3.5 预测变化和“贴切的”结构
如果我们预测到了这种变化,那么就可以设计一个抽象来隔离它。我们在程序92中所选定的抽象对于这种变化来说反倒成为一种障碍。可能你会觉得奇怪:还有什么比定义一个Shape类,并从它派生出Square类和Cice类更贴切的结构呢?为何这个贴切的模型不是最优的呢?很明显,这个模型对于一个形状的顺序比形状类型具有更重要意义的系统来说,就不再是贴切的了。
这就导致了一个麻烦的结果,一般而言,无论模块是多么的“封闭”,都会存在一些无法对之封闭的变化。没有对于所有的情况都贴切的模型。
既然不可能完全封闭,那么就必须有策略地对待这个问题。也就是说,设计人员必须对于他设计的模块应该对哪种变化封闭做出选择。他必须先猜测出最有可能发生的变化种类,然后构造抽象来隔离那些变化。
这需要设计人员具备一些从经验中获得的预测能力。有经验的设计人员希望自己对用户和应用领域很了解,能够以此来判断各种变化的可能性。然后,他可以让设计对于最有可能发生的变化遵循OCP原则。
这一点不容易做到。因为它意味着要根据经验猜测那些应用程序在生长历程中有可能遭受的变化。如果开发人员猜测正确,他们就获得成功。如果他们猜测错误,他们会遭受失败。并且在大多数情况下,他们都会猜测错误。
同时,遵循OCP的代价也是昂贵的。创建正确的抽象是要花费开发时间和精力的。同时,那些抽象也增加了软件设计的复杂性。开发人员有能力处理的抽象的数量也是有限的。显然,我们希望把OCP的应用限定在可能会发生的变化上。
我们如何知道哪个变化有可能发生呢?我们进行适当的调查,提出正确的问题,并且使用我们的经验和一般常识。最终,我们会一直等到变化发生时才采取行动。
9.3.6 放置吊钩
我们怎样去隔离变化呢?在上个世纪,我们常常说的一句话是,我们会在我们认为可能发生变化的地方放置吊钩(hook)。我们觉得这样做会使软件灵活一些。
然而,我们放置的吊钩常常是错误的。更糟的是,即使不使用这些吊钩,也必须要去支持和维护它们,从而就具有了不必要的复杂性的臭味。这不是一件好事。我们不希望设计背着许多不必要的抽象。通常,我们更愿意一直等到确实需要那些抽象时再把它放置进去。
1.只受一次愚弄
有句古老的谚语说:“愚弄我一次,应感羞愧的是你。再次愚弄我,应感羞愧的是我。”这也是一种有效的对待软件设计的态度。为了防止软件背着不必要的复杂性,我们会允许自己被愚弄一次。这意味着在我们最初编写代码时,假设变化不会发生。当变化发生时,我们就创建抽象来隔离以后发生的同类变化。简而言之,我们愿意被第一颗子弹击中,然后我们会确保自己不再被同一只枪发射的其他任何子弹击中。
2.刺激变化
如果我们决定接受第一颗子弹,那么子弹到来的越早、越快就对我们越有利。我们希望在开发工作展开不久就知道可能发生的变化。查明可能发生的变化所等待的时间越长,要创建正确的抽象就越困难。
因此,我们需要去刺激变化。我们已在第2章中讲述的一些方法来完成这项工作。
(1)我们首先编写测试。测试描绘了系统的一种使用方法。通过首先编写测试,我们迫使系统成为可测试的。在一个具有可测试性的系统中发生变化时,我们可以坦然对之。因为我们已经构建了使系统可测试的抽象。并且通常这些抽象中的许多都会隔离以后发生的其他种类的变化。
(2)我们使用很短的迭代周期进行开发个周期为几天而不是几周。
(3)我们在加入基础结构前就开发特性,并且经常性地把那些特性展示给涉众。
(4)我们首先开发最重要的特性。
(5)尽早地、经常性地发布软件。尽可能快地、尽可能频繁地把软件展示给客户和使用人员。
9.3.7 使用抽象获得显示封闭
第一颗子弹已经击中我们,用户要求我们在绘制正方形之前先绘制所有的圆。现在我们希望可以隔离以后所有的同类变化。
怎样才能使得DrawAllShapes函数对于绘制顺序的变化是封闭的呢?请记住封闭是建立在抽象的基础之上的。因此,为了让DrawAllShapes对于绘制顺序的变化是封闭的,我们需要一种“顺序抽象体”。这个抽象体定义了一个抽象接口,通过这个抽象接口可以表示任何可能的排序策略。
一个排序策略意味着,给定两个对象可以推导出应该先绘制哪一个。我们可以定义一个Shpe类的抽象方法叫作Precedes.。这个方法以另外一个Shape作为参数,并返回-一个bool型结果。如果接收消息的Shape对象应该先于作为参数传入的Shape对象绘制,那么函数返回true。
在C++中,这个函数可以通过重载operator<来表示。程序9.3中展示了添加了排序方法后的Shape类。
既然我们已经有了决定两个Shape对象的绘制顺序的方法,我们就可以对列表中的shape对象进行排序后依序绘制。程序9.4展示了C++的实现代码。
图9.3.7.1
这给我们提供了一种对Shape对象排序的方法,也使得可以按照一定的顺序来绘制它们。但是我们仍然没有一个好的用来排序的抽象体。按照目前的设计,Shape对象应该覆写Precedes方法来指定顺序。这究竟是如何工作的呢?我们应该在Circle:Precedes成员函数中编写一些什么代码,来保证圆一定会被先于正方形绘制呢?请看程序9.5。
图9.3.7.2
显然这个函数以及所有Shape类的派生类中的Precedes函数都不符合OCP。没有办法使得这些函数对于Shape类的新派生类做到封闭。每次创建一个新的Shape类的派生类时,所有的Precedest)函数都需要改动。
当然,如果从来不需创建新的Shape类的派生类,就没有关系了。另一方面,如果需要频繁的创建新的Sape类的派生类,这个设计就会遭到沉重的打击。我们再次被第一颗子弹击中。
9.3.8 使用“数据驱动”的方法获取封闭性
如果我们要使Shape类的各个派生类间互不知晓,可以使用表格驱动的方法。程序9.6展示了一·种可能的实现。
通过这种方法,我们成功地做到了一般情况下DrawAllShapes函数对于顺序问题的封闭,也使得每个Shape派生类对于新的Shape派生类的创建或者基于类型的Shape对象排序规则的改变是封闭的。(比如,改变顺序为正方形必须最先绘制。)
对于不同的Shapes的绘制顺序的变化不封闭的惟-一部分就是表本身。可以把表放置在一个单独的模块中,和所有其他模块隔离,因此对于表的改动不会影响到其他任何模块。事实上,在C++中,我们可以在链接时选择要使用的表。
9.4 结论
在许多方面,OCP都是面向对象设计的核心所在。遵循这个原则可以带来面向对象技术所声称的巨大好处(也就是,灵活性、可重用性以及可维护性)。然而,并不是说只要使用一种面向对象语言就是遵循了这个原则。对于应用程序中的每个部分都肆意地进行抽象同样不是一个好主意。正确的做法是,开发人员应该仅仅对程序中呈现出频繁变化的那些部分做出抽象。拒绝不成熟的抽象和抽象本身一样重要。