登录
/
注册
首页
论坛
其它
首页
科技
业界
安全
程序
社区
BBS
广播
Follow
园子
关于
博客
发1篇日志+1圆
记录
发1条记录+2圆币
发帖说明
登录
/
注册
账号
自动登录
找回密码
密码
登录
立即注册
搜索
搜索
关闭
CSDN热搜
程序园
精品问答
技术交流
资源下载
本版
帖子
用户
软件
问答
教程
代码
VIP申请
网盘
联系我们
道具
勋章
任务
设置
我的收藏
退出
腾讯QQ
微信登录
返回列表
首页
›
业界区
›
业界
›
设计原则与模式——从DIP中“倒置”的含义说接口的正确 ...
设计原则与模式——从DIP中“倒置”的含义说接口的正确使用
[ 复制链接 ]
决台
2025-5-29 19:03:19
提纲
开灯的例子
暗流涌动
Guru眼中的依赖
DIP(依赖倒置原则)
为什么要解耦合?
接口的坏味道
同一张类图的不同解释——真假DIP
了解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)大都实现了这个接口,因为上层的功能(比如排序)依赖类的对象有相互比较的能力,同时每个类的实现方式又都不一样,即所谓的同时存在多个实现。
所以,对于需要“换一种方式实现它”的情况,大可以把原来的代码删除然后重新写一个。
有句话叫“拿着锤子,看什么都像钉子”。了解一项技术,不仅仅要了解他能做什么,更要了解这个技术适用在什么地方。所以千万别今天听了解耦的概念觉得很前卫,第二天就去把所有的类都提取出个接口。多数情况当然不会这么夸张,但滥用其实就在一念之间。
接口的坏味道
我承认,上面解释
也许
正确,但没什么用。懂的人懂,不懂的还是不懂;所以我还是举些接口有问题的坏味道吧。
最常见的接口坏味儿包括:(注意,总可以找到反例,所以一开始就说了,没有准则,总要具体问题具体分析,但是如果使用接口的原因是如下几种之下,我觉得应该再仔细考虑一下)
为了提取出某一个类所提供的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页
来源:程序园用户自行投稿发布,如果侵权,请联系站长删除
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!
回复
使用道具
举报
提升卡
置顶卡
沉默卡
喧嚣卡
变色卡
千斤顶
照妖镜
相关推荐
那些年搞不懂的高深术语——依赖倒置•控制反转•依赖注入•面向接口编程
如何优雅的使用RabbitMQ
分布式锁1 Java常用技术方案
浅谈我对DDD领域驱动设计的理解
游戏编程十年总结(下)
【前端性能】高性能滚动 scroll 及页面渲染优化
验证码对抗之路及现有验证机制介绍
从零开始入门 K8s | 手把手带你理解 etcd
NHibernate之旅(2):第一个NHibernate程序
第七章: SEO与渲染方式
中文写程序,何陋之有?
谈谈如何从本质上理解sql语句, 存储过程,ORM之间的联系和取舍。
模板模式
[一步一步MVC]第一回:使用ActionSelector控制Action的选择
公司的中场
FFmpeg开发笔记(六十二)Windows给FFmpeg集成H.266编码器vvenc
Android 系统缺陷不完全点评
.net环境下跨进程、高频率读写数据
高级模式
B
Color
Image
Link
Quote
Code
Smilies
您需要登录后才可以回帖
登录
|
立即注册
回复
本版积分规则
回帖并转播
回帖后跳转到最后一页
签约作者
程序园优秀签约作者
发帖
决台
2025-5-29 19:03:19
关注
0
粉丝关注
16
主题发布
板块介绍填写区域,请于后台编辑
财富榜{圆}
敖可
9986
凶契帽
9992
背竽
9992
4
猷咎
9990
5
里豳朝
9990
6
处匈跑
9990
7
黎瑞芝
9990
8
松菊
9990
9
段干叶农
9990
10
炀餮氢
9990
查看更多