依赖注入是否必须以封装为代价?

145

如果我理解正确,依赖注入的典型机制是通过类构造函数或类的公共属性(成员)进行注入。

这会暴露被注入的依赖项,并违反了面向对象编程中的封装原则。

我对这种权衡做出的判断是否正确?如何处理这个问题?

请参阅下面我自己的答案。


9
我认为这是一个非常聪明的问题。 - dfa
5
首先回答这个问题需要对“封装”这个概念进行论述。 ;) - Jeff Sternal
2
封装由接口维护。它们展示了对象的基本特征并隐藏了依赖等细节。这使我们能够在一定程度上“打开”类,以提供更灵活的配置。 - Lawrence Wagerfield
@JeffSternal 你为什么删除了你的回答?相比现在一些最受赞同的答案,它非常准确和直接。 - Steven Liekens
1
使用依赖注入(DI)将强制构造函数参数和其他类变为公开的。出于使用 DI 框架的目的,你不应该将东西暴露给外部世界。 - IamDOM
21个回答

65

这个问题可以有另一种有趣的理解方式。

当我们使用IoC/依赖注入时,我们并没有在使用面向对象编程(OOP)的概念。尽管我们使用的是面向对象语言作为“宿主”,但IoC背后的思想来自组件化软件工程,而不是面向对象。

组件化软件是关于管理依赖关系的 - 常见使用例子是.NET的程序集机制。每个程序集都发布它所引用的程序集列表,这使得汇集(和验证)运行应用程序所需的部分变得更加容易。

通过在我们的面向对象程序中应用类似的技术,如IoC,我们旨在使程序更容易配置和维护。发布依赖关系(例如构造函数参数等)是其中的一个关键部分。封装并不真正适用,因为在组件/服务导向的世界中,没有“实现类型”来泄漏细节。

不幸的是,我们的语言目前并没有将精细的面向对象概念与粗粒度的组件化概念区分开来,因此这是一个你必须仅靠脑海中理解的区别 :)


22
封装不仅是一个花哨的术语,它是一种真实存在且有实际好处的东西。无论您将程序视为“组件导向”还是“面向对象”,封装都应该保护您的对象/组件/服务/其他状态免受意外更改,并且控制反转(IoC)确实会削弱某些保护措施,因此这肯定是一个权衡。 - Ron Inbar
2
通过构造函数提供的参数仍然属于对象可能被“更改”的_预期_方式的范畴:它们被明确地公开,并且对它们周围的不变量进行了强制执行。_信息隐藏_是您所指的隐私类型的更好术语,@RonInbar,并且它并不总是有益的(它使意大利面更难解开 ;-))。 - Nicholas Blumhardt
2
面向对象编程的整个重点在于将意大利面混乱的部分分离到单独的类中,只有在需要修改特定类的行为时才需要处理它(这就是面向对象编程如何减轻复杂性的方式)。一个类(或模块)封装了其内部,同时公开了方便的公共接口(这就是面向对象编程如何促进重用的方式)。通过接口公开其依赖关系的类会为其客户端创建复杂性,并因此变得不太可重用。它也本质上更加脆弱。 - Neutrino
一个接口可以被不同类型实现,这些类型表现出不同的行为,因此接口不像具体类那样强制执行约束。通过以这种方式公开其依赖项,使它们必须由接口满足,一个类打开了自己的可能性,即注入的依赖项表现出意外的方式,从而打开了一整个运行时错误的类别,而具有静态编译依赖项的代码不会遭受这种错误。 - Neutrino
1
无论我怎么看,依我之见,DI严重削弱了OOP的一些最有价值的优点,而且我还没有遇到过一种情况,实际上使用它解决了一个真正存在的问题。 - Neutrino
2
以下是另一种表述问题的方式:想象一下,如果.NET程序集选择“封装”,而不声明它们依赖哪些其他程序集,那将是一个疯狂的情况,在加载后只能阅读文档并希望某些东西能够正常工作。在该级别声明依赖关系使得自动化工具能够处理应用程序的大规模组成。您需要眯起眼睛才能看到类比,但是在组件级别也适用类似的力量。总会有权衡取舍,结果因人而异 :-) - Nicholas Blumhardt

