SweetDream
高歌一壶新酿酒,醉抱青山不必归。

 设计模式是对特定问题经过无数次经验总结后提出的能够解决它的优雅的方案。但是,如果想要真正使设计模式发挥最大作用,仅仅知道设计模式是什么,以 及它是如何实现的是很不够的,因为那样就不能使你对于设计模式有真正的理解,也就不能够在自己的设计中正确、恰当的使用设计模式。本文试图从另一个角度 (设计模式的意图、动机)来看待设计模式,通过这种新的思路,设计模式会变得非常贴近你的设计过程,并且能够指导、简化你的设计,最终将会导出一个优秀的 解决方案。
  1、介绍
  在进行项目的开发活动中,有一些设计在项目刚刚开始工作的很好,但是随着项目的进展, 发现需要对已有的代码进行修改或者扩展,导致这样做的原因主要有:新的功能需求的需要以及对系统进一步理解。在这个时候,我们往往会发现进行这项工作比较 困难,即使能完成也要付出很大的代价。此时,一个必须要做的工作就是要对现有的代码进行重构(refactoring),通过重构使得我们接下来的工作变 得相对容易。
  重构就是在不改变软件系统代码的外部行为的前提下,改善它的内部结构。重构的目标就是使代码结构更加合理,富有弹性,能够适应新的需求、新的变 化。对于特定问题给出优美解决方案的设计模式往往会成为重构的目标,而且一旦我们能够识别出能够解决我们问题的设计模式,将会大大简化我们的工作,因为我 们可以重用别人已经做过的工作。但是在我们的原始设计和最终可能会适用于我们的设计模式间的过渡并不是平滑的,而是有一个间隙。这样的结果就是:即使我们 已经知道了很多的设计模式,面对我们的实际问题,我们也没有一个有效的方法去判断哪一个设计模式适用于我们的系统,我们应该去怎样应用它。
  造成上述问题的原因往往是由于过于注重设计模式所给出的解决方案这个结果,而对于设计模式的意图,以及它产生的动机却忽略了。然而,正是设计模 式的意图、动机促使人们给出了一个解决一类问题的方案这个结果,设计模式的动机、意图体现了该模式的形成思路,所以更加贴近我们的实际问题,从而会有效的 指导我们的重构历程。本文将通过一个实例来展示这个过程。
  在本文中对例子进行了简化,这样做是为了突出问题的实质并且会使我们的思路更加清晰。思路本身才是最重要、最根本的,简化了的例子不会降低我们所展示的思路、方法的适用性。
  2、问题描述
  一个完善的软件系统,必须要对出现的错误进行相应的处理,只有这样才能使系统足够的健壮,我准备以软件系统中对于错误的处理为例,来展示我所使用的思路、方法。
  在一个分布式的网管系统中,一个操作往往不会一定成功,常常会因为这样或者那样的原因失败,此时我们就要根据失败的原因相应的处理,使错误的影响局限在最小的范围内,最好能够恢复而不影响系统的正常运行,还有一点很重要,那就是在对错误进行处理的同时,一定不要忘记通知系统的管理者,因为只有管 理者才有能力对错误进行进一步的分析,从而查找出错误的根源,从根本上解决错误。
  下面我就从错误处理的通告管理者部分入手,开始我们的旅程。假定一个在一个分布式环境中访问数据库的操作,那么就有可能因为通信的原因或者数据库本身的原因失败,此时我们要通过用户界面来通知管理者发生的错误。简化了的代码示例如下:
   /* 错误码定义 */
    class ErrorConstant
    {
        public static final int ERROR_DBACCESS     =  100;
        public static final int ERROR_COMMUNICATION  =  101;
    }
    /* 省略了用户界面中的其他的功能 */
    class GUISys    {
        public void announceError(int errCode) {
            switch(errCode) {
            case ErrorConstant.ERROR_DBACCESS:                /* 通告管理者数据库访问错误的发生*/
            break;
            case ErrorConstant.ERROR_COMMUNICATION:
                /* 通告管理者通信错误的发生*/
            break;
            }
        }
    }
  开始,这段代码工作的很好,能够完成我们需要的功能。但是这段代码缺少相应的弹性,很难适应需求的变化。
  3、问题分析  熟悉面向对象的读者很快就会发现上面的代码是典型的结构化的方法,结构化的方法是 以具体的功能为核心来组织程序的结构,它的封装度仅为1级,即仅有对于特定的功能的封装(函数)。这使得结构化的方法很难适应需求的变化,面向对象的方法 正是在这一点上优于结构化的方法。在面向对象领域,是以对象来组成程序结构的,一个对象有自己的职责,通过对象间的交互来完成系统的功能,这使得它的封装 度至少为2级,即封装了为完成自己职责的方法和数据。另外面向对象的方法还支持更高层次的封装,比如:通过对于不同的具体对象的共同的概念行为进行描述, 我们可以达到3级的封装度- 抽象的类(在Java中就是接口)。封装的层次越高,抽象的层次就越高,使得设计、代码有越高的弹性,越容易适应变化。
  考虑对上一节中的代码,如果在系统的开发过程中发现需要对一种新的错误进行处理,比如:用户认证错误,我们该如何做使得我们的系统能够增加对于此项功能的需求呢?一种比较简单、直接的做法就是在增加一条用来处理此项错误的case语句。是的,这种方法的确能够工作,但是这样做是要付出代价的。
  首先,随着系统的进一步开发,可能会出现更多的错误类型,那么就会导致对于错误的处理部分代码冗长,不利于维护。其次,也是最根本的一点,修改已经能够工作的代码,很容易引入错误,并且在很多的情况下,错误都是在不经意下引入的,对于这种类型的错误很难定位。有调查表明,我们在开发过程中,用于 修正错误的时间并不多,大部分的时间是在调试、发现错误。在面向对象领域,有一个很著名的原则:OCP(Open-Closed Principle),它的核心含意是:一个好的设计应该能够容纳新的功能需求的增加,但是增加的方式不是通过修改又有的模块(类),而是通过增加新的模 块(类)来完成的。如果一个设计能够遵循OCP,那么就能够有效的避免上述的问题。
  要是一个设计能够符合OCP原则,就要求我们在进行设计时不能简单的以功能为核心。要实现OCP的关键是抽象,抽象表征了一个固定的行为,但是 对于这个行为可以有很多个不同的具体实现方法。通过抽象,我们就可以用一个固定的抽象的概念来代替那些容易变化的数量众多的具体的概念,并且使得原来依赖 于哪些容易变化的概念的模块,依赖于这个固定的抽象的概念,这样的结果就是:系统新的需求的增加,仅仅会引起具体的概念的增加,而不会影响依赖于具体概念的抽象体的其他模块。在实现的层面上,抽象体是通过抽象类来描述的,在Java中是接口(interface)。
  既然知道了问题的本质以及相应的解决方法,下面就来改善我们的代码结构。
 4、初步方案  让我们重新审视代码,看看该如何进行抽象。在错误处理中,需要处理不同类型的错 误,每个具体的错误具有特定于自己本身的一些信息,但是它们在概念层面上又是一致的,比如:都可以通过特定的方法接口获取自已内部的错误信息,每一个错误 都有自己的处理方法。由此可以得到一个初步的方案:可以定义一个抽象的错误基类,在这个基类里面定义一些在概念上适用于所有不同的具体错误的方法,每个具 体的错误可以有自己的不同的对于这些方法的实现。代码示例如下:
//错误信息应该和错误处理放在一个类中吗
interface ErrorBase
{    public void handle()
    public String getInfo();
}  
class DBAccessError implements ErrorBase
{
    public void handle() {/* 进行关于数据库访问错误的处理 */ }
    public String getInfo() {/* 构造返回关于数据库访问错误的信息 */ }
}
class CommunicationError implements ErrorBase
{
    public void handle() { /* 进行关于通信错误的处理 */ }
    public String getInfo() {/* 构造返回关于通信错误的信息 */ }
}  
  这样,我们就可以在错误发生处,构造一个实际的错误对象,并以ErrorBase引用它。然后,交给给错误处理模块,此时错误处理模块就仅仅知道一个类型ErrorBase,而无需知道每一个具体的错误类型,这样就可以使用统一的方式来处理错误了。代码示例如下:
class GUISys
{
    public void announceError(ErrorBase error)
  {
         /* 使用一致的方式处理错误 */
         error.handle();
     }
 }  
  可以看出,对于新的错误类型的增加,仅仅需要增加一个具体的错误类,对于错误处理部分没有任何影响。看上去很完美,也符合OCP原则,但是进一步分析就会发现,这个方案一样存在着问题,我们将在下一个小节进行详细的说明。
  5、进一步分析  上一个小节给出了一个方案,对于只有GUISys这一个错误处理者是很完美的, 但是情况往往不是这样的。前面也曾经提到过,对于发生的错误,除了要通知系统的使用者外,还要进行其他的处理,比如:试图恢复,记如日志等。可以看出,这 些处理方法和把错误通告给使用者是非常不同的,完全没有办法仅仅用一个handle方法来统一所有的不同的处理。但是,如果我们在ErrorBase中增 加不同的处理方法声明,在具体的错误类中,根据自身的需要来相应的实现这些方法,好像也是一个不错的方案。代码示例如下:
