py4u guide

GoF Design Patterns: Translating Theory into Python Code

Design patterns are time-tested solutions to recurring software design problems. Popularized by the "Gang of Four" (GoF)—Erich Gamma, Richard Helm, Ralph Johnson, and John Vlissides—in their 1994 book *Design Patterns: Elements of Reusable Object-Oriented Software*, these patterns provide a common vocabulary for developers to communicate complex design ideas and build maintainable, scalable systems. The GoF identified 23 patterns, categorized into three groups: **Creational** (handling object creation), **Structural** (organizing object relationships), and **Behavioral** (managing object interactions). While the original book uses C++ and Smalltalk for examples, design patterns are language-agnostic. However, Python’s unique features—such as dynamic typing, first-class functions, and built-in abstractions—offer elegant, idiomatic ways to implement these patterns. This blog demystifies key GoF patterns by translating their theoretical concepts into practical Python code. Whether you’re a beginner looking to learn patterns or an experienced developer seeking Python-specific implementations, this guide will help you apply these solutions effectively.

Table of Contents

  1. Creational Patterns
  2. Structural Patterns
  3. Behavioral Patterns
  4. Conclusion
  5. References

Creational Patterns

Creational patterns focus on object instantiation, providing flexible ways to create objects while hiding the complexity of their creation logic.

1. Singleton

Intent

Ensure a class has only one instance and provide a global point of access to it.

Problem

Many applications require a single point of control for resources like configuration settings, database connections, or logging. For example, a configuration manager should not have multiple instances, as this could lead to inconsistent settings.

Solution

The Singleton pattern restricts instantiation to one object and exposes a static method to access it. In Python, this can be implemented using metaclasses (to control class creation) or decorators (to cache instances).

Python Implementation

Approach 1: Metaclass (Most Robust)

Metaclasses in Python define the behavior of classes. By overriding the __call__ method, we can ensure only one instance is created:

class SingletonMeta(type):
    """Metaclass to enforce Singleton behavior."""
    _instances: dict[type, object] = {}  # Cache for instances

    def __call__(cls, *args, **kwargs):
        """Control instance creation."""
        if cls not in cls._instances:
            # Create a new instance if none exists
            instance = super().__call__(*args, **kwargs)
            cls._instances[cls] = instance
        return cls._instances[cls]


class ConfigurationManager(metaclass=SingletonMeta):
    """Example Singleton class for managing app configurations."""
    def __init__(self):
        self.settings = {}  # Initialize with empty settings

    def set_config(self, key: str, value: str) -> None:
        self.settings[key] = value

    def get_config(self, key: str) -> str:
        return self.settings.get(key, "Not found")


# Usage
config1 = ConfigurationManager()
config1.set_config("theme", "dark")

config2 = ConfigurationManager()
print(config2.get_config("theme"))  # Output: "dark" (same instance as config1)
assert config1 is config2  # True (both reference the same object)
Approach 2: Decorator (Simpler)

A decorator can wrap a class to cache its instance. This is lighter than a metaclass but less flexible for subclassing:

from functools import wraps

def singleton(cls):
    """Decorator to enforce Singleton behavior."""
    instances = {}
    @wraps(cls)
    def wrapper(*args, **kwargs):
        if cls not in instances:
            instances[cls] = cls(*args, **kwargs)
        return instances[cls]
    return wrapper


@singleton
class Logger:
    """Singleton logger class."""
    def log(self, message: str) -> None:
        print(f"[LOG] {message}")


logger1 = Logger()
logger2 = Logger()
assert logger1 is logger2  # True

Key Takeaways

  • Use Cases: Centralized resource management (e.g., database connections, configs).
  • Python Quirk: Modules are singletons by default! For simple cases, a module with functions (e.g., config.py) may suffice instead of a class-based Singleton.
  • Caveats: Singletons can hinder testing (due to global state) and violate the Single Responsibility Principle. Use sparingly.

2. Factory Method

Intent

Define an interface for creating objects but let subclasses decide which class to instantiate.

Problem

You want to delegate object creation to subclasses to avoid tight coupling between a creator class and its products. For example, a document editor might support multiple document types (text, spreadsheet, presentation), but the editor shouldn’t hardcode logic for creating each type.

Solution

The Factory Method pattern defines a “factory” method in a base class that subclasses override to produce specific objects. This decouples the creator from the product.

Python Implementation

Let’s build a document editor where subclasses handle creating specific document types:

