py4u guide

Exploring the Most Essential Design Patterns in Python

Design patterns are reusable, time-tested solutions to common software design problems. They represent best practices evolved by experienced developers to address recurring challenges in code organization, scalability, and maintainability. Popularized by the "Gang of Four" (GoF) book *Design Patterns: Elements of Reusable Object-Oriented Software*, these patterns provide a shared vocabulary for developers to communicate complex ideas concisely. Python, with its flexibility, dynamic typing, and support for multiple paradigms (object-oriented, functional, procedural), is an excellent language for implementing design patterns. Its简洁 syntax and built-in features (e.g., decorators, metaclasses, and duck typing) simplify pattern adoption, making even complex patterns accessible. In this blog, we’ll explore **10 essential design patterns** across three categories: *Creational*, *Structural*, and *Behavioral*. For each pattern, we’ll break down its purpose, the problem it solves, a hands-on Python implementation, real-world use cases, and pros/cons to help you decide when to use it.

Table of Contents

  1. What Are Design Patterns?
  2. Creational Patterns
  3. Structural Patterns
  4. Behavioral Patterns
  5. Conclusion
  6. References

What Are Design Patterns?

Design patterns are not code snippets or libraries; they are templates for solving specific design problems. They focus on how to structure classes and objects to interact effectively. Patterns are categorized into three types:

  • Creational: Handle object creation mechanisms, ensuring flexibility and reuse.
  • Structural: Deal with object composition, simplifying relationships between classes.
  • Behavioral: Manage communication and interaction between objects.

Patterns are not one-size-fits-all. Overusing them can complicate code, so they should be applied only when the problem they solve is clearly present.

Creational Patterns

Creational patterns abstract the instantiation process, making a system independent of how its objects are created, composed, and represented.

1. Singleton

Overview

Ensures a class has only one instance and provides a global point of access to it.

Problem

You need a single instance of a class (e.g., a database connection pool, logger, or configuration manager) to avoid redundant resource usage or inconsistent state.

Solution

Restrict the class constructor to prevent multiple instantiations. Provide a static method to return the single instance.

Python Implementation

In Python, the Singleton can be implemented using a metaclass (the class of a class), which controls class instantiation:

class SingletonMeta(type):
    """Metaclass to enforce Singleton behavior."""
    _instances = {}  # Stores instances of classes using this metaclass

    def __call__(cls, *args, **kwargs):
        """Controls instantiation: returns existing instance or creates a new one."""
        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 DatabaseConnection(metaclass=SingletonMeta):
    """Example class using Singleton pattern for a database connection."""
    def __init__(self, connection_string):
        self.connection_string = connection_string
        print(f"Initializing connection with: {connection_string}")

    def query(self, sql):
        print(f"Executing query: {sql}")


# Usage
db1 = DatabaseConnection("postgresql://user:pass@localhost/db")
db2 = DatabaseConnection("mysql://user:pass@localhost/db")  # Ignored; uses existing instance

print(db1 is db2)  # Output: True (both are the same instance)

Use Cases

  • Configuration managers (ensure all parts of the app use the same config).
  • Loggers (centralize log output to a single file).
  • Thread pools or connection pools (avoid excessive resource creation).

Pros & Cons

  • Pros: Controlled access to a single instance; reduces resource usage.
  • Cons: Global state can make testing harder (tight coupling); violates Single Responsibility Principle (controls both its logic and instantiation).

2. Factory Method

Overview

Defines an interface for creating objects but lets subclasses decide which class to instantiate.

Problem

A class cannot anticipate the type of objects it needs to create. For example, a UI framework may need to create buttons, but the button type (Windows, macOS) depends on the operating system.

Solution

Delegate object creation to subclasses via a “factory method.” The base class declares the method, and subclasses override it to produce specific objects.

Python Implementation

Example: A pizza shop where different regions (NY, Chicago) make distinct pizza styles:

from abc import ABC, abstractmethod

# Product: Pizza (interface)
class Pizza(ABC):
    @abstractmethod
    def prepare(self):
        pass

    @abstractmethod
    def bake(self):
        pass

