使用依赖注入有哪些缺点?

350

我正在尝试在工作中引入依赖注入(DI)模式,我们的一位主要开发人员想知道:使用依赖注入模式有哪些缺点

请注意,我在这里寻求的是尽可能详尽的列表,而不是主观讨论。


澄清:我谈论的是依赖注入模式(参见Martin Fowler的这篇文章),而不是特定的框架,无论是基于XML的(例如Spring)还是基于代码的(例如Guice),或者"自制"的。


一些很棒的进一步讨论/发泄/辩论正在Reddit的子版块/r/programming进行。


5
我不仅仅是在寻找只适用于特定框架的答案,比如“XML很糟糕”。 :) 我正在寻找适用于Fowler在这里概述的DI概念的答案:http://martinfowler.com/articles/injection.html - Epaga
3
总的来说,我认为 DI(依赖注入)的构造函数、设置器或方法都没有任何缺点。它可以消除耦合并减少代码复杂度,而且不会增加额外负担。 - Kissaki
2
@kissaki,除了setter注入之外,最好创建可在构造时使用的对象。如果你不相信我,试着给一个使用“setter”注入的对象添加另一个协作者,你肯定会得到一个NPE(空指针异常)…… - time4tea
2
《捍卫服务定位器》(In Defense of Service Locator) - ZhongYu
@Epaga 如果不使用“class”语法来实现设计模式,那么这将是一种基本的方法来澄清这些问题。这个 是 DI 的一个示例实现。使用英语文献来回答这样的问题,总是他们自己的解释,没有别的。 - overexchange
显示剩余3条评论
19个回答

227
一些要点:
  • 依赖注入会增加复杂性,通常通过增加类的数量来实现,因为职责被更加分离,这并不总是有益的。
  • 您的代码将(在某种程度上)与您使用的依赖注入框架耦合(或更一般地说,与您决定实现DI模式的方式耦合)。
  • 执行类型解析的DI容器或方法通常会产生轻微的运行时开销(非常微不足道,但确实存在)。
通常来说,解耦使每个任务更容易阅读和理解,但增加了编排更复杂任务的复杂性。

78
分离类可以降低复杂度。许多类并不会使应用程序变得复杂。你应该只在应用程序根目录处依赖于DI框架。 - Robert
108
我们是人类,我们的短时记忆是有限制的,无法同时处理许多<xxx>。这对于类、方法、函数、文件或任何你用来开发你的程序的结构都是一样的。 - Håvard S
53
是的,但五个非常复杂的类并不比十五个简单的类更简单。降低复杂性的方法是减少程序员在编写代码时所需的工作集 - 复杂的、高度相互依赖的类无法实现这一目标。 - kyoryu
39
@kyoryu 我认为我们都同意这一点。请记住,复杂性并不等同于耦合度。减少耦合度可能会增加复杂性。我并不是说 DI 是一件坏事,因为它增加了类的数量,我只是强调与其相关的潜在缺点。 :) - Håvard S
112
+1 对于“DI 增加了复杂性”是正确的,不仅在 DI 中如此。几乎每当我们增加灵活性时,就会增加复杂性。这完全取决于平衡,平衡从了解利弊开始。当人们说“没有缺点”时,这表明他们还没有完全理解这件事。 - Don Branson
显示剩余11条评论

201

在面向对象编程、样式规则和几乎所有其他方面,你经常会遇到同样的基本问题。事实上,过度抽象、添加过多间接性以及通常过度应用好的技术并且在错误的地方使用是很常见的。

每个模式或其他结构都会带来复杂性。抽象和间接性会散布信息,有时会将不相关的细节移出,但同样也会使理解正在发生的事情更加困难。每个应用的规则都会带来不灵活性,排除可能是最佳方法的选项。

关键是编写能够完成任务并且具有强大、可读性和可维护性的代码。你是一名软件开发人员 - 而不是象牙塔建筑师。

相关链接

内部平台效应

不要被架构宇航员吓倒


依赖注入最简单的形式(不要笑)可能是一个参数。被依赖的代码依赖于数据,这些数据通过传递参数来注入。

