Strategy Pattern in Python: First-Class Functions
The Strategy pattern encapsulates interchangeable algorithms behind a common interface. You've got a family of algorithms, you make them interchangeable, and clients can swap them without knowing the...
Key Insights
- Python’s first-class functions eliminate the need for abstract base classes and concrete strategy classes in most cases, reducing boilerplate by 60-70%
- Use callable classes with
__call__when your strategy needs to maintain state between invocations; use plain functions when it doesn’t - A dictionary mapping strategy names to functions provides runtime flexibility with cleaner code than a factory class
Introduction to the Strategy Pattern
The Strategy pattern encapsulates interchangeable algorithms behind a common interface. You’ve got a family of algorithms, you make them interchangeable, and clients can swap them without knowing the implementation details. Classic Gang of Four stuff.
In traditional OOP languages like Java or C#, you implement this with an interface or abstract base class, then create concrete classes for each strategy. It works, but it’s verbose. You end up with a file per strategy, inheritance hierarchies, and a lot of ceremony for what might be a 10-line algorithm.
Python gives you a better option. Functions are first-class citizens—you can pass them around, store them in variables, and stuff them into data structures. This means you can implement the Strategy pattern with plain functions, no classes required.
Let’s see both approaches and understand when each makes sense.
The Traditional OOP Implementation
Here’s the classic approach. We’ll build a payment processing system with different payment strategies:
from abc import ABC, abstractmethod
from dataclasses import dataclass
from decimal import Decimal
@dataclass
class PaymentResult:
success: bool
transaction_id: str
message: str
class PaymentStrategy(ABC):
@abstractmethod
def process(self, amount: Decimal) -> PaymentResult:
pass
class CreditCardPayment(PaymentStrategy):
def __init__(self, card_number: str, expiry: str, cvv: str):
self.card_number = card_number
self.expiry = expiry
self.cvv = cvv
def process(self, amount: Decimal) -> PaymentResult:
# Integration with credit card processor
masked_card = f"****{self.card_number[-4:]}"
return PaymentResult(
success=True,
transaction_id="cc_txn_12345",
message=f"Charged {amount} to {masked_card}"
)
class PayPalPayment(PaymentStrategy):
def __init__(self, email: str):
self.email = email
def process(self, amount: Decimal) -> PaymentResult:
# Integration with PayPal API
return PaymentResult(
success=True,
transaction_id="pp_txn_67890",
message=f"Charged {amount} via PayPal ({self.email})"
)
class CryptoPayment(PaymentStrategy):
def __init__(self, wallet_address: str, currency: str = "BTC"):
self.wallet_address = wallet_address
self.currency = currency
def process(self, amount: Decimal) -> PaymentResult:
return PaymentResult(
success=True,
transaction_id="crypto_txn_11111",
message=f"Transferred {amount} {self.currency}"
)
class PaymentProcessor:
def __init__(self, strategy: PaymentStrategy):
self.strategy = strategy
def checkout(self, amount: Decimal) -> PaymentResult:
return self.strategy.process(amount)
# Usage
processor = PaymentProcessor(CreditCardPayment("4111111111111111", "12/25", "123"))
result = processor.checkout(Decimal("99.99"))
This works fine. It’s explicit, type-checkable, and familiar to developers from other languages. But look at the boilerplate: an abstract base class, three concrete classes, inheritance declarations, and a context class to hold the strategy.
For a payment system with complex validation and state, this structure might be justified. For simpler algorithms, it’s overkill.
Python’s First-Class Functions Advantage
Python treats functions as objects. You can assign them to variables, pass them as arguments, return them from other functions, and store them in collections. This lets us implement Strategy with far less code:
from decimal import Decimal
from dataclasses import dataclass
from typing import Callable
@dataclass
class PaymentResult:
success: bool
transaction_id: str
message: str
# Strategy type alias for clarity
PaymentStrategy = Callable[[Decimal, dict], PaymentResult]
def credit_card_payment(amount: Decimal, config: dict) -> PaymentResult:
masked_card = f"****{config['card_number'][-4:]}"
return PaymentResult(
success=True,
transaction_id="cc_txn_12345",
message=f"Charged {amount} to {masked_card}"
)
def paypal_payment(amount: Decimal, config: dict) -> PaymentResult:
return PaymentResult(
success=True,
transaction_id="pp_txn_67890",
message=f"Charged {amount} via PayPal ({config['email']})"
)
def crypto_payment(amount: Decimal, config: dict) -> PaymentResult:
currency = config.get("currency", "BTC")
return PaymentResult(
success=True,
transaction_id="crypto_txn_11111",
message=f"Transferred {amount} {currency}"
)
def checkout(amount: Decimal, strategy: PaymentStrategy, config: dict) -> PaymentResult:
return strategy(amount, config)
# Usage
result = checkout(
Decimal("99.99"),
credit_card_payment,
{"card_number": "4111111111111111", "expiry": "12/25", "cvv": "123"}
)
We went from four classes to three functions. The behavior is identical, but the code is more direct. Each function is a self-contained strategy that you can test in isolation.
The Callable type hint documents the expected signature. Any function matching that signature works as a strategy—no inheritance required.
Using Callables and the __call__ Method
Sometimes you need strategies with state. Maybe a discount calculator needs to track how many times it’s been applied, or a rate limiter needs to remember recent requests. Pure functions don’t cut it here.
Python’s __call__ method lets you create callable objects—classes that behave like functions but can hold state:
from decimal import Decimal
from dataclasses import dataclass, field
from typing import Protocol
class DiscountStrategy(Protocol):
def __call__(self, price: Decimal) -> Decimal: ...
@dataclass
class PercentageDiscount:
percentage: Decimal
max_discount: Decimal = Decimal("Infinity")
times_applied: int = field(default=0, init=False)
def __call__(self, price: Decimal) -> Decimal:
self.times_applied += 1
discount = price * (self.percentage / 100)
discount = min(discount, self.max_discount)
return price - discount
@dataclass
class TieredDiscount:
"""Discount increases with purchase amount."""
tiers: list[tuple[Decimal, Decimal]] # (threshold, percentage)
def __call__(self, price: Decimal) -> Decimal:
applicable_percentage = Decimal("0")
for threshold, percentage in sorted(self.tiers, reverse=True):
if price >= threshold:
applicable_percentage = percentage
break
return price * (1 - applicable_percentage / 100)
@dataclass
class LoyaltyDiscount:
"""Discount based on customer purchase history."""
base_percentage: Decimal
bonus_per_purchase: Decimal
purchase_count: int = 0
def record_purchase(self):
self.purchase_count += 1
def __call__(self, price: Decimal) -> Decimal:
total_percentage = self.base_percentage + (
self.bonus_per_purchase * self.purchase_count
)
total_percentage = min(total_percentage, Decimal("50")) # Cap at 50%
return price * (1 - total_percentage / 100)
def apply_discount(price: Decimal, strategy: DiscountStrategy) -> Decimal:
return strategy(price)
# Usage
holiday_sale = PercentageDiscount(Decimal("25"), max_discount=Decimal("100"))
print(apply_discount(Decimal("200"), holiday_sale)) # 150.00
print(apply_discount(Decimal("500"), holiday_sale)) # 400.00 (capped at $100 off)
print(f"Applied {holiday_sale.times_applied} times") # 2
tiered = TieredDiscount([
(Decimal("100"), Decimal("10")),
(Decimal("250"), Decimal("15")),
(Decimal("500"), Decimal("20")),
])
print(apply_discount(Decimal("300"), tiered)) # 255.00 (15% off)
The Protocol class provides structural typing—any object with a matching __call__ signature satisfies the protocol. This gives you type safety without forcing inheritance.
Callable classes shine when you need to track usage, maintain configuration, or implement complex initialization logic. They’re the middle ground between pure functions and full-blown strategy classes.
Practical Implementation: A Text Formatter
Let’s build something more realistic: a text processing pipeline with interchangeable output formats. This demonstrates runtime strategy selection using a dictionary of functions.
from dataclasses import dataclass
from typing import Callable
from enum import Enum, auto
@dataclass
class Document:
title: str
paragraphs: list[str]
author: str | None = None
# Strategy type
Formatter = Callable[[Document], str]
def format_markdown(doc: Document) -> str:
lines = [f"# {doc.title}", ""]
if doc.author:
lines.extend([f"*By {doc.author}*", ""])
for para in doc.paragraphs:
lines.extend([para, ""])
return "\n".join(lines).strip()
def format_html(doc: Document) -> str:
parts = [f"<article>", f" <h1>{doc.title}</h1>"]
if doc.author:
parts.append(f" <p class='author'>By {doc.author}</p>")
for para in doc.paragraphs:
parts.append(f" <p>{para}</p>")
parts.append("</article>")
return "\n".join(parts)
def format_plain(doc: Document) -> str:
lines = [doc.title.upper(), "=" * len(doc.title), ""]
if doc.author:
lines.extend([f"Author: {doc.author}", ""])
for para in doc.paragraphs:
lines.extend([para, ""])
return "\n".join(lines).strip()
def format_rst(doc: Document) -> str:
lines = [doc.title, "=" * len(doc.title), ""]
if doc.author:
lines.extend([f":author: {doc.author}", ""])
for para in doc.paragraphs:
lines.extend([para, ""])
return "\n".join(lines).strip()
class OutputFormat(Enum):
MARKDOWN = auto()
HTML = auto()
PLAIN = auto()
RST = auto()
# Strategy registry
FORMATTERS: dict[OutputFormat, Formatter] = {
OutputFormat.MARKDOWN: format_markdown,
OutputFormat.HTML: format_html,
OutputFormat.PLAIN: format_plain,
OutputFormat.RST: format_rst,
}
def render_document(doc: Document, format: OutputFormat) -> str:
formatter = FORMATTERS.get(format)
if formatter is None:
raise ValueError(f"Unknown format: {format}")
return formatter(doc)
# Usage
doc = Document(
title="Strategy Pattern in Python",
paragraphs=[
"Python's first-class functions simplify design patterns.",
"You can often replace class hierarchies with simple functions.",
],
author="Application Architect"
)
print(render_document(doc, OutputFormat.MARKDOWN))
print("\n---\n")
print(render_document(doc, OutputFormat.HTML))
The dictionary-based registry makes adding new formats trivial—just write a function and add it to the dictionary. No factory classes, no switch statements, no registration decorators. The enum provides type safety and autocompletion in IDEs.
When to Use Which Approach
Here’s my decision framework:
Use plain functions when:
- The algorithm is stateless
- You don’t need to configure the strategy at instantiation
- The strategy logic fits in 20-30 lines
- You’re building something simple and want to ship fast
Use callable classes (__call__) when:
- The strategy needs to maintain state between calls
- You have complex initialization or configuration
- You need additional methods beyond the main operation
- Testing requires mocking internal behavior
Use the full class-based pattern when:
- You’re integrating with a framework that expects it
- Your team is more comfortable with explicit interfaces
- The strategies share significant common behavior (use a base class)
- You need runtime introspection of strategy metadata
Don’t let pattern purity override pragmatism. If a function works, use a function. If you need state, use a callable class. If you’re building a plugin system where third parties implement strategies, the full class-based approach with abstract base classes provides clearer contracts.
Key Takeaways
The Strategy pattern is about interchangeable algorithms, not about class hierarchies. Python’s first-class functions let you implement this pattern with minimal ceremony.
Start with plain functions. They’re easy to write, easy to test, and easy to understand. When you need state, upgrade to callable classes with __call__. Reserve the full abstract base class approach for genuinely complex scenarios or framework requirements.
A dictionary mapping names to functions replaces the factory pattern in most cases. Combined with enums for type safety, this gives you runtime flexibility without the indirection of factory classes.
The best code isn’t the most “correct” implementation of a pattern—it’s the simplest code that solves the problem. In Python, that usually means functions.