小例子背后的大道理——从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)大都实现了这个接口,因为上层的功能(比如排序)依赖类的对象有相互比较的能力,同时每个类的实现方式又都不一样,即所谓的同时存在多个实现。
所以,对于需要“换一种方式实现它”的情况,大可以把原来的代码删除然后重新写一个。
有句话叫“拿着锤子,看什么都像钉子”。了解一项技术,不仅仅要了解他能做什么,更要了解这个技术适用在什么地方。所以千万别今天听了解耦的概念觉得很前卫,第二天就去把所有的类都提取出个接口。多数情况当然不会这么夸张,但滥用其实就在一念之间。
接口的坏味道
我承认,上面解释也许正确,但没什么用。懂的人懂,不懂的还是不懂;所以我还是举些接口有问题的坏味道吧。
最常见的接口坏味儿包括:(注意,总可以找到反例,所以一开始就说了,没有准则,总要具体问题具体分析,但是如果使用接口的原因是如下几种之下,我觉得应该再仔细考虑一下)
-
为了提取出某一个类所提供的Public方法。接口应该用来抽象依赖,而不是抽象实现。后面再解释。你想知道或控制一个类有哪些Method的方法有很多,但是引入一个接口,不仅达不到你的目的,还引入了复杂度——每当你要加一个方法,都要修改两个地方,一个是接口,一个是实现。
-
接口抽象出来了,但是和实现放了一起,或者根本没用到这个接口。比如,如果你写出了:
Interface f = new Implementation();
这样的代码,而且这个接口只被这样用过,那或许需要考虑一下使用这个接口的用法了。我并不是指你需要一个依赖注入的框架。但是这至少看上去不太对劲,像是为了使用接口而提取出了这个接口。
-
接口中包含了互不相关的方法。如果某个方法出现在这个接口里会让人觉得惊讶,那这个接口就是有问题的。不能因为有两个以上的类都有这个方法,所以就提取出来了。要看这两个方法有没有关系,还要看上层是不是一定会同时依赖这两个方法。使用者使用接口中的方法时,应该全部都用得到。如果没全用到,可能需要考虑一下这个接口划分的是否合理?的粒度是不是太粗了?还是把接口当成了Common Service Host来用了?
同一张类图的不同解释——真假DIP
扯得有点儿远了。回来继续正题,考虑如何把User和Implementation解耦合。所有人都知道,解耦的方法是:
- 定义接口I
- Implementation实现接口I
- User使用接口I,则不是Implementation。
这个描述已经很细了,而且画出来的类图也是唯一的。但是很可惜,这个描述是不明确的,有歧义的。
代码2和代码5都符合这个描述,但是其实是不同的设计。用图来描述会更清楚一些。
图2
图3
或许有人一看到学术派的设计图就兴奋起来,一眼就看出有一个设计是有问题的。但是当你看到代码2时,你有一眼看出问题吗?到你自己的项目代码中,你能一眼看出问题吗?问题总是出现在“混乱”中,简化成图2、图3这样,只要知道DIP的人,恐怕都能看出问题。但到项目中,那就是另一回事儿了。就像多数人都很鄙视国家组织的“软考”,考得再好,也不表示有相当的设计水平。这种简化了的问题和考题一样,也许能明白,但是能在该用的时候记得用,并不是个容易的事儿。
我来解释一下,其中根本的区别在于谁依赖谁。至于谁持有接口,只是表象。从逻辑上,调用方很明显地依赖着实现方,因为实现方才是功能的实现者,没有实现方,调用方就工作不了。但是在图3的设计中,其设计意图是,实现方要实现的功能,由调用方来决定,而不是实现方实现了什么,调用方就用什么。也就是说,要让实现方依赖调用方。这,就是DIP(依赖倒置原则)的含义。其具体表现就是,调用方定义并持有接口。
从概念上来讲,DIP的定义如下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页