【设计原则篇】开放-封闭原则(OCP)

1、开放封闭原则(open—closed principle)

软件实体(类、模块、函数等等)或者说我们在面相对象编程时,应该是可以扩展的,但是不可修改的。

  如果程序中的一处改动就会产生连锁反应,导致一系列相关模块的改动,那么设计就具有僵化性的臭味。OCP建议我们应该对系统进行重构,这样以后对系统在进行那样的改动时,就不会导致更多的修改。如果正确地应用OCP,那么以后再进行同样的改动时,就只需要添加新的代码,而不必改动已经正常运行的代码。

  也许,这看起来像是众所周知的可望而不可及的美好理想——然而,事实上却有一些相对简单并且有效的策略可以帮助接近这个理想。

2、描述

遵循开放—封闭原则设计出的模块具有两个主要的特征。它们是:

  • "对于扩展是开放的"(Open for extension)
      这意味着模块的行为是可以扩展的。当应用的需求改变时,我们可以对模块进行扩展,使其具有满足那些改变的行为。换句话说,我们可以改变模块的功能。
  • "对于修改说封闭的"(Closed for modification)
      对模块行为进行扩展时,不必改动模块的源代码。模块的二进制可执行版本,无论是可链接的库、DLL或者Java的.jar文件,都无需改动。

  这两个特征好像是互相矛盾的。扩展模块行为的通常方式就是修改模块的源代码。不允许修改的模块常常都被认为是具有固定的行为。

  怎样可能在不改动模块源代码的情况下更改它的行为呢?怎样才能无需对模块进行改动的情况下就改变它的功能呢?

3、关键是抽象

  在C++、Java或者其他任何的OOPL(面向对象编程语言)中,可以创建出固定却能够描述一组任意个可能行为的抽象体。这个抽象体就是抽象基类。而这一组任意个可能的行为则表现为可能的派生类。

  抽象可以操作一个抽象体。由于模块依赖于一个固定的抽象体,所以它对于更改可以是关闭的。同时,通过从这个抽象体派生,也可以扩展此模块的行为。

  图1展示了一个简单的不遵循OCP的设计。Client类和Server类都是具体类。Client类使用/依赖Server类。如果我们希望Client对象使用另外一个不同的服务器对象,那么就必须要把Client类中使用Server类的地方更改为新的服务器类。
image

图 1 既不开放又不封闭的Client

image

图 2 STRATEGY模式:即开放又封闭的Client

  图2中展示了一个针对上述问题的遵循OCP的设计。在这个设计中,ClientInterface类是一个拥有抽象成员函数的抽象类。Client类使用这个抽象类;然而Client类的对象却是使用Server类的派生类的对象。如果我们希望Client对象使用一个不同的服务器类,那么只需要从ClientInterface类派生一个新的类。无需对Client类做任何改动。

  Client需要实现一些功能,它可以使用ClientInterface抽象接口去描绘那些功能。ClientInterface的子类型可以以任何它们所选择的方式去实现这个接口。这样,就可以通过创建ClientInterface的新的子类型的方式去扩展、更改Client中制定的行为。

  也许你想知道我为何把抽象接口命名为ClientInterface。为何不把它命名为AbstractServer呢?因为(后面在依赖倒置原则会看到)抽象类和它们的客户的关系要比和实现它们的类的关系更密切一些。

  图3展示了另一个可选的结构。Policy类具有一组实现了某种策略的公有函数。和图2中的Client类的函数类似,这些策略函数使用一些抽象接口描绘了一些要完成的功能。不同的是,在这个结构中,这些抽象接口是Policy类本身的一部分。它们在C++中表现为纯虚函数,在Java/C#中表现为抽象方法。这些函数在Policy的子类型中实现。这样,可以通过从Policy类派生出新类的方式,对Policy中指定的行为进行扩展或者更改。
image

图 3 Template Method模式:即开放又封闭的基类

  这两个模式是满足OCP的最常用的方法。应用它们,可以把一个功能的通用部分和实现细节部分清晰的分离开来。

