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

时间:2009-06-17 06:58:02

标签: oop dependency-injection inversion-of-control encapsulation

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

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

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

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

21 个答案:

答案 0 :(得分:59)

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

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

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

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

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

答案 1 :(得分:29)

这是一个很好的问题 - 但是在某些时候,如果对象永远要满足其依赖性,那么以最纯粹的形式封装需要。某些依赖项提供程序必须知道有问题的对象需要Foo,并且提供程序必须能够为对象提供Foo

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

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

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

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

答案 2 :(得分:17)

  

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

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

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

继承

  

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

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

C ++中的朋友声明确实

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

嗯,很多东西确实

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

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

答案 3 :(得分:13)

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

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

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

答案 4 :(得分:5)

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

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

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

答案 5 :(得分:4)

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

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

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

理论范例:

Foo 有4个方法,需要一个整数进行初始化,所以它的构造函数看起来像 Foo(int size),对于类的用户来说很明显Foo 他们必须在实例化时提供大小才能让Foo工作。

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

让我感到不安的是,现在我们有一个构造函数混合初始化数据和依赖项 - 一个输入是类的用户感兴趣的( size ),另一个是内部依赖,仅用于混淆用户,是一个实现细节(小部件)。

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

更糟糕的是,如果Widget只是这个类中4种方法中的2种所必需的,那该怎么办呢?我可能会为Widget带来实例化开销,即使它可能没有被使用!

如何妥协/调和此事?

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

它负责Widget依赖,但是如何在不依赖Foo接口的单独初始化方法的情况下初始化“size”?使用此解决方案,我们失去了确保在获取实例时Foo实例完全初始化的能力。糟糕,因为我非常喜欢构造函数注入的想法和简单

如果初始化不仅仅是外部依赖性,那么如何在这个DI世界中实现有保证的初始化?

答案 6 :(得分:4)

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

封装意味着两个主要阵营:

  1. 与对象相关的所有内容都是对象的方法。因此,File对象可能包含SavePrintDisplayModifyText等方法。
  2. 一个对象是它自己的小世界,并不依赖于外部行为。
  3. 这两个定义彼此直接矛盾。如果File对象可以自行打印,则很大程度上取决于打印机的行为。另一方面,如果它只是知道关于可以为它打印的东西(IFilePrinter或某个这样的接口),那么File对象不必知道关于打印的任何事情,因此使用它将减少对象的依赖性。

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

    另一方面,如果你使用第二个封装定义,依赖注入几乎是强制

答案 7 :(得分:4)

它不违反封装。你提供了一个合作者,但是课程决定如何使用它。只要你遵循Tell don't ask一切都很好。我发现建构物注射更可取,但是只要它们很聪明,它们就可以很好。也就是说,它们包含维护类所代表的不变量的逻辑。

答案 8 :(得分:3)

纯封装是一种永远无法实现的理想选择。如果隐藏了所有依赖项,那么您根本不需要DI。以这种方式考虑,如果你真的拥有可以在对象内部化的私有值,例如汽车对象的速度的整数值,那么你没有外部依赖,也不需要反转或注入该依赖。完全由私有函数操作的这些内部状态值是您想要封装的内容。

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

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

答案 9 :(得分:2)

这取决于依赖性是否真的是一个实现细节,或者客户端想要/需要以某种方式知道的东西。有一点是相关的是该类所针对的抽象级别。以下是一些例子:

如果你有一个方法在引擎盖下使用缓存来加速调用,那么缓存对象应该是Singleton或其他东西,并且应该注入。完全使用缓存这一事实是一个实现细节,您的类的客户端不应该关心。

如果您的类需要输出数据流,则注入输出流以使类可以轻松地将结果输出到数组,文件或其他人可能想要发送数据的其他地方可能是有意义的。

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

答案 10 :(得分:2)

我相信简单。在域类中应用IOC / Dependecy注入没有任何改进,除了通过使用描述关系的外部xml文件使代码更加难以实现。许多技术,如EJB 1.0 / 2.0& struts 1.1通过减少XML中的内容来反转,并尝试将它们放入代码作为annoation等。因此,将IOC应用于您开发的所有类将使代码无意义。

当依赖对象在编译时尚未准备好创建时,IOC会受益。这可能发生在大多数infrasture抽象级架构组件中,尝试建立一个可能需要适用于不同场景的公共基础框架。在那些地方使用IOC更有意义。这仍然不会使代码更简单/可维护。