是的,这很愚蠢,它没有解决依赖注入的面向对象的问题,但函数式编程者会告诉你(如果你有一流函数),这是你需要的唯一类型的依赖注入。这里的重点是以一个微不足道的例子来展示潜在的问题。

让我们看看这个简单的传统函数。C++语法在这里并不重要,但我必须以某种方式拼写它...

void Say_Hello_World ()
{
  std::cout << "Hello World" << std::endl;
}

我有一个依赖项,我想要将其提取出来并注入 - 文本“Hello World”。很容易...

void Say_Something (const char *p_text)
{
  std::cout << p_text << std::endl;
}

那么,它比原来更不灵活在哪里呢?如果我决定输出应该是Unicode,我可能想要从std :: cout切换到std :: wcout。但这意味着我的字符串必须是*wchar_t*,而不是*char*。每个调用者都必须更改,或者(更合理的是),旧实现被替换为一个适配器,翻译字符串并调用新实现。
这就是维护工作,如果我们保留了原始内容,就不需要这样做。
如果看起来微不足道,请看看Win32 API中的这个真实函数... CreateWindowExA函数(winuser.h) 有12个“依赖项”需要处理。例如,如果屏幕分辨率变得非常巨大,也许我们需要64位坐标值 - 和另一个版本的CreateWindowEx。是的,已经有一个旧版本仍然挂在那里,它可能在幕后映射到新版本... CreateWindowA宏(winuser.h) 这些“依赖项”不仅是原始开发人员的问题 - 每个使用该接口的人都必须查找依赖项,如何指定以及其含义,并确定如何为其应用程序执行操作。这就是“合理的默认值”可以使生活变得简单的地方。

面向对象的依赖注入原则上并没有什么不同。编写一个类既是源代码文本的开销,也是开发人员时间的开销。如果该类是根据某些受支持对象规格提供依赖项的,则即使需要替换该对象的实现,依赖对象也被锁定为支持该接口。

这并不意味着声称依赖注入是不好的-相反,它非常有用。但是任何好的技术都可以过度应用和使用不当。就像不需要将每个字符串都提取出来并转换为参数一样,不需要将每个低级行为从高级对象中提取出来并转换为可注入的依赖项。


3
依赖注入并不具有上述缺点。它只是改变了您向对象传递依赖项的方式,这既不会增加复杂性也不会使其不灵活。恰恰相反。 - Kissaki
28
@Kissaki - 是的,它改变了传递依赖项的方式。你需要编写代码来处理传递依赖项的过程。在此之前,你需要确定要传递哪些依赖项,以一种对于没有编写相关代码的人来说是有意义的方式进行定义等等。你可以避免运行时影响(例如在C++中使用策略参数模板),但这仍然是你需要编写和维护的代码。如果有正当理由,为获得巨大回报支付这个小代价是值得的,但是如果你假装没有代价,那就意味着当没有获益时你会支付这个代价。 - user180247
6
关于不灵活性,一旦您指定了依赖项,就会被锁定 - 其他人已经编写了根据该规范注入的依赖项。如果您需要为需要略有不同依赖项的依赖代码编写新的实现 - 很难,您已经被锁定了。现在是时候开始编写适配器来处理这些依赖项了(这需要更多的开销和另一个抽象层)。 - user180247
2
@ingredient_15939 - 是的,这只是一个简化的玩具示例。如果你真的在做这个,你会使用重载而不是适配器,因为复制代码的成本比适配器的成本低。但请注意,复制代码通常是一件坏事,并且使用适配器来避免复制代码是另一种好的技术(有时可能会过度使用和用错地方)。 - user180247
2
@FrancescoPasa - 关键不是倡导另一种依赖管理形式,而是不要因为“DI很棒!”而出现依赖管理问题,只有在需要时才使用。如果即使不必要也提供了DI,则这是封装的失败——本应完全封装的实现细节已以该依赖项的形式暴露给调用者,因此如果更改,调用者可能会中断。根据定义,完全封装的实现细节不能引起这种中断。权衡之处在于,封装的内部也无法由调用者控制。 - user180247
显示剩余5条评论

82

