单例模式:在Python中的研究

paopao00 2019-09-19

单例模式:一个来自“四人帮”著作中的“创造模式”

Gang of Four (GOF):设计模式四人组(“四人帮”,又称Gang of Four,即Erich Gamma, Richard Helm, Ralph Johnson & John Vlissides四人)的《设计模式》,原名《Design Patterns: Elements of Reusable Object-Oriented Software》(1995年出版,出版社:Addison Wesly Longman.Inc),第一次将设计模式提升到理论高度,并将之规范化。

结论

Python程序员几乎从不会像“四人帮”著作中所描述的那样来实现单例模式,其单例类禁止正常的实例化,而是提供一个返回单例实例的类方法。Python更加优雅,它允许类继续支持用于实例化的常规语法,同时定义一个返回单例实例的自定义的__new__()方法。但是,如果你的设计强制要求你提供对单例对象的全局访问,那么一个更python化的方法是使用全局对象模式代替。

解疑

在面向对象设计模式社区定义“单例模式”之前,Python就已经在使用术语singleton了。因此,我们应该首先从区分Python中“singleton”的几个含义开始。

  1. 长度为1的元组称为一个单例。虽然这个定义可能会让一些程序员感到惊讶,但它反映了数学中单例的原始定义:一个只包含一个元素的集合。Python教程本身介绍了这个定义的新成员,在其关于数据结构的章节中,将单元素元组称为一个“单例”,并且在Python文档的其他部分中,这个词仍然以这个意思来使用。当《扩展和嵌入指南》中写到,“使用一个参数去调用Python函数…传递一个单例元组”时,这意味着它是只包含一个项的元组。
  2. 模块在Python中是“单例”,因为import只创建每个模块的一个单独副本;同名的后续导入将不断返回相同的模块对象。例如,当Python/C API参考手册的模块对象一章断言“Single-phase初始化会创建单例模块”时,它的意思是一个“单例模块”,即一个只有一个对象会为它创建的模块。
  3. 一个“单例”是通过全局对象模式分配了一个全局名称的类实例。例如,官方的Python编程FAQ以在Python中“使用一个模块也是实现单例设计的基础”的断言回答了“如何在模块之间共享全局变量?”这个问题——因为模块的全局名称空间不是只可以存储常量(FAQ的示例是在多个模块之间共享x = 0),还可以存储可变类实例。
  4. 作为Flyweight模式示例的单个flyweight对象通常被Python程序员称为“单例”对象。例如,标准库的itertoolsmodule.c中的注释断言道:“CPython的空元组是一个单例”——这意味着Python解释器只创建一个空元组对象,每次传递一个零长度序列时,tuple()都会一次又一次地返回这个空元组对象。marshal.c中的一个注释类似地引用了“空frozenset单例”。但这两个单例对象都不是"四人帮"单例模式的例子,因为它们都不是其类的唯一实例: tuple允许你构建空元组之外的其他元组,frozenset允许你构建其他冻结集合。类似地,True和False对象是一对flyweight,而不是单例模式的例子,因为它们都不是bool的唯一实例。
  5. 最后,只有在极少数情况下,Python程序员在将一个对象称为一个“单例”时,才确实是指“单例模式”: 每次调用该类时,该类就会返回一个唯一对象。

Python 2标准库没有包含单例模式的例子。但是它确实支持像None和Ellipsis这样的单例对象,该语言通过在__builtin__模块中为它们进行命名来以python化的全局对象模式提供了对它们的访问支持。但是它们的类是不可调用的:

单例模式:在Python中的研究

然而,在Python 3中, 这些类被升级到使用单例模式:

单例模式:在Python中的研究

对于那些需要一个总是返回None的快速调用的程序员来说,这使得他们的工作变得更容易,尽管这种情况很少见。在大多数Python项目中,这些类从来没有被调用过,其好处纯粹是理论上的。当Python程序员需要None对象时,他们就使用全局对象模式并简单地输入其名称。

“四人帮”的实现

“四人帮”所瞄准的C++语言在对象创建上采用了一种独特的语法,看起来像这样:

单例模式:在Python中的研究

这一行C++代码表明new总是创建一个新的类实例——它从不返回一个单例。有了这种特殊的语法,它们提供单例对象的选项是什么呢?

  1. “四人帮”没有简单地使用全局对象模式,因为它在C++语言的早期版本中表现的不是特别好。在那里,全局名称都共享一个拥挤的全局名称空间,因此需要详细的命名约定来防止来自不同库的名称发生冲突。这伙人认为,在拥挤的全局名称空间中同时添加类和它的单例实例是过分的。由于C++程序员无法控制全局对象初始化的顺序,因此全局对象不能依赖于能够调用任何其他对象,因此初始化全局对象的责任通常落在客户端代码上。
  2. 在C++中没有办法覆盖new的含义,因此,如果所有客户端都要接收相同的对象,就必须使用另一种替代语法。不过,通过将类构造器标记为protected或private,客户机代码在调用new时至少有可能成为一个编译时错误。
  3. 因此,“四人帮”转向一个能返回该类的单例对象的类方法。与全局函数不同,类方法避免向全局名称空间添加另一个名称,而且与静态方法不同,它还可以支持是单例的子类。

