135

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

这暴露了被注入的依赖,违反了 OOP 的封装原则。

我确定这种权衡是否正确?你如何处理这个问题?

另请参阅下面我对我自己的问题的回答。

4

21 回答 21

63

还有另一种看待这个问题的方法,您可能会觉得有趣。

当我们使用 IoC/依赖注入时,我们没有使用 OOP 概念。诚然,我们使用 OO 语言作为“宿主”,但 IoC 背后的想法来自面向组件的软件工程,而不是 OO。

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

通过 IoC 在我们的 OO 程序中应用类似的技术,我们的目标是使程序更易于配置和维护。发布依赖项(作为构造函数参数或其他)是其中的关键部分。封装并不真正适用,因为在面向组件/服务的世界中,没有“实现类型”可以泄露细节。

不幸的是,我们的语言目前并没有将细粒度的面向对象的概念与粗粒度的面向组件的概念区分开来,所以这是你必须牢记的区别:)

于 2009-11-26T00:11:00.073 回答
30

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

正如您所说,通常通过构造函数参数或 setter 方法处理后一种情况。但是,这不一定是正确的——我知道最新版本的 Java 中的 Spring DI 框架,例如,让您注释私有字段(例如 with @Autowired),并且依赖项将通过反射设置,而无需您通过暴露依赖项任何类的公共方法/构造函数。这可能是您正在寻找的解决方案。

也就是说,我认为构造函数注入也不是什么大问题。我一直认为对象在构造后应该是完全有效的,因此它们执行其角色所需的任何东西(即处于有效状态)都应该通过构造函数提供。如果您有一个需要协作者工作的对象,那么构造函数公开宣传此要求并确保在创建该类的新实例时满足该要求对我来说似乎很好。

理想情况下,在处理对象时,无论如何您都通过接口与它们交互,并且您这样做的次数越多(并且通过 DI 连接依赖项),您实际上需要自己处理构造函数的次数就越少。在理想情况下,您的代码不会处理甚至创建类的具体实例;所以它只是IFoo通过 DI 获得,而不用担心构造函数FooImpl表明它需要完成它的工作,实际上甚至不知道FooImpl' 的存在。从这个角度来看,封装是完美的。

这当然是一种观点,但在我看来,DI 不一定违反封装,实际上可以通过将所有必要的内部知识集中到一个地方来帮助它。这不仅本身是一件好事,而且更好的是这个地方在您自己的代码库之外,因此您编写的任何代码都不需要了解类的依赖关系。

于 2009-06-17T11:17:03.827 回答
18

这暴露了被注入的依赖,违反了 OOP 的封装原则。

好吧,坦率地说,一切都违反了封装。:) 这是一种必须善待的温柔原则。

那么,什么违反了封装?

继承可以

“因为继承将子类暴露给其父类的实现细节,所以人们常说‘继承破坏封装’”。(四人帮 1995:19)

面向方面的编程 确实如此。例如,您注册 onMethodCall() 回调,这为您提供了一个很好的机会将代码注入到正常的方法评估中,添加奇怪的副作用等。

C++ 中的 Friend 声明确实.

Ruby 中的类扩展确实如此。只需在完全定义字符串类之后的某处重新定义字符串方法即可。

嗯,很多东西都可以

封装是一个很好的重要原则。但不是唯一的。

switch (principle)
{
      case encapsulation:
           if (there_is_a_reason)
      break!
}
于 2010-04-25T18:54:05.540 回答
13

是的,DI 违反了封装(也称为“信息隐藏”)。

但真正的问题是当开发人员以此为借口违反 KISS(保持简短和简单)和 YAGNI(你不需要它)原则时。

就个人而言,我更喜欢简单有效的解决方案。我主要使用“new”操作符来实例化有状态的依赖关系,无论何时何地都需要它们。它简单,封装良好,易于理解,易于测试。那么,为什么不呢?

于 2009-07-21T23:10:34.487 回答
5

一个好的依赖注入容器/系统将允许构造函数注入。依赖对象将被封装,根本不需要公开。此外,通过使用 DP 系统,您的代码甚至都不“知道”对象是如何构造的细节,甚至可能包括正在构造的对象。在这种情况下有更多的封装,因为几乎所有代码不仅不知道封装对象的知识,而且甚至不参与对象的构造。

现在,我假设您正在与创建的对象创建自己的封装对象的情况进行比较,很可能是在其构造函数中。我对DP的理解是,我们想把这个责任从对象身上拿下来,交给别人。为此,“其他人”,在这种情况下是 DP 容器,确实具有“违反”封装的内知知识;好处是它将知识从对象本身中提取出来。必须有人拥有它。您的应用程序的其余部分没有。

