Behavioral Design Patterns in Python¶
What Are Behavioral Patterns?¶
Behavioral patterns define common communication patterns between objects and realize these patterns. By doing so, these patterns increase flexibility in carrying out communication and help make complex control flows more understandable.
The 10 Behavioral Design Patterns¶
1. Chain of Responsibility¶
Purpose: Pass requests along a chain of handlers. Each handler decides either to process the request or to pass it to the next handler in the chain.
Python Example:
from abc import ABC, abstractmethod
from typing import Optional
class Handler(ABC):
def __init__(self):
self._next_handler: Optional[Handler] = None
def set_next(self, handler: 'Handler') -> 'Handler':
self._next_handler = handler
return handler
@abstractmethod
def handle(self, request: str) -> Optional[str]:
if self._next_handler:
return self._next_handler.handle(request)
return None
class BasicSupportHandler(Handler):
def handle(self, request: str) -> Optional[str]:
if "password reset" in request.lower():
return f"BasicSupport: Handling '{request}'"
return super().handle(request)
class TechnicalSupportHandler(Handler):
def handle(self, request: str) -> Optional[str]:
if "bug" in request.lower():
return f"TechnicalSupport: Handling '{request}'"
return super().handle(request)
class ManagerHandler(Handler):
def handle(self, request: str) -> Optional[str]:
if "refund" in request.lower():
return f"Manager: Handling '{request}'"
return super().handle(request)
# Usage
basic = BasicSupportHandler()
technical = TechnicalSupportHandler()
manager = ManagerHandler()
basic.set_next(technical).set_next(manager)
print(basic.handle("I need a password reset"))
print(basic.handle("I found a bug"))
print(basic.handle("I want a refund"))
2. Command¶
Purpose: Encapsulate a request as an object, thereby letting you parameterize clients with different requests, queue or log requests, and support undoable operations.
Python Example:
from abc import ABC, abstractmethod
class Command(ABC):
@abstractmethod
def execute(self) -> None:
pass
@abstractmethod
def undo(self) -> None:
pass
class Light:
def turn_on(self):
print("Light is ON")
def turn_off(self):
print("Light is OFF")
class LightOnCommand(Command):
def __init__(self, light: Light):
self._light = light
def execute(self) -> None:
self._light.turn_on()
def undo(self) -> None:
self._light.turn_off()
class RemoteControl:
def __init__(self):
self._history = []
def execute_command(self, command: Command):
command.execute()
self._history.append(command)
def undo_last(self):
if self._history:
command = self._history.pop()
command.undo()
# Usage
light = Light()
light_on = LightOnCommand(light)
remote = RemoteControl()
remote.execute_command(light_on) # Light is ON
remote.undo_last() # Light is OFF
3. Iterator¶
Purpose: Provide a way to access elements of an aggregate object sequentially without exposing its underlying representation.
Python Example:
from collections.abc import Iterator, Iterable
class Book:
def __init__(self, title: str, author: str):
self.title = title
self.author = author
class BookCollection(Iterable):
def __init__(self):
self._books = []
def add_book(self, book: Book):
self._books.append(book)
def __iter__(self) -> Iterator:
return BookIterator(self._books)
class BookIterator(Iterator):
def __init__(self, books):
self._books = books
self._index = 0
def __next__(self) -> Book:
if self._index < len(self._books):
book = self._books[self._index]
self._index += 1
return book
raise StopIteration
# Usage
collection = BookCollection()
collection.add_book(Book("1984", "George Orwell"))
collection.add_book(Book("Brave New World", "Aldous Huxley"))
for book in collection:
print(f"{book.title} by {book.author}")
4. Mediator¶
Purpose: Define an object that encapsulates how a set of objects interact, promoting loose coupling.
Python Example:
from abc import ABC, abstractmethod
class Mediator(ABC):
@abstractmethod
def notify(self, sender: object, event: str) -> None:
pass
class ChatRoom(Mediator):
def __init__(self):
self._users = []
def register_user(self, user: 'User'):
self._users.append(user)
def notify(self, sender: 'User', event: str) -> None:
for user in self._users:
if user != sender:
user.receive(event)
class User:
def __init__(self, name: str, mediator: Mediator):
self._name = name
self._mediator = mediator
def send(self, message: str):
print(f"{self._name} sends: {message}")
self._mediator.notify(self, message)
def receive(self, message: str):
print(f"{self._name} receives: {message}")
# Usage
chat_room = ChatRoom()
alice = User("Alice", chat_room)
bob = User("Bob", chat_room)
chat_room.register_user(alice)
chat_room.register_user(bob)
alice.send("Hello, everyone!")
5. Memento¶
Purpose: Capture and externalize an object's internal state without violating encapsulation, allowing the object to be restored to this state later.
Python Example:
from typing import List
from datetime import datetime
class Memento:
def __init__(self, state: str):
self._state = state
self._timestamp = datetime.now()
def get_state(self) -> str:
return self._state
def get_timestamp(self) -> datetime:
return self._timestamp
class TextEditor:
def __init__(self):
self._content = ""
def write(self, text: str):
self._content += text
def save(self) -> Memento:
return Memento(self._content)
def restore(self, memento: Memento):
self._content = memento.get_state()
def show_content(self):
print(f"Content: {self._content}")
class History:
def __init__(self):
self._mementos: List[Memento] = []
def push(self, memento: Memento):
self._mementos.append(memento)
def pop(self) -> Memento:
return self._mementos.pop()
# Usage
editor = TextEditor()
history = History()
editor.write("Hello ")
history.push(editor.save())
editor.write("World!")
editor.show_content() # Hello World!
editor.restore(history.pop())
editor.show_content() # Hello
6. Observer¶
Purpose: Define a one-to-many dependency between objects so that when one object changes state, all its dependents are notified.
Python Example:
from abc import ABC, abstractmethod
from typing import List
class Observer(ABC):
@abstractmethod
def update(self, subject: 'Subject') -> None:
pass
class Subject:
def __init__(self):
self._observers: List[Observer] = []
self._state = None
def attach(self, observer: Observer):
self._observers.append(observer)
def detach(self, observer: Observer):
self._observers.remove(observer)
def notify(self):
for observer in self._observers:
observer.update(self)
def set_state(self, state):
self._state = state
self.notify()
def get_state(self):
return self._state
class EmailAlert(Observer):
def update(self, subject: Subject) -> None:
print(f"EmailAlert: State changed to {subject.get_state()}")
class SMSAlert(Observer):
def update(self, subject: Subject) -> None:
print(f"SMSAlert: State changed to {subject.get_state()}")
# Usage
subject = Subject()
email = EmailAlert()
sms = SMSAlert()
subject.attach(email)
subject.attach(sms)
subject.set_state("New Product Available!")
7. State¶
Purpose: Allow an object to alter its behavior when its internal state changes.
Python Example:
from abc import ABC, abstractmethod
class State(ABC):
@abstractmethod
def handle(self, context: 'Context') -> None:
pass
class DraftState(State):
def handle(self, context: 'Context') -> None:
print("Document is in Draft. Publishing...")
context.set_state(PublishedState())
class PublishedState(State):
def handle(self, context: 'Context') -> None:
print("Document is Published. Archiving...")
context.set_state(ArchivedState())
class ArchivedState(State):
def handle(self, context: 'Context') -> None:
print("Document is Archived. Cannot modify.")
class Context:
def __init__(self):
self._state = DraftState()
def set_state(self, state: State):
self._state = state
def request(self):
self._state.handle(self)
# Usage
doc = Context()
doc.request() # Draft -> Published
doc.request() # Published -> Archived
doc.request() # Already Archived
8. Strategy¶
Purpose: Define a family of algorithms, encapsulate each one, and make them interchangeable.
Python Example:
from abc import ABC, abstractmethod
class PaymentStrategy(ABC):
@abstractmethod
def pay(self, amount: float) -> None:
pass
class CreditCardPayment(PaymentStrategy):
def __init__(self, card_number: str):
self._card_number = card_number
def pay(self, amount: float) -> None:
print(f"Paid ${amount} using Credit Card ending in {self._card_number[-4:]}")
class PayPalPayment(PaymentStrategy):
def __init__(self, email: str):
self._email = email
def pay(self, amount: float) -> None:
print(f"Paid ${amount} using PayPal account {self._email}")
class CryptoPayment(PaymentStrategy):
def __init__(self, wallet: str):
self._wallet = wallet
def pay(self, amount: float) -> None:
print(f"Paid ${amount} using Crypto wallet {self._wallet}")
class ShoppingCart:
def __init__(self):
self._amount = 0
self._payment_strategy = None
def set_payment_strategy(self, strategy: PaymentStrategy):
self._payment_strategy = strategy
def set_amount(self, amount: float):
self._amount = amount
def checkout(self):
if self._payment_strategy:
self._payment_strategy.pay(self._amount)
# Usage
cart = ShoppingCart()
cart.set_amount(150.00)
cart.set_payment_strategy(CreditCardPayment("1234567812345678"))
cart.checkout()
cart.set_payment_strategy(PayPalPayment("user@example.com"))
cart.checkout()
9. Template Method¶
Purpose: Define the skeleton of an algorithm in a method, deferring some steps to subclasses.
Python Example:
from abc import ABC, abstractmethod
class DataMiner(ABC):
def mine(self, path: str):
file = self.open_file(path)
data = self.extract_data(file)
parsed_data = self.parse_data(data)
analysis = self.analyze_data(parsed_data)
self.send_report(analysis)
self.close_file(file)
@abstractmethod
def open_file(self, path: str):
pass
@abstractmethod
def extract_data(self, file):
pass
def parse_data(self, data):
# Default implementation
return data
@abstractmethod
def analyze_data(self, data):
pass
def send_report(self, analysis):
print(f"Sending report: {analysis}")
def close_file(self, file):
print("File closed")
class CSVDataMiner(DataMiner):
def open_file(self, path: str):
print(f"Opening CSV file: {path}")
return f"CSV_FILE:{path}"
def extract_data(self, file):
print("Extracting CSV data")
return "CSV_DATA"
def analyze_data(self, data):
return "CSV Analysis Results"
class PDFDataMiner(DataMiner):
def open_file(self, path: str):
print(f"Opening PDF file: {path}")
return f"PDF_FILE:{path}"
def extract_data(self, file):
print("Extracting PDF data")
return "PDF_DATA"
def analyze_data(self, data):
return "PDF Analysis Results"
# Usage
csv_miner = CSVDataMiner()
csv_miner.mine("data.csv")
print("\\n")
pdf_miner = PDFDataMiner()
pdf_miner.mine("document.pdf")
10. Visitor¶
Purpose: Represent an operation to be performed on elements of an object structure, letting you define a new operation without changing the classes of the elements.
Python Example:
from abc import ABC, abstractmethod
class Visitor(ABC):
@abstractmethod
def visit_circle(self, circle: 'Circle') -> None:
pass
@abstractmethod
def visit_rectangle(self, rectangle: 'Rectangle') -> None:
pass
class Shape(ABC):
@abstractmethod
def accept(self, visitor: Visitor) -> None:
pass
class Circle(Shape):
def __init__(self, radius: float):
self.radius = radius
def accept(self, visitor: Visitor) -> None:
visitor.visit_circle(self)
class Rectangle(Shape):
def __init__(self, width: float, height: float):
self.width = width
self.height = height
def accept(self, visitor: Visitor) -> None:
visitor.visit_rectangle(self)
class AreaCalculator(Visitor):
def visit_circle(self, circle: Circle) -> None:
area = 3.14 * circle.radius ** 2
print(f"Circle area: {area:.2f}")
def visit_rectangle(self, rectangle: Rectangle) -> None:
area = rectangle.width * rectangle.height
print(f"Rectangle area: {area:.2f}")
class DrawVisitor(Visitor):
def visit_circle(self, circle: Circle) -> None:
print(f"Drawing circle with radius {circle.radius}")
def visit_rectangle(self, rectangle: Rectangle) -> None:
print(f"Drawing rectangle {rectangle.width}x{rectangle.height}")
# Usage
shapes = [Circle(5), Rectangle(4, 6), Circle(3)]
area_calculator = AreaCalculator()
drawer = DrawVisitor()
for shape in shapes:
shape.accept(area_calculator)
print("\\n")
for shape in shapes:
shape.accept(drawer)
When to Use Behavioral Patterns¶
- Chain of Responsibility: When you want to decouple request senders from receivers
- Command: When you need to parameterize objects with operations, queue operations, or implement undo/redo
- Iterator: When you need to traverse a collection without exposing its internal structure
- Mediator: When objects need to communicate but you want to avoid tight coupling
- Memento: When you need to implement undo functionality or save/restore state
- Observer: When changes to one object require changing others, and you don't know how many objects need to be changed
- State: When an object's behavior depends on its state and it must change behavior at runtime
- Strategy: When you have multiple algorithms and want to switch between them at runtime
- Template Method: When you have an algorithm with invariant steps but some steps need customization
- Visitor: When you need to perform operations across a diverse set of objects without modifying their classes
Conclusion¶
Behavioral design patterns are powerful tools in a Python developer's arsenal. They help manage complex control flows, improve code maintainability, and promote loose coupling between objects. By understanding and applying these patterns appropriately, you can write more elegant, flexible, and maintainable Python code.
Note
Patterns are not silver bullets. Use them when they solve real problems in your codebase, not just for the sake of using patterns.