什么时候不应用依赖倒置原则?


43

我目前正在尝试弄清SOLID。因此,依赖倒置原则意味着任何两个类都应该通过接口而不是直接进行通信。示例:如果class A有一个方法,该方法需要一个指向类型的对象的指针class B,则该方法实际上应在一个类型为对象的对象abstract base class of B。这对打开/关闭也有帮助。

如果我理解正确,那么我的问题是将其应用于所有类交互是一种好习惯还是应该尝试从层次上考虑

我对此表示怀疑是因为我们为遵循这一原则付出了一些代价。说,我需要实现feature Z。经过分析,我的结论是功能Z包含的功能ABC。我创建了一个门面Z,即,通过接口,使用类ABC。我开始编码的实施,在某些时候我意识到,任务Z实际上是由功能性ABD。现在,我需要取消C界面,C类原型并编写单独的D界面和类。如果没有接口,则只需要替换该类。

换句话说,要更改某些内容,我需要更改1.调用者2.接口3.声明4.实现。在python直接耦合的实现中,我需要更改实现。


13
依赖倒置只是一种技术,因此它仅应在需要时应用...对它可以应用的程度没有限制,因此,如果将其应用到任何地方,都将导致垃圾:与其他情况一样特定的技术。
Frank Hileman 2015年

简而言之,某些软件设计原则的应用取决于在需求变化时能够毫不留情地进行重构。其中,接口部分被认为可以最好地捕获设计的契约不变性,而源代码(实现)则可以承受更频繁的更改。
rwong 2015年

@rwong仅当您使用支持合同不变式的语言时,接口才会捕获合同不变式。在通用语言(Java,C#)中,接口只是一组API签名。添加多余的接口只会降低设计质量。
Frank Hileman

我会说你理解错了。DIP的目的是避免编译时从“高级”组件到“低级”组件的依赖,以便允许在其他上下文中重用高级组件,对于低级组件,您将使用不同的实现。级组件;这是通过在高层创建抽象类型来完成的,该抽象类型由底层组件实现;因此高层和低层组件都依赖于此抽象。最后,高低层组件确实通过接口进行通信,但这不是 DIP的本质。
罗杰里奥2015年

Answers:


87

在许多卡通或其他媒体中,善恶的力量通常由坐在角色肩膀上的天使和恶魔来说明。在这里的故事中,我们的肩膀不是固体,而是善恶,而另一侧则是YAGNI(您将不需要它!)。

最大限度地利用SOLID原则最适合大型,复杂,超可配置的企业系统。对于更小或更具体的系统,使所有内容都变得异常灵活是不合适的,因为花费抽象的时间并不会带来好处。

例如,传递接口而不是具体的类有时意味着您可以轻松地将文件中的读取交换为网络流。然而,对于软件项目的量很大,这种灵活性是不永远会被需要,而且你可能也只是通过具体的文件类和收工和饶你的脑细胞。

对软件开发的部分了解是随着时间的流逝可能会改变什么,什么没有改变。对于可能会更改的内容,请使用界面和其他SOLID概念。对于不会用到的东西,请使用YAGNI并仅传递具体类型,忘记工厂类,忘记所有运行时连接和配置等,并忘记很多SOLID抽象。以我的经验,YAGNI方法被证明是正确的要多得多。


19
我第一次介绍SOLID大约是15年前,当时是我们正在构建的新系统。我们都喝了库尔援助人。如果有人提到听起来像YAGNI的东西,我们就像“ Pfffft ... plebeian”。我很荣幸看到这个系统在接下来的十年中不断发展。这变成了一个笨拙的混乱,没人能理解,甚至我们的创始人也无法理解。建筑师喜欢SOLID。真正赚钱的人是爱YAGNI。两者都不是完美的,但是YAGNI更接近完美,如果您不知道自己在做什么,则应将其作为默认设置。:-)
Calphool 2015年

