依赖注入必须以牺牲封装为代价吗?

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

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

我认为这种权衡是正确的吗? 你是如何处理这个问题的?

请看下面我对自己问题的回答。

12417 次浏览

它没有违反封装。您提供了一个合作者,但是类可以决定如何使用它。只要你遵循 告诉别人不要问就没问题。我觉得构造函数注入更好,但设置器可以罚款以及只要他们的智能。也就是说,它们包含维护类所表示的不变量的逻辑。

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

现在,我假设您正在比较创建的对象创建自己的封装对象的情况,很可能是在其构造函数中。我对 DP 的理解是,我们想把这个责任从对象那里拿走,交给其他人。为此,“ someone else”(在本例中是 DP 容器)确实具有“违反”封装的亲密知识; 其好处是它将这些知识从对象本身中抽取出来。总得有人拥有它。您的应用程序的其余部分则不需要。

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

这是一个很好的问题——但是在某些时候,如果对象的依赖性得到了满足,那么封装就会以最纯粹的 需求形式被破坏。依赖关系 必须的的某个提供者知道所涉及的对象需要 Foo,并且提供者必须有办法向对象提供 Foo

通常,后一种情况是通过构造函数参数或 setter 方法处理的。然而,这并不一定正确——我知道 Java 中 Spring DI 框架的最新版本,例如,允许您注释私有字段(例如使用 @Autowired) ,并且依赖关系将通过反射设置,而不需要通过任何类公共方法/构造函数公开依赖关系。这可能就是你想要的解决方案。

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

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

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

我也在纠结这个想法。起初,使用 DI 容器(如 Spring)来实例化一个对象的“需求”让人感觉像是跳过了一个圈。但在现实中,它真的不是一个箍-它只是另一个“发布”的方式来创建我需要的对象。当然,封装是“破碎的”,因为“类之外”的人知道它需要什么,但真正知道这一点的并不是系统的其他部分——而是 DI 容器。没有什么神奇的事情会因为 DI“知道”一个对象需要另一个而发生不同。

事实上,它甚至变得更好-通过关注工厂和存储库,我甚至不必知道依赖注入是在所有!对我来说,这样就可以重新封装了。呼!

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

当您正在处理的组件要交付给“外部”方时(想象一下为客户编写库) ,DI 违反封装的原因就很明显了。

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

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

同时也不能说

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

两个报价都来自 维基百科

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

一般来说,我完全支持 DI。在这个特殊(极端)的例子中,它给我的印象是危险的。

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

但是真正的问题是,当开发人员用它作为借口来违反 KISS (保持简洁)和 YAGNI (你不需要它)原则时。

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

我相信简单。在 Domain 类中应用 IOC/Dependecy 注入不会带来任何改进,除了通过使用描述关系的外部 xml 文件使代码更难以进行 main。许多像 EJB 1.0/2.0和 struts 1.1这样的技术正在通过减少放入 XML 中的内容,并尝试将它们作为注释放入代码中等方式倒退。因此,将 IOC 应用于您开发的所有类将使代码变得毫无意义。

当依赖对象在编译时还没有准备好创建时,IOC 可以从中受益。这可能发生在大多数基础设施抽象级体系结构组件中,试图建立一个可能需要在不同场景下工作的公共基础框架。在这些地方使用 IOC 更有意义。但这并不能使代码更加简单/易于维护。

正如所有其他技术一样,这也有正反两面。我担心的是,我们在所有地方都实现了最新的技术,而不考虑它们的最佳上下文使用情况。

还有另一种方式来看待这个问题,你可能会感兴趣。

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

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

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

不幸的是,我们的语言目前还没有将细粒度的、面向对象的概念与粗粒度的面向组件的概念隔离开来,所以这是一个你只需要牢记在心的区别:)

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

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

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

这取决于依赖项是否真的是一个实现细节,或者是客户机希望/需要以某种方式了解的东西。有一件事是相关的,那就是这个类的目标是什么级别的抽象。下面是一些例子:

如果您有一个方法使用底层缓存来加速调用,那么缓存对象应该是一个 Singleton 或者类似的东西,并且应该注入 没有。正在使用缓存的事实是类的客户端不应该关心的实现细节。

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

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

这个答案与上面提到的类似,但我想大声说出来——也许其他人也是这样看待问题的。

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

  • (构造函数) DI 不可否认地通过这个公共构造函数接口流出 实施细节来破坏封装。只要我们仍然认为公共构造函数负责为用户定义初始化契约,我们就已经创建了一个可怕的封装冲突。

理论上的例子:

阿福有4个方法,初始化需要一个整数,所以它的构造函数看起来像 Foo (int size),类 阿福的用户很快就会明白,为了让 Foo 工作,他们必须在实例化时提供一个 尺寸

假设 Foo 的这个特定实现也需要一个 IWidget来完成它的工作。这个依赖项的构造函数注入会让我们创建一个类似于 Foo (int size,IWidget widget)的构造函数

让我烦恼的是,现在我们有了一个构造函数,它是带有依赖项的 混合初始化数据——一个输入是类的用户感兴趣的(尺寸) ,另一个是内部依赖项,它只会让用户感到困惑,并且是一个实现细节(小工具)。

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

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

如何妥协/调解这个问题?

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

这就解决了 Widget 的依赖性问题,但是我们如何在 Foo 接口上不使用单独的初始化方法来初始化“ size”呢?使用这个解决方案,我们无法确保在获得实例时完全初始化 Foo 的实例。真可惜,因为我真的很喜欢构造函数注入的 想法和简单

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

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

嗯,坦率地说,任何事情都违反了封装。 :)这是一种必须得到妥善对待的温和原则。

那么,是什么违反了封装呢?

继承 是的

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

面向侧面的程序设计 是的。例如,您注册 onMethodCall ()回调,这给您一个很好的机会将代码注入到正常的方法计算中,添加奇怪的副作用等等。

C + + 是的中的好友声明。

只是在字符串类完全定义之后重新定义一个字符串方法。

很多东西。

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

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

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

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

  1. 与对象相关的所有东西都是对象上的方法。因此,一个 File对象可能有 SavePrintDisplayModifyText等的方法。
  2. 对象是它自己的小世界,不依赖于外部行为。

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

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

另一方面,如果使用封装的第二个定义,依赖注入接近于 强制性的

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

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

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

也许这是一种天真的想法,但是接受整数参数的构造函数和接受服务作为参数的构造函数之间有什么区别呢?这是否意味着在新对象之外定义一个整数并将其提供给对象将打破封装?如果服务只在新对象中使用,我不认为这会破坏封装。

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

我同意在极端情况下,DI 可能违反封装。通常 DI 公开从未真正封装过的依赖项。下面是一个简化的例子,借自 Mi ko Hvery 的 单身人士是病态的说谎者:

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

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

下个月你会收到100美元的账单。你为什么被起诉?单元测试影响了生产数据库。在内部,信用卡呼叫 Database.getInstance()。重构 CreditCard,使其在构造函数中接受 DatabaseInterface,这暴露了存在依赖关系的事实。但是我认为从一开始就没有封装依赖关系,因为 CreditCard 类会导致外部可见的副作用。如果您想在不进行重构的情况下测试 CreditCard,那么当然可以观察依赖关系。

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


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

我认为不必担心将数据库作为依赖项公开是否会减少封装,因为这是一个好的设计。不是所有的督察决定都这么直接。然而,其他的答案都没有反例。

只有当一个类同时负责创建对象(需要了解实现细节)并使用该类(不需要了解这些细节)时,封装才会中断。我会解释为什么,但首先,汽车类比:

当我开着我那辆1971年的康比, 我可以按下加速器 走得(稍微)快一点。我没有 需要知道原因,但是那些 在工厂制造 Kombi 知道 为什么。

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

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

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

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

那是我开着我的康比去海滩。

现在,回到封装。如果实现细节发生更改,则在运行时使用该实现的类不需要更改。封装没有破损。

我也可以开我的新车去海滩,封装没坏。

如果实现细节发生变化,DI 容器(或工厂)确实需要变化。您从一开始就没有试图向工厂隐藏实现细节。

我认为这是范围的问题。当您定义封装时(不要告诉我们如何定义) ,您必须定义什么是被封装的功能。

  1. 所示初始化类: 您封装的是该类的唯一责任。它知道怎么做。例如,排序。如果你注入一些比较器来进行排序,比方说,客户端,那就不是被封装的东西: 快排。

  2. 已配置的功能 : 如果您想提供一个随时可以使用的功能,那么您不需要提供 QuickSort 类,而是提供一个配置了 Compaator 的 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) 对它的分类器一无所知。

值得一提的是,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类工作的人的角度来看,在第二个示例中,Athis.b的性质知道的要少得多

而没有督察呢

new A()

new A(new B())

查看此代码的人员对第二个示例中 A的性质有更多的了解。

对探长来说,至少泄露的信息都在一个地方。

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

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

  2. 它会使代码更加脆弱。接口规范提供的依赖性可以以意想不到的方式实现,从而导致整个类别的运行时错误,这些错误不可能用静态解析的具体依赖性来实现。

  3. 它可以降低代码的灵活性,这意味着您可能最终对代码的工作方式没有多少选择。并不是每个类都需要在拥有实例的整个生命周期中拥有所有的依赖项,但是对于许多 DI 实现,您没有其他选择。

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

如果确实需要从外部提供依赖关系,这通常意味着对象之间的关系是协作关系,而不是内部依赖关系,在这种情况下,适当的目标是封装 每个人类,而不是封装一个类内部的另一个类。

根据我的经验,使用 DI 的主要问题是,无论你是从内置 DI 的应用框架开始,还是在代码库中添加 DI 支持,出于某种原因,人们认为既然你有 DI 支持,那么实例化 一切的正确方式就一定是 DI 支持。他们甚至从来没有问过这样的问题: “这种依赖性需要外部指定吗?”.更糟糕的是,他们还开始试图强迫其他人也使用 一切的 DI 支持。

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

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

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

DI 的支持者认为对象应该永远不要使用新的操作符。 这种“纯粹主义”的方法不仅违反了封装,而且也违反了创建对象的人的 Liskov代换原则。