[11]DIP:依赖倒置原则

 

《敏捷软件开发 原则、模式与实践(c#版)》

第11章 DIP:依赖倒置原则

    传统的软件开发方法,比如结构化分析和设计,总是倾向于创建一些高层模块依赖于低层模块、策略依赖于细节的软件结构。实际上这些方法的目的之一就是要定义子程序层次结构,该层次结构描述了高层模块怎样调用低层模块。然而一个设计良好的面向对象的程序,其依赖程序结构相对于传统的过程式方法设计的通常结构而言就是被“倒置”了。

    请考虑依赖于低层模块的高层模块意味着什么。正是高层模块包含了应用程序中重要的策略选择和业务模型。这些高层模块使得其所在的应用程序区别于其它。然而,如果这些高层模块依赖于低层模块,那么对低层模块的改动就会直接影响到高层模块,从而迫使它们依次作出改动。

    这种情形是非常荒谬的!本应该是高层的策略设置模块去影响低层的细节实现模块的。 包含高层业务规则的模块应该优先并独立于包含实现细节的模块。无论如何高层模块都不应该依赖于低层模块。

    此外,我们更希望能够重用的是高层的策略设置模块。我们已经非常擅长于通过之程序库的形式来重用低层模块。如果高层模块独立于依赖低层模块,那么在不通的上下文中重用高层模块就会变得非常困难。然而,如果高层模块独立于低层模块,那么高层模块就可以非常容易地被重用。该原则是框架设计的核心原则。

定义

    “a. 高层模块不应该依赖于低层模块。二者都应该依赖于抽象。” 

    "b. 抽象不应该依赖于细节。细节应该依赖于抽象。

层次化

    Booch 曾经说过:“所有结构良好的面向对象构架都具有清晰的层次定义,每个层次通过一个定义良好的、受控的接口向外提供了一组内聚的服务。”对这个陈述的简单理解可能会导致使设计中设计出类似 (图1-1) 的结构。图中,高层的 Policy 层使用了低层的 Mechanism 层,而 Mechanism 层又使用了更细节的层 Utility 层。这看起来似乎是正确的,然而它存在一个隐伏的错误特征,那就是:Polity 层对于其下一直到 Utility 层的改动都是敏感的。依赖关系是传递的。Policy 层依赖于某些依赖于 Utility 层的层次:因此 Policy 层传递性地依赖于 Utility 层。这是非常槽糕的。


图1-1 简单的层次化方案

    (图1-2) 展示了一个更为合适的模型。每个较高层次都为它需要的服务声明一个抽象接口。较低的层次实现了这些抽象接口。每个高层类都通过该抽象接口使用下一层。这样高层就不依赖于低层。低层反而依赖于在高层中声明的抽象服务接口。这不仅解除了 PolicyLayer 对于 UtilityLayer 的传递依赖关系,甚至也解除了 PolicyLayer 对于 MechanismLayer 的依赖关系。

 

图1-2 倒置的层次

倒置的接口所有权

    请注意这里的倒置不仅仅是依赖关系的倒置, 它也是接口所有权的倒置。我们通常会认为工具库应该拥有它们自己的接口。但是当应用了DIP时,我们发现往往是客户拥有抽象接口,而它们的服务者则从这些抽象接口派生。

    这就是著名的 Hollywood 原则:“Don't call us,we'll call you.(不要调用我们,我么会调用你。)“ 低层模块实现了在高层模块中声明并被高层模块调用的接口。

    通过这种倒置的接口所有权,对于 MechanismLayer 或者 UtilityLayer 的任何改动都不会再影响到 PolicyLayer。而且,PolicyLayer 可以在定义了符合 PolicyService 的任何上下文中重用。这样,通过倒置这些依赖关系,我们创建了一个更灵活、更持久、更易改变的结构。

    这里所说的所有权仅仅是指接口是随拥有它们的客户程序发布的,而非实现它们的服务器程序。接口和客户位于同一个包或者库中。这就迫使服务器程序或者包依赖于客户程序库或者包。

    当然,有时我们会不想让服务器程序依赖于客户程序,特别是当有多份客户程序但是服务器却仅有一份时,在这种情况下,客户程序必须得遵循服务接口,并把它发布到一个独立的包中。

依赖于抽象

    一个稍微简单但仍然非常有效的对于DIP的解释,是这样一个简单的启发规则:”依赖于抽象。“这是一个简单的陈述,该启发式规则建议不应该依赖于具体类——也就是说,程序中所有的依赖关系都应该终止于抽象类或接口。

    A 任何变量都不应该持有一个指向具体类的引用。

    B 任何类都不应该从具体类派生。

    C 任何方法都不应该重写它的任何基类中的已经实现了的方法

    当然,每个程序中都会有违反该启发规则的情况。有时必须要创建具体类的实例,而创建这些实例的模块将会依赖于它们。此外,该启发规则对于那些虽是具体但却稳定的类来说似乎不太合理。如果一个具体类不太会改变,并且也不会创建其它类似的派生类,那么依赖于它不会造成损害。

    比如,在大多数的系统中,描述字符串的类都是具体的。例如,在C#中,表示字符串的是具体类 String 。该类是稳定的,也就是说,它不太会改变。因此,直接依赖于它不会造成损害。 

    然而,我们在应用程序中所编写的大多数具体类都是不稳定的。我们不想直接依赖于这些不稳定的具体类。通过把它们隐藏在抽象接口的后面,可以隔离它们的不稳定性。

    这不是一个完整的解决方案。常常,如果一个不稳定类的接口必须变化时,这个变化一定会影响到表示该类的抽象接口。这种变化破坏了由抽象维系的隔离性。

    由此可知,该启发规则对问题的思考有点简单了。另一方面,如果看得更远一点,认为是由客户模块或者层来声明它们需要的服务接口,那么仅当客户需要才会对接口进行改变。这样,改变实现抽象接口的类就不会影响到客户。 

简单的DIP示例

    依赖倒置可以应用于任何存在一个类向另一个类发送消息的地方。 例如,Button 对象和Lamp 对象之间的情形。

    Button 对象感知外部环境的变化。当接受到 Poll 消息时,它会判断是否被用户”按下“。它不关心是通过什么样的机制去感知的。可能是GUI上的一个按钮图标,也可能是一个能够用手指按下的真正按钮,甚至可能是一个家庭安全系统的中的运动检测器。Button 对象可以检测到用户激活或者关闭它。

    Lamp 对象会影响外部环境。当接收到 TurnOn 消息时,它显示某种灯光。当接收到 TurnOff 消息时,它把灯光熄灭。具体的物理机制并不重要。它可以是计算机控制台的LED,也可以是停车场的水银灯,甚至是激光打印机中的激光。 

    该如何设计一个用 Button 对象控制 Lamp 对象的系统呢?(图1-3 )展示了一个不成熟的设计。Button 对象接收 Poll 消息,判断按钮是否被按下,接着简单地发送 TurnOn 或者 TurnOff 消息给 Lamp 对象。

 

图1-3 不成熟的Button和Lamp模型 

    为何说它是不成熟的呢? 考虑一下对应这个模型的C#代码(代码清单 1-1)。请注意 Button 类直接依赖 Lamp 类。这个依赖关系意味着当 Lamp 类改变时,Button 类会受到影响。此外,想要重用 Button 来控制一个 Motor 对象是不可能的。在这个设计中,Button 控制着 Lamp 对象,并且也只能控制 Lamp 对象。

代码清单 1-1 Button.cs 

  public class Button
  {
     
private Lamp aLamp;

     
public void Poll()
     {
        
if(/* some condition */)
        {
           aLamp.TrunOn();
        }
     }
  }

 

    这个方案违反了DIP。应用程序的高层策略没有和低层分离。抽象没有和具体细节分离。没有这种分离,高层策略就自动地依赖于低层模块,抽象就自动地依赖于具体细节。

找出潜在的抽象

    什么是高层策略呢? 它是应用背后的抽象,是那些不随具体细节的改变而改变的真理。它是系统内部的系统——它是隐喻(metaphore)。在Button/Lamp例子中,背后的抽象是检测用户的开/关指令并将指令传给目标对象。用什么机制检测用户的指令呢?无关紧要!目标对象是什么?同样无关紧要!这些都是不会影响到抽象的具体细节。

    通过倒置对 Lamp 对象的依赖关系,可以改进(图1-3)中的设计。在(图1-4)中,可以看到 Button 现在和一个称为 ButtonServer 的接口关联起来了,Button 可以使用它来开启或者关掉一些东西。Lamp 实现了 ButtonServer 接口。这样,Lamp 现在是依赖于别的东西了,而不是被依赖了。

 

图1-4 对Lamp应用依赖倒置原则

    (图1-4)中的设计可以使 Button 控制那些愿意实现 ButtonServer 接口的任何设备。这赋予我们极大的灵活。同时也意味着 Button 对象将能控制还没有被创造出来的对象。

    不过,这个方案对那些需要被 Button 控制的对象提出了一个约束。需要被 Button 控制的对象必须要实现 ButtonServer 接口。这不太好,因为这些对象可能也要被 Switch 对象或者一些不同于 Button 的对象控制。

    通过倒置依赖关系的方向,并使得 Lamp 依赖于其它类而不是被其它类依赖,我们已经使 Lamp 依赖于一个不同的具体细节:Button。我们确实已经做到了吗?

    Lamp 的确依赖于 ButtonServer,但是 ButtonServer没有依赖于 Button。任何知道如何去操作 ButtonServer 接口的对象都能够控制 Lamp。因此,这个依赖关系只是名字上的依赖。可以通过给 ButtonServer 起一个更通用一点的名字,比如 SwitchableDevice,来修正这一点。也可以确保把 Button 和 SwitchableDevice 放置在不同库中,这样对 SwitchableDevice 的使用就不必包含对 Button 的使用。

    在本例中,接口没有所有者。这是一个有趣的情形,其中接口可以被许多不同的客户使用,并被许多不同的服务者实现。这样,接口就需要独立存在而不属于任意一方。在C#中,可以把它放在一个单独的命名空间和库中。

熔炉示例

    我们来看一个更有趣的例子。 考虑一个控制熔炉调节器的软件。该软件可以从一个 I/O 通道中读取当前的温度,并通过向另一个 I/O 通道发送命令来指示熔炉的开或者关。算法结构看起来如代码1-2所示。

代码清单1-2 温度调节器的简单算法

  const byte THERMONETER=0x86;
  
const byte FURNACE=0x87;
  
const byte ENGAGE=1;
  
const byte DISENGAGE=0;

  
void Regulate(double minTemp,double maxTemp)
  {
     
for(;;)
     {
        
while (in(THERMONETER) > minTemp)
           wait(
1);
        
out(FURNACE,ENGAGE);
        
        
while (in(THERMONETER) < maxTemp)
           wait(
1);
        
out(FURNACE,DISENGAGE);
     }
  }

 

    算法的高层意图是清楚的,但是实现代码中却夹杂着许多低层细节。这段代码根本不能重用于不同的控制硬件。

    由于代码很少,所以这样做不会造成太大的损害。但是,即使是这样,使算法失去重用性也是可惜的。我们更愿意倒置这种依赖关系,结果如(图1-5 )所示。

 

 图1-5 通用的调节器

    图中显示了 Regulate 函数接受了两个接口参数。Thermometer 接口可以读取,而 Heater 接口可以启动和停止。Regulate 算法需要的就是这些。它的实现代码如清单1-3所示。

    这就倒置了依赖关系,使得高层的调节策略不再依赖于任何温度计或者熔炉的特定细节。该算法具有很好的可重用性。

代码清单1-3 通用的调节器

  void Regulate(Thermometer t,Heater h,double minTemp,
     
double maxTemp)
  {
    
for(;;)
    {
       
while (t.Read() > minTemp)
          wait(
1);
       h.Engate();

       
while (t.Read() < maxTemp)
          wait(
1);
       h.Disengage();
    }
  }

 

结论

    使用传统的过程话程序设计所创建出来的依赖关系结构,策略是依赖于细节的。这是糟糕的,因为这样会使策略受到细节改变的影响。面向对象的程序设计倒置了依赖关系结构,使得细节和策略都依赖于抽象,并且常常是客户程序拥有服务接口。

    事实上,这种依赖关系的倒置正是好的面向对象设计的标志所在。使用何种语言来编写程序是无关紧要的。如果程序的依赖关系是倒置的,它就是面向对象的设计。如果程序的依赖关系不是倒置的,它就是过程化的设计。:-)

    依赖倒置原则是实现许多面向对象技术所宣称的好处的基本低层机制。它的正确应用对于创建可重用的框架来说是必须的。同时它对于构建在变化面前富有弹性的代码也是非常重要的。由于抽象和细节彼此隔离,所以代码也非常容易维护。

 

    End. 


posted @ 2009-06-17 10:09  Old  阅读(2297)  评论(7编辑  收藏  举报