py4u guide

A Guide to Python OOP Design Patterns

Object-Oriented Programming (OOP) is a paradigm centered around "objects"—data structures containing attributes and methods. While OOP simplifies code organization, building scalable, maintainable, and reusable systems requires more than just classes and inheritance. This is where **design patterns** come in. Design patterns are reusable, proven solutions to common software design problems. Coined by the "Gang of Four" (GoF) in their 1994 book *Design Patterns: Elements of Reusable Object-Oriented Software*, these patterns provide a shared vocabulary for developers to communicate complex ideas succinctly. Python, with its flexible syntax, dynamic typing, and built-in OOP features (classes, inheritance, polymorphism), is an excellent language for implementing design patterns. This guide will break down the most essential OOP design patterns, categorized by their purpose, with practical Python examples, use cases, and tradeoffs.

Table of Contents

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

What Are Design Patterns?

Design patterns are not code snippets or libraries—they are abstract solutions to recurring problems in software design. They:

  • Promote reusability: Solve problems that appear across projects.
  • Enhance readability: Use a shared vocabulary (e.g., “Singleton” or “Observer”) to describe solutions.
  • Improve maintainability: Decouple components, making code easier to modify.

Patterns are typically categorized into three types:

  • Creational: Focus on object creation (e.g., Singleton, Factory).
  • Structural: Deal with object composition (e.g., Decorator, Adapter).
  • Behavioral: Manage object interactions and responsibilities (e.g., Observer, Strategy).

Creational Patterns

Creational patterns control object creation, ensuring flexibility and decoupling between object creation and usage.

Singleton

Intent

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

Problem

You need to restrict instantiation of a class to one object (e.g., a database connection pool, logger, or configuration manager). Multiple instances could cause conflicts (e.g., duplicate database connections).

Solution

Use a class that controls its instantiation, ensuring only one instance exists. In Python, this can be implemented using the __new__ method (which creates instances) or a metaclass.

Example: Database Connection Singleton

class DatabaseConnection:
    _instance = None  # Class-level variable to hold the single instance

    def __new__(cls):
        if cls._instance is None:
            print("Creating new database connection...")
            cls._instance = super().__new__(cls)
            # Initialize connection (e.g., to PostgreSQL)
            cls._instance.connection = "psycopg2.connect(...)"
        return cls._instance

# Usage
db1 = DatabaseConnection()
db2 = DatabaseConnection()

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

Use Cases

  • Loggers (prevent multiple log files).
  • Configuration managers (centralize app settings).
  • Thread pools or connection pools.

Pros & Cons

  • Pros: Single instance, controlled access, reduces resource usage.
  • Cons: Global state can make testing harder; violates Single Responsibility Principle (handles instantiation and business logic).

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 the creator and the product.

Solution

Create a “factory” method in a base class that subclasses override to produce specific objects.

Example: Document Converter Factory

Suppose you need to convert files (PDF, DOCX, TXT) to HTML. Use a factory to create the appropriate converter:

from abc import ABC, abstractmethod

# Product interface: All converters must implement convert()
class Converter(ABC):
    @abstractmethod
    def convert(self, file_path: str) -> str:
        pass

# Concrete Products
class PDFConverter(Converter):
    def convert(self, file_path: str) -> str:
        return f"Converted PDF at {file_path} to HTML"

class DOCXConverter(Converter):
    def convert(self, file_path: str) -> str:
        return f"Converted DOCX at {file_path} to HTML"

# Creator interface: Defines the factory method
class ConverterCreator(ABC):
    @abstractmethod
    def create_converter(self) -> Converter:
        pass  # Subclasses implement this

    def convert_document(self, file_path: str) -> str:
        converter = self.create_converter()
        return converter.convert(file_path)

# Concrete Creators
class PDFConverterCreator(ConverterCreator):
    def create_converter(self) -> Converter:
        return PDFConverter()

class DOCXConverterCreator(ConverterCreator):
    def create_converter(self) -> Converter:
        return DOCXConverter()

# Usage
pdf_creator = PDFConverterCreator()
print(pdf_creator.convert_document("report.pdf"))  # Output: Converted PDF at report.pdf to HTML

