py4u guide

Exploring Behavioral Design Patterns with Python

Design patterns are proven solutions to common software design problems. They provide a structured approach to solving issues like code maintainability, scalability, and reusability. Among the three main categories of design patterns—**Creational** (object creation), **Structural** (object composition), and **Behavioral** (object interaction)—behavioral patterns focus on how objects *communicate* and *distribute responsibility* to achieve complex functionality. Behavioral patterns address dynamic interactions between objects, ensuring that objects collaborate effectively while remaining loosely coupled. In Python, a language known for its readability and flexibility, these patterns can be implemented elegantly using classes, inheritance, and composition. This blog will dive deep into the most widely used behavioral design patterns, explaining their intent, real-world problems they solve, Python implementations, use cases, and tradeoffs. Whether you’re a beginner or an experienced developer, this guide will help you master behavioral patterns and apply them to write cleaner, more maintainable code.

Table of Contents

  1. Observer Pattern

    • Intent
    • Problem & Solution
    • Python Implementation
    • Use Cases
    • Pros & Cons
  2. Strategy Pattern

    • Intent
    • Problem & Solution
    • Python Implementation
    • Use Cases
    • Pros & Cons
  3. Command Pattern

    • Intent
    • Problem & Solution
    • Python Implementation
    • Use Cases
    • Pros & Cons
  4. Template Method Pattern

    • Intent
    • Problem & Solution
    • Python Implementation
    • Use Cases
    • Pros & Cons
  5. Iterator Pattern

    • Intent
    • Problem & Solution
    • Python Implementation
    • Use Cases
    • Pros & Cons
  6. State Pattern

    • Intent
    • Problem & Solution
    • Python Implementation
    • Use Cases
    • Pros & Cons
  7. Conclusion

  8. References

1. Observer Pattern

Intent

Define a one-to-many dependency between objects so that when one object (the subject) changes state, all its dependents (the observers) are notified and updated automatically.

Problem & Solution

Problem: You need multiple objects to react to changes in another object’s state (e.g., a weather station updating displays for temperature, humidity, and pressure). Tightly coupling the subject to its observers would make the code rigid and hard to extend.

Solution: Decouple the subject from observers using an interface. The subject maintains a list of observers and notifies them of state changes. Observers register/unregister themselves with the subject and define how to react to updates.

Python Implementation

Let’s implement a weather monitoring system where a WeatherStation (subject) notifies TemperatureDisplay and HumidityDisplay (observers) of state changes.

from abc import ABC, abstractmethod

# Observer Interface
class Observer(ABC):
    @abstractmethod
    def update(self, temperature: float, humidity: float) -> None:
        pass

# Subject Interface
class Subject(ABC):
    @abstractmethod
    def attach(self, observer: Observer) -> None:
        pass

    @abstractmethod
    def detach(self, observer: Observer) -> None:
        pass

    @abstractmethod
    def notify(self) -> None:
        pass

# Concrete Subject: Weather Station
class WeatherStation(Subject):
    def __init__(self):
        self._observers: list[Observer] = []
        self._temperature: float = 0.0
        self._humidity: float = 0.0

    def attach(self, observer: Observer) -> None:
        self._observers.append(observer)

    def detach(self, observer: Observer) -> None:
        self._observers.remove(observer)

    def notify(self) -> None:
        for observer in self._observers:
            observer.update(self._temperature, self._humidity)

    # Update weather data and trigger notifications
    def set_measurements(self, temperature: float, humidity: float) -> None:
        self._temperature = temperature
        self._humidity = humidity
        self.notify()

# Concrete Observers
class TemperatureDisplay(Observer):
    def update(self, temperature: float, humidity: float) -> None:
        print(f"Temperature Display: Current Temp = {temperature}°C")

class HumidityDisplay(Observer):
    def update(self, temperature: float, humidity: float) -> None:
        print(f"Humidity Display: Current Humidity = {humidity}%")

