如果我理解正确,则依赖注入的典型机制是通过类的构造函数或类的公共属性(成员)进行注入。
这暴露了要注入的依赖项,并且违反了封装的OOP原理。
在确定这种折衷方案时我是否正确?您如何处理这个问题?
另请在下面查看我对自己问题的回答。
如果我理解正确,则依赖注入的典型机制是通过类的构造函数或类的公共属性(成员)进行注入。
这暴露了要注入的依赖项,并且违反了封装的OOP原理。
在确定这种折衷方案时我是否正确?您如何处理这个问题?
另请在下面查看我对自己问题的回答。
Answers:
您可能会发现解决此问题的另一种方法。
当我们使用IoC /依赖注入时,我们并没有使用OOP概念。诚然,我们使用OO语言作为“宿主”,但是IoC背后的思想来自面向组件的软件工程,而不是OO。
组件软件就是关于管理依赖关系的。常用的例子是.NET的Assembly机制。每个程序集都会发布其引用的程序集列表,这使得将运行的应用程序所需的各个部分放在一起(并验证)变得更加容易。
通过IoC在我们的OO程序中应用类似的技术,我们旨在使程序更易于配置和维护。发布依赖项(作为构造函数参数或其他)是其中的关键部分。封装并不是真正适用的,因为在面向组件/服务的世界中,没有“实现类型”用于泄漏细节。
不幸的是,我们的语言目前没有将细粒度的,面向对象的概念与粗粒度的面向组件的概念区分开,因此这是您仅需牢记的区别:)
这是一个很好的问题-但在某些时候,封装在其最纯粹的形式需要,如果对象是永远有它的依赖性履行受到侵犯。依赖项的某些提供者必须知道所讨论的对象需要一个Foo
,并且提供者必须具有一种Foo
向该对象提供的方式。
通常,后一种情况是按照您所说的通过构造函数参数或setter方法处理的。但是,这不一定是正确的-例如,我知道Java中的Spring DI框架的最新版本可让您注释私有字段(例如使用@Autowired
),并且将通过反射来设置依赖关系,而无需通过以下方式公开依赖关系任何类的公共方法/构造函数。这可能是您正在寻找的解决方案。
话虽如此,我也不认为构造函数注入不是很大的问题。我一直觉得对象在构造之后应该是完全有效的,因此无论如何需要通过构造函数提供它们执行其角色(即处于有效状态)所需的任何东西。如果您有一个需要协作者才能工作的对象,那么对我来说,构造函数公开发布此要求并确保在创建类的新实例时可以满足要求就可以了。
理想情况下,在处理对象时,无论如何都要通过接口与对象进行交互,并且这样做越多(并且通过DI关联了依赖项),则实际需要自己处理的构造函数就越少。在理想情况下,您的代码不会处理甚至无法创建类的具体实例;因此,仅IFoo
通过DI 即可获得它,而不必担心的构造函数FooImpl
表示其需要完成工作,甚至不用担心它FooImpl
的存在。从这个角度来看,封装是完美的。
这当然是一种意见,但是在我看来,DI不一定违反封装,实际上可以通过将内部的所有必要知识集中到一个地方来帮助它。这本身不仅是一件好事,而且更好的是,此地方不在您自己的代码库之外,因此您编写的所有代码都无需了解类的依赖关系。
这暴露了要注入的依赖项,并且违反了封装的OOP原理。
好吧,坦率地说,一切都违反了封装。:)这是一种温柔的原则,必须加以妥善处理。
那么,什么违反封装?
继承确实。
“因为继承将子类公开给其父级实现的详细信息,所以经常说“继承破坏封装”。(四人帮1995:19)
面向方面的编程 确实可以做到。例如,您注册onMethodCall()回调,这为您提供了一个将代码注入到常规方法评估中的绝好机会,从而增加了奇怪的副作用等。
C ++ 中的 Friend声明可以。
Ruby 中的类扩展确实可以。在完全定义字符串类之后,只需在某个地方重新定义字符串方法。
好吧,很多东西都可以。
封装是一个很好的重要原则。但不是唯一的。
switch (principle)
{
case encapsulation:
if (there_is_a_reason)
break!
}
良好的依赖注入容器/系统将允许构造函数注入。依赖对象将被封装,并且根本不需要公开。此外,通过使用DP系统,您的代码都不会“知道”对象的构造细节,甚至可能不包括正在构造的对象。在这种情况下,存在更多的封装,因为几乎所有代码都不仅不了解封装对象,而且甚至不参与对象构造。
现在,我假设您正在与创建对象创建自己的封装对象(最有可能在其构造函数中)的情况进行比较。我对DP的理解是,我们希望将这种责任从对象上移开,然后将其交给其他人。为此,“其他人”(在这种情况下为DP容器)确实具有“违反”封装的深入了解;好处是它将知识从对象本身中拉出来。有人必须拥有它。您的应用程序的其余部分则没有。
我会这样想:依赖注入容器/系统违反了封装,但是您的代码没有这样做。实际上,您的代码比以往更加“封装”。
正如Jeff Sternal在对该问题的评论中指出的那样,答案完全取决于您如何定义封装。
封装的含义似乎有两个主要阵营:
File
对象可能有方法Save
,Print
,Display
,ModifyText
,等。这两个定义彼此直接矛盾。如果File
对象可以自行打印,它将在很大程度上取决于打印机的行为。另一方面,如果它仅知道可以为其打印的内容(一个IFilePrinter
或某些这样的接口),则该File
对象不必了解任何有关打印的信息,因此使用它可以减少对对象的依赖。
因此,如果使用第一个定义,依赖项注入将破坏封装。但是,坦率地说,我不知道我是否喜欢第一个定义-它显然无法扩展(如果这样,MS Word将是一大类)。
另一方面,如果您使用封装的第二种定义,则依赖注入几乎是必需的。
它不违反封装。您正在提供一个协作者,但是该类可以决定如何使用它。只要您遵循“告诉”,不要问一切都很好。我发现最好使用构造器注入,但是只要是聪明的二传手就可以。也就是说,它们包含维护类表示的不变式的逻辑。
这类似于被投票的答案,但我想大声思考-也许其他人也这样看。
经典OO使用构造函数为该类的使用者定义公用的“初始化”协定(隐藏所有实现细节;也称为封装)。该合同可以确保实例化之后,您具有一个随时可用的对象(即,用户无需记住(忘记)其他任何初始化步骤)。
(构造函数)DI 通过此公共构造函数接口通过泄漏实现细节,无疑地破坏了封装。只要我们仍然认为公共构造函数负责为用户定义初始化合同,我们就已经造成了可怕的封装违规。
理论示例:
班富有4个方法,并且需要一个整数来进行初始化,因此其构造函数看起来像Foo(int size),并且对于Foo类的用户来说,他们必须立即提供一个大小,这是很清楚的在实例化时才能使Foo工作。
说这个特定的Foo实现可能还需要IWidget来完成其工作。此依赖项的构造方法注入将使我们创建类似Foo(int size,IWidget小部件)的构造方法
令我烦恼的是,现在我们有一个混合的构造函数初始化数据与依赖项函数-一个输入对类(size)的用户来说是令人感兴趣的,另一个输入是一个内部依赖项,仅用于混淆用户,并且是一种实现。详细信息(小部件)。
size参数不是依赖项-它是每个实例的简单初始化值。IoC是外部依赖项(如小部件)的花花公子,但内部状态初始化不是。
更糟糕的是,如果仅对此类的4种方法中的2种使用Widget,该怎么办?即使未使用Widget,我可能也会产生实例化开销!
如何妥协/调和呢?
一种方法是专门切换到接口以定义操作合同。并取消用户对构造函数的使用。为了保持一致,所有对象都只能通过接口访问,并且只能通过某种形式的解析器(例如IOC / DI容器)实例化。只有容器可以实例化事物。
这就解决了Widget的依赖性,但是如何在不借助Foo接口上单独的初始化方法的情况下初始化“大小”呢?使用此解决方案,我们失去了确保Foo实例在您获得实例之前已完全初始化的能力。Bummer,因为我真的很喜欢构造函数注入的想法和简单性。
当初始化不仅仅是外部依赖时,如何在这个DI世界中实现有保证的初始化?
纯封装是永远无法实现的理想选择。如果所有依赖项都隐藏了,那么您根本就不需要DI。这样考虑一下,如果您确实拥有可以在对象内部内部化的私有值,例如汽车对象速度的整数值,那么您就没有外部依赖关系,也不需要反转或注入该依赖关系。这些类型的内部状态值仅由私有函数进行操作,因此您总是希望对其进行封装。
但是,如果您要建造一辆想要某种引擎对象的汽车,那么您将具有外部依赖性。您可以在汽车对象的构造函数内部内部实例化该引擎(例如,新的GMOverHeadCamEngine()),保留封装但创建与具体类GMOverHeadCamEngine的更隐蔽的耦合,也可以注入它,以允许Car对象运行例如,在没有具体依赖性的情况下,对接口IEngine进行不可知论的(并且更加健壮)。不管您使用IOC容器还是简单的DI来实现这一点都不是问题-关键是您拥有一辆可以使用多种引擎而无需与之耦合的Car,从而使您的代码库更加灵活,较少出现副作用。
DI并不违反封装,它是在几乎每个OOP项目中都必然要破坏封装时将耦合最小化的一种方式。从外部将依赖项注入接口可以最大程度地减少耦合副作用,并使您的类对实现保持不可知论。
这取决于依赖项是真正的实现细节,还是客户端希望/需要以某种方式了解的某种方式。相关的一件事是该类所针对的抽象级别。这里有些例子:
如果你有一个方法,它使用引擎盖下的缓存,以加快呼叫,那么缓存对象应该是一个单身或东西,应该不会被注入。完全使用高速缓存的事实是类的客户端不必关心的实现细节。
如果您的类需要输出数据流,则可能需要注入输出流,以便该类可以轻松地将结果输出到数组,文件或其他可能要发送数据的地方。
对于灰色区域,假设您有一个进行蒙特卡洛模拟的类。它需要随机性的来源。一方面,它需要一个事实是一个实现细节,因为客户端实际上并不在乎随机性来自何处。另一方面,由于现实世界中的随机数生成器会在客户端可能希望控制的随机度,速度等之间进行权衡,并且客户端可能希望控制播种以获得可重复的行为,因此注入可能很有意义。在这种情况下,我建议提供一种无需指定随机数生成器即可创建类的方法,并使用线程局部的Singleton作为默认值。如果/当需要更精细的控制时,请提供另一个构造函数,以允许注入随机性源。
我相信简单。在Domain类中应用IOC / Dependecy注入没有任何改进,只是通过使用描述关系的外部xml文件使代码更难以维护。诸如EJB 1.0 / 2.0和struts 1.1之类的许多技术正在通过减少XML中的内容并尝试将其作为注释等方式放入代码中来进行逆转。因此,对您开发的所有类应用IOC都会使代码变得毫无意义。
当从属对象在编译时尚未准备好创建时,IOC就有好处。在大多数基础架构抽象级别体系结构组件中都可能发生这种情况,尝试建立一个通用的基础框架,该框架可能需要在不同的场景下工作。在那些地方使用IOC更有意义。但这仍然不能使代码更加简单/可维护。
与所有其他技术一样,它也具有优点和缺点。我担心的是,无论最佳使用环境如何,我们都会在所有地方实施最新技术。
仅当类既负责创建对象(需要了解实现细节)又负责使用类(不需要了解这些细节)时,封装才被破坏。我将解释原因,但首先快速分析一下汽车:
当我驾驶旧的1971 Kombi时,我可以踩油门,它的行驶速度(稍微)更快。我不需要知道为什么,但是在工厂制造Kombi的人确切知道为什么。
但是回到编码。 封装是“从使用该实现的东西中隐藏实现细节”。封装是一件好事,因为实现细节可以在类的用户不知情的情况下更改。
使用依赖项注入时,构造函数注入用于构造服务类型对象(与建模状态的实体/值对象相反)。服务类型对象中的任何成员变量都表示不应泄漏的实现细节。例如套接字端口号,数据库凭据,要执行加密的另一个类,缓存等。
该构造在最初创建的类时是相关的。这种情况发生在施工阶段,而您的DI容器(或工厂)将所有服务对象连接在一起。DI容器仅知道实现细节。它了解所有实施细节,例如Kombi工厂的人员也了解火花塞。
在运行时,创建的服务对象被称为apon以完成一些实际工作。此时,对象的调用者不了解任何实现细节。
那是我开车将我的Kombi送到海滩。
现在,回到封装。如果实现细节发生更改,则在运行时使用该实现的类无需更改。封装没有损坏。
我也可以开车去海滩。封装没有损坏。
如果实现细节发生更改,则DI容器(或工厂)确实需要更改。您从来没有试图首先从工厂隐藏实现细节。
经过与问题的进一步斗争后,我现在认为依赖注入确实(此时)在某种程度上违反了封装。不过请不要误会我的意思-我认为在大多数情况下使用依赖注入是值得进行权衡的。
当您正在处理的组件要交付给“外部”参与者(想为客户编写库)时,DI违反封装的原因就变得很清楚。
当我的组件要求通过构造函数(或公共属性)注入子组件时,则无法保证
“防止用户将组件的内部数据设置为无效或不一致的状态”。
同时不能说
“该组件(其他软件)的用户只需要知道该组件的功能,而不必依赖于它如何执行的细节”。
这两个引文均来自维基百科。
举一个具体的例子:我需要提供一个客户端DLL,以简化和隐藏与WCF服务(本质上是远程外观)的通信。因为它依赖于3个不同的WCF代理类,所以如果我采用DI方法,则必须通过构造函数公开它们。这样,我就暴露了我试图隐藏的通信层的内部。
通常,我全力以赴。在这个特殊(极端)的例子中,它使我感到非常危险。
DI违反了非共享对象的封装-期限。共享对象的寿命超出正在创建的对象的范围,因此必须将其聚合到正在创建的对象中。对于要创建的对象私有的对象应该被组合到创建的对象中-当创建的对象被销毁时,它会带走组成的对象。让我们以人体为例。什么组成,什么汇总。如果使用DI,则人体构造函数将具有100个对象。例如,许多器官(可能)是可替换的。但是,它们仍然组成身体。血细胞每天在体内产生(并被破坏),而无需外部影响(蛋白质除外)。因此,血细胞是由人体内部产生的-新的BloodCell()。
DI的提倡者认为,一个对象永远不要使用新的运算符。这种“纯粹的”方法不仅违反了封装,而且还违反了创建对象的人的《里斯科夫替代原理》。
我也为这个想法而苦恼。最初,使用DI容器(例如Spring)来实例化对象的“要求”就像跳铁环一样。但是实际上,这实际上并不是一个障碍-只是创建我需要的对象的另一种“发布”方式。当然,封装是“破坏”的,因为某个人在“课堂外”知道了它的需求,但实际上并不是系统的其余部分知道它-DI容器。没有什么神奇的事情发生了,因为DI“知道”一个物体需要另一个物体。
实际上,它甚至变得更好-通过专注于工厂和存储库,我什至不必知道DI完全参与其中!对我来说,这将使盖子重新封装。ew!
PS。通过提供依赖注入,您不必破坏封装。例:
obj.inject_dependency( factory.get_instance_of_unknown_class(x) );
客户端代码仍然不知道实现细节。
也许这是一种幼稚的思考方式,但是采用整数参数的构造函数和采用服务作为参数的构造函数之间有什么区别?这是否意味着在新对象之外定义整数并将其馈送到对象中会破坏封装?如果仅在新对象中使用该服务,则看不到如何破坏封装。
另外,通过使用某种自动装配功能(例如,用于C#的Autofac),它使代码变得非常干净。通过为Autofac构建器构建扩展方法,我能够切出大量的DI配置代码,随着依赖关系列表的增加,这些配置代码将随着时间的推移而必须维护。
我认为很明显,DI至少会大大削弱封装。除此以外,DI还需要考虑其他一些缺点。
它使代码更难重用。客户端无需显式提供依赖项就可以使用的模块显然比客户端必须以某种方式发现该组件的依赖项是什么然后使它们可用的方式容易使用。例如,最初创建用于ASP应用程序的组件可能希望由DI容器提供其依赖项,该DI容器为对象实例提供与客户端http请求相关的生存期。要在不具有与原始ASP应用程序相同的内置DI容器的另一个客户端中进行复制,可能并不容易。
它可以使代码更脆弱。接口规范提供的依赖关系可以以意想不到的方式实现,这会引起一类运行时错误,而这些错误在静态解析的具体依赖关系中是不可能的。
从某种意义上来说,它可能会使代码的灵活性降低,从而使您最终对自己的工作方式仅有较少的选择。在拥有实例的整个生命周期中,并不是每个类都需要拥有其所有依赖项,但是对于许多DI实现,您别无选择。
考虑到这一点,我认为接下来最重要的问题是:“ 是否需要从外部指定特定的依赖项? ”。实际上,我很少发现有必要为了支持测试而从外部提供依赖项。
如果确实需要外部提供依赖项,则通常表明对象之间的关系是协作而不是内部依赖项,在这种情况下,适当的目标是封装每个类,而不是将一个类封装在另一个内部。
以我的经验,有关使用DI的主要问题是,是从内置DI的应用程序框架开始,还是在代码库中添加DI支持,出于某些原因,人们认为,既然您拥有DI支持,那么它必须是正确的。实例化所有内容的方法。他们甚至从未问过“是否需要在外部指定此依赖关系”这个问题。更糟糕的是,他们也开始试图迫使其他人来使用的DI支持一切了。
这样的结果是,您的代码库不可避免地开始陷入一种状态,即在您的代码库中创建任何实例的任何实例都需要大量的钝化DI容器配置,并且调试任何事情的难度都是原来的两倍,因为您有额外的工作量试图确定如何以及任何实例化的地方。
所以我对这个问题的回答是这样。使用DI可以确定要解决的实际问题,而其他任何方法都无法简单地解决。
我同意极端地说,DI可能会违反封装。通常,DI公开从未真正封装过的依赖项。这是从米什科·赫弗里(MiškoHevery)的《单身人士是病态的骗子》中借来的简化示例:
您从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();
}
我认为不必担心将数据库公开为依赖项会减少封装,因为这是一个很好的设计。并非所有DI决策都会如此直接。但是,没有其他答案显示反例。
CreditCard
对象从WebAPI更改为PayPal,而无需更改类外部的内容?
我认为这是范围问题。在定义封装(不让其知道如何)时,必须定义什么是封装功能。
照原样进行类:您封装的是类的唯一责任。它知道该怎么办。例如,排序。如果您注入一些比较器进行订购(比方说客户),那不属于封装的内容:quicksort。
配置的功能:如果您想提供即用型功能,则您不是在提供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>
它被封装是因为如果您更改实现(更改clientSorter
Bean定义),则不会中断客户端的使用。也许,当您将xml文件一起编写时,您会看到所有详细信息。但请相信我,客户端代码(ClientService
)
不知道任何关于它的分拣机。