docx_creator = DOCXConverterCreator()
print(docx_creator.convert_document("resume.docx"))  # Output: Converted DOCX at resume.docx to HTML

Use Cases

  • Frameworks (e.g., web frameworks creating controllers/views based on routes).
  • Plugins (dynamically loading plugins via factories).

Pros & Cons

  • Pros: Decouples product creation from usage; easy to add new products.
  • Cons: Requires creating a new creator subclass for each product.

Abstract Factory

Intent

Provide an interface for creating families of related or dependent objects without specifying their concrete classes.

Problem

You need to create objects that belong to a “family” (e.g., UI components for Windows vs. macOS) and ensure consistency between them.

Solution

Define an abstract factory with methods for creating each family member. Concrete factories implement these methods for specific families.

Example: UI Component Factory

from abc import ABC, abstractmethod

# Abstract Products
class Button(ABC):
    @abstractmethod
    def render(self) -> str:
        pass

class Checkbox(ABC):
    @abstractmethod
    def render(self) -> str:
        pass

# Concrete Products (Windows)
class WindowsButton(Button):
    def render(self) -> str:
        return "Rendering Windows-style button"

class WindowsCheckbox(Checkbox):
    def render(self) -> str:
        return "Rendering Windows-style checkbox"

# Concrete Products (macOS)
class MacOSButton(Button):
    def render(self) -> str:
        return "Rendering macOS-style button"

class MacOSCheckbox(Checkbox):
    def render(self) -> str:
        return "Rendering macOS-style checkbox"

# Abstract Factory
class UIFactory(ABC):
    @abstractmethod
    def create_button(self) -> Button:
        pass

    @abstractmethod
    def create_checkbox(self) -> Checkbox:
        pass

# Concrete Factories
class WindowsUIFactory(UIFactory):
    def create_button(self) -> Button:
        return WindowsButton()

    def create_checkbox(self) -> Checkbox:
        return WindowsCheckbox()

class MacOSUIFactory(UIFactory):
    def create_button(self) -> Button:
        return MacOSButton()

    def create_checkbox(self) -> Checkbox:
        return MacOSCheckbox()

# Client Code
def build_ui(factory: UIFactory):
    button = factory.create_button()
    checkbox = factory.create_checkbox()
    print(button.render())
    print(checkbox.render())

# Usage
os = "windows"  # Determined at runtime (e.g., via OS detection)
if os == "windows":
    factory = WindowsUIFactory()
else:
    factory = MacOSUIFactory()

build_ui(factory)
# Output (Windows):
# Rendering Windows-style button
# Rendering Windows-style checkbox

Use Cases

  • Cross-platform applications (ensuring UI consistency).
  • Theming (light/dark mode components).

Pros & Cons

  • Pros: Ensures product compatibility; isolates concrete classes.
  • Cons: Hard to add new product types (requires modifying all factories).

Builder

Intent

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

Problem

You need to create an object with many optional components (e.g., a pizza with crust, cheese, toppings) without bloating the constructor.

Solution

Use a builder class to step-by-step construct the object, then retrieve the final product.

Example: Pizza Builder

class Pizza:
    def __init__(self):
        self.crust = None
        self.cheese = None
        self.toppings = []

    def __str__(self):
        return (f"Pizza with {self.crust} crust, {self.cheese} cheese, "
                f"and toppings: {', '.join(self.toppings)}")

class PizzaBuilder:
    def __init__(self):
        self.pizza = Pizza()

    def set_crust(self, crust: str) -> "PizzaBuilder":
        self.pizza.crust = crust
        return self  # Enable method chaining

    def set_cheese(self, cheese: str) -> "PizzaBuilder":
        self.pizza.cheese = cheese
        return self

    def add_topping(self, topping: str) -> "PizzaBuilder":
        self.pizza.toppings.append(topping)
        return self

    def build(self) -> Pizza:
        return self.pizza

# Usage
builder = PizzaBuilder()
pizza = (builder
         .set_crust("thin")
         .set_cheese("mozzarella")
         .add_topping("pepperoni")
         .add_topping("mushrooms")
         .build())

