小例子背后的大道理——从DIP中“倒置”的含义说接口的正确使用

小例子背后的大道理——从DIP中“倒置”的含义说接口的正确使用

提纲

开灯的例子

选开灯做例子,是因为这个例子既常见又简单,而且潜在的需求多样。对于最简单的灯,从功能上讲,按下灯上的开关,灯就开了。

用代码实现这样一个有开关功能的灯,也是一件很容易的事情。

public class Light
{
    public void TurnOn() { Console.WriteLine("Light Turn On"); }
    public void TurnOff() { Console.WriteLine("Light Turn Off"); }
}

代码1

一个具有开关功能的灯就完成了。这个灯,功能完备、也满足当下的需求。一切美好。

直到有一天,有个客户说,灯上的开关坏了,能不能换一个?我才意识到这个灯的设计有问题——它的开关是换不了的。一面给用户解释,一面考虑着把灯和开关分开。

咱也是学过设计模式的人,知道要面向接口编程,绝不应该简单地把Light类拆解成Light和Switcher两个类。因为Switcher不应该依赖于具体实现,于是写出了下面的代码。

 
   
namespace Me.Lighting
{
    public interface ILightable
    {
        void ShowLight();
        void HideLight();
    }
    public class Light : ILightable
    {
        public void ShowLight() { Console.WriteLine("Light Turn On"); }
        public void HideLight() { Console.WriteLine("Light Turn Off"); }
    }
}
namespace Me.Switch
{
    using Me.Lighting;
 
    
    public class Switcher
    {
        public ILightable Light { get; set; }
        public void TurnOn() { Light.ShowLight(); }
        public void TurnOff() { Light.HideLight(); }
    }
}

代码 2

这个设计,不仅分离了灯和开关,甚至可以让这个开关灵活地控制要开关哪个灯。只要在开关前设置一下就可以,多方便。我自信满满地迁入了代码。

事实也证明这样的设计是成功的,产品的灵活设计得到了用户的认可,销量直线上升。

亲,请看下代码,在不使用什么别的设计模式的前提下,您觉得代码2有什么问题?无论是什么角度的都可以(当然,可能您的角度不是本文讨论的重点),最好先回复下留个底,别事后诸葛。

如果您一眼看到了问题,请直接阅读DIP那一节。

暗流涌动

公司壮大之后 ,开始考虑向收音机行业进军。而且公司希望,这种灵活的设计可以沿用下去,收音机和灯的开关应该可以通用,对用户而言,都是拨那么一下。

我听到这个信息也是相当兴奋,但是当我开始着手写代码时,发现一些坏味道,开关依赖于ILightable 接口,那么我的收音机不得不写成这个样子才能与现有的开关兼容。

public class Radio : ILightable
{ 
    public void ShowLight() { Console.WriteLine("Play radio"); }
    public void HideLight() { Console.WriteLine("Stop radio"); } 
}

代码3

虽然可以工作,但是这是严重的坏味道。因为如果有一天,灯的接口变化,我却要连收音机的代码一起改。这种情况绝不应该出现。且不用把LSP(Liskov替换原则)搬出来说教,很显然Radio其实并没有完成ILightable所定义的功能——发光。无论从哪个角度讲都是错的。

一个可行的设计是,让开关支持收音机的开启和停止。像下面这样。

namespace Me.Radio
{
    public interface IRadio
    {
        void Play();
        void Stop();
    }
    public class Radio : IRadio
    {
        public void Play() { Console.WriteLine("Play radio"); }
        public void Stop() { Console.WriteLine("Stop radio"); }
    }
}
namespace Me.Switch
{
    using Me.Lighting;
    using Me.Radio;
 
    
    public class Switcher
    {
        public ILightable Light { get; set; }
        public IRadio Radio { get; set; }
        public void TurnOn()
        {
            if (Light != null) Light.ShowLight();
            else if (Radio != null) Radio.Play();
        }
        public void TurnOff() { Light.HideLight(); }
    }
}

代码4