与所有其他技术一样,这也有PROs&利弊。我担心的是,无论最佳环境使用情况如何,我们都会在所有地方实施最新技术。

答案 11 :(得分:2)

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

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

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

  

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

同时不能说

  

“组件的用户(其他软件)只需要知道组件的功能,并且不能让自己依赖于它的工作细节”

两个引号均来自wikipedia

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

一般来说,我都是DI。在这个特殊(极端)的例子中,它让我感到危险。

答案 12 :(得分:2)

只有当一个类同时负责创建对象(需要了解实现细节)然后使用该类(不需要了解这些细节)时,才会破坏封装。我会解释原因,但首先是一个快速的汽车动画学:

  

当我驾驶我1971年的Kombi时,   我可以按下加速器   (稍微)更快。我没有   需要知道为什么,但那些家伙们   在工厂建造的Kombi知道   究竟是为什么。

但回到编码。 封装是“使用该实现隐藏实现细节。”封装是一件好事,因为实现细节可以在没有类知道的用户的情况下改变。

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

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

在运行时,创建的服务对象称为apon以执行一些实际工作。此时,对象的调用者不知道实现细节。

  

那是我把我的Kombi带到海滩。

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

  

我也可以把我的新车开到海滩。封装没有被打破。

如果实施细节发生变化,DI容器(或工厂)确实需要更改。你从来没有试图从工厂隐藏实现细节。

答案 13 :(得分:1)

我也在努力解决这个问题。首先,使用DI容器(如Spring)实例化对象的“要求”感觉就像跳过箍。但实际上,它实际上并不是一个箍 - 它只是另一种“已发布”的方式来创建我需要的对象。当然,封装是“破坏”的,因为某个“类外”的人知道它需要什么,但它确实不是系统的其他人知道的 - 它是DI容器。没有什么神奇的事情发生不同,因为DI'知道'一个对象需要另一个。

事实上它变得更好 - 通过专注于工厂和存储库,我甚至不必知道DI参与其中!对我来说,盖子重新封装。呼!

答案 14 :(得分:1)

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

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

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

答案 15 :(得分:1)

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

此外,通过使用某种自动装配功能(例如,用于C#的Autofac),它使代码非常干净。通过为Autofac构建器构建扩展方法,我能够删除很多DI配置代码,随着依赖项列表的增长,我将不得不维护这些代码。

答案 16 :(得分:1)

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

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

  2. 它可以使代码更脆弱。接口规范提供的依赖关系可以以意想不到的方式实现,这会产生一系列运行时错误,这些错误是静态解析的具体依赖关系所无法实现的。

  3. 从某种意义上讲,它可能会使代码变得不那么灵活,因为您最终可能会选择更少的代码来实现代码。并非每个类都需要在拥有实例的整个生命周期中存在所有依赖项,但是对于许多DI实现,您没有其他选项。

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

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

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

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

    所以我对这个问题的回答是这样的。 使用DI,您可以识别它为您解决的实际问题,而您无法通过其他任何方式解决这些问题。

答案 17 :(得分:1)

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

DI的倡导者认为,对象不应该使用new运算符。 这种“纯粹主义”的方法不仅违反了封装,而且违反了Liskov替代原则,无论是谁创造了这个对象。

答案 18 :(得分:0)

我同意极端,DI可以违反封装。通常DI暴露从未真正封装的依赖关系。以下是从MiškoHeverySingletons 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会暴露出依赖性的事实。但我认为依赖性从未被封装开始,因为CreditCard类导致外部可见的副作用。如果你想在没有重构的情况下测试CreditCard,你当然可以观察到依赖性。

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

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

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

答案 19 :(得分:0)

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

  1. 按类:您正在封装的内容是该类的唯一责任。它知道怎么做。例如,排序。如果您为了订购注入一些比较器,比方说客户,那不是封装的东西:快速排序。

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

  3. 当您编写类时,就是将单个职责实现到类中,您正在使用选项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>
    

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

答案 20 :(得分: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课程的人员的角度来看,在第二个示例中Athis.b

的性质知之甚少

而没有DI

new A()

VS

new A(new B())

在第二个示例中,查看此代码的人更了解A的性质。

通过DI,至少所有泄露的知识都在一个地方。