Ok, I must admit at the beginning that this topic is a bit of a clickbait – but if you find it cheating, I have to write in my defense that it was in good faith.
If you were starting to write a book, it wouldn’t cross your mind to ask “what are the top plot elements that I should learn to be able to create an interesting story?” because you need as much context and life experience as you can assemble.
Gangs of Four
The book “Design Patterns: Elements of Reusable Object-Oriented Software” (by Erich Gamma, Richard Helm, Ralph Johnson, John Vlissides), thanks to which design patterns gained popularity in computer science, isn’t about telling you the best ways to do things.
It’s about teaching your brain to pick up patterns that can be applied to existing code — to give you the highest leverage as a developer.
It’s a huge toolbox of tools and some of them are used more often than others, but the fact that a tool is frequently used does not mean that you should use it for all your work.
Instead, you should learn as many patterns as possible – to be able to choose the right one when you notice the possibility of its use.
The book Gangs of Four (that’s what it is called in the industry) is primarily about patterns for Java, and to a lesser extent for C++, but here we are writing in a different language, Python, so in this short article I chose a few design patterns from each category (according to the originally proposed classification) that I found interesting in the context of Python programming.
I sincerely hope that it will inspire you to learn more about this issue on your own, and who knows, maybe there will be more similar articles on the Finxter website in the future.
Whatโs a Software Design Pattern?
In software design a design pattern is a general, reusable solution to a commonly occurring problem within a given context.
They are like pre-made blueprints that you can customize to solve a problem in your code.
It is not possible to apply a design pattern just like you would use a function from a newly imported library (the pattern is not a code snippet, but a general concept that describes how to solve a specific recurring problem).
Instead, you should follow the pattern details and implement a solution that suits the requirements of your program.
Classification of Design Patterns
Initially, there were two basic classifications of design patterns – based on what problem the pattern solves, and based on whether the pattern concerns classes or objects. Taking into account the first classification, the patterns can be divided into three groups:
- Creational – provide the capability to create, initialize and configure objects, classes, and data types based on a required criterion and in a controlled way.
- Structural – help to organize structures of related objects and classes, providing new functionalities.
- Behavioral – are about identifying common communication patterns between objects.
Later, new design patterns appeared, from which another category can be distinguished:
- Concurrency – those types of design patterns that deal with the multi-threaded programming paradigm.
Pattern 1: Singleton
The Singleton is a creational pattern the purpose of which is to limit the possibility of creating objects of a given class to one instance and to ensure global access to the created object.
Use Cases
- A class in your program has only a single instance available to all clients such as a single database object shared by different parts of the program.
- You need stricter control over global variables.
Code examples
First naive approach
class Logger: @staticmethod def get_instance(): if '_instance' not in Logger.__dict__: Logger._instance = Logger() return Logger._instance def write_log(self, path): pass if __name__ == "__main__": s1 = Logger.get_instance() s2 = Logger.get_instance() assert s1 is s2
What is wrong with this code?
It violates the single responsibility principle and has non-standard class access (you must remember to access instances of the class only by the get_instance()
method) – we try to fix these problems in another code example.
class Singleton: _instances = {} def __new__(cls, *args, **kwargs): if cls not in cls._instances: instance = super().__new__(cls) cls._instances[cls] = instance return cls._instances[cls] class Logger(Singleton): def write_log(self, path): pass if __name__ == "__main__": logger1 = Logger() logger2 = Logger() assert logger1 is logger2
So the problems from the previous example have been addressed, but can we take a better approach (without inheritance)?
Let’s try.
class Singleton(type): _instances = {} def __call__(cls, *args, **kwargs): if cls not in cls._instances: instance = super().__call__(*args, **kwargs) cls._instances[cls] = instance return cls._instances[cls] class Logger(metaclass=Singleton): def write_log(self, path): pass if __name__ == "__main__": logger1 = Logger() logger2 = Logger() assert logger1 is logger2
Great, it works, but we should make one more adjustment – prepare our program to run in a multi-threaded environment.
from threading import Lock, Thread class Singleton(type): _instances = {} _lock: Lock = Lock() def __call__(cls, *args, **kwargs): with cls._lock: if cls not in cls._instances: instance = super().__call__(*args, **kwargs) cls._instances[cls] = instance return cls._instances[cls] class Logger(metaclass=Singleton): def __init__(self, name): self.name = name def write_log(self, path): pass def test_logger(name): logger = Logger(name) print(logger.name) if __name__ == "__main__": process1 = Thread(target=test_logger, args=("FOO",)) process2 = Thread(target=test_logger, args=("BAR",)) process1.start() process2.start()
Output:
FOO FOO
Both processes called constructors with two different parameters, but only one instance of the Logger
class was created – our hard work is finally over!
Consequences
- You know that a class has only a single instance;
- You gain a global access point to that instance;
- The singleton is initialized only when requested for the first time;
- Masks bad design to a certain point. For example, when the components of the program know too much about each other. Consequently, many consider it as an anti-pattern.
Sources
- Dive Into Design Patterns by Alexander Shvets
- Python Design Patterns Playbook by Gerald Britton (from Pluralsight)
Pattern 2: Decorator
The Decorator is a structural pattern the purpose of which is to provide new functionalities to classes/objects at runtime (unlike inheritance, which allows you to achieve a similar effect, but at the compilation time).
The decorator is most often an abstract class that takes an object in the constructor, the functionality of which we want to extend — but in Python, there is also a built-in decorator mechanism that we can use.
Use Cases
- You want to assign additional responsibilities to objects at runtime without breaking the code using these objects;
- You cannot extend an object’s responsibilities through inheritance for some reason.
Code examples
Using decorators, you can wrap objects multiple times because both the target and the decorators implement the same interface.
The resulting object will have the combined and stacked functionality of all wrappers.
from abc import ABC, abstractmethod class Component(ABC): @abstractmethod def operation(self): pass class ConcreteComponent(Component): def operation(self): return "ConcreteComponent" class Decorator(Component): def __init__(self, component): self.component = component @abstractmethod def operation(self): pass class ConcreteDecoratorA(Decorator): def operation(self): return f"ConcreteDecoratorA({self.component.operation()})" class ConcreteDecoratorB(Decorator): def operation(self): return f"ConcreteDecoratorB({self.component.operation()})" if __name__ == "__main__": concreteComponent = ConcreteComponent() print(concreteComponent.operation()) decoratorA = ConcreteDecoratorA(concreteComponent) decoratorB = ConcreteDecoratorB(decoratorA) print(decoratorB.operation())
Output:
ConcreteComponent ConcreteDecoratorB(ConcreteDecoratorA(ConcreteComponent))
And a slightly more practical example using the built-in decorator mechanism.
import sys def memoize(f): cache = dict() def wrapper(x): if x not in cache: cache[x] = f(x) return cache[x] return wrapper @memoize def fib(n): if n <= 1: return n else: return fib(n - 1) + fib(n - 2) if __name__ == "__main__": sys.setrecursionlimit(2000) print(fib(750))
Output:
2461757021582324272166248155313036893697139996697461509576233211000055607912198979704988704446425834042795269603588522245550271050495783935904220352228801000
Without using the cache decorator for the function (that recursively computes the n-th term of the Fibonacci series), we probably would not have computed a result for value 100 in our lifetime.
Consequences
- Extend the behavior of an object without creating a subclass;
- Add or remove object responsibilities at runtime;
- Combine multiple behaviors by applying multiple decorators to an object;
- Divide a monolithic class that implements many variants of behavior into smaller classes;
- It’s difficult to take one particular wrapper from the center of the wrappers stack;
- It’s difficult to implement a decorator in such a way that its behavior does not depend on the order in which the wrapper is stacked.
Sources
- Dive Into Design Patterns by Alexander Shvets
- Python. Kurs video. Wzorce czynnoลciowe i architektoniczne oraz antywzorce byย Karol Kurek
Pattern 3: Iterator
Iterator is a behavioral pattern the purpose of which is to allow you to traverse elements of a collection without exposing its underlying representation.
To implement your iterator in Python, we have two possible options:
- Implement the
__iter__
and__next__
special methods in the class. - Use generators.
Use Cases
- The collection has a complicated structure and you want to hide it from the client for convenience or security reasons;
- You want to reduce duplication of the traversal code across your app;
- You want your code to be able to traverse elements of different data structures or when you don’t know the details of their structure in advance.
Code Examples
In the example below, we will see how we can create a custom collection with an alphabetical order iterator.
from collections.abc import Iterator, Iterable class AlphabeticalOrderIterator(Iterator): _position: int = None _reverse: bool = False def __init__(self, collection, reverse=False): self._collection = sorted(collection) self._reverse = reverse self._position = -1 if reverse else 0 def __next__(self): try: value = self._collection[self._position] self._position += -1 if self._reverse else 1 except IndexError: raise StopIteration() return value class WordsCollection(Iterable): def __init__(self, collection): self._collection = collection def __iter__(self): return AlphabeticalOrderIterator(self._collection) def get_reverse_iterator(self): return AlphabeticalOrderIterator(self._collection, True) if __name__ == "__main__": wordsCollection = WordsCollection(["Third", "First", "Second"]) print(list(wordsCollection)) print(list(wordsCollection.get_reverse_iterator()))
Output:
['First', 'Second', 'Third'] ['Third', 'Second', 'First']
The next example is for a generator, which is a special kind of function that can be paused and resumed from where it was paused.
Based on the stored state, it is possible to return different values during subsequent calls of the generator.
def prime_generator(): yield 2 primes = [2] to_check = 3 while True: sqrt = to_check ** 0.5 is_prime = True for prime in primes: if prime > sqrt: break if to_check % prime == 0: is_prime = False break if is_prime: primes.append(to_check) yield to_check to_check += 2 generator = prime_generator() print([next(generator) for _ in range(20)])
Output:
[2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47, 53, 59, 61, 67, 71]
Consequences
- You can clean up the client code and collections by extracting the traversal code into separate classes;
- You can implement new collection types and iterators and pass them into existing code without breaking anything;
- You can iterate the same collection with multiple iterators in parallel because each of them stores information about its iteration state;
- For this reason, you can delay the iteration and continue it as needed;
- The use of this pattern will be overkill if your application only works with simple collections;
- Using an iterator may be less efficient than traversing directly through the items of some specialized collection.
Sources
- Dive Into Design Patterns by Alexander Shvets
- Python. Kurs video. Kreacyjne i strukturalne wzorce projektowe byย Karol Kurek
Conclusion
The bottom line is even if you never encounter problems that are solved by the design patterns mentioned in the article, knowing patterns is still useful because it teaches you to solve problems using principles of object-oriented design.