我看来看去都觉得这个代码太恶心了,因为Switcher的实现方式违反了OCP(开放—封闭原则),如果这样发展下去,公司的产品越丰富,这坨代码就越难以维护。我的末日也就越近。

于是我的考虑Switcher的设计是不是有问题,我已经用上面向接口编程了,为什么还是有问题呢?

Guru眼中的依赖

我把代码发给了我的导师,一个设计Guru,他看完之后哭笑着说,你的基本功很扎实,理论知识也很全面,可惜却缺乏一定的经验。面向接口编程没有错,但是更重要的是模型的建立。

简单而言,你的开关的依赖关系错了。问你一个问题你就明白了,开关为什么要依赖ILightable呢?但是好在你有一定的设计基础,知道要提取出一个接口,所以要改成正确的设计也非常容易。你只需要把ILightable这个接口的名字改成ISwitchable,再把接口方法名字改下,并把它与Switcher放一起就行了。

听罢,我恍然大悟。原来接口的名字和位置,也会给使用者带来如此大的困扰。在先进的开发工具的帮助下,瞬间就完成了这个简单的重命名和移动操作。现在的代码像这个样子了。

namespace Me.Lighting
{
    using Me.Switch;
    public class Light : ISwitchable
    {
        public void TurnOn() { Console.WriteLine("Light Turn On"); }
        public void TurnOff() { Console.WriteLine("Light Turn Off"); }
    }
}
namespace Me.Radio
{
    using Me.Switch;
    public class Radio : ISwitchable
    {
        public void TurnOn() { Console.WriteLine("Play radio"); }
        public void TurnOff() { Console.WriteLine("Stop radio"); }
    }
}
namespace Me.Switch
{
    public interface ISwitchable
    { 
        void TurnOn();
        void TurnOff();
    }
    public class Switcher
    {
        public ISwitchable Switchee { get; set; } 
        public void TurnOn() { Switchee.TurnOn(); } 
        public void TurnOff() { Switchee.TurnOff(); }
    }
}

代码5

注意:这个代码与之前有问题的代码2,只是各种名称上的变化。结构上一点儿没变。

以后有新的产品,也只需要实现ISwitchable接口,就可以支持这个开关了。之前的失败设计,看似与这个设计相差无几,但是其中蕴含的设计思想天差地远,也正是在这种地方,才更能体现出设计师间的差距。这一种设计所体现的,即是DIP(依赖倒置原则),的表现之一,接口应当被其使用者所拥有,而非其实现者。1

DIP(依赖倒置原则)

具体问题解决了,还需要把整个问题抽象一下,从本质上了解一下DIP的含义。(我会尽量清楚,可能会有些啰嗦,但这比在回复里争论要舒坦得多。)

假设有如下所示的类图。假设我们要把这种关系解耦合。

图1

注:图1中的User表示使用者(调用者),而不是用户的意思。

为什么要解耦合?

我说“假设要解耦合”,是因为在尝试解耦这种依赖关系之前,应该先确定有没有解耦的必要。这种关系在代码中比比皆是,如果把所有的依赖都解耦,不仅工作量大、带不来任何好处,而且引入了不必要的复杂度,最终演变成了过度设计,增加了编码成本和维护成本。(我已经被人骂怕了,怕不说清楚这一点,总要有人跳出来说我滥用模式,说这种关系要不要解耦要看情况,云云。都是好意,我也心领了,谢谢。但被人假设狗屁不通,总不太舒服。)

明确某个依赖关系是否需要被分解,是一件很复杂的事情,个人觉得并没有什么准则能让你轻松地做出这个判断。因为几乎所有的依赖,在一句经典的“我以后可能会换一种方式实现它”面前,都变得似乎需要被解耦。这种理由,听上去合理,其实是狗屁。换一种方式实现它,并不意味着要用一个接口来抽象它,接口是用来抽象并解耦依赖关系的,应该被用在:同时存在多个实现、实现未知或需要模块化的情况下(还有一种情况,是方便多人开发时工作内容的解耦,但我还没有想明白,引入接口来达到这个目的是否合适:因管理需要导致的复杂度上升。所以先不讨论这种情况)

