python中的单身人士的n+1个变体
#python #designpatterns

介绍

在某些情况下,我们需要在程序中有一个和一个特定类的实例。我们可能希望此实例封装状态和逻辑,从应用程序中的多个位置访问。这种情况是消耗大量资源并作为特定资源的中心访问点的对象的理想选择。示例包括数据库连接,日志助手和应用程序设置。 Singleton是创建设计模式,它向我们展示了如何创建这样的对象。让我们看看我们可以在Python中实现单身模式(及其变体)的几种方法,并尝试分析每种模式的优缺点。

在讨论“一个和仅一个实例”的概念时,要考虑这种独特性的特定边界至关重要。这些边界通常称为名称空间或范围。

单例模式背后的逻辑包括以下内容:

  1. 定义一个命名空间以容纳实例。
  2. 在对象创建请求时,请检查所选名称空间中的实例是否已经存在。如果确实如此,请返回现有实例;否则,创建一个新实例并将其保存在该名称空间中。

在Python中,可以在不同级别(包括模块,函数或类)上定义命名空间。因此,此模式的实际实现将基于这些选项,具体取决于要存储唯一实例的位置。

假设我们有以下课程,我们需要将其变成单身人士:

# example.py

class Server:
    """This is a helper class providing a way to send requests 
    to some third-party service. We want to use it across 
    the whole application as the single access point to that 
    third-party service"""

    def __init__(self, hostname: str):
        # Let's assume that the initialization phase of this 
        # helper includes some hard work in creating the 
        # connection to the particular server. 
        # To avoid repeating this work, it holds that connection 
        # in the instance attribute for reusing in the future.
        # Let's keep our example as simple as this:
        self.connection = f"connection to the {hostname}"

        # Let's print a notification to indicate that our 
        # 'connection' was established by the particular 
        # Server instance
        print(f"{id(self)} connected to the {hostname}")

    def ping(self):
        # Instead of making real requests somewhere, 
        # let's just print a notification.
        print(f"{id(self)} sent request via {self.connection}")

在Python中,如果对象具有相同的标识符,则将其视为相同。但是,我们不仅会比较ID,还可以通过调用instance.ping()并观察所请求的实际服务器来检查我们实例的实际状态。

让我们用python shell运行我们的示例。

>>> from example import Server
>>> srv = Server(hostname="test.server")
140262246251248 connected to the test.server
>>> srv.ping()
140262246251248 sent request via connection to the test.server

请注意,辅助类别在初始化后立即建立连接。日志消息包含实例ID,该ID表示建立连接并执行请求的确切实例。

让我们从将我们的服务器类转换为单顿的最简单方法开始。

模块级变量

在Python中,导入模块时,其内容仅执行一次,随后的同一模块导入将参考已加载的模块对象。这种行为确保模块的变量,功能和类可在代码的不同部分共享,提供类似单元的行为。

# module_level_variable.py

class Server:
    """ The actual implementation is in the Intro section """

srv = Server(hostname="test.server")

python壳测试:

>>> from module_level_variable import srv
140138551301488 connected to the test.server
>>> srv.ping()
140138551301488 sent request via connection to the test.server
>>> from module_level_variable import srv
>>> srv.ping()
140138551301488 sent request via connection to the test.server

尽管重新出现,但我们仍然有相同的初始化实例发送请求。

确实是最直接的实现,不需要对目标类的修改。 Python本身可以免费提供可访问性和状态持久性。但是,值得注意的是,该连接首先在班级进口之后发生,在某些情况下这可能是不利的。我们可能更喜欢在特定时间初始化实例,而不是在导入后立即初始化实例。

现在,让我们探索如何消除这种初始化劣势。

带有实例Getter的模块级变量

我们可以在需要时使用模块级函数在需要时初始化实例

# module_level_instance_getter.py

class Server:
    """ The actual implementation is in the Intro section """


instance = None


def get_instance(*args, **kwargs):
    global instance
    if instance is None:
        instance = Server(*args, **kwargs)
    return instance

python壳测试:

>>> from module_level_instance_getter import get_instance
>>> srv = get_instance(hostname="test.server")
139961814546704 connected to the test.server
>>> srv.ping()
139961814546704 sent request via connection to the test.server
>>> srv2 = get_instance(hostname="test.server")
>>> srv2.ping()
139961814546704 sent request via connection to the test.server

我们可以更新此实例getter,以与传递的任何类作为参数一起使用。这将使您可以通过将它们保存在全球变量中创建的单例中来将任何类变成单例。

# module_level_multiple_instances_getter.py

class Server:
    """ The actual implementation is in the Intro section """


singletons = {}

def get_singleton(cls, *args, **kwargs):
    global singletons
    if cls not in singletons:
        singletons[cls] = cls(*args, **kwargs)
    return singletons[cls]

python壳测试:

>>> from module_level_multiple_instances_getter import get_singleton, Server
>>> srv = get_singleton(Server, hostname="test.server")
139938106238832 connected to the test.server
>>> srv_2 = get_singleton(Server, hostname="test.server")
>>> srv.ping()
139938106238832 sent request via connection to the test.server
>>> srv_2.ping()
139938106238832 sent request via connection to the test.server

一个人可能会问为什么不让get_instance成为类方法。让我们看看。

类方法getter

我们可以将类名称空间用作包含实例的范围。让我们将get_server类方法添加到我们的助手类中。

# class_method_getter.py

class Server:
    """ The actual implementation is in the Intro section """

    _instance = None

    @classmethod
    def get_instance(cls, *args, **kwargs):
        if cls._instance is None:
            cls._instance = cls(*args, **kwargs)
        return cls._instance

python壳测试:

>>> from class_method_getter import Server
>>> srv = Server.get_instance(hostname="test.server")
139953073616112 connected to the test.server
>>> srv.ping()
139953073616112 sent request via connection to the test.server
>>> srv_2 = Server.get_instance(hostname="test.server")
>>> srv_2.ping()
139953073616112 sent request via connection to the test.server

请注意,我们可以通过省略get_instance呼叫:
来轻松地打破此(和上一个)实现。

>>> srv_3 = Server.get_instance(hostname="test.server")
139953073616112 connected to the test.server
>>> srv_3.ping()
139953073616112 sent request via connection to the test.server
>>> srv_4 = Server(hostname="test.server")
139953073617168 connected to the test.server
>>> srv_4.ping()

有一个黑客允许我们创建一个单个入口处以创建实例。

带有嵌套类的模块级实例getter

python允许我们将整个类别定义包装在特定函数中,这可能是获得类实例的单个入口点。

# module_level_getter_nested_class.py

instance = None        

def get_instance(*args, **kwargs):

    class Server:
        """ The actual implementation is in the Intro section """

    global instance
    if instance is None:
        instance = Server(*args, **kwargs)
    return instance

python壳测试:

>>> from module_level_getter_nested_class import get_instance
>>> srv = get_instance(hostname="test.server")
140111140043504 connected to the test.server
>>> srv.ping()
140111140043504 sent request via connection to the test.server
>>> srv_2 = get_instance(hostname="test.server")
>>> srv_2.ping()
140111140043504 sent request via connection to the test.server

虽然该解决方案起作用,但由于其不灵活性而不常用。此外,使用这些Getters被认为是非pythonic。与单身人士相关的逻辑与目标类别的紧密耦合似乎打破了单一的责任原则。幸运的是,有更好的方法可以解决这些问题并改善单身模式的实施。

班级装饰员

Python的多个多动力性质使我们能够创建可以封装Singleton相关行为的类装饰器。

# class_decorator.py

def singleton(cls):
    _instance = None

    def wrapper(*args, **kwargs):
        nonlocal _instance
        if _instance is None:
            _instance = cls(*args, **kwargs)
        return _instance
    return wrapper

@singleton
class Server:
    """ The actual implementation is in the Intro section """

python壳测试:

>>> from class_decorator import Server
>>> srv = Server(hostname="test.server")
140568956844784 connected to the test.server
>>> srv.ping()
140568956844784 sent request via connection to the test.server
>>> srv_2 = Server(hostname="test.server")
>>> srv_2.ping()
140568956844784 sent request via connection to the test.server

当我们实现更清洁,更可读和无缠结的代码时,我们可能会遇到儿童课程的问题。为了确保一种更强大的方法,让我们探索可以有效处理继承和儿童课程的其他解决方案。

基类

我们可以将实例存储在类变量中,并在__new__方法中实现与单例相关的逻辑。

# base_class.py

class Singleton:
    _instance = None

    def __new__(cls, *args, **kwargs):
        if cls._instance is None:
            cls._instance = super().__new__(cls)
        return cls._instance

class Server(Singleton):
    """ The actual implementation is in the Intro section """

python壳测试:

>>> from base_class import Server
>>> srv = Server(hostname="test.server")
139871502953360 connected to the test.server
>>> srv.ping()
139871502953360 sent request via connection to the test.server
>>> srv_2 = Server(hostname="test.server")
139871502953360 connected to the test.server
>>> srv_2.ping()
139871502953360 sent request via connection to the test.server

如果需要,可以进一步将所得的子分类服务器类进一步子分类,这些子类将继续充当单例。但是,该解决方案有一个问题:“连接到test.Server”日志消息两次。这种方法不允许我们懒惰地进行初始化,只有在第一个通话中。这可能是资源过度初始化的问题。
确实,还有改进的余地。让我们继续探索更复杂的方法来实现单身模式。

metaclass