interface ErrorBase
{
     public void guiHandle();
     public void logHandle();
}
class DBAccessError implements ErrorBase
{    public void guiHandle() { /* 通知用户界面的数据库访问错误处理 */ }
 public void logHandle() { /* 通知日志系统的数据库访问错误处理 */ }
 }
class CommunicationError implements ErrorBase{
    public void guiHandle() { /* 通知用户界面的通信错误处理 */ }
 public void logHandle() {/* 通知日志系统的通信错误处理 */ }
}
class GUISys{
    public void announceError(ErrorBase error)
 {
         error.guiHandle();
  }
}
class LogSys
{
    public void announceError(ErrorBase error)
  {
         error.logHandle();    }
}  
  读者可能已经注意到,这种做法其实也不是十分符合OCP,虽然它把变化局限在ErrorBase这个类层次架构中,但是增加新的处理方法,还是 更改了已经存在的ErrorBase类。[其实没有完全的OCP实现,只是要找到容易变化的地方将它封装就OK了]其实,这种设计方法,还违反了另外一个著名的面向对象的设计原则:SRP(Single Responsibility Principle)。这个原则的核心含意是:一个类应该有且仅有一个职责。关于职责的含意,面向对象大师Robert.C Martin有一个著名的定义:所谓一个类的职责是指引起该类变化的原因,如果一个类具有一个以上的职责,那么就会有多个不同的原因引起该类变化,其实就 是耦合了多个互不相关的职责,就会降低这个类的内聚性。错误类的职责就是,保存和自己相关的错误状态,并且提供方法用于获取这些状态。上面的设计中把不同 的处理方法也放到错误类中,从而增加了错误类的职责,这样即使和错误类本身没有关系的对于错误处理方式的变化,增加、修改都会导致错误类的修改。这种设计 方法一样会在需求变化时,带来没有预料到的问题。那么能否将对错误的处理方法从中剥离出来呢?如果读者比较熟悉设计模式(这里的熟悉是指,设计模式的意 图、动机,而不是指怎样去实现一个具体的设计模式),应该会隐隐约约感觉到一个更好的设计方案即将出现。
  6、设计模式浮出水面  让我们对问题重新描述一下:我们已经有了一个关于错误的类层次结构,现在 我们需要在不改变这个类层次结构的前提下允许我们增加对于这个类层次的新的处理方法。听起来很耳熟吧,不错,这正是过于visitor设计模式的意图的描 述。[偶开始觉得command模式也合适,ErrorBase就是Command体系,它的接收者,也就是它的处理者ErrorHandler就是Listen体系,不过command模式重在对请求排队,并可以保存处理前后的信息,方便撤消与重作,所以这里没必要使用Command。并且它的发起者也不明显。]通过对于该模式动机的分析,我们很容易知道,要想使用visitor模式,需要定义两个类层次:一个对应于接收操作的元素的类层次(就是我们的错误 类),另一个对应于定义对元素的操作的访问者(就是我们的对于错误的不同处理方法)。这样,我们就转换了问题视角,即把需要不同的错误处理方法的问题转变 为需要不同的错误处理类,这样的结果就是我们可以通过增加新的模块(类)来增加新的错误处理方法,而不是通过增加新的错误处理方法(这样做,就势必要修改 已经存在的类)。
  一旦到了这一部,下面的工作就比较简单了,因为visitor模式已经为我们搭建了一个设计的上下文,此时我们就可以关注visitor模式的 实现部分来指导我们下面的具体实现了。下面仅仅给出最终的程序结构的UML图以及代码示例,其中忽略了错误类中的属于错误本身的方法,各个具体的错误处理 方法通过这些方法和具体的错误类对象交互,来完成各自的处理功能。

                                    最终的设计的程序结构图