print(pizza)  # Output: Pizza with thin crust, mozzarella cheese, and toppings: pepperoni, mushrooms

Use Cases

  • Complex objects with many optional parts (e.g., emails, reports).
  • Immutable objects (constructed step-by-step before being frozen).

Pros & Cons

  • Pros: Clear, readable construction; supports different representations.
  • Cons: Requires creating a separate builder for each product type.

Prototype

Intent

Specify the kinds of objects to create using a prototypical instance, and create new objects by copying this prototype.

Problem

Creating new objects is expensive (e.g., database calls to fetch initial data). Instead, clone an existing “prototype” object.

Solution

Implement a clone method in the prototype class to create copies.

Example: User Profile Prototype

import copy

class UserProfile:
    def __init__(self, name: str, role: str, preferences: dict):
        self.name = name
        self.role = role
        self.preferences = preferences  # Mutable attribute (needs deep copy)

    def clone(self) -> "UserProfile":
        # Use deepcopy to copy mutable attributes (e.g., preferences dict)
        return copy.deepcopy(self)

# Create a prototype admin profile
admin_prototype = UserProfile(
    name="Admin",
    role="admin",
    preferences={"theme": "dark", "notifications": True}
)

# Clone the prototype to create a new admin
new_admin = admin_prototype.clone()
new_admin.name = "John Doe"  # Modify only the name

print(new_admin.name)  # Output: John Doe
print(new_admin.role)  # Output: admin (copied from prototype)
print(new_admin.preferences)  # Output: {'theme': 'dark', 'notifications': True} (copied)

Use Cases

  • Expensive object initialization (e.g., data-heavy objects).
  • Dynamic object creation (e.g., user-defined templates).

Pros & Cons

  • Pros: Avoids expensive initialization; easy to create new objects.
  • Cons: Cloning complex objects with circular references can be tricky.

Structural Patterns

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

Decorator

Intent

Attach additional responsibilities to an object dynamically. Decorators provide a flexible alternative to subclassing for extending functionality.

Problem

You want to add features to individual objects (e.g., adding toppings to a coffee) without affecting other objects of the same class.

Solution

Wrap the original object in a “decorator” class that adds new behavior.

Example: Coffee with Add-ons

from abc import ABC, abstractmethod

# Component Interface
class Coffee(ABC):
    @abstractmethod
    def cost(self) -> float:
        pass

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

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

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

# Decorator Base Class
class CoffeeDecorator(Coffee):
    def __init__(self, coffee: Coffee):
        self._coffee = coffee

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

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

# Concrete Decorators (Add-ons)
class MilkDecorator(CoffeeDecorator):
    def cost(self) -> float:
        return self._coffee.cost() + 0.5

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

class SugarDecorator(CoffeeDecorator):
    def cost(self) -> float:
        return self._coffee.cost() + 0.2

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

# Usage
coffee = Espresso()
print(coffee.description())  # Output: Espresso
print(coffee.cost())  # Output: 2.0

# Add milk and sugar
coffee = MilkDecorator(SugarDecorator(coffee))
print(coffee.description())  # Output: Espresso, Sugar, Milk
print(coffee.cost())  # Output: 2.7 (2.0 + 0.2 + 0.5)

Use Cases

  • Adding features to objects at runtime (e.g., logging, caching).
  • Python’s built-in decorators (e.g., @staticmethod, @lru_cache).

Pros & Cons

  • Pros: Flexible, runtime extension; avoids subclass explosion.
  • Cons: Can lead to complex decorator chains that are hard to debug.

Adapter

Intent

Convert the interface of a class into another interface clients expect. Adapter lets classes work together that couldn’t otherwise because of incompatible interfaces.

Problem

You need to use a legacy class with an outdated interface alongside a new system that expects a different interface.

Solution

Create an adapter class that wraps the legacy class and translates calls to the new interface.

Example: Legacy Payment Gateway Adapter

# Legacy class with outdated interface
class LegacyPaymentGateway:
    def process_payment(self, amount: float, card_number: str) -> str:
        return f"Legacy: Charged ${amount} to {card_number}"