from abc import ABC, abstractmethod

# -------------------
# Product Interface
# -------------------
class Document(ABC):
    """Abstract base class for all documents."""
    @abstractmethod
    def open(self) -> None:
        pass

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


# -------------------
# Concrete Products
# -------------------
class TextDocument(Document):
    def open(self) -> None:
        print("Opening text document...")

    def save(self) -> None:
        print("Saving text document...")


class SpreadsheetDocument(Document):
    def open(self) -> None:
        print("Opening spreadsheet document...")

    def save(self) -> None:
        print("Saving spreadsheet document...")


# -------------------
# Creator Interface
# -------------------
class DocumentEditor(ABC):
    """Abstract base class for document editors."""
    @abstractmethod
    def create_document(self) -> Document:
        """Factory Method: Subclasses implement this to create documents."""
        pass

    def new_document(self) -> None:
        """Template method using the factory method."""
        document = self.create_document()
        document.open()


# -------------------
# Concrete Creators
# -------------------
class TextEditor(DocumentEditor):
    def create_document(self) -> Document:
        return TextDocument()


class SpreadsheetEditor(DocumentEditor):
    def create_document(self) -> Document:
        return SpreadsheetDocument()


# Usage
text_editor = TextEditor()
text_editor.new_document()  # Output: "Opening text document..."

spreadsheet_editor = SpreadsheetEditor()
spreadsheet_editor.new_document()  # Output: "Opening spreadsheet document..."

Key Takeaways

  • Use Cases: When a class can’t anticipate the type of objects it needs to create (e.g., plugin systems).
  • Python Benefit: Dynamic typing simplifies the pattern—no need for strict interfaces; duck typing works if products share methods.
  • Flexibility: Adding new products (e.g., PresentationDocument) only requires a new PresentationEditor subclass.

3. Builder

Intent

Separate the construction of a complex object from its representation, allowing the same construction process to create different representations.

Problem

Creating complex objects with many optional components (e.g., a computer with CPU, RAM, storage, and peripherals) can lead to bloated constructors or tangled initialization logic.

Solution

The Builder pattern splits object construction into steps handled by a “builder” object. A “director” controls the construction process, and clients use the director with a specific builder to get the desired object.

Python Implementation

Let’s build a system to construct custom computers:

from abc import ABC, abstractmethod

# -------------------
# Product
# -------------------
class Computer:
    """Complex object to be built."""
    def __init__(self):
        self.cpu: str = ""
        self.ram: str = ""
        self.storage: str = ""
        self.peripherals: list[str] = []

    def __str__(self) -> str:
        return (f"Computer Specs:\n"
                f"CPU: {self.cpu}\n"
                f"RAM: {self.ram}\n"
                f"Storage: {self.storage}\n"
                f"Peripherals: {', '.join(self.peripherals)}")


# -------------------
# Builder Interface
# -------------------
class ComputerBuilder(ABC):
    """Abstract builder defining construction steps."""
    @abstractmethod
    def set_cpu(self) -> None:
        pass

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

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

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

    @abstractmethod
    def get_computer(self) -> Computer:
        pass


# -------------------
# Concrete Builders
# -------------------
class GamingPCBuilder(ComputerBuilder):
    def __init__(self):
        self.computer = Computer()

    def set_cpu(self) -> None:
        self.computer.cpu = "Intel i9-13900K"

    def set_ram(self) -> None:
        self.computer.ram = "32GB DDR5"

    def set_storage(self) -> None:
        self.computer.storage = "2TB NVMe SSD"

    def add_peripherals(self) -> None:
        self.computer.peripherals = ["Mechanical Keyboard", "RGB Mouse", "4K Monitor"]

    def get_computer(self) -> Computer:
        return self.computer


class OfficePCBuilder(ComputerBuilder):
    def __init__(self):
        self.computer = Computer()

    def set_cpu(self) -> None:
        self.computer.cpu = "Intel i5-13400"

    def set_ram(self) -> None:
        self.computer.ram = "16GB DDR4"

    def set_storage(self) -> None:
        self.computer.storage = "1TB SSD"

    def add_peripherals(self) -> None:
        self.computer.peripherals = ["Wireless Mouse", "HD Monitor"]

    def get_computer(self) -> Computer:
        return self.computer


# -------------------
# Director
# -------------------
class Director:
    """Controls the construction process."""
    def __init__(self, builder: ComputerBuilder):
        self.builder = builder

    def build_computer(self) -> None:
        self.builder.set_cpu()
        self.builder.set_ram()
        self.builder.set_storage()
        self.builder.add_peripherals()