这是我的初始反应:基本上与任何模式相同的缺点。

  • 需要时间学习
  • 如果误解了,可能会带来更多的伤害而不是好处
  • 如果过度追求,可能会比获得的收益还要劳累

2
为什么学习需要时间?我认为与 EJB 相比,它使事情变得简单。 - fastcodejava
9
考虑到您不希望进行主观讨论,这种说法似乎有些含糊。我建议(如有必要可以共同)构建一个现实的示例以供检验。首先,建立一个没有依赖注入的示例是一个很好的起点。然后,我们可以针对需求变更进行挑战,并检查其影响。(顺便说一句,这对我来说非常方便,因为我即将去睡觉。) - Jesse Millikan
5
确实需要一些例子来支持这个观点。作为 DI 的教学者,我可以告诉你,它确实是一个非常简单的概念,容易学习并开始实践。 - chillitom
4
我不认为DI是一个设计模式。它(再加上IoC)更像是一种编程模型 - 如果你完全遵循它们,你的代码看起来更像演员模式,而不是“典型”的伪过程化面向对象编程。 - kyoryu
2
+1 我正在使用Symfony 2,DI遍布用户代码中,仅仅使用它就很累人。 - MGP
显示剩余2条评论

48

控制反转(Inversion of Control)最大的“缺点”(虽然这不完全等同于依赖注入,但相似度很高)就是它往往会消除算法的总览性。然而,当你有松散耦合的代码时,这基本上就是发生的事情——只能在一个地方查看算法是一种紧密耦合的产物。


3
但是毫无疑问,这个“缺点”是由我们要解决的问题的本质所决定的。将其解耦以便于轻松更改实现方式意味着没有一个地方可以查看,那么能够查看它有什么相关性呢?我唯一能想到的情况就是在调试时,调试环境应该能够进入实现中。 - vickirk
3
这就是为什么单词“downside”加了引号的原因:宽松耦合和强封装基本上防止了“一个地方看到所有东西”的出现,这种情况已经被定义了。如果你感觉我反对依赖注入(DI)/控制反转(IoC),请参考我对哈佛大学S回答的评论。 - kyoryu
1
我同意(我甚至投了您一票)。 我只是在表达观点。 - vickirk
1
@vickirk:我想缺点在于人类很难理解。解耦会增加复杂性,因为对人类来说更难理解,并需要更长时间才能完全理解。 - Bjarke Freund-Hansen
2
相反,依赖注入的一个优点是,您可以查看单个类的构造函数,并立即确定它所依赖的类(即需要执行其工作的类)。对于其他代码来说,这要困难得多,因为代码可以随意创建类。 - Contango
显示剩余5条评论

45

我过去6个月一直大量使用Guice (Java DI框架)。总体而言,我认为它非常好(特别是从测试的角度),但也有一些缺点。其中最明显的是:

  • 代码可能变得更难理解。 依赖注入可以以非常...富有创意的方式使用。例如,我刚刚发现一些代码使用自定义注释来注入某些IOStreams(例如:@Server1Stream、@Server2Stream)。尽管这确实有效,并且我承认具有一定的优雅性,但它使了解Guice注入成为了理解代码的先决条件。
  • 在学习项目时需要更高的学习曲线。 这与第1点有关。为了理解使用依赖注入的项目如何工作,您需要理解依赖注入模式和具体框架。当我加入目前的工作后,我花费了相当多的混乱时间来理解Guice在幕后执行的操作。
  • 构造函数变得较大。 尽管这可以通过默认构造函数或工厂来解决。
  • 错误可能会被隐藏。 我最近遇到的一个例子是,我在两个标志名称上发生了冲突。 Guice默默地吞掉了错误,其中一个标志没有初始化。
  • 错误被推迟到运行时。 如果您配置Guice模块不正确(循环引用、错误绑定等),大多数错误将不会在编译时被揭示。相反,这些错误仅在程序实际运行时才会暴露出来。

现在我抱怨完了。让我说一下,我将继续(自愿地)在我的当前项目和可能的下一个项目中使用Guice。依赖注入是一种伟大且非常强大的模式。但它确实可能会使人感到困惑,而您几乎肯定会花费一些时间在所选择的任何依赖注入框架上发泄不满。

