现在的位置: 首页 > 综合 > 正文

依赖倒置原则

2013年10月05日 ⁄ 综合 ⁄ 共 6725字 ⁄ 字号 评论关闭

本文译自Robert C. Martin于1996年发表的文章,将分为三部分贴在这里。原文可参看http://www.objectmentor.com/resources/articles/dip.pdf

这是我给《C++报导》“工程笔记”专栏的第三篇文章。这个专栏的文章专注于C++OOD的使用,及软件工程方面的问题。我将努力写一些编程方面的,对处在战壕中的软件工程师直接有用的文章。这些文章将使用BoochRumbaugh的新的“统一”标识符(Version
0.8
)来说明面向对象的设计。下图简要的说明了这种标识符。

Sidebar

1、引言

我的上一篇文章(1996-03)讨论了里氏替换原则(Liskov
Substitution Principle
——LSP)。这个原则被应用到C++时,为公共继承的使用提供了指导。它指出,在不知道派生类的情况下,每一个通过基类的引用或指针来进行操作的函数,都应当能够通过这个基类的派生类来执行操作。这意味着派生类的虚函数既不能比基类的相应成员函数多,也不能少。它还意味着基类中的虚函数在派生类中必须存在,而且必须实现有用的功能。如果违背了这个原则,通过基类的引用或指针来进行操作的函数就需要检查实际对象的类别以确认通过它能够进行正确的操作。需要检查类别的话就违背了我们去年1月讨论的开闭原则(OCP)。

在这个专栏里,讨论OCPLSP在结构方面的含义。严格使用这些原则所产生的结构本身可以归结为一条原则。我称它为“依赖倒置”(The
Dependency Inversion Principle
——DIP)。

2、软件是怎么了?

我们绝大多数人都有过努力去处理具有“糟糕的设计”的软件的惨痛经历。我们中的有些人甚至有过更惨痛的经历:发现具有“糟糕的设计”的软件的作者竟然就是我们自己!到底是什么使一个设计变得糟糕了呢?

大多数软件工程师都不会一开始就去做“糟糕的设计”。但是,大多数的软件最终都会沦落到被一些人宣称设计不佳的地步。为什么会这样呢?是设计的一开始就差呢,还是设计会象腐烂的肉一样会变质呢?这个问题的核心是我们没有很好地去定义什么是“糟糕”的设计。

2.1. “糟糕设计”的定义

你曾经把你特别引以为豪的软件设计呈给你的同行评审过吗?那个同行有没有以一种嘲笑的口气说一些类似于“你为什么那么做?”之类的话?当然,这在我身上发生过,我也看到许多其他的工程师也遇到过。显然,具有不同意见的工程师对于什么是“糟糕设计”没有采用相同的标准。我见过的最常用的标准是“TNTWIWHDI”(That’s
not the way I would have done it.
),即“如果是我,就不会那么做”标准。

但是,有一组标准我想所有的工程师都会同意。一段软件虽然满足了它的需求,但是它呈现任一或全部下列三种特性的话,就是糟糕的设计。

①.很难修改,因为每一个修改都会影响系统的很多其它部分。(僵化性)
②.当你做一点修改时,不知道系统的那个地方会崩溃。(脆弱性)
③.很难在其它应用中复用,因为它不能从当前的应用中分离出来。(不可移植性)

而且,很难去证明一个不具有以上特性的软件,比如,它具有灵活性、健壮性、又可复用,还满足了它的所有需求,却有着糟糕的设计。如此,我们就可以把这三个特性作为一种方法来明确地判断一个设计是“优良”还是“糟糕”。

2.2. “糟糕设计”的原因

是什么使得一个设计僵化、脆弱和不可移植呢?是设计中各个模块间的相互依赖。一个不易被修改的设计,是僵化的。这种僵化是因为这个事实:对一个严重相互依赖的软件的一个修改会引发对所依赖模块进行一系列修改的雪崩效应。当这个修改雪崩的范围不能被设计者或维护者预期时,这个修改所带来的影响也是没法估计的。这就不可能去预期修改的成本。面对这样的不可知性,管理者很难批准去修改。因此,这个设计就变成正式僵化的。