我们以这种方法从type实例化了我们的Singleton类,这是在Python中实现适当的元类行为所必需的。在元类的情况下,__call__方法首先称为拦截实例创建的好地方。

# metaclass.py

class Singleton(type):
    _instance = None

    def __call__(cls, *args, **kwargs):
        if cls._instance is None:
            cls._instance = super().__call__(*args, **kwargs)
        return cls._instance

class Server(metaclass=Singleton):
    """ The actual implementation is in the Intro section """

python壳测试:

>>> from metaclass import Server
>>> srv = Server(hostname="test.server")
140421527038288 connected to the test.server
>>> srv.ping()
140421527038288 sent request via connection to the test.server
>>> srv2 = Server(hostname="test.server")
>>> srv2.ping()
140421527038288 sent request via connection to the test.server

这种解决方案看起来像是实现单身人士的最PYTHONIC方法。它是可读的,可插入的,适用于子分类的对象,如果不是一个,则可能是一个不错的选择。

在实际应用程序中,我们可能希望将连接器与多个服务器建立连接器,其中这些服务器充当独立的Singleton。

>>> from metaclass import Server
>>> srv = Server(hostname="test.server")
140654371744080 connected to the test.server
>>> srv.ping()
140654371744080 sent request via connection to the test.server
>>> srv_2 = Server(hostname="second.test.server")
>>> srv_2.ping()
140654371744080 sent request via connection to the test.server
>>> srv.ping()
140654371744080 sent request via connection to the test.server

请注意,srv实例的连接如何被新创建的实例“覆盖”。这是由于原始型变量_instance而发生的,它一次只能容纳一个实例。让我们看看我们如何处理。

Multiton

Multiton是单例模式的变体,我们可以根据某些标准存储多个实例。在我们的情况下,我们可以使用包含类名称和类参数的键的字典来解决上一节中遇到的问题。

# multiton.py

class Multiton(type):
    _instances = {}

    @classmethod
    def _generate_instance_key(cls, args, kwargs):
        # This implementation of a unique key may be sensitive 
        # to complex objects passed as parameters. Feel free to 
        # override this method for the target class to fit 
        # your specific use-case.
        return f"{cls}{args}{sorted(kwargs)}"

    def __call__(cls, *args, **kwargs):
        key = cls._generate_instance_key(args, kwargs)
        if key not in cls._instances:
            instance = super().__call__(*args, **kwargs)
            cls._instances[key] = instance
        return cls._instances[key]


class Server(metaclass=Multiton):
    """ The actual implementation is in the Intro section """

python壳测试:

>>> from multiton import Server
>>> srv = Server(hostname="test.server")
139800681970704 connected to the test.server
>>> srv.ping()
139800681970704 sent request via connection to the test.server
>>> srv_2 = Server(hostname="test.second.server")
139800681969888 connected to the test.second.server
>>> srv_2.ping()
139800681969888 sent request via connection to the test.second.server
>>> srv.ping()
139800681970704 sent request via connection to the test.server

看来,我们已经找到了迄今为​​止最有效的实施,但是让我们花一点时间考虑一种不同的方法。

单位

我们一直在尝试重复使用相同的实例,但是亚历克斯·马特利(Alex Martelli)指出,我们应该专注于共同的状态和行为,而不是共同的身份。他改为使用单静态图案,在可能有多个实例的情况下使用单静态模式,但它们共享相同的__dict__特殊方法的内容。

# monostate.py

class Monostate(type):
    _shared_state = {}

    def __call__(cls, *args, **kwargs):
        obj = super().__call__(*args, **kwargs)
        obj.__dict__ = cls._shared_state
        return obj

class Server(metaclass=Monostate):
    """ The actual implementation is in the Intro section """

亚历克斯实际上称其为非模式,因为缺乏广泛使用的证据。它的直观程度不及经典的单例方法,实际实现可能会根据用例而有所不同。但是,我认为这种方法也有空间。

线程安全

到目前为止,我们已经实现了使用Metaclass的Singleton(Multiton)模式的强大实现。但是,看来我们的实现可能在多线程上下文中表现不佳。当对象创建在多个线程共享的代码中发生时,可能会有一个竞赛条件。一个线程可能会开始实例化对象,然后另一个线程控制。由于该对象尚未完全创建,因此第二个线程也开始创建它。在下面的测试中,借助于故意减慢的__init__方法来复制此问题。重型初始化逻辑可能就是这种情况。

# thread_test.py

import threading
import time

class Server(metaclass=Monostate):
    def __init__(self):
        time.sleep(0.1)


result_set = set()

def create_instance():
    instance = Server()
    result_set.add(str(instance))


threads = [
    threading.Thread(target=create_instance)
    for _ in range(5)
]

for thread in threads:
    thread.start()

for thread in threads:
    thread.join()

print(f"Created {len(result_set)} instance(s):")
print('\n'.join(result_set))

