Exploring Python Design Patterns with First-Class Functions
Written on
Chapter 1: Introduction to Design Patterns
Design patterns serve as universal solutions to prevalent design challenges encountered by software developers. Though they are not specific to any programming language, their application can vary across different languages. In Python, certain design patterns can be effectively utilized with first-class functions. Today, we will delve into two such patterns: The Strategy Pattern and The Command Pattern.
First-class functions are particularly valuable because they enable developers to write more succinct code, minimizing boilerplate.
For those interested in staying updated with my latest articles or joining a community of fellow developers, consider following me on Medium; your support is greatly appreciated! ❤️
Section 1.1: The Strategy Pattern
The Strategy Pattern is succinctly described in the book "Design Patterns" as follows:
"Define a family of algorithms, encapsulate each one, and make them interchangeable. Strategy enables the algorithm to vary independently of the clients that utilize it."
To elaborate, the Strategy Pattern suggests that we should take a class or callable, which in Python’s context can perform specific tasks in various ways, and separate them into distinct callables referred to as strategies.
This pattern comprises three components: The Context, The Strategy, and Concrete Strategies. The Context holds a reference to one of the strategies and delegates the task to the selected strategy callable rather than executing it directly. The Context remains unaware of which strategy is being used, thus having limited knowledge about the strategies available.
To illustrate, let’s consider a scenario where a customer wants to apply a discount based on their attributes and the order details. The book "Fluent Python" offers an in-depth discussion on this example.
Case Study: Discount Rules in an Online Store
Imagine an online shop with the following discount policies:
- Customers with 1,000 or more loyalty points receive a 5% discount on their entire order.
- Each line item with 20 or more units gets a 10% discount.
- Orders containing at least 10 different items qualify for a 7% overall discount.
- Only one discount can be applied per order.
Context
The context maintains a reference to the strategy in use. In our case, the context is represented by an Order that is set up to apply a promotional discount.
Strategy
The interface that is common to the components implementing different algorithms is defined by an abstract class named Promotion.
Concrete Strategies
The concrete subclasses of the Strategy utilized by the Context include FidelityPromo, BulkPromo, and LargeOrderPromo.
There are various approaches to implementing the Strategy Pattern in Python:
- The classic method using classes, as illustrated in the UML diagram.
- Utilizing first-class functions as strategies.
Classic Strategy Pattern Using Classes:
from abc import ABC, abstractmethod
from collections.abc import Sequence
from decimal import Decimal
from typing import NamedTuple, Optional
class Customer(NamedTuple):
name: str
fidelity: int
class LineItem(NamedTuple):
product: str
quantity: int
price: Decimal
def total(self) -> Decimal:
return self.price * self.quantity
class Order(NamedTuple): # the Context
customer: Customer
cart: Sequence[LineItem]
promotion: Optional['Promotion'] = None
def total(self) -> Decimal:
totals = (item.total() for item in self.cart)
return sum(totals, start=Decimal(0))
def due(self) -> Decimal:
if self.promotion is None:
discount = Decimal(0)else:
discount = self.promotion.discount(self)return self.total() - discount
def __repr__(self):
return f''
class Promotion(ABC): # the Strategy: an abstract base class
@abstractmethod
def discount(self, order: Order) -> Decimal:
"""Return discount as a positive dollar amount"""
class FidelityPromo(Promotion): # first Concrete Strategy
"""5% discount for customers with 1000 or more fidelity points"""
def discount(self, order: Order) -> Decimal:
rate = Decimal('0.05')
if order.customer.fidelity >= 1000:
return order.total() * ratereturn Decimal(0)
class BulkItemPromo(Promotion): # second Concrete Strategy
"""10% discount for each LineItem with 20 or more units"""
def discount(self, order: Order) -> Decimal:
discount = Decimal(0)
for item in order.cart:
if item.quantity >= 20:
discount += item.total() * Decimal('0.1')return discount
class LargeOrderPromo(Promotion): # third Concrete Strategy
"""7% discount for orders with 10 or more distinct items"""
def discount(self, order: Order) -> Decimal:
distinct_items = {item.product for item in order.cart}
if len(distinct_items) >= 10:
return order.total() * Decimal('0.07')return Decimal(0)
To see it in action:
ann = Customer('Ann Smith', 1100)
cart = (LineItem('banana', 4, Decimal('.5')), LineItem('apple', 10, Decimal('1.5')), LineItem('watermelon', 5, Decimal(5)))
# Ann qualifies for a 5% discount due to her 1,000 points.
Order(ann, cart, FidelityPromo())
Using First-Class Functions as Concrete Strategies
In the traditional Strategy Pattern, classes model promotions; however, since these instances lack internal state, they can be represented as functions, allowing us to eliminate the Promo abstract class entirely.
from collections.abc import Sequence
from dataclasses import dataclass
from decimal import Decimal
from typing import Optional, Callable, NamedTuple
class Customer(NamedTuple):
name: str
fidelity: int
class LineItem(NamedTuple):
product: str
quantity: int
price: Decimal
def total(self):
return self.price * self.quantity
@dataclass(frozen=True)
class Order: # the Context
customer: Customer
cart: Sequence[LineItem]
promotion: Optional[Callable[['Order'], Decimal]] = None
def total(self) -> Decimal:
totals = (item.total() for item in self.cart)
return sum(totals, start=Decimal(0))
def due(self) -> Decimal:
if self.promotion is None:
discount = Decimal(0)else:
discount = self.promotion(self)return self.total() - discount
def __repr__(self):
return f''
def fidelity_promo(order: Order) -> Decimal:
"""5% discount for customers with 1000 or more fidelity points"""
if order.customer.fidelity >= 1000:
return order.total() * Decimal('0.05')return Decimal(0)
def bulk_item_promo(order: Order) -> Decimal:
"""10% discount for each LineItem with 20 or more units"""
discount = Decimal(0)
for item in order.cart:
if item.quantity >= 20:
discount += item.total() * Decimal('0.1')return discount
def large_order_promo(order: Order) -> Decimal:
"""7% discount for orders with 10 or more distinct items"""
distinct_items = {item.product for item in order.cart}
if len(distinct_items) >= 10:
return order.total() * Decimal('0.07')return Decimal(0)
# Example usage
ann = Customer('Ann Smith', 1100)
cart = (LineItem('banana', 4, Decimal('.5')), LineItem('apple', 10, Decimal('1.5')), LineItem('watermelon', 5, Decimal(5)))
# Ann gets a 5% discount due to her 1,000 points.
Order(ann, cart, fidelity_promo())
Chapter 2: The Command Pattern
The Command Pattern is an architectural approach that encapsulates a request into a standalone entity that contains all relevant request data. This design makes it easier to forward requests as method arguments, or to suspend and queue their execution.
The primary objective of the Command Pattern is to decouple the object that invokes an operation (the invoker) from the object that implements it (the receiver).
A Command object serves as an intermediary between the invoker and the receiver, providing a unified interface with a method, typically named execute, which instructs the receiver to perform the designated operation. Consequently, the invoker does not need to comprehend the receiver's interface, allowing for the adaptation of various receivers through distinct Command callables.
The example presented in the book "Design Patterns" illustrates that each invoker corresponds to a menu item in a graphical application, while the receivers are the document being edited or the application itself.
Command Pattern Implementation:
from abc import ABC, abstractmethod
class Command(ABC):
"""Constructor method"""
def __init__(self, receiver):
self.receiver = receiver
"""Process method"""
def process(self):
pass
class CommandImplementation(Command):
"""Constructor method"""
def __init__(self, receiver):
self.receiver = receiver
"""Process method"""
def process(self):
self.receiver.perform_action()
class Receiver:
"""Perform-action method"""
def perform_action(self):
print('Action performed in receiver.')
class Invoker:
"""Command method"""
def command(self, cmd):
self.cmd = cmd
"""Execute method"""
def execute(self):
self.cmd.process()
# Main method
if __name__ == "__main__":
"""Create Receiver object"""
receiver = Receiver()
cmd = CommandImplementation(receiver)
invoker = Invoker()
invoker.command(cmd)
invoker.execute()
Conclusion
Utilizing callables in Python emerges as the most straightforward option due to the availability of various callable types, including functions and objects. This flexibility allows us to apply well-known design patterns in a clear and comprehensible manner without the clutter associated with classes.
Further Reading
- Recipe 8.21: Implementing the Visitor Pattern in the Python Cookbook, 3rd ed.
- The Fluent Python Book.
- The Design Patterns Book.
- Learning Python Design Patterns Book.
- Expert Python Programming Book.
- Level Up Coding
Thanks for being a part of our community! Before you leave, please consider giving a clap for this article and following the author for more insights!
The first video explains the significance of design patterns in Python, addressing the common question: "Why Use Design Patterns When Python Has Functions?"
The second video discusses underrated design patterns that can enhance your Python coding practices.