依赖倒置原则
(The Dependency Inversion Principle)
【注:这是Robert C. Martin 1996——有一千年了,在C++ Report上发表的文章,影响深远。依赖倒置原理是面向对象技术宣称的很多优越性的根源。也是设计模式(design patterns)的基石。
原文为:The Dependency Inversion Principle
(即http://www.objectmentor.com/resources/articles/dip.pdf)PDF格式。
***作为作业让吴兰芳、金艳、马婧、段亚岚、阮珊、佟哲翻译,最后yqj2065做了一些修改。一些我搞不定的地方,请大虾指教。为了阅读方便,显示如in the trenches】
文章内【……】里的咚咚,是我的注释。
这是我【Robert C. Martin】的工程笔记专栏C++报导(Engineering Notebook columns for The C++ Report)的第3篇,将在本栏目里刊登的这篇文章将主要讨论C++和OOD的使用,和软件工程上的一些要点。我将争取写出对软件工程师【in the trenches、同一个战壕、兄弟?】有实效而且直接有用的文章。在这些文章里,我将用Booch 和Rumbaugh新提出的统一符号(Version 0.8)【UML的前身】建档面向对象设计。旁边这个图提供一个该标识法的简要说明 (The sidebar provides a brief lexicon of this notation.) 。
边图:统一符号0.8
§1引言
我的上一篇文章(1996-3)讨论了里氏替换原则(Liskov Substitution Principle 、LSP)。这个原则,被应用于C++时,为公有继承的使用提供了指导。其阐明:每一个函数,运用(操作)在基类的引用或指针之上时,就应该能够运用在该基类的派生类上,(甚至)不需要知道派生类为何物。这意味着:子类的虚函数必须指望它们不多于基类的相应函数,同时要保证不少于(基类的相应函数)。这也意味着基类中呈现的虚拟成员函数必须在衍生类中出现,而且它们必须能做有用的事儿。当这个原则被违反时,运用在基类的引用或指针之上的函数就需要检查该当前对象(the actual object)的类型,以保证它们(这些函数)能够正确的运用在其【这个实际对象】之上。而这——需要去检查类型,就违背了开闭原则(OCP),我们在去年1月就讨论过了。
在这个专栏(文章)里,我们讨论OCP 和LSP的结构推断(the structural implications)。这个结构——作为严格使用这些原则的结果——能被概括为一条原则,我称其为"依赖倒置原则"(DIP)。【Robert C. Martin :I first stumbled on this principle when Jim Newkirk and I were arranging the source code directories of a C++ project. We realized that we could make the directories that contained detailed code depend upon the directories that contained abstract classes. This seemed like an inversion to me, so I coined the name "Dependency Inversion".】
§2 软件出了什么毛病?
我们大多数人都有这样不愉快的经历,试图处理一些"坏设计"【设计得很水】的软件片断。我们中的一些人甚至有更不愉快的体验,发现我们正是"坏设计"软件的作者。是什么造成了糟糕的设计?
大多数软件工程师并不以创建"坏设计"为出发点,然而大多数软件最终沦落到这个地步,被某人宣判为设计不健全。这又是如何发生的?是一开始就是糟糕的设计,还是设计居然会变质——就象坏了的肉一样?这个议题的核心在于我们缺乏合适的定义:什么是"坏"设计。
“坏设计”的定义
你是否曾经展示过一种自己特感骄傲的软件设计,让同伴评论?那些同伴有没有用一种嘲笑的语气抱怨,例如“你为什么要用那种方式做这件事呢”?这种事真的在我身上发生过,我也看见它发生在很多其他工程师身上。无疑地,意见不一的工程师没有使用相同的标准去定义何谓之“坏设计”。我见过的使用得最普遍的标准是TNTWIWHDI,就是说"那不是我去做时会使用的方式"(That’s not the way I would have done it)。
但是,这里有一些标准,我相信所有工程师都会认同。软件片断(虽然)符合其需求(fulfills its requirements),但因(yet)表现出以下3 种特性中的一些或全部,那就是“坏设计”。
1. 改变起来很难,因为每种变化都会影响系统的太多其他部分。(Rigidity刚性、僵硬)。
2. 当你作了一个变动时,系统中意想不到的部分会出错。 (Fragility、易碎性)
3. 它难以在另一个应用程序中复用,由于它不能脱离当前应用。(Immobility、固定、无移植性)
此外,很难例证(demonstrate)某个软件片断在没有任一上述特征时,也就是说,它是灵活的(flexible),鲁棒的(robust)和可复用的(reusable)而且符合其需求,会是一个“坏设计”。因此,我们能使用这3 种特性作为确切判定一种设计是"好"或者"坏"的一种方法。
导致“坏设计”的原因
是什么导致设计刚性(rigid)、脆弱(fragile)和不易移植(immobile)的呢?它(原因)就是该设计中模块的相互依赖(interdependence)。这个设计就是刚性的,如果它不能容易地被改变,这样的刚性是因为这一事实——对于严重相互依赖的软件,单个变化引起了依赖模块中的级联变化(a cascade of changes、连锁反应)。一旦级联变化的范围不能被设计者或者维护者预先知道,变化的影响就不能被估计。这导致变化的开销不可能被预言。管理层面对如此不可预测性,变得不愿意批准变动。因此,设计就官方上(正式、officially)地成为刚性。
脆弱性(易碎性、Fragility)是一种单个变化发生时,程序在很多地方中断的趋势。经常的,新问题出现在与被改变的领域没有概念上的关系的地方。这样的易碎性极大降低了设计的可信性(credibility)和可维护性(maintenance organization)。用户和管理者不能预言他们的产品的质量。应用的某个部分的简单变化导致在看起来完全无关的其他部分出现失败。 解决那些问题导致甚至更多的问题,而维护工作开始(变得)像一条狗追赶它尾巴。
设计是不易移植的,是说设计中想要的部分高度地依赖于不太想要的细节。一些设计者被分配的任务是研究(调查)设计,看看它能否在不同的应用中复用,他们会惊叹于该设计能够那么好的复用于新的应用中(Designers tasked with investigating the design to see if it can be reused in a different application may be impressed with how well the design would do in the new application.)然而,如果一个设计是高度互相依赖的,他们就会非常的苦恼,把该设计中想要的部分与该设计中不想要的部分分开,有大量的工作必须做。多数情况下,这样的设计是不被复用的,因为分离的费用被认为要高于重新设计(redevelopment of the design)的费用。
例子:“Copy”程序
图1:“复制”程序
一个简单的例子可以帮助理解这一点。 考虑一个承担下面任务的简单程序:copy键盘上输入的字符,在打印机上输出的。而且假定,实施平台并没有一个操作系统去支持设备无关性。我们可以想象出这个程序的结构就像是图1。图1就是一个"结构图"(structure chart)。(1See:The Practical Guide To Structured Systems Design , by Meilir Page-Jones, Yourdon Press,1988) 它表明在应用中有3个模块或者子程序。Copy模块调用其他的两个模块。人们很容易想象,在Copy模块内有一个循环(见清单1.)。该循环体中调用Read Keyboard模块从键盘那里获取一个字符,它然后将那个字符发送到Write Printer模块以打印该字符。
清单1:“复制”程序
void Copy()
{
int c;
while ((c = ReadKeyboard()) != EOF)
WritePrinter(c);
}
这两个低层模块具有好的复用性。它们可以用在很多其他程序里,以访问键盘和打印机。 这和我们从子程序库中获得的复用性是相同。
但是Copy模块在不包括键盘或者打印机任何上下文里是不可复用的。让人脸红的是系统的智能就靠这个模块来维持。正是Copy模块封装了我们想复用的非常有趣的策略(policy,功能)。【我们的不希望像C语言那样,处在子程序库的复用水平,我们希望能复用Copy模块,它是我们程序的主要功能模块。】
举例来说,考虑一个新程序,它把键盘输入的字符复制到磁盘文件的。显然,我们希望复用Copy模块,因为它封装了我们需要的高层功能,就是说,它知道如何从一个来源复制字符到一个接受器。不幸的是,Copy模块依赖于Write Printer模块,因此不能在新上下文里被复用。
当然,我们能修改Copy模块以赋予它新的所希望的功能性(见清单2)。
清单2:“增强”的“复制”程序
enum OutputDevice {printer, disk};
void Copy(outputDevice dev)
{
int c;
while ((c = ReadKeyboard()) != EOF)
if (dev == printer)
WritePrinter(c);
else
WriteDisk(c);
}
我们在其中(its policy??)增加一个if语句,它依赖某种标志在Write Printer 模块和Write Disk 模块之间做出选择。但是,这就给系统添加了新的相互依赖性。慢慢地,当越来越多的设备必须加入到“复制”程序,该Copy模块塞满了if/else语句,并且将依赖很多较低层的模块。最终它变得硬而脆。
§依赖倒置
刻画上述问题的一种方法是,留意到包含高层功能的模块(如copy()模块)依赖于它所控制的低层更细节的模块(例如:. WritePrinter() 和 ReadKeyboard())。如果我们能找到新的途径使copy()模块不依赖于它控制的细节,那么,我们就能自由地复用它。我们能开发出其它的程序,其中使用这个模块从任何输入装置复制字符到任何输出装置。OOD给了我们一个机制以实现这种依赖倒置。
图2:面向对象的“复制”程序
参考图2这个简单的类图(class diagram)。 这里我们有一个Copy类,它包含(contains!***)了一个抽象类Reader和一个抽象类Writer。容易想象,在Copy类中有一个循环,从它的Reader中获取字符,并将字符发送给它的Writer(见清单3)。
清单3:面向对象的“复制”程序
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);
}
}
此时Copy类压根的既不依赖于Keyboard Reader也不依赖于Printer Writer。因此,依赖性已经被倒置;Copy类依赖于抽象(抽象类),而具体的读取器和写出器依赖相同的抽象。【这里,depends upon用得很随意。】
现在,我们能够复用Copy类了,它不依赖于“Keyboard Reader” 和 “Printer Writer”。 我们能发明各种新的Reader和Writer的派生物,以支持我们的Copy类。更绝的是,不管多少种(具体的)读取器和写出器被创造出来,Copy类将不会依赖它们中任何一个。这里没有相互依赖性去导致设计变得刚性或者脆弱,而Copy()【函数?】本身可以被很多不同的上下文使用。 它是可移植的。
设备独立性
到这里,或许有人喃喃自语,我能通过使用stdio.h固有的设备独立性(即getchar 和putchar),用C编写Copy()函数以达到相同的效果(见清单4)。
清单4:使用stdio.h的“复制”程序
#include <stdio.h>
void Copy()
{
int c;
while((c = getchar()) != EOF)
putchar(c);
}
如果你仔细考虑清单3 和4 ,你将意识到两者是逻辑等效的。在图3中的抽象的类被清单4中另一种不同的抽象所替换。的确,在清单4没有使用类和纯虚函数(pure virtual functions),然而它仍然使用了抽象和多态达到目的【呵呵,C的抽象和多态!!!】。而且,它仍然使用依赖倒置!在清单4中Copy程序不依赖任何其控制的细节,相反它依赖在stdio.h里声明的抽象设备。而且,最终被调用的IO设备也依赖在stdio.h里声明的抽象。因此,在stdio.h库内的设备独立性是依赖倒置另一例子。
既然我们已经见了一些例子,我们能说明DIP的一般形式。
§依赖倒置原则
A .高层模块不应该依赖低层模块。两个都应该依赖抽象。
B . 抽象不应该依赖细节。细节应该依赖抽象。
有人可能会问,为什么我要使用单词"倒置"(“inversion”.)。坦白地说,这是因为比较传统的软件开发方法——例如结构化分析和设计,倾向于创建这样的软件结构:高层模块依赖于低层模块,并且抽象依赖细节。的确,这些方法的一个目标在于定义一个子程序层次以描述高层模块如何调用低层模块,图1是一个这样层次的好例子。因此,一个设计良好的面向对象程序的依赖结构,对应于传统的过程式方法通常会形成的依赖结构,是"倒置"的。
想想高层模块依赖于低层模块的寓意。是高层模块包含一个应用的重要策略决定和商业模式,是这些模块包含应用的身份。然而,当这些模块依赖较低级的模块, 低层模块的改变就对它们有直接的影响,并且迫使他们去改变。
这种困境是荒谬的!是高层模块应该迫使低层模块改变。 是高层模块应该优于低层模块。无论如何,高层模块完全不应该依赖低层模块。
而且,高层模块才是我们想要复用的。 我们已经十分擅长以子程序库的形式复用低层模块。当高层模块依赖低层模块,在不同的上下文中复用那些高层模块就非常困难。另一方面,当高层模块不依赖低层模块时,高层模块可以被十分简单复用。
正是这个原则,它是框架设计(framework design.)的核心。
分层(Layering)
按照Booch的说法, "所有良好结构化的面向对象架构(well structured object-oriented architecture)都有定义清楚的层次,通过【though似乎应该是through】定义良好及受控的接口(interface)使每个层提供某种相关的一些服务"。
图3:简单的分层
这句话的一种幼稚的(naive)解释会导致设计者搞出一个类似于图3的结构。在这张图解里,高层的类policy(策略)使用一个较低层的Mechanism(机制); (后者)依次的使用一个细节的层utility(工具)类。这种情况也许看起来是恰当的,但这里存在一个随时会引爆的地雷(the insidious characteristic)——policy层对于深入到Utility层内的所有方式的改变都是敏感的。(因为)依赖是可传递的。policy层依赖某些东西,而某些东西又依赖utility层,故而policy层传递性的依赖utility层,这非常不幸。
图4显示一个更合适的模型。每个较低级别的层由一种抽象类(abstract class)描述,而实际层从这些抽象类派生。每种较高级的类通过该抽象接口使用下一个较低的层次。 因此,每个层都不依赖任何其它层。相反,层依赖抽象类。不仅Policy层对utility层的传递性依赖被断开,甚至Policy层对Mechanism层的直接依赖也被断开。
使用本模型,Mechanism层或者utility层任何变化都不会影响Policy层,而且,对于所定义的符合Mechanism层接口的低层模块之任何上下文中,Policy层都能复用。因此,通过倒置依赖性,我们就构建了兼有更灵活,更耐用,可移植性更强的结构。
C++中的接口与实现相分离
有人会抱怨道,图3中的结构并不存在我声称的依赖和传递性依赖问题,不管怎么说,Policy层仅仅依赖于Mechanism层的接口。Mechanism层的实现(implementation)的变化怎么会向上最终影响到Policy层呢?
在一些面向对象语言中,这可能是真的。在那些语言中,接口自动的从实现中分离开来。【表扬Java一下。】然而,在C++中,接口与实现并不存在分离。准确的说,C++中,分离的是类的定义和它的成员函数的定义。
C++中,我们通常把一个类分为两块,a.h和a.cc;.h文件包含类的定义。. cc文件包含类的成员函数的定义。类的定义——在.h文件中,还包含了所有类的所有成员函数和所有成员变量的声明。这种信息超出了简单的接口。类所需要的所有的功能函数(utility functions)和私有变量也都在.h文件中声明。这些功能和私有变量是类的实现的一部分,而它们出现在其中的模块是该类的所有用户都必须是依赖的。因此,C++中,实现并不是自动与接口相分离的。
C++中,接口与实现相分离的缺欠,能够使用纯粹的抽象的类(purely abstract class)来处理。纯抽象类是这样的类,它除了包含纯虚函数外,一无所有。这样的类才是纯粹的接口,并且它的.h文件不包含实现。图4展示了这一结构。图4中的抽象类都意味者是纯抽象(类)以使得每个层仅仅依赖于子层的接口。
§一个简单的例子
无论在哪里,一个类向另一个类发送消息时都能应用依赖倒置。例如,考虑一下Button对象和Lamp对象的案例。
Button(按钮)对象能感知外部环境。它能测定用户是已经“按”还是没有按它。这种感知的机制是什么并不重要。它可能是GUI上的一个button图标,或者被人的指头按的物理按钮,甚至是家用安全系统的运动探测器。Button(按钮)对象探测用户是激活它还是使其失效(开或关)。Lamp(灯)对象感受(affects)外部环境(的刺激)。一旦接受到TurnOn(打开)的消息,灯就发出某种形式的光。当接受到TurnOff(关灯)消息,就熄灭该光。其物理机制不重要,它可能是电脑控制台上的一个LED,停车场的mercury vapor lamp或是一个激光打印机的激光。
我们怎样设计一个系统使Button对象控制Lamp对象呢? 图5展示了一个幼稚的模型。Button对象简单的发送TurnOn 和TurnOff消息给Lamp对象。方便起见,Button类使用“包含”的关系来拥有一个Lamp类的实例。列表五显示了这个模型的C++代码。
图5:幼稚的按钮/灯模型
清单5:幼稚的按钮/灯代码
--------------lamp.h----------------
class Lamp {
public:
void TurnOn();
void TurnOff();
};
-------------button.h---------------
class Lamp;
class Button{
public:
Button(Lamp& l) : itsLamp(&l) {}
void Detect();
private:
Lamp* itsLamp;
};
-------------button.cc--------------
#include “button.h”
#include “lamp.h”
void Button::Detect(){
bool buttonOn = GetPhysicalState();
if (buttonOn)
itsLamp->TurnOn();
else
itsLamp->TurnOff();
}
注意:Button类是直接依赖于Lamp类的。实际上,button.cc文件#include了lamp.h文件。这种依赖性暗示,无论Lamp类何时改变,Button类就必须改变,或者至少要重新编译。而且,复用Button类来操作马达对象是不可能的。图5和列表5违反了依赖倒置原则。应用的高层(policy)没有与低层模块相分离;抽象没有与细节相分离。没有这样的分离,高层自动的依赖于低层模块,并且抽象也自动的依赖于细节。
发掘潜在的抽象
什么是高水平的策略?正是抽象作为应用的基础,原理(truths)是不随细节的变化而改变。在Button/Lamp的例子中,潜在的抽象是识别出用户的on/off信号并将这个信号传递到目标对象。识别用户信号的机制是什么?管它呢!目标物体是什么?管它呢!这些都是不影响抽象的枝末细节。
为了与依赖倒置原则相符,我们必须把这种抽象与问题的细节相隔离。因而我们必须指导依赖性的设计,让细节依赖于抽象。图6显示了这种设计。
图6中,我们已经将Button类的抽象从它的详细实现中隔离出来。列表6给出了相应的代码。注意:高层的policy完全封装(captured)在抽象Button类中,Button类对检测用户信号的物理机械作用全然不知;对lamp更是一无所知。这些细节隔离到具体的派生物中:ButtonImplementation和Lamp。
列表6中高层策略能够复用到任何按钮上,以及任何类型的需要控制的设备。而且,它不受低层机制变化的影响。 因此它在变化面前是鲁棒的,灵活的,且可复用的。
----------byttonClient.h---------
class ButtonClient
{
public:
virtual void TurnOn() = 0;
virtual void TurnOff() = 0;
};
-----------button.h---------------
class ButtonClient;
class Button
{
public:
Button(ButtonClient&);
void Detect();
virtual bool GetState() = 0;
private:
ButtonClient* itsClient;
};
---------button.cc----------------
#include button.h
#include buttonClient.h
Button::Button(ButtonClient& bc)
: itsClient(&bc) {}
void Button::Detect()
{
bool buttonOn = GetState();
if (buttonOn)
itsClient->TurnOn();
else
itsClient->TurnOff();
}
-----------lamp.h----------------
class Lamp : public ButtonClient
{
public:
virtual void TurnOn();
virtual void TurnOff();
};
---------buttonImp.h-------------
class ButtonImplementation
: public Button
{
public:
ButtonImplementaton(
ButtonClient&);
virtual bool GetState();
};
抽象的更进一步扩展
人们(Once? ones)能对Figure/Listing 6的设计提出正常的埋怨。由button控制的设备必须派生于ButtonClient。如果Lamp类来自第三方库,并且我们不能修改源代码该怎么办。
图7:Lamp适配器
图7示范了如何使用适配器模式将第三方Lamp对象连接到模块上的。LampAdapter类简单地将从ButtonClient中继承的TurnOn和TurnOff消息转化为Lamp类需要明白的那些消息。
结论
依赖倒置原理是面向对象技术宣称的很多优越性的根源。对其适当的应用是创造可复用框架所必要的。要想构造出对变化富于弹性的代码,该原理也极其重要。既然抽象和细节彼此被完全隔离,代码就非常容易维护。
这篇文章是我的新书——《模式和面向对象设计高级原则》,很快将由Prentice Hall出版——中一章的高度浓缩版本。在后面的文章中,我们将探索面向对象设计许多其他原理。我们还将会学习各种设计模式(design patterns),以及它们联系到C++实现时的强大或薄弱之处。我们还将在C++中学习Booch的类理论(class categories),它们作为C++命名空间(namespaces)的适用性。我们还将定义OOD中什么是"内聚性(cohesion )"(cohesion )和"耦合"(coupling),并且我们将会开发出衡量面向对象设计质量的方法学(metrics)。此后,我们将会讨论许多其他感兴趣的论题。
另外的一篇: