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

uri*_*rig 123 oop encapsulation dependency-injection inversion-of-control

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

这暴露了注入的依赖性并违反了封装的OOP原则.

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

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

Nic*_*rdt 60

还有另一种看待这个问题的方法,你可能会感兴趣.

当我们使用IoC /依赖注入时,我们没有使用OOP概念.不可否认,我们使用OO语言作为"主机",但IoC背后的思想来自面向组件的软件工程,而不是OO.

组件软件就是关于管理依赖关系 - 常用的一个例子是.NET的汇编机制.每个程序集都会发布它引用的程序集列表,这样可以更容易地将正在运行的应用程序所需的部分组合在一起(并进行验证).

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

不幸的是,我们的语言目前没有将细粒度的,面向对象的概念与粗粒度的面向组件的概念隔离开来,所以这是一个区别,你必须在脑海中只有:)

  • 封装不仅仅是一个花哨的术语.这是真正的好处,如果你认为你的程序是"面向组件的"或"面向对象的",这并不重要.封装应该保护你的对象/组件/服务的状态/以及以意想不到的方式改变的东西,IoC确实带走了一些保护,所以肯定有一个权衡. (18认同)
  • 通过构造函数提供的参数仍然属于对象可能被“更改”的_预期_方式范围内:它们被显式公开,并且它们周围的不变量被强制执行。_信息隐藏_是你所指的隐私类型的更好术语,@RonInbar,它并不一定总是有益的(它使意大利面更难解开;-))。 (2认同)
  • OOP的重点在于意大利面缠结被分成单独的类,如果它是你希望修改的特定类的行为,你只需要弄乱它(这就是OOP减轻复杂性的方式).类(或模块)封装其内部,同时暴露方便的公共接口(这是OOP如何促进重用).通过接口公开其依赖项的类会为其客户端创建复杂性,因此不太可重用.它本身也更脆弱. (2认同)
  • 这是解决问题的另一种方法:想象一下,如果 .NET 程序集选择“封装”并且没有声明它们所依赖的其他程序集。这将是一个疯狂的情况,阅读文档并只是希望加载后某些东西能够工作。在该级别声明依赖项可以让自动化工具处理应用程序的大规模组合。您必须眯着眼睛才能看到这个类比,但类似的力量确实适用于组件级别。需要权衡,YMMV 一如既往:-) (2认同)

And*_*yle 29

这是一个很好的问题 - 但是在某些时候,如果对象永远要满足其依赖性,则需要违反其最纯粹的形式.某些依赖项提供者必须知道所讨论的对象需要一个Foo,并且提供者必须有一种方法来提供Foo该对象.

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

也就是说,我认为构造函数注入也不是问题.我一直认为对象在构造之后应该是完全有效的,因此无论如何,它们为了执行它们的角色(即处于有效状态)所需的任何东西都应该通过构造函数提供.如果你有一个需要协作者工作的对象,我觉得构造函数公开地宣传这个要求并确保在创建一个新的类实例时完成它.

理想情况下,在处理对象时,无论如何都要通过接口与它们进行交互,并且执行此操作的次数越多(并且通过DI连接依赖关系),实际上您自己必须处理构造函数的次数就越少.在理想情况下,您的代码不会处理甚至创建类的具体实例; 所以它只是IFoo通过DI 得到了,而不用担心构造函数FooImpl表明它需要做什么工作,事实上甚至没有意识到它FooImpl的存在.从这个角度来看,封装是完美的.

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

  • @Rogerio - 可以说任何DI框架的行为都与您描述的ServiceLocator完全相同; 客户端不知道有关Foo实现的任何具体内容,并且DI工具不知道有关客户端的任何具体信息.而使用"新"是违反封装差多少,因为你需要知道的不仅是确切的实现类,但也确切类*和*它需要的所有依存关系的实例. (5认同)
  • 我不同意.DI确实违反了封装,这可以避免.例如,通过使用ServiceLocator,显然不需要知道客户端类的任何信息; 它只需要知道Foo依赖的实现.但在大多数情况下,最好的方法是简单地使用"新"运算符. (4认同)
  • 使用"new"来实例化一个通常甚至不公开的辅助类,可以促进封装.DI替代方案是将helper类设为public,并在客户端类中添加公共构造函数或setter; 这两个更改都会破坏原始帮助程序类提供的封装. (4认同)
  • 好点。我建议不要在私有字段上使用@Autowired;这使全班很难测验;然后如何注入模拟或存根? (2认同)

top*_*dev 17

这暴露了注入的依赖性并违反了封装的OOP原则.

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

那么,什么违反了封装?

继承确实如此.

"因为继承将子类暴露给其父实现的细节,所以通常会说'继承会破坏封装'".(Gang of Four 1995:19)

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

C++ 中的朋友声明确实如此.

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

好了,很多东西.

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

switch (principle)
{
      case encapsulation:
           if (there_is_a_reason)
      break!
}
Run Code Online (Sandbox Code Playgroud)

  • "这些是我的原则,如果你不喜欢他们......好吧,我还有其他人." (Groucho Marx) (3认同)
  • 我认为这是重点.它依赖注入与封装.因此,只有使用依赖注射才能获得显着的益处.到处都是DI给DI一个坏名字 (2认同)

Rog*_*rio 13

是的,DI违反了封装(也称为"信息隐藏").

但真正的问题出现在开发人员将其作为违反KISS(保持简短和简单)和YAGNI(你不需要它)原则的借口时.

就个人而言,我更喜欢简单有效的解决方案.我主要使用"new"运算符来随时随地实例化有状态依赖项.它简单,封装良好,易于理解,易于测试.那么,为什么不呢?

  • 提前思考并不可怕,但我同意保持简单,愚蠢,特别是如果你不需要它!我见过开发人员浪费周期,因为他们过度设计了一些过于面向未来的东西,并且基于预感,甚至没有已知/怀疑的业务需求。 (2认同)

Bil*_*ill 5

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

现在,我假设您正在与创建的对象创建自己的封装对象的情况进行比较,很可能是在其构造函数中.我对DP的理解是,我们希望将此责任从对象中移除并将其交给其他人.为此,"其他人",在这种情况下是DP容器,确实具有"违反"封装的亲密知识; 好处是它将这些知识从对象中拉出来.有人必须拥有它.你的应用程序的其余部分没有.

我会这样想:依赖注入容器/系统违反了封装,但是你的代码没有.事实上,您的代码更加"封装".

  • 如果您遇到客户端对象可以直接实例化其依赖关系的情况,那么为什么不这样做呢?这绝对是最简单的事情,并不一定会降低可测试性.除了简单和更好的封装外,这也使得有状态的对象更容易,而不是无状态的单例. (3认同)

小智 5

这与投票的答案类似,但我想大声思考 - 也许其他人也这么看问题。

  • 经典面向对象使用构造函数为类的使用者定义公共“初始化”契约(隐藏所有实现细节;也称为封装)。该契约可以确保实例化后您拥有一个随时可用的对象(即用户无需记住(呃,忘记)额外的初始化步骤)。

  • (构造函数) 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 接口上单独的初始化方法的情况下初始化“size”呢?使用此解决方案,我们无法确保 Foo 实例在您获取实例时已完全初始化。真糟糕,因为我真的很喜欢构造函数注入的想法和简单性

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


kyo*_*ryu 5

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

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

  1. 与对象有关的所有事物都是对象上的方法。所以,一个File对象可能有方法SavePrintDisplayModifyText,等。
  2. 一个对象是它自己的小世界,并且不依赖于外部行为。

这两个定义彼此直接矛盾。如果File对象可以自行打印,它将在很大程度上取决于打印机的行为。另一方面,如果它仅知道可以为其打印的内容(一个IFilePrinter或某些此类接口),则该File对象不必了解任何有关打印的信息,因此使用它可以减少对对象的依赖。

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

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