29

这是一个很好的问题 - 但是如果要满足对象的依赖关系,它最纯粹的封装形式在某些时候需要被违反。一些提供依赖项的提供者 必须 知道所讨论的对象需要 Foo,并且提供者必须有一种方法来为对象提供 Foo

经典的情况是如你所说,通过构造函数参数或设置方法来处理后一种情况。但是,并非总是如此-例如,我知道 Java 中 Spring DI 框架的最新版本允许您使用私有字段进行注释 (例如,使用 @Autowired),并且依赖项将通过反射设置而无需通过任何类的公共方法/构造函数来公开依赖项。这可能是您正在寻找的解决方案。

话虽如此,我认为构造函数注入也不是问题。我一直认为对象在构建之后应该是完全有效的,因此为了执行其角色(即处于有效状态),它需要的任何东西都应通过构造函数提供。如果您拥有一个需要协作才能工作的对象,则在新创建类的实例时公开此要求并确保其得到满足似乎对我是可以接受的。

理想情况下,在处理对象时,您始终通过接口与它们交互,并且您做得越多(并且通过 DI 进行依赖项连接),就越不必自己处理构造函数。在理想情况下,您的代码不会处理或甚至创建类的具体实例;因此,它只会通过 DI 得到一个 IFoo,而不用担心 FooImpl 的构造函数需要执行其工作的事情,实际上甚至不知道 FooImpl 的存在。从这个角度来看,封装是完美的。

当然,这只是我的观点,但我认为DI并不一定违反封装性,事实上可以通过将所有必要的内部知识集中到一个位置来帮助封装。这本身就是一件好事,更好的是,这个位置在你自己的代码库之外,所以你编写的代码不需要了解类的依赖关系。


谢谢您详细的回复 :)您知道有没有一个DI框架适用于.NET,可以注入私有成员吗?Spring.net可能支持这个功能吗?由于这种机制依赖于反射,对性能会产生多大的影响呢? - urig
2
好的建议。我建议不要在私有字段上使用@Autowired;这会使类难以测试;那么你如何注入模拟或存根? - user98989
4
我不同意。依赖注入确实会破坏封装性,但这是可以避免的。例如,可以使用服务定位器来避免破坏封装性,服务定位器只需要了解 Foo 依赖项的实现,而无需知道客户端类的任何信息。 但在大多数情况下,最好的方法是直接使用 "new" 运算符。 - Rogério
6
任何 DI 框架都像你描述的 ServiceLocator 一样运作;客户端不知道 Foo 实现的任何具体信息,而 DI 工具也不知道客户端的任何具体信息。而使用 "new" 更容易违反封装性,因为你需要知道确切的实现类以及它需要的所有依赖项的确切类和实例。 - Andrzej Doyle
4
使用“new”来实例化一个辅助类,通常这个类并不是公共的,可以促进封装性。使用依赖注入的替代方法将会使得这个辅助类变成公共的,并且需要在客户端类中添加一个公共构造函数或设置器;这两种改动都会破坏原本辅助类提供的封装性。 - Rogério
1
这是一个很好的问题 - 但是在某些时候,如果对象需要满足其依赖关系,纯粹形式的封装需要被违反。你回复的基本前提是不正确的。正如@Rogério所说,内部新建一个依赖项以及任何其他方法,在对象内部满足自己的依赖关系并不会违反封装。 - Neutrino

18

这会暴露出被注入的依赖项,违反了面向对象编程的封装原则。

说实话,其实任何东西都违反了封装原则。 :) 封装是一种必须小心对待的温柔原则。

那么,到底有哪些东西违反了封装原则呢?

继承违反。