最终的代码示例
interface ErrorBase
{
    public void handle(ErrorHandler handler);
}
class DBError implements ErrorBase{
    public void handle(ErrorHandler handler)
 {
         handler.handle(this);
     }}
class CommError implements ErrorBase{
    public void handle(ErrorHandler handler)
    {
         handler.handle(this);
      }
 }
interface ErrorHandler
{
 public void handle(DBrror dbError);
 public void handle(CommError commError);
}
class GUISys implements ErrorHandler
{
       public void announceError(ErrorBase error)
 {
            error.handle(this);
 }
 public void handle(DBError dbError)
 {
  /* 通知用户界面进行有关数据库错误的处理 */
       }
 public void handle(CommError commError)
 {
             /* 通知用户界面进行有关通信错误的处理 */        }
}
class LogSys implements ErrorHandler{
    public void announceError(ErrorBase error)
    {
        error.handle(this);
    }
 public void handle(DBError dbError)
  {
            /* 通知日志系统进行有关数据库错误的处理 */
   }
 public void handle(CommError commError)
  {
           /* 通知日志系统进行有关通信错误的处理 */     }
}
  7、结论  设计模式并不仅仅是一个有关特定问题的解决方案这个结果,它的意图以及它的动机往往更重要,因为一旦我们理解了一个设计模式的意图、动机,那么在设计过程中,就很容易的发现适用于我们自己的设计模式,从而大大简化设计工作,并且可以得到一个比较理想的设计方案。
  另外,在学习设计模式的过程中,应该更加注意设计模式背后的东西,即具体设计模式所共有的的一些优秀的指导原则,这些原则在 参考文献[1]的第一章中有详细的论述,基本上有两点:  
