Design Patterns for Python’s First Class Functions
An overview of Python's Design Patterns using Functions as First Class Objects.
Design Patterns are general recipes for solving common design problems that software engineers face, these are language independent, but they cannot all be used by all the programming languages. In Python’s case there are certain design patterns that can be used with First Class Functions, we will visit the following patterns today:
- The Strategy Pattern
- The Command Pattern
We chose First Class Functions in particular because they allow us to write concise code removing a lot of boilerplate.
The Strategy Pattern
The Strategy pattern is summarized like this in the book Design Patterns:
Define a family of algorithms, encapsulate each one, and make them interchangeable. Strategy lets the algorithm vary independently from clients that use it.
Here’s how we can describe the Strategy Pattern:
- The Strategy pattern states that we should take a class or a callable, in Python’s case, that does something specific in a variety of ways and extract them into distinct callables called strategies.
- This pattern has 3 parts: The context, The Strategy and the Concrete Strategies.
- The Context needs to have a field that holds a connection to one of the strategies. The context passes the work along to a connected strategy callable instead of doing it itself.
- The context does not determine which strategy to use as it has limited knowledge of strategies.
- Through the same generic interface, all strategies are able to work with one single callable to activate the algorithm stored in the selected strategy.
Let’s imagine a situation where a customer wants to order something with a discount and there are multiple ways to compute the discount according to the attributes of the customer and the order itself, the book Fluent Python discusses this example in a very detailed manner! We’ll see how it is portrayed next.
Case Study:
Consider an online store with these discount rules:
- Customers with 1,000 or more fidelity points get a global 5% discount per order.
- A 10% discount is applied to each line item with 20 or more units in the same order.
- Orders with at least 10 distinct items get a 7% global discount.
- Only one discount may be applied to an order.
Context
The context is where we maintain a reference to the strategy we are using. In our example, the context is an Order, which is configured to apply a promotional discount.
Strategy
The interface is common to the components that implement the different algorithms. In our example, this role is played by an abstract class called Promotion.
Concrete strategy
One of the concrete subclasses of the Strategy used by the Context. FidelityPromo, BulkPromo, and LargeOrderPromo are the three concrete strategies implemented.
There are multiple ways to implement the Strategy Pattern in Python:
- We implement it using the Classic way using classes like shown in the uml diagram above.
- We implement it using Functions as first class objects.
Classic Strategy Pattern Using Classes:
Implementing the classic strategy patterns means using classes to model our promotions.
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'<Order total: {self.total():.2f} due: {self.due():.2f}>'
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() * rate
return 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)
Now let’s try using it:
> 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 because she has at least 1,000 points.
> Order(ann, cart, FidelityPromo())
<Order total: 42.00 due: 39.90>
Using First Class Functions as Concrete Strategies
In the classic Strategy Pattern we used classes to model the promotions, i.e. the concrete strategies, only the instances have no internal state and they can be rewritten as functions and we would eventually remove the Promo abstract class since we won’t need it.
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'<Order total: {self.total():.2f} due: {self.due():.2f}>'
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)
Now let’s try using it:
> 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 because she has at least 1,000 points.
> Order(ann, cart, FidelityPromo())
<Order total: 42.00 due: 39.90>
The Command Pattern
Command is an architectural pattern that encapsulates a request as an individual entity, containing all pertinent data of the request. This facilitates forwarding requests as method arguments, suspending or queuing a request’s execution.
The goal of the Command Pattern is to decouple an object that invokes an operation (the invoker) from the provider object that implements it (the receiver).
A Command object should be between the invoker and the receiver, providing an interface with a single method, usually execute, which orders the receiver to accomplish the specified operation. That way the invoker does not need to know the interface of the receiver, and different receivers can be adapted through different Command callables.
The example in the book Design Patterns specifies that each invoker is a menu item in a graphical application, and the receivers are the document being edited or the application itself.
Here's an implementation we can use:
from abc import ABC, abstractmethod
class Command(ABC):
"""constructor method"""
def __init__(self, receiver):
self.receiver = receiver
"""process method"""
def process(self):
pass
"""Class dedicated to Command Implementation"""
class CommandImplementation(Command):
"""constructor method"""
def __init__(self, receiver):
self.receiver = receiver
"""process method"""
def process(self):
self.receiver.perform_action()
"""Class dedicated to Receiver"""
class Receiver:
"""perform-action method"""
def perform_action(self):
print('Action performed in receiver.')
"""Class dedicated to Invoker"""
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
In Python, using callables is the obvious choice because there are more callable options besides classes like functions or objects, allowing us to apply popular design patterns in a clear and understandable way without the boilerplate of 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.