开放-封闭原则(OCP)
怎样的升级才能面对需求的改变却可以保持相对稳定,从而使得系统可以在第一个版本以后不断推出新的版本呢?
开放-封闭原则(The Open-Closed Principle, OCP)为我们提供了指引。
软件实体(类、模块、函数等)应该是可以扩展的,但是不可修改的。
如果程序中一处改动就会产生连锁反应,导致一系列相关模块的改动,那么设计就具有僵化性的臭味。
OCP建议我们应该对系统进行重构,这样以后对系统再就行那样的改动时,就不会导致更多的改动。
如果正确地应用OCP,那么以后再进行同样的改动时,就只需要添加新的代码,而不必改动已经正常运行的代码。
1. 描述
遵循开发-封闭原则设计出的模块具有两个主要的特征:
(1)对于扩展是开放的Open for extension
这意味着模块的行为是可以扩展的。当应用的需求改变时,可以对模块进行扩展,使其具有满足那些改变的新行为。换句话说,可以改变模块的功能。
(2)对于更改是封闭的Closed for modification
对模块行为进行扩展时,不必改动模块的源代码或二进制代码。模块的二进制可执行版本,无论是可链接的库、DLL或Java的jar文件,都无需改动。
怎样可能在不改动模块源代码的情况下去更改它的行为呢?
怎样才能在无需对模块进行改动的情况下就改变它的功能呢?
2. 关键是抽象
在C++、Java或其他任何的OOPL中,可以创建出固定却能够描述一组任意个可能行为的抽象体。这个抽象体就是抽象基类。而这一组任意个可能的行为则表现为可能的派生类。
模块可以操作一个抽象体,由于模块依赖于一个固定的抽象体,所以它对于更改可以是关闭的。同时,通过从这个抽象体派生,也可以扩展此模块的行为。
不遵循OCP的设计。Client类和Server类都是具体类。Client类使用Server类。如果希望Client对象使用另外一个不同的服务器对象,那么就必须要把Client类中使用Server类的地方更改为新的服务器类。
上图展示了一个针对上述问题的遵循OCP的设计。在这个设计中,ClientInterface类是一个拥有抽象成员函数的抽象类。Client类使用这个抽象类;
然而Client类的对象却使用Server类的派生类的对象。如果希望Client对象使用一个不同的服务器类,只需要从ClientInterface类派生一个新的类,无需对Client类做任何修改。
Client需要实现一些功能,它可以使用ClientInterface抽象接口去描绘那些功能。ClientInterface的子类型可以以任何它们所选择的方式去实现这个接口。这样,就可以通过创建ClientInterface的新的子类型的方式去扩展、更改Client中指定的行为。
为何把抽象接口命名为ClientInterface。为何不把它命名为AbstractServer?
因为抽象类和它们的客户的关系要比和实现它们的类的关系更密切一些。
Policy类具有一组是实现了某种策略的公有函数。与Client类的函数类似,这些策略函数使用一些抽象接口描绘了一些要完成的功能。
不同的是,在这个结构中,这些抽象接口是Policy类本身的一部分。这些函数在Policy的子类型中实现。这样,可以通过从Policy类派生出新类的方式,对Policy中指定的行为进行扩展或更改。
Template Method模式:既开放由封闭的典型。
3. Shape应用程序
在一个标准的GUI上绘制圆和正方形的应用程序。圆和正方形要按照特定的顺序绘制。
创建一个列表,列表由按照适当的顺序排列的圆和正方形组成,程序遍历该列表,依次绘制出每个圆和正方形。
class Point { double x; double y; } class Shape { ShapeType itsType; } class Circle extends Shape { double itsRadius; Point itsCenter; } class Square extends Shape { double itsSide; Point itsTopLeft; } enum ShapeType { circle, square } public class Ocp { void drawAllShapes(Shape[] list, int n) { for (int i = 0; i < n; i++) { Shape sh = list[i]; switch (sh.itsType) { case square: drawSquare((Square)sh); break; case circle: drawCircle((Circle)sh); break; default: break; } } } void drawSquare(Square square) { System.out.println(square); } void drawCircle(Circle circle) { System.out.println(circle); } }
drawAllShapes函数不符合OCP,因为它对于新的形状类型的添加不是封闭的。如果希望这个函数能够绘制包含有三角形的列表,就必须得修改这个函数。事实上,没增加一种新的形状类型,都必须要更改这个函数。
同时,在进行上述改动时,必须要在ShapeType里面增加一个新的成员,由于所有不同种类的形状都依赖于这个enum的声明,所以我们必须要重新编译所有的形状模块。并且也必须要重新编译所有依赖于Shape类的模块。
下面代码展示了符合OCP的解决方案。
class Point { double x; double y; } abstract class Shape { ShapeType itsType; public abstract void draw(); } class Circle extends Shape { double itsRadius; Point itsCenter; @Override public void draw() { System.out.println(this); } } class Square extends Shape { double itsSide; Point itsTopLeft; @Override public void draw() { System.out.println(this); } } enum ShapeType { circle, square } public class Ocp { void drawAllShapes(Shape[] list, int n) { for (int i = 0; i < n; i++) { list[i].draw(); } } }
可以看出,如果想要扩展程序中drawAllShapes函数的行为(对扩展开放),使之能够绘制一种新的形状,只需要增加一个新的Shape的派生类。drawAllShapes函数并不需要改变(对修改封闭)。
这样drawAllShapes就符合OCP,无需改动自身代码,就可以扩展它的行为。
假如增加Triangle类对于这里展示的任何模块完全没有影响。为了能够处理Triangle类,需要要改动系统中的某些部分,但是这里展示的所有代码都无需改动。
上面的例子其实并非是100%封闭的,如果要求所有的圆必须在正方形之前绘制,那么程序中的drawAllShapes函数会怎样?
drawAllShapes函数无法对这种变化做到封闭。
3.1 预测变化和“贴切的”结构
如果预测到了这种变化,那么就可以设计一个抽象来隔离它。
这就导致了一个麻烦的结果,一般而言,无论模块是多么的“封闭”,都会存在一些无法对之封闭的变化,没有对于所有的情况都贴切的模型。
既然不可能完全封闭,那么就必须由策略地对待这个问题。也就是说,设计人员必须对于他设计的模型应该对哪种变化封闭做出选择。
他必须先猜测出最可能发生的变化种类,然后构造抽象来隔离这些变化。
同时,遵循OCP的代价也是昂贵的。创建正确的抽象是要花费开发时间和精力的。同时,那些抽象也增加了软件设计的复杂性。开发人员有能力处理的抽象的数量也是有限的。
显然,希望把OCP的应用限定在可能会发生的变化上。
3.2 使用抽象获得显式封闭
用户要求在绘制正方形之前先绘制所有的圆,我们希望可以隔离以后所有的同类变化。
怎样才能使得drawAllShapes函数对于绘制顺序的变化时封闭的呢?请记住封闭是建立在抽象的基础之上的。因此,为了让drawAllShapes对于绘制顺序的变化四封闭的,需要一种“顺序抽象体”。
这个抽象体定义了一个抽象接口,通过这个抽象接口可以表示任何可能的排序策略。
class Point { double x; double y; } abstract class Shape implements Comparable<Shape>{ ShapeType itsType; public abstract void draw(); public int precedes(Shape sh) { if (sh.itsType == ShapeType.square) { return 1; } else { return -1; } } @Override public int compareTo(Shape sh) { return precedes(sh); } } class Circle extends Shape { double itsRadius; Point itsCenter; public Circle(ShapeType shapeType) { itsType = shapeType; } @Override public void draw() { System.out.println(this); } } class Square extends Shape { double itsSide; Point itsTopLeft; public Square(ShapeType shapeType) { itsType = shapeType; } @Override public void draw() { System.out.println(this); } } enum ShapeType { circle, square } public class Ocp { static void drawAllShapes(Shape[] list, int n) { Arrays.sort(list); for (int i = 0; i < n; i++) { list[i].draw(); } } public static void main(String[] args) { Shape[] list = new Shape[5]; list[0] = new Circle(ShapeType.circle); list[1] = new Square(ShapeType.square); list[2] = new Circle(ShapeType.circle); list[3] = new Square(ShapeType.square); list[4] = new Circle(ShapeType.circle); drawAllShapes(list, 5); } }
显然precedes函数以及所有Shape类的派生类中的precedes函数都不符合OCP。没有办法使得这些函数对于Shape类的新派生类做到封闭。每次创建一个新的Shape类的派生类时,所有的precedes函数都需要改动。
class Point { double x; double y; } abstract class Shape implements Comparable<Shape> { ShapeType itsType; public abstract void draw(); public int precedes(Shape sh) { int thisIdx = -1; int argIdx = -1; for (int i = 0; i < ShapeType.SORT_SHAPE_TYPE.length; i++) { ShapeType shapeType = ShapeType.SORT_SHAPE_TYPE[i]; if (shapeType == this.itsType) { thisIdx = i; } if (shapeType == sh.itsType) { argIdx = i; } } return thisIdx - argIdx; } @Override public int compareTo(Shape sh) { return precedes(sh); } } class Circle extends Shape { double itsRadius; Point itsCenter; public Circle(ShapeType shapeType) { itsType = shapeType; } @Override public void draw() { System.out.println(this); } } class Square extends Shape { double itsSide; Point itsTopLeft; public Square(ShapeType shapeType) { itsType = shapeType; } @Override public void draw() { System.out.println(this); } } enum ShapeType { circle, square; public static final ShapeType[] SORT_SHAPE_TYPE = {square, circle}; } public class Ocp { static void drawAllShapes(Shape[] list, int n) { Arrays.sort(list); for (int i = 0; i < n; i++) { list[i].draw(); } } public static void main(String[] args) { Shape[] list = new Shape[5]; list[0] = new Circle(ShapeType.circle); list[1] = new Square(ShapeType.square); list[2] = new Circle(ShapeType.circle); list[3] = new Square(ShapeType.square); list[4] = new Circle(ShapeType.circle); drawAllShapes(list, 5); } }
通过这种方法,成功做到了一般情况下drawAllShapes函数对于顺序问题的封闭,也使得每个Shape派生类对于新的Shape派生类的创建或基于类型的Shape对象排序规则的改变是封闭的。
对于不同的Shape的绘制顺序的变化不封闭的唯一部分就是ShapeType对象。
4. 结论
OCP都是面向对象设计的核心所在。遵循这个原则可以带来面向对象技术所声称的巨大好处(灵活性、可用性以及可维护性)。
然而,并不是说只要使用一种面向对象语言就是遵循了这个原则。正确的做法是,开发人员应该仅仅对程序中呈现出频繁变化的那些部分作出抽象。