# New interface expected by the system
class PaymentProcessor(ABC):
    @abstractmethod
    def charge(self, amount: float, payment_details: dict) -> str:
        pass  # New interface uses a dict for payment details

# Adapter: Wraps LegacyPaymentGateway to match PaymentProcessor
class PaymentAdapter(PaymentProcessor):
    def __init__(self, legacy_gateway: LegacyPaymentGateway):
        self.legacy_gateway = legacy_gateway

    def charge(self, amount: float, payment_details: dict) -> str:
        # Translate new interface (payment_details dict) to legacy interface (card_number str)
        card_number = payment_details["card_number"]
        return self.legacy_gateway.process_payment(amount, card_number)

# Usage
legacy_gateway = LegacyPaymentGateway()
adapter = PaymentAdapter(legacy_gateway)

# New system uses the adapter (follows PaymentProcessor interface)
result = adapter.charge(100.0, {"card_number": "4111-1111-1111-1111"})
print(result)  # Output: Legacy: Charged $100.0 to 4111-1111-1111-1111

Use Cases

  • Integrating third-party libraries with incompatible interfaces.
  • Legacy code modernization without rewriting.

Pros & Cons

  • Pros: Reuses existing code; separates interface translation from business logic.
  • Cons: Adds an extra layer of indirection, which may impact performance.

Facade

Intent

Provide a unified interface to a set of interfaces in a subsystem. Facade defines a higher-level interface that makes the subsystem easier to use.

Problem

A complex subsystem (e.g., a video processing pipeline with encoding, filtering, and rendering) has many moving parts. Clients should not need to interact with all of them directly.

Solution

Create a facade class that hides the subsystem’s complexity and provides simple methods for common tasks.

Example: Video Processing Facade

# Complex Subsystem Components
class VideoEncoder:
    def encode(self, input_file: str, format: str) -> str:
        return f"Encoded {input_file} to {format}"

class VideoFilter:
    def apply_filter(self, input_file: str, filter_type: str) -> str:
        return f"Applied {filter_type} filter to {input_file}"

class VideoRenderer:
    def render(self, input_file: str, resolution: str) -> str:
        return f"Rendered {input_file} at {resolution}"

# Facade
class VideoProcessor:
    def __init__(self):
        self.encoder = VideoEncoder()
        self.filter = VideoFilter()
        self.renderer = VideoRenderer()

    def process_video(self, input_file: str) -> str:
        # Simplified workflow: filter → encode → render
        filtered = self.filter.apply_filter(input_file, "enhance")
        encoded = self.encoder.encode(filtered, "mp4")
        rendered = self.renderer.render(encoded, "1080p")
        return rendered

# Usage
processor = VideoProcessor()
result = processor.process_video("raw_footage.mp4")
print(result)  # Output: Rendered Encoded Applied enhance filter to raw_footage.mp4 to mp4 at 1080p

Use Cases

  • Simplifying client interaction with complex libraries (e.g., requests library wrapping HTTP complexity).
  • Reducing dependencies between clients and subsystems.

Pros & Cons

  • Pros: Simplifies usage; decouples clients from subsystem details.
  • Cons: Risk of creating a “god object” that becomes a single point of failure.

Composite

Intent

Compose objects into tree structures to represent part-whole hierarchies. Composite lets clients treat individual objects and compositions of objects uniformly.

Problem

You need to work with hierarchical data (e.g., a file system with files and folders) where individual items and groups of items should be treated the same.

Solution

Define a component interface for both leaf nodes (individual items) and composite nodes (groups). Composites contain child components and delegate operations to them.

Example: File System Composite

from abc import ABC, abstractmethod
from typing import List

# Component Interface
class FileSystemItem(ABC):
    @abstractmethod
    def get_size(self) -> int:
        pass

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

# Leaf Node (File)
class File(FileSystemItem):
    def __init__(self, name: str, size: int):
        self.name = name
        self.size = size

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

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