脆弱性是一个程序的这样一种倾向:当做某一处修改时,会在很多地方引起崩溃。经常,新问题发生的部分跟被修改的部分没有什么概念上的关系。这种脆弱性严重降低了设计及维护组织的可信任性。用户和管理者不能预期他们的产品的质量。对应用程序某一部分的简单的修改会导致看上去毫不相关的其它部分的失败。修改这些问题又会导致更多的问题,这种维护过程变得象狗在追赶它自己的尾巴。

如果一个设计中,我们想要的部分紧紧依赖于我们不想要的其它具体细节,那么这个设计就是不可移植的。对于研究一个设计看它是否能够在另一个不同的应用中复用的设计人员,一个设计在新应用中的良好表现留下深刻的印象。但是,如果一个设计内部是严重相互依赖的,设计人员把需要的部分从其它不需要的部分中分离出来就会需要大量的工作,这令他们感到沮丧。大多数情况下,这样的设计是不可复用的,因为大家认为分离的成本要比重新开发这个设计的成本还高。

2.3. 示例:“拷贝”程序

一个简单的例子就可以帮助说明这个问题。来看一个简单的程序,它的任务是把键盘上敲入的字符复制到打印机上。而且,假定实现平台中没有支持设备独立性的操作系统。我们构思的程序结构可能如1所示:

1拷贝程序

 1是一个“结构化的图”1,它显示应用中包含三个模块或子程序,“Copy”模块调用其它两个模块。可以很容易地想象出“Copy”模块中有一个循环(见程序1)。循环体调用“Read
Keyboard
”模块从键盘获取一个字符,然后把这个字符发送到“Write Printer”模块,它将打印这个字符。

-----------------------------------
void Copy()
{
int c;
while ((c = ReadKeyboard()) != EOF)
WritePrinter(c);
}
-----------------------------------
程序1拷贝程序

这两个低层次的模块具有很好的可复用性。它们能够用在许多其它的程序中用来访问键盘和打印机。这跟我们通过子程序库获得的可复用性类似。

但是,“Copy”模块在不涉及键盘或打印机的任何环境中都是不能复用的。这真是一个耻辱,因为这个系统的智能部分就是在这个模块中维护的。是“Copy”模块封装了我们想要复用的一个非常令人感兴趣的策略。

例如,来看一个新程序,它把键盘字符拷贝到磁盘文件。自然,我们想去复用这个“Copy”模块,因为它封装了我们所需要的高层策略。比如,它知道如何把字符从一个源拷贝到一个宿。不幸的是,这个“Copy”模块依赖于“Write
Printer
”模块,所以不能在新环境中复用。

我们当然可以修改这个“Copy”模块使它具有我们想要的新的特性(见程序2)。我们可以在其策略中添加一个“if”语句,使它可以根据某个标志来选择使用“Write
Printer
”模块还是“Write Disk”模块。但是,这会给系统增加新的相互依赖。随着时间的推移,越来越多的设备必须加入到拷贝程序中,这个“Copy”模块将会被if/else语句弄得很乱,而且依赖于很多的低层模块。它最终将变得僵化、脆弱。

-----------------------------------
enum OutputDevice {printer, disk};
void Copy(outputDevice dev)
{
int c;
while ((c = ReadKeyboard()) != EOF)
if (dev == printer)
WritePrinter(c);
else
WriteDisk(c);
}
------------------------------------
程序2“增强”的拷贝程序
 

3、依赖倒置

导致上面所述问题的一个原因是,含有高层策略的模块,如Copy()模块,依赖于它所控制的低层的具体细节的模块(如WritePrinter()ReadKeyboard())。如果我们能够找到一种方法使Copy()模块独立于它所控制的具体细节,那么我们就可以自由地复用它了。我们就可以用这个模块来生成其它的程序,来把任何输入设备的字符拷贝到任何输出设备。OOD给我们提供了一种机制来实现这种“依赖倒置”。

2面向对象的拷贝程序