我会这样想:依赖注入容器/系统违反了封装,但您的代码没有。事实上,您的代码比以往任何时候都更加“封装”。

于 2009-06-17T10:49:09.000 回答
5

正如 Jeff Sternal 在对该问题的评论中指出的那样,答案完全取决于您如何定义封装

封装的含义似乎有两个主要阵营:

  1. 与对象相关的一切都是对象上的方法。所以,一个File对象可能有Save, Print, Display,ModifyText等的方法。
  2. 一个对象是它自己的小世界,不依赖于外部行为。

这两个定义是直接矛盾的。如果一个File对象可以打印自己,它将在很大程度上取决于打印机的行为。另一方面,如果它只知道可以为它打印的东西(一个IFilePrinter或一些这样的接口),那么File对象就不必知道任何关于打印的事情,因此使用它会给对象带来更少的依赖。

因此,如果您使用第一个定义,依赖注入将破坏封装。但是,坦率地说,我不知道我是否喜欢第一个定义——它显然不能扩展(如果是,MS Word 将是一个大类)。

另一方面,如果您使用封装的第二种定义,则依赖注入几乎是强制性的。

于 2010-04-25T19:12:29.277 回答
4

它不违反封装。你提供了一个合作者,但班级可以决定如何使用它。只要您遵循告诉不要问事情就可以了。我发现构造函数注入更可取,但只要他们聪明,setter 就可以了。也就是说,它们包含维护类所代表的不变量的逻辑。

于 2009-06-17T07:57:42.950 回答
4

这类似于赞成的答案,但我想大声思考——也许其他人也这样看待事情。

  • 经典 OO 使用构造函数为类的消费者定义公共“初始化”契约(隐藏所有实现细节;也称为封装)。这个契约可以确保在实例化之后你有一个可以使用的对象(即用户不需要记住(呃,忘记)额外的初始化步骤)。

  • (构造函数)DI 不可否认地通过此公共构造函数接口泄露实现细节来破坏封装。只要我们仍然考虑负责为用户定义初始化合约的公共构造函数,我们就已经创建了一个可怕的封装违规行为。

理论例子:

Foo有 4 个方法并且需要一个整数来初始化,所以它的构造函数看起来像Foo(int size)并且类Foo的用户立即清楚他们必须在实例化时提供一个大小才能使 Foo 工作。

假设 Foo 的这个特定实现可能还需要一个IWidget来完成它的工作。这种依赖的构造函数注入会让我们创建一个像Foo(int size, IWidget widget)这样的构造函数

令我恼火的是现在我们有一个将初始化数据与依赖项混合的构造函数——一个输入是类的用户感兴趣的(大小),另一个是一个内部依赖项,它只会混淆用户并且是一个实现详细信息(小部件)。

size 参数不是依赖项——它是一个简单的每个实例的初始化值。IoC 对于外部依赖项(如小部件)非常有用,但不适用于内部状态初始化。

更糟糕的是,如果这个类的 4 个方法中的 2 个只需要 Widget 怎么办?即使它可能不被使用,我也可能会产生 Widget 的实例化开销!

如何妥协/调和这个?

一种方法是专门切换到接口来定义操作合约;并废除用户对构造函数的使用。为了保持一致,所有对象都只能通过接口访问,并且只能通过某种形式的解析器(如 IOC/DI 容器)进行实例化。只有容器才能实例化事物。

这处理了 Widget 依赖关系,但是我们如何在不使用 Foo 接口上的单独初始化方法的情况下初始化“大小”?使用此解决方案,我们失去了确保 Foo 实例在您获得实例时完全初始化的能力。太糟糕了,因为我真的很喜欢构造函数注入的想法和简单性。

当初始化不仅仅是外部依赖项时,如何在这个 DI 世界中实现有保证的初始化?

于 2010-02-25T06:16:09.737 回答
3

在进一步解决了这个问题之后,我现在认为依赖注入确实(此时)在某种程度上违反了封装。不过不要误会我的意思——我认为在大多数情况下使用依赖注入是值得的。

当您正在处理的组件要交付给“外部”方(考虑为客户编写库)时,为什么 DI 违反封装的情况变得很清楚。

当我的组件需要通过构造函数(或公共属性)注入子组件时,无法保证

“防止用户将组件的内部数据设置为无效或不一致的状态”。

同时也不能说

“组件(其他软件)的用户只需要知道组件做什么,不能让自己依赖于它如何做的细节”

两句话都来自维基百科

举一个具体的例子:我需要提供一个客户端 DLL,它可以简化和隐藏与 WCF 服务(本质上是一个远程外观)的通信。因为它依赖于 3 个不同的 WCF 代理类,所以如果我采用 DI 方法,我将被迫通过构造函数公开它们。这样我就暴露了我试图隐藏的通信层的内部结构。

一般来说,我都支持DI。在这个特殊(极端)的例子中,我觉得它很危险。

于 2009-06-28T16:07:26.163 回答
3

纯封装是永远无法实现的理想。如果所有依赖项都被隐藏,那么您根本不需要 DI。这样想,如果你真的有可以在对象中内化的私有值,例如汽车对象速度的整数值,那么你就没有外部依赖,也不需要反转或注入该依赖。这些纯粹由私有函数操作的内部状态值是您始终想要封装的。

但是如果你正在建造一辆需要某种引擎对象的汽车,那么你就有了外部依赖。您可以在汽车对象的构造函数内部实例化该引擎(例如 new GMOverHeadCamEngine()),保留封装但创建与具体类 GMOverHeadCamEngine 的更隐蔽的耦合,或者您可以注入它,允许您的汽车对象运行例如,在没有具体依赖关系的接口 IEngine 上不可知(并且更健壮)。无论您使用 IOC 容器还是简单的 DI 来实现这一点都不是重点——重点是您拥有一辆可以使用多种引擎而无需耦合任何引擎的 Car,从而使您的代码库更加灵活和不太容易出现副作用。

DI 不是对封装的违反,它是一种在几乎每个 OOP 项目中必然会破坏封装时最小化耦合的方法。从外部将依赖项注入接口可以最大限度地减少耦合副作用,并允许您的类对实现保持不可知论。

于 2010-01-14T19:12:40.990 回答
3

这取决于依赖项是否真的是一个实现细节,还是客户希望/需要以某种方式知道的东西。相关的一件事是该类所针对的抽象级别。这里有些例子:

如果你有一个在后台使用缓存来加速调用的方法,那么缓存对象应该是 Singleton 或其他东西,应该被注入。缓存正在被使用的事实是您的类的客户不必关心的实现细节。

如果你的类需要输出数据流,那么注入输出流可能是有意义的,这样类就可以轻松地将结果输出到数组、文件或其他人可能想要发送数据的任何其他地方。

对于灰色区域,假设您有一个类进行蒙特卡罗模拟。它需要随机性的来源。一方面,它需要这个事实是一个实现细节,因为客户端实际上并不关心随机性的确切来源。另一方面,由于现实世界的随机数生成器在客户端可能想要控制的随机程度、速度等之间进行权衡,并且客户端可能想要控制播种以获得可重复的行为,因此注入可能是有意义的。在这种情况下,我建议提供一种在不指定随机数生成器的情况下创建类的方法,并使用线程本地单例作为默认值。如果/当需要更精细的控制时,请提供另一个允许注入随机源的构造函数。

于 2010-01-27T03:33:31.077 回答
2

我相信简单。在域类中应用 IOC/Dependecy 注入并没有带来任何改进,只是通过使用描述关系的外部 xml 文件使代码更难维护。许多技术(如 EJB 1.0/2.0 和 struts 1.1)正在通过减少放入 XML 中的内容并尝试将它们作为注释等放入代码中来逆转。因此,将 IOC 应用于您开发的所有类将使代码变得毫无意义。

当依赖对象在编译时还没有准备好创建时,IOC 有它的好处。这可能发生在大多数基础设施抽象级架构组件中,试图建立一个可能需要适用于不同场景的通用基础框架。在那些地方使用 IOC 更有意义。但这并没有使代码更简单/可维护。

与所有其他技术一样,这也有优点和缺点。我担心的是,我们在所有地方都实施了最新技术,而不管它们的最佳上下文使用情况如何。

于 2009-11-24T19:56:36.547 回答
2

只有当一个类既负责创建对象(需要了解实现细节)又使用该类(不需要了解这些细节)时,封装才会被打破。我将解释原因,但首先是一个快速的汽车类比:

当我驾驶 1971 年的旧 Kombi 时,我可以踩下油门,它(稍微)快一点。我不需要知道为什么,但是在工厂制造 Kombi 的人就知道为什么。

但回到编码。 封装是“对使用该实现的东西隐藏实现细节”。封装是一件好事,因为实现细节可以在类用户不知道的情况下发生变化。

使用依赖注入时,构造函数注入用于构造服务类型对象(与建模状态的实体/值对象相反)。服务类型对象中的任何成员变量都表示不应泄露的实现细节。例如套接字端口号、数据库凭据、另一个要调用以执行加密的类、缓存等。

最初创建类时,构造函数是相关的。这发生在构建阶段,而您的 DI 容器(或工厂)将所有服务对象连接在一起。DI 容器只知道实现细节。它知道所有关于实施细节的知识,就像 Kombi 工厂的人知道火花塞一样。

在运行时,创建的服务对象被称为 apon 来做一些实际的工作。此时,对象的调用者对实现细节一无所知。

那是我开着我的 Kombi 去海滩。

现在,回到封装。如果实现细节发生变化,那么在运行时使用该实现的类不需要改变。封装没有被破坏。

我也可以开着我的新车去海滩。封装没有被破坏。

如果实现细节发生变化,DI 容器(或工厂)确实需要改变。首先,您从未试图向工厂隐藏实施细节。

于 2011-07-07T02:52:13.850 回答
2

DI 违反了非共享对象的封装 - 句号。共享对象的生命周期在被创建对象之外,因此必须聚合到被创建对象中。对正在创建的对象私有的对象应该组合到创建的对象中 - 当创建的对象被销毁时,它会带上组合的对象。我们以人体为例。什么是合成的,什么是聚合的。如果我们使用 DI,人体构造函数将有 100 个对象。例如,许多器官(可能)是可更换的。但是,它们仍然组成了身体。血细胞每天在体内产生(并被破坏),无需外部影响(蛋白质除外)。因此,血细胞是由身体内部产生的 - new BloodCell()。

Advocators of DI argue that an object should NEVER use the new operator. That "purist" approach not only violates encapsulation but also the Liskov Substitution Principle for whoever is creating the object.

于 2018-03-22T02:11:08.650 回答
1

我也为这个想法苦苦挣扎。起初,使用 DI 容器(如 Spring)来实例化对象的“要求”感觉就像是在跳跃。但实际上,它真的不是一个箍——它只是另一种“发布”的方式来创建我需要的对象。当然,封装被“破坏”了,因为“类外”的某个人知道它需要什么,但实际上并不是系统的其他部分知道这一点——它是 DI 容器。没有什么神奇的事情发生不同,因为 DI“知道”一个对象需要另一个对象。

事实上,它变得更好 - 通过专注于工厂和存储库,我什至根本不需要知道 DI 参与其中!对我来说,这让盖子重新回到了封装上。哇!

于 2009-06-25T20:02:23.407 回答
1

PS。通过提供依赖注入,您不一定会破坏封装。例子:

obj.inject_dependency(  factory.get_instance_of_unknown_class(x)  );

客户端代码仍然不知道实现细节。

于 2010-04-25T19:51:37.540 回答
1

也许这是一种幼稚的思考方式,但是接受整数参数的构造函数和接受服务作为参数的构造函数之间有什么区别?这是否意味着在新对象之外定义一个整数并将其输入对象会破坏封装?如果该服务仅在新对象中使用,我看不出这会如何破坏封装。

此外,通过使用某种自动装配功能(例如 C# 的 Autofac),它使代码非常干净。通过为 Autofac 构建器构建扩展方法,我能够删减大量的 DI 配置代码,随着依赖项列表的增长,我必须随着时间的推移维护这些代码。

于 2010-06-04T17:11:04.500 回答
1

我认为不言而喻,至少 DI 显着削弱了封装性。除此之外,还有 DI 的其他一些缺点需要考虑。

  1. 它使代码更难重用。客户端可以使用而无需显式提供依赖关系的模块显然比客户端必须以某种方式发现该组件的依赖关系然后以某种方式使它们可用的模块更容易使用。例如,最初创建用于 ASP 应用程序的组件可能期望其依赖项由 DI 容器提供,该 DI 容器为对象实例提供与客户端 http 请求相关的生命周期。在另一个没有与原始 ASP 应用程序相同的内置 DI 容器的客户端中重现这可能并不简单。

  2. 它可以使代码更加脆弱。接口规范提供的依赖关系可以以意想不到的方式实现,这会导致一整类运行时错误,而这些错误是静态解决的具体依赖关系所不可能实现的。

  3. 从某种意义上说,它可能会使代码的灵活性降低,因为您最终可能会在希望其工作方式方面做出更少的选择。并非每个类都需要在拥有实例的整个生命周期内都存在其所有依赖项,但是对于许多 DI 实现,您别无选择。

考虑到这一点,我认为最重要的问题变成了“是否需要从外部指定特定的依赖项? ”。在实践中,我很少发现有必要为了支持测试而在外部提供依赖项。

在真正需要外部提供依赖关系的情况下,这通常表明对象之间的关系是协作而不是内部依赖关系,在这种情况下,适当的目标是封装每个类,而不是将一个类封装在另一个类中.

根据我的经验,关于使用 DI 的主要问题是,无论您是从具有内置 DI 的应用程序框架开始,还是将 DI 支持添加到您的代码库中,出于某种原因,人们认为既然您有 DI 支持,那一定是正确的实例化一切的方式。他们甚至都懒得问“这种依赖是否需要在外部指定?”。更糟糕的是,他们还开始试图强迫其他人也对所有事情都使用 DI支持

这样做的结果是,你的代码库不可避免地开始演变为一种状态,在你的代码库中创建任何实例都需要大量迟钝的 DI 容器配置,并且调试任何东西的难度都是两倍,因为你有额外的工作量来尝试确定如何以及实例化任何东西的地方。

所以我对这个问题的回答是这样的。使用 DI,您可以确定它为您解决的实际问题,这是您无法以任何其他方式更简单地解决的问题。

于 2017-10-25T20:20:20.873 回答
0

我同意极端情况下,DI 可能违反封装。通常 DI 会暴露从未真正封装过的依赖项。这是从 Miško Hevery 的Singletons are Pathological Liars 中借用的一个简化示例:

您从 CreditCard 测试开始并编写一个简单的单元测试。

@Test
public void creditCard_Charge()
{
    CreditCard c = new CreditCard("1234 5678 9012 3456", 5, 2008);
    c.charge(100);
}

下个月你会收到一张 100 美元的账单。你为什么被指控?单元测试影响了生产数据库。在内部,CreditCard 调用Database.getInstance(). 重构 CreditCard 使其DatabaseInterface在其构造函数中采用 a 暴露了存在依赖的事实。但我会争辩说,从一开始就没有封装依赖关系,因为 CreditCard 类会导致外部可见的副作用。如果你想在不重构的情况下测试 CreditCard,你当然可以观察依赖关系。

@Before
public void setUp()
{
    Database.setInstance(new MockDatabase());
}

@After
public void tearDown()
{
    Database.resetInstance();
}

我认为将数据库作为依赖项公开是否会减少封装并不值得担心,因为这是一个很好的设计。并非所有 DI 决策都会如此直接。但是,其他答案都没有显示反例。

于 2010-07-19T21:39:14.597 回答
0

我认为这是一个范围问题。当您定义封装(不让知道如何)时,您必须定义什么是封装的功能。

  1. 按原样上课:您所封装的是班级的唯一责任。它知道该怎么做。例如,排序。如果您注入一些比较器进行排序,比如说客户,那不是封装的一部分:快速排序。

  2. 已配置的功能:如果您想提供即用型功能,那么您提供的不是 QuickSort 类,而是配置了 Comparator 的 QuickSort 类的实例。在这种情况下,负责创建和配置的代码必须对用户代码隐藏。这就是封装。

当您对类进行编程时,在类中实现单一职责时,您正在使用选项 1。

当你在编写应用程序时,就是做一些可以承担一些有用的具体工作的东西,然后你会重复使用选项 2。

这是配置实例的实现:

<bean id="clientSorter" class="QuickSort">
   <property name="comparator">
      <bean class="ClientComparator"/>
   </property>
</bean>

这是其他一些客户端代码使用它的方式:

<bean id="clientService" class"...">
   <property name="sorter" ref="clientSorter"/>
</bean>

它是封装的,因为如果您更改实现(更改clientSorterbean 定义),它不会破坏客户端的使用。也许,当您使用所有一起编写的 xml 文件时,您会看到所有细节。但请相信我,客户端代码 ( ClientService) 对它的分拣机一无所知

于 2012-08-09T07:39:58.637 回答
0

可能值得一提的是,这Encapsulation在某种程度上取决于视角。

public class A { 
    private B b;

    public A() {
        this.b = new B();
    }
}


public class A { 
    private B b;

    public A(B b) {
        this.b = b;
    }
}

从在A课堂上工作的人的角度来看,在第二个例子中 A,对this.b

而没有 DI

new A()

对比

new A(new B())

看这段代码的人更了解A第二个例子中的本质。

使用 DI,至少所有泄露的知识都集中在一个地方。

于 2015-05-26T12:53:02.863 回答