# Composite Node (Folder)
class Folder(FileSystemItem):
    def __init__(self, name: str):
        self.name = name
        self.children: List[FileSystemItem] = []

    def add_child(self, item: FileSystemItem) -> None:
        self.children.append(item)

    def remove_child(self, item: FileSystemItem) -> None:
        self.children.remove(item)

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

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

# Usage
# Create files
file1 = File("document.txt", 1024)
file2 = File("image.jpg", 2048)

# Create a folder and add files
docs_folder = Folder("Documents")
docs_folder.add_child(file1)
docs_folder.add_child(file2)

# Create a root folder containing the docs folder
root_folder = Folder("Root")
root_folder.add_child(docs_folder)

# Get total size of root folder (includes all children)
print(root_folder.get_size())  # Output: 3072 (1024 + 2048)

Use Cases

  • Tree structures (file systems, organization charts, XML/JSON parsers).
  • UI components (e.g., buttons, panels, windows treated uniformly).

Pros & Cons

  • Pros: Treats individual and composite objects uniformly; easy to add new components.
  • Cons: Can make it harder to restrict components (e.g., folders can’t contain other folders).

Behavioral Patterns

Behavioral patterns focus on how objects interact and distribute responsibility.

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 to notify multiple objects (e.g., UI widgets) when a central object (e.g., a data model) changes.

Solution

Subject (observable) maintains a list of observers and notifies them of state changes. Observers register/unregister with the subject and update when notified.

Example: Weather Station

from abc import ABC, abstractmethod
from typing import List

# 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 notify observers
    def set_measurements(self, temperature: float, humidity: float) -> None:
        self._temperature = temperature
        self._humidity = humidity
        self.notify()

# 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
        print(f"Forecast: {forecast_temp}°C, {humidity - 5}% humidity")

# Usage
station = WeatherStation()
current_display = CurrentConditionsDisplay()
forecast_display = ForecastDisplay()

station.attach(current_display)
station.attach(forecast_display)

# Update weather data (triggers notifications)
station.set_measurements(22.5, 65.0)
# Output:
# Current Conditions: 22.5°C, 65.0% humidity  
# Forecast: 24.5°C, 60.0% humidity

Use Cases

  • Event-driven systems (GUI frameworks, chat apps).
  • Data synchronization (e.g., caching layers invalidating when data changes).

Pros & Cons

  • Pros: Loose coupling between subject and observers; dynamic registration.
  • Cons: Observers may receive unnecessary updates; can lead to memory leaks if observers aren’t detached.

Strategy

Intent

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

Problem

You need to offer multiple ways to perform a task (e.g., sorting, payment processing) and allow clients to switch algorithms dynamically.

Solution

Encapsulate each algorithm in a strategy class. The context class uses a strategy object to delegate the task.

Example: Payment Processing

from abc import ABC, abstractmethod

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

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

    def pay(self, amount: float) -> str:
        return f"Paid ${amount} with credit card {self.card_number}"

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

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

# Context
class ShoppingCart:
    def __init__(self, payment_strategy: PaymentStrategy):
        self.payment_strategy = payment_strategy

    def checkout(self, amount: float) -> str:
        return self.payment_strategy.pay(amount)

# Usage
# Pay with credit card
credit_card = CreditCardPayment("4111-1111-1111-1111")
cart = ShoppingCart(credit_card)
print(cart.checkout(99.99))  # Output: Paid $99.99 with credit card 4111-1111-1111-1111

# Switch to PayPal dynamically
paypal = PayPalPayment("[email protected]")
cart.payment_strategy = paypal
print(cart.checkout(49.99))  # Output: Paid $49.99 via PayPal ([email protected])

Use Cases

  • Dynamic algorithm selection (e.g., sorting with different algorithms based on data size).
  • Pluggable behaviors (e.g., discount strategies in e-commerce).

Pros & Cons

  • Pros: Easy to switch algorithms; open/closed principle (add new strategies without changing context).
  • Cons: Clients must be aware of different strategies to select the right one.

Command

Intent

Encapsulate a request as an object, thereby letting you parameterize clients with different requests, queue or log requests, and support undoable operations.

Problem