3.1、Shape应用程序

  下面的例子在许多讲述OOD(面向对象的设计)的书中都提到过。它就是声名狼藉的"Shape"样例。它常常被用来展示太多工作原理。不过,这次我们将使用它来阐明OCP。

  我们有一个需要在标准的GUI上绘制圆和正方形的应用程序。圆和正方形必须要按照特定的顺序绘制。我们将创建一个列表,列表由按照适当的顺序排列的圆和正方形组成,程序遍历该列表。依次绘制出每个圆和正方形。

3.2、违反OCP

  如果使用C语言,并采用不遵循OCP的过程化方法,我们也许会得到程序1中所示的解决方法。其中,我们看到了一组的数据结构,它们的第一个成员都相同,但是其余的成员都不同。每个结构中的第一个成员都是一个用来标识该结构上代表圆或者正方形的类型码。DrawAllShapes函数遍历一个数组。该数组的元素上指向这些数据结构的指针。DrawAllShapes函数先检查类型码,然后根据类型码调用对应的函数(DrawCircle或者DrawSquare)。

程序1 Square/Circle问题的过程化解决方案
-- Shape.cs

public enum ShapeType
{
  circle,
  square
}
public class Shape
{
  ShapeType itsType;
}
public class Point
{
  double x;
  double y;
}

-- Circle.cs

public class Circle
{
  ShapeType itsType;
  double itsRadius;
  Point itsCenter;
}

-- Square.cs

public class Square
{
  ShapeType itsType;
  double itsSide;
  Point itsTopLeft;
}

-- DrawAllShapes.cs

void DrawAllShapes(List<Shape> list)
{
  foreach(var item in list)
  {
    var t=item.GetType();
    switch(t)
    {
      case typeof(Square):
        DrawSquare((struct Square*)s);
        break;
      case typeof(Circle):
        DrawCircle((struct Square*)s);
        break;  
    }  
  }
}

  DrawAllShapes函数不符合OCP,因为它对于新的形状类型的添加不是封闭的。如果希望这个函数能够绘制包含有三角形的列表,就必须得更改这个函数。事实上,每增加一种新的形状类型,都必须要更改这个函数。

  当然这只是一个简单的例子来。在实际程序中,类似DrawAllShapes函数中的switch语句会在应用程序的各个函数中重复不断地出现。每个函数中switch语句负责完成的工作差别甚微。这些函数中,可能有负责拖拽形状对象的,有负责拉伸形状对象的,有负责移动形状对象的,有负责删除形状对象的,等等。在这样的应用程序中增加一种新的形状类型,就意味要找出所有包含上述switch语句(或者链式if/else语句)的函数,并在每一处都添加对新增的形状类型的判断。

  更糟的是,并不是所有的switch语句和if/else链都像DrawAllShapes中的那样有比较好的结构。更有可能的情形是,if语句中的判断条件由逻辑操作符组合而成,或者是处理方式相同的case的语句被成组处理。在一些极端错误的实现中,会有一些函数对于Square的处理竟然和对于Circle的处理一样。在这样的函数中,甚至根本就没有switch/case语句或者if/else链。这样,要发现和理解所有的需要增加对新的形状类型进行判断的地方,恐怕就非常的困难了。

  同样,在进行上述改动时,我们必须要在ShapeType enum中添加一个新的成员。由于所有不同种类的形状都依赖于这个enum的声明,所以我们必须重新编译所有的形状模块。并且也必须要重新编译所有依赖于Shape类的模块。

  因此,我们不但必须要更改源代码中所有的switch/case语句或者if/else链,而且还必须得改动所有使用任一个Shape数据结构的模块的二进制文件(通过重新编译)。更改二进制文件意味着必须要重新部署所有DLL、共享库或者其他类型的二进制组件。给应用程序增加一种新的形状类型这样的一个简单的行为,就导致了随后对于许多模块的源代码、甚至许多模块的二进制码和二进制组件的连锁改动。可见,增加一种新的形状类型带来的影响是巨大的。

