原文链接
by Robert C. Martin(Uncle Bob)
前言
这篇文章写于1996年,里边清晰而简明的介绍了“依赖反转原则”,对了解Dependency Injection很有帮助。今天看来,仍然具有很好的释疑作用。
介绍
我上一篇96年的文章谈到了 Liskov Substitution Principle(LSP)。 这个原则应用到了C++时,为使用公开(public)继承(inheritance)提供了指导。 该原则说,每一个操作一个基类的引用或者指针的函数,都应能够同样操作该基类的派生类,即使其对该派生类一无所知。这就意味着,派生类的虚成员函数必须与基类的虚成员函数保持一致,并且不应该做更多的事情。也就是说,基类中的虚成员函数必须要在派生类中,并且保证只做有用的工作。如果违背LSP原则,操作基类的引用或指针的函数将不得不检查具体对象的类型,以确保操作正确。而检查对象类型则违反了上周讲到的Open-Closed Principle(OCP)。
在此次专栏里,我们将讨论OCP与LSP结构化带来的启示。 严格使用这些原则所产生的结构,可以被一般化成一个更为基本的原则,我称之为“Dependency Inversion Principle”(DIP)。
软件出现了什么问题?
大部分人都与有着”糟糕设计“的软件打过交道。有些人则往往发现,自己就是那些具有“糟糕设计”的软件的作者。那么,究竟是什么让一个设计变得如此糟糕了?
大多数软件工程师并不准备创造”糟糕的设计”。然后大多数软件最终都沦为到某个一个点:有人宣称这个设计很不靠谱。为什么发生这种事了?是否设计一开始就很糟糕,抑或设计实际上像一块腐肉一样变得越来越烂?该问题的关键在于,我们缺乏一个”糟糕设计”的好的定义。
什么是糟糕的设计
你是否曾提交过一个令自己感到自豪的软件设计,被同行所评审?而那个评审人员却发牢骚似的讥讽道,“你为什么要这么设计”。当然,这时在我身上发生过,我也见过它发生在其他很多工程师身上。很明显,有不同意见的评审工程师并没有使用相同的标准去定义糟糕的设计是什么。我见过最常使用的原则是,“这不会是我实现它的方式”。
但是,我以为应该有一套标准为所有的工程师所认同。一个软件如果满足其设计需求,但是仍然表现出如下三个特征中的任何一个的话,就是一个糟糕的设计:
- 难以修改: 每个修改都会影响到系统的很多部分(Regidity)
- 修改时,系统某些部分难以预料出现崩溃(Fragility)
- 因为无法从当前应用中分离出来,很难在其他应用中复用(Immobility)
而且,很难说,一个软件并没表现出上述三个特征中的任何一个的话,它做到了可扩展性(flexible),健壮性(robust)以及可复用性(reusable),并且满足了设计需求,会有糟糕的设计。因此,我们可以用上述三个特征来清晰的区分什么是好的设计,什么是坏的设计。
糟糕设计的源头
什么导致一个设计缺乏可扩展性、健壮性以及可复用性了?设计里相互依赖的模块。一个设计不容易被修改,则不可扩展。如此僵化源自于一个事实,修改一个模块之间严重依赖的软件会导致一系列其他的相关模块的改动。当此种修改的范围超出设计者或者维护人员的预期时,修改的影响是无法估计的。这使得修改的代价难以预料。项目管理人员,面临如此不可预料的改动,常常不愿意授权修改。如此,这样的修改就被正式确定下来。
不具备健壮性,意味着一个小的修改都会引起程序其他部分发送崩溃。往往,新问题的发生点与修改点并没有概念上的关联。这种设计上的脆弱很大程度上降低了设计与维护公司的信用度。用户与管理人员无法预料他们产品的质量。在应用一个部分的小改动导致了其他毫不相关部分的崩溃。修复这些问题导致更多的问题,致使维护过程变成了一个狗追咬自己尾巴的恶性循环。
一个设计不可复用,是由于期望设计的部分严重依赖于其他部分的实现细节。如果设计考量下该设计是否可能在其他应用中复用,可能他会对该设计在新的应用中的表现有更深的印象。倘若该设计模块之间高度依赖,设计者们同样会被将需要的部分与不需要的部分分离所苦恼。大部分情况下,这样的设计不可复用,是因为分离的代价要远高于重新开发设计的代价。
示例: “Copy”程序
为了更好的阐述上述观点,先看个简单的例子。考虑实现一个将键盘输入的字符拷贝到一个打印机上的程序。假定,实现平台并没有一个支持设备独立的操作系统。这个程序的结构看起来如下图1所示:
图 1 是一个框架图:程序由3个部分组成,”Copy”模块调用其他两个模块。其对应的代码实现如下所示:主体循环不断地调用“读取键盘”模块,以获取键盘输入字符,然后将字符发送到“写打印机”模块以打印字符。
1 | void copy(){ |
两个低层模块具有很好的可复用性。他们可以用在其他程序中操作键盘与打印机。这与从库中调用子程序很类似。
但是,如果没有键盘或者打印机的时候,这个“Copy”模块就不具备可复用性了。这个系统所蕴含的智慧仅仅用在了这个模块,这真是让人惭愧。正是”Copy”模块蕴藏了一个我们可以复用而很有趣的策略。
例如,考虑一个将键盘字符拷贝到磁盘文件的新程序。当然,我们希望使用已有的“Copy”程序,其包含了一个我们需要的高层策略;它知道如何将字符从一个源拷贝到另一个源。不幸的是,“Copy”模块依赖于“Write Printer”模块,因此在此种情况下,不能使用。
为此,我们可以赋予”Copy”模块一个新的功能(代码如下)。通过对输出设备进行判定,来决定是写入“Printer”还是“Disk”。但是,这却增加了系统彼此依赖的程度。随着时间的推移,越来越多的设备希望使用复制功能,而“Copy”模块则会被一长串的 if/else弄凌乱,也因此它需要依赖于更底层的模块。最后,代码的可复用性与健壮性都会受到影响。
1 | enum OutputDevice { printer, disk} |
依赖反转(Dependency Inversion)
一个描述上述问题的方法是,注意到包含了上层策略的“Copy”模块,依赖于底层它控制模块的实现细节。如果,我们能找到一个办法,使”Copy()”模块独立于它控制的细节的话,代码的可复用性就大大提高了。我们可以使用该模块从任何设备拷贝字符,然后复制字符到任意的输出设备。面向对象设计(OOD)给了我们一个实现依赖反转的机制。
考虑下图2的类结构图:有一个Copy类,包含了一个抽象的Reader类、Writer类。不难以此写出一个从Reader获取字符然后发送到Writer的”Copy“类(见下列代码)。这个“Copy”类不依赖于“KeyBoard Reader”,也不依赖于”Printer Writer”。因而依赖被反转了;”Copy”类依赖于抽象,并且reader与writer的具体实现依赖于同一个抽象。
现在,只要派生出“AbstractReader”以及”AbstractWriter“,我们可以复用“copy”类。并且,不管有多少新的”Reader”,”Writer”,“Copy“都不会依赖于它们。这里并没有什么相互依赖使得程序变得不可复用或者健壮性差。而且,Copy类可以再不同的输入输出环境里使用。同样满足了可复用性。
1 | class Reader{ |
设备无关
到现在为止,很多人可能会想,利用stdio.h
中的设备无关性,完全可以用C来实现同样的 Copy功能:getChar()
,putChar()
(具体代码如下所示)。如果你仔细考虑下上一节中OO版本的代码与C版本的代码,你会认识都两者在逻辑上完全是等价的。图3 中的抽象类被另一种形式的抽象替代了。尽管,C版本代码没有使用类和纯虚函数,但它仍然使用了抽象与多态来实现相同的目的。而且,也同样使用了依赖反转:Copy 并不依赖于任何实现的细节,而是依赖于stdio.h
提供的抽象;调用的IO驱动也依赖于stdio.h
中的抽象。因此,stdio.h
中的设备无关性是另一个依赖反转的栗子。
1 |
|
看过了几个例子之后,我们来叙述下DIP的更一般形式。
The Dependency Inversion Principle
- High Level modules should not depend upon low level modules. Both should depend upon abstractions.(上层模块不应该依赖于底层模块;两者都应该依赖于抽象)
- Abstractions should not depend upon details. Details should depend upon abstractions(抽象不应该依赖于细节;细节应该依赖于抽象).
有人可能疑惑,我为什么要用“inversion”(反转)。坦白说,是因为传统的软件开发技术,如结构化分析与设计,通常创造出一种上层依赖于底层的软件结构,而且抽象依赖于细节。此种方法的目标之一是定义描述上层模块如何调用下层模块的子程序层级结构。下图3是一个很好的示例。因此,相对于传统的面向过程的方法,一个设计良好的OOP程序结构被“反转(inverted)”了。这是中间层设计的核心原则。
分层
根据Grady Booch的说法,“所有结构良好的OO架构都有很清晰的分层结构,每一层通过清晰定义的接口提供了一套一致性的服务”,简单的利用该原则,我们可以将图3中的结构改变成类似的结构。在这个结构图中,上层Policy类使用底层的机制,而该机制反过来使用了一个细节化的Utility类。尽管这种方法看起来不错,但它有一个隐藏的要害:Policy层容易受到Mechanism层一直到Utility层的影响。依赖具有传递性。Policy层依赖于Utility层的某些接口,因此Policy层传递性的依赖于Utility层。这很不幸。
图4展示了一个更为合适的模型。底层都是由抽象接口来表示。实际的实现都是直接继承了该接口,而上层则通过接口与底层进行交互。因此,各个层之间是相互独立的。相反,层是依赖于抽象接口。这样,Policy层与Utility层之间的依赖传递被阻断了,并且Policy层与Mechanism层的直接依赖关系也被打破了。
使用这个模型,Policy层不会受到Mechanism层或者Utility层变化的影响。更重要的是,只要提供了一个与Mechanism接口一致的底层模块,Policy层可以被复用。因此,通过反转依赖,我们创造出来一种同时具备可扩展性、可持续性以及可复用性的结构。
在C++中将接口与实现分离
有人可能抱怨说,图3的结构并没有表现我所说的依赖关系以及依赖传递。毕竟,Policy层仅仅依赖于Mechanism层的接口。为什么Mechanism层的修改会影响到Policy层了?
对某些OO编程语言来说,确实如此。在这些语言中,接口(interface)与实现(implementation)是自动分离的。然而,在C++中,并没有将接口与实现分离,相反,这种分离存在于类的定义以及类的成员函数定义之间。
在C++中,我们通常将一个类分成两个模块:.h
和.cc
。.h
模块包含了类的定义,.cc
文件包含了类成员函数的定义。在.h
类的定义中,包含了所有成员函数与变量的声明。这种声明超过了简单的接口。所有通用的函数以及私有变量都在.h
模块中声明了。这些通用函数以及私用变量是类实现的一部分,然后他们出现在用户需要依赖的模块中了。因此,在C++中,实现并没有与接口分离。
接口与实现分离的情况可以通过纯虚类来处理。一个纯虚类是一个只有纯虚函数的类。由于.h
文件中并没有该类的实现,因此纯虚类是一个单纯的接口。图4就是这样的结构:抽象类是纯抽象的,以至每一层都仅仅依赖于下一层的接口(interface)。
一个简单的示例
只要一个类需要发送消息给另一个类,依赖反转就适用。接下来,以一个Button对象和Lamp对象为例说明这种情况。
Button对象感知外界环境,确定是否有用户按下。具体的原理怎么样,并无关系,它可以是一个GUI界面上的按钮图标,一个被手指按压的物理按钮,甚至可以是一个家庭安全系统中的运动检测器。Button对象检测是否有用户按下。Lamp对象在接收到TurnOn的信息之后,点亮一个类似于灯的东西;如果接收到TurnOff的消息,则熄灭灯。
怎么才能设计一个Button对象控制Lamp对象的系统了?下图5是该系统的一个简图。Button对象只是将TurnOn/TurnOff发送给Lamp对象。为实现一目的,Button类包含了一个Lamp类作为类成员。
下列代码实现了图5中的模型。注意到,Button类直接依赖于Lamp类。这意味着,如果Lamp类改变了,Button至少需要重新编译。而且,用来控制一个Motor对象,Button类就不可复用了。因而,该实现违背了DIP原则:上层并没有与底层分离开;抽象也没有与细节分离。没有这样的分离,上层会自动依赖于底层,而抽象也会自动依赖于细节。
1 | // lamp.h |
寻找潜在的抽象
什么才是上层需要的策略?是构成应用的某种抽象,细节的变化并不会引起上层变化的事实。在Button/Lamp示例中,潜在的抽象是,从用户那里检测on/off的动作,并将其传递给目标对象。究竟是什么机制来检测该动作?这毫不相关。什么是目标对象?毫不相关!这些实现的细节不应该影响抽象。
为了实现DIP原则,必须要讲问题的抽象与实现细节分离。因此,需要将设计的依赖换一个方向,使得细节依赖于抽象。下图6展示了这样一个设计。
在图6中,我们将Button类的抽象与其实现的细节隔离开来。下面是其实现的代码。现在上层的策略全部在抽象的Button类中了。Button类不知道任何检测用户物理状态的机制。这些细节全部被隔离在具体的派生类:ButtonImpl以及Lamp类。
下列代码中的上层策略可以针对任何按钮与设备复用,而且它并不会受到底层机制变化的影响。因此,它具有很好的健壮性,可扩展性以及可复用性。
1 | // buttonClient.h |
进一步扩展抽象
有人也许会对图6中的设计抱怨说,被Button控制的设备必须从ButtonClient中派生出来,假如Lamp类来自第三方库的话,我们无法修改源代码了。下图7展示了如何用Adapter模式将第三方库的Lamp对象加入到该模型中来:LampAdapter将继承自ButtonClient的TurnOn/TurnOff消息转换成任何Lamp类可以理解的消息。
结论
依赖反转的原则深植于许多面向对象技术的优点之中。它可以很好的应用于需要创建可复用中间件的情况。同时对于构建不易受变化影响的代码也是至关重要的。并且,由于抽象与细节隔离开来,代码的往往更容易维护。
这篇文章是我即将被Printice Hall出版的新书Patterns and Advanced Principles of OOD一章的压缩版。在接下来的系列文章中,我们将探讨许多面向对象设计的原则,研究不同设计模式在C++实现中的优点与缺点。我们会对“cohesion”(内聚)与“coupling”(耦合)进行定义,也会发展一种衡量面向对象设计质量的标准。最后,我们还会讨论其他许多有趣的主题。