软件实体(类、模块、函数等)应该是可以扩展的,但是不可修改的。
如果程序中的一处改动就会产生连锁反应,导致一系列相关模块的改动,那么设计就具有僵化性的臭味。OCP建议我们应该对系统进行重构,这样以后对系统再进行这样那样的改动时,就不会导致更多的修改。如果正确地应用OCP,那么以后再进行同样的改动时,就只需要添加新的代码,而不必改动已经正常运行的代码。
OCP概述
遵循开放-封闭原则设计出的模块具有两个主要的特征。它们是:
- 对于扩展是开放的(open for extension)。这意味着模块的行为是可以扩展的。当应用的需求改变时,我们可以对模块进行扩展,使其具有满足那些改变的新行为。换句话说,我们可以改变模块的功能。
- 对于修改是封闭的(closed for modification)。对模块行为进行扩展时,不必改动模块的源代码或者二进制代码。模块的二进制可执行版本,无论是可链接的库、DLL或者.EXE文件,都无需改动。
这两个特征好像是互相矛盾的。扩展模块的行为的通常方式,就是修改该模块的源代码。不允许修改的模块常常都认为具有固定的行为。
怎样可能在不改动模块源代码的情况下去更改它的行为呢?如果不改变一个模块,又怎么能够去改变它的功能呢?
答案是抽象。在C#或者其他任何的OOPL(面向对象程序设计语言)中,可以创建出固定却能够描述一组任意个可能行为的抽象体。这个抽象体就是抽象基类。而一组任意个可能的行为则表现为可能的派生类。
模块可能对抽象体进行操作。由于模块依赖于一个固定的抽象体,所以它对于更改可以是封闭的。同时,通过从这个抽象体派生,可以扩展此模块的行为。
下图展示了一个简单的不遵循OCP的设计。Client类和Server类都是具体类。Client类使用Server类。如果我们希望Client类对象使用另外一个不同的服务器对象,那么就必须要把Client类中使用Server类的地方更改为新的服务器类。
下图展示了一个针对上述问题的遵循OCP的设计(通过使用STRATEGY模式)。在这个设计中,ClientInterface是一个拥有抽象成员函数的抽象接口。Client类使用这个抽象接口。然而Client类的对象却使用派生的Server类的对象。如果我们希望Client对象使用一个新的服务器类,那么只需要从ClientInterface派生一个新的类。无需从Client类做任何改动。
Client需要实现一些功能,它可以根据ClientInterface抽象接口去描绘那些功能。ClientInterface的子类型可以以任何它们所选择的方式去实现这个接口。这样,就可以通过创建ClientInterface的新的子类型的方式去扩展、更改Client中指定的行为。
也许你想知道为何把抽象接口命名为ClientInterface。为何不把它命名为AbstractServer呢?因为抽象类和它们的客户的关系要比和实现它们的类的关系更密切一些。
Shape应用程序
Shape示例在许多讲述面向对象设计的书中都提到过。这个声名狼藉的例子常常用来展示多态的工作原理。
我们有一个需要在标准的GUI上绘制圆和正方形的应用程序。圆和正方形必须按照特定的顺序绘制。我们将创建一个列表,列表由按照适当顺序排列的圆和正方形组成,程序遍历该列表,依次绘制出每一个圆和正方形。
违反OCP
//--shape.h--------------------------------------- enum ShapeType { circle, square }; struct Shape { ShapeType itsType; }; //--circle.h--------------------------------------- struct Circle { ShapeType itsType; double itsRadius; Point itsCenter; }; void DrawCircle(struct Circle*); //--square.h--------------------------------------- struct Square { ShapeType itsType; double itsSide; Point itsTopLeft; }; void DrawSquare(struct Square*); //--drawAllShapes.cc------------------------------- typedef struct Shape *private ShapePointer ; void DrawAllShapes(ShapePointer list[]private ,private 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; } } }
DrawAllShapes函数不符合OCP,因为它对于新的形状类型的添加不是封闭的。如果希望这个函数能够绘制包含三角形的列表,就必须变更这个函数。事实上,每增加一种新的形状类型,都必须要更改这个函数。
当然这只是一个简单的例子。在实际应用程序中,类似DrawAllShape函数中的switch语句会在应用程序的许多函数中不断地重复出现,每个函数中switch语句负责完成的工作差别甚微。这些函数中,可能有负责拖拽形状对象的,有负责拉伸形状对象的,有负责移动形状对象的,有负责删除形状对象的,等等。在这样的应用程序中增加一种新的形状类型,就意味着要找出所有包含上述switch语句(或者链式if/else语句)的函数,并在每一处都添加对新增的形状类型的判断。
同样,在进行上述改动时,我们必须要在ShapeType中添加一个新的成员。由于所有不同种类的形状都依赖于这个枚举声明,所以我们必须要重新编译所有的形状模块。并且也必须要重新编译所有依赖于Shape类的模块。
遵循OCP
public interface Shape { void Draw(); } public class Square : Shape { public void Draw() { //draw a square } } public class Circle : Shape { public void Draw() { //draw a circle } } public void DrawAllShapes(IList shapes) { foreach (Shape shape in shapes) { shape.Draw(); } }
这个程序是符合OCP的。对它的改动是通过增加新代码进行的,而不是更改现有的代码。因此,它就不会引起像不遵循OCP的程序那样的连锁改动。所需要的改动仅仅是增加新的模块,以及为了能够实例化新类型的对象而进行的围绕main的改动。
如果我们要求所有的Circle必须在Square之前绘制,那么代码中的DrawAllShapes函数会怎么样呢?DrawAllShapes函数无法对这种变化做到封闭。要实现这个需求,我们必须要修改DrawAllShapes的实现,使它首先扫描列表中所有的Circle,然后再扫描所有的Square。
预测变化和“贴切的”结构
如果我们预测到了这种变化,那么就可以设计出一个抽象来隔离它。我们在代码中所选定的抽象对于这种变化来说反倒成为一种障碍。可能你会觉得奇怪:还有什么比定义一个Shape类,并从它派生出Square类和Circle类更贴切的结构呢?为何这个贴切的模型不是最优的呢?很明显,模型对于一个形状的顺序比形状更具有重要意义的系统来说,就不再那么贴切了。
这就导致了一个麻烦的结果,一般而言,无论模块是多么的“封闭”,都会存在一些无法对之封闭的变化。没有对于所有的情况都贴切的模型!
遵循OCP的代价是昂贵的。创建适当的抽象是要花费开发时间和精力的。同时,那些抽象也增加了软件设计的复杂性。我们希望把OCP的应用限定在可能会发生的变化上。
那么我们如何知道哪个变化有可能发生呢?我们进行适当的调查,提出正确的问题,并且使用我们的经验和一般常识。最终,我们会一直等到变化发生时才采取行动!
使用抽象获得显示封闭
封闭是建立在抽象的基础上的。因此,为了让DrawAllShapes对于绘制顺序的变化是封闭的。我们需要一种“顺序抽象体”。这个抽象体定义了一个抽象接口,通过这个接口可以表示任何可能的排序策略。
一个排序策略意味着,给定两个对象,可以推导出先绘制哪一个。C#提供了这样的抽象。IComparable是一个接口,它只提供一个方法:CompareTo。这个方法以一个对象作为输入参数,当接受消息的对象小于、等于、大于参数数对象时,该方法分别返回-1,0,1 。
扩展了IComparable接口的Shape类
public interface Shape : IComparable { void Draw(); }
依次绘制DrawAllShape函数
public void DrawAllShapes(ArrayList shapes) { shapes.Sort(); foreach (Shape shape in shapes) { shape.Draw(); } }
对Circle排序
public class Circle : Shape { public int CompareTo(object obj) { if (obj is Square) { return -1; } else { return 0; } }
显然这个函数以及所有的Shape类的派生类中的CompareTo函数都不符合OCP。没有办法使得这些函数对于Shape类的新派生类做到封闭。每次创建一个新的Shape类的派生类时,所有的CompareTo函数都需要改动。如果需要频繁地创建新的Shape类的派生类,那么这个设计就会遭到沉重的打击。
使用“数据驱动”的方法获取封闭性
public class ShapeComparer : IComparer { private static Hashtable priorities = new Hashtable(); static ShapeComparer() { priorities.Add(typeof(Circle), 1); priorities.Add(typeof(Square), 2); } private int PriorityFor(Type type) { if(priorities.Contains(type)) return (int)priorities[type]; else return 0; } public int Compare(object o1, object o2) { int priority1 = PriorityFor(o1.GetType()); int priority2 = PriorityFor(o2.GetType()); return priority1.CompareTo(priority2); } }
通过这种方法,我们成功地做到了一般情况下DrawAllShapes函数对于顺序问题的封闭,也使得每一个Shape派生类对于新的Shape派生类的创建或者基于类型的Shape对象排序规则的改变是封闭的。(比如,改变顺序为Square必须最先绘制)
结论
在许多方面,OCP都是面向对象设计的核心所在。遵循这个原则可以带来面向对象技术所声称的巨大好处:灵活性、可重用性以及灵活性。然而,并不是说使用一种面向对象的语言就是遵循了这个原则。对于应用程序中的每个部分都肆意地进行抽象同样不是一个好主意。正确的做法是,开发人员仅仅对程序中出现频繁变化的那些部分作出抽象。拒绝不成熟的抽象和抽象本身一样重要。
摘录自:[美]RobertC.Martin、MicahMartin著,邓辉、孙鸣译 敏捷软件开发原则、模式与实践(C#版修订版) [M]、人民邮电出版社,2013、93-101、