此外,我同意其他帖子的观点,即依赖注入可能被过度使用。


20
我不明白为什么人们不对“错误被推迟到运行时”更加重视,对我来说这是一个决定性因素。静态类型和编译时错误是赋予开发人员的最大礼物,我不会为了任何事情而放弃它们。 - Richard Tingle
2
@RichardTingle,我认为在应用程序启动时,DI模块将首先被初始化,因此任何模块中的配置错误都会在应用程序启动后立即显示出来,而不是在几天或一段时间之后。也可以逐步加载模块,但如果我们遵循DI的精神,在初始化应用程序逻辑之前限制模块加载,我们可以成功地将错误绑定隔离到应用程序的开始。但是,如果我们将其配置为服务定位器反模式,那么这些错误绑定肯定会让人惊讶。 - Kavin Eswaramoorthy
@RichardTingle 我明白它永远不会像编译器提供的安全网那样,但是如果正确使用DI作为工具,如盒子中所述,那么这些运行时错误将仅限于应用程序初始化。然后,我们可以将应用程序初始化视为DI模块的一种编译阶段。根据我的经验,大多数情况下,如果应用程序启动,则其中不会有错误的绑定或不正确的引用。 PS-我一直在使用C#的NInject - Kavin Eswaramoorthy
@RichardTingle - 我同意你的观点,但这是一种权衡,为了获得松散耦合、易于测试的代码。正如k4vin所说,缺失的依赖关系在初始化时会被发现,并且使用接口仍然有助于编译时错误的处理。 - Andrei Epure is hiring
1
我会在你的列表中加上“难以保持代码清洁”。在你对没有它的代码进行全面测试之前,你永远不知道是否可以删除注册。 - fernacolo

41

7
当有人询问"wonsides"时,为什么回答"没有任何"这样的回答会得到赞同,而那些包含与问题相关信息的回答却不会得到赞同,我很好奇。 - Gabriel Ščerbák
13
-1因为我不是在寻找链接收集,而是在寻找实际答案:是否可以总结每篇文章的要点?那么我会点赞。请注意,这些文章本身非常有趣,尽管似乎前两篇文章实际上是揭示DI的负面效果? - Epaga
5
我想知道最得票的答案是否也遭到了反对,因为在我看来它实际上并没有回答问题 :) 当然,我知道你问了什么,我回答了什么,我不是在要求点赞,只是困惑于我的答案似乎没有帮助,而说 DI 的缺点是太酷了却很有用。 - Gabriel Ščerbák

29

没有任何DI的代码可能会陷入意大利面条式代码,一些症状是类和方法过大,做了太多事情,难以轻松更改、分解、重构或测试。

使用了很多DI的代码可以成为意大利瑞士卷式代码,其中每个小类都像一个独立的意大利瑞士卷馅料 - 它只做一件小事,并且遵循单一职责原则,这是好的。但仅看单个类很难看出整个系统的功能,因为这取决于所有这些许多小部件如何配合,这很难看出来。它看起来只像是一堆小东西。

通过避免大类中耦合代码的意大利面条般的复杂性,您将面临另一种复杂性,即有很多简单的小类,它们之间的交互是复杂的。

我不认为这是致命的缺点-DI仍然非常值得。一定程度上具有只做一件事的小类的意大利瑞士卷式风格可能是有好处的。即使过度使用,我认为它也不像意大利面条代码那样糟糕。但是意识到它会被过分使用是避免它的第一步。请参阅讨论如何避免它的链接。


1
是的,我喜欢“意大利肉饺代码”这个术语;当我谈论DI的缺点时,我会更冗长地表达它。不过,我无法想象在Java中开发任何真正的框架或应用程序而没有DI。 - Howard M. Lewis Ship
“ravioli code”的诀窍在于理解,与“意大利面条代码”不同的是,主要代码流程不存在于函数中,而是存在于连接对象的任何地方。这是需要学习的一件事情,但一旦你习惯寻找它,就会变得更容易。 - kyoryu

14