“因为继承将子类暴露给父类实现的细节,所以通常说‘继承破坏封装’”。(Gang of Four 1995:19)

面向方面编程违反。例如,你注册onMethodCall()回调就会给你一个很好的机会来注入代码到正常的方法评估中,添加奇怪的副作用等。

C++中的友元声明违反。

Ruby中的类扩展违反。只需在完全定义字符串类之后在某个地方重新定义一个字符串方法即可。

嗯,很多东西违反封装原则。

封装是一项好的和重要的原则。但不是唯一的原则。

switch (principle)
{
      case encapsulation:
           if (there_is_a_reason)
      break!
}

3
那些是我的原则,如果你不喜欢它们……嗯,我还有其他的。 (格劳乔·马克思) - Ron Inbar
2
我认为这就是重点所在。它涉及到依赖注入与封装的问题。因此,只有在它能够带来显著好处时才使用依赖注入。无节制地使用依赖注入会让依赖注入声名狼藉。 - Richard Tingle
1
不确定这个答案想要表达什么...是说在进行DI时违反封装是可以的,因为无论如何都会被违反,还是仅仅是DI可能是违反封装的原因?另外,现在已经不需要依赖于public构造函数或属性来注入依赖项了;相反,我们可以注入到private annotated fields中,这样更简单(代码更少)并且保留了封装性。因此,我们可以同时利用这两个原则。 - Rogério
继承原则上不会违反封装,但如果父类编写不当,则可能会违反封装。您提出的其他观点是相对较为边缘的编程范例和几个与架构或设计关系不大的语言特性。 - Neutrino

12

是的,依赖注入(DI)违反了封装性(也被称为“信息隐藏”)。

但真正的问题在于开发者将其用作违反KISS(保持简单和简洁)和YAGNI(你不会需要它)原则的借口。

个人而言,我更喜欢简单有效的解决方案。我通常使用“new”操作符在需要和任何地方实例化有状态依赖项。这是简单、良好封装、易于理解和测试的。那么,为什么不呢?


2
提前思考并不可怕,但我同意保持简单,特别是如果你不需要它!我见过开发人员浪费时间,因为他们过度设计某些东西以使其过于未来化,并且基于直觉,而不是已知/猜测的业务需求。 - Jacob McKay

6
一个好的依赖注入容器/系统将允许构造函数注入。被依赖的对象将被封装起来,不需要公开暴露。此外,通过使用DP系统,您的代码甚至不“知道”对象的构造细节,甚至包括正在构造的对象。在这种情况下,有更多的封装,因为几乎所有的代码不仅被屏蔽了对封装对象的了解,而且甚至不参与对象的构造。
现在,我假设您正在比较创建对象自己创建其封装对象的情况,最可能是在其构造函数中。我对DP的理解是,我们希望从对象身上拿走这个责任,并将其交给其他人。为此,“其他人”,在这种情况下就是DP容器,确实具有亲密的知识,这“违反”了封装;好处是它将那些知识从对象本身中提取出来。必须有人拥有它。您的应用程序的其余部分则没有。
我会这样想:依赖注入容器/系统违反了封装,但是您的代码没有。事实上,您的代码比以往任何时候都更加“封装”。

3
如果客户端对象可以直接实例化其依赖项,为什么不这样做呢?它绝对是最简单的方法,并且不一定会降低可测试性。除了简单和更好的封装性外,这还使得拥有有状态对象而不是无状态单例变得更容易。 - Rogério
1
补充一下@Rogério所说的,这也可能会更加高效。世界上历史上创建的每个类都不需要在拥有对象的整个生命周期中实例化其所有依赖项。使用DI的对象失去了对其自身依赖项的最基本控制,即它们的生命周期。 - Neutrino