# Usage
if __name__ == "__main__":
    weather_station = WeatherStation()
    temp_display = TemperatureDisplay()
    humidity_display = HumidityDisplay()

    weather_station.attach(temp_display)
    weather_station.attach(humidity_display)

    # Simulate new weather data
    weather_station.set_measurements(25.5, 60.0)  # Both displays update
    weather_station.set_measurements(27.0, 55.0)  # Both displays update again

    # Detach humidity display
    weather_station.detach(humidity_display)
    weather_station.set_measurements(28.0, 50.0)  # Only temperature display updates

Output:

Temperature Display: Current Temp = 25.5°C  
Humidity Display: Current Humidity = 60.0%  
Temperature Display: Current Temp = 27.0°C  
Humidity Display: Current Humidity = 55.0%  
Temperature Display: Current Temp = 28.0°C  

Use Cases

  • Event-driven systems (e.g., GUI frameworks like Tkinter, where buttons notify listeners of clicks).
  • Notifications (e.g., email/SMS alerts triggered by system events).
  • Real-time data dashboards (e.g., stock price trackers).

Pros & Cons

ProsCons
Loose coupling between subject and observers.Observers may receive unnecessary updates.
Easy to add/remove observers dynamically.Can lead to memory leaks if observers are not detached.

2. Strategy Pattern

Intent

Define a family of interchangeable algorithms, encapsulate each one, and make them interchangeable. Strategy lets the algorithm vary independently from clients that use it.

Problem & Solution

Problem: You need to offer multiple ways to perform an action (e.g., different payment methods: credit card, PayPal, Bitcoin). Hardcoding all methods into a single class would make it bloated and hard to extend.

Solution: Encapsulate each algorithm (strategy) in a separate class with a common interface. The client delegates the work to a strategy object, allowing runtime switching of strategies.

Python Implementation

Let’s implement a payment processor that supports credit card and PayPal payments.

from abc import ABC, abstractmethod

# Strategy Interface
class PaymentStrategy(ABC):
    @abstractmethod
    def pay(self, amount: float) -> None:
        pass

# Concrete Strategies
class CreditCardPayment(PaymentStrategy):
    def __init__(self, card_number: str, name: str):
        self.card_number = card_number
        self.name = name

    def pay(self, amount: float) -> None:
        print(f"Paid ${amount} via Credit Card (Card: {self.card_number}, Name: {self.name})")

class PayPalPayment(PaymentStrategy):
    def __init__(self, email: str):
        self.email = email

    def pay(self, amount: float) -> None:
        print(f"Paid ${amount} via PayPal (Email: {self.email})")

# Context: Uses a strategy to perform payment
class ShoppingCart:
    def __init__(self, payment_strategy: PaymentStrategy):
        self._payment_strategy = payment_strategy
        self._items: list[str] = []

    def add_item(self, item: str) -> None:
        self._items.append(item)

    def set_payment_strategy(self, payment_strategy: PaymentStrategy) -> None:
        self._payment_strategy = payment_strategy

    def checkout(self, amount: float) -> None:
        print(f"Checking out items: {', '.join(self._items)}")
        self._payment_strategy.pay(amount)

# Usage
if __name__ == "__main__":
    # Initialize cart with Credit Card payment
    cart = ShoppingCart(CreditCardPayment("4111-1111-1111-1111", "John Doe"))
    cart.add_item("Laptop")
    cart.add_item("Mouse")
    cart.checkout(1200.0)  # Pays with Credit Card

    # Switch to PayPal payment
    cart.set_payment_strategy(PayPalPayment("[email protected]"))
    cart.add_item("Keyboard")
    cart.checkout(150.0)  # Pays with PayPal

Output:

Checking out items: Laptop, Mouse  
Paid $1200.0 via Credit Card (Card: 4111-1111-1111-1111, Name: John Doe)  
Checking out items: Laptop, Mouse, Keyboard  
Paid $150.0 via PayPal (Email: [email protected])  