# Usage
# Build a gaming PC
gaming_builder = GamingPCBuilder()
director = Director(gaming_builder)
director.build_computer()
gaming_pc = gaming_builder.get_computer()
print("Gaming PC:\n", gaming_pc)

# Build an office PC
office_builder = OfficePCBuilder()
director.builder = office_builder  # Reuse director with a different builder
director.build_computer()
office_pc = office_builder.get_computer()
print("\nOffice PC:\n", office_pc)

Key Takeaways

  • Use Cases: Complex objects with many optional parts (e.g., meal kits, car configurations).
  • Python Flexibility: Omit the Director if clients need full control over the build process.
  • Benefits: Avoids telescoping constructors (e.g., Computer(cpu, ram, storage, peripherals, ...)).

Structural Patterns

Structural patterns organize classes and objects to form larger, flexible structures while keeping them decoupled.

1. Adapter

Intent

Convert the interface of a class into another interface clients expect, enabling incompatible classes to work together.

Problem

You have legacy code or third-party libraries with interfaces that don’t match your application’s requirements. For example, a legacy Rectangle class uses get_width() and get_height(), but your new code expects get_dimensions() to return a tuple (width, height).

Solution

The Adapter wraps the legacy class and exposes the desired interface.

Python Implementation

# -------------------
# Legacy Class (Adaptee)
# -------------------
class LegacyRectangle:
    """Legacy class with an incompatible interface."""
    def __init__(self, width: float, height: float):
        self._width = width
        self._height = height

    def get_width(self) -> float:
        return self._width

    def get_height(self) -> float:
        return self._height


# -------------------
# Target Interface
# -------------------
class Shape(ABC):
    """Interface expected by the client."""
    @abstractmethod
    def get_dimensions(self) -> tuple[float, float]:
        pass


# -------------------
# Adapter
# -------------------
class RectangleAdapter(Shape):
    """Adapts LegacyRectangle to the Shape interface."""
    def __init__(self, legacy_rect: LegacyRectangle):
        self.legacy_rect = legacy_rect  # Wrap the legacy object

    def get_dimensions(self) -> tuple[float, float]:
        # Convert legacy interface to target interface
        return (self.legacy_rect.get_width(), self.legacy_rect.get_height())


# Usage
# Client code expects Shape objects
def print_shape_dimensions(shape: Shape) -> None:
    width, height = shape.get_dimensions()
    print(f"Dimensions: Width={width}, Height={height}")


# Use the legacy class via the adapter
legacy_rect = LegacyRectangle(10.0, 20.0)
adapter = RectangleAdapter(legacy_rect)
print_shape_dimensions(adapter)  # Output: "Dimensions: Width=10.0, Height=20.0"

Key Takeaways

  • Use Cases: Integrating legacy code, third-party libraries, or refactoring without breaking existing clients.
  • Python Trick: Use __getattr__ to dynamically forward calls if the interface mismatch is small (e.g., def __getattr__(self, name): return getattr(self.legacy_rect, name)).
  • Types: Class Adapter (uses inheritance) vs. Object Adapter (uses composition, shown here). Composition is preferred for flexibility.

2. Decorator

Intent

Attach additional responsibilities to an object dynamically without subclassing.

Problem

You want to add features to objects (e.g., logging, caching, or validation) without creating a proliferation of subclasses (e.g., LoggedTextDocument, EncryptedTextDocument, etc.).

Solution

The Decorator pattern wraps objects in “decorator” classes that add behavior while maintaining the original interface.

Python Implementation

Let’s add condiments to a coffee order dynamically:

from abc import ABC, abstractmethod

# -------------------
# Component Interface
# -------------------
class Coffee(ABC):
    """Abstract base class for all coffees."""
    @abstractmethod
    def cost(self) -> float:
        pass

    @abstractmethod
    def description(self) -> str:
        pass


# -------------------
# Concrete Component
# -------------------
class Espresso(Coffee):
    def cost(self) -> float:
        return 2.50

    def description(self) -> str:
        return "Espresso"


# -------------------
# Decorator
# -------------------
class CoffeeDecorator(Coffee):
    """Base decorator class (wraps a Coffee)."""
    def __init__(self, coffee: Coffee):
        self._coffee = coffee  # Wrap the coffee

    @abstractmethod
    def cost(self) -> float:
        pass

    @abstractmethod
    def description(self) -> str:
        pass