2中这个简单的类图。这儿有一个“Copy”类,它包含一个抽象的“Reader”类和一个抽象的“Writer”类。很容易会想到“Copy”类中有一个循环从它的“Reader”中取得字符然后发送给它的“Writer”(见程序3)。这个“Copy”类根本不依赖于“Keyboard
Reader
”和“Printer Writer”。所以,依赖关系被“倒置”了:“Copy”模块依赖于抽象,那些具体的读设备和写设备也依赖于相同的抽象。

―――――――――――――――――――――――――――――

class Reader
{
public:
virtual int Read() = 0;
};
class Writer
{
public:
virtual void Write(char) = 0;
};
void Copy(Reader& r, Writer& w)
{
int c;
while((c=r.Read()) != EOF)
w.Write(c);
}
―――――――――――――――――――――――――――――――
程序3面向对象的拷贝程序

现在,我们可以复用“Copy”类,不依赖于“Keyboard
Reader
”和“Printer Writer”了。我们可以发明新的、可以提供给“Copy”类使用的、“Reader”和“Writer”的派生类。而且,无论创建多少种“Reader”和“Writer”,“Copy”不依赖于他们中的任何一个。没有什么相互依赖使这个程序变得僵化和脆弱。Copy()本身可以用在许多不同的具体环境中。它是可移植的。

3.1. 设备独立

现在,你们中的一些人或许会对自己说,用CCopy(),使用stdio.h中固有的设备独立的特性,如getcharputchar(见程序4),也会取得同样的效果。如果你仔细看一下程序3程序4,会发现它们俩在逻辑上是等同的。程序3中的抽象类被程序4中一种不同的抽象所代替。程序4是确实没有用到类和纯虚函数,但它仍然使用了抽象和多态性来达到它的目的。而且,它也使用了依赖倒置!程序4所示的拷贝程序不依赖于它所控制的任何具体细节。相反,它依赖于stdio.h中声明的抽象设备。而且,那些最终被调用的IO设备也依赖于stdio.h所声明的抽象设备。所以,stdio.h库中的设备独立是依赖倒置的另一个实例。

――――――――――――――――――――――――――――――

#include <stdio.h>
void Copy()
{
int c;
while((c = getchar()) != EOF)
putchar(c);
}
――――――――――――――――――――――――――――――
程序4使用stdio.h的拷贝

现在,我们已经看了一些例子,我们可以来说明DIP的一般形式。

4、依赖倒置原则

A.高层次的模块不应该依赖于低层次的模块,它们都应该依赖于抽象。

B.抽象不应该依赖于具体,具体应该依赖于抽象。

也许有人会问为什么要用“倒置”这个词。坦白地讲,这是因为传统的软件开发方法,如结构化的分析和设计,倾向于创建高层模块依赖于低层模块、抽象依赖于具体的软件结构。实际上,这些方法的目标之一就是去定义描述上层模块如何调用低层模块的子程序层次结构。1是这种层次结构的一个很好的例子。所以,相对于传统的过程化的方法通常所产生的那种依赖结构,一个设计良好的面向对象的程序中的依赖结构就是“被倒置”的。

来看一下那些依赖于低层模块的高层模块的含义。一个应用中的重要策略决定及业务模型正是在这些高层的模块中。也正是这些模型包含着应用的特性。但是,当这些模块依赖于低层模块时,低层模块的修改将会直接影响到它们,迫使它们也去改变。

这种境况是荒谬的。应该是处于高层的模块去迫使那些低层的模块发生改变。应该是处于高层的模块优先于低层的模块。无论如何高层的模块也不应依赖于低层的模块。

而且,我们想能够复用的是高层的模块。通过子程序库的形式,我们已经可以很好地复用低层的模块了。当高层的模块依赖于低层的模块时,这些高层模块就很难在不同的环境中复用。但是,当那些高层模块独立于低层模块时,它们就能很简单地被复用了。这正是位于框架设计的最核心之处的原则。

4.1. 分层