Use Cases

  • Sorting algorithms (e.g., switching between quicksort and mergesort based on data size).
  • Validation rules (e.g., different password strength checks).
  • Compression tools (e.g., ZIP vs. RAR compression).

Pros & Cons

ProsCons
Easy to add new strategies without changing client code.Increases the number of classes (one per strategy).
Clients can switch strategies at runtime.Clients must be aware of different strategies to choose the right one.

3. Command Pattern

Intent

Encapsulate a request as an object, thereby allowing parameterization of clients with queues, requests, and operations. Supports undoable operations.

Problem & Solution

Problem: You need to decouple the object that issues a request (invoker) from the object that performs the action (receiver). For example, a remote control (invoker) should trigger actions on devices (receivers like lights, fans) without knowing the details of how each device works.

Solution: Wrap each request in a Command object that defines an execute() method. The invoker stores and triggers commands, while the receiver performs the actual work.

Python Implementation

Let’s build a remote control with buttons that control a light and a fan.

from abc import ABC, abstractmethod

# Command Interface
class Command(ABC):
    @abstractmethod
    def execute(self) -> None:
        pass

    @abstractmethod
    def undo(self) -> None:
        pass

# Concrete Commands
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 LightOffCommand(Command):
    def __init__(self, light: "Light"):
        self._light = light

    def execute(self) -> None:
        self._light.turn_off()

    def undo(self) -> None:
        self._light.turn_on()

class FanHighCommand(Command):
    def __init__(self, fan: "Fan"):
        self._fan = fan
        self._prev_speed = 0  # Track previous speed for undo

    def execute(self) -> None:
        self._prev_speed = self._fan.speed
        self._fan.set_speed(3)  # High speed

    def undo(self) -> None:
        self._fan.set_speed(self._prev_speed)

# Receiver: Devices that perform actions
class Light:
    def turn_on(self) -> None:
        print("Light is ON")

    def turn_off(self) -> None:
        print("Light is OFF")

class Fan:
    def __init__(self):
        self.speed = 0  # 0=off, 1=low, 2=medium, 3=high

    def set_speed(self, speed: int) -> None:
        self.speed = speed
        print(f"Fan speed set to {speed} (0=off, 1=low, 2=medium, 3=high)")

# Invoker: Remote Control with buttons
class RemoteControl:
    def __init__(self):
        self._commands: dict[int, Command] = {}  # Button -> Command
        self._undo_stack: list[Command] = []

    def set_command(self, button: int, command: Command) -> None:
        self._commands[button] = command

    def press_button(self, button: int) -> None:
        if button in self._commands:
            command = self._commands[button]
            command.execute()
            self._undo_stack.append(command)

    def press_undo(self) -> None:
        if self._undo_stack:
            last_command = self._undo_stack.pop()
            last_command.undo()
            print("Undo performed")

# Usage
if __name__ == "__main__":
    # Initialize receiver objects
    living_room_light = Light()
    bedroom_fan = Fan()

    # Create commands
    light_on = LightOnCommand(living_room_light)
    light_off = LightOffCommand(living_room_light)
    fan_high = FanHighCommand(bedroom_fan)

    # Initialize remote control
    remote = RemoteControl()
    remote.set_command(0, light_on)    # Button 0: Light ON
    remote.set_command(1, light_off)   # Button 1: Light OFF
    remote.set_command(2, fan_high)    # Button 2: Fan HIGH

    # Press buttons
    remote.press_button(0)  # Light ON
    remote.press_button(2)  # Fan HIGH
    remote.press_undo()     # Undo Fan HIGH (sets to previous speed 0)
    remote.press_button(1)  # Light OFF

Output:

Light is ON  
Fan speed set to 3 (0=off, 1=low, 2=medium, 3=high)  
Fan speed set to 0 (0=off, 1=low, 2=medium, 3=high)  
Undo performed  
Light is OFF  