# Concrete Products
class NYStyleCheesePizza(Pizza):
    def prepare(self):
        print("Preparing NY Style Cheese Pizza (thin crust, tomato sauce)")

    def bake(self):
        print("Baking at 350°F for 25 minutes")

class ChicagoStyleCheesePizza(Pizza):
    def prepare(self):
        print("Preparing Chicago Style Cheese Pizza (deep dish, thick crust)")

    def bake(self):
        print("Baking at 400°F for 40 minutes")

# Creator: Pizza Store (interface with factory method)
class PizzaStore(ABC):
    def order_pizza(self):
        # Let subclass decide pizza type via factory_method
        pizza = self.create_pizza()
        pizza.prepare()
        pizza.bake()
        return pizza

    @abstractmethod
    def create_pizza(self):  # Factory Method
        pass

# Concrete Creators
class NYPizzaStore(PizzaStore):
    def create_pizza(self):
        return NYStyleCheesePizza()

class ChicagoPizzaStore(PizzaStore):
    def create_pizza(self):
        return ChicagoStyleCheesePizza()


# Usage
ny_store = NYPizzaStore()
chicago_store = ChicagoPizzaStore()

ny_pizza = ny_store.order_pizza()  # Prepares and bakes NY-style pizza
chicago_pizza = chicago_store.order_pizza()  # Prepares and bakes Chicago-style pizza

Use Cases

  • Libraries/Frameworks (e.g., SQLAlchemy using create_engine to return database-specific engines).
  • Plugins (e.g., a text editor allowing plugins to register new file format handlers via a factory).

Pros & Cons

  • Pros: Decouples object creation from usage; easy to add new product types (just add a new subclass of the creator).
  • Cons: Requires creating a new subclass for each product type, which can lead to class explosion.

3. Builder

Overview

Separates object construction from its representation, allowing the same construction process to create different representations.

Problem

Creating complex objects with many optional components (e.g., a meal with a main dish, side, drink, and dessert). Direct instantiation with a constructor leads to bloated, unreadable code (e.g., Meal("Burger", "Fries", "Coke", "Ice Cream", True, False)).

Solution

Use a “builder” class to construct the object step-by-step. A “director” class controls the construction process, using the builder to assemble the object.

Python Implementation

Example: Building a customizable meal (veggie/non-veggie) with optional items:

from abc import ABC, abstractmethod

# Product: Meal
class Meal:
    def __init__(self):
        self.items = []

    def add_item(self, item):
        self.items.append(item)

    def show_items(self):
        print("Meal Items:")
        for item in self.items:
            print(f"- {item.name}, Price: ${item.price}")

# Components of the Product (items in the meal)
class Item(ABC):
    @property
    @abstractmethod
    def name(self):
        pass

    @property
    @abstractmethod
    def price(self):
        pass

class Burger(Item):
    pass

class VegBurger(Burger):
    @property
    def name(self):
        return "Veg Burger"

    @property
    def price(self):
        return 3.50

class ChickenBurger(Burger):
    @property
    def name(self):
        return "Chicken Burger"

    @property
    def price(self):
        return 4.50

# Builder: Defines steps to build a Meal
class MealBuilder(ABC):
    @abstractmethod
    def add_burger(self):
        pass

    @abstractmethod
    def add_drink(self):
        pass

    @abstractmethod
    def get_meal(self):
        pass

# Concrete Builders
class VegMealBuilder(MealBuilder):
    def __init__(self):
        self.meal = Meal()

    def add_burger(self):
        self.meal.add_item(VegBurger())

    def add_drink(self):
        self.meal.add_item(Coke())  # Assume Coke is a Drink Item

    def get_meal(self):
        return self.meal

class NonVegMealBuilder(MealBuilder):
    def __init__(self):
        self.meal = Meal()

    def add_burger(self):
        self.meal.add_item(ChickenBurger())

    def add_drink(self):
        self.meal.add_item(Pepsi())  # Assume Pepsi is a Drink Item

    def get_meal(self):
        return self.meal

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

    def construct_meal(self):
        self.builder.add_burger()
        self.builder.add_drink()

    def get_meal(self):
        return self.builder.get_meal()