★ 发现变化,封装变化  
★ 优先使用组合(Composition),而不是继承
  如果注意从这些方面来学习、理解设计模式,就会得到一些比单个具体设计模式本身更有用的知识,并且即使在没有现成模式可用的情况下,我们也一样可以设计出一个好的系统来。 

 
 在本系列的第一篇文 章中,描述了如何通过设计模式来指导我们的程序重构过程,并且着重介绍了设计模式意图、动机的重要性。在本文中我们将继续上篇文章进行讨论,这次主要着重 于设计模式的适用性,对于设计模式适用性的掌握有助于从另一个不同的方面来判断一个设计模式是否真正适用于我们的实际问题,从而做出明智的选择。
  1、回顾  在上一篇文章中,我们给出了一个使用设计模式来改善程序结构的例子,着重介绍了设计模式的意图、动机在我们程序重构过程中的指导作用。
  现在,我们将关注设计模式的另一个重要方面:设计模式的适用性。解决同一个问题一般会有多种方案或者模式,但是这些模式所关注的是同一个问题的 不同方面,解决不同的需求,有各自的优点和限制,各有各的解决之道。这就要求我们在选择设计模式时,对我们自己的问题有很好的理解:我们的需求是什么,我 们要克服什么样的限制,我们要获得什么样的特性等等。然后,可以看看我们想使用的解决问题的设计模式是否适用于我们的问题,如果不适用,是否可以使用其他 的模式来弥补,是否可以对这个设计模式进行改进使它符合我们的要求。
  本文下面的部分,我们将对上一篇文章中的最终解决方案进行进一步的分析,来看看它到底满足了我们什么样的需求,又暴露了什么样的不足,最后我们会给出一个更为符合要求的解决方案。
  2、问题描述  在上一篇文章中, 我们对一个网管系统中的错误处理部分的代码进行了重构,最终使用了一个visitor设计模式解决了我们的问题。细心的读者肯定会发现,这个最终方案一样 存在着一个问题:如果错误的类型不是固定不变的,而是随着系统的进展不断增加的[找变化点],会有什么结果呢?让我们首先来看看上一篇文章中最终的类图:

                     
   在这个类图中,我增加了几条依赖线,即ErrorHandler是依赖于DbError和CommError的。此时我们可以看到: ErrorBase依赖于ErrorHandler,ErrorHandler依赖于ErrorBase的派生类(DbError和 CommError),而ErrorBase的派生类又要依赖于ErrorBase本身。这就形成了一个循环的依赖过程,这样的结果就是 ErrorBase传递地依赖于它的所有派生类。
  这种循环依赖关系会带来严重的问题,一旦ErrorBase新增了一个派生类,那么ErrorHandler类必须要被修改,由于 ErrorBase又依赖于ErrorHandler,那么依赖于ErrorBase的所有类都需要重新编译。这就意味着ErrorBase的所有派生类 以及所有这些派生类的使用者都要重新编译,这种大规模的重新编译在开发一个分布式系统时会导致非常大的工作量,因为要重新分布每一个重新编译过的类,如果 在重新分布时出现一些差错(如:忘记替换一些类),就会导致微妙的错误,而且很不容易查找出来。
  另外,在该模式中存在一个假设,就是默认任意一个错误处理者要处理所有的错误类型,这个假设在某些情况下是不成立的,比如:如果对于 LogicError我们不打算通知LOGSys进行处理会怎样呢?我们不得不要写一个处理该错误的空函数(当然你可以在ErrorHandler中写一 个缺省的实现)。如果ErrorBase的类层次架构越来越大,并且它们要求的处理方法也有很多的不同,就会导致ErrorHandler接口中的方法集 庞大,并且任何一个ErrorBase的派生类的改变,都会导致大规模的重新编译(甚至是毫无关系的类也要重新编译),重新分布,如果这种改变比较频繁, 结果当然是无法忍受的。
  3、问题分析  上述的问题描述暴露了visitor模式的一些使用限制,即它仅仅适用于哪些受访 问的类层次架构比较固定的情况,导致这样的原因可以使用一个著名的面向对象设计原则来解释,这个原则就是:DIP(Dependence Inversion Principle),这个原则的核心含义是:高层模块不应该直接依赖于低层模块,所有的模块都要依赖于抽象。也就是说:容易变化的东西一定要依赖于不容 易变化的东西,因为抽象的东西最不容易变化,具体的东西容易变化,所以具体应该依赖于抽象。而在visitor模式中,ErrorHandler依赖于 ErrorBase的所有的具体的派生类,并且如果这些派生类容易变化的话,就会导致不能接受的结果。
  通过上面的分析,可以看出打断这个循环依赖的环是克服visitor模式适用范围限制的关键。
  4、解决方案  在上述的循环依赖关系中,有两段依赖关系是无法打断的,一段是ErrorBase 的所有派生类对于ErrorBase的依赖,一段是ErrorBase对于ErrorHandler的依赖,并且这两段依赖关系也是符合DIP的,那么对 于仅剩的一段依赖关系我们是必须要打断的了,这段关系就是,ErrorHandler对于ErrorBase所有派生类的依赖,并且这段关系也是违反 DIP的。如果我们不让ErrorHandler知道ErrorBase的派生类,那么怎样才能够针对每一个具体的ErrorBase的派生类进行相应的 处理呢?
  面向对象大师Robert C. Martin给出了一个优雅的解决方案,他所采用的技巧是OO方法所建议避免使用的,RTTI(运行时类型鉴别)。可见如果RTTI运用的得当,一样可以 得到很好的设计方案,并且还能够克服一些OO中多态的方法解决不了的问题(当然如果使用多态能够解决的问题,推荐还是使用多态的方法进行解决)。让我们先 看看这个解决方案的类图:


  通过上图可以看出,ErrorHandler中没有任何方法了,已经退化为一个空的接口,所以也就不可能再依赖于任何ErrorBase的派生 类了。和visitor模式不同,这个方案中针对每一个特定的ErrorBase的派生类定义一个相应的处理接口,在每个派生类的handle方法实现 中,运用RTTI技术进行相应的类型转换(把ErrorHandler转换为自己对应的错误处理接口,比如:在DbError的handle方法中,就把 ErrorHandler转换为DbErrorHandler。Java在这方面做得不错,可以进行比较安全的类型转换),想要处理该错误的实体不仅要实 现ErrorHandler接口,而且还要实现相应的针对具体错误类的处理接口,比如:GUISys就实现了三个接口(ErrorHandler、 DBErrorHandler以及CommErrorHandler),从而也就实现了对DbError和CommError的处理。
  这种方法打断了类之间的循环依赖关系,使得增加新的错误类型变得容易[GUISys,LOGsys一样也要改变],并且也避免了可能出现的大规模的重新编译以及类的重新分布。并且,你也可 以有选择地进行错误的处理,比如:如果GUISys不想处理DbError的话,很简单,不要实现DbErrorHandler接口即可,使得程序的结构 非常清晰。可见这个方案克服了原始的visitor设计模式的不足。
  通过对比上下两个类图可以看出,下面的要比上面的复杂,这也是该方案的一个缺点。如果问题的规模不大,重新编译以及重新分布没有多少工作量的 话,还是可以使用原始的visitor模式的。仅仅当问题的规模扩大到visitor模式不能适用的地步时,可以考虑使用该方案。另外,由于改进的方案中 使用了RTTI技术,会导致性能上的损失以及不可预测性,在使用时也要特别注意。
  我们做如下的类比以便于更好地理解原始的visitor模式和改进后的方案。原始的visitor模式好像一个矩阵,在X方向上是一个个具体的 错误类型,在Y方向上是一个个可以处理错误的实体,每一个交叉点上是具体的处理方法,矩阵中的每一个位置上都必须有一个处理方法。而改进后的方案象一个稀 疏矩阵,仅仅在需要的位置上才有具体的处理方法,从而减少了很多冗余。
