DEV Community

Sergey Piskunov
Sergey Piskunov

Posted on

N+1 variations of a Singleton in Python

Intro

There are cases when we need to have one and only one instance of a particular class in our program. We may want this instance to encapsulate state and logic, accessible from multiple locations within the application. Such scenarios are ideal for object that consume significant resources and serve as a central access point to specific resources. Examples include database connections, logging helpers, and application settings. The Singleton is the creational design pattern that shows us how to create such an object. Let's see how many ways we may implement a Singleton pattern (and its variations) in Python and try to analyze the pros and cons of each.

When discussing the concept of "one and only one instance" it is crucial to consider the specific boundaries of that uniqueness. These boundaries are commonly referred to as namespaces or scopes.

The logic behind the Singleton pattern includes the following:

  1. Defining a namespace to hold an instance.
  2. Upon an object creation request, check if an instance already exists within the chosen namespace. If it does, return the existing instance; otherwise, create a new instance and save it within that namespace.

In Python, namespaces can be defined at different levels, including a module, a function, or a class. Therefore, the actual implementations of this pattern will be based on these options, depending on where the unique instance is intended to be stored.

Let's assume we have the following class, which we need to turn into a Singleton:

# 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}")
Enter fullscreen mode Exit fullscreen mode

In Python, objects are considered the same if they have the same identifier. However, we will not only compare the IDs but also check the actual state of our instances by calling instance.ping() and observing the actual server being requested.

Let's use the Python shell to run our examples.

>>> 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
Enter fullscreen mode Exit fullscreen mode

Notice that the helper class establishes the connection right after initialization. The log messages contain instance IDs, which indicate the exact instance that established the connection and performed the request.

Let's begin with the simplest way to convert our Server class into a Singleton.

Module-level variable

In Python, when a module is imported, its contents are executed only once, and subsequent imports of the same module will refer to the already loaded module object. This behavior ensures that the module's variables, functions, and classes are shared across different parts of the code, providing a singleton-like behavior.

# module_level_variable.py

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

srv = Server(hostname="test.server")
Enter fullscreen mode Exit fullscreen mode

Python Shell test:

>>> 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

Enter fullscreen mode Exit fullscreen mode

Despite re-importing, we still have the same initialized instance sending requests already.

That is indeed the most straightforward implementation, where no modification of the target class is needed. Python itself provides accessibility and state persistence for free. However, it's worth noting that the connection occurs right after the class is first imported, which can be disadvantageous in some cases. We may prefer initializing the instance at a specific time rather than immediately after importing.

Now, let's explore how we can eliminate this initialization disadvantage.

Module-level variable with instance getter

We may initialize instances when needed using a module-level function responsible for returning either a new or already instantiated object from the global namespace.

# 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

Enter fullscreen mode Exit fullscreen mode

Python Shell test:

>>> 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

Enter fullscreen mode Exit fullscreen mode

We can update this instance getter to work with any class passed as a parameter. That will allow you to turn any class into a singleton by keeping them in a global mapping and storing created singletons in the global variable.

# 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]
Enter fullscreen mode Exit fullscreen mode

Python Shell test:

>>> 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
Enter fullscreen mode Exit fullscreen mode

One may ask why not make the get_instance a class method. Let's see.

Class-method instance getter

We can use the class namespace as a scope containing the instance. Let's add the get_server class method to our helper class.

# 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
Enter fullscreen mode Exit fullscreen mode

Python Shell test:

>>> 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
Enter fullscreen mode Exit fullscreen mode

Notice how easily we can break this (and the previous) implementation by omitting the get_instance call:

>>> 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()
Enter fullscreen mode Exit fullscreen mode

There is a hack allowing us to create a single entrypoint for instance creation.

Module-level instance getter with a nested class

Python allows us to enclose the whole class definition within a particular function which may be a single entrypoint for getting the class instance.

# 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

Enter fullscreen mode Exit fullscreen mode

Python Shell test:

>>> 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
Enter fullscreen mode Exit fullscreen mode

While the solution works, it is not commonly used due to its inflexibility. Furthermore, the use of those getters is considered non-Pythonic. The tight coupling of Singleton-related logic with the target class appears to break the Single Responsibility Principle. Fortunately, there are better approaches to address these issues and improve the implementation of the Singleton pattern.

Class decorator

The multiparadigm nature of Python enables us to create class decorators that can encapsulate the Singleton-related behavior.

# 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 """

Enter fullscreen mode Exit fullscreen mode

Python Shell test:

>>> 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
Enter fullscreen mode Exit fullscreen mode

While we achieved cleaner, more readable, and untangled code, we may encounter issues with child classes. To ensure a more robust approach, let's explore other solutions that can effectively handle inheritance and child classes.

Base class

We can store the instance within a class variable and implement the Singleton-related logic in the __new__ method.

# 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 """

Enter fullscreen mode Exit fullscreen mode

Python Shell test:

>>> 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
Enter fullscreen mode Exit fullscreen mode