11
@NWard是的,我们在一个项目上做到了。坚果了。现在,我们的测试无法读取或维护,部分原因是过度模拟。最重要的是,由于依赖项注入,当您试图找出某些问题时,在代码中浏览很麻​​烦。SOLID不是灵丹妙药。YAGNI并非灵丹妙药。自动化测试不是万灵丹。没有什么能使您免于辛苦工作来思考您正在做的事情,以及就它是否会帮助或阻碍您或他人的工作做出决定
jpmc26 2015年

22
这里有很多反固感。SOLID和YAGNI并非光谱的两端。它们就像图表上的X和Y坐标。一个好的系统几乎没有多余的代码(YAGNI),并且遵循SOLID原则。
斯蒂芬

31
嗯,(a)我不同意SOLID =企业化,并且(b)SOLID的全部观点是,我们倾向于对所需要的东西做出极差的预测。我必须在这里同意@Stephen。YAGNI表示,我们不应试图预测目前尚不清楚的未来需求。SOLID说,我们应该期望设计会随着时间的推移而发展,并应用某些简单的技术来简化它。它们不是互斥的;两者都是适应不断变化的需求的技术。当您尝试针对不清楚或非常遥远的需求进行设计时,就会出现真正的问题。
Aaronaught 2015年

8
“您可以轻松地将文件中的读取内容交换为网络流”-这是一个很好的示例,其中对DI的过度简化描述使人们误入歧途。人们有时会认为(实际上),“此方法将使用File,因此将需要IFile完成一项工作”。这样一来,他们就无法轻松替换网络流,因为它们对接口的需求过大,并且IFile该方法中的某些操作甚至没有使用,并且不适用于套接字,因此套接字无法实现IFile。DI并非灵丹妙药之一,而是发明了正确的抽象(接口):-)
Steve Jessop 2015年

11

用外行的话来说:

应用DIP既简单又有趣。初次尝试时设计不正确还不足以完全放弃DIP。

  • 通常,IDE可以帮助您进行这种重构,有些甚至可以使您从已经实现的类中提取接口。
  • 几乎不可能在第一时间就完成设计
  • 正常的工作流程涉及在开发的第一阶段更改和重新考虑界面
  • 随着开发的发展,它日趋成熟,您将不需要太多理由来修改接口
  • 在高级阶段,接口(设计)将变得成熟并且几乎不会改变
  • 从那一刻起,您就可以从中受益,因为您的应用程序可以扩大规模。

另一方面,使用接口和OOD进行编程可以将乐趣带回到有时过时的编程技巧中。

有人说这增加了复杂性,但我认为反对是对的。即使是小型项目。它使测试/模拟更加容易。它使您的代码更少(如果有任何case语句或嵌套的话)ifs。它降低了圈复杂度,使您以崭新的方式思考。它使编程更类似于现实世界的设计和制造。


5
我不知道OP使用的是哪种语言或IDE,但是在VS 2013中,对接口进行操作,提取接口并实现它们非常简单,如果使用TDD则很关键。使用这些原理进行开发没有额外的开发开销。
stephenbayer 2015年

如果问题是关于DIP的,为什么这个答案是关于DI的?DIP是1990年代的概念,而DI是2004年的概念。它们有很大的不同。
罗杰里奥2015年

1
(我先前的评论是要给出另一个答案;请忽略它。)“对接口进行编程”比DIP更为通用,但这并不是要使每个类都实现一个单独的接口。而且,只有在测试/模拟工具受到严重限制的情况下,它才能使“测试/模拟”变得更加容易。
罗杰里奥2015年

@Rogério通常在使用DI时,并非每个类都实现单独的接口。一个由多个类实现的接口是常见的。
图兰斯·科尔多瓦

@Rogério我更正了我的答案,每当我提到DI时,我的意思就是DIP。
图兰斯·科尔多瓦

9

在有意义的地方使用依赖关系反转

一个极端的反例是许多语言中包含的“字符串”类。它代表一个原始概念,本质上是一个字符数组。假设您可以更改此核心类,则在此处使用DI毫无意义,因为您永远不需要将内部状态换成其他内容。

如果您在模块内部使用的一组对象没有暴露给其他模块或在任何地方重复使用,则可能不值得使用DI。