糟糕的设计
再来回顾以下。程序1中的解决方法是僵化的,这是因为增加Triangle会导致Shape、Square、Circle以及DrawAllShapes的重新编译和重新部署。该方法是脆弱的,因为有许多其他的既难以查找又难以理解的switch/case或者if/else语句。该方法是牢固的,因为想在另一个程序中复用DrawAllShapes时,都必须要附带上Square和Circle,即使那个新程序不需要它们。因此,在程序1中展示了许多糟糕设计的臭味。

3.3、遵循OCP

  程序2中展示了一个square/circle问题的符合OCP的解决方案。在这个方案中,我们编写了一个名为Shape的抽象类。这个抽象类仅有一个名为Draw的抽象方法。Circle和Square都从Shape类派生。

程序2问 题的OOD解决方案

public abstract class Shape
{
   public abstract void Draw();
}

public class Square:Shape
{
  public override void Draw()
  {
    //..你的代码
  }
}

public class Circle:Shape
{
  public override void Draw()
  {
    //..你的代码
  }
}

public void DrawAllShapes(List)<Shape> list
{
  foreach(var item in list)
  {
    item.Draw();
  }
}

  可以看到,如果我们想要扩展程序2中DrawAllShapes函数的行为,使之能够绘制一种新的形状,我们只需要增加一个新的Shape派生类。DrawAllShapes函数并不需要改变。这样DrawAllShapes就符合了OCP。无需改动自身代码,就可以扩展它的行为。实际上,增加一个Triangle类对于这里展示任何模块完全没有影响。很明显,为了能够处理Triangle类,必须要改动系统中的某些部分,但是这里展示的所有代码都无需改动。

  在实际的应用程序中,Shape类可能会有更多的方法。但是在应用程序中增加一种新的形状类型依然非常简单,因为所需要做的工作只是创建Shape类的新的派生类,并实现它的所有函数。再也不需要为了找出需要更改的地方而在应用程序的所有地方进行搜寻。这个解决方案不再是脆弱的。

  同时,这个方案也不再是僵化的。在增加一个新的形状类型时,现有的所有模块的源码都无需改动,并且现有的所有二进制模块都无需进行重新构建(rebuild)。只有一个例外,那就是实际创建Shape类新的派生类实例的模块必须改动。通常情况下,创建Shape类新的派生类实例的工作要么是在main中或者被main调用的一些函数中完成,要么是在被main创建的一些对象的方法中完成。

  最后,这个方案也不再是牢固的。现在,在任何应用程序中重用DrawAllShapes时,都无需再附带上Square和Circle。因而,这个解决方案就不再有前面提及的任何糟糕设计的特征。

  这个程序是符合OCP的。对它的改动是通过增加新代码进行的,而不是更改现有的代码。因此,它就不会引起像不遵循OCP的程序那样的连锁改动。所需要的改动仅仅是增加新的模块,以及为了能够实例化新类型的对象而进行的围绕main的改动。

3.4、是的,我说谎了

  上面的例子其实并非是100%封闭的!如果我们要求所有的圆必须再正方形之前绘制,那么程序2中的DrawAllShapes函数会怎样呢?DrawAllShapes函数无法对这种变化做到封闭。要实现这个需求,我们必须要修改DrawAllShapes的实现,使它首先扫描列表中的所有的圆,然后再扫描所有的正方形。

