[ad_1]
Introduction
We’ve previously delved into Structural and Creational design patterns, and this section focuses on another vital category – Behavioral Design Patterns.
Behavioral patterns are all about the communication between objects. They address the responsibilities of objects and how they communicate, ensuring that objects collaborate effectively while remaining loosely coupled. This loose coupling is crucial as it promotes flexibility in the system, allowing for easier maintenance and scalability.
Note: Loose coupling is a design principle that promotes the independence of system components, ensuring that individual modules or classes have minimal knowledge of the inner workings of other modules or classes. By adhering to this principle, changes in one module have minimal to no impact on others, making the system more maintainable, scalable, and flexible.
This means that you should design your classes, functions, and modules so that they rely less on the specifics of other classes, functions, or modules. Instead, they should rely on abstractions or interfaces.
In contrast to Structural patterns, which focus on how objects are composed, or Creational patterns, which deal with object creation mechanisms, Behavioral patterns shine a light on the dynamic interactions among objects.
The design patterns covered in this section are:
Chain of Responsibility Design Pattern
Imagine you’re developing a customer support system for a large e-commerce platform. Customers can raise various types of issues, from payment problems to shipping inquiries. Not all support agents can handle every type of issue. Some agents specialize in refunds, others in technical problems, and so on. When a customer raises an issue, how do you ensure it reaches the right agent without hardcoding a complex decision-making structure?
In our code, this could look like a series of nested if-else statements, checking the type of issue and then directing it to the appropriate agent. But this approach quickly becomes unwieldy as more types of issues and specialists are added to the system.
def handle_issue(issue_type, issue_details):
if issue_type == "payment":
elif issue_type == "shipping":
The Chain of Responsibility pattern offers an elegant solution to this problem. It decouples the sender (in this case, the customer’s issue) from its receivers (the support agents) by allowing multiple objects to process the request. These objects are linked in a chain, and the request travels along the chain until it’s processed or reaches the end.
In our support system, each agent represents a link in the chain. An agent either handles the issue or passes it to the next agent in line.
class SupportAgent:
def __init__(self, specialty, next_agent=None):
self.specialty = specialty
self.next_agent = next_agent
def handle_issue(self, issue_type, issue_details):
if issue_type == self.specialty:
print(f"Handled {issue_type} issue by {self.specialty} specialist.")
elif self.next_agent:
self.next_agent.handle_issue(issue_type, issue_details)
else:
print("Issue couldn't be handled.")
Let’s test this out by creating one payment agent and one shipping agent. Then, we’ll pass the payment issue to the shipping agent and observe what happens:
payment_agent = SupportAgent("payment")
shipping_agent = SupportAgent("shipping", payment_agent)
shipping_agent.handle_issue("payment", "Payment declined.")
Because of the Chain of Responsibility pattern we implemented here, the shipping agent passes the issue to the payment agent, who handles it:
Handled payment issue by payment specialist.
With the Chain of Responsibility pattern, our system becomes more flexible. As the support team grows and new specialties emerge, we can easily extend the chain without altering the existing code structure.
Command Design Pattern
Consider you’re building a smart home system where users can control various devices like lights, thermostats, and music players through a central interface. As the system evolves, you’ll be adding more devices and functionalities. A naive approach might involve creating a separate method for each action on every device. However, this can quickly become a maintenance nightmare as the number of devices and actions grows.
For instance, turning on a light might look like this:
class SmartHome:
def turn_on_light(self):
Now, imagine adding methods for turning off the light, adjusting the thermostat, playing music, and so on. The class becomes too bulky, and any change in one method might risk affecting others.
The Command pattern comes to the rescue in such scenarios. It encapsulates a request as an object, thereby allowing users to parameterize clients with different requests, queue requests, and support undoable operations. In essence, it separates the object that invokes the command from the object that knows how to execute it.
To implement this, we define a command interface with an execute()
method. Each device action becomes a concrete command implementing this interface. The smart home system merely invokes the execute()
method without needing to know the specifics of the action:
from abc import ABC, abstractmethod
class Command(ABC):
@abstractmethod
def execute(self):
pass
class LightOnCommand(Command):
def __init__(self, light):
self.light = light
def execute(self):
self.light.turn_on()
class Light:
def turn_on(self):
print("Light is ON")
class SmartHome:
def __init__(self, command):
self.command = command
def press_button(self):
self.command.execute()
To test this out, let’s create a light, a corresponding command for turning the light on, and a smart home object designed to turn on the light. To turn on the light, you just need to invoke the press_button()
method of the home
object, you don’t need to know what it actually does under the hood:
light = Light()
light_on = LightOnCommand(light)
home = SmartHome(light_on)
home.press_button()
Running this will give you:
Light is ON
The Command pattern helps you add new devices or actions. Each new action is a new command class, ensuring the system remains modular and easy to maintain.
Iterator Design Pattern
Imagine you’re developing a custom data structure, say a unique type of collection for storing books in a library system. Users of this collection should be able to traverse through the books without needing to understand the underlying storage mechanism. A straightforward approach might expose the internal structure of the collection, but this could lead to tight coupling and potential misuse. For instance, if our custom collection is a list:
class BookCollection:
def __init__(self):
self.books = []
def add_book(self, book):
self.books.append(book)
To traverse the library you’d have to expose the internal books
list:
library = BookCollection()
library.add_book("The Great Gatsby")
for book in library.books:
print(book)
This is not a great practice! If we change the underlying storage mechanism in the future, all code that directly accesses books
will break.
The Iterator pattern provides a solution by offering a way to access the elements of an aggregate object sequentially without exposing its underlying representation. It encapsulates the iteration logic into a separate object.
To implement this in Python, we can make use of Python’s built-in iterator protocol (__iter__()
and __next__()
methods):
class BookCollection:
def __init__(self):
self._books = []
def add_book(self, book):
self._books.append(book)
def __iter__(self):
return BookIterator(self)
class BookIterator:
def __init__(self, book_collection):
self._book_collection = book_collection
self._index = 0
def __iter__(self):
return self
def __next__(self):
if self._index < len(self._book_collection._books):
book = self._book_collection._books[self._index]
self._index += 1
return book
raise StopIteration
Now, there’s no need to expose the internal representation of the library
when we’re iterating over it:
library = BookCollection()
library.add_book("The Great Gatsby")
for book in library:
print(book)
In this case, running the code will give you:
The Great Gatsby
With the Iterator pattern, the internal structure of
BookCollection
is hidden. Users can still traverse the collection seamlessly, and we retain the flexibility to change the internal storage mechanism without affecting the external code.
Mediator Design Pattern
Say you’re building a complex user interface (UI) for a software application. This UI has multiple components like buttons, text fields, and dropdown menus. These components need to interact with each other. For instance, selecting an option in a dropdown might enable or disable a button. A direct approach would involve each component knowing about and interacting directly with many other components. This leads to a web of dependencies, making the system hard to maintain and extend.
To illustrate this, imagine you are facing a simple scenario where a button should be enabled only when a text field has content:
class TextField:
def __init__(self):
self.content = ""
self.button = None
def set_content(self, content):
self.content = content
if self.content:
self.button.enable()
else:
self.button.disable()
class Button:
def enable(self):
print("Button enabled")
def disable(self):
print("Button disabled")
textfield = TextField()
button = Button()
textfield.button = button
Here, TextField
directly manipulates the Button
, leading to tight coupling. If we add more components, the interdependencies grow exponentially.
The Mediator pattern introduces a central object that encapsulates how a set of objects interact. This mediator promotes loose coupling by ensuring that instead of components referring to each other explicitly, they refer to the mediator, which handles the interaction logic.
Let’s refactor the above example using the Mediator pattern:
class Mediator:
def __init__(self):
self.textfield = TextField(self)
self.button = Button(self)
def notify(self, sender, event):
if sender == "textfield" and event == "content_changed":
if self.textfield.content:
self.button.enable()
else:
self.button.disable()
class TextField:
def __init__(self, mediator):
self.content = ""
self.mediator = mediator
def set_content(self, content):
self.content = content
self.mediator.notify("textfield", "content_changed")
class Button:
def __init__(self, mediator):
self.mediator = mediator
def enable(self):
print("Button enabled")
def disable(self):
print("Button disabled")
Now, you can use the Mediator
class to set the content of the text field:
ui_mediator = Mediator()
ui_mediator.textfield.set_content("Hello")
This will automatically notify the Button
class that it needs to enable the button, which it does:
Button enabled
The same applies every time you change the content, but, if you remove it altogether, the button will be disabled.
The Mediator pattern helps you keep the interaction logic centralized in the
Mediator
class. This makes the system easier to maintain and extend, as adding new components or changing interactions only requires modifications in the mediator, without touching individual components.
Memento Design Pattern
You’re developing a text editor. One of the essential features of such an application is the ability to undo changes. Users expect to revert their actions to a previous state seamlessly. Implementing this “undo” functionality might seem straightforward, but ensuring that the editor’s state is captured and restored without exposing its internal structure can be challenging. Consider a naive approach:
Check out our hands-on, practical guide to learning Git, with best-practices, industry-accepted standards, and included cheat sheet. Stop Googling Git commands and actually learn it!
class TextEditor:
def __init__(self):
self.content = ""
self.previous_content = ""
def write(self, text):
self.previous_content = self.content
self.content += text
def undo(self):
self.content = self.previous_content
This approach is limited – it only remembers the last state. If a user makes multiple changes, only the most recent one can be undone.
The Memento pattern provides a way to capture an object’s internal state such that it can be restored later, all without violating encapsulation. In the context of our text editor, each state of the content can be saved as a memento, and the editor can revert to any previous state using these mementos.
Now, let’s utilize the Memento pattern to save the changes made to a text. We’ll create a Memento
class that houses the state, and a getter method that you can use to access the saved state. On the other hand, we’ll implement the write()
method of the TextEditor
class so that it saves the current state before making any changes to the content:
class Memento:
def __init__(self, state):
self._state = state
def get_saved_state(self):
return self._state
class TextEditor:
def __init__(self):
self._content = ""
def write(self, text):
return Memento(self._content)
self._content += text
def restore(self, memento):
self._content = memento.get_saved_state()
def __str__(self):
return self._content
Let’s quickly run the code:
editor = TextEditor()
editor.write("Hello, ")
memento1 = editor.write("world!")
editor.write(" How are you?")
print(editor)
Here, we created the TextEditor
object, wrote some text to the text editor, then wrote some more text, and prompted the content from the text editor:
Hello, world! How are you?
But, since we saved the previous state in the memento1
variable, we can also undo the last change we made to the text – which is adding the "How are you?"
question at the end:
editor.restore(memento1)
print(editor)
This will give us the last state of the text editor, without the "How are you?"
part:
Hello, world!
With the Memento pattern, the
TextEditor
can save and restore its state without exposing its internal structure. This ensures encapsulation and provides a robust mechanism to implement features like undo and redo.
Observer Design Pattern
Imagine you’re building a weather monitoring application. This application has multiple display elements, such as a current conditions display, a statistics display, and a forecast display. Whenever the weather data (like temperature, humidity, or pressure) updates, all these displays need to be updated to reflect the latest data. A direct approach might involve the weather data object knowing about all the display elements and updating them explicitly. However, this leads to tight coupling, making the system inflexible and hard to extend. For instance, say the weather data updates like this:
class WeatherData:
def __init__(self):
self.temperature = 0
self.humidity = 0
self.pressure = 0
self.current_display = CurrentConditionsDisplay()
self.stats_display = StatisticsDisplay()
def measurements_changed(self):
self.current_display.update(self.temperature, self.humidity, self.pressure)
self.stats_display.update(self.temperature, self.humidity, self.pressure)
This approach is quite problematic. If we add a new display or remove an existing one, the WeatherData
class needs to be modified.
The Observer pattern provides a solution by defining a one-to-many dependency between objects so that when one object changes state, all its dependents are notified and updated automatically.
In our case, WeatherData
is the subject, and the displays are observers:
from abc import ABC, abstractmethod
class Observer(ABC):
@abstractmethod
def update(self, temperature, humidity, pressure):
pass
class Subject(ABC):
@abstractmethod
def register_observer(self, observer):
pass
@abstractmethod
def remove_observer(self, observer):
pass
@abstractmethod
def notify_observers(self):
pass
class WeatherData(Subject):
def __init__(self):
self.observers = []
self.temperature = 0
self.humidity = 0
self.pressure = 0
def register_observer(self, observer):
self.observers.append(observer)
def remove_observer(self, observer):
self.observers.remove(observer)
def notify_observers(self):
for observer in self.observers:
observer.update(self.temperature, self.humidity, self.pressure)
def measurements_changed(self):
self.notify_observers()
def set_measurements(self, temperature, humidity, pressure):
self.temperature = temperature
self.humidity = humidity
self.pressure = pressure
self.measurements_changed()
class CurrentConditionsDisplay(Observer):
def update(self, temperature, humidity, pressure):
print(f"Current conditions: {temperature}°C and {humidity}% humidity")
Let’s make a quick test for the example we created:
weather_data = WeatherData()
current_display = CurrentConditionsDisplay()
weather_data.register_observer(current_display)
weather_data.set_measurements(25, 65, 1012)
This will yield us with:
Current conditions: 25°C and 65% humidity
Here, the
WeatherData
class doesn’t need to know about specific display elements. It just notifies all registered observers when the data changes. This promotes loose coupling, making the system more modular and extensible.
State Design Pattern
State design patterns can come in handy when you’re developing a simple vending machine software. The vending machine has several states, such as “No Coin”, “Has Coin”, “Sold”, and “Empty”. Depending on its current state, the machine behaves differently when a user inserts a coin, requests a product, or asks for a refund. A straightforward approach might involve using a series of if-else
or `switch-case statements to handle these actions based on the current state. However, this can quickly become cumbersome, especially as the number of states and transitions grows:
class VendingMachine:
def __init__(self):
self.state = "No Coin"
def insert_coin(self):
if self.state == "No Coin":
self.state = "Has Coin"
elif self.state == "Has Coin":
print("Coin already inserted.")
The VendingMachine
class can easily become too cumbersome, and adding new states or modifying transitions becomes challenging.
The State pattern provides a solution by allowing an object to alter its behavior when its internal state changes. This pattern involves encapsulating state-specific behavior in separate classes, ensuring that each state class handles its own transitions and actions.
To implement the State pattern, you need to encapsulate each state transition and action in its respective state class:
from abc import ABC, abstractmethod
class State(ABC):
@abstractmethod
def insert_coin(self):
pass
@abstractmethod
def eject_coin(self):
pass
@abstractmethod
def dispense(self):
pass
class NoCoinState(State):
def insert_coin(self):
print("Coin accepted.")
return "Has Coin"
def eject_coin(self):
print("No coin to eject.")
return "No Coin"
def dispense(self):
print("Insert coin first.")
return "No Coin"
class HasCoinState(State):
def insert_coin(self):
print("Coin already inserted.")
return "Has Coin"
def eject_coin(self):
print("Coin returned.")
return "No Coin"
def dispense(self):
print("Product dispensed.")
return "No Coin"
class VendingMachine:
def __init__(self):
self.state = NoCoinState()
def insert_coin(self):
self.state = self.state.insert_coin()
def eject_coin(self):
self.state = self.state.eject_coin()
def dispense(self):
self.state = self.state.dispense()
To put all this into action, let’s simulate a simple vending machine that we’ll insert a coin into, then we’ll dispense the machine, and, finally, try to eject a coin from the dispensed machine:
machine = VendingMachine()
machine.insert_coin()
machine.dispense()
machine.eject_coin()
As you probably guessed, this will give you:
Coin accepted.
Product dispensed.
No coin to eject.
Strategy Design Pattern
To illustrate the Strategy Design Pattern, say you’re building an e-commerce platform where different types of discounts are applied to orders. There could be a “Festive Sale” discount, a “New User” discount, or even a “Loyalty Points” discount. A direct approach might involve using if-else
statements to apply these discounts based on the type. However, as the number of discount types grows, this method becomes unwieldy and hard to maintain:
class Order:
def __init__(self, total, discount_type):
self.total = total
self.discount_type = discount_type
def final_price(self):
if self.discount_type == "Festive Sale":
return self.total * 0.9
elif self.discount_type == "New User":
return self.total * 0.95
With this approach the Order
class becomes bloated, and adding new discount strategies or modifying existing ones becomes challenging.
The Strategy pattern provides a solution by defining a family of algorithms (in this case, discounts), encapsulating each one, and making them interchangeable. It lets the algorithm vary independently from clients that use it.
When using the Strategy pattern, you need to encapsulate each discount type in its respective strategy class. This makes the system more organized, modular, and easier to maintain or extend. Adding a new discount type simply involves creating a new strategy class without altering the existing code:
from abc import ABC, abstractmethod
class DiscountStrategy(ABC):
@abstractmethod
def apply_discount(self, total):
pass
class FestiveSaleDiscount(DiscountStrategy):
def apply_discount(self, total):
return total * 0.9
class NewUserDiscount(DiscountStrategy):
def apply_discount(self, total):
return total * 0.95
class Order:
def __init__(self, total, discount_strategy):
self.total = total
self.discount_strategy = discount_strategy
def final_price(self):
return self.discount_strategy.apply_discount(self.total)
Let’s test this out! We’ll create two orders, one with the festival sale discount and the other with the new user discount:
order1 = Order(100, FestiveSaleDiscount())
print(order1.final_price())
order2 = Order(100, NewUserDiscount())
print(order2.final_price())
Printing out order prices will give us 90.0
for the festival sale discounted order, and 95.0
for the order on which we applied the new user discount.
Visitor Design Pattern
In this section, you’re developing a computer graphics system that can render various shapes like circles, rectangles, and triangles. Now, you want to add functionality to compute the area of these shapes and later, perhaps, their perimeter. One approach would be to add these methods directly to the shape classes. However, this would violate the open/closed principle, as you’d be modifying existing classes every time you want to add new operations:
class Circle:
def __init__(self, radius):
self.radius = radius
def area(self):
As you add more operations or shapes, the classes become bloated, and the system becomes harder to maintain.
The Visitor pattern provides a solution by allowing you to add further operations to objects without having to modify them. It involves creating a visitor class for each operation that needs to be implemented on the elements.
With the Visitor pattern, adding a new operation (like computing the perimeter) would involve creating a new visitor class without altering the existing shape classes. This ensures that the system remains extensible and adheres to the open/closed principle. Let’s implement that:
from abc import ABC, abstractmethod
class Shape(ABC):
@abstractmethod
def accept(self, visitor):
pass
class Circle(Shape):
def __init__(self, radius):
self.radius = radius
def accept(self, visitor):
return visitor.visit_circle(self)
class Rectangle(Shape):
def __init__(self, width, height):
self.width = width
self.height = height
def accept(self, visitor):
return visitor.visit_rectangle(self)
class ShapeVisitor(ABC):
@abstractmethod
def visit_circle(self, circle):
pass
@abstractmethod
def visit_rectangle(self, rectangle):
pass
class AreaVisitor(ShapeVisitor):
def visit_circle(self, circle):
return 3.14 * circle.radius * circle.radius
def visit_rectangle(self, rectangle):
return rectangle.width * rectangle.height
And now, let’s use this to calculate the area of a circle and rectangle:
circle = Circle(5)
rectangle = Rectangle(4, 6)
area_visitor = AreaVisitor()
print(circle.accept(area_visitor))
print(rectangle.accept(area_visitor))
This will give us the correct areas of the circle and the rectangle, respectively:
78.5
24
Conclusion
Through the course of this article, we observed nine critical behavioral design patterns, each catering to specific challenges and scenarios commonly encountered in software design. These patterns, ranging from the Chain of Responsibility, that decentralizes request handling, to the Visitor pattern, which provides a mechanism to add new operations without altering existing classes, present robust solutions to foster modularity, flexibility, and maintainability in our applications.
It’s essential to remember that while design patterns offer tried and tested solutions to recurring problems, their judicious application is crucial. Overusing or misapplying them can sometimes introduce unnecessary complexity. Thus, always consider the specific needs of your project, and choose the pattern that aligns best with your problem statement.
[ad_2]
Source link