# Usage
veg_builder = VegMealBuilder()
waiter = Waiter(veg_builder)
waiter.construct_meal()
veg_meal = waiter.get_meal()
veg_meal.show_items()  # Output: Veg Burger ($3.50), Coke ($1.50)

Use Cases

  • Complex object creation (e.g., JSON/XML parsers, where the same parsing logic can build different data structures).
  • Meal kits, car configurators (build a car with optional features like GPS, sunroof).

Pros & Cons

  • Pros: Separates construction from representation; allows fine-grained control over object creation.
  • Cons: Adds complexity (requires builder and director classes); overkill for simple objects.

Structural Patterns

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

1. Adapter

Overview

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

Problem

A legacy system has a useful class, but its interface doesn’t match the new code’s requirements. For example, a European electrical device (220V) needs to work with a US socket (110V).

Solution

Wrap the legacy class in an “adapter” that translates its interface to the one the client expects.

Python Implementation

Example: A legacy EuropeanPlug with a provide_220v() method, adapted to work with a USSocket expecting provide_110v():

# Target: Interface client expects
class USSocket:
    def provide_110v(self):
        return "110V power"

# Adaptee: Legacy class with incompatible interface
class EuropeanPlug:
    def provide_220v(self):
        return "220V power"

# Adapter: Wraps Adaptee to match Target interface
class PlugAdapter(USSocket):
    def __init__(self, european_plug):
        self.european_plug = european_plug

    def provide_110v(self):
        # Convert 220V to 110V (simulated here)
        power = self.european_plug.provide_220v()
        return power.replace("220V", "110V")  # Simulate voltage conversion


# Usage
euro_plug = EuropeanPlug()
adapter = PlugAdapter(euro_plug)
print(adapter.provide_110v())  # Output: "110V power" (adapted from 220V)

Use Cases

  • Integrating legacy code with modern systems (e.g., using a Python 2 library in Python 3 via an adapter).
  • Third-party API integration (e.g., adapting Stripe’s API to match your internal payment processor interface).

Pros & Cons

  • Pros: Reuses existing code without modifying it; decouples client from adaptee.
  • Cons: Adds a layer of indirection; can introduce performance overhead for simple adaptations.

2. Decorator

Overview

Dynamically adds new responsibilities to objects without altering their structure.

Problem

You need to add features to an object (e.g., logging, caching) without subclassing every possible combination (leading to a “class explosion”).

Solution

Wrap the object in “decorator” classes that add behavior before/after delegating to the wrapped object.

Python Implementation

Example: Adding toppings to a pizza (base pizza + cheese + pepperoni, each decorator adds cost and description):

from abc import ABC, abstractmethod

# Component: Pizza (interface)
class Pizza(ABC):
    @abstractmethod
    def get_description(self):
        pass

    @abstractmethod
    def get_cost(self):
        pass

# Concrete Component: Base pizza
class MargheritaPizza(Pizza):
    def get_description(self):
        return "Margherita Pizza"

    def get_cost(self):
        return 8.99

# Decorator: Wraps Pizza to add toppings
class ToppingDecorator(Pizza):
    def __init__(self, pizza):
        self.pizza = pizza  # Wrapped pizza

    @abstractmethod
    def get_description(self):
        pass

    @abstractmethod
    def get_cost(self):
        pass

# Concrete Decorators
class CheeseTopping(ToppingDecorator):
    def get_description(self):
        return f"{self.pizza.get_description()}, Extra Cheese"

    def get_cost(self):
        return self.pizza.get_cost() + 1.50

class PepperoniTopping(ToppingDecorator):
    def get_description(self):
        return f"{self.pizza.get_description()}, Pepperoni"

    def get_cost(self):
        return self.pizza.get_cost() + 2.00


# Usage
pizza = MargheritaPizza()
print(pizza.get_description())  # "Margherita Pizza"
print(pizza.get_cost())         # $8.99

pizza = CheeseTopping(pizza)    # Add cheese
print(pizza.get_description())  # "Margherita Pizza, Extra Cheese"
print(pizza.get_cost())         # $10.49