The resulting subclassed Server class can be further subclassed if needed, and these subclasses will continue to act as singletons. However, this solution has an issue: the 'connected to the test.server' log message appears twice. This approach does not allow us to run initialization lazily, only during the first call. That may be an issue for resource-heavy initialization.
Indeed, there is room for improvement. Let's continue exploring more sophisticated approaches to implement the Singleton pattern.

Metaclass

We instantiate our Singleton class from the type in this approach, which is required to achieve proper metaclass behavior in Python. In the case of a metaclass, the __call__ method is called first, making it an excellent place to intercept instance creation.

# 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 """
Enter fullscreen mode Exit fullscreen mode

Python Shell test:

>>> 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
Enter fullscreen mode Exit fullscreen mode

This solution looks like the most Pythonic way to implement a Singleton. It is readable, pluggable, works for subclassed objects, and may be a good choice if not for one but.

In the real application we may want to have connectors to multiple servers, which of them acting as independent 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
Enter fullscreen mode Exit fullscreen mode

Notice how the srv instance's connection gets "overridden" by the newly created instance. This occurs because of the primitive-type variable _instance, which can hold only one instance at a time. Let's see how we can deal with that.

Multiton

Multiton is a variation of the Singleton pattern where we can store multiple instances based on certain criteria. In our case, we can use a dictionary with a key consisting of the class name and class arguments to resolve the issue encountered in the previous section effectively.

# 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 """
Enter fullscreen mode Exit fullscreen mode

Python Shell test:

>>> 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
Enter fullscreen mode Exit fullscreen mode

It appears that we have found the most effective implementation so far, but let's take a moment to consider a different approach.

Monostate

We have been trying to reuse the same instance all this time, but Alex Martelli notes that we should focus on the shared state and behavior rather than the shared identity. He proposed using the Monostate pattern instead, where there may be multiple instances, but they share the same __dict__ special method's contents.

# 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 """
Enter fullscreen mode Exit fullscreen mode

Alex actually calls it a nonpattern because of a lack of evidence of widespread usage. It is a bit less intuitive than the classic Singleton approach, and the actual implementation may differ according to the use case. However, I think there is room for such an approach as well.

Thread safety

So far, we have achieved a robust implementation of the Singleton (Multiton) pattern using a metaclass. However, it appears that our implementation may misbehave in a multithreaded context. When object creation occurs within code shared by multiple threads, there can be a race condition. One thread might start instantiating an object, and then another thread takes control. Since the object is not yet fully created, the second thread starts creating it as well. In the following test, this issue was reproduced with the help of the deliberately slowed-down __init__ method. That may be the case for a heavy initializing logic.

# 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))

Enter fullscreen mode Exit fullscreen mode

Output:

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>
Enter fullscreen mode Exit fullscreen mode

Depending on the use case, we should decide whether to make our instance unique within each thread or unique across all threads.

Thread confined Multiton

By utilizing this approach, we can associate the state with a particular thread. However, it's crucial to understand that this would no longer be a Singleton pattern at the global application level. Instead, each thread will have its unique instance of the object.

# 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]

Enter fullscreen mode Exit fullscreen mode

Thread safe Multiton

To avoid race conditions, we must lock access to the shared _instances dictionary while an instance is being instantiated. That may be easily achieved with the Lock object from the Python standard library.

# 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 """

Enter fullscreen mode Exit fullscreen mode

Output:

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

One may ask why if instance_key not in cls._instances is called twice. Technically, it would be sufficient to make that check once after the lock is acquired. However, the case when this check results in True will occur for the very first call only. All subsequent calls will result in False indicating that the lock was acquired unnecessarily. Acquiring locks unnecessarily in a class/method can lead to slow code that is hard to identify, so it's crucial to acquire locks only when necessary to avoid performance issues.

Epilogue

As we are aware, there is no universal solution, and nearly every approach has its limitations and disadvantages. Some articles even label the Singleton as an antipattern. Among the concerns, such as "difficult to comprehend," "challenging to read," and "violates the Single Responsibility Principle," one particular concern holds significant meaning. The constraint of utilizing only a single instance may complicate testing in scenarios where we genuinely require multiple tests against various states of that instance. However, this problem can be alleviated by structuring the application in a way that allows external injection of the instance. During regular operations, the block of code can utilize the Server(metaclass=Multiton) instance, yet during tests, the Server() instance can be externally injected.

We must also bear in mind that, in certain cases, the namespace housing unique instances could be overwritten by invoking importlib.reload(module_name). This action would result in the re-instantiation of instances.

Additionally, depending on the specific implementation, extra steps might be necessary to dispose of instantiated singletons. Even if we no longer require those instances, the scope retaining references to our singletons could hinder the garbage collector from eliminating them.

In the context of simple projects or prototypes, one might resort to module-level variables for maintaining unique instances. Nevertheless, a metaclass approach is preferable for more intricate scenarios, particularly when lazy evaluation is imperative. When crafting a common library without a clear vision of its usage, opting for a thread-safe approach is advisable.

Anyway, despite of added complexity and risks, it is better to understand the idea behind the Singleton pattern. Its ability to provide a single access point to some resource with lazy instantiation outweighs its drawbacks, at least for languages like Python.

Bibliography

  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

Top comments (0)