# -------------------
# Concrete Decorators
# -------------------
class Milk(CoffeeDecorator):
    def cost(self) -> float:
        return self._coffee.cost() + 0.50

    def description(self) -> str:
        return f"{self._coffee.description()}, Milk"


class Sugar(CoffeeDecorator):
    def cost(self) -> float:
        return self._coffee.cost() + 0.25

    def description(self) -> str:
        return f"{self._coffee.description()}, Sugar"


class WhippedCream(CoffeeDecorator):
    def cost(self) -> float:
        return self._coffee.cost() + 0.75

    def description(self) -> str:
        return f"{self._coffee.description()}, Whipped Cream"


# Usage
# Start with a base coffee
coffee = Espresso()
print(f"Basic: {coffee.description()}, Cost: ${coffee.cost():.2f}")  # Espresso, $2.50

# Add milk and sugar
coffee = Milk(Sugar(coffee))
print(f"With Milk & Sugar: {coffee.description()}, Cost: ${coffee.cost():.2f}")  # Espresso, Milk, Sugar, $3.25

# Add whipped cream
coffee = WhippedCream(coffee)
print(f"Fancy: {coffee.description()}, Cost: ${coffee.cost():.2f}")  # Espresso, Milk, Sugar, Whipped Cream, $4.00

Key Takeaways

  • Use Cases: Dynamic feature addition (e.g., logging, input validation, UI theming).
  • Python Built-Ins: Python’s functools.wraps is a decorator for functions! Class-based decorators (shown here) work for objects.
  • Caveats: Overuse can lead to “decorator soup” (hard-to-follow nested wrappers).

3. Composite

Intent

Compose objects into tree structures to represent part-whole hierarchies. Clients treat individual objects and compositions uniformly.

Problem

You need to work with hierarchical data (e.g., file systems, organization charts) where elements can be either “leaves” (individual items) or “composites” (collections of items). Clients should interact with both types identically.

Solution

The Composite pattern defines a common interface for leaves and composites. Composites contain child elements (leaves or other composites) and delegate operations to their children.

Python Implementation

Let’s model a file system with files (leaves) and directories (composites):

from abc import ABC, abstractmethod
from typing import List

# -------------------
# Component Interface
# -------------------
class FileSystemComponent(ABC):
    """Abstract base class for files and directories."""
    @abstractmethod
    def get_name(self) -> str:
        pass

    @abstractmethod
    def get_size(self) -> int:
        pass

    def add(self, component: "FileSystemComponent") -> None:
        """Optional: Add child (only implemented by composites)."""
        raise NotImplementedError("add() not supported")

    def remove(self, component: "FileSystemComponent") -> None:
        """Optional: Remove child (only implemented by composites)."""
        raise NotImplementedError("remove() not supported")


# -------------------
# Leaf (File)
# -------------------
class File(FileSystemComponent):
    def __init__(self, name: str, size: int):
        self._name = name
        self._size = size

    def get_name(self) -> str:
        return self._name

    def get_size(self) -> int:
        return self._size


# -------------------
# Composite (Directory)
# -------------------
class Directory(FileSystemComponent):
    def __init__(self, name: str):
        self._name = name
        self._children: List[FileSystemComponent] = []

    def get_name(self) -> str:
        return self._name

    def get_size(self) -> int:
        """Sum the sizes of all children."""
        return sum(child.get_size() for child in self._children)

    def add(self, component: FileSystemComponent) -> None:
        self._children.append(component)

    def remove(self, component: FileSystemComponent) -> None:
        self._children.remove(component)

    def list_contents(self, indent: int = 0) -> None:
        """Recursively print directory contents."""
        print("  " * indent + f"[DIR] {self._name} (Size: {self.get_size()})")
        for child in self._children:
            if isinstance(child, Directory):
                child.list_contents(indent + 1)
            else:
                print("  " * (indent + 1) + f"[FILE] {child.get_name()} (Size: {child.get_size()})")


# Usage
# Build a file system tree
root = Directory("root")
docs = Directory("documents")
photos = Directory("photos")

root.add(docs)
root.add(photos)

docs.add(File("resume.pdf", 1024))
docs.add(File("notes.txt", 512))

photos.add(File("vacation.jpg", 2048))
photos.add(Directory("selfies"))  # Nested directory
photos.get_children()[-1].add(File("me.jpg", 1024))  # Add file to nested directory

