翻译 - The Dependency Inversion Principle 依赖倒置原则
作者: Robert C. Martin
原文连接: The Dependency Inversion Principle
本翻译未经授权,仅做为私人笔记记录,转载传播责任自负
这是我在C++报导工程笔记专栏的第四篇。这个专栏的主题是C++和OOD的使用,以及解决软件工程方面的问题。我尽力写一些实用性的文章,为奋斗中的软件工程师提供一些帮助。这些文章将使用Booch和Rumbaugh新的统一建模语言(UML版本0.8)描述面向对象设计,侧栏图片显示了主要表的达元素
侧栏图
介绍
上一篇文章(96年3月)介绍了里氏代换原则(Liskov Substitution Principle, LSP),这个原则为C++中继承的使用提供了指导。文中我们提到如果函数使用使用了基类的引用或指针进行操作,就应当能够操作这个基类的派生对象而无需了解它们。这意味着派生类的虚成员函数必须与基类相应成员函数保持一致,其行为不能越轨也不能偷工减料。另外也意味着基类中的虚成员函数也必须出现在派生类中,它们必须发挥有效的作用。违背这一原则时,使用了基类的指针或引用的函数需要检查实际对象的类型,以决定是否可以正确的对它们进行操作。检查类型的要求违背了开闭原则,我们在1月份刚讨论过
我们已经讨论了OCP和LSP,严格使用这些原则本身可以概括成一个原则,我把它叫做依赖倒置原则
软件出了什么问题?
我们中大部分人有过处理"Bad Design"软件的不愉快经历,有些人甚至更懊恼的发现自己就是"Bad Design"的作者,是什么使软件设计很糟糕呢?
"Bad Design"并非大部分软件工程师的初衷,然而大部分软件最终都被某些人确定为设计不好。为什么?是设计开始的太仓促,还是像腐烂的肉一样慢慢变味?问题的关键还包括对"Bad"缺乏有效定义
"Bad Design"的定义
你是否曾经将某个引以为荣的设计提交给同行评审,同行却嗤之以鼻的说:"你怎么这么做?"。当然我以前也遇到过,也见过很多别的工程师同样遇到过。很明显意见分歧的工程师对"Bad Design"的判断标准不同,见过最多的标准是"我不会用这种方式来做"
我认为下面一系列准则是所有工程师都应当认可的。符合需求的软件如果具备部分或者全部这些特征就是Bad Design
1. 难以改变,因为每个修改都给系统其它部分造成太大影响(笨拙)
2. 修改可能给系统中意想不到的部分造成破坏(脆弱)
3. 难以在其它应用中复用,因为无法从当前应用中剥离出来(不可移植)
此外很难发现一个可扩展、健壮、可复用,符合所有需求并且不具备上面这些特征的软件是Bad Design,因此我们可以使用这3个特征清晰的确定一个设计是"Good"还是"Bad"
"Bad Design"的原因
是什么导致设计变得笨拙、脆弱、无法移植呢?是模块间的相互依赖性。如果模块不容易修改它就是笨拙的,笨拙的产生原因是严重的耦合,软件中单一的变更央及到一系列相关模块,当设计师或维护者无法确定这一系列变更时,就无法评估变更造成的影响,导致变更的成本无法确定。面对这样的不确定性管理者也难以决断,因此设计被正式的确定为笨拙
脆弱表现为单一的修改可能破坏程序的许多方面,而这些方面与修改的地方毫不相干。这种脆弱性极大的降低人们对设计和维护团队的认可,用户和管理者无法确定产品的质量。程序某个地方的一个修改导致其它毫不相关的部分发生问题,修改好这些问题又带来了更多其它问题,维护过程变得周而复始无法停止
设计无法移植表现为希望复用的部分高度依赖其它不需要的部分,设计者检视自己的设计是否可用于其它应用程序中可以改善这种状况。如果设计高度耦合,在把需要复用的部分与不需要的部分分离开时,设计者也可能对工作量赶到沮丧。大部分情况下这样的设计不会复用,因为分离的成本可能高于重新开发
示例: Copy程序
一个简单的示例有助于理解这些观点。假设有个简单的程序,它负责将键盘输入拷贝到打印机,假设实现平台上没有设备无关的操作系统。我们可能会像图1一样勾画这个程序的结构
图1 拷贝程序
图1是一幅结构图,它表示程序有三个模块或者子程序,由Copy模块调用其它2个模块。人们可能在Copy模块中使用一个循环进行处理(参看列表1),在循环体中调用"Read Keyboard"模块从键盘读取一个字符,然后将它发送给"Write Printer"模块进行打印
底层的2个模块很好复用,它们可以用于其它程序访问键盘和打印机,这和子函数库的复用类似
列表1: Copy程序
比如一个程序需要将键盘输入字符拷贝到磁盘文件中,当然我们希望复用Copy模块,因为它封装了一些我们需要的高层策略,例如它知道怎样将字符从输入源发送给接收器。不幸的是Copy模块依赖于"Write Printer"模块,无法在这个新的程序中复用
当然我们可以修改Copy模块给它赋予我们期望的功能(参考列表2),我们可以在处理中增加一个if语句,让它根据某些标记在"Write Printer"和"Write Disk"之间做出选择。但这样给系统带来了新的依赖性,随着时间推移需要在拷贝程序中添加越来越多的设备,Copy模块将堆满if else语句,依赖很多底层模块,最终变得笨拙、脆弱
列表2: Copy程序的增强版
依赖倒置
分析上面问题的一种方式是关注Copy()模块,它包含高层策略,依赖于它控制的底层细节性模块(例如WritePrinter()和ReadKeyboard())。如果我们可以采用方法使Copy()模块和这些细节部分独立开来,那我们就可以任意复用了。我们可以使用这个模块创建其它程序,将字符从任意输入设备拷贝到任意输出设备。OOD为我们提供了一种机制来处理这种依赖倒置
图2: OO版拷贝程序
查看图2中的简单类图,Copy类包含一个抽象Reader类和一个抽象Writer类,Copy类可以从Reader读取字符将它发送给Writer(查看列表3)。然而Copy类根本不依赖"Keyboard Reader"和"Printer Writer",因此依赖性被反转了,Copy类和细节的readers、writers都依赖于相同的抽象
列表3: OO版拷贝程序
设备无关
现在有些人可能会想到,使用C自带的设备无关stdio.h实现Copy()也可以达到同样的效果,比如使用getchar和purchar(参考列表4)。如果仔细观察列表3和4,会发现它们逻辑上是一样的,图3中的抽象类在列表4中由不同形式的抽象替代了,当然列表4并没有使用类和虚函数,但同样使用了抽象和多态达到了目的。此外他也运用了依赖倒置!列表4中的拷贝程序同样不依赖他控制的任何细节模块,他依赖stdio.h中声明的抽象工具类,最终被调用的IO驱动同样依赖stdio.h中的抽象,因此stdio.h库中的设备无关性是依赖倒置的另一个例子
列表4: 使用stdio.h的拷贝程序
依赖倒置原则
A. HIGH LEVEL MODULES SHOULD NOT DEPEND UPON LOW LEVEL MODULES. BOTH SHOULD DEPEND UPON ABSTRACTIONS.
高层模块不应当依赖底层模块,它们都应当依赖于抽象
B. ABSTRACTIONS SHOULD NOT DEPEND UPON DETAILS. DETAILS SHOULD DEPEND UPON ABSTRACTIONS.
抽象不应当依赖细节,细节应当依赖于抽象
有人可能问我为什么使用"倒置"这个词,坦白的说是因为很多结构化分析设计的传统软件开发方法中,习惯在软件中使高层模块依赖底层模块,抽象依赖细节。这些方法的目标之一是定义子程序体系结构,描述高层模块如何调用底层模块。图1就是这种体系结构一个很好的例子。正因为这样,拥有良好设计的面向对象程序,其依赖结构相对于传统过程式方法是"颠倒过来的"
看一下高层模块依赖底层模块的结果。高层模块包含了重要的决策处理以及应用程序的业务模型,这是应用程序的特征,当这些模块依赖底层模块时,修改底层模块将直接影响到它们,迫使它们也得修改
这种现象是荒谬的,应当是高层模块迫使底层模块修改,高层模块优先于底层模块,高层模块无论如何也不能依赖底层模块
此外我们希望高层模块可以复用,使用子函数库的形式我们在底层模块复用方面已经做的很好了。高层模块依赖底层模块时它们很难复用于不同环境,而不依赖底层模块时则很容易
依赖倒置是框架(framework)设计的关键
分层
Booch说: "...所有结构良好的面向对象架构拥有清晰的分层,每个分层通过良好定义的接口提供条理分明的一系列服务"。对这句话粗浅的理解可能导致设计者创建类似图3的结构,图中高层Policy类使用底层Mechanism,而Mechanism使用细节的Utility类。虽然这看起来是正确的,但潜在的缺点是Policy层受底层包括Utility层修改的影响,依赖具有传递性,Policy层依赖了某些层,而这些层又依赖Utility层,因此Policy层也就依赖Utility层了,这很糟糕
图3: 简单分层
图4显示了一个更正确的模型,每个较低的层都使用抽象类表示,实际的层由这些抽象类划分开,较高的层都通过抽象接口使用下一层,因此没有任何一层依赖其它层,它们都只是依赖于抽象类。这样不仅消除了Policy层对Utility层的依赖,也消除了Policy层对Mechanism层的直接依赖
图4: 抽象分层
使用这个模型Policy层不受Mechanism层和Utility层的影响,此外Policy层可以在实现了Mechanism层接口的其它环境中复用,因此通过倒置依赖关系,我们创建了一个更好扩展、耐用、可复用的结构
在C++中分离接口与实现
有人可能提出异议,图3并没有说明依赖,不存在我说的依赖传递性,毕竟Policy层只是依赖Mechanism层的接口,为什么对Mechanism层实现的修改会影响Policy层呢?
在某些面向对象语言中这种说法是对的,这些语言中接口自动与实现分离。然而C++中接口与实现不是分离的,C++中类的定义和成员函数的定义才是分离的
C++中我们通常将类分成2个模块:一个.h模块和一个.cc模块。.h模块包含类的定义,.cc模块包含类成员函数的定义。.h中类的定义包含类的所有成员函数和成员变量的声明,这些信息与简单的接口差别很大。另外所有用到的工具性函数和私有变量也是类实现(class implementation)的一部分,然而它们出现在所有类的客户端需要依赖的模块中,因此C++中接口与实现不是自动分离的
C++中这种接口与实现分离机制的缺乏可以通过纯抽象类弥补,纯抽象类是只包含纯虚函数的类,这样的类是纯粹的接口,它的.h模块不包含实现。图4显示的就是这样一种结构,图中的抽象类是纯抽象的因此每个层只是依赖相关层的接口
一个简单示例
一个类需要向另一个类发送消息时都可以使用依赖倒置,例如可以看一下Button(开关按钮)对象和Lamp(灯)对象的例子
Button对象感应外部环境,它可以确定用户是否有"按下"按钮。通过什么机制接受"按下按钮"事件并不重要,可能是GUI上的一个图标,可能是一个真正的按钮被手指按下,甚至可能是家庭安全系统中的一个行为感应器,Button对象检测用户的打开或关闭灯的事件。Lamp对象作用于外部环境,接收到TurnOn(打开)消息时它发出某种形式的光,接收到TurnOff(关闭)消息时关闭光。其中的物理机制也是不重要的,可能是电脑控制台上的一个LED,可能是停车位上的一个日光灯,甚至可能是激光打印机上的激光
我们该怎样设计一个系统让Button对象控制Lamp对象呢?图5给出了一个原始模型,Button对象简单的向Lamp对象发送TurnOn和TurnOff消息,出于方便Button类使用"包含"关系拥有Lamp类的一个实例
图5: 原始的Button、Lamp模型
列表5: 原始的Button、Lamp模型
图5和列表5违背了依赖倒置原则,高层应用程序策略没有于底层模块分离,抽象没有与细节分离。缺少了这些分离,高层策略自然依赖底层模块,抽象自然也依赖细节
找出潜在的抽象
什么是高层策略?它是应用程序背后的抽象,是细节改变时那些不会变化的本质性东西。在Button、Lamp例子中潜在的抽象是检测用户的打开、关闭行为,将这个行为通知目标对象。用什么机制检测用户行为?无所谓!目标对象是什么?无所谓!这些是不会对抽象造成影响的细节
为了符合依赖倒置原则,我们必须将抽象与问题的细节分离开,因此我们必须控制设计的依赖性使细节依赖于抽象,图6展示了这样的设计
图6: 倒置的Button模型
图6中我们将Button类的抽象和它的细节实现分离开,列表6给出了相应代码。注意高层策略完整的包含在抽象Button类中,Button类对检测用户行为的物理机制一无所知,对Lamp对象也一无所知,这些细节隔离在具体的派生类ButtonImplementation和Lamp中
列表6: 倒置的Button模型
列表6中的高层策略可以在任何类型的按钮以及任何类型需要控制的设备上重用,此外它不受底层机制改变的影响,因此发生变更时它是健壮的、可扩展的、可复用的
进一步扩展抽象
进一步我们可以合理置疑图6、列表6的设计,Button控制的设备必须派生自ButtonClient,如果Lamp类来自第三方库我们无法修改它的源代码,情况怎样?
图7演示了Adapter模式怎样将第三方Lamp对象连接到这个模型中,LampAdapter类简单的将继承自ButtonClient的TurnOn和TurnOff消息翻译成Lamp类需要的形式
图7
结论
依赖倒置原则是许多面向对象技术优点的根本因素,它的正确运用对开发可复用框架非常有用,对编写具备修改弹性空间的代码特别重要,另外因为抽象和细节彼此隔离,代码非常容易维护
这篇文章是我的新书《面向对象设计原则与模式》(The Principles and Patterns of OOD)中一个章节的压缩版,它即将由Prentice Hall出版。随后的文章中我们将探讨面向对象设计的其它原则;基于C++实现研究几种设计模式以及它们的优点和缺点;研究一下Booch对C++中 class分类的作用以及与C++命名空间的关系;定义面向对象设计中"内聚"和"耦合"的意义,制定衡量面向对象设计质量的度量标准。另外也包括很多其它有意思的方面
原文连接: The Dependency Inversion Principle
本翻译未经授权,仅做为私人笔记记录,转载传播责任自负
这是我在C++报导工程笔记专栏的第四篇。这个专栏的主题是C++和OOD的使用,以及解决软件工程方面的问题。我尽力写一些实用性的文章,为奋斗中的软件工程师提供一些帮助。这些文章将使用Booch和Rumbaugh新的统一建模语言(UML版本0.8)描述面向对象设计,侧栏图片显示了主要表的达元素
侧栏图
介绍
上一篇文章(96年3月)介绍了里氏代换原则(Liskov Substitution Principle, LSP),这个原则为C++中继承的使用提供了指导。文中我们提到如果函数使用使用了基类的引用或指针进行操作,就应当能够操作这个基类的派生对象而无需了解它们。这意味着派生类的虚成员函数必须与基类相应成员函数保持一致,其行为不能越轨也不能偷工减料。另外也意味着基类中的虚成员函数也必须出现在派生类中,它们必须发挥有效的作用。违背这一原则时,使用了基类的指针或引用的函数需要检查实际对象的类型,以决定是否可以正确的对它们进行操作。检查类型的要求违背了开闭原则,我们在1月份刚讨论过
我们已经讨论了OCP和LSP,严格使用这些原则本身可以概括成一个原则,我把它叫做依赖倒置原则
软件出了什么问题?
我们中大部分人有过处理"Bad Design"软件的不愉快经历,有些人甚至更懊恼的发现自己就是"Bad Design"的作者,是什么使软件设计很糟糕呢?
"Bad Design"并非大部分软件工程师的初衷,然而大部分软件最终都被某些人确定为设计不好。为什么?是设计开始的太仓促,还是像腐烂的肉一样慢慢变味?问题的关键还包括对"Bad"缺乏有效定义
"Bad Design"的定义
你是否曾经将某个引以为荣的设计提交给同行评审,同行却嗤之以鼻的说:"你怎么这么做?"。当然我以前也遇到过,也见过很多别的工程师同样遇到过。很明显意见分歧的工程师对"Bad Design"的判断标准不同,见过最多的标准是"我不会用这种方式来做"
我认为下面一系列准则是所有工程师都应当认可的。符合需求的软件如果具备部分或者全部这些特征就是Bad Design
1. 难以改变,因为每个修改都给系统其它部分造成太大影响(笨拙)
2. 修改可能给系统中意想不到的部分造成破坏(脆弱)
3. 难以在其它应用中复用,因为无法从当前应用中剥离出来(不可移植)
此外很难发现一个可扩展、健壮、可复用,符合所有需求并且不具备上面这些特征的软件是Bad Design,因此我们可以使用这3个特征清晰的确定一个设计是"Good"还是"Bad"
"Bad Design"的原因
是什么导致设计变得笨拙、脆弱、无法移植呢?是模块间的相互依赖性。如果模块不容易修改它就是笨拙的,笨拙的产生原因是严重的耦合,软件中单一的变更央及到一系列相关模块,当设计师或维护者无法确定这一系列变更时,就无法评估变更造成的影响,导致变更的成本无法确定。面对这样的不确定性管理者也难以决断,因此设计被正式的确定为笨拙
脆弱表现为单一的修改可能破坏程序的许多方面,而这些方面与修改的地方毫不相干。这种脆弱性极大的降低人们对设计和维护团队的认可,用户和管理者无法确定产品的质量。程序某个地方的一个修改导致其它毫不相关的部分发生问题,修改好这些问题又带来了更多其它问题,维护过程变得周而复始无法停止
设计无法移植表现为希望复用的部分高度依赖其它不需要的部分,设计者检视自己的设计是否可用于其它应用程序中可以改善这种状况。如果设计高度耦合,在把需要复用的部分与不需要的部分分离开时,设计者也可能对工作量赶到沮丧。大部分情况下这样的设计不会复用,因为分离的成本可能高于重新开发
示例: Copy程序
一个简单的示例有助于理解这些观点。假设有个简单的程序,它负责将键盘输入拷贝到打印机,假设实现平台上没有设备无关的操作系统。我们可能会像图1一样勾画这个程序的结构
图1 拷贝程序
图1是一幅结构图,它表示程序有三个模块或者子程序,由Copy模块调用其它2个模块。人们可能在Copy模块中使用一个循环进行处理(参看列表1),在循环体中调用"Read Keyboard"模块从键盘读取一个字符,然后将它发送给"Write Printer"模块进行打印
底层的2个模块很好复用,它们可以用于其它程序访问键盘和打印机,这和子函数库的复用类似
列表1: Copy程序
void Copy()
{
int c;
while ((c = ReadKeyboard()) != EOF)
WritePrinter(c);
}
然而在缺少键盘或打印机的地方Copy模块都无法复用,这样很可惜,因为系统的处理是由这个模块完成的,可能Copy模块中包含了一些很不错的处理策略,我们希望能够复用{
int c;
while ((c = ReadKeyboard()) != EOF)
WritePrinter(c);
}
比如一个程序需要将键盘输入字符拷贝到磁盘文件中,当然我们希望复用Copy模块,因为它封装了一些我们需要的高层策略,例如它知道怎样将字符从输入源发送给接收器。不幸的是Copy模块依赖于"Write Printer"模块,无法在这个新的程序中复用
当然我们可以修改Copy模块给它赋予我们期望的功能(参考列表2),我们可以在处理中增加一个if语句,让它根据某些标记在"Write Printer"和"Write Disk"之间做出选择。但这样给系统带来了新的依赖性,随着时间推移需要在拷贝程序中添加越来越多的设备,Copy模块将堆满if else语句,依赖很多底层模块,最终变得笨拙、脆弱
列表2: Copy程序的增强版
enum OutputDevice {printer, disk};
void Copy(outputDevice dev)
{
int c;
while ((c = ReadKeyboard()) != EOF)
if (dev == printer)
WritePrinter(c);
else
WriteDisk(c);
}
void Copy(outputDevice dev)
{
int c;
while ((c = ReadKeyboard()) != EOF)
if (dev == printer)
WritePrinter(c);
else
WriteDisk(c);
}
依赖倒置
分析上面问题的一种方式是关注Copy()模块,它包含高层策略,依赖于它控制的底层细节性模块(例如WritePrinter()和ReadKeyboard())。如果我们可以采用方法使Copy()模块和这些细节部分独立开来,那我们就可以任意复用了。我们可以使用这个模块创建其它程序,将字符从任意输入设备拷贝到任意输出设备。OOD为我们提供了一种机制来处理这种依赖倒置
图2: OO版拷贝程序
查看图2中的简单类图,Copy类包含一个抽象Reader类和一个抽象Writer类,Copy类可以从Reader读取字符将它发送给Writer(查看列表3)。然而Copy类根本不依赖"Keyboard Reader"和"Printer Writer",因此依赖性被反转了,Copy类和细节的readers、writers都依赖于相同的抽象
列表3: OO版拷贝程序
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"。我们可以派生新的"Reader"和"Writer"提供给Copy类,此外不管创建了多少"Readers"和"Writers",Copy类都不会依赖它们,它们之间不存在使程序变得笨拙、脆弱的依赖关系,并且Copy模块可以用于更多的程序中,它是可复用的{
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);
}
设备无关
现在有些人可能会想到,使用C自带的设备无关stdio.h实现Copy()也可以达到同样的效果,比如使用getchar和purchar(参考列表4)。如果仔细观察列表3和4,会发现它们逻辑上是一样的,图3中的抽象类在列表4中由不同形式的抽象替代了,当然列表4并没有使用类和虚函数,但同样使用了抽象和多态达到了目的。此外他也运用了依赖倒置!列表4中的拷贝程序同样不依赖他控制的任何细节模块,他依赖stdio.h中声明的抽象工具类,最终被调用的IO驱动同样依赖stdio.h中的抽象,因此stdio.h库中的设备无关性是依赖倒置的另一个例子
列表4: 使用stdio.h的拷贝程序
#include <stdio.h>
void Copy()
{
int c;
while((c = getchar()) != EOF)
putchar(c);
}
我们已经看了几个示例,现在可以陈述依赖倒置的一般形式了void Copy()
{
int c;
while((c = getchar()) != EOF)
putchar(c);
}
依赖倒置原则
A. HIGH LEVEL MODULES SHOULD NOT DEPEND UPON LOW LEVEL MODULES. BOTH SHOULD DEPEND UPON ABSTRACTIONS.
高层模块不应当依赖底层模块,它们都应当依赖于抽象
B. ABSTRACTIONS SHOULD NOT DEPEND UPON DETAILS. DETAILS SHOULD DEPEND UPON ABSTRACTIONS.
抽象不应当依赖细节,细节应当依赖于抽象
有人可能问我为什么使用"倒置"这个词,坦白的说是因为很多结构化分析设计的传统软件开发方法中,习惯在软件中使高层模块依赖底层模块,抽象依赖细节。这些方法的目标之一是定义子程序体系结构,描述高层模块如何调用底层模块。图1就是这种体系结构一个很好的例子。正因为这样,拥有良好设计的面向对象程序,其依赖结构相对于传统过程式方法是"颠倒过来的"
看一下高层模块依赖底层模块的结果。高层模块包含了重要的决策处理以及应用程序的业务模型,这是应用程序的特征,当这些模块依赖底层模块时,修改底层模块将直接影响到它们,迫使它们也得修改
这种现象是荒谬的,应当是高层模块迫使底层模块修改,高层模块优先于底层模块,高层模块无论如何也不能依赖底层模块
此外我们希望高层模块可以复用,使用子函数库的形式我们在底层模块复用方面已经做的很好了。高层模块依赖底层模块时它们很难复用于不同环境,而不依赖底层模块时则很容易
依赖倒置是框架(framework)设计的关键
分层
Booch说: "...所有结构良好的面向对象架构拥有清晰的分层,每个分层通过良好定义的接口提供条理分明的一系列服务"。对这句话粗浅的理解可能导致设计者创建类似图3的结构,图中高层Policy类使用底层Mechanism,而Mechanism使用细节的Utility类。虽然这看起来是正确的,但潜在的缺点是Policy层受底层包括Utility层修改的影响,依赖具有传递性,Policy层依赖了某些层,而这些层又依赖Utility层,因此Policy层也就依赖Utility层了,这很糟糕
图3: 简单分层
图4显示了一个更正确的模型,每个较低的层都使用抽象类表示,实际的层由这些抽象类划分开,较高的层都通过抽象接口使用下一层,因此没有任何一层依赖其它层,它们都只是依赖于抽象类。这样不仅消除了Policy层对Utility层的依赖,也消除了Policy层对Mechanism层的直接依赖
图4: 抽象分层
使用这个模型Policy层不受Mechanism层和Utility层的影响,此外Policy层可以在实现了Mechanism层接口的其它环境中复用,因此通过倒置依赖关系,我们创建了一个更好扩展、耐用、可复用的结构
在C++中分离接口与实现
有人可能提出异议,图3并没有说明依赖,不存在我说的依赖传递性,毕竟Policy层只是依赖Mechanism层的接口,为什么对Mechanism层实现的修改会影响Policy层呢?
在某些面向对象语言中这种说法是对的,这些语言中接口自动与实现分离。然而C++中接口与实现不是分离的,C++中类的定义和成员函数的定义才是分离的
C++中我们通常将类分成2个模块:一个.h模块和一个.cc模块。.h模块包含类的定义,.cc模块包含类成员函数的定义。.h中类的定义包含类的所有成员函数和成员变量的声明,这些信息与简单的接口差别很大。另外所有用到的工具性函数和私有变量也是类实现(class implementation)的一部分,然而它们出现在所有类的客户端需要依赖的模块中,因此C++中接口与实现不是自动分离的
C++中这种接口与实现分离机制的缺乏可以通过纯抽象类弥补,纯抽象类是只包含纯虚函数的类,这样的类是纯粹的接口,它的.h模块不包含实现。图4显示的就是这样一种结构,图中的抽象类是纯抽象的因此每个层只是依赖相关层的接口
一个简单示例
一个类需要向另一个类发送消息时都可以使用依赖倒置,例如可以看一下Button(开关按钮)对象和Lamp(灯)对象的例子
Button对象感应外部环境,它可以确定用户是否有"按下"按钮。通过什么机制接受"按下按钮"事件并不重要,可能是GUI上的一个图标,可能是一个真正的按钮被手指按下,甚至可能是家庭安全系统中的一个行为感应器,Button对象检测用户的打开或关闭灯的事件。Lamp对象作用于外部环境,接收到TurnOn(打开)消息时它发出某种形式的光,接收到TurnOff(关闭)消息时关闭光。其中的物理机制也是不重要的,可能是电脑控制台上的一个LED,可能是停车位上的一个日光灯,甚至可能是激光打印机上的激光
我们该怎样设计一个系统让Button对象控制Lamp对象呢?图5给出了一个原始模型,Button对象简单的向Lamp对象发送TurnOn和TurnOff消息,出于方便Button类使用"包含"关系拥有Lamp类的一个实例
图5: 原始的Button、Lamp模型
列表5: 原始的Button、Lamp模型
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();
}
列表5给出了这个模型的C++实现,注意Button类直接依赖Lamp类,即button.cc模块#include了lamp.h模块,这个依赖表明任何时候Lamp类有修改时,必须修改至少是重新编译Button类,此外也没法使用Button类控制一个Motor(发电机)对象{
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();
}
图5和列表5违背了依赖倒置原则,高层应用程序策略没有于底层模块分离,抽象没有与细节分离。缺少了这些分离,高层策略自然依赖底层模块,抽象自然也依赖细节
找出潜在的抽象
什么是高层策略?它是应用程序背后的抽象,是细节改变时那些不会变化的本质性东西。在Button、Lamp例子中潜在的抽象是检测用户的打开、关闭行为,将这个行为通知目标对象。用什么机制检测用户行为?无所谓!目标对象是什么?无所谓!这些是不会对抽象造成影响的细节
为了符合依赖倒置原则,我们必须将抽象与问题的细节分离开,因此我们必须控制设计的依赖性使细节依赖于抽象,图6展示了这样的设计
图6: 倒置的Button模型
图6中我们将Button类的抽象和它的细节实现分离开,列表6给出了相应代码。注意高层策略完整的包含在抽象Button类中,Button类对检测用户行为的物理机制一无所知,对Lamp对象也一无所知,这些细节隔离在具体的派生类ButtonImplementation和Lamp中
列表6: 倒置的Button模型
列表6中的高层策略可以在任何类型的按钮以及任何类型需要控制的设备上重用,此外它不受底层机制改变的影响,因此发生变更时它是健壮的、可扩展的、可复用的
进一步扩展抽象
进一步我们可以合理置疑图6、列表6的设计,Button控制的设备必须派生自ButtonClient,如果Lamp类来自第三方库我们无法修改它的源代码,情况怎样?
图7演示了Adapter模式怎样将第三方Lamp对象连接到这个模型中,LampAdapter类简单的将继承自ButtonClient的TurnOn和TurnOff消息翻译成Lamp类需要的形式
图7
结论
依赖倒置原则是许多面向对象技术优点的根本因素,它的正确运用对开发可复用框架非常有用,对编写具备修改弹性空间的代码特别重要,另外因为抽象和细节彼此隔离,代码非常容易维护
这篇文章是我的新书《面向对象设计原则与模式》(The Principles and Patterns of OOD)中一个章节的压缩版,它即将由Prentice Hall出版。随后的文章中我们将探讨面向对象设计的其它原则;基于C++实现研究几种设计模式以及它们的优点和缺点;研究一下Booch对C++中 class分类的作用以及与C++命名空间的关系;定义面向对象设计中"内聚"和"耦合"的意义,制定衡量面向对象设计质量的度量标准。另外也包括很多其它有意思的方面