Use Cases

  • GUI buttons, menus, and keyboard shortcuts (each triggers a command).
  • Undo/redo functionality (storing a history of commands).
  • Task scheduling (queuing commands to execute later).

Pros & Cons

ProsCons
Decouples invoker and receiver.Increases code complexity (many small command classes).
Supports undo/redo via command history.Simple commands may feel over-engineered.

4. Template Method Pattern

Intent

Define the skeleton of an algorithm in a base class, deferring some steps to subclasses. Subclasses can redefine certain steps without changing the algorithm’s structure.

Problem & Solution

Problem: Multiple algorithms share the same overall structure but differ in specific steps. For example, making coffee and tea both involve boiling water, brewing, pouring into a cup, and adding condiments—but the brewing and condiment steps differ.

Solution: Define a template_method in a base class that outlines the algorithm’s steps. Mark variable steps as abstract, forcing subclasses to implement them.

Python Implementation

Let’s implement a beverage maker with coffee and tea recipes.

from abc import ABC, abstractmethod

# Abstract Base Class (Template)
class CaffeineBeverage(ABC):
    # Template Method: Defines the algorithm skeleton
    def prepare_recipe(self) -> None:
        self.boil_water()
        self.brew()          # Abstract: Subclasses implement
        self.pour_in_cup()
        self.add_condiments()  # Abstract: Subclasses implement

    def boil_water(self) -> None:
        print("Boiling water")

    def pour_in_cup(self) -> None:
        print("Pouring into cup")

    @abstractmethod
    def brew(self) -> None:
        pass

    @abstractmethod
    def add_condiments(self) -> None:
        pass

# Concrete Subclasses
class Coffee(CaffeineBeverage):
    def brew(self) -> None:
        print("Dripping coffee through filter")

    def add_condiments(self) -> None:
        print("Adding sugar and milk")

class Tea(CaffeineBeverage):
    def brew(self) -> None:
        print("Steeping the tea")

    def add_condiments(self) -> None:
        print("Adding lemon")

# Usage
if __name__ == "__main__":
    print("Making Coffee:")
    coffee = Coffee()
    coffee.prepare_recipe()

    print("\nMaking Tea:")
    tea = Tea()
    tea.prepare_recipe()

Output:

Making Coffee:  
Boiling water  
Dripping coffee through filter  
Pouring into cup  
Adding sugar and milk  

Making Tea:  
Boiling water  
Steeping the tea  
Pouring into cup  
Adding lemon  

Use Cases

  • Frameworks (e.g., Django’s class-based views, where dispatch() is a template method).
  • Report generation (common steps like fetching data, formatting, exporting; varying steps like data source).
  • Game development (character creation with shared steps but unique abilities).

Pros & Cons

ProsCons
Enforces a consistent algorithm structure.Subclasses can only override specific steps (may limit flexibility).
Reduces code duplication (shared steps in base class).Base class may become a “god class” if too many steps are added.

5. Iterator Pattern

Intent

Provide a way to access the elements of an aggregate object sequentially without exposing its underlying representation (e.g., list, tree, or hash table).

Problem & Solution

Problem: You need to traverse a collection (e.g., a custom BookShelf) but don’t want to expose its internal structure (e.g., a list or array). Clients should iterate over elements uniformly regardless of the collection type.

Solution: Define an Iterator interface with has_next() and next() methods. The aggregate object provides an iterator that traverses its elements, hiding the internal structure.

Python Implementation

Python has built-in iterator support via the __iter__() and __next__() methods, but we’ll implement a custom iterator for clarity.

from abc import ABC, abstractmethod
from typing import Any, Optional

# Iterator Interface
class Iterator(ABC):
    @abstractmethod
    def has_next(self) -> bool:
        pass

    @abstractmethod
    def next(self) -> Any:
        pass

# Aggregate Interface
class Aggregate(ABC):
    @abstractmethod
    def create_iterator(self) -> Iterator:
        pass