# Client treats root (composite) uniformly with files (leaves)
print(f"Total size of root: {root.get_size()} bytes")  # 1024 + 512 + 2048 + 1024 = 4608
root.list_contents()  # Recursively print the tree

Key Takeaways

  • Use Cases: Hierarchical data (file systems, menus, organization charts).
  • Python Benefit: Duck typing simplifies the component interface (no strict ABC required, but ABCs improve clarity).
  • Key Insight: Composites delegate operations to children (e.g., Directory.get_size() sums child sizes).

Behavioral Patterns

Behavioral patterns focus on communication between objects, defining how they interact and distribute responsibility.

1. Observer

Intent

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

Problem

You need multiple objects to react to changes in another object. For example, a weather station might notify displays (temperature, humidity, forecast) when sensor data updates.

Solution

The Observer pattern has a “Subject” (source of changes) and “Observers” (dependents). Observers register with the Subject, which notifies them of state changes.

Python Implementation

from abc import ABC, abstractmethod
from typing import List

# -------------------
# Observer Interface
# -------------------
class Observer(ABC):
    """Abstract base class for observers."""
    @abstractmethod
    def update(self, temperature: float, humidity: float) -> None:
        pass


# -------------------
# Subject Interface
# -------------------
class Subject(ABC):
    """Abstract base class for subjects (observables)."""
    @abstractmethod
    def register_observer(self, observer: Observer) -> None:
        pass

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

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


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

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

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

    def notify_observers(self) -> None:
        """Notify all registered observers of state changes."""
        for observer in self._observers:
            observer.update(self._temperature, self._humidity)

    def set_measurements(self, temperature: float, humidity: float) -> None:
        """Update state and notify observers."""
        self._temperature = temperature
        self._humidity = humidity
        self.notify_observers()


# -------------------
# Concrete Observers
# -------------------
class CurrentConditionsDisplay(Observer):
    def update(self, temperature: float, humidity: float) -> None:
        print(f"Current Conditions: {temperature}°C, {humidity}% humidity")


class ForecastDisplay(Observer):
    def update(self, temperature: float, humidity: float) -> None:
        # Simplified forecast logic
        forecast_temp = temperature + 2.0 if humidity < 60 else temperature - 1.0
        print(f"Forecast: {forecast_temp}°C (based on current {temperature}°C, {humidity}% humidity)")


# Usage
weather_station = WeatherStation()

# Register observers
current_display = CurrentConditionsDisplay()
forecast_display = ForecastDisplay()
weather_station.register_observer(current_display)
weather_station.register_observer(forecast_display)

# Simulate new weather data
print("--- New Measurements: 25°C, 50% humidity ---")
weather_station.set_measurements(25.0, 50.0)
# Output:
# Current Conditions: 25.0°C, 50% humidity
# Forecast: 27.0°C (based on current 25.0°C, 50% humidity)

print("\n--- New Measurements: 30°C, 70% humidity ---")
weather_station.set_measurements(30.0, 70.0)
# Output:
# Current Conditions: 30.0°C, 70% humidity
# Forecast: 29.0°C (based on current 30.0°C, 70% humidity)

Key Takeaways

  • Use Cases: Event-driven systems (e.g., UI frameworks, real-time data feeds).
  • Python Built-Ins: tornado and PyQt use Observer-like patterns for event handling.
  • Caveats: Ensure observers unregister when no longer needed to prevent memory leaks.

2. Strategy

Intent

Define a family of algorithms, encapsulate each one, and make them interchangeable.

Problem

You need to switch between different algorithms dynamically. For example, a payment processor might support credit cards, PayPal, or Bitcoin, with each requiring distinct logic.

Solution

The Strategy pattern encapsulates each algorithm in a separate “strategy” class, allowing clients to swap strategies at runtime.

Python Implementation

from abc import ABC, abstractmethod

# -------------------
# Strategy Interface
# -------------------
class PaymentStrategy(ABC):
    """Abstract base class for payment methods."""
    @abstractmethod
    def pay(self, amount: float) -> None:
        pass


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

    def pay(self, amount: float) -> None:
        print(f"Paid ${amount:.2f} via Credit Card (****-****-****-{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:.2f} via PayPal (Account: {self.email})")


class BitcoinPayment(PaymentStrategy):
    def __init__(self, wallet_address: str):
        self.wallet_address = wallet_address

    def pay(self, amount: float) -> None:
        print(f"Paid ${amount:.2f} via Bitcoin (Wallet: {self.wallet_address[:8]}...)")


