Written by Bhavuk Kalra✏️
In this post, we will explore how the Singleton design pattern is implemented in code and demonstrate one of its most common use cases: a Logger.
Why is a Logger a Singleton?
A logger should have a single instance that is shared across all files in an application. This ensures that all logs are stored in one place, making debugging and tracking events easier.
Project Overview
Let's consider a real-world scenario where we have two different files performing separate tasks, but both need to log information. To achieve this, we will create a Logger class that follows the Singleton pattern, ensuring that all logs are written to the same instance.
Project Structure
Our project will consist of three main files:
logger.py
– This file will contain the Singleton Logger class.user1.py
anduser2.py
– These files represent different parts of the application that will use the logger.main.py
– This file acts as the central server, callinguser1.py
anduser2.py
to execute their respective processes.
After setting up, the directory structure will look like this:
└── root/
├── user1.py
├── user2.py
├── logger.py
├── main.py
In the next steps, we will implement the Singleton pattern for our logger and see it in action.
Boilerplate Code
Now, let's set up the basic functionality of our project. We'll define the Logger class and ensure that all logs are recorded using a single instance.
Setting Up the Logger
We’ll start by defining the Logger class in logger.py
. This class will ensure that only one instance exists throughout the application.
📄 logger.py
class Logger:
def __init__(self):
print("Logger Instantiated")
def log(self, message: str):
print(message)
Using the Logger in Different Files
Both user1.py
and user2.py
will import and use the Logger
class to log messages corresponding to two different processes.
📄 user1.py
from logger import Logger
def doProcessingUser1():
loggerUser1 = Logger()
loggerUser1.log("Log from the first user")
📄 user2.py
from logger import Logger
def doProcessingUser2():
loggerUser1 = Logger()
loggerUser1.log("Log from the second user")
Entry Point:
📄 main.py
from user1 import doProcessingUser1
from user2 import doProcessingUser2
if __name__ == "__main__":
doProcessingUser1()
doProcessingUser2()
Lets run the main.py
file to check if our base setup is working.
Output
Logger Instantiated
Log from the first user
Logger Instantiated
Log from the second user
Logger Functionality 📝
For our Logger, we need to ensure:
🔹 Single Instance: Only one instance of the Logger class exists throughout the project.
🔹 Restricted Instantiation: Other processes must not create additional instances of the Logger.
With this setup, all logs will be managed by a single, central Logger instance, ensuring consistency and efficiency! 🚀
Tracking Logger Instances for Debugging 🧐
To ensure that our Singleton implementation is working correctly, we will track how many instances of the Logger class have been created. This will help us debug and verify that only one instance is used throughout the application.
How Will We Track Instances? 📊
🔹 Use a Static Variable – This will keep track of the number of instances created.
🔹 Why a Static Variable? – Unlike instance variables (which belong to a specific object), static variables belong to the class itself and are shared across all instances.
Implementing Instance Tracking 🔍
1️⃣ Add a static variable to count the number of instances created.
2️⃣Update the constructor to increment this counter whenever a new instance is attempted.
3️⃣ Log the instance count for verification.
With these changes, we’ll be able to confirm whether our Singleton pattern is correctly restricting multiple instances! 🚀
Since our Logger class should ideally have only one instance, we will implement functionality to print how many instances have been created. This will help us verify that the Singleton pattern is correctly enforced.
Updating the Codebase 🛠️
We implement a static counter belonging to a specific class and shared across all instances.
📄 logger.py
class Logger:
# private Static variable to track num of instances created
__numInstances = 0
# private:
def __init__(self):
Logger.__numInstances = Logger.__numInstances + 1
print("Logger Instantiated, Total number of instances - ", Logger.__numInstances)
def log(self, message: str):
print(message)
We have now introduced a static variable numInstances
to keep track of how many instances of the Logger class have been created across the codebase.
Lets run the main.py
to see if what we get
📄 output
Logger Instantiated, Current number of instances - 1
Log from the first user
Logger Instantiated, Current number of instances - 2
Log from the second user
which is an expected output as we are here creating two instances of Logger
class in two different files namely user1.py
and user2.py
.
Converting to a Singleton Design Pattern 🏗️
To implement the Singleton design pattern for our Logger, we need to ensure that only one instance of the class is created and shared across the entire application.
Steps to Implement Singleton ✅
1️⃣ Make the Constructor Private
- Prevents the creation of new instances directly.
2️⃣ Create a Static Instance
Stores the single instance of the class.
Ensures all users access the same instance.
3️⃣ Provide a Static Method for Access
This method will instantiate the static instance if it hasn’t been created yet.
All users will access the same instance through this method.
Implementation 🛠️
Making the constructor private 🔐
Since the constructor is private, no one can create multiple instances of the class. However, we still need at least one instance that other users can access, right?
What we can do is create a static function that returns the instance of the Logger class.
(Read that again if you have to so that you make sense of it)
Since Python is a very flexible language, we can't directly make a class constructor private like in C++ or Java.
Instead, we will use Name Mangling
to achieve this.
Using __new__
with Name Mangling
The __new__()
method is called before __init__()
and controls the instance creation.
By altering __new__
method we are going to restrict the access to the __init__
method. Raising an error every time a method tries to directly instantiate it.
class Logger:
# private Static variable to track num of instances created
__numInstances = 0
# private static variable to denote if instance was created
__loggerInstance = None
def __new__(cls):
# Private constructor using __new__ to prevent direct instantiation
raise Exception("Use getLlogger() to create an instance.")
def __init__(self):
Logger.__numInstances = Logger.__numInstances + 1
print("Logger Instantiated, Total number of instances - ", Logger.__numInstances)
def log(self, message: str):
print(message)
@classmethod
def getLogger(cls):
# Returns the singleton instance, creates one if it doesn't exist
if cls.__loggerInstance is None:
# Bypass __new__ and directly instantiate the class
cls.__loggerInstance = super(Logger, cls).__new__(cls)
# Trigger __init__ manually on first creation
cls.__loggerInstance.__init__()
return cls.__loggerInstance
We use the @classmethod
decorator to pass the caller class (cls
) into the function. This makes our code safer in cases of inheritance and polymorphism.
Understanding super(Logger, cls)
🔄
super(Logger, cls)
returns a proxy object that allows access to the parent class methods.
🔹 Logger – The current class where we start the lookup.
🔹 cls
– The class whose instance we want to create.
🔹 This ensures that if we use inheritance, Python creates an instance of the correct class.
How super(Logger, cls).new(cls) Works ⚙️
🔹 __new__(cls)
is a special method that creates a new instance of a class before initializing it.
🔹 Python looks for the parent class of Logger
(which is object
).
🔹 It then calls object.__new__(cls)
, ensuring that an instance of cls (either Logger or a subclass) is created properly.
This approach keeps our Singleton pattern safe and flexible when used with inheritance!
📄 user1.py
from logger import Logger
def doProcessingUser1():
loggerUser1 = Logger.getLogger()
loggerUser1.log("Log from the first user")
📄 user2.py
from logger import Logger
def doProcessingUser2():
loggerUser1 = Logger.getLogger()
loggerUser1.log("Log from the second user")
Output
Logger Instantiated, Total number of instances - 1
Log from the first user
Log from the second user
🔍 Observe the Singleton in Action
Even with two getLogger()
calls, we still have only one instance of the Logger
class being used. ✅
🚫 What Happens if Someone Tries to Instantiate the Logger?
Now that we have restricted direct instantiation, let’s imagine a scenario where a user tries to create a new instance manually.
To test this, we will modify our code and see what happens when someone attempts to instantiate the Logger
class directly. 🛠️
📄 user1.py
from logger import Logger
def doProcessingUser1():
loggerUser1 = Logger()
loggerUser1.log("Log from the first user")
We get this error, which is our custom exception.
raise Exception("Use getLlogger() to create an instance.")
✅ First Step of the Singleton Pattern Completed!
But are we completely done? No! 🚫
⚠️ What’s the Next Challenge? (Hint: Multi-threading) 🧵
Our current implementation is not thread-safe.
🧐 What’s the Problem?
Imagine a scenario where two threads call getLogger()
at the same time:
🔹 Both threads check if __loggerInstance
is None
.
🔹 Since both see it as None
, they both try to create an instance.
🔹 As a result, two instances of Logger
are created— **breaking **our Singleton pattern! ❌
🔒 Next Step: Making the Code Thread-Safe
In the next part, we will:
✔️ Explore different approaches to handling multi-threading.
✔️ Identify corner cases to watch out for.
✔️ Learn how to use mutex locks efficiently to prevent race conditions.
Stay tuned as we enhance our Singleton implementation! 🚀
All this good stuff in the part 2 of this tiny two part series on Singleton Design Pattern
Top comments (0)