仅通过实现依赖注入而没有真正解耦你的代码,这种假象是最危险的。我认为这就是DI最危险的地方。


13

如果您有自己开发的解决方案,依赖项就在构造函数中或者作为方法参数出现,这不太难发现。但是,过多的由框架管理的依赖项可能会开始显得像魔法。

然而,一个类中有太多依赖关系,这是类结构混乱的明确标志。因此,无论是自己开发的还是框架管理的依赖注入都可以帮助揭示那些可能隐藏在黑暗中的明显设计问题。


为了更好地说明第二点,这里引用了本文 (原始来源) 中的一段摘录,我完全相信这是建立任何系统的根本问题,不仅仅是计算机系统。

假设你想设计一个大学校园。你必须将设计的一部分委托给学生和教授,否则物理建筑就不适合物理人员使用。没有一位建筑师知道物理人员需要什么样的条件,以便自己完成所有工作。但是您不能将每个房间的设计都委托给其占用者,否则您将得到一堆废墟。

如何在大型层次结构中通过分配设计责任来维护整体设计的一致性和和谐性?这是Alexander试图解决的建筑设计问题,但也是计算机系统开发的根本问题。

依赖注入能够解决这个问题吗?不是。但它确实可以帮助您清楚地看到您是否正在尝试将每个房间的设计责任委托给其占用者。


13

在依赖注入中,让我有点不放心的是假设所有被注入的对象都是廉价实例没有副作用-OR-这个依赖项在使用时频率很高,以至于它超过了任何相关的实例化成本。

当一个依赖项在消费类中不是频繁地使用时,比如像IExceptionLogHandlerService这样的东西。显然,这样的服务在类中很少被调用(希望如此:)),可能只在需要记录日志的异常上调用;然而典型的构造函数注入模式......

Public Class MyClass
    Private ReadOnly mExLogHandlerService As IExceptionLogHandlerService

    Public Sub New(exLogHandlerService As IExceptionLogHandlerService)
        Me.mExLogHandlerService = exLogHandlerService
    End Sub

    ' ...
End Class

要求提供“活”服务的实例,即使获取该实例需要付出成本/副作用。如果构造该依赖项实例涉及服务/数据库调用、配置文件查找或锁定资源直到被处理掉,那又怎样呢?如果将该服务改为按需构建、定位服务或工厂生成(它们都有自己的问题),则只有在必要时才会付出构建成本。

现在,通常接受的软件设计原则是,构造对象是廉价的且不会产生副作用。虽然这是一个很好的想法,但并不总是如此。然而,使用典型的构造函数注入基本上要求这种情况发生。这意味着当您创建依赖项的实现时,必须考虑到DI。也许您会使对象构造更加昂贵以获得其他优势,但如果该实现将被注入,则可能会迫使您重新考虑该设计。

顺便说一句,某些技术可以通过允许延迟加载注入的依赖项来缓解这个问题,例如,将Lazy<IService>实例作为依赖项提供给类。这将改变你的依赖对象的构造函数,并使它们更加了解实现细节,例如对象构造费用,这可能并不理想。


1
我理解你的意思,但我认为说“当你创建一个依赖项的实现时,你必须考虑 DI”并不公平 - 我认为更准确的说法应该是“在实现时最佳实践是考虑实例化成本、构造期间缺乏副作用或故障模式”,这样 DI 才能发挥最佳效果。最坏的情况下,您可以注入一个延迟代理实现,直到第一次使用才分配真正的对象。 - Jolly Roger
1
任何现代的IoC容器都允许您为特定的抽象类型/接口指定对象的生命周期(始终唯一、单例、http作用域等)。您还可以使用Func<T>或Lazy<T>提供工厂方法/委托来惰性实例化它。 - Dmitry S.
非常准确。不必要地实例化依赖项会消耗内存。我通常使用“延迟加载”,通过只在调用时实例化类的getter来实现。您可以提供没有参数的默认构造函数以及接受已实例化依赖项的后续构造函数。例如,在try/catch语句中发生错误时,仅使用this.errorLogger.WriteError(ex)实例化实现IErrorLogger的类。 - John Bonfardeci

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接