下面给出关键的代码片断:
interface ErrorBase{
    public void handle(ErrorHandler handler);
}
class DBError implements ErrorBase{
    public void handle(ErrorHandler handler)
   {
        try
          {
              DbErrorHandler dbHandler = (DbErrorHandler)handler;
              dbHandler.handle(this);
           }
        catch(ClassCastException e)
        {
        }
    }
}
class CommError implements ErrorBase
{
    public void handle(ErrorHandler handler) {
        try
       {
            CommErrorHandler commHandler = (CommErrorHandler)handler;
            commHandler.handle(this);
         }
        catch(ClassCastException e)
        {
        }
    }
}
interface ErrorHandler
{
}
interface DbErrorHandler
{
    public void handle(DBrror dbError);
}
interface CommErrorHandler
{
    public void handle(CommError commError);
}
class GUISys implements ErrorHandler, DbErrorHandler, CommErrorHandler
{
    public void announceError(ErrorBase error) {
        error.handle(this);
    }
    public void handle(DBError dbError) {
   /* 通知用户界面进行有关数据库错误的处理 */           }
    public void handle(CommError commError) {
      /* 通知用户界面进行有关通信错误的处理 */           }
}
class LogSys implements ErrorHandler, DbErrorHandler, CommErrorHandler
{    public void announceError(ErrorBase error) {
        error.handle(this);
    }
    public void handle(DBError dbError) {
          /* 通知日志系统进行有关数据库错误的处理 */      
    }
 public void handle(CommError commError) {
   /* 通知日志系统进行有关通信错误的处理 */      
     }}
 这样设计的结果是,如果增加一个ErrorBase的子类,需要重新编译的类是ErrorHandle的所有子类。而不是所有的类了。这里遵守了DIP原则。高层定义一个接口让低层实现,这个接口就是DbErrorHandle和CommErrorHandle。
  5、结论  本文从另一个方面,设计模式的适用性,探讨了在进行程序重构时该如何选择重构的目标, 以及如何对现有的设计模式进行改进使之符合我们的目标。本文的主要目的是想说明,在进行设计模式的选择时,不仅要关注设计模式是解决什么问题的,还要关注 使用该设计模式解决问题时会受到什么约束和限制。这样就可以帮助你更好地理解问题,做出合理的取舍,或者你可以根据自己的需求对已有的设计模式进行修改使 之满足你的要求,如果你这样做了,你就很可能创造出了一种新的设计模式。
 设计模式在某种程度上确实能够改善我们的程序结构,使设计具有更好的弹性。也正是由于这个原因,会导致我们可能过度的使用它。程序结构具有过度的、 不必要的灵活性和程序结构没有灵活性一样都是有害的。本文将分析过度的灵活性可能造成的危害,并且结合一些实例来阐述使用设计模式改善程序结构应遵循的原则。
  