Python代码是如何说明它们的方法的呢?Python缺乏new、protected和private的复杂性。另一种方法是在__init__()中引发一个异常,使正常的对象实例化成为不可能。然后,类方法可以使用一个dunder方法来创建对象,而不会触发异常:

单例模式:在Python中的研究

这就成功地阻止了客户端通过调用类来创建新的实例:

单例模式:在Python中的研究

相反,调用者会被指引去使用instance()类方法,它会创建并返回一个对象:

单例模式:在Python中的研究

后续对instance()的调用会返回单例,而不重复初始化步骤(我们可以从“创建新实例”没有再次打印出来的事实中看到),这正是“四人帮”的意图:

单例模式:在Python中的研究

对于在Python中实现最初的"四人帮"类方法,我可以想象有更复杂的方案,但是我认为上面的例子用最少的魔力最好地说明了最初的方案。由于"四人帮"的模式并不适合Python,所以我将抵制对其进行进一步迭代的诱惑,转而尝试如何在Python中最好地支持该模式。

一个更Pythonic的实现

从某种意义上说,Python一开始就比C++对单例模式有更好的准备,因为Python缺少一个强制创建新对象的new关键字。相反,对象是通过调用一个callable(可调用对象)来创建的,这对该callable实际执行的操作没有语法限制:

单例模式:在Python中的研究

为了让开发者能够控制对类的调用,Python 2.4添加了__new__() 双下方法来支持相关的创建模式,比如单例模式和Flyweight模式。

Web上到处都是以__new__()为特色的单例模式方案,每个方案都提出了一种或多或少复杂的机制来处理该方法的最大怪癖: 不管返回的对象是否是新的,返回值总是会调用__init__()。为了让我自己的例子更简单,我将不定义一个__init__()方法,从而避免必须去处理它

单例模式:在Python中的研究

对象会在首次调用类时被创建:

单例模式:在Python中的研究

但是第二次调用会返回相同的实例。“正在创建对象”消息不会打印,也不会返回一个不同的对象:

单例模式:在Python中的研究

上面的示例选择了简单性,但以在通常情况下执行两次cls._instance属性查询为代价。对于对这种浪费感到尴尬的程序员来说,他们当然可以为结果分配一个名称并在return语句中进行重用。可以想象,其它各种改进将会导致更快的字节码。但是,无论如何精心调整,上面的模式都是每个Python类的基础,每个Python类都在读法类似于普通的类实例化的东西后面隐藏了一个单例对象。

结论

虽然“四人组”的原始单例模式与像Python这样一种缺乏 new, private, 和 protected概念的语言的适配性很差,但在__new__()顶上创建它时忽略该模式并非易事——毕竟,单例是引入__new__() 双下划线方法的部分原因!

但是Python中的单例模式确实存在一些缺点。

第一个反对意见是,对于许多Python程序员来说,单例模式的实现很难阅读。另一种替代的全局对象模式很容易阅读的:它只是熟悉的赋值语句,被放置在模块的顶部。但是,当一个Python程序员第一次阅读到一个__new__()方法时,他可能不得不停下来并查看文档来理解这是怎么一回事。

第二个反对意见是,单例模式会对类进行调用,比如Logger(),会误导读者。除非设计者已经在类名中添加了“单例”或其他一些提示,并且读者非常了解设计模式,能够理解这些提示,否则代码读起来就像在创建和返回一个新实例一样。

第三个反对意见是,单例模式会强制执行一个全局对象模式不会执行的设计承诺。提供一个全局对象仍然可以让程序员自由地创建该类的其他实例——这对测试来说是特别有用的,让它们每个都测试一个完全独立的对象,而不需要将共享对象重置回已知的良好状态。但是单例模式使附加实例变得不可能。(除非调用者愿意屈尊去使用猴子补丁修复; 或临时修改_instance,以颠覆__new__()中的逻辑; 或者创建一个替换该方法的子类。但你必须绕开的模式通常是你应该避免的模式。)

那么,你为什么要在Python中使用单例模式呢?

真正需要该模式的一种情况是已经存在一个类,由于有了新的需求,该类现在作为一个单个实例运行是最好的。如果不可能迁移所有客户机代码来停止直接调用该类并开始使用一个全局对象,那么单例模式将是一种自然而然的方法,它可以在保留旧语法的同时转向一个单例。

但是,最好是避免使用这种模式,最好遵循Python官方FAQ的建议并使用全局对象模式。

英文原文:https://python-patterns.guide/gang-of-four/singleton/

译者:好酒不上头

相关推荐