# Concrete Iterator
class BookShelfIterator(Iterator):
    def __init__(self, book_shelf: "BookShelf"):
        self._book_shelf = book_shelf
        self._index = 0

    def has_next(self) -> bool:
        return self._index < len(self._book_shelf.books)

    def next(self) -> "Book":
        if self.has_next():
            book = self._book_shelf.books[self._index]
            self._index += 1
            return book
        raise StopIteration("No more books")

# Concrete Aggregate: BookShelf
class BookShelf(Aggregate):
    def __init__(self):
        self.books: list["Book"] = []

    def add_book(self, book: "Book") -> None:
        self.books.append(book)

    def create_iterator(self) -> Iterator:
        return BookShelfIterator(self)

# Book Class (Element)
class Book:
    def __init__(self, title: str):
        self.title = title

    def __str__(self) -> str:
        return self.title

# Usage
if __name__ == "__main__":
    # Create a BookShelf and add books
    book_shelf = BookShelf()
    book_shelf.add_book(Book("The Great Gatsby"))
    book_shelf.add_book(Book("1984"))
    book_shelf.add_book(Book("To Kill a Mockingbird"))

    # Get iterator and traverse
    iterator = book_shelf.create_iterator()
    print("Books in shelf:")
    while iterator.has_next():
        book = iterator.next()
        print(f"- {book}")

Output:

Books in shelf:  
- The Great Gatsby  
- 1984  
- To Kill a Mockingbird  

In Python, you’d typically use the built-in iterator protocol (e.g., for book in book_shelf), but this example illustrates the pattern’s core logic.

Use Cases

  • Collections (e.g., lists, dictionaries, and custom data structures like trees or graphs).
  • Streaming data (e.g., iterating over a file line by line without loading it all into memory).
  • Database result sets (iterating over query results).

Pros & Cons

ProsCons
Clients access elements without knowing the collection’s internal structure.Adding new aggregate types requires new iterator types.
Supports multiple traversals of the same collection (via separate iterators).Simple collections may not need a custom iterator (Python’s built-ins suffice).

6. State Pattern

Intent

Allow an object to alter its behavior when its internal state changes. The object will appear to change its class.

Problem & Solution

Problem: An object’s behavior depends on its state, and it must change behavior dynamically based on that state. For example, a vending machine behaves differently when idle, when a coin is inserted, or when dispensing a product. Using conditional statements (e.g., if-elif-else) to handle states leads to messy, unmaintainable code.

Solution: Encapsulate each state in a separate class that implements a common interface. The context object delegates state-specific behavior to the current state object, which can transition the context to another state.

Python Implementation

Let’s model a vending machine with states: IdleState, HasCoinState, and DispensingState.

from abc import ABC, abstractmethod

# State Interface
class State(ABC):
    @abstractmethod
    def insert_coin(self) -> None:
        pass

    @abstractmethod
    def eject_coin(self) -> None:
        pass

    @abstractmethod
    def press_button(self) -> None:
        pass

    @abstractmethod
    def dispense(self) -> None:
        pass

# Concrete States
class IdleState(State):
    def __init__(self, vending_machine: "VendingMachine"):
        self.vending_machine = vending_machine

    def insert_coin(self) -> None:
        print("Coin inserted. Machine is now ready to dispense.")
        self.vending_machine.set_state(self.vending_machine.has_coin_state)

    def eject_coin(self) -> None:
        print("No coin inserted to eject.")

    def press_button(self) -> None:
        print("Insert a coin first.")

    def dispense(self) -> None:
        print("Insert a coin and press a button to dispense.")

class HasCoinState(State):
    def __init__(self, vending_machine: "VendingMachine"):
        self.vending_machine = vending_machine

    def insert_coin(self) -> None:
        print("Already has a coin. Cannot insert another.")

    def eject_coin(self) -> None:
        print("Coin ejected.")
        self.vending_machine.set_state(self.vending_machine.idle_state)

    def press_button(self) -> None:
        print("Button pressed. Dispensing product...")
        self.vending_machine.set_state(self.vending_machine.dispensing_state)
        self.vending_machine.dispense()  # Trigger dispense

    def dispense(self) -> None:
        print("Cannot dispense yet. Press the button.")