1、介绍  本系列文章的前两篇主要讲述了如何使用设计模式来改善我们的程序结构,大家可以看到经过调整的代码具有了更大的弹性,更容易适应变化。读者朋友可能也具有类似的经验,通过使用设计模式使得自己的软件系统更加具有可扩展性和健壮性。但是,这 样就可能会造成一个结果:无论遇到任何问题,我们首先做的就是设法找到一个解决它的设计模式来,而不是解决问题的最简洁的方法。
  上面所述的就是过分使用设计模式的情况,它赋予了代码过度的灵活性。大家往往对于僵化、拙劣的设计所导致的危害非常清楚,但是对于过度灵活的设计可能带来的危害却不是很重视。本文试图从这个角度来谈谈使用设计模式改善程序结构应遵循的原则,使大家避免陷入过分使用设计模式的状况。其中的一个关键 议题就是:我们为什么要使用设计模式,到底什么样的程序结构才是好的。
  2、过分设计的危害  正是由于大家对于僵化的设计所造成的结果的恐惧,以及对于设计模式给我们的程序结构带来的无比的弹性的赞叹,才会导致过分的预先设计(up-front design)。原因很简单:需求肯定是要变化的。所以,我们就需要给代码一些更多的灵活性,使得它可以适应以后的变化。于是,我们在最开始的设计中,就 针对需求的变化做了很多的假设,并把对于这些假设的支持放在代码中。
  如果对这些假设的预测是正确的,那么做的这一切都是值得的。不幸的是,对这些假设的预测很难是正确的。原因很简单:需求是我们的客户(一般是另 外一个企业)提出的,但是作为一个现代的企业,要想生存,就要不断的改变自己以适应日新月异的变化,所以客户的需求肯定是要根据自身生存、发展的需要而不 断变化的,并且这些变化都是很难预测的,常常是客户自己都不知道下一步该如何变化(如果都能够被你预测到的话,这个公司肯定会高薪聘请你去做他们的 CEO)。
  如果预测是错误的话,第一个直接后果就是,浪费了宝贵的时间、资金。我们花了很多的时间在一些根本没有任何用处的灵活性上,而这些时间本可以用来为系统增加新的功能或者修正错误。
  过分灵活的代码往往更加复杂、难以理解。其他的开发人员不得不花费很多的时间来理解这些本来可以去除的复杂性。必然导致代码的维护、扩展困难(如果需求的变化和你的假设不同),项目的开发效率降低。
  例如:发现一种计算有多个不同的方式, 不加思索就直接采用Strategy设计模式,而不是采用简单、清楚的条件表达式的方法(if-else语句),那么就会导致结构的复杂(要增加好几个 类)。如果后来发现根本就没有在增加新的计算方法方面的需求,或者更糟糕的是需求的变化是某几个计算策略间要增加依赖关系,那么修改起来就会十分困难。系 统中如果存在太多的这种没有必要的灵活性,很可能最终的程序结构就会陷入冗余、混乱之中。
  程序结构的灵活性是有代价的,这种代价往往是更多的复杂性或者造成系统不容易理解,需要我们在设计时进行权衡。
  3、软件开发的节奏  现在在软件工程领域很活跃的一个组织是:敏捷社团,他们提出了一系列的敏捷 方法(XP就是其中很著名的一种)。在敏捷方法中制定了一系列的策略、实践来拥抱需求的变化。其目标是:使软件以规范的节奏进展,最终保质、按时交付软件 产品。现在已有很多使用这种方法成功的商业案例。
  对于软件开发的节奏可以描述如下:首先写测试代码,提出对于系统的功能需求,然后写工作代码满足这个需求,重复这个过程直到实现系统的所有需 求。在这个过程中间要频繁的(一般是在增加新功能或者修正错误时)进行重构,去除冗余的、含糊的代码,改善程序的结构,使得新功能的添加变得容易。可以看 出在这样的软件开发节奏中,没有对需求的变化做什么预测(进行预测的主要原因是恐惧变化),而是以一种主动的姿态来拥抱变化,当目前的设计不能够适应发生 的变化时,大胆的进行重构(因为有频繁测试保证,所以重构的风险是不大的)去适应新的变化。这种方式被称为演化设计(evolutionary design),在参考文献〔3〕中可以得到进一步的内容。
  设计模式一般会成为重构的目标,但是为什么要这样做?我们一定要重构到一个设计模式吗?怎样的程序结构才是好的呢?
  4、关键是要展现设计意图  现在有一个普遍的误解就是:程序结构的灵活性越高越好,所以对程序结 构改善的目标就是使它具有更高的弹性,这样在未来需求发生变化时可以很容易的改变程序来适应这种变化。其实,结构的灵活性和结构的易更改性之间是有矛盾 的。很明显,结构越灵活相应的就会越复杂,越复杂就越不容易理解,不容易理解怎么会容易更改呢?参考文献〔1〕中专门探讨了这个问题,有兴趣可以看看。
  正是这个误解的存在,使得很多开发者看到了设计模式所带给程序结构的灵活性,从而在进行代码重构时就把结构的灵活性作为一个最重要的目标。最终导致了程序结构的过分灵活性,损伤了软件的质量。
  但是,设计模式确实能够改善我们的程序结构,面向对象大师Martin Fowler的经典著作《Refactoring Improving the Design of Existing Code》一书中就有很多的使用设计模式进行重构的例子。难道仅仅是因为设计模式能够带来足够的灵活性吗?显然不是!主要是因为这些设计模式更能够展示设 计者的设计意图,更加便于理解!
  很多面向对象专家和模式研究专家对重构的动机进行了研究,发现对于一个好的重构过程来说,重构的结果到底是不是一个设计模式是不重要的。它们的最终动机都是:减少或者消除冗余代码,简化设计最终达到展示真正的设计意图,更加便于交流。
  Martin Fowler的《Refactoring》一书的第一章有一个关于影碟出租的例子,详细的展示了重构的过程以及每一步的动机,很好的说明了上面的问题。
  5、再谈设计模式的动机  本系列文章的第一篇中谈论设计模式本身的意图、动机的重要性。这里我想再结合上面的内容重新认识一下这个问题。现在我们有两个动机存在,设计模式本身的动机以及我们要重构来改善我们的程序结构的动机(可能是要重构到一个设计模式)。这两个动机其实是没有很大的关系的。
  设计模式本身的动机往往是领域无关的,但是我们重构到设计模式的动机却往往是领域相关的。因为我们重构的主要目标是要达到能够很好的展示我们的设计意图,而这些设计意图往往和问题领域的上下文关系密切。
  比如:Factory Method设计模式的意图是定义一个创建对象的接口,让子类来确定到底实例化哪个类,Factory Method方法使得一个类的实例化时机延迟到子类中。但是我们在重构的过程中何时决定使用Factory Method设计模式呢?基本上是因为一个类的构造方式有多种,每一种都有不同的含义,但是构造函数的名字是唯一的,通过同样的构造函数名,赋予不同的参 数的方法来实例化对象的方式,使得使用者很难理解这个构造函数的真正含义。所以通过使用Factory Method设计模式,定义不同的用于展示具体意图的函数名称来实例化对象,就更加能够展示使用者的意图。另外,该模式还简化了该类的构造方式,封装了该 类的实例化细节。
  参考文献〔2〕中有很多这方面的实例,大家可以下载下来阅读一下,相信会有更大的收获。
  6、结论  本文结合设计模式探讨了在进行程序结构改善的过程中,容易造成的误解:即过分的关注程 序结构的灵活性。这样做很容易从是否能够给设计带来灵活性的角度来进行程序结构的重构,最终可能造成系统的复杂、混乱、不易理解、难以维护,从而导致项目 失败。其实一个好的程序结构根本不在于其中使用了多少设计模式、多么具有弹性,主要在于该结构是否能够非常清晰的展现设计者的意图(可以参考参考文献 〔1〕)。由于在很多情况下,通过引入设计模式可以很好的做到这一点,所以设计模式往往会成为重构的目标,但是有一点要记住:不要过早的引入设计模式,要 让它在重构的过程中自然浮现出来。
  参考资料  [1] To Be Explicit,Martin Fowler, IEEE Software Vol.18 No.6
  [2] Refactoring To Patterns, Joshua Kerievsky, industriallogic.com
  [3] Is Design Dead, Martin Fowler, www.martinfowler.com

posted on 2005-12-19 11:15  SweetDream  阅读(286)  评论(0编辑  收藏  举报