具体解释一下,“同时存在多个实现”的意思。以IComparable接口为例,很多数据类(比如DTO)大都实现了这个接口,因为上层的功能(比如排序)依赖类的对象有相互比较的能力,同时每个类的实现方式又都不一样,即所谓的同时存在多个实现。

所以,对于需要“换一种方式实现它”的情况,大可以把原来的代码删除然后重新写一个。

有句话叫“拿着锤子,看什么都像钉子”。了解一项技术,不仅仅要了解他能做什么,更要了解这个技术适用在什么地方。所以千万别今天听了解耦的概念觉得很前卫,第二天就去把所有的类都提取出个接口。多数情况当然不会这么夸张,但滥用其实就在一念之间。

接口的坏味道

我承认,上面解释也许正确,但没什么用。懂的人懂,不懂的还是不懂;所以我还是举些接口有问题的坏味道吧。

最常见的接口坏味儿包括:(注意,总可以找到反例,所以一开始就说了,没有准则,总要具体问题具体分析,但是如果使用接口的原因是如下几种之下,我觉得应该再仔细考虑一下)

  1. 为了提取出某一个类所提供的Public方法。接口应该用来抽象依赖,而不是抽象实现。后面再解释。你想知道或控制一个类有哪些Method的方法有很多,但是引入一个接口,不仅达不到你的目的,还引入了复杂度——每当你要加一个方法,都要修改两个地方,一个是接口,一个是实现。

  2. 接口抽象出来了,但是和实现放了一起,或者根本没用到这个接口。比如,如果你写出了:

    Interface f = new Implementation();

    这样的代码,而且这个接口只被这样用过,那或许需要考虑一下使用这个接口的用法了。我并不是指你需要一个依赖注入的框架。但是这至少看上去不太对劲,像是为了使用接口而提取出了这个接口。

  3. 接口中包含了互不相关的方法。如果某个方法出现在这个接口里会让人觉得惊讶,那这个接口就是有问题的。不能因为有两个以上的类都有这个方法,所以就提取出来了。要看这两个方法有没有关系,还要看上层是不是一定会同时依赖这两个方法。使用者使用接口中的方法时,应该全部都用得到。如果没全用到,可能需要考虑一下这个接口划分的是否合理?的粒度是不是太粗了?还是把接口当成了Common Service Host来用了?

同一张类图的不同解释——真假DIP

扯得有点儿远了。回来继续正题,考虑如何把User和Implementation解耦合。所有人都知道,解耦的方法是:

  1. 定义接口I
  2. Implementation实现接口I
  3. User使用接口I,则不是Implementation。

这个描述已经很细了,而且画出来的类图也是唯一的。但是很可惜,这个描述是不明确的,有歧义的。

代码2和代码5都符合这个描述,但是其实是不同的设计。用图来描述会更清楚一些。

图2

图3

或许有人一看到学术派的设计图就兴奋起来,一眼就看出有一个设计是有问题的。但是当你看到代码2时,你有一眼看出问题吗?到你自己的项目代码中,你能一眼看出问题吗?问题总是出现在“混乱”中,简化成图2、图3这样,只要知道DIP的人,恐怕都能看出问题。但到项目中,那就是另一回事儿了。就像多数人都很鄙视国家组织的“软考”,考得再好,也不表示有相当的设计水平。这种简化了的问题和考题一样,也许能明白,但是能在该用的时候记得用,并不是个容易的事儿。

我来解释一下,其中根本的区别在于谁依赖谁。至于谁持有接口,只是表象。从逻辑上,调用方很明显地依赖着实现方,因为实现方才是功能的实现者,没有实现方,调用方就工作不了。但是在图3的设计中,其设计意图是,实现方要实现的功能,由调用方来决定,而不是实现方实现了什么,调用方就用什么。也就是说,要让实现方依赖调用方。这,就是DIP(依赖倒置原则)的含义。其具体表现就是,调用方定义并持有接口。

从概念上来讲,DIP的定义如下2

  1. 高层次的模块不应该依赖于低层次的模块,他们都应该依赖于抽象。
  2. 抽象(Abstractions)不应该依赖于实现(details),实现应该依赖于抽象。

