Must Dependency Injection come at the expense of Encapsulation?
如果我理解正确,依赖注入的典型机制是通过类的构造函数或通过类的公共属性(成员)注入。
这暴露了注入的依赖关系,并违反了OOP封装原则。
我确定这种权衡是否正确?你如何处理这个问题?
请看下面我自己的问题的答案。
有另一种方式来看待这个问题,你可能会觉得有趣。
当我们使用IOC/依赖注入时,我们不使用OOP概念。诚然,我们使用OO语言作为"主机",但IOC背后的思想来自面向组件的软件工程,而不是OO。
组件软件是关于管理依赖关系的——一个常用的例子是.NET的组装机制。每个程序集都发布它引用的程序集列表,这使得将正在运行的应用程序所需的各个部分集中(和验证)起来更加容易。
通过IOC在OO程序中应用类似的技术,我们的目标是使程序更容易配置和维护。发布依赖项(作为构造函数参数或其他参数)是其中一个关键部分。封装并不是真正适用的,就像在面向组件/服务的世界中,没有"实现类型"来泄漏细节。
不幸的是,我们的语言目前没有将细粒度的、面向对象的概念与细粒度的面向组件的概念分开,因此这是一个您必须记住的区别:)
这是一个很好的问题——但是在某种程度上,如果对象需要满足其依赖性,那么就需要违背其最纯形式的封装。依赖关系的一些提供者必须知道,所讨论的对象需要一个
经典地,后一种情况是通过构造函数参数或setter方法来处理的,如您所说。但是,这不一定是真的,例如,我知道Java中的Spring DI框架的最新版本可以让您注释私有字段(例如使用EDCOX1,2),并且依赖性将通过反射来设置,而不需要通过任何类公开方法/构造函数来公开依赖性。这可能是您正在寻找的解决方案。
也就是说,我认为构造函数注入也不是什么问题。我一直认为,对象在构造后应该是完全有效的,这样,为了执行其角色(即处于有效状态)而需要的任何东西都应该通过构造函数提供。如果您有一个需要合作者工作的对象,在我看来,构造函数可以公开地宣传这个需求,并确保在创建类的新实例时满足它。
理想情况下,处理对象时,无论如何都要通过一个接口与它们进行交互,这样做的次数越多(并且通过DI连接了依赖项),实际需要自己处理构造函数的次数就越少。在理想的情况下,您的代码不处理或甚至不创建类的具体实例;因此它只是通过DI得到一个
当然,这是一种观点,但在我看来,DI并不一定违反封装,事实上,它可以通过将内部的所有必要知识集中到一个地方来帮助它。这不仅本身是一件好事,而且更好的是,这个地方在您自己的代码库之外,所以您编写的代码都不需要知道类的依赖性。
This exposes the dependency being injected and violates the OOP principle of encapsulation.
好吧,坦率地说,一切都违反了封装。:)这是一种必须处理好的招标原则。
那么,什么违反了封装?
继承就是这样。
"Because inheritance exposes a subclass to details of its parent's implementation, it's often said that 'inheritance breaks encapsulation'". (Gang of Four 1995:19)
面向方面的编程。例如,注册onmethodCall()回调,这就给了您一个向正常方法评估注入代码、添加奇怪的副作用等的好机会。
C++中的朋友声明。
Ruby中的类扩展。只需在字符串类完全定义之后的某个地方重新定义一个字符串方法。
嗯,很多东西都有。
封装是一个好的和重要的原则。但不是唯一一个。
1 2 3 4 5 6 | switch (principle) { case encapsulation: if (there_is_a_reason) break! } |
是的,DI违反了封装(也称为"信息隐藏")。
但真正的问题是,当开发人员把它作为违反KISS(保持简短)和YAGNI(你不需要它)原则的借口时。
就我个人而言,我更喜欢简单有效的解决方案。我主要使用"new"操作符在需要的任何时候和任何地方实例化状态依赖项。它简单,封装良好,易于理解,易于测试。那么,为什么不呢?
良好的依赖性注入容器/系统将允许建造商注入。依赖对象将被封装,完全不需要公开。此外,通过使用一个dp系统,您的代码甚至都不"知道"对象是如何构造的细节,甚至可能包括正在构造的对象。在这种情况下有更多的封装,因为几乎所有的代码不仅被屏蔽,不了解封装的对象,甚至不参与对象的构造。
现在,我假设您正在与创建的对象创建自己的封装对象的情况进行比较,很可能是在其构造函数中。我对dp的理解是,我们要把这个责任从对象上移开,交给别人。为此,本例中的dp容器"someone else"确实具有"违反"封装的私密知识;其好处是它将该知识从对象iteself中提取出来。必须有人拥有它。应用程序的其余部分没有。
我会这样想:依赖注入容器/系统违反了封装,但您的代码没有。实际上,您的代码比以前更加"封装"。
它不会违反封装。您提供了一个合作者,但是类可以决定如何使用它。只要你跟我说,别问就行了。我觉得构造注入更可取,但设置器可以很好,只要他们是聪明的。也就是说,它们包含维护类所表示的不变量的逻辑。
这与赞成的答案类似,但我想好好想想——也许其他人也会这样看待问题。
经典OO使用构造函数为类的使用者定义公共的"初始化"契约(隐藏所有实现细节;又称封装)。这个契约可以确保在实例化之后,您拥有一个随时可用的对象(即,用户不需要记住(er,忘记)其他初始化步骤)。
(构造器)DI不可否认地通过这个公共构造器接口出血实现细节来破坏封装。只要我们仍然考虑到负责为用户定义初始化契约的公共构造函数,我们就造成了对封装的严重破坏。
理论示例:
类foo有4个方法,需要一个整数进行初始化,因此它的构造函数看起来像foo(int-size),对于类foo的用户来说,很明显他们必须在实例化时提供一个大小,以便foo工作。
比如说,FOO的这种特殊实现可能还需要一个iWidget来完成它的工作。此依赖项的构造函数注入将使我们创建一个类似foo的构造函数(int-size,iwidget小部件)
让我感到不安的是,现在我们有了一个将初始化数据与依赖项混合在一起的构造函数——一个输入对类(大小)的用户很感兴趣,另一个输入是一个内部依赖项,它只起到混淆用户的作用,是一个实现细节(小部件)。
大小参数不是依赖项-它只是每个实例的初始化值。IOC对外部依赖性(如小部件)很敏感,但不适合内部状态初始化。
更糟糕的是,如果这个小部件只对这个类的4个方法中的2个是必需的,那该怎么办?我可能会导致小部件的实例化开销,即使它可能不会被使用!
如何妥协/和解?
一种方法是只切换到接口来定义操作契约;并取消用户对构造函数的使用。为了保持一致,所有对象只能通过接口访问,并且只能通过某种形式的解析器(如IOC/DI容器)进行实例化。只有容器才能实例化事物。
这考虑了小部件的依赖性,但是我们如何在不诉诸于foo接口上的单独初始化方法的情况下初始化"大小"?使用这个解决方案,我们失去了确保foo实例在获取该实例时完全初始化的能力。很糟糕,因为我非常喜欢构造函数注入的思想和简单性。
当初始化不仅仅是外部依赖项时,如何在这个DI世界中实现有保证的初始化?
正如JeffSternal在对问题的评论中指出的,答案完全取决于如何定义封装。
封装的含义主要有两个阵营:
这两个定义是直接矛盾的。如果一个
因此,如果使用第一个定义,依赖注入将破坏封装。但是,坦率地说,我不知道我是否喜欢第一个定义——它显然不具有规模(如果是,Word女士将是一个大类)。
另一方面,如果使用封装的第二个定义,依赖注入几乎是强制的。
纯封装是一种永远无法实现的理想。如果所有依赖项都是隐藏的,那么您根本就不需要DI。这样想吧,如果你真的有可以在对象内部内化的私有值,比如汽车对象速度的整数值,那么你就没有外部依赖性,也不需要反转或注入这种依赖性。纯粹由私有函数操作的这些类型的内部状态值是您希望始终封装的。
但是,如果你正在制造一辆汽车,它需要某种引擎对象,那么你就有了一个外部依赖。您可以在Car对象的构造函数内部实例化该引擎,例如new gmoverheadcamengine(),保留封装,但创建一个更隐蔽的耦合到具体的gmoverheadcamengine类,或者您可以注入它,允许您的Car对象不可知地(更强大地)运行以进行检查。请选择一个没有具体依赖关系的接口。无论您使用IOC容器还是简单的DI来实现这一点都不是重点——重点是您有一辆汽车,它可以使用多种发动机,而无需与任何发动机耦合,从而使您的代码库更灵活,不易产生副作用。
DI并不是对封装的破坏,它是一种在几乎每个OOP项目中都必须破坏封装时最小化耦合的方法。在外部向接口注入依赖项可以最大限度地减少耦合副作用,并允许类对实现保持不可知性。
只有当一个类同时有责任创建对象(这需要了解实现细节),然后使用该类(这不需要了解这些细节),封装才会被破坏。我会解释原因,但首先是一个快速汽车分析:
When I was driving my old 1971 Kombi,
I could press the accelerator and it
went (slightly) quicker. I did not
need to know why, but the guys who
built the Kombi at the factory knew
exactly why.
但回到编码。封装是"从使用该实现的对象中隐藏实现细节。"封装是一件好事,因为实现细节可以在不需要类用户知道的情况下更改。
使用依赖项注入时,构造函数注入用于构造服务类型对象(而不是建模状态的实体/值对象)。服务类型对象中的任何成员变量都表示不应泄漏的实现细节。例如,套接字端口号、数据库凭据、要调用以执行加密的另一个类、缓存等。
在最初创建类时,构造函数是相关的。这发生在构建阶段,而DI容器(或工厂)将所有服务对象连接在一起。DI容器只知道实现细节。它知道所有的实现细节,比如Kombi工厂的人知道火花塞。
在运行时,创建的服务对象被称为apon以完成一些实际工作。此时,对象的调用者对实现细节一无所知。
That's me driving my Kombi to the beach.
现在,回到封装。如果实现细节发生更改,那么在运行时使用该实现的类就不需要更改。封装没有损坏。
I can drive my new car to the beach too. Encapsulation is not broken.
如果实现细节更改,则DI容器(或工厂)需要更改。首先,您从未试图从工厂隐藏实现细节。
我相信简单。在域类中应用IOC/Dependency注入不会有任何改进,除非通过使用描述关系的外部XML文件使代码更加难以主控。许多技术(如EJB1.0/2.0&Struts1.1)正在通过减少XML中的内容并尝试将它们作为注释等放入代码中来进行逆转。因此,对您开发的所有类应用IOC将使代码失去意义。
当依赖对象在编译时还没有准备好创建时,IOC将获得它的好处。这可以发生在大多数基础抽象层体系结构组件中,尝试建立一个通用的基础框架,该框架可能需要用于不同的场景。在这些地方,使用IOC更有意义。但这并不能使代码更简单/更易于维护。
与所有其他技术一样,这也有其优点和缺点。我担心的是,我们在所有地方实现最新的技术,而不考虑它们的最佳上下文使用。
在进一步讨论这个问题之后,我现在认为依赖注入在某种程度上违反了封装。不过,别误会我的意思——我认为在大多数情况下,使用依赖注入是值得权衡的。
当您正在处理的组件将被交付给"外部"方(考虑为客户编写库)时,DI违反封装的原因就变得很清楚了。
当我的组件需要通过构造函数(或公共属性)注入子组件时,不能保证
"preventing users from setting the internal data of the component into an invalid or inconsistent state".
同时也不能说
"users of the component (other pieces of software) only need to know what the component does, and cannot make themselves dependent on the details of how it does it".
这两条引文都来自维基百科。
为了给出一个具体的例子:我需要提供一个客户端DLL,它简化并隐藏了与WCF服务(本质上是一个远程外观)的通信。因为它依赖于3个不同的WCF代理类,所以如果采用DI方法,就必须通过构造函数公开它们。这样我就暴露了我试图隐藏的通信层的内部。
一般来说,我完全赞成DI。在这个特别(极端)的例子中,我觉得这很危险。
这取决于依赖关系是否真的是一个实现细节,或者客户机希望/需要以某种方式了解的东西。有一点是相关的,那就是类的目标抽象级别是什么。以下是一些例子:
如果您有一个方法使用引擎盖下的缓存来加速调用,那么缓存对象应该是一个单实例或其他东西,不应该被注入。缓存被使用的事实是一个实现细节,您的类的客户机不必关心这个细节。
如果您的类需要输出数据流,那么注入输出流可能是有意义的,这样类就可以轻松地将结果输出到数组、文件或其他人可能希望发送数据的地方。
对于灰色区域,假设您有一个类来进行蒙特卡洛模拟。它需要一个随机性的来源。一方面,它需要这样一个实现细节,因为客户机实际上并不关心随机性从何而来。另一方面,由于现实世界中的随机数生成器在客户可能想要控制的随机度、速度等之间进行权衡,而客户可能想要控制播种以获得可重复的行为,因此注入可能是有意义的。在本例中,我建议提供一种创建类的方法,而不指定随机数生成器,并使用线程本地单例作为默认值。如果/当需要更精细的控制时,提供另一个允许注入随机性源的构造函数。
另外,通过提供依赖注入,您不必破坏封装。例子:
1 | obj.inject_dependency( factory.get_instance_of_unknown_class(x) ); |
客户端代码仍然不知道实现细节。
DI违反了非共享对象的封装-句点。共享对象在创建的对象之外有一个寿命,因此必须聚合到正在创建的对象中。被创建对象的私有对象应组合成创建的对象-当创建的对象被销毁时,它会将已创建的对象与之一起。让我们以人体为例。组成和聚合的内容。如果我们要使用DI,人体构造函数将拥有100个对象。例如,许多器官是(潜在的)可替换的。但是,它们仍然组成身体。血细胞每天在体内产生(并被破坏),不需要外部影响(蛋白质除外)。因此,血细胞是由身体内部产生的-新血细胞()。
DI的支持者认为一个对象不应该使用新的操作符。这种"纯粹主义"方法不仅违背了封装,而且违背了正在创建对象的人的Liskov替换原则。
也许这是一种幼稚的思考方式,但是采用整数参数的构造函数和采用服务作为参数的构造函数之间有什么区别?这是否意味着在新对象外部定义一个整数并将其输入到对象中会破坏封装?如果服务只在新对象中使用,我看不出这会如何破坏封装。
此外,通过使用某种自动布线功能(例如autopac for c),它使代码非常干净。通过为autofac构建器构建扩展方法,我能够删除大量的DI配置代码,这些代码随着依赖项列表的增长而必须维护。
我也很难理解这个概念。首先,使用DI容器(如Spring)来实例化一个对象的"需求",感觉就像是在跳跃。但实际上,这并不是一个圈套——它只是另一种"公开"的方式来创建我需要的对象。当然,封装是"破坏"的,因为"类外"的人知道它需要什么,但真正知道这一点的不是系统的其他部分——而是DI容器。没有什么神奇的事情会发生,因为我"知道"一个物体需要另一个。
事实上,它会变得更好——通过专注于工厂和仓库,我甚至不必知道DI涉及到什么!对我来说,这就把盖子放回了封装上。唷!
我认为这是不言而喻的,至少DI显著地削弱了封装。除此之外,还有一些其他的不利因素需要考虑。
这使得代码难以重用。客户机不必显式地提供依赖项就可以使用的模块显然比客户机必须以某种方式发现组件的依赖项是什么然后以某种方式使它们可用的模块更容易使用。例如,最初创建用于ASP应用程序的组件可能期望由DI容器提供其依赖项,该容器为对象实例提供与客户端HTTP请求相关的生存期。这可能不容易在另一个客户机中重现,而该客户机与原始ASP应用程序没有相同的内置DI容器。
它可以使代码更加脆弱。接口规范提供的依赖项可以以意外的方式实现,这会导致一类运行时错误,这些错误是静态解决的具体依赖项不可能实现的。
从某种意义上说,它可以降低代码的灵活性,因为您最终可能会在希望它如何工作方面选择较少。并非每个类都需要在所属实例的整个生命周期内拥有其所有依赖项,但是对于许多DI实现,您没有其他选择。
考虑到这一点,我认为最重要的问题是,"一个特定的依赖关系是否需要从外部指定?".在实践中,我很少发现为了支持测试而需要外部提供依赖关系。
当依赖项真正需要外部提供时,这通常意味着对象之间的关系是协作而不是内部依赖,在这种情况下,适当的目标是封装每个类,而不是将一个类封装到另一个类中。
在我的经验中,关于DI的使用的主要问题是,无论您是从内置DI的应用程序框架开始,还是向代码库添加DI支持,由于某些原因,人们认为既然您有DI支持,那么这必须是实例化所有东西的正确方法。他们甚至都懒得问"这种依赖关系需要外部指定吗?"更糟糕的是,他们也开始试图强迫其他人使用DI支持。
这样做的结果是,您的代码库不可避免地开始转入一种状态,在这种状态下,在代码库中创建任何事物的实例都需要大量的钝器DI容器配置,而调试任何事物的难度是创建实例的两倍,因为您有额外的工作量来尝试确定如何以及在何处实例化任何事物。
所以我对这个问题的答案是。使用DI,您可以确定它为您解决的实际问题,而您不能用任何其他方法更简单地解决这个问题。
我同意在极端情况下,DI可能违反封装。通常DI会公开从未真正封装的依赖项。这里有一个从mi借来的简化示例?Ko Hevery的独生子是病态的骗子:
从信用卡测试开始,编写一个简单的单元测试。
1 2 3 4 5 6 | @Test public void creditCard_Charge() { CreditCard c = new CreditCard("1234 5678 9012 3456", 5, 2008); c.charge(100); } |
下个月你将得到一张100美元的钞票。你为什么被指控?单元测试影响了生产数据库。在内部,信用卡呼叫
1 2 3 4 5 6 7 8 9 10 11 | @Before public void setUp() { Database.setInstance(new MockDatabase()); } @After public void tearDown() { Database.resetInstance(); } |
我认为不值得担心将数据库作为依赖项公开是否会减少封装,因为它是一个好的设计。并非所有的DI决策都是如此直接。然而,其他答案都没有反例。
我认为这是范围问题。当您定义封装(不知道如何)时,您必须定义什么是封装功能。
类原样:您封装的是类的唯一责任。它知道怎么做。例如,排序。如果您为排序注入了一些比较器,比如说,客户机,这不是封装内容的一部分:快速排序。
配置的功能:如果您想要提供一个随时可用的功能,那么您不需要提供QuickSort类,而是使用比较器配置的QuickSort类的实例。在这种情况下,负责创建和配置的代码必须对用户代码隐藏。这就是封装。
当您在编程类时,它是将单个职责实现到类中,您使用的是选项1。
当你在编程应用程序时,它是,做一些有用的具体工作,然后你就可以重复使用选项2了。
这是配置实例的实现:
1 2 3 4 5 | <bean id="clientSorter" class="QuickSort"> <property name="comparator"> <bean class="ClientComparator"/> </property> </bean> |
这是其他客户端代码使用它的方式:
1 2 3 | <bean id="clientService" class"..."> <property name="sorter" ref="clientSorter"/> </bean> |
它是封装的,因为如果更改实现(更改
可能值得一提的是,
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | 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; } } |
从某个在
鉴于没有DI
1 | new A() |
VS
1 | new A(new B()) |
在第二个示例中,查看此代码的人更了解
有了DI,至少所有泄露的知识都在一个地方。