pizza = PepperoniTopping(pizza) # Add pepperoni
print(pizza.get_description())  # "Margherita Pizza, Extra Cheese, Pepperoni"
print(pizza.get_cost())         # $12.49

Use Cases

  • Adding features dynamically (e.g., logging, compression, encryption in web frameworks like Flask/Django).
  • GUI toolkits (adding borders, shadows to buttons without subclassing).

Pros & Cons

  • Pros: Adds responsibilities dynamically; avoids subclassing for every combination.
  • Cons: Can lead to a large number of small decorator classes; debugging is harder (multiple layers of wrapping).

2. Decorator (Python’s Built-in vs. Pattern)

Note: Python’s @decorator syntax is a language feature for wrapping functions/methods, but the Decorator Pattern focuses on object composition (as above). Both share the goal of adding behavior dynamically.

3. Composite

Overview

Treats individual objects and collections of objects uniformly. Clients interact with a single object or a group of objects the same way.

Problem

You need to represent hierarchical structures (e.g., file systems, organization charts) where elements can be either leaves (individual items) or composites (collections of items).

Solution

Define a common interface for leaves and composites. Composites contain child elements (leaves or other composites) and delegate operations to them.

Python Implementation

Example: A file system with File (leaf) and Directory (composite) elements, both with a get_size() method:

from abc import ABC, abstractmethod

# Component: Common interface for leaves and composites
class FileSystemElement(ABC):
    @abstractmethod
    def get_size(self):
        pass

    @abstractmethod
    def get_name(self):
        pass

# Leaf: Individual element (File)
class File(FileSystemElement):
    def __init__(self, name, size):
        self.name = name
        self.size = size

    def get_size(self):
        return self.size

    def get_name(self):
        return self.name

# Composite: Collection of elements (Directory)
class Directory(FileSystemElement):
    def __init__(self, name):
        self.name = name
        self.children = []  # Contains Files or Directories

    def add_child(self, element):
        self.children.append(element)

    def remove_child(self, element):
        self.children.remove(element)

    def get_size(self):
        # Sum size of all children (recursively)
        return sum(child.get_size() for child in self.children)

    def get_name(self):
        return self.name

    def list_contents(self, indent=0):
        print("  " * indent + f"- {self.name}/ (Size: {self.get_size()}B)")
        for child in self.children:
            if isinstance(child, Directory):
                child.list_contents(indent + 1)
            else:
                print("  " * (indent + 1) + f"- {child.get_name()} (Size: {child.get_size()}B)")


# Usage
# Create files
file1 = File("notes.txt", 1024)  # 1KB
file2 = File("image.jpg", 204800) # 200KB

# Create directories
docs_dir = Directory("Documents")
docs_dir.add_child(file1)

photos_dir = Directory("Photos")
photos_dir.add_child(file2)

root_dir = Directory("Root")
root_dir.add_child(docs_dir)
root_dir.add_child(photos_dir)

# List contents and total size
root_dir.list_contents()
# Output:
# - Root/ (Size: 205824B)
#   - Documents/ (Size: 1024B)
#     - notes.txt (Size: 1024B)
#   - Photos/ (Size: 204800B)
#     - image.jpg (Size: 204800B)

Use Cases

  • File systems, organization charts, XML/HTML DOM (elements can contain text or other elements).
  • UI components (menus with submenus, where both menus and menu items have render() methods).

Pros & Cons

  • Pros: Clients treat leaves and composites uniformly; simplifies traversal of hierarchical structures.
  • Cons: Hard to restrict composite contents (e.g., a directory can’t contain a video file in some systems); can make type checks necessary.

Behavioral Patterns

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

1. Observer

Overview

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

Problem

A weather station collects data (temperature, humidity) and needs to update multiple displays (e.g., phone app, web dashboard, LCD screen) in real time.

Solution

The subject maintains a list of observers, notifying them of state changes via a common interface.

Python Implementation

Example: A weather station notifying displays of temperature updates:

from abc import ABC, abstractmethod

# Observer: Interface for objects that need updates
class Observer(ABC):
    @abstractmethod
    def update(self, temperature):
        pass

