refactoring with singleton
- References :
- Questions :
- Notes:
Welcome to tale of me writing a bad code and refactoring it with new learned knowledge to write not so bad code.
I was watching a conference video 1 on metaclasses, which introduced me to singleton. I realized that i could use this creational design pattern 1.1 for one of my project.
Previous Logic in my project
There is a factory which processes list of Box
object to form a final Box
. Names have been changed for their privacy.
I had a state ProcessState
keeping tracking of whole process and is attached to all the Box
which are being processed in this Factory. Box
dumps data in ProcessState
which can later be used in preparing the final box. In simple terms ProcessState
is a global variable on which different Box
dump their data.
from typing import Tuple, Optional, Dict
from dataclasses import dataclass, field
@dataclass
class ProcessState:
box_type: str
state: Dict = field(default_factory=dict)
box_count: int = 0
log: str = ""
state = ProcessState(box_type="generic")
print(state)
ProcessState(box_type='generic', state={}, box_count=0, log='')
@dataclass
class Box:
color: str = "white"
size: Tuple[int, int] = (0, 0)
pattern: str = "flower"
state: Optional[ProcessState] = None
box = Box()
box
Box(color='white', size=(0, 0), pattern='flower', state=None)
Creation of boxes
raw_boxes = [
Box(),
Box(color='pink', size=(1,1), pattern="bird"),
Box(color='green', size=(3, 3), pattern="snake"),
Box(color='purple', size=(2, 4), pattern="dust")
]
for box in raw_boxes:
box.state = state
state.box_count += 1
print(boxes[0])
print(state)
On this list of boxes a list of processor would work on to create a final box. Each processor would have their own business logic which modifies the passed box or creates new box.
def remove_snake_pattern(box: Box) -> Box:
"""
A business logic processor that modifies and creates a new box
Removes snake patterns and replaces with default box attribute
"""
if box.pattern == "snake":
new_box = Box()
box.state.log += "Cleaned snake pattern box\n"
box.state.state.update({'snake': 'seen'})
else:
return box
new_box.size = box.size
new_box.state = box.state # make sure you sync up the state properly
return new_box
remove_snake_pattern
<function remove_snake_pattern at 0x7fae4f2b6840>
Now lets call this processor for our raw boxes
processors = [remove_snake_pattern]
processed_box = []
for box in raw_boxes:
print(f"for box {box.pattern}")
for processor in processors:
print(f"State id before Process {id(box.state)}")
box = processor(box)
print(f"State id after Process {id(box.state)}")
processed_box.append(box)
print(state)
for box flower
State id before Process 140386633097680
State id after Process 140386633097680
for box bird
State id before Process 140386633097680
State id after Process 140386633097680
for box snake
State id before Process 140386633097680
State id after Process 140386633097680
for box dust
State id before Process 140386633097680
State id after Process 140386633097680
ProcessState(box_type='generic', state={'snake': 'seen'}, box_count=4, log='Cleaned snake pattern box\n')
It was bugging me that i had to use an extra line to make sure that i attach or assign the ProcessState
to all Box
object explicitly. It felt redundant and required me to explicitly mention why it was done. In addition to that we need to make sure that the new document state object is not created and attached during the processing flow.
Implementing Singleton
To cure my above itch and reduce that extra work of making sure that i attach the state when moving from box to box or making sure that only a single ProcessState
object is created i thought of introducing Singleton
pattern to my code-base.
To implement the singleton pattern i took the reference from this 2 stackoverflow thread
class Singleton(type):
"""
A metaclass that creates a Singleton base class when called.
Read more here:
https://stackoverflow.com/questions/6760685/what-is-the-best-way-of-implementing-singleton-in-python
Also, Destroying a Singleton object in Python
https://stackoverflow.com/questions/43619748/destroying-a-singleton-object-in-python
https://stackoverflow.com/questions/63985051/how-to-reset-a-singleton-instance-in-python-in-this-case
"""
_instances = {}
def __call__(cls, *args, **kwargs):
if cls not in cls._instances:
instance = super(Singleton, cls).__call__(*args, **kwargs)
cls._instances[cls] = instance
return cls._instances[cls]
def clear(cls):
cls._instances = {}
@dataclass
class ProcessStateV2(metaclass=Singleton):
box_type: str
state: Dict = field(default_factory=dict)
box_count: int = 0
log: str = ""
state = ProcessStateV2(box_type="generic")
print(state)
print(id(state))
ProcessStateV2(box_type='generic', state={}, box_count=0, log='')
140386634458256
Based on this new ProcessState
class definition we need to slightly modify our definition of the Box
class to truly utilize the singleton design pattern
@dataclass
class BoxV2:
color: str = "white"
size: Tuple[int, int] = (0, 0)
pattern: str = "flower"
state: ProcessStateV2 = ProcessStateV2(box_type="generic")
box = BoxV2()
print(box)
print(id(box.state))
Why Initialize the ProcessState on the Box Object Initialization Now?
- It was done to utilize the singleton pattern. For now on, any
Box
object we create will only have a singleProcesState
object to refer to.
raw_boxes = [
BoxV2(),
BoxV2(color='pink', size=(1,1), pattern="bird"),
BoxV2(color='green', size=(3, 3), pattern="snake"),
BoxV2(color='purple', size=(2, 4), pattern="dust")
]
for box in raw_boxes:
state.box_count += 1
print(id(box.state))
print(boxes[0])
print(state)
Lets also refactor the processor to utilize new definition.
def remove_snake_pattern_v2(box: BoxV2) -> BoxV2:
"""
A business logic processor that modifies and creates a new box
Removes snake patterns and replaces with default box attribute
"""
if box.pattern == "snake":
new_box = BoxV2()
new_box.state.log += "Cleaned snake pattern box\n"
new_box.state.state.update({'snake': 'seen'})
else:
return box
new_box.size = box.size
return new_box
remove_snake_pattern_v2
In this refactored function we can see that we have removed the explicit assignment of `state` to the new Box
object. Now a dev can focus on the business logic and won’t have to remember to preserve the state or copy the state before modifying it. Here ProcessState
flows through the boxes as a global variable.
Now lets call this refactor processor for our raw boxes
processors = [remove_snake_pattern_v2]
processed_box = []
for box in raw_boxes:
print(f"for box {box.pattern}")
for processor in processors:
print(f"State id before Process {id(box.state)}")
box = processor(box)
print(f"State id after Process {id(box.state)}")
processed_box.append(box)
print(state)
And Done. This is how applied singleton to refactor for my project.
A pitfall to be aware of
Be aware that the Singleton pattern will restrict the ProcessState
class to have only a single object. When such an object is used in an API request, we need to ensure that it is cleaned or deleted after the request; otherwise, the same object will be reused.
How did I handle it in my API?
We implemented a method that clears out the class instance(s) created, i.e., ProcessStateV2.clear()
ProcessStateV2.clear()
state = ProcessStateV2(box_type="generic")
state.log += "new state created"
print(f"{id(state)} - {state}")
state2 = ProcessStateV2(box_type="new_generic")
print(f"{id(state2)} - {state2}")
ProcessStateV2.clear()
state3 = ProcessStateV2(box_type="cleaned")
print(f"{id(state3)} - {state3}")
ProcessStateV2.clear()
140386629460816 - ProcessStateV2(box_type='generic', state={}, box_count=0, log='new state created')
140386629460816 - ProcessStateV2(box_type='generic', state={}, box_count=0, log='new state created')
140386629256528 - ProcessStateV2(box_type='cleaned', state={}, box_count=0, log='')
P.S
- Do go through 2 and 1.2
- There might be better way to handle this of-course but as of now i could only think of this solution
All this changes resulted in adding more lines of code then removing (refactoring i guess).