class DispensingState(State):
    def __init__(self, vending_machine: "VendingMachine"):
        self.vending_machine = vending_machine

    def insert_coin(self) -> None:
        print("Please wait, dispensing in progress.")

    def eject_coin(self) -> None:
        print("Cannot eject coin during dispensing.")

    def press_button(self) -> None:
        print("Already dispensing.")

    def dispense(self) -> None:
        self.vending_machine.release_product()
        if self.vending_machine.count > 0:
            self.vending_machine.set_state(self.vending_machine.idle_state)
        else:
            print("Machine is out of stock!")
            self.vending_machine.set_state(self.vending_machine.idle_state)  # Or OutOfStockState

# Context: Vending Machine
class VendingMachine:
    def __init__(self, count: int = 5):
        # Initialize states
        self.idle_state = IdleState(self)
        self.has_coin_state = HasCoinState(self)
        self.dispensing_state = DispensingState(self)
        self.count = count  # Number of products
        self.current_state: State = self.idle_state  # Initial state

    def set_state(self, state: State) -> None:
        self.current_state = state

    def release_product(self) -> None:
        if self.count > 0:
            print("Product dispensed. Enjoy!")
            self.count -= 1
        else:
            print("No products left!")

    # Delegate state-specific methods to current state
    def insert_coin(self) -> None:
        self.current_state.insert_coin()

    def eject_coin(self) -> None:
        self.current_state.eject_coin()

    def press_button(self) -> None:
        self.current_state.press_button()

    def dispense(self) -> None:
        self.current_state.dispense()

# Usage
if __name__ == "__main__":
    vending_machine = VendingMachine(count=2)  # 2 products

    print("=== Test 1: Insert coin, press button, dispense ===")
    vending_machine.insert_coin()  # Idle -> HasCoin
    vending_machine.press_button()  # HasCoin -> Dispensing (triggers dispense)

    print("\n=== Test 2: Insert coin, eject coin ===")
    vending_machine.insert_coin()  # Idle -> HasCoin
    vending_machine.eject_coin()   # HasCoin -> Idle

    print("\n=== Test 3: Dispense last product ===")
    vending_machine.insert_coin()  # Idle -> HasCoin
    vending_machine.press_button()  # HasCoin -> Dispensing (triggers dispense)

Output:

=== Test 1: Insert coin, press button, dispense ===  
Coin inserted. Machine is now ready to dispense.  
Button pressed. Dispensing product...  
Product dispensed. Enjoy!  

=== Test 2: Insert coin, eject coin ===  
Coin inserted. Machine is now ready to dispense.  
Coin ejected.  

=== Test 3: Dispense last product ===  
Coin inserted. Machine is now ready to dispense.  
Button pressed. Dispensing product...  
Product dispensed. Enjoy!  

Use Cases

  • Finite state machines (e.g., traffic lights, elevators).
  • Game characters (e.g., idle, running, attacking states).
  • Order processing systems (e.g., pending, shipped, delivered states).

Pros & Cons

ProsCons
Eliminates complex conditional logic.Increases the number of classes (one per state).
States can be added/modified independently.Transitions between states can become complex to track.

Conclusion

Behavioral design patterns are indispensable for building flexible, maintainable, and scalable software. By focusing on object interaction and responsibility distribution, they solve common problems like loose coupling, dynamic behavior changes, and algorithm interchangeability.

In Python, these patterns leverage the language’s features (e.g., classes, inheritance, duck typing) to create elegant solutions. Whether you’re implementing event-driven systems with the Observer pattern, adding undo functionality with Command, or managing state transitions with State, behavioral patterns empower you to write code that’s easier to understand and extend.

Remember: Design patterns are tools, not rules. Apply them when they solve a specific problem, and avoid over-engineering simple scenarios.

References