Design Patterns in Python
Today, we'll delve into design patterns in Python. Design patterns are standard solutions to common problems in software design. They are tried-and-tested templates that you can use to efficiently address recurring issues with standardized solutions.
The concept of design patterns was introduced in 1995 by the Gang of Four (Erich Gamma, Richard Helm, Ralph Johnson, John Vlissides) with the publication of the book "Design Patterns: Elements of Reusable Object-Oriented Software." In this context, a "pattern" describes a recurring problem and a solution that can be applied in various scenarios.
Each pattern typically consists of:
- a name
- a description of the problem
- a solution
- an analysis of its effects.
Besides patterns, there are also anti-patterns, which identify solutions to problems that should be avoided due to their negative consequences.
For instance, the common practice of reusing code through copy-and-paste is a typical anti-pattern. This practice should be avoided because it makes the code harder to maintain and increases the risk of errors over time.
Here are some practical examples of design patterns.
Singleton
The Singleton pattern ensures that a class has only one instance and provides a global point of access to that instance.
It's useful when you need only one instance of a class, such as a logger or a database connection.
class Singleton:
_instance = None
def __new__(cls, *args, **kwargs):
if not cls._instance:
cls._instance = super(Singleton, cls).__new__(cls, *args, **kwargs)
return cls._instance
In this example, the `Singleton` class has a class attribute `_instance` which is initially set to `None`.
The special method `__new__` checks if `_instance` is `None` and if so, creates a new instance of the class; otherwise, it returns the already created instance.
This ensures that no matter how many times you instantiate the class, the same instance is always returned.
For example, create two instances `s1` and `s2` of the class:
s1 = Singleton()
s2 = Singleton()
Both variables point to the same instance of the `Singleton` class.
print(s1 is s2)
True
Factory
The Factory pattern is used to create objects without specifying the exact class of the object that will be created. It's useful when the creation process is complex or when the exact class of the object isn't known until runtime.
class Car:
def drive(self):
return "Driving a car!"
class Bike:
def drive(self):
return "Riding a bike!"
class VehicleFactory:
def create_vehicle(self, vehicle_type):
if vehicle_type == "car":
return Car()
elif vehicle_type == "bike":
return Bike()
else:
raise ValueError("Unknown vehicle type")
This code defines two classes, `Car` and `Bike`, both with a `drive` method that returns a descriptive string of the action.
The `VehicleFactory` class has a `create_vehicle` method that takes a `vehicle_type` parameter and returns an instance of the corresponding class (`Car` or `Bike`), or raises an exception if the vehicle type is unknown.
This way, you can use the `VehicleFactory` to create and use `Car` and `Bike` objects based on the requested type.
factory = VehicleFactory()
For example, you can create a `Car` object:
car = factory.create_vehicle("car")
print(car.drive())
Driving a car!
With the same method, you can also create a `Bike` object:
bike = factory.create_vehicle("bike")
print(bike.drive())
Riding a bike!
In short, this code demonstrates how to use the Factory Pattern to instantiate objects of different classes in a flexible and centralized manner.
Observer
The Observer pattern defines a one-to-many dependency between objects so that when one object changes state, all its dependents are notified and updated automatically. It's useful for implementing notification systems.
class Subject:
def __init__(self):
self._observers = []
def attach(self, observer):
self._observers.append(observer)
def detach(self, observer):
self._observers.remove(observer)
def notify(self, message):
for observer in self._observers:
observer.update(message)
class Observer:
def update(self, message):
pass
class ConcreteObserver(Observer):
def update(self, message):
print(f"Received message: {message}")
For example, create a Subject object, which will be the object being observed. This object maintains a list of observers that will be notified when a change occurs.
subject = Subject()
Create two instances of ConcreteObserver, observer1 and observer2. These are the observers that will be notified of changes in the Subject.
observer1 = ConcreteObserver()
observer2 = ConcreteObserver()
Add observer1 and observer2 to the Subject's list of observers.
Now, the subject knows it must notify these observers when a change occurs.
subject.attach(observer1)
subject.attach(observer2)
Call the Subject's notify method, which iterates through all registered observers (in this case, observer1 and observer2) and calls their update method, passing the message "Hello, observers!".
subject.notify("Hello, observers!")
Each observer (observer1 and observer2) receives the message and prints "Received message: Hello, observers!".
So, the output of this code will be:
Received message: Hello, observers!
Received message: Hello, observers!
This example shows how a Subject can notify multiple observers of a change, and the observers react to the change by receiving and printing the message.
Decorator
The Decorator pattern allows you to dynamically add behavior to an object. It's useful for extending the functionality of classes without modifying them directly.
class Coffee:
def cost(self):
return 5
class MilkDecorator:
def __init__(self,
coffee):
self._coffee = coffee
def cost(self):
return self._coffee.cost() + 1
class SugarDecorator:
def __init__(self, coffee):
self._coffee = coffee
def cost(self):
return self._coffee.cost() + 0.5
This code defines three classes:
- The Coffee class represents a simple coffee with a fixed cost of 5.
- The MilkDecorator class is a decorator that adds milk to the coffee. It takes a Coffee object (or a decorator of Coffee) and adds 1 to its cost.
- The SugarDecorator class is another decorator that adds sugar to the coffee. It takes a Coffee object (or a decorator of Coffee) and adds 0.5 to its cost.
For example, create a "Coffee" object:
coffee = Coffee()
The base cost of the coffee is 5:
print(coffee.cost())
5
Now, add milk to the coffee using the MilkDecorator class:
milk_coffee = MilkDecorator(coffee)
Adding milk increases the cost of the coffee by 1:
print(milk_coffee.cost())
6
Next, add sugar to the milk coffee using the SugarDecorator:
milk_sugar_coffee = SugarDecorator(milk_coffee)
The cost of the coffee further increases by 0.5:
print(milk_sugar_coffee.cost())
6.5
In summary, the Decorator pattern allows you to add functionality (like milk or sugar) to a Coffee object in a flexible and incremental manner, without modifying the Coffee class directly.
Strategy
The Strategy pattern allows you to define a family of algorithms, encapsulate them, and make them interchangeable. It's useful for selecting the appropriate algorithm at runtime.
class Strategy:
def execute(self, a, b):
pass
class AddStrategy(Strategy):
def execute(self, a, b):
return a + b
class SubtractStrategy(Strategy):
def execute(self, a, b):
return a - b
class Context:
def __init__(self, strategy):
self._strategy = strategy
def set_strategy(self, strategy):
self._strategy = strategy
def execute_strategy(self, a, b):
return self._strategy.execute(a, b)
This example implements the Strategy pattern, which allows you to define a family of algorithms, encapsulate them, and make them interchangeable. Let's look at the code in detail:
For example, create a "Context" object with AddStrategy as the initial strategy:
context = Context(AddStrategy())
If you now call the method context.execute_strategy(5, 3), it executes the execute method of AddStrategy, returning the sum of 5 and 3:
print(context.execute_strategy(5, 3))
8
Next, change the strategy to SubtractStrategy using set_strategy:
context.set_strategy(SubtractStrategy())
If you now call the method context.execute_strategy(5, 3), it executes the execute method of SubtractStrategy, returning the difference between 5 and 3:
print(context.execute_strategy(5, 3))
2
In summary, the Strategy pattern allows you to dynamically change the algorithm used by a Context object without modifying the context itself. This makes the code more flexible and maintainable.
These are just a few of the most common and useful design patterns in Python. Each pattern has its ideal use cases and can help you write more maintainable, reusable, and robust code.
I hope this guide has been helpful and that it enhances your understanding of how and when to use design patterns!