依照Booch2所说,“所有结构良好的面向对象的架构都有着清晰定义的层次,每一层都通过一个定义良好的、受约束的接口来提供一些相关联的服务。”对这句话的直接表面的理解会使设计人员做出类似于3所示的结构。在这个图中,高层的Policy类使用了低一层的Mechanism;而Mechanism又使用了具体细节层次上的Utility。这也许看上去很好,但是它有一个很阴险的特性:Policy层对从它到Uility层这一路径上的所有变动都是敏感的。“依赖是可传递的”。Policy层依赖于那些依赖于Utility层的东西,所以Policy层就被传递地依赖于Utility层。这是非常不幸的。

3简单的层次

 4展示了一个更好的模型。每一个较低的层都被一个抽象类所表示。实际的层就由这些抽象类分隔开来。每一个处于较高层中的类都通过抽象接口来使用下一层。这样,就没有一层是依赖于其它层的。相反,这些层都依赖于抽象类。不但Policy层到Utility层的传递依赖被打破了,连Policy层到Mechanism层的直接依赖也被打破了。

4抽象的层次

使用这个模型,Policy层不会被Mechanism层或Utility层的修改所影响。而且,Policy层可以复用到任何按照Mechanism层的接口定义的低层模块的环境中。从而,通过倒置依赖关系,我们可以创建一个同时更加灵活、更加健壮、更加可移植的结构。

4.2. C++中分离接口与实现

有人会抱怨说3中的结构并没有显示出我所提出的那种依赖和传递依赖的问题。毕竟,Policy层仅仅依赖于Mechanism层的“接口”。为什么对Mechanism层的实现所做的修改就会对Policy层产生影响呢?

对某些面向对象的语言来说,这是事实。在这些语言中,接口和实现是自动分离的。但是在C++中,没有将接口和实现分离。C++中是将类的定义和它的成员函数的定义分开了。

C++中,我们通常把一个类分成两个模块:一个.h模块和一个.cc模块。.h模块包含了这个类的定义,而.cc模块则包含了这个类的成员函数的定义。在.h模块中的类定义包含了这个类所有的成员函数和成员变量的声明。这些信息超出了简单的接口定义的范围。这个类所需要的所有工具函数和私有变量也都在.h模块中声明。这些工具类和私有变量是这个类的实现的一部分,它们却出现在所有使用这个类的用户所必须依赖的模块(.h模块)中。因此,在C++中,实现和接口没有自动分开。

C++中接口和实现不分离的问题可以通过使用纯抽象类来解决。一个纯抽象类中除了纯虚函数外,不包含任何内容。这样的类是纯的接口;它的.h模块中没有包含实现。4显示了这样一个结构。4中的抽象类是纯抽象,所以每一层仅仅依赖于后面层的“接口”。

5、一个简单的例子

在任何一个类向另一个发送消息的地方都可以使用依赖倒置。例如,看一下Button(按钮)对象和Lamp(灯)对象的场景。

Button对象感知外界环境。它能够确定是否有用户“按下”了它。它的感知机制是什么样的无所谓,它可能是图形用户界面上的一个按钮图标,或者是一个用手指摁的实实在在的按钮,甚至是一个家庭安全系统中的运动探测器。Button对象检测用户是激活了它还是使它不激活。Lamp对象影响外部环境。当收到TurnOn(打开)的消息时,它就发出某种类型的光。当它收到TurnOff(关闭)的消息时,它就熄灭那些光。其物理机制并不重要。它可能是电脑控制台上的一个LED,或者是停车场里的一个汞汽灯,甚至是激光打印机上的激光。

如何来设计这个Button对象控制Lamp对象的系统?5展示了一个直接的模型。Button对象简单地发送TurnOnTurnOff消息给Lamp。要实现它,Button类使用了“包含”关系来持有Lamp类的一个实例。

程序 5列出了由这个模型所产生的代码。注意,Button类直接依赖于Lamp类。实际上是,button.cc模块用#include包含了lamp.h模块。这种依赖意味着,当Lamp类发生变化时,Button类必须变化,至少要重新编译。而且,不可能复用Button类去控制一个Motor对象。

抱歉!评论已关闭.