敏捷软件开发原则,模式与实践书摘

敏捷软件开发原则,模式与实践书摘。

拙劣设计的症状:

  • 僵化性(Rigidity):很难对系统进行改动,因为每个改动都会迫使许多对系统其它部分的其它改动
  • 脆弱性(Fragility):对系统的改动会导致系统中和改动的地方在概念上无关的许多地方出现问题
  • 牢固性(Immobility):很难解开系统的纠结,使之成为一些可在其他系统中重用的组件
  • 粘滞性(Viscosity):做正确的事情比做错误的事情要困难
  • 不必要的复杂性(Needless Complexity):设计中包含不具任何直接好处的基础结构
  • 不必要的重复(Needless Repetition):设计中包含有重复的结构,而该重复的结构本可以使用单一的抽象进行统一
  • 晦涩性(Opacity):很难阅读,理解。没有很好地表现出意图

面向对象设计原则:

  • 单一职责原则(The Single Responsibility Principle, 简称SRP)
    • 就一个类而言,应该仅有一个引起它变化的原因(一个类一个职责)
    • 例子1:有两个不同的应用程序使用Rectangle类。一个有关计算几何学方面的,Rectangle类会在几何形状计算方面为它提供帮助,它从来不会在屏幕上绘制矩形。另外一个应用程序实质上是有关图形绘制方面的,它可能也会进行一些计算几何方面的工作,但是它肯定在屏幕上绘制矩形。

    敏捷软件开发原则,模式与实践书摘_第1张图片
                                                                    例子1:图示

    • 例子2:Modem接口显示出两个职责。第一个职责是连接管理;第二个职责是数据通信。dial和hangup函数进行调制解调器的连接处理,而send和recv函数进行数据通信
      interface Modem
      {
        public void dial(String pno);
        public void hangup();
        public void sned(char c):
        public void recv();
      }
      这两个职责应该被分开吗?这依赖于应用程序的变化方式。如果应用程序的变化会影响连接函数的签名(signature),那么这个设计就具有僵化的臭味,因为调用send和recv的类必须重新编译。在这种情况下,这两个职责应该被分离,如下图所示。
      另一方面,如果应用程序的变化方式总是导致这两个职责同时变化,那么就不应该分离它们。实际上,分离它们就会具有不必要的复杂性臭味
      敏捷软件开发原则,模式与实践书摘_第2张图片
                                                例子2:图示
    • 例子3:下图展示了一种常见的违反SRP的情形。Employee类包含了业务规则和对于持久化的控制。这两个职责在大多数情况下绝不应该混合在一起。业务规则往往会频繁的变化,而持久化的方式却不会如此频繁的变化,并且变化的原因也是完全不同的。把业务规则和持久化子系统绑定在一起的做法是自讨苦吃。
      敏捷软件开发原则,模式与实践书摘_第3张图片
                                          例子3:图示
  • 开放——封闭原则(The Open-Close Pinciple, 简称OCP)
    • 软件实体(类,模块,函数等等)应该是可以扩展的,但是不可修改的。如果程序中的一处改动就会产生连锁反应,导致一系列相关模块的改动,那么设计就具有僵化性的臭味。OCP建议我们应该对系统进行重构,这样以后对系统再进行那样的改动时,就不会导致更多的修改。如果正确地应用OCP,那么以后再进行同样的改动时,就只需要添加新的代码,而不必改动已经正常运行的代码。
      遵循open-close原则设计的模块具有两个主要的特征。它们是:
      1. “对于扩展是开放的”(Open for extension)
        这意味着模块的行为是可以扩展的。当应用的需求改变时,我们可以对模块进行扩展,是其具有满足那些改变的新行为。换句话说,我们可以改变模块的功能。
      2. “对于更改时封闭的”(Close for modification)
        对模块行为进行扩展时,不必改动模块的源代码或者二进制代码。模块的二进制可执行版本,无论是可链接库,DLL或Java的.jar文件,都无需改动。
      3. 如何做到以上两点,关键是“抽象”。模块应该选择依赖于抽象,而不是实现。模块依赖于一个抽象体,所以它对于更改时关闭的。同时,通过从这个抽象体派生,也可以扩展此模块的行为。但请注意,in reality,模块很难做到100%的封闭,一般都会存在一些无法对其封闭的变化(没有对所有情况都贴切的模型)。此时就必须有策略地对待这个问题。也就是说设计人员必须对于他设计的模块应该对哪些变化封闭作出选择。他必须先猜测出最有可能发生的变化种类(HOW? 测试驱动或许是个好的开始),然后构造抽象来隔离那些变化(记住,抽象是为了哪些易变的东西(用抽象隔离变化))。另外一种经常使用的方法是使用hook(C语言常用, 至少对我来说是这样的)技术,在我们认为可能发生变化的地方install hook。然而我们放置的hook常常是错误的。更糟糕的是,即使不使用这些hook,也必须去支持和维护它们(这点确实是深有体会),从而就具有了不必要的复杂性味道。这不是什么好事,因为我们不希望设计背着许多不必要的抽象。通常,我们更愿意一直等到确实需要那些抽象时再把它放置进去。
  • Liskov替换原则(The Liskov Subsitution Principle, 简称LSP)
    • 字类型(subtype)必须能够替换掉它们的基类型(base type)。
    • 什么时候知道我们自己违反了LSP?
      1. 假设有一个函数f,它的参数为指向某个基类B的指针或引用。假设有B的某个派生类D,如果把D的对象作为B类型传递给f,会导致f出现错误的行为。那么D就违反了LSP。显然,D对于f来说是脆弱的。
      2. 如果我们code里面显示地使用RTTI方式去确定一个对象的类型,然后用if/else做dispatch(有一部分code必须知道所有可能的类型,将来新增类型是,这处code也必须modify),这有可能是一个明显违反OCP的例子(Double dispatching有可能解决这种问题)。
      3. 如果我们试图写一个以某种方式从其基类中去除功能的派生类(完成的功能少于其基类的派生类通常是不能替换其基类的),这暗示这我们在违反LSP。
      4. 如果我们试图在派生类的方法中添加其基类不会抛出的异常,那么我们正在违反LSP。
    • 例子1:有一个third party的类库,包含有一些容器类,这些容器有两个Set类的变体。第一个变体是“有限的(bounded)”,基于数组实现。第二个变体是“无限的”,基于链表实现。不希望自己的程序代码依赖于这些容器类,因为以后会用更好的来替换它们。因此,可以把它们包装在自己的抽象接口之下。如下图1所示。问题:如果在该层次中加入PersistentSet,但是这个类不是模板类,只接受虚基类PersistentObject的派生对象,于是创建了图2所示的类层次。注意,PersitentSet包含了一个ThirdParty 持久性集合的实例,它把它的所有方法都delegate给该实例。这样,如果调用了PersistentSet的Add方法,它就简单地把该调用委托给ThirdParty的持久性集合中包含的方法。表面看起来,好像没有什么问题。其实隐藏着一个别扭的设计问题。加入到ThirdParty持久性集合中的元素必须得从PersistentObject派生。由于PersistentSet只是把调用委托给ThirdParty持久性集合,所以任何要加入PersistentSet的元素也必须得从PersistentObject派生。可以,Set接口没有这样的限制。从程序代码示例可以看出,任何客户企图向PersistentSet中添加不是从PersistentObject派生的对象,将会发生运行时错误(这个例子也间接地暗示我们,使用了dynamic_cast的地方或多或少的可能存在设计缺陷)。dynamic_cast会抛出bad_cast异常。但是抽象基类Set的所有现存的客户都不会预计到调用Add时抛出异常。由于Set的派生类会导致这些函数出现错误,所以对类层次的这种改动违反了LSP。

      • 解决方案1:通过约定(convention)的方式解决。约定不让PersistentSet和PersistentObject暴露给整个应用程序。它们只被一个特定的模块使用。该模块负责从持久性存储设备读出所有容器,也负责把所有容器写入到持久性存储设备。在写入容器时,该容器的内容先被复制到对应的PersistentObject的派生对象中,再加入到PersistentSets,然后存入流中。在从流中读入容器时,过程是相反的。先把信息从流读到PersistentSet中,再把PersistentObjects从PersistentSet中移出并复制到常规的(非持久化)对象中,然后再加入到常规的Set中。

      • 解决方案2:首先得承认PersistentSet和Set之间不是ISA的关系,它不应该派生自Set。因此可以分离这个层次结构,但不是完全的分离。Set和PersistentSet之间有一些公有的特性。事实上,仅仅是Add方法致使在LSP原则下出了问题。因此,可以创建一个层次结构,其中Set和PersistentSet是兄弟关系(siblings),统一在一个具有测试成员关系,遍历等操作的抽象接口之下,如例子1:图示3所示。这样可以对PersistentSet对象进行遍历以及测试成员关系等操作。但是它不能把不是派生自PersistentObject的对象加入到PersistentSet中。

    敏捷软件开发原则,模式与实践书摘_第4张图片
                                                     例子1: 图示1
    敏捷软件开发原则,模式与实践书摘_第5张图片
                                                         例子1:图示2
    程序示例:
    template<typename T>
    void PersistentSet::Add(const T& t)
    {
      PersistentObject& p = dynamic_cast<PersistentObject&>(t);
      itsThirdPartyPersistentSet.Add(p);
    }
    敏捷软件开发原则,模式与实践书摘_第6张图片

                                                                     例子1:图示3

    • 例子2:Line和LineSegment的例子。如代码段1。最初看到这两个类时,会觉得它们之间有自然的公有继承关系。LineSegment需要Line中的每一个成员变量和成员函数。此外,LineSegment新增了一个自己的成员函数GetLength,并override了IsOn函数。但是这两个类还是以微妙的方式违反了LSP。Line的使用者可以期望和该Line具有线性对应关系的所有点都在该Line上。例如由Intercept函数返回的点就是线和y轴的交点。由于这个点和线具有线性对于关系,所以Line的使用这可以期望IsOn(Intercept()) == true。然而,对于许多LineSegment的实例,这条声明会失效。这为什么是一个重要的问题呢?为什么不简单地让LineSegment从Line派生并忍受这个微妙的问题呢?这是一个需要进行判断的问题。在大多数情况下,接受一个多态行为中的微妙错误都不会比试着修改设计使之完全符合LSP更为有利。接受缺陷而不是去追求完美这是一个工程上的权衡问题。好的工程师知道何时接受缺陷比追求完美更有利。不过,不应该轻易放弃对于LSP的遵循。总是保证子类可以代替它的基类是一个有效的管理复杂性的方法。一旦放弃了这一点,就必须要单独来考虑每个子类。

      • 解决方案:用提取公共部分的方法代替继承。有一个简单的方案可以解决Line和LineSegment的问题,该方案也阐明了一个OOD的重要工具。如果既要使用类Line又要使用类LineSegment,那么可以把这两个类的公共部分提取出来作为一个抽象基类。如代码2所示。

      • 提取公共部分是一个设计工具,最好在代码不是很多的时候应用。当然,如果Line已经存在很多clients,那么提取出LinearObject就不会这么轻松(绝对需啊测试套件的支持)。不过在有可能时,它仍然是一个有效的工具。
        #ifndef GEOMETRY_LINE_H
        #define GEOMETRY_LINE_H
        #include "geometry/point.h"

        class Line
        {
        public:
          Line(const Point& p1, const Point& p2);

          double GetSlope() const;
          double GetIntercept() const;
          double GetP1() const;
          double GetP2() const;
          virtual bool IsOn(const Point&) const;
         
        private:
          Point itsP1;
          Point itsP2;
        };

        #endif

        //////////////////////////

        #ifndef GEOMETRY_LINESEGMENT_H
        #define GEOMETRY_LINESEGMENT_H

        class LineSegment: public Line
        {
        public:
          LineSegment(const Point& p1, const Point& p2);
          double GetLength() const;
          virtual bool IsOn(const Point&) const;
        };

        #endif

        代码段1 - 有问题的代码

      • #ifndef GEOMETRY_LINEAR_OBJECT_H
        #define GEOMETRY_LINEAR_OBJECT_H
        #include "geometry/point.h"

        class LinearObject
        {
        public:
          LinearObject(const Point& p1, const Point& p2);

          double GetSlope() const;
          double GetIntercept() const;
          double GetP1() const;
          double GetP2() const;
          virtual bool IsOn(const Point&) const = 0; // Abstract
         
        private:
          Point itsP1;
          Point itsP2;
        };

        #endif

        #ifndef GEOMETRY_LINE_H
        #define GEOMETRY_LINE_H

        class LineSegment: public LinearObject
        {
        public:
          Line(const Point& p1, const Point& p2);
          virtual bool IsOn(const Point&) const;
        };

        #endif

        #ifndef GEOMETRY_LINESEGMENT_H
        #define GEOMETRY_LINESEGMENT_H

        class LineSegment: public LinearObject
        {
        public:
          LineSegment(const Point& p1, const Point& p2);
          double GetLength() const;
          virtual bool IsOn(const Point&) const;
        };

        #endif


        #ifndef GEOMETRY_RAY_H
        #define GEOMETRY_RAY_H

        class Ray: public LinearObject
        {
        public:
          Ray(const Point& p1, const Point& p2);
          virtual bool IsOn(const Point&) const;
        };

        #endif

        代码段2 - 重构后的代码
  • 依赖倒置原则(The Dependency Inversion Principle, 简称DIP)
    1. 高层模块不应该依赖于底层模块。二者都应该依赖于抽象。
    2. 抽象不应该依赖于细节。细节应该依赖于抽象。
    • 一个设计良好的面向对象程序,其依赖程序结构相对于传统的过程式方法设计的通常结构而言是被“倒置了。
            考虑一下当高层模块依赖于底层模块时意味着什么。高层模块包含了一个应用程序中的重要的策略选择和业务模型。正是这些高层模块才使得其所在的应用程序区别于其它。然而,如果这些高层模块依赖于底层模块,那么对底层模块的改动就会直接影响到高层模块,从而迫使它们依次做出改动。
            这种情形是非常荒谬的!本应该是高层的策略设置模块去影响底层的细节实现模块的。包含高层业务规则的模块应该优先并独立于包含实现细节的模块。无论如何高层模块不应该依赖于底层模块。
            此外,我们更希望能够重用的是高层的策略设置模块。我们已经非常擅长于通过子程序库的形式来重用底层模块。如果高层模块依赖于底层模块,那么在不同的上下文中重用高层模块就会变得非常困难。然而,如果高层模块独立于底层模块,那么高层模块就可以非常容易地被重用。该原则是framework设计的核心原则。
    • 下图1显示了一个简单的层次化设计。图中高层次的Policy Layer使用了低层的Mechanism Layer,而Mechanism Layer又使用了更细节的Utility Layer。这看起来似乎是正确的,然而它存在一个潜伏的错误特征,那就是:Policy Layer对于其下一直到Utility Layer的改动都是敏感的。这种依赖关系是传递的。Policy Layer依赖于某些依赖于Utility Layer的层次;因此Policy Layer传递性地依赖于Utility Layer。这是非常糟糕的。
      下图2展示了一个更为合适的模型。每个较高层次都为它所需要的服务声明一个抽象接口,较低层次实现了这些抽象接口,每个高层次类都通过该抽象接口使用下一层,这样高层就不依赖于低层。底层反而依赖于高层中声明的抽象服务接口。这不仅解除了PolicyLayer对于UtilityLayer的传递依赖关系,甚至解除了PolicyLayer对于MechanismLayer的依赖关系。请注意这里的倒置不仅仅是依赖关系的倒置,它也是接口所有权的倒置。我们通常认为工具库应该拥有它们自己的接口。但是当应用了依赖DIP时,我们发现往往是客户拥有抽象接口,而它们的服务者则从这些抽象接口派生。
      敏捷软件开发原则,模式与实践书摘_第7张图片
                           图1 简单的层次化方案
      敏捷软件开发原则,模式与实践书摘_第8张图片
                                          图2 倒置的关系
    • 依赖于抽象。,这个启发式规则建议不应该依赖于具体类——也就是说,程序中所有的依赖关系都应该终止于抽象类或这接口。根据这个启发式规则,可以推断出下面三条规则。
      1. 任何变量都不应该持有一个指向具体类的指针或这引用
      2. 任何类都不应该从具体类派生
      3. 任何方法都不应该override它的任何基类中的已经实现了的方法
      • 当然,每个程序中都会有违反该启发规则的情况。有时必须要创建具体类的实例,而创建这些实例的模块将会依赖它们。此外,该启发规则对于那些虽是具体但却稳定(nonvolatile)的类来说似乎不太合理。如果一个具体类不太会改变,并且也不会创建其它类似的派生类,那么依赖于它并不会造成损害(其实到时,进行合理的重构还是可以解决这一问题)。比如,在大多数系统中,描述字符串的类都是具体的。但该类是稳定的,不太会改变。因此,直接依赖于它不会造成损害。
      • 然而,我们在应用程序中所编写的大多数具体类都是不稳定的。我们不想直接依赖于这些不稳定的具体类。通过把它们隐藏在抽象接口的后面,可以隔离它们的不稳定性。但这并不是一个完美的解决放啊。常常,如果一个不稳定类的接口必须要变化时,这个变化一定会影响到表示该类的抽象接口。这种变化破坏了由抽象接口维系的隔离性。
      • 由此可知,该启发规则对问题的考虑有点简单了。另外一方面,如果看到更远一点,认为是有客户类来声明他们需要的服务接口,那么仅当客户需要时才会对接口进行改变。这样,改变实现抽象接口的类就不会影响到客户。
    • 例子1:依赖倒置可以应用于任何存在一个类向另一个类发送消息的地方。例如,Button对象和Lamp对象之间的情形。Button对象感知外部环境的变化。但接收到Poll消息时,它会判断是否被用户“按下”。它不关心是通过什么样的机制去感知的。可能是GUI上的按钮图标,也可能是一个能够用手指按下的真正按钮,甚至可能是一个家庭安全系统中的运动检测器。Button对象可以检测到用户激活或关闭它。
              Lamp对象会影响外部环境。当接收到TurnOn消息时,它显示某种灯光。当接收到TurnOff消息时,它把灯光熄灭。它可以是计算机控制台的LED,也可以是停车场的水银灯,甚至是激光打印机中的激光。
             该如何设计一个用Button对象控制Lamp对象的系统呢?下图1展示了一个不成熟的设计。Button对象接收Poll消息,判断按钮是否被按下,接着简单地发送TurnOn或者TurnOff消息给Lamp对象。这个模型的相应代码如下代码段1所示。请注意Button类直接依赖于Lamp类。这个依赖意味着,当Lamp类改变时,Button类会受到影响。此外,想要重用Button来控制一个Motor对象是不可能的。在这个设计中,Button对象控制着Lamp对象,并且也只能控制Lamp对象。这个方案违反了DIP。应用程序的高层策略没有和底层实现分离。抽象没有和具体细节分离。没有这种分离,高层策略就自动地依赖于底层模块,抽象就会自动地依赖于具体细节。
      • 解决方案。什么是高层策略呢?它是应用背后的抽象,是那些不随具体细节的改变而改变的真理。它是系统内部的系统——它是隐喻(metaphore)。在Button/Lamp例子中,背后的臭显是检测用户的开/关指令并将指令传给目标对象。用什么样的机制检测用户的指令呢?无关紧要!目标对象是什么?无关紧要!这些都是不会影响到抽象的具体细节。通过倒置对Lamp对象的依赖关系,可以改进图1中的设计,如下图2所示。在本例中,接口没有所有者,可以被多个不同的客户使用,并被许多不同的服务者实现。这样,接口就需要被放置在一个单独的组(group)中。在C++中,可以把它放在一个单独的namespace和库中。在Java中,可以把它放在一个单独的package中。

             图1  不成熟的Button和Lamp模型
        敏捷软件开发原则,模式与实践书摘_第9张图片
               图2 对Lamp应用依赖倒置原则
  • 接口隔离原则(The Interface Segregation Principle, 简称ISP)
    1. 不应该强迫客户依赖于它们不用的方法(胖接口)
    2. 如果强迫客户程序依赖于那些它们不使用的方法,那么这些客户程序就面临着由于这些未使用方法的改变所带来的变更。这无意中导致了所有客户程序之间的耦合。换种说法,如果一个客户程序依赖于一个包含它不使用的方法的类,但是其它客户程序却要使用这些方法,那么当其它客户要求这个类改变时,就会影响到这个客户程序。应该尽可能避免这种耦合,因此需要分离接口(拆分为多个具有内聚接口的抽象基类)。
    • 例子1:在一个安全系统中,有一些Door对象,它们可以被加锁和解锁,并且Door对象知道自己是开着还是关着。如程序片段1所示。现在考虑这样的实现,TimedDoor,如果门开着的时间过长,它就会发出警报声。为了做到这一点,TimedDoor对象需要和另一个名为Timer的对象交互,如代码片段2所示。如果一个对象希望得到超时通知,可以调用Timer的Register函数。该函数有两个参数,一个超时时间和一个指向TimerClient的对象的指针,该对象的TimeOut函数会在超时到达时被调用。怎样将TimerClient类和TimedDoor类联系起来,才能在超时时通知到TimedDoor中相应的处理代码呢?
      class Door
      {
      public:
        virtual void Lock() = 0;
        virtual void Unlock() = 0;
        virtual bool IsDoorOpen() = 0;
      };
      代码片段1

      class Timer
      {
      public:
        void Register(int timeout, TimerClient* client);
      };

      class TimerClient
      {
      public:
        virtual void Timeout() = 0;
      };
      代码片段2

      • 方案1:让Door继承TimedDoor,如下图1所示。该方案的问题:Door类依赖于TimerClient类了,可是并不是所有种类的Door都需要定时功能。事实上,最初的Door抽象类和定时功能没有任何关系。如果创建了无需定时功能的Door的派生类,那么这些派生类中就必须提哦那个TimeOut方法的退化(degenerate)实现——这就有可能违反LSP。这是一个接口污染的例子。
        敏捷软件开发原则,模式与实践书摘_第10张图片
                                       图1
      • 方案2:使用委托分离接口。如下图2所示。该方案解决方案遵循ISP原则,并且避免了Door的客户程序和Timer之间的耦合。这是一个非常通用的解决方案。缺点是:需要多创建一个对象,委托处理可能导致一些性能和内存的损失。
        敏捷软件开发原则,模式与实践书摘_第11张图片
                                                                        图2
      • 方案3:使用多重继承分离接口。优先选用的方案。
        敏捷软件开发原则,模式与实践书摘_第12张图片
                                                                       图3

你可能感兴趣的:(敏捷软件开发原则,模式与实践书摘)