在Python中使用依赖注入容器有什么意义?

17 投票
2 回答
8692 浏览
提问于 2025-04-16 22:33

我最近在玩Python,结果又把我那种死板的静态类型面向对象的世界搞得一团糟。Python支持“鸭子类型”,没有像C#那样可用的接口编程概念,而且还允许全局变量。既然有了这些特性,依赖注入容器还有什么意义,难道Python的运行时就成了容器吗?

我明白在像Java和C#这样的静态类型面向对象语言中,这些容器的作用,但在Python这个疯狂的世界里,这种东西又有什么用呢(我真心喜欢Python)?

我一直觉得,依赖注入作为一种设计模式,其实是因为C#和Java那种“所有东西都得是类”的思维方式造成的坏味道,我这样想对吗?还是说我漏掉了什么?

到目前为止,我觉得我可以通过使用全局变量来处理工厂模式、单例模式和多实例对象。我还怀疑,切面编程的东西也可以搞定,虽然我还在思考这个问题。

现在让我困惑的是“鸭子类型”,我习惯了定义接口,然后基于这些接口来创建类,让静态类型来掩盖我的愚蠢,所以我觉得没有静态类型的话,容器就有点没用。

2 个回答

10

结论

(以下是原帖中最相关的部分。我承认,我写得有点文艺,所以我觉得应该把最重要的句子单独放在一个部分里。不过,我觉得这些文艺的部分也很重要,所以没有删掉。)

依赖注入仍然在使用。对象之间总是需要进行通信,尤其是那些已经创建好的对象。总会有“父”对象(或者叫容器,随便你怎么称呼)需要设置它们的“子”对象的状态。这些状态需要通过某种方法传递进来,或者通过某种赋值来设置,但从抽象的角度来看,这其实是一样的。

原始回答:

类型系统

在很多方面,Python是我遇到过的第二种最数学化的编程语言——它仅次于Scheme(虽然我没用过Haskell,但我听说它也很数学化)。

Python本身就支持闭包,它有继续执行的功能(yield语法)、多重继承,还有一种几乎无与伦比的循环推导语法。这些特性让它更接近阿隆佐·丘奇在λ演算中的原始设想(以及麦卡锡在Lisp背后的想法)。Python 3让它变得更加纯粹,增加了集合推导(光想想它的美丽就让我心动)。

人们常常认为,Python中存储在变量里的数据与数学中的数据有很多共同点,以至于一个对象的接口可以简化为“描述这个对象的形容词或形容词集合”。基本上,一个对象的接口完全包含在它的__dict__中,并可以通过dir来查看。

考虑到这些,确实让人开始怀疑,传统的看法(用引号是因为Python和Java一样古老)在这样的语言中是否真的适用。当Python中的参数列表都有面向对象的感觉时(比如kwargs),这确实让人感到颠覆。

依赖注入仍然在使用。对象之间总是需要进行通信,尤其是那些已经创建好的对象。总会有“父”对象(或者叫容器,随便你怎么称呼)需要设置它们的“子”对象的状态。这些状态需要通过某种方法传递进来,或者通过某种赋值来设置,但从抽象的角度来看,这其实是一样的。不过,传递的对象的__dict__内容被隐含地信任,认为它会包含合适的描述。因此,这变得不再是“我一旦创建了这个对象,就会赋予它生存所需的一切”,而更像是“嗯,是的,一个对象需要状态,所以我给它一个。”

这揭示了静态类型的一个隐藏特性。为了让一个期望IFoo的东西正常工作,它必须完全了解什么是IFoo,即使它可能永远用不到这个定义的90%。与此同时,鸭子类型让依赖的对象只需要知道在运行时属性X、Y和Z应该存在。

全局变量

关于全局变量。除非别无选择,否则尽量避免使用。使用单例模式会更好,因为单例可以让你在值改变时记录日志,而全局变量则做不到这一点。

有个编程规则是,接口暴露得越多、越复杂,维护起来就越困难。如果某个东西放在全局变量中,那么该模块中的任何东西,甚至可能是任何模块中的任何东西都可以修改这个值。代码几乎会变得不可预测。我向你保证,这会带来很多麻烦。

14

没有可用的接口编程概念(就像C#的接口那样)

编译器不能检查你是否正确使用接口,并不意味着“没有可用的接口概念”。你可以为接口写文档,并编写单元测试来验证它。

至于全局变量,C#或Java类中的public static方法和字段其实也没什么不同。想想java.lang.Math是怎么工作的。再考虑一下,java.lang.Math并不是一个单例。这是有原因的。

有了这些好东西,依赖注入容器还有什么意义吗?

我对此表示怀疑,但我在C#或Java中也从未真正理解它们的意义。在我看来,依赖注入是一种编程技巧。其实也没那么复杂。

我一直怀疑依赖注入作为设计模式是一种不好的思维方式,因为它源于“所有东西都必须是类”的极端想法。

其实不是。依赖注入在很多情况下都是个好主意。你不一定需要一个类来注入依赖。每次你将某个东西作为参数传递给一个自由函数,而不是让这个函数去调用另一个函数来获取信息时,你实际上是在做同样的事情:控制反转。Python在很多方面也允许你将模块视为类(比Java和C#要多得多)。有些问题可以通过将模块作为参数传递给函数来解决。:)

到目前为止,我觉得我可以通过使用全局变量来处理工厂、单例、多实例对象。

如果说有什么不好的地方,那就是单例。在我丰富的经验中,几乎每次单例的存在都是因为有人认为全局变量原则上是“不好的”,而没有真正考虑过可能的选项,或者他们为什么想要对一个共享对象有那种访问方式,甚至为什么全局变量在原则上是“不好的”。

可以在Python中创建一个作为工厂的全局函数。不过,我认为更符合Python风格的做法是:

a) 首先,确保你真的不能仅仅通过__init__来实现你想要的功能。在动态类型语言中,你可以通过这种方式做很多事情。

b) 如果__init__不够用,试试用__new__来控制行为。

在Python中,类本身就是可调用的对象。默认情况下,调用类会实例化这个类。通过__new__,你可以在这个过程中插入一些操作。

c) 使用装饰器应用于类。这里有一个示例,创建一个单例(仅仅是因为):

def _singleton(cls):
  instance = cls()
  result = lambda: instance
  result.__doc__ = cls.__doc__
  return result

@_singleton
class example(object): pass

这个方法的工作原理是:当你装饰类时,_singleton()会被调用,并传入类。然后构造一个实例并缓存,_singleton()返回一个匿名函数,当调用时会返回这个实例。为了让这个过程看起来更真实,类的文档会附加到这个匿名函数上。然后Python会将类的名称在全局范围内重新绑定到返回的匿名函数上。所以每次你调用它时,都会得到同一个类的实例。

当然,这种方法仍然可以被绕过(你可以做类似example().__class__()来获取另一个实例),但这样做比简单忽略工厂函数而正常使用构造函数要明显得多。此外,这意味着调用代码实际上就像正常调用构造函数一样 :)

鸭子类型是我目前最困惑的地方,我习惯于定义接口,然后基于这些接口创建类,让静态类型掩盖我的愚蠢,觉得没有静态类型,容器有点没用。

你需要转变思维:别再担心你得到的东西是什么,而是要关注它是否能做你想要的事情。这就是鸭子类型的工作方式。

撰写回答