All the single(ton) ladies
Another day, another engineering design pattern.
Back at it again with the white vans! IYKYK.
This time around, we'll be looking at the singleton creational design pattern.
Note: if you want to read more about creational design patterns in general, check out a previous post: Factories are friends
When you only want a single instance of an object, reach for the singleton design pattern. Wait, why would you only want a single instance of an object? Let's look at the problem first.
The Problem
Let's say we created a basic API. The API returns some data to the end user and that's that. As a part of the application, we wanted to ensure that at least some logging is taking place. So, we create a logger class which is going to write logs out to a file for us to review later. It might look something like this.
When we initialize the logger, we open our log file. We then can utilize the log() method to write messages to the log file. Finally, we include a close message to close our logger.
Seems ok, right? At least, it has the basic functionality we are looking for to log in our API. Certainly, there could be some improvements, but, it'll get the job done.
Imagine some time goes by and we end up adding a new module to our API. In it, we create a new instance of the logger. What happens when we do so?
If we don't pass a different file name, a new file handler is opened for the same file, which at scale starts to introduce a number of problems:
- The most direct issue is with the file handle itself. Opening a file for writing consumes an operating system resource. If you have a class where every instance opens the same file, you end up with multiple independent file handles pointing to the same log file. This not only wastes memory for redundant objects but, more critically, it wastes the OS resources needed to manage those handles. The application is doing more I/O setup than necessary.
- A potentially more dangerous problem is I/O conflict. When multiple independent objects try to write to the same physical file simultaneously the operating system must serialize those writes, which can introduce performance bottlenecks (I/O constraint). In some less controlled environments, simultaneous writes can lead to data corruption, where log messages are interleaved or overwritten, making the logs useless.
Jeepers, sounds like we need a refactor.
The Singleton Pattern
The primary problem the singleton pattern solves is controlling resource consumption and ensuring coordinated state when a system inherently requires only one instance of a particular class.
Here's a breakdown of the two main issues it addresses:
1. Ensuring a Single Instance (The Core Requirement)
In many real-world and software scenarios, having more than one object of a certain type would be nonsensical, dangerous, or lead to incorrect behavior.
- If multiple objects are created, they might conflict with each other by managing the same physical resource (like a printer spooler) or holding inconsistent states for a critical piece of application data (like a configuration manager).
- The singleton pattern provides a mechanism to prevent direct instantiation and instead manages the single instance itself, returning the same object every time it is requested.
2. Providing a Global Access Point
While the problem could technically be solved by using a collection of static methods or variables, the singleton pattern goes one step further by providing the object-oriented benefits of a class (e.g., polymorphism, implementing interfaces) while ensuring global access.
The singleton pattern defines a well-known method that acts as the only gateway to the instance, making it accessible from anywhere in the application code.
The Solution
To follow the singleton pattern and give only a single access point into our class, we need to modify a dunder method (aka magic methods) of python classes, __new__, to ensure that we only ever create one instance of the class.
By the way, if you want to learn more about dunder methods, check out this Python Morsels post.
When you create a class, the __new__ constructor method is what gets called to create the instance of that class. In most cases, you should never need to modify the __new__ dunder method, but in our case, we care about creating new instances of our logger, so modifying it is a must.
Here's how we could modify our logger from before so that only a single instance of the logger is ever created.
You'll notice that by overwriting the __new__ method, we are checking to see if there's any instance of the class which already exists. If it does, we return the instance of that class. If it doesn't we create the new instance and initialize it by opening our log file.
Now, in all of the new modules we are creating for our API, we can call this logger and if it's instantiated elsewhere, we don't have the I/O issues from before!
To see this in action, feel free to check out the code here and run it locally. You'll see in the bad_logger.py example, multiple file handlers are used, but in the singleton_logger.py example, even though we attempt to create multiple instances of the logger, only one is ever used.
Happy coding! 😁