# Subject: Interface for objects being observed
class Subject(ABC):
    @abstractmethod
    def attach(self, observer):
        pass

    @abstractmethod
    def detach(self, observer):
        pass

    @abstractmethod
    def notify(self):
        pass

# Concrete Subject: Weather Station
class WeatherStation(Subject):
    def __init__(self):
        self.observers = []
        self.temperature = 0.0

    def attach(self, observer):
        self.observers.append(observer)

    def detach(self, observer):
        self.observers.remove(observer)

    def notify(self):
        # Notify all observers of new temperature
        for observer in self.observers:
            observer.update(self.temperature)

    def set_temperature(self, new_temp):
        print(f"Weather Station: Temperature updated to {new_temp}°C")
        self.temperature = new_temp
        self.notify()  # Trigger updates

# Concrete Observers
class PhoneDisplay(Observer):
    def update(self, temperature):
        print(f"Phone Display: Current Temp = {temperature}°C")

class WebDashboard(Observer):
    def update(self, temperature):
        print(f"Web Dashboard: Temp Alert! {temperature}°C (High: 30°C)")


# Usage
station = WeatherStation()
phone_display = PhoneDisplay()
web_dashboard = WebDashboard()

# Attach observers
station.attach(phone_display)
station.attach(web_dashboard)

# Update temperature (triggers notifications)
station.set_temperature(25.5)
# Output:
# Weather Station: Temperature updated to 25.5°C
# Phone Display: Current Temp = 25.5°C
# Web Dashboard: Temp Alert! 25.5°C (High: 30°C)

# Detach web dashboard
station.detach(web_dashboard)
station.set_temperature(30.0)
# Output:
# Weather Station: Temperature updated to 30.0°C
# Phone Display: Current Temp = 30.0°C (Web Dashboard no longer notified)

Use Cases

  • Event-driven systems (e.g., GUI frameworks, where buttons notify listeners on click).
  • Stock tickers (notifying investors of price changes), chat apps (notifying users of new messages).

Pros & Cons

  • Pros: Decouples subject and observers; supports dynamic attachment/detachment.
  • Cons: Observers may receive unnecessary updates; can lead to memory leaks if observers are not detached properly.

2. Strategy

Overview

Defines a family of interchangeable algorithms and encapsulates each, allowing them to be swapped dynamically.

Problem

An object needs to use different variants of an algorithm (e.g., sorting, payment processing) based on context. For example, an e-commerce app may support credit card, PayPal, or Bitcoin payments.

Solution

Encapsulate each algorithm in a “strategy” class, and make the context object use a strategy to delegate the work.

Python Implementation

Example: Order processing with interchangeable payment strategies:

from abc import ABC, abstractmethod

# Strategy: Payment algorithm interface
class PaymentStrategy(ABC):
    @abstractmethod
    def pay(self, amount):
        pass

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

    def pay(self, amount):
        print(f"Paid ${amount} via Credit Card (****-****-****-{self.card_number[-4:]})")

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

    def pay(self, amount):
        print(f"Paid ${amount} via PayPal (Account: {self.email})")

# Context: Uses a strategy to perform work
class Order:
    def __init__(self, total_amount):
        self.total_amount = total_amount
        self.payment_strategy = None  # Strategy to use

    def set_payment_strategy(self, strategy):
        self.payment_strategy = strategy

    def process_payment(self):
        if not self.payment_strategy:
            raise ValueError("No payment strategy set!")
        self.payment_strategy.pay(self.total_amount)


# Usage
order = Order(99.99)

# Pay with Credit Card
cc_strategy = CreditCardPayment("4111-1111-1111-1234", "123")
order.set_payment_strategy(cc_strategy)
order.process_payment()  # Output: Paid $99.99 via Credit Card (****-****-****-1234)

# Switch to PayPal
paypal_strategy = PayPalPayment("[email protected]")
order.set_payment_strategy(paypal_strategy)
order.process_payment()  # Output: Paid $99.99 via PayPal (Account: [email protected])