输出:

Created 5 instance(s):
<__main__.Server object at 0x7f2f8d72b8b0>
<__main__.Server object at 0x7f2f8d72ba30>
<__main__.Server object at 0x7f2f8d72b7f0>
<__main__.Server object at 0x7f2f8d72b730>
<__main__.Server object at 0x7f2f8d72b970>

根据用例,我们应该决定是使我们的实例在每个线程中独特还是在所有线程中唯一。

线程限制的元顿

通过利用这种方法,我们可以将状态与特定线程相关联。但是,至关重要的是要了解这将不再是全球应用程序级别的单身模式。相反,每个线程将具有对象的唯一实例。

# thread_confined.py

import threading


class Multiton(type):
    _local = threading.local()

    @classmethod
    def _ensure_local_namespace(cls):
        if not hasattr(cls._local, "_instances"):
            cls._local._instances = {}

    @classmethod
    def _generate_instance_key(cls, args, kwargs):
        # This implementation of a unique key may be sensitive 
        # to complex objects passed as parameters. Feel free to 
        # override this method for the target class to fit 
        # your specific use-case.
        return f"{cls}{args}{sorted(kwargs)}"

    def __call__(cls, *args, **kwargs):
        cls._ensure_local_namespace()
        key = cls._generate_instance_key(*args, **kwargs)
        if key not in cls._local._instances:
            instance = super().__call__(*args, **kwargs)
            cls._local._instances[key] = instance
        return cls._local._instances[key]

线程安全的Multiton

要避免种族条件,我们必须在实例化实例化时锁定对共享_instances词典的访问。从Python标准库中的锁定对象可以很容易地实现。

# thread_safe.py

import threading


class Multiton(type):
    _instances = {}
    _lock = threading.Lock()

    @classmethod
    def _generate_instance_key(cls, args, kwargs):
        # This implementation of a unique key may be sensitive 
        # to complex objects passed as parameters. Feel free to 
        # override this method for the target class to fit 
        # your specific use-case.
        return f"{cls}{args}{sorted(kwargs)}"

    def __call__(cls, *args, **kwargs):
        instance_key = cls._generate_instance_key(*args, **kwargs)
        # Double-checked locking technique
        if instance_key not in cls._instances:
            with cls._lock:
                if instance_key not in cls._instances:
                    instance = super().__call__(*args, **kwargs)
                    cls._instances[instance_key] = instance
        return cls._instances[instance_key]

class Server(metaclass=Multiton):
    """ The actual implementation is in the Intro section """

输出:

Created 1 instance(s):
<__main__.Server object at 0x7f0c6e16b730>

可能会问为什么if instance_key not in cls._instances被称为两次。从技术上讲,在获取锁后,进行一次检查就足够了。但是,当此检查导致True中的情况仅在第一个呼叫中发生。随后的所有呼叫都将导致False,表明该锁定是不必要的。在类/方法中不必要地获取锁可能会导致难以识别的速度代码,因此仅在必要时才获得锁以避免绩效问题至关重要。

结语

我们知道,没有通用的解决方案,几乎每种方法都有其局限性和缺点。有些文章甚至将Singleton标记为对抗者。在“难以理解”,“阅读挑战”和“违反单一责任原则”之类的关注中,一个特别的关注是重要的含义。仅利用单个实例的限制可能会使测试在我们真正需要对该实例各种状态进行多个测试的情况下进行复杂化。但是,可以通过以允许外部注入实例的方式构造应用程序来缓解此问题。在常规操作过程中,代码块可以使用Server(metaclass=Multiton)实例,但是在测试期间,Server()实例可以外部注入。

我们还必须牢记,在某些情况下,命名空间的唯一实例可以通过调用importlib.reload(module_name)来覆盖。此操作将导致实例重新发生。

此外,根据特定的实施,处理实例化的单例可能需要额外的步骤。即使我们不再需要这些实例,保留对我们单人的参考的范围也可能阻碍垃圾收集器消除它们。

在简单的项目或原型的背景下,人们可能会诉诸于模块级变量,以维护独特的实例。然而,对于更复杂的场景,尤其是在懒惰评估是必须进行的,尤其是一种元素方法。当制作公共图书馆而没有清晰的使用情况时,建议选择线程安全的方法。

无论如何,尽管具有增加的复杂性和风险,但最好了解单顿模式背后的想法。它可以为某些资源提供单个访问点的能力,而懒惰的实例化的能力超过其缺点,至少对于Python等语言。

参考书目

  1. Chetan Giridhar. 2016. Learning Python Design Patterns
  2. Lelek, Tomasz. Skeet, John. 2021. Software Mistakes and Tradeoffs
  3. Shvets, Alexander. 2022. Dive Into Design Patterns
  4. Five Easy Pieces: Simple Python Non-Patterns