5
这与被赞同的答案相似,但我想大声思考一下——也许其他人也这样看。
经典的面向对象(OO)使用构造函数来定义类的公共“初始化”合约(隐藏所有实现细节;即封装)。该合约可以确保在实例化后,您拥有一个可立即使用的对象(即用户不需要记住任何其他初始化步骤(嗯,遗忘))。
(构造函数)DI 通过此公共构造函数接口泄漏实现细节而不可避免地破坏了封装。只要我们仍然认为公共构造函数负责为用户定义初始化合约,我们就会创建一个可怕的封装违规。
理论示例:
类 Foo 有 4 个方法并且需要一个整数进行初始化,因此其构造函数看起来像 Foo(int size) ,对于类 Foo 的用户来说,他们必须在实例化时提供 size 才能使 Foo 工作。
假设此特定实现的 Foo 还需要 IWidget 来完成其工作。此依赖项的构造函数注入将使我们创建类似于 Foo(int size, IWidget widget) 的构造函数。
我对此感到恼火的是,现在我们有一个混合初始化数据和依赖项的构造函数——一个输入对于类的用户很重要(size),另一个是只会使用户感到困惑并且是实现细节的内部依赖项(widget)。
size 参数不是依赖项——它只是每个实例的初始化值。IoC 对于外部依赖项(如 widget)非常好,但对于内部状态初始化则不适用。
更糟糕的是,如果 Widget 仅对此类的 4 个方法中的 2 个方法必要,那么即使可能不使用 Widget,我也可能会产生实例化开销!
如何妥协/协调?
一种方法是完全切换到接口以定义操作合约,并消除用户使用构造函数的方式。为了保持一致,所有对象都必须仅通过接口访问,并且仅通过某种解析器(例如 IOC / DI 容器)进行实例化。只有容器才能实例化事物。
这解决了 Widget 依赖性问题,但我们如何初始化“size”而不诉诸 Foo 接口上的单独初始化方法?使用此解决方案,我们失去了确保在获得实例时 Foo 已完全初始化的能力。真遗憾,因为我真的很喜欢构造函数注入的思想和简单性。
在这个 DI 世界中,当初始化不仅仅是外部依赖关系时,如何实现保证初始化?

更新:我刚注意到Unity 2.0支持为构造函数参数提供值(例如状态初始化程序),同时在解析期间仍使用依赖项的正常机制。也许其他容器也支持此功能?这解决了在一个构造函数中混合状态初始化和DI的技术难题,但它仍然违反了封装! - shawnT
我明白你的意思。我问这个问题是因为我也觉得两个好东西(DI和封装)其中一个会牺牲另一个。顺便说一下,在你的例子中,只有4个方法中的2个需要IWidget,这表明其他2个应该属于不同的组件。 - urig

5
正如Jeff Sternal在评论中指出的那样,答案完全取决于您如何定义“封装”。 似乎有两个主要的封装意义: 1. 与对象相关的所有内容都是对象上的方法。因此,File对象可以具有Save、Print、Display、ModifyText等方法。 2. 对象是自己的小世界,不依赖外部行为。
这两个定义彼此直接矛盾。如果File对象可以打印自己,则会严重依赖打印机的行为。另一方面,如果它仅仅“知道”可以为其打印的东西(IFilePrinter或某种接口),则File对象不必了解任何关于打印的信息,因此与之一起工作将会减少对象间的依赖关系。
因此,如果使用第一个定义,依赖注入将会破坏封装。但是,老实说,我不知道我是否喜欢第一个定义——显然它不能扩展(如果可以的话,MS Word将成为一个大类)。
另一方面,如果使用第二个封装定义,则几乎强制使用依赖注入。

我完全同意你对第一个定义的看法。它也违反了SoC,这可以说是编程中的基本原则之一,也可能是它无法扩展的原因之一。 - Marcus Stade

4

它不违反封装。您提供了一个协作者,但类可以决定如何使用它。只要遵循告诉别问的原则,一切都很好。我发现构造函数注入更可取,但是设置器也可以,只要它们是智能的。也就是说,它们包含维护类所代表的不变量的逻辑。


