解析Python中的单例
单例是软件设计模式中比较常用的模式,也是最方便实现的一种模式。正是因此,其实现方式也是最能够体现一个人对python基础知识的掌握情况的标尺
实现方式
每一种单例的实现方式都会有很多可以联系到的基础知识
以下根据该单例存储的位置范畴由大到小依次说明
1. 全局存储
俗称懒汉模式
1 | class _Singleton(object): |
当需要使用这个单例时,直接使用 Singlton
实例即可。最简单的情况是在直接使用这个单例,无论是在同一段代码还是以包的形式引用。稍微有点区别的是,以包的形式引用的话,还需要知道 import
这个语法引入变量是单例的,保证了 Singleton
变量地址不发生变化。更妙的是,这段代码不会产生竞争条件,也就是说不会有多线程环境下多次实例化的情况(正常使用的话)。
然而,如果使用 reload
重新加载这个包含单例的包时,将会产生新的实例。还有另一点,一般不希望在全局存在过多变量,可能在无意中造成不需要的单例的创建,换句话说就是,这种单例不容易由自己决定在什么时候创建,除非你很清楚自己在做什么。最后,这种模式下如果文档中说明不清晰,或者跟其他开发人员交流不够明白,有可能导致手动再创建一个实例,因为 _Singleton
这个类是可以手动再实例化的。
虽然全局存储的形式看上去问题挺多,但这种方式其实揭示了单例模式的通行做法—— 保存实例
其他创建单例的形式无非是让这个过程变得更加安全和健壮了而已
2. 静态变量存储
将实例存储为静态变量的方式将该实例的访问权限缩小为通过类及其实例,相对全局而言提升了一层抽象。据我观察,这也是实现形式最多的一种方式,也是隐藏问题最多的一种。
1 | from threading import Lock |
这里主要利用了 __new__
方法控制实例的创建过程,但是有几个需要注意的地方
1) 新式类
这个问题存在于python2.7及以下
也就是说这个类必须是 object
的子类,否则 __new__
方法将失效。说白了,__new__
方法是 object
给的
2) 静态变量名
静态变量的名字不能是私有变量,也就是说不能偷懒给全局变量命名为 __instance
因为会被重写为 _Singleton__instance
,也就是说 hasattr(cls, "__instance__")
永远都是 False
,因此每次创建实例时都会重建实例
为了避免这种隐含问题,减少动态属性的使用,一般也会这么写:
1 | from threading import Lock |
3) 初始化方法重复执行
__init__
方法作为初始化方法,在每次创建实例完成后都会默认执行,也就是在 __new__
之后自动执行,所以如果有什么只希望在单例创建时执行的动作一定不能直接放在 __init__
中
所以这里也就有了其他写法:
1 | from threading import Lock |
以及这种写法的退化写法:
1 | from threading import Lock |
其实就是使用类方法代替了 __new__
方法,存储了一个实例到静态变量 _instance
。当然,比较不爽的地方就是单例的获取只能通过调用类方法 get_instance
获得
4) 单例模式不可继承
这里强调是模式不可继承,也就是说父类是单例模式,子类不会不经修饰就成为天然单例模式。而且对于简单的继承,可能会出大问题
1 | from threading import Lock |
由于 B
与 Singleton
的实例共享 __instance__
静态变量,所以由于 Singleton
先实例化,导致 B
的实例其实还是 Singleton
的实例
正确的继承方式:
1 | from threading import Lock |
当然,自然有一种可以继承单例模式的方式:
1 | from threading import Lock |
不过这种方法看起来还是有那么一点丑,所以还有一种稍微高级一点的方式,那就是 元类
。不过元类在python2和python3之间差异较大,所以是不通用的,但是原理是类似的。以下以 python2
作为例子
元类作为类的类,本质上只是给出了类创建时候的模板,最终还是依靠类本身的特性实现的。可以看看如下例子:
1 | from threading import Lock |
上述例子本质上还是依赖 __new__
方法实现,只是将 __new__
方法的实现模板化,使用元类实现类的量产
3. 外部变量存储
一提到外部变量,那么就会提到闭包,一提到闭包,就会说到装饰器。由于装饰器的本质其实都是可执行对象(大多数情况下都说的是函数),所以以下以普通装饰器为例:
1 | from threading import Lock |
注意:这里假设对python中的字典结构进行加key与查询key的操作是线程安全的
在这种模式下创建单例不再需要一个一个的手写,而是通过装饰器进行包装。这种方式的强大之处在于即使一个已经写好的类不是单例,也可以在外部强行转变为单例。这个例子里可能有一些地方需要特别指出(并不是无意为之)
关于装饰器的一些问题在另一篇文章中会详细整理
1) 这是一个带参数的装饰器
首先,这个参数的作用是作为实例在外部字典 _instance
中的key,其次这个key可以通过其他方式获取(也就是说可以用不带参数的装饰器实现)。当然,如果在项目中就只有一个类需要作为单例来使用,可以将key固定为 instance
类似的字符串(虽然我觉得没有多少人会这么做),甚至将整个实例占用单个外部变量(但是如果是修改外部变量会涉及到内外部作用域的问题,会用到引用传递或者 nonlocal
等)
如何让 singleton
不带参数使用?
1 | from threading import Lock |
这里用到一个可能不存在的全局变量 __file__
,是为了防止在不同python文件中存在同名的类,如果不觉得key丑的话 name = repr(cls)
也可以(其实这样的代码反而看起来很清晰=。=)
2) wraps装饰单例类
wraps的作用在于恢复被装饰类的基本元信息(对,不仅可以恢复函数的元信息,还可以恢复类的元信息),默认情况下会保留 __module__
, __name__
, __doc__
三个元变量数据
3) 线程锁 Lock
_lock
锁实例在每次装饰类时实例化,在 with _lock
时控制代码片段访问序列。在一开始首先判断实例是否创建,如果已经创建,则立即返回该实例;如果没有创建,则通过加锁方式创建单例。
在锁中又判断一次该实例是否创建,是有必要的,考虑如下代码:
1 | class Delay(object): |
当多个线程同时开始实例化时都会发现 _instance
中无该实例,在锁的安排下,会依次进入临界区,创建实例,但是这个过程要等待10s之久,所有的线程一定都会尝试一遍创建实例,如果不判断实例是否已经被别的线程创建了,那一定会出现多次实例化。当然,最终的确只会残留一个实例。
所以,锁 + 存在判断是为了在多线程环境下,从始至终只生成一个正确的实例。
4) 这是一个装饰器
意味着单例被使用函数创建出来,被装饰的单例类再也不是类(至少不是表面上看起来的类),而是函数,最简单的验证方式就是:
1 | type(A) # function |
因为此时类已经被替换成了 cls_wrapper
(带参数) 或者 init_wrapper
(不带参数)了
所以丢失了原来用类来判断的能力,比如 isinstance
,__class__
等等
5) 初始化仅执行一次
和上一类实现方式不一样的是,这里的单例只会执行一次初始化操作。某种意义上算是很方便的一个特性
总结
实际情况中,每个人都会根据自己的需求进行单例的实践,甚至多种方式组合使用。不过创建单例的思想一直都没有变过:常驻内存+线程安全
本文作者 : hellflame
原文链接 : https://hellflame.github.io/2019/10/04/singleton-in-python/
版权声明 : 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明出处!