向依赖关系宣战——依赖倒置、控制反转和依赖注入辨析
|
在《道法自然——面向对象实践指南》一书中,我们采用了一个对立统一的辩证关系来说明“模板方法”模式—— “正向依赖 vs. 依赖倒置”(参见:《道法自然》第15章[王咏武, 王咏刚 2004])。这种把“好莱坞”原则和 “依赖倒置”原则等量齐观的看法其实来自于轻量级容器PicoContainer主页上的一段话: 依赖和耦合(Dependency and Coupling) Rational Rose的帮助文档上是这样定义“依赖”关系的:“依赖描述了两个模型元素之间的关系,如果被依赖的模型元素发生变化就会影响到另一个模型元素。典型的,在类图上,依赖关系表明客户类的操作会调用服务器类的操作。” 接口和实现分离 把接口和实现分开是人们试图控制依赖关系的第一个尝试,图 1是Robert C. Martin在《依赖倒置》[Martin 1996]一文中所举的第一个例子。其中,ReadKeyboard()和WritePrinter()为函数库中的两个函数,应用程序循环调用这两个函数,以便把用户键入的字符拷贝到打印机输出。 为了使应用程序不依赖于函数库的具体实现,C语言把函数的定义写在了一个分离的头文件(函数库.h)中。这种做法的好处是:虽然应用程序要调用函数库、依赖于函数库,但是,当我们要改变函数库的实现时,只要重写函数的实现代码,应用程序无需发生变化。例如,改变函数库.c文件,把WritePrinter()函数重新实现成向磁盘中输出,这时只要将应用程序和函数库重新链接,程序的功能就会发生相应的变化。 上面的函数库也可以采用C++语言来实现。我们通常把这种用面向对象技术实现的,为应用程序提供多个支持类的模块称为 “类库”,如图 2所示。这种通过分离接口和实现来消解应用程序和类库之间依赖关系的做法具有以下特点: 1. 应用程序调用类库,依赖于类库。 2. 接口和实现的分离从一定的程度上消解了这个依赖关系,具体实现可以在编译期间发生变化。但是,这种消解方法的作用非常有限。比如说,一个系统中无法容纳多个实现,不同的实现不能动态发生变化,用WritePrinter函数名来实现向磁盘中输出的功能也显得非常古怪,等等。 3. 类库可以单独重用。但是应用程序不能脱离类库而重用,除非提供一个实现了相同接口的类库。 依赖倒置(Dependency Inversion Principle) 可以看出,上面讨论的简单分离接口的方法对于依赖关系的消解作用非常有限。Java语言提供了纯粹的接口类,这种接口类不包括任何实现代码,可以更好地隔离两个模块。C++语言中虽然没有定义这种纯粹的接口类,但所有成员函数都是纯虚函数的抽象类也不包含任何实现代码,可以起到类似于Java接口类的作用。为了和上一节中提到的简单接口相区别,本文后面将把基于Java 接口类或C++抽象类定义的接口称为抽象接口。依赖倒置原则就是建立在抽象接口的基础上的。Robert Martin这样描述依赖倒置原则[Martin 1996]: 但还有另外一种情况。图 4是Martin Fowler在《Reducing Coupling》一文中使用的一个例子[Fowler 2001]。其中,Domain包要使用数据库包,即Domain包依赖于数据库包。为了隔离Domain包和数据库包,可以引入一个Mapper包。如果在特定的情况下,我们希望Domain包能够被多次重用,而Mapper包可以随时变化,那么,我们就必须防止Domain包过分地依赖于Mapper包。这时,可以由 Domain包的设计者总结出自己需要的抽象接口(如Store),而由Mapper包的设计者来实现该抽象接口。这样一来,无论是在接口层面,还是在实现层面,依赖关系都完全颠倒过来了。 控制反转(Inversion of Control) 前面描述的是应用程序和类库之间的依赖关系。如果我们开发的不是类库,而是框架系统,依赖关系就会更强烈一点。那么,该如何消解框架和应用程序之间的依赖关系呢? 并非只有面向对象的方法才能解决这一问题。WIN32 API早就为我们提供了在面向过程的设计思路下解决类似问题的范例。类WIN32 的架构模型如图 6所示。 在图 6中,应用程序调用CreateWindow()函数时,要传递一个消息处理函数的指针给GUI框架(对WIN32而言,我们在注册窗口类时传递这一指针),GUI框架把该指针记录在窗口信息结构中。需要发送窗口消息时,GUI框架就通过该指针调用窗口函数。和图 5 相比,GUI框架仍然需要调用应用程序,但这一调用从一个硬编码的函数调用变成了一个由应用程序事先注册被调用对象的动态调用。图 6用一条虚线表示这种动态调用。可以看出,这种动态的调用关系有一个非常大的好处:当应用程序发生变化时,它可以自行改变框架系统的调用目标,GUI框架无需随之发生变化。现在,我们可以说,虽然还存在着从GUI框架到应用程序的调用关系,但GUI框架已经完全不再依赖于应用程序了。这种动态调用机制通常也被称为“回调函数”。 在面向对象领域,“回调函数”的替代物就是“模板方法模式”,也就是“好莱坞原则(不要调用我们,让我们调用你)”。GUI框架的一个面向对象的实现如图 7所示。 图 7中,“GUI框架抽象接口”是GUI框架系统提供给应用程序使用的接口。抽象出该接口的动机是根据“依赖倒置”的原则,消解从应用程序到GUI框架之间的直接依赖关系,以使得GUI框架实现的变化对应用程序的影响最小化。Window接口类则是“模板方法模式”的核心。应用程序调用CreateWindow()函数时,GUI框架会把该窗口的引用保存在窗口链表中。需要发送窗口消息时,GUI框架就调用窗口对象的SendMessage()函数,该函数是实现在Window类中的非虚成员函数。SendMessage()函数又调用WindowProc()虚函数,这里实际执行的是应用程序MyWindow类中实现的WindowProc()函数。在图 7中,我们已经看不到从GUI框架到应用程序之间的直接依赖关系了。因此,模板方法模式完全实现了回调函数的动态调用机制,消解了从框架到应用程序之间的依赖关系。 从上面的分析可以看出,模板方法模式是框架系统的基础,任何框架系统都离不开模板方法模式。Martin Fowler也说 [Folwer 2004],“几位轻量级容器的作者曾骄傲地对我说:这些容器非常有用,因为它们实现了‘控制反转’。这样的说辞让我深感迷惑:控制反转是框架所共有的特征,如果仅仅因为使用了控制反转就认为这些轻量级容器与众不同,就好像在说‘我的轿车是与众不同的,因为它有四个轮子’。问题的关键在于:它们反转了哪方面的控制?我第一次接触到的控制反转针对的是用户界面的主控权。早期的用户界面是完全由应用程序来控制的,你预先设计一系列命令,例如‘输入姓名’、‘输入地址’等,应用程序逐条输出提示信息,并取回用户的响应。而在图形用户界面环境下,UI 框架将负责执行一个主循环,你的应用程序只需为屏幕的各个区域提供事件处理函数即可。在这里,程序的主控权发生了反转:从应用程序移到了框架。” 确实:对比图 3和图 7可以看出,使用普通类库时,程序的主循环位于应用程序中,而使用框架系统的应用程序不再包括一个主循环,只是实现某些框架定义的接口,框架系统负责实现系统运行的主循环,并在必要的时候通过模板方法模式调用应用程序。 也就是说,虽然“依赖倒置”和“控制反转”在设计层面上都是消解模块耦合的有效方法,也都是试图令具体的、易变的模块依赖于抽象的、稳定的模块的基本原则,但二者在使用语境和关注点上存在差异:“依赖倒置”强调的是对于传统的、源于面向过程设计思想的层次概念的“倒置”,而“控制反转”强调的是对程序流程控制权的反转;“依赖倒置”的使用范围更为宽泛,既可用于对程序流程的描述(如流程的主从和层次关系),也可用于描述其他拥有概念层次的设计模型(如服务组件与客户组件、核心模块与外围应用等),而“控制反转”则仅适用于描述流程控制权的场合(如算法流程或业务流程的控制权)。 从某种意义上说,我们也可以把“控制反转”看作是“依赖倒置”的一个特例。例如,用模板方法模式实现的“控制反转”机制其实就是在框架系统和应用程序之间抽象出了一个描述所有算法步骤原型的接口类,框架系统依赖于该接口类定义并实现程序流程,应用程序依赖于该接口类提供具体算法步骤的实现,应用程序对框架系统的依赖被“倒置”为二者对抽象接口的依赖。 总地说来,应用程序和框架系统之间的依赖关系有以下特点: 1. 应用程序和框架系统之间实际上是双向调用,双向依赖的关系。 2. 依赖倒置原则可以减弱应用程序到框架之间的依赖关系。 3. “控制反转”及具体的模板方法模式可以消解框架到应用程序之间的依赖关系,这也是所有框架系统的基础。 4. 框架系统可以独立重用。 依赖注入(Dependency Injection) (编辑:安卓应用网) 【声明】本站内容均来自网络,其相关言论仅代表作者个人观点,不代表本站立场。若无意侵犯到您的权利,请及时与联系站长删除相关内容! |