1
因为它不会违反封装性,如果你有一个记录器,并且你有一个需要该记录器的类,将记录器传递给该类并不会违反封装性。这就是依赖注入的全部内容。 - jrockway
3
我认为你误解了封装。例如,拿一个简单的日期类来说。在内部,它可能有日、月和年的实例变量。如果这些被暴露为简单的设置器而没有逻辑检查,那么这将破坏封装性,因为我可以将月份设置为2并将日期设置为31。另一方面,如果设置器是智能的并检查不变量,那么就没问题了。此外,请注意,在后一种情况下,我可以更改存储方式为自1970年1月1日以来的天数,只要我适当地重写日/月/年方法,就不需要让使用接口的任何程序意识到这一点。 - Jason Watkins
4
DI 确实违反了封装/信息隐藏原则。如果你将一个私有的内部依赖项转变为公共接口中暴露的内容,那么根据定义,你已经破坏了该依赖项的封装性。 - Rogério
2
我有一个具体的例子,我认为DI破坏了封装性。我有一个FooProvider从数据库获取“foo数据”,还有一个FooManager在提供程序的基础上缓存并计算其他内容。我的代码消费者错误地去FooProvider获取数据,而我更希望将其封装起来,使他们只知道FooManager。这基本上是我最初提出问题的触发器。 - urig
1
@Rogerio:我认为构造函数不是公共接口的一部分,因为它仅在组合根中使用。因此,依赖项仅由组合根“看到”。组合根的单一职责是将这些依赖项连接在一起。因此,使用构造函数注入不会破坏任何封装性。 - Jay Sullivan
显示剩余2条评论

3
纯粹的封装是一个无法实现的理想。如果所有依赖都被隐藏,那么根本不需要 DI。想一想,如果您真正拥有可以在对象内部内部化的私有值,比如汽车对象的整数速度值,那么您就没有外部依赖性,也没有倒置或注入该依赖性的需要。这些纯粹由私有函数操作的内部状态值始终是您要封装的内容。
但是,如果您正在构建需要某种类型的引擎对象的汽车,则具有外部依赖性。您可以在汽车对象的构造函数内部实例化该引擎,例如 new GMOverHeadCamEngine(),从而保留封装性,但创建与具体类 GMOverHeadCamEngine 更紧密的耦合,也可以注入它,使您的 Car 对象能够在接口 IEngine 上运行(并更加强大),而不受具体依赖性的影响。使用 IOC 容器还是简单的 DI 并不重要——重要的是,您已经拥有了一个 Car,可以使用许多种类的引擎,而不与它们中的任何一个相耦合,从而使您的代码库更加灵活,不易产生副作用。
DI 不是封装的违规行为,而是在几乎每个 OOP 项目中必然破坏封装时最小化耦合的一种方式。将依赖项外部注入接口可以最小化耦合副作用,并使您的类保持对实现的不可知性。

3
这取决于依赖关系是实现细节还是客户端需要以某种方式了解的内容。一个相关的因素是类定位的抽象级别。以下是一些例子:
如果您有一个方法使用缓存来加速调用,则缓存对象应该是单例或其他什么,而不应该被注入。事实上,使用缓存本身就是实现细节,而类的客户端不需要关心这一点。
如果您的类需要输出数据流,注入输出流可能是合理的,这样该类可以轻松地将结果输出到数组、文件或其他任何其他人可能希望发送数据的地方。
对于一个灰色区域,假设您有一个进行蒙特卡罗模拟的类。它需要一个随机来源。一方面,它需要这个源是实现细节,因为客户端真的不在乎随机性来自哪里。另一方面,由于现实世界中的随机数生成器在随机程度、速度等方面进行权衡,因此客户端可能希望控制,客户端可能希望控制种子以获得可重复的行为,这时注入可能是有意义的。在这种情况下,建议提供一种无需指定随机数生成器即可创建类的方法,并使用线程本地单例作为默认值。如果/当需要更精细的控制时,提供另一个构造函数,允许注入随机源。

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