在 Python 中理解与使用单例模式

作者: iswbm / 发表于 2024-06-01 / 分类: Programming, Python

python

单例模式一种常见的设计模式,并且也是最基础的设计模式,需要每个开发人员都熟练掌握。

当你希望在整个系统中,某个类只能出现一个实例时,就需要学会使用单例模式。

比较常见的场景是:某个项目的配置信息存放在一个配置文件中,通过一个 Config​ 的类来读取配置文件的信息。如果在程序运行期间,有很多地方都需要使用配置文件的内容,也就是说,很多地方都需要创建 Config​ 对象的实例,这就导致系统中存在多个 Config​ 的实例对象,而这样会严重浪费内存资源,尤其是在配置文件内容很多的情况下。事实上,类似 Config​ 这样的类,我们希望在程序运行期间只存在一个实例对象。

在 Python 中,可以用多种方法实现单例模式,常见的有:

  • 通过使用模块
  • 通过装饰器
  • 通过__new__方法
  • 通过元类

不同的方法,有不同的坑,对 Python 机制认识不够的同学,很容易踩到坑,本文用实例来详细说明一下这几种方法的区别,以及那些不容易被察觉的坑点,给出最佳的选择方案。

下面就详细说明以下这几种实现方法:

1. 通过使用模块

在Python中,Python 的模块就是天然的单例模式。

因为模块在第一次导入时,会生成 .pyc​ 文件,当第二次导入时,就会直接加载 .pyc​ 文件,而不会再次执行模块代码。因此,我们只需把相关的函数和数据定义在一个模块中,就可以获得一个单例对象了。

看如下示例:

# 例如在config.py文件中定义Config类和实例
class Config:
    LOG_LEVEL = 'INFO'
    ...

config = Config()


# 然后在其他文件中,通过import导入此实例
from config import config

print(config.LOG_LEVEL)
# 输出结果:INFO

如上所示,通过在文件中先生成一个类实例,然后在别的文件中直接导入这个类实例,就实现了单例模式。

2. 通过装饰器

另外一种典型的用法是使用类装饰器,由于逻辑比较易懂,被广泛被开发人员使用。

如下是一个例子,在每次对 Config 实例化的时候,都会进入 Singleton 类装饰器,该装饰器维护一个 dict,key 为 cls,value 为 instance,每次实例化都检查该 cls 是否已经在 dict 中存在,若存在则直接将之前实例化的对象返回,如此来保证单例。

from functools import wraps


def Singleton(cls):
    instance = {}

    @wraps(cls)
    def wrapper(*args, **kwargs):
        if cls not in instance:
            instance[cls] = cls(*args, **kwargs)
        return instance[cls]

    return wrapper


@Singleton
class Config(object):
    pass


cfg1 = Config()
cfg2 = Config()

print(cfg1 == cfg2)

运行后,输出结果 True

不过,这种方法虽然逻辑非常清晰易懂,但却有一个非常大的问题:经过 Singleton 装饰的后,其返回的是函数,因为无法被继承。

装饰器由于本身机制的限制,在实例单例的同时也带来了一定的 “副作用”。

仔细一想,用装饰器实现单例的思路,不就是拦截实例对象的创建,来保证类只有唯一的实例嘛!

那我们只要换种方法,来拦截实例对象的创建不就行了。

通常的方法,有两种:

  • new 方法
  • 元类

3. 通过__new__方法

先说一下 new 方法,它是 Python 中的魔法方法之一。

很多人可能对他不是很熟悉,不过完全没有关系,你只要知道,当你在实例化的时候,是先执行类的 new 方法,再执行类的 init 即可。

因此,我们可以在 new 上做一些事情,使得类的实例只能存在一个。

如下是一个示例,在第一次实例的时候,会在 cls 上添加一个属性 _instance​ 来保存第一个实例,后面再实例化时,就会返回第一次创建的时候,

class Config(object):
    def __new__(cls, *args, **kwargs):
        if not hasattr(cls, '_instance'):
            cls._instance = super().__new__(cls, *args, **kwargs)
        return cls._instance


cfg1 = Config()
cfg2 = Config()

print(cfg1 == cfg2)

运行后,输出结果 True

细心的朋友,想必已经发现,如果有其他类继承了 Config 这个类,那么 _instance​ 也同样会被子类继承过去,这会导致只要 Config 及其子类只能有一个实例,只要 Config 实例过了,其子类就不能再实例化了,但这显然不是我们所期望的。

有的朋友,可能会想到用双下划线的 __instance​,这样就不会被继承了。

很遗憾的是,双下划线的属性,虽然不会被继承,但却有一个问题,就是属性名会被 Python 修改掉,变成 _Config__instance​,这样一样,我们在编写 new 时就无法使用 hasattr 来判断。

class Config(object):
    def __new__(cls, *args, **kwargs):
        if not hasattr(cls, '__instance'):
            cls.__instance = super().__new__(cls, *args, **kwargs)
        return cls.__instance


cfg1 = Config()
print(hasattr(Config, "__instance"))          # 输出:False
print(hasattr(Config, "_Config__instance"))   # 输出:True