我认为应该在两个地方自动使用DI:

  1. 在模块设计用于扩展。如果一个模块的全部目的是扩展它并改变行为,那么从一开始就将DI引入是很有意义的。

  2. 在出于代码重用目的而重构的模块中。也许您编码了一个类以执行某项操作,然后稍后意识到通过重构可以在其他地方利用该代码,因此有必要这样做。这是DI和其他可扩展性更改的理想选择。

这里的关键是在需要的地方使用它,因为它将引入额外的复杂性,并确保您通过技术要求(第一点)或定量代码审查(第二点)来衡量需求。

DI是一个很棒的工具,但就像任何*工具一样,它可能会被过度使用或滥用。

*以上规则的例外:往复锯是完成任何工作的理想工具。如果它不能解决您的问题,它将删除它。永久。


3
如果“您的问题”是墙上有孔怎么办?锯不会将其移除;这会使情况变得更糟。;)
Mason Wheeler

3
@MasonWheeler具有强大且有趣的使用锯,“墙洞”可能会变成“门口”,这是有用的资产:-)

1
不能用锯子打孔吗?
JeffO

尽管拥有非用户可扩展的String类型有一些优点,但是在许多情况下,如果类型具有良好的虚拟操作集(例如,将子字符串复制到的指定部分short[],请报告是否包含substring包含或可能仅包含ASCII,请尝试将被认为仅包含ASCII的子字符串复制到的指定部分byte[],等等。)太糟糕的框架是,它们的字符串类型没有实现任何有用的与字符串相关的接口。
2015年

1
如果问题是关于DIP的,为什么这个答案是关于DI的?DIP是1990年代的概念,而DI是2004年的概念。它们有很大的不同。
罗杰里奥2015年

5

在我看来,最初的问题缺少DIP的要点。

我对此表示怀疑是因为我们为遵循这一原则付出了一些代价。说,我需要实现功能Z。经过分析,我得出的结论是功能Z由功能A,B和C组成。我创建了外观类Z,该类通过接口使用类A,B和C。实现,到某个时候,我意识到任务Z实际上包含功能A,B和D。现在,我需要取消C接口,C类原型并编写单独的D接口和类。没有接口,只有类会被替换。

要真正利用DIP,您首先要创建Z类,并使其调用A,B和C类(尚未开发)的功能。这为您提供了A,B和C类的API。然后,您将创建A,B和C类,并填写详细信息。实际上,在创建类Z时,应该完全根据类Z的需要来创建所需的抽象。您甚至可以在编写A,B或C类之前就围绕Z类编写测试。

请记住,DIP曾说过:“高级模块不应依赖于低级模块。两者都应取决于抽象。”

一旦确定了Z类需要什么以及它想要获得它所需要的方式,就可以填写详细信息。当然,有时需要对Z类进行更改,但99%的情况并非如此。

永远不会有D类,因为您已经确定Z在编写之前需要A,B和C。需求的变化完全是另一回事。


5

简短的答案是“几乎从不”,但实际上,在一些地方,DIP毫无意义:

  1. 工厂或建筑商,其工作是创建对象。这些本质上是完全包含IoC的系统中的“叶子节点”。在某些时候,某些东西必须实际创建您的对象,并且不能依靠其他任何东西来做到这一点。在许多语言中,IoC容器都可以为您做到这一点,但有时您需要采用老式的方式。

  2. 数据结构和算法的实现。通常,在这些情况下,您要优化的显着特征(例如运行时间和渐近复杂性)取决于所使用的特定数据类型。如果要实现哈希表,则确实需要知道要使用的是存储数组,而不是链表,并且只有表本身知道如何正确分配数组。您也不想传递可变数组,并且让调用者通过弄乱哈希表的内容来破坏哈希表。

  3. 域模型类。这些实现了您的业务逻辑,并且(大多数时候)只有一种实现才有意义,因为(大多数时候)您只为一项业务开发软件。虽然某些领域模型类可能使用其他的域模型类构造,这通常会是对案件逐案基础。由于域模型对象不包含任何可以有效地模拟的功能,因此DIP没有可测试性或可维护性。

  4. 作为外部API提供且需要创建其他对象的任何对象,这些对象的实现详细信息您不希望公开公开。这属于“库设计与应用程序设计不同”的一般类别。一个库或框架可以在内部自由使用DI,但是最终将不得不做一些实际的工作,否则它不是一个非常有用的库。假设您正在开发网络库;您确实希望使用者能够提供自己的套接字实现。您可能会在内部使用套接字的抽象,但是向调用者公开的API将创建自己的套接字。

  5. 单元测试,测试加倍。伪造品和存根应该做一件事并且简单地做。如果您的假冒品非常复杂,足以担心是否要进行依赖注入,那么它可能太复杂了(也许是因为它实现的接口也太复杂了)。