目前在网上找到的对DIP的解释,多数都停留在第一项,即模块依赖抽象上,都没有解释清楚“倒置”这个词的含义。希望本文中的图2和图3解释清楚了“倒置”的含义。从概念上来讲,“抽象不应该依赖于实现”,就是要求“倒置”。因为如果像图2那种思路,从实现中抽象出接口,那么这个接口就是依赖于实现的。重复一下之前说过的:接口,应该是对依赖的抽象,而不是对实现(底层功能)的抽象,这就是所谓的倒置。(这里的依赖的含义是,调用者所需要的功能,而不是实现者实现了的功能。)

另外,还是这个类图,还有一种常见的组织形式。像下面这样。

图4

从箭头的方向上来看,这个更倒置。但是模块的细分,箭头方向的颠倒,并不意味着这个设计真的是倒置的。这要取决于抽象层中的接口,是与图2中的接口定位一致呢?还是与图3中的接口定位一致?单纯地把接口放在抽象层里,就和单纯地定义一个接口,却没有地方用到它一样没有意义。

所以说,清楚地表达一个设计,并能让人确切地明白你的设计。其实是一件非常不容易的事情。可能把UML的所有功能都用上,才能做到这一点。仅仅画个框框、线线、写俩字儿,是很容易让人误会的。开会的时候有人解释着还好,如果写出的文档如果是这样,对新手而言还不如没有,因为基本上一定会被误解。

了解DIP有什么用?DIP能用在什么地方?

我猜不少人看到这里会很想问,知道“倒置”到底是什么意思有个鸟用?有好的创意去开发项目才是正经事儿,把项目按时保质地做出来才是正经事儿,老子按时下班才是正经事儿。

首先,我非常同意!然后,回答这个问题,这个每个人的个性使然。就像天天研究吃什么健康有个鸟用?中国的食品安全都保证不了,还健康?!但是就是有人就好这口,不是么?而且,我在这里只是解释DIP,也并没有说做的项目里,都要符合DIP啊。项目管理和架构是很灵活的,不是几个P就可以规范的起来的。有时候,直接找个开源的产品一搭,多快好省,一个P也用不着。如果非要给出个理由,我想恬不知耻地说句,追求卓越。(好吧,根本原因是,我喜欢得瑟,但是又不喜欢被明白人骂成猪头,所以我选择先搞明白了再去得瑟。)

但是我还是要说说了解这个原则的好处,不然写这文章不是打自己脸么?了解依赖倒置的意义,并不限于设计,还在于思想上的转变。理解这个原则之后,你会发现自己明明已经把这个原则用上了,比如做需求分析的时候,肯定是问用户想要什么,而不是我们能做到什么。

这个原则在协作上也有用处。请回想一下,在工作中,是否遇到过上层开发人员等下层开发接口的情况呢?如果遇到过,当时有没有想过,这个依赖关系是不是反了呢?其实,应该是下层模块的开发者依赖上层开发者呀。上层开发者定义好他依赖的接口,下层开发者来实现,同时,因为接口已经定义好了,上层也不用等下层开发者,完全可以用些Mock框架进行测试嘛。但是,如果让下层开发者定义接口,显然上层开发者就必须等,Mock类也写不了。

关于这个原则,我还见到过更广义,更天下大同的解释。在客户关系上,我们常见的依赖是开发者依赖客户,客户说什么我们就得做什么,一点主动权都没有。于是有人就把依赖倒置的原则拿来,说,应该让客户依赖开发者!大有,“我们说什么,客户就听什么!”的派头。到底哪个依赖是倒置的我就不在这儿争了,因为我觉得这完全不是依赖的方向性问题。而是店大欺客还是客大欺店的问题。如果你在IBM、在SAP、在四大,你可以让客户听你的。如果你在一个小屁公司,或者客户是政府部门,你倒置个试试?

自此之后,一切安好。

直到有一天,又有一个用户,他的灯上的开关也坏了,然后他试着把另外一家厂商的开关装了上去,却发现打不开灯。用户抱怨道,他的这个开关可是按国际标准实现的,我们的灯具应该支持这种标准开关。