# -------------------
# Context
# -------------------
class ShoppingCart:
    def __init__(self):
        self._items: list[float] = []
        self._payment_strategy: PaymentStrategy | None = None

    def add_item(self, price: float) -> None:
        self._items.append(price)

    def set_payment_strategy(self, strategy: PaymentStrategy) -> None:
        """Set the payment strategy dynamically."""
        self._payment_strategy = strategy

    def checkout(self) -> None:
        if not self._payment_strategy:
            raise ValueError("No payment strategy set!")
        total = sum(self._items)
        self._payment_strategy.pay(total)


# Usage
cart = ShoppingCart()
cart.add_item(49.99)
cart.add_item(29.99)

# Pay with Credit Card
cart.set_payment_strategy(CreditCardPayment("1234-5678-9012-3456", "123"))
cart.checkout()  # Paid $79.98 via Credit Card (****-****-****-3456)

# Switch to PayPal
cart.set_payment_strategy(PayPalPayment("[email protected]"))
cart.checkout()  # Paid $79.98 via PayPal (Account: [email protected])

Key Takeaways

  • Use Cases: Dynamic algorithm selection (e.g., sorting, compression, validation).
  • Python Flexibility: Strategies can be functions instead of classes (using functools.partial for stateful strategies).
  • Benefits: Eliminates conditional logic (e.g., if payment_type == "credit_card": ... elif ...).

3. Command

Intent

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

Problem

You need to decouple the sender of a request from the receiver (e.g., a remote control button shouldn’t know about the device it controls). You also want to support undo/redo or queue requests.

Solution

The Command pattern wraps a request in a “command” object that exposes an execute() method. Senders (e.g., buttons) trigger commands, and receivers (e.g., devices) perform the work.

Python Implementation

Let’s build a remote control with buttons that execute commands (e.g., turn on a light, play music):

from abc import ABC, abstractmethod

# -------------------
# Command Interface
# -------------------
class Command(ABC):
    """Abstract base class for commands."""
    @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  # Receiver

    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 StereoOnCommand(Command):
    def __init__(self, stereo: "Stereo"):
        self._stereo = stereo

    def execute(self) -> None:
        self._stereo.turn_on()
        self._stereo.set_volume(10)

    def undo(self) -> None:
        self._stereo.turn_off()


# -------------------
# Receivers
# -------------------
class Light:
    def turn_on(self) -> None:
        print("Light is ON")

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


class Stereo:
    def turn_on(self) -> None:
        print("Stereo is ON")

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

    def set_volume(self, level: int) -> None:
        print(f"Stereo volume set to {level}")


# -------------------
# Invoker
# -------------------
class RemoteControl:
    def __init__(self):
        self._commands: dict[int, Command] = {}  # Button -> Command
        self._last_command: Command | None = None  # For undo

    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._last_command = command

    def press_undo(self) -> None:
        if self._last_command:
            print("Undoing last command...")
            self._last_command.undo()


# Usage
# Create receivers
living_room_light = Light()
stereo = Stereo()

# Create commands
light_on = LightOnCommand(living_room_light)
light_off = LightOffCommand(living_room_light)
stereo_on = StereoOnCommand(stereo)

# Configure remote
remote = RemoteControl()
remote.set_command(0, light_on)    # Button 0: Turn light on
remote.set_command(1, light_off)   # Button 1: Turn light off
remote.set_command(2, stereo_on)   # Button 2: Turn stereo on

# Press buttons
remote.press_button(0)  # Light is ON
remote.press_button(2)  # Stereo is ON; Stereo volume set to 10
remote.press_undo()     # Undoing last command...; Stereo is OFF
remote.press_button(1)  # Light is OFF

Key Takeaways

  • Use Cases: Undo/redo, queuing requests, logging actions (e.g., text editors, remote controls).
  • Python Trick: Use __call__ to make commands callable (e.g., class Command: def __call__(self): self.execute()).
  • Benefits: Decouples senders (remote buttons) from receivers (lights, stereo).

Conclusion

Design patterns are not rigid rules but tools to solve common problems. Python’s flexibility—dynamic typing, first-class functions, and built-in abstractions—allows for pragmatic, often simplified implementations of GoF patterns.

By understanding the intent behind each pattern, you can apply them judiciously to write cleaner, more maintainable code. Remember: “Don’t shoehorn patterns into code”—use them only when they solve a specific problem.

References