You need to decouple the sender of a request (e.g., a button) from the receiver (e.g., a light). The sender shouldn’t know how the receiver executes the request.

Solution

Wrap requests in command objects that implement an execute method. The sender invokes execute on the command, which delegates to the receiver.

Example: Remote Control

from abc import ABC, abstractmethod

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

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

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

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

# 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()

# Invoker (Remote Control)
class RemoteControl:
    def __init__(self):
        self._command: Command | None = None

    def set_command(self, command: Command) -> None:
        self._command = command

    def press_button(self) -> None:
        if self._command:
            self._command.execute()

    def press_undo(self) -> None:
        if self._command:
            self._command.undo()

# Usage
light = Light()
on_command = LightOnCommand(light)
off_command = LightOffCommand(light)

remote = RemoteControl()

# Press "ON" button
remote.set_command(on_command)
remote.press_button()  # Output: Light is ON

# Press "UNDO"
remote.press_undo()  # Output: Light is OFF

# Press "OFF" button
remote.set_command(off_command)
remote.press_button()  # Output: Light is OFF

Use Cases

  • Undo/redo operations (e.g., text editors).
  • Event queues (e.g., print spoolers, task schedulers).

Pros & Cons

  • Pros: Decouples sender and receiver; supports undo/redo and logging.
  • Cons: Requires creating a new command class for each operation, increasing complexity.

Template Method

Intent

Define the skeleton of an algorithm in an operation, deferring some steps to subclasses. Template Method lets subclasses redefine certain steps of an algorithm without changing the algorithm’s structure.

Problem

Multiple algorithms share a common structure but differ in specific steps (e.g., making tea vs. coffee).

Solution

Define the common steps in a base class (template method) and let subclasses override the varying steps.

Example: Beverage Maker

from abc import ABC, abstractmethod

# Abstract Class with Template Method
class BeverageMaker(ABC):
    # Template Method: Defines the algorithm skeleton
    def make_beverage(self) -> None:
        self.boil_water()
        self.brew()  # Varies by beverage
        self.pour_in_cup()
        self.add_condiments()  # Varies by beverage

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

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

    # Abstract steps to be implemented by subclasses
    @abstractmethod
    def brew(self) -> None:
        pass

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

# Concrete Subclasses
class TeaMaker(BeverageMaker):
    def brew(self) -> None:
        print("Steeping the tea")

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

class CoffeeMaker(BeverageMaker):
    def brew(self) -> None:
        print("Brewing coffee grounds")

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

# Usage
tea = TeaMaker()
print("Making tea:")
tea.make_beverage()
# Output:
# Boiling water
# Steeping the tea
# Pouring into cup
# Adding lemon

coffee = CoffeeMaker()
print("\nMaking coffee:")
coffee.make_beverage()
# Output:
# Boiling water
# Brewing coffee grounds
# Pouring into cup
# Adding sugar and milk

Use Cases

  • Frameworks (e.g., web frameworks with request/response lifecycle hooks).
  • Algorithms with fixed structure but variable steps (e.g., data processing pipelines).

Pros & Cons

  • Pros: Enforces algorithm structure; reduces code duplication.
  • Cons: Rigid algorithm structure; subclasses can only override specific steps.

When to Use Design Patterns

Design patterns are powerful tools, but they should be applied judiciously:

  • Solve recurring problems: Use patterns when you encounter a common design challenge (e.g., ensuring a single instance with Singleton).
  • Avoid premature optimization: Don’t force patterns where simple code suffices. Start with a simple solution, then refactor to a pattern if needed.
  • Communicate intent: Use patterns to make your code more readable to other developers familiar with the pattern vocabulary.

Conclusion

Design patterns are foundational to writing clean, maintainable, and scalable OOP code. By understanding and applying patterns like Singleton, Decorator, Observer, and Strategy, you can solve complex problems with proven solutions.

Remember, patterns are not rules—they are guidelines. The key is to recognize when a pattern fits the problem, adapt it to your needs (Python’s flexibility helps here!), and prioritize clarity over教条ism.

Happy coding, and may your designs be pattern-perfect!

References