Use Cases

  • Sorting algorithms (swap between quicksort, mergesort based on data size).
  • Shipping calculators (standard, expedited, international shipping).

Pros & Cons

  • Pros: Encapsulates algorithms; allows runtime switching; open/closed principle (add new strategies without changing context).
  • Cons: Requires clients to be aware of different strategies; increases number of classes.

3. Command

Overview

Encapsulates a request as an object, allowing parameterization of clients with different requests, queuing of requests, and support for undo/redo.

Problem

You need to decouple the object that issues a request (client) from the object that performs it (receiver). For example, a remote control should operate multiple devices (TV, lights) without knowing their internal logic.

Solution

Wrap each request in a “command” object that contains all information needed to execute it. The client triggers the command, which delegates to the receiver.

Python Implementation

Example: A remote control with buttons that execute commands (turn TV on/off, dim lights):

from abc import ABC, abstractmethod

# Command: Interface for all commands
class Command(ABC):
    @abstractmethod
    def execute(self):
        pass

    @abstractmethod
    def undo(self):
        pass

# Receiver: The object that performs the work
class TV:
    def on(self):
        print("TV is ON")

    def off(self):
        print("TV is OFF")

class Light:
    def __init__(self):
        self.brightness = 0  # 0-100

    def dim(self, level):
        self.brightness = level
        print(f"Light dimmed to {self.brightness}%")

# Concrete Commands
class TVOnCommand(Command):
    def __init__(self, tv):
        self.tv = tv  # Receiver

    def execute(self):
        self.tv.on()

    def undo(self):
        self.tv.off()

class TVOffCommand(Command):
    def __init__(self, tv):
        self.tv = tv

    def execute(self):
        self.tv.off()

    def undo(self):
        self.tv.on()

class LightDimCommand(Command):
    def __init__(self, light, level):
        self.light = light  # Receiver
        self.target_level = level
        self.prev_level = light.brightness  # Store previous state for undo

    def execute(self):
        self.prev_level = self.light.brightness  # Update before dimming
        self.light.dim(self.target_level)

    def undo(self):
        self.light.dim(self.prev_level)

# Invoker: Triggers commands (remote control)
class RemoteControl:
    def __init__(self):
        self.commands = {}  # Maps button names to commands
        self.last_command = None  # For undo

    def set_command(self, button_name, command):
        self.commands[button_name] = command

    def press_button(self, button_name):
        if button_name in self.commands:
            command = self.commands[button_name]
            command.execute()
            self.last_command = command  # Track last command for undo

    def press_undo(self):
        if self.last_command:
            print("Undoing last action...")
            self.last_command.undo()


# Usage
# Create receivers
tv = TV()
light = Light()

# Create commands
tv_on_cmd = TVOnCommand(tv)
tv_off_cmd = TVOffCommand(tv)
light_dim_cmd = LightDimCommand(light, 75)  # Dim to 75%

# Configure remote
remote = RemoteControl()
remote.set_command("TV On", tv_on_cmd)
remote.set_command("TV Off", tv_off_cmd)
remote.set_command("Dim Light", light_dim_cmd)

# Press buttons
remote.press_button("TV On")  # TV is ON
remote.press_button("Dim Light")  # Light dimmed to 75%
remote.press_undo()  # Undo: Light dimmed to 0% (previous level)
remote.press_button("TV Off")  # TV is OFF
remote.press_undo()  # Undo: TV is ON

Use Cases

  • GUI buttons, menu items (each triggers a command).
  • Task queues, undo/redo functionality (e.g., text editors), macro recording.

Pros & Cons

  • Pros: Decouples sender and receiver; supports undo/redo, queuing, logging of requests.
  • Cons: Increases number of classes (one command per action); overhead for simple requests.

Conclusion

Design patterns are powerful tools for writing clean, maintainable, and scalable Python code. By understanding when and how to apply them—Singleton for single instances, Factory Method for flexible object creation, Observer for event handling—you can solve complex problems with proven solutions.

Remember: Patterns are guidelines, not rules. Overusing them can make code harder to read. Always prioritize simplicity, and reach for a pattern only when it clearly solves a problem you’re facing.

References