Singleton Pattern

The singleton pattern ensures controlled access to a single instance of a class. While it offers significant benefits in terms of resource management and access control, developers must be mindful of its downsides, such as potential scalability issues and the introduction of global states. When used carefully, it can be an invaluable design choice for managing resources and coordinating actions across complex systems.

Singleton Pattern

Image credits: Image generated by DALL-E.

Overview

A singleton pattern limits the number of instances of a class to one. It falls under a creational design pattern1 because it deals with an object creation procedure. The singleton pattern ensures that a class has only one instance and provides a global access point2 to it. With that said, its primary purpose is to control object creation, limiting the number of instances to just one, thereby preventing the instantiation of more than one object of a class.

Except for the special creation method (magic method in Python), the singleton pattern prevents the creation of objects via any other means. If an object has already been created, this method either returns it or creates a new one if needed.

Dunder methods: The dunder methods are special methods that start and end with double underscores. These double underscores are referred by the acronym "dunder."


Use cases

Who would want to limit the number of instances a class has? The most common justification for this is to manage access to a shared resource, such a file or database.

Creation of a singleton object

Singleton in Python

The following shows the creation of a singleton object in Python. Here, __new__ is a special or magic method (aka the dunder method). This method is invoked every time a class is instantiated. Note that the __new__ method is invoked before the __init__ method gets called.

When we attempt to create an object inside of the __new__ method in the usual way (ClassName()), it runs recursively. It loops back on itself until the maximum recursion depth is reached and a RecursionError error is thrown.

class Singleton(object):
    """Represents Singleton class."""

    __instance = None

    # This __new__ method is invoked every time a class is instantiated.
    # It's invoked before the __init__ method gets called.
    def __new__(cls, *args, **kwargs):
        if not cls.__instance:
            print("Creating singleton object...")
            cls.__instance = super(Singleton, cls).__new__(cls, *args, **kwargs)
            # cls.__instance = object.__new__(cls, *args, **kwargs)  # Other way of creating an object
            # cls.__instance = Singleton() #  Will get into a RecursionError error
        return cls.__instance

def main():
    singleton_object_1 = Singleton()
    singleton_object_2 = Singleton()

if __name__ == '__main__':
    main()

Output:

Creating singleton object...

The output shows that the new object is created just once.


Issues and challenges associated with the singleton pattern

Singleton pattern is popular in software engineering for various applications, including logging, driver objects, caching, thread pools, and configuration settings. However, it also introduces several issues and challenges, including:

Global State

Global state refers to data that is accessible from anywhere in an application, at any time, without being passed through the application flow. In the context of the singleton pattern, the single instance of the class that is globally accessible acts as this global state. While global state can simplify access to common resources, it often introduces several problems, such as increased complexity, hidden dependencies, and difficulties in debugging and testing.

Global state issues:

  1. Hidden dependencies: When components rely on global state, the dependencies between them are not explicit, making the code harder to understand and maintain.
  2. Tight coupling: Components that rely on global state are tightly coupled, meaning changes in the global state can have unpredictable effects on these components.
  3. Testing challenges: Global state makes it difficult to write independent unit tests since the state can persist across tests, leading to flaky tests that pass or fail depending on the order in which they run.
  4. Concurrency issues: In multi-threaded applications, managing global state can lead to race conditions and data inconsistencies.
  5. Debugging difficulties: Tracking down bugs becomes harder when multiple parts of the code can change the global state, making it difficult to pinpoint the source of an issue.

For example, consider a simple application where we have a global configuration object that stores settings for the application. This configuration object is accessed and modified by different parts of the application.

# Global state example in Python
class Config:
    _instance = None

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

    def __init__(self):
        self.settings = {}

config = Config()

# Different parts of the application modify the global state
def initialize_settings():
    config.settings["database_url"] = "localhost:5432"
    config.settings["debug"] = True

def update_settings():
    config.settings["debug"] = False

def read_settings():
    print(config.settings)
# Usage
initialize_settings()
read_settings()  # Output: {'database_url': 'localhost:5432', 'debug': True}
update_settings()
read_settings()  # Output: {'database_url': 'localhost:5432', 'debug': False}

Issues with the above example:

  1. Hidden dependencies: Functions initialize_settings, update_settings, and read_settings all rely on the global config object, but this dependency is not explicit in their interfaces.
  2. Tight coupling: If the structure of the config object changes, all functions that interact with it need to be updated, making the code harder to maintain.
  3. Testing challenges: Writing unit tests for these functions is difficult because they share the same config object. Tests might interfere with each other by modifying the global state.
  4. Concurrency issues: If this code were part of a multi-threaded application, accessing and modifying the global config object without proper synchronization could lead to race conditions.

Improved approch:

A better design would be to pass the configuration object explicitly to functions that need it, avoiding the reliance on global state.

# Improved design without global state
class Config:
    def __init__(self):
        self.settings = {}

def initialize_settings(config):
    config.settings["database_url"] = "localhost:5432"
    config.settings["debug"] = True

def update_settings(config):
    config.settings["debug"] = False

def read_settings(config):
    print(config.settings)

# Usage
config = Config()
initialize_settings(config)
read_settings(config)  # Output: {'database_url': 'localhost:5432', 'debug': True}
update_settings(config)
read_settings(config)  # Output: {'database_url': 'localhost:5432', 'debug': False}

By explicitly passing the config object, we make dependencies clear, reduce tight coupling, and make the code easier to test and maintain.

Tight coupling

Let's take the same example of ConfigManager. All components in the application directly depend on ConfigManager for configuration data, making them tightly coupled to this singleton. This coupling makes it harder to modify or replace ConfigManager without affecting the entire system.

Scalability concerns

Scalability issues with the singleton pattern arise mainly because it enforces a single shared instance across an application, leading to problems in a distributed system or a system that requires concurrency. Consider an application using the singleton pattern for managing database connections through a DatabaseConnectionManager class. This class ensures that there is only one global database connection throughout the application, which seems efficient at first glance. However, as the application scales and the number of simultaneous user requests increases, several problems can emerge. For instance, as the number of concurrent requests increases, the single connection becomes a bottleneck. Requests may start to queue up, waiting for their turn to use the connection, leading to increased response times and degraded application performance.

Testing difficulties

Because singletons are accessible throughout the application, isolating tests to check individual components can be challenging. When tests run, they may inadvertently alter the singleton's state, which could affect other tests. - Global state and side effects: The Logger instance maintains a global state through its logs. When multiple tests log different messages, they can interfere with each other. For example, one test might expect the logs to contain specific messages, but if tests are run in parallel, previous test cases might have left residual data, leading to unpredictable results. - Difficulty in mocking: The difficulty in mocking singleton instances in unit tests is a common challenge that stems from the pattern's design, which ensures a class has only one instance throughout the application's lifecycle. This design makes it hard to replace a singleton with a mock object during testing.

Unpredictable behavior

Since any part of the application can modify the global state, tracking down who changed the state can become difficult. For example, if one component changes a setting in ConfigManager, it might have unintended side effects on other parts of the system that rely on that setting, leading to bugs that are hard to diagnose and fix immediately.


Notes and references

  1. 1. Design patterns: In software engineering, a design pattern is a general, reusable solution to a commonly occurring problem in software design. It’s not a fully finished design that can be put into source code right away. Instead, it serves as a description, model, or template for problem-solving that may be used in a variety of situations.
  2. 2. Global access: Singleton provides a global access point to that instance, making it easily accessible from any point in the application without passing the object around.