如果有可能,我们一定会让这个灯支持这个国际标准。可是灯已经卖出去了,出厂的千千万万个灯都召回的代价也很大。

这个灯的设计,又要做出怎样的变化呢?

参考文献:

1. 《敏捷软件开发 原则、模式与实践(C#版)》 第117页11.1.1节

2. 《敏捷软件开发 原则、模式与实践(C#版)》 第115页

版权声明:本文内容由互联网用户自发贡献,该文观点与技术仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 dio@foxmail.com 举报,一经查实,本站将立刻删除。

相关推荐


什么是设计模式一套被反复使用、多数人知晓的、经过分类编目的、代码 设计经验 的总结;使用设计模式是为了 可重用 代码、让代码 更容易 被他人理解、保证代码 可靠性;设计模式使代码编制  真正工程化;设计模式使软件工程的 基石脉络, 如同大厦的结构一样;并不直接用来完成代码的编写,而是 描述 在各种不同情况下,要怎么解决问题的一种方案;能使不稳定依赖于相对稳定、具体依赖于相对抽象,避免引
单一职责原则定义(Single Responsibility Principle,SRP)一个对象应该只包含 单一的职责,并且该职责被完整地封装在一个类中。Every  Object should have  a single responsibility, and that responsibility should be entirely encapsulated by t
动态代理和CGLib代理分不清吗,看看这篇文章,写的非常好,强烈推荐。原文截图*************************************************************************************************************************原文文本************
适配器模式将一个类的接口转换成客户期望的另一个接口,使得原本接口不兼容的类可以相互合作。
策略模式定义了一系列算法族,并封装在类中,它们之间可以互相替换,此模式让算法的变化独立于使用算法的客户。
设计模式讲的是如何编写可扩展、可维护、可读的高质量代码,它是针对软件开发中经常遇到的一些设计问题,总结出来的一套通用的解决方案。
模板方法模式在一个方法中定义一个算法的骨架,而将一些步骤延迟到子类中,使得子类可以在不改变算法结构的情况下,重新定义算法中的某些步骤。
迭代器模式提供了一种方法,用于遍历集合对象中的元素,而又不暴露其内部的细节。
外观模式又叫门面模式,它提供了一个统一的(高层)接口,用来访问子系统中的一群接口,使得子系统更容易使用。
单例模式(Singleton Design Pattern)保证一个类只能有一个实例,并提供一个全局访问点。
组合模式可以将对象组合成树形结构来表示“整体-部分”的层次结构,使得客户可以用一致的方式处理个别对象和对象组合。
装饰者模式能够更灵活的,动态的给对象添加其它功能,而不需要修改任何现有的底层代码。
观察者模式(Observer Design Pattern)定义了对象之间的一对多依赖,当对象状态改变的时候,所有依赖者都会自动收到通知。
代理模式为对象提供一个代理,来控制对该对象的访问。代理模式在不改变原始类代码的情况下,通过引入代理类来给原始类附加功能。
工厂模式(Factory Design Pattern)可细分为三种,分别是简单工厂,工厂方法和抽象工厂,它们都是为了更好的创建对象。
状态模式允许对象在内部状态改变时,改变它的行为,对象看起来好像改变了它的类。
命令模式将请求封装为对象,能够支持请求的排队执行、记录日志、撤销等功能。
备忘录模式(Memento Pattern)保存一个对象的某个状态,以便在适当的时候恢复对象。备忘录模式属于行为型模式。 基本介绍 **意图:**在不破坏封装性的前提下,捕获一个对象的内部状态,并在该
顾名思义,责任链模式(Chain of Responsibility Pattern)为请求创建了一个接收者对象的链。这种模式给予请求的类型,对请求的发送者和接收者进行解耦。这种类型的设计模式属于行为
享元模式(Flyweight Pattern)(轻量级)(共享元素)主要用于减少创建对象的数量,以减少内存占用和提高性能。这种类型的设计模式属于结构型模式,它提供了减少对象数量从而改善应用所需的对象结