最好的做法是:在所有的子类将 _instance​ 重置为 None,并且重写 __new__​ 方法,最重要的是不要去调用 supper.__new__()

class Config(object):
    def __new__(cls, *args, **kwargs):
        if not hasattr(cls, '_instance'):
            cls._instance = super().__new__(cls, *args, **kwargs)
        return cls._instance


class ConfigExt(Config):
    _instance = None

    def __new__(cls, *args, **kwargs):
        if not hasattr(cls, '_instance'):
            cls._instance = object.__new__(cls, *args, **kwargs)
        return cls._instance


cfg1 = Config()
cfg2 = ConfigExt()
cfg3 = ConfigExt()

print(cfg1 == cfg2)  # False
print(cfg2 == cfg3)  # True

可以看到,通过修改 __new__​ 方法,已经可以解决装饰器不能被继承的问题,但在有可能被继承的类里,却存在安全隐患。

4. 通过 metaclass

metaclass 是什么呢? 用一句话说明

  • 类,是用来创建实例对象的「模板」。
  • 而元类,是创建类的「模板」。

因此我们可以通过修改元类,来使得创建此类时,就直接是一个单例类。

学习元类的作用过程,可以用普通类来做对比学习:

  • 在创建一个实例时,会走类的 new 方法
  • 同样地逻辑,在创建一个普通类时,也会走元类的 new

与此同时:

  • 在类里实现了 call 可以让这个类的实例,变成可调用对象
  • 对普通类进行实例化时,实际是对一个元类的实例(也就是普通类)进行直接调用,因此会走元类的 call 方法

如下是一个示例,可以发现,父类和子类都可以完美实现单例,而不需要有额外的约定,也不需要团队里的人有统一的技术积累,就像平时一样,不会有任何突兀的感觉。

class Singleton(type):
    def __init__(cls, *args, **kwargs):
        cls.__instance = None
        super().__init__(*args, **kwargs)

    def __call__(cls, *args, **kwargs):
        if not cls.__instance:
            cls.__instance = super().__call__(*args, **kwargs)
        return cls.__instance


class Config(metaclass=Singleton):
    pass


class ConfigExt(Config):
    pass


cfg1 = Config()
cfg2 = Config()
cfg3 = ConfigExt()
cfg4 = ConfigExt()

print(cfg1 == cfg2)  # True
print(cfg2 == cfg3)  # False
print(cfg3 == cfg4)  # True

另外,该方法在多线程场景下并发创建实例对象时,由于初始化时,是需要一点时间,那么就会导致数据不同步的问题,导致出现多个实例。

为了方便复现,我在 __call__​ 里加了 time.sleep(1) 来实现延长实例化的时间

import time
import threading

class Singleton(type):
    def __init__(cls, *args, **kwargs):
        cls.__instance = None
        super().__init__(*args, **kwargs)

    def __call__(cls, *args, **kwargs):
        if not cls.__instance:
            time.sleep(1)
            cls.__instance = super().__call__(*args, **kwargs)
        return cls.__instance


class Config(metaclass=Singleton):
    pass


def task():
    cfg = Config()
    print(id(cfg))


for i in range(10):
    t = threading.Thread(target=task)
    t.start()

运行结果如下,说明单例模式出现了问题

4336068992
4373051424
4335324896
4336069088
4335324848
4336069088
4335324896
4336069088
4335324848
4336069088

但这个问题可以通过线程锁来解决,先定义一个装饰器 synchronized​ ,然后在元类里的 __call__​ 加上这个装饰器。

import time
import threading


def synchronized(func):
    func.__lock__ = threading.Lock()

    def lock_func(*args, **kwargs):
        with func.__lock__:
            return func(*args, **kwargs)

    return lock_func


class Singleton(type):
    def __init__(cls, *args, **kwargs):
        cls.__instance = None
        super().__init__(*args, **kwargs)

    @synchronized
    def __call__(cls, *args, **kwargs):
        if not cls.__instance:
            time.sleep(1)
            cls.__instance = super().__call__(*args, **kwargs)
        return cls.__instance


class Config(metaclass=Singleton):
    pass


def task():
    cfg = Config()
    print(id(cfg))


for i in range(10):
    t = threading.Thread(target=task)
    t.start()

运行的结果就正常了

4381387136
4381387136
4381387136
4381387136
4381387136
4381387136
4381387136
4381387136
4381387136
4381387136

5. 总结一下

在 Python 中有很多种方法实现单例的效果,但不同的方法,却有不同的局限性:

  • 使用模块:最简单直接且安全,推荐使用
  • 使用装饰器:不能被继承,不推荐使用
  • 使用 new 方法:需要开发成员对Python有足够的认识,不然代码会有BUG。
  • 使用 metaclass:完美的单例实践,也推荐使用,但要注意加锁
iswbm

作者

iswbm

云计算 & 容器 & 前后端研发工程师。喜欢探索新技术,空闲时也折腾 Logseq 等效率工具。 可以在 GitHub 关注我了解更多,也可以加我微信(stromwbm) 与我交流。