3.5、预测变化和“贴切的”结构

  如果我们预测到了这种变化,那么就可以设计一个抽象来隔离它。我们在程序2中所选定的抽象对于这种变化来说反倒成为了一种障碍。可能你会觉得奇怪;还有什么比定义一个Shape类,并从它派生出Square类和Circle类更贴切的结构呢?为何这个贴切的模型不是最优的呢?很明显,这个模型对于一个形状的顺序比形状类型具有更重要意义的系统来说,就不再是贴切的了。

  这就导致了一个麻烦的结果,一般而言,无论模块是多么的“封闭”,都会存在一些无法对之封闭的变化。没有对于所有情况都贴切的模型。

  既然不可能完全封闭,那么就必须有策略地对待这个问题。也就是说,设计人员必须对于他设计模块应该对哪种变化封闭做出选择。他必须先猜测出最有可能发生的变化种类,然后构造抽象来隔离那些变化。

  这需要设计人员具备一些从经验中获得的预测能力。有经验的设计人员希望自己对用户和应用领域很了解,能够以此来判断各种变化的可能性。然后,他可以让设计对于最有可能发生的变化遵循OCP原则。

  这一点不容易做到。因为它意味着要根据经验猜测那些应用程序在生长历程中有可能遭受的变化。如果开发人员猜测正确,他们就获得成功。如果他们猜测错误,他们会遭受失败。并且在大多数情况下,他们都会猜测错误。

  同时,遵循OCP的代价也是昂贵的。创建正确的抽象是要花费开发时间和精力的。同时,那些抽象也增加了软件设计的复杂性。开发人员有能力处理的抽象的数量也是有限的。显然,我们希望把OCP的应用限定在可能会发生的变化上。

  我们如何知道哪个变化有可能发生呢?我们进行适当的调查,提出正确的问题,并且使用我们的经验和一般常识。最终,我们会一直等到变化发生时才采取行动。

3.6、放置吊钩

我们怎样去隔离变化呢?在上个世纪,我们常常说的一句话是,我们会在我们认为可能发生变化的地方放置钩子(hook)。我们觉得这样做会使软件灵活一些。

然而,我们放置钩子常常是错误的。更糟的是,即使不使用这些钩子,也必须去支持和维护它们,从而就具有了不必要的复杂性的臭味。这不是一件好事。我们不希望设计背着许多不必要的抽象。通常,我们更愿意一直等到确实需要那些抽象时再把它放置进去。

3.6.1、只受一次愚弄

有句古老的谚语说:“愚弄我一次,应该羞愧的是你。再次愚弄我,应感羞愧的是我。”这也是一种有效的对待软件设计的态度。为了防止软件背着不必要的复杂性,我们会允许自己被愚弄一次。这意味着在我们最初编写代码时,假设变化不会发生。当变化发生时,我们就创建抽象来隔离以后发生的同类变化。简而言之,我们愿意被第一颗子弹击中,然后我们会确保自己不再被同一支枪发射的其他任何子弹击中。

3.6.2、刺激变化

如果我们决定接受第一颗子弹,那么子弹到来的越早、越快就对我们越有利。我们希望在开发工作展开不久就知道可能发生的变化。查明可能发生的变化所等待的时间越长,要创建正确的抽象就越困难。
因此,我们需要去刺激变化。
我们首先编写测试。测试描绘了系统的一种使用方法。通过首先编写测试,我们迫使系统成为可测试的。在一个具有可测试性的系统中发生变化时,我们可以坦然对之。因为我们已经构建了使系统可测试的抽象。并且通常这些抽象中给许多都会隔离以后发生的其他种类的变化。
我们使用很短的迭代周期进行开发——一个周期为几天而不是几周。
我们在加入基础结构前就开发特性,并且经常性地把那些特性展示给涉众。
我们首先开发最重要的特性。
尽早地、经常性地发布软件。尽可能地、尽可能频繁地把软件展示给客户和使用人员。

4、结论

在许多方面,OCP都是面向对象设计的核心所在。遵循这个原则可以带来面向对象技术所声称的巨大好处(也就是,灵活性、可重用性以及可维护性)。然而,并不是说只要使用一种面向对象语言就是遵循了这个原则。对于应用程序中的每个部分都肆意地进行抽象同样不是一个好主意。正确的做法是,开发人员应该仅仅对程序中呈现出频繁变化的那些部分做出抽象。拒绝不成熟的抽象和抽象本身一样重要。

References:

《Agile Software Development Principles,Patterns,and Practices》(Robert C.Martin)
posted @   Harley-Chang  阅读(647)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 分享4款.NET开源、免费、实用的商城系统
· 全程不用写代码,我用AI程序员写了一个飞机大战
· MongoDB 8.0这个新功能碉堡了,比商业数据库还牛
· 白话解读 Dapr 1.15:你的「微服务管家」又秀新绝活了
· 上周热点回顾(2.24-3.2)
点击右上角即可分享
微信分享提示