可能还有更多;这些都是我一个上看到的有些频繁。


“任何时候您使用动态语言工作”如何?
凯文

没有?我在JavaScript中做了很多工作,在那儿它仍然同样适用。但是,SOLID中的“ O”和“ I”可能会有点模糊。
Aaronaught

嗯...我发现Python的一流类型与鸭子类型相结合使它变得不必要了。
凯文

DIP与类型系统绝对无关。python以什么方式唯一的“一流类型”?当您想孤立地测试某些东西时,应该用test double代替它的依赖性。这些测试双打可以是接口的替代实现(以静态类型的语言),也可以是匿名对象或恰好在其上具有相同方法/功能的替代类型(鸭子类型)。在这两种情况下,您仍然需要一种方法来实际替代实例。
Aaronaught

1
@Kevin python几乎不是第一种拥有动态类型或松散模拟的语言。这也是完全不相关的。问题不是对象的类型是什么,而是如何/在何处创建对象。当一个对象创建自己的依赖关系时,您将不得不做一些可怕的事情,例如将公共API未提及的类的构造函数存根,从而对实现细节进行单元测试。忘记测试,混合行为和对象构造只会导致紧密耦合。鸭子打字不能解决这些问题。
亚罗诺(Aaronaught)2015年

2

有些迹象表明您可能在无法提供价值的水平上应用DIP:

  • 您有一个C / CImpl或IC / C对,并且只有该接口的一个实现
  • 介面和实作中的签章一一对应(违反DRY原则)
  • 您经常同时更改C和CImpl。
  • C在您的项目内部,不作为库在项目外部共享。
  • 您对Eclipse / Visual Studio中的F12中的F3感到沮丧,将您带到界面而不是实际的类

如果您看到的是这种情况,最好直接让Z调用C并跳过该界面,这样会更好。

另外,我不认为依赖注入/动态代理框架(Spring,Java EE)通过方法修饰来实现与真正的SOLID DIP相同的方式-这更像是方法修饰在该技术堆栈中的实现细节。Java EE社区认为这是一种改进,您不需要像以前那样使用Foo / FooImpl对(参考)。相比之下,Python支持将函数修饰作为一流的语言功能。

另请参阅此博客文章


0

如果您总是反转依赖关系,那么所有依赖关系都将倒置。这意味着,如果您从带有一堆依赖关系的凌乱代码开始,那(实际上)就是那样,只是倒过来了。在这里,您会遇到一个问题,即对实现的每次更改也都需要更改其接口。

依赖关系反转的要点是您有选择地反转使事物纠结的依赖关系。从A到B再到C再到C的人仍然这样做。

结果应该是没有周期的依赖关系图-DAG。有多种 工具可以检查此属性并绘制图形。

有关更完整的说明,请参阅本文

正确应用依赖倒置原则的本质是:

将您依赖的代码/服务/…拆分为一个接口和实现。接口使用它重新构造代码术语中的依赖关系,实现根据其底层技术来实现。

实现仍保留在原处。但是该接口现在具有不同的功能(并使用不同的术语/语言),描述了使用代码可以执行的操作。将其移动到该程序包。通过不将接口和实现放在同一包中,依赖项的(方向)从用户→实现转为实现→用户。

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.