Strategy pattern

In the design pattern book, the strategy pattern is defined as a family of algorithms that should be encapsulated and interchangeable. The algorithms vary independently of the clients.

title UML class diagram for the strategy design pattern

abstract class  "Client"
Client --> Context
Client --> Strategy

together {
    interface Context {
        {method} context_interface()
    }
    abstract class Strategy {
        {method} algorithm()
    }
}
Context o-> Strategy

together {
    class ConcreteStrategyA {
        {method} algorithm()
    }
    class ConcreteStrategyB {
        {method} algorithm()
    }
}
ConcreteStrategyA -u-|> Strategy
ConcreteStrategyB -u-|> Strategy

The strategy pattern is a good example of a design pattern that can be simpler in Python if functions are used as first-class objects. To do this, we first implement the classic structure of this pattern and then refactor this code using functions.

An illustrative example of the application of the strategy pattern is the calculation of discounts on orders depending on the characteristics of the customers and the items ordered.

Let’s take an online shop with the following discount rules:

  • Customers with a thousand or more loyalty points receive a global discount of 5% per order.

  • A 10% discount is applied to any item with ten or more units in the same order.

  • A 7% discount is granted on orders with at least ten different items.

Only one discount can be applied to an order.

Context

holds a strategy variable that references a specific strategy. In our e-commerce example, the context is an Order that is configured to apply a promotional discount according to one of several algorithms.

Strategy

is the common interface for the components that implement the various algorithms. In our example, this role is performed by an abstract class called Discount.

Concrete Strategy

is one of the concrete subclasses of the abstract strategy. LoyaltyDiscount, QuantityDiscount and BulkDiscount are the three concrete strategies implemented.

  1from abc import ABC, abstractmethod
  2from collections import namedtuple
  3
  4Customer = namedtuple("Customer", "loyalty")
  5
  6
  7class Product:
  8    def __init__(self, product, quantity, price):
  9        self.product = product
 10        self.quantity = quantity
 11        self.price = price
 12
 13    def total(self):
 14        return self.price * self.quantity
 15
 16
 17class Order:
 18    """The context class."""
 19
 20    def __init__(self, customer, cart, promotion=None):
 21        self.customer = customer
 22        self.cart = list(cart)
 23        self.promotion = promotion
 24
 25    def total(self):
 26        if not hasattr(self, "__total"):
 27            self.__total = sum(item.total() for item in self.cart)
 28        return self.__total
 29
 30    def due(self):
 31        if self.promotion is None:
 32            discount = 0
 33        else:
 34            discount = self.promotion.discount(self)
 35        return self.total() - discount
 36
 37    def __repr__(self):
 38        fmt = "<Order total: {:.2f} due: {:.2f}>"
 39        return fmt.format(self.total(), self.due())
 40
 41
 42class Promotion(ABC):
 43    """The abstract strategy class."""
 44
 45    @abstractmethod
 46    def discount(self, order):
 47        """Return discount"""
 48
 49
 50class LoyaltyPromo(Promotion):
 51    """First concrete Strategy
 52
 53    5% discount for customers with 1000 or more loyalty points.
 54    """
 55
 56    def discount(self, order):
 57        return order.total() * 0.05 if order.customer.loyalty >= 1000 else 0
 58
 59
 60class QuantityItemPromo(Promotion):
 61    """Second concrete Strategy.
 62
 63    10% discount for each Product with 10 or more units.
 64    """
 65
 66    def discount(self, order):
 67        discount = 0
 68        for item in order.cart:
 69            if item.quantity >= 10:
 70                discount += item.total() * 0.1
 71        return discount
 72
 73
 74class BulkPromo(Promotion):
 75    """Third concrete Strategy
 76
 77    7% discount for orders with 10 or more distinct items.
 78    """
 79
 80    def discount(self, order):
 81        distinct_items = {item.product for item in order.cart}
 82        if len(distinct_items) >= 10:
 83            return order.total() * 0.07
 84        return 0
 85
 86
 87class Order:
 88    """The context class."""
 89
 90    def __init__(self, customer, cart, promotion=None):
 91        self.customer = customer
 92        self.cart = list(cart)
 93        self.promotion = promotion
 94
 95    def total(self):
 96        if not hasattr(self, "__total"):
 97            self.__total = sum(item.total() for item in self.cart)
 98        return self.__total
 99
100    def due(self):
101        if self.promotion is None:
102            discount = 0
103        else:
104            discount = self.promotion.discount(self)
105        return self.total() - discount
106
107    def __repr__(self):
108        fmt = "<Order total: {:.2f} due: {:.2f}>"
109        return fmt.format(self.total(), self.due())
110
111
112class Promotion(ABC):
113    """The abstract strategy class."""
114
115    @abstractmethod
116    def discount(self, order):
117        """Return discount"""
118
119
120class LoyaltyPromo(Promotion):
121    """First concrete Strategy
122
123    5% discount for customers with 1000 or more loyalty points.
124    """
125
126    def discount(self, order):
127        return order.total() * 0.05 if order.customer.loyalty >= 1000 else 0
128
129
130class QuantityItemPromo(Promotion):
131    """Second concrete Strategy.
132
133    10% discount for each Product with 10 or more units.
134    """
135
136    def discount(self, order):
137        discount = 0
138        for item in order.cart:
139            if item.quantity >= 10:
140                discount += item.total() * 0.1
141        return discount
142
143
144class BulkPromo(Promotion):
145    """Third concrete Strategy
146
147    7% discount for orders with 10 or more distinct items.
148    """
149
150    def discount(self, order):
151        distinct_items = {item.product for item in order.cart}
152        if len(distinct_items) >= 10:
153            return order.total() * 0.07
154        return 0
155
156
157class Order:
158    """The context class."""
159
160    def __init__(self, customer, cart, promotion=None):
161        self.customer = customer
162        self.cart = list(cart)
163        self.promotion = promotion
164
165    def total(self):
166        if not hasattr(self, "__total"):
167            self.__total = sum(item.total() for item in self.cart)
168        return self.__total
169
170    def due(self):
171        if self.promotion is None:
172            discount = 0
173        else:
174            discount = self.promotion.discount(self)
175        return self.total() - discount
176
177    def __repr__(self):
178        fmt = "<Order total: {:.2f} due: {:.2f}>"
179        return fmt.format(self.total(), self.due())

Function-orientated strategy

Each concrete strategy in the previous example is a class with a single method, discount(). In addition, the strategy instances have no state (no instance attributes). In the following example, we do a refactoring, replacing the concrete strategies with simple functions and removing the abstract Promotion class.

 1from collections import namedtuple
 2
 3Customer = namedtuple("Customer", "name loyalty")
 4
 5
 6class Product:
 7    def __init__(self, product, quantity, price):
 8        self.product = product
 9        self.quantity = quantity
10        self.price = price
11
12    def total(self):
13        return self.price * self.quantity
14
15
16class Order:
17    """The context class."""
18
19    def __init__(self, customer, cart, promotion=None):
20        self.customer = customer
21        self.cart = list(cart)
22        self.promotion = promotion
23
24    def total(self):
25        if not hasattr(self, "__total"):
26            self.__total = sum(item.total() for item in self.cart)
27        return self.__total
28
29    def due(self):
30        if self.promotion is None:
31            discount = 0
32        else:
33            discount = self.promotion(self)
34        return self.total() - discount
35
36    def __repr__(self):
37        fmt = "<Order total: {:.2f} due: {:.2f}>"
38        return fmt.format(self.total(), self.due())
39
40    def loyalty_promo(order):
41        """5% discount for customers with 1000 or more loyalty points."""
42        return order.total() * 0.05 if order.customer.fidelity >= 1000 else 0
43
44    def quantity_item_promo(order):
45        """10% discount for each LineItem with 10 or more units."""
46        discount = 0
47        for item in order.cart:
48            if item.quantity >= 10:
49                discount += item.total() * 0.1
50        return discount
51
52    def bulk_promo(order):
53        """7% discount for orders with 10 or more distinct items."""
54        distinct_items = {item.product for item in order.cart}
55        if len(distinct_items) >= 10:
56            return order.total() * 0.07
57        return 0
Line 33:

To calculate a discount, simply call the function self.promotion().

Line 40:

Each strategy is a function, not a class.

The authors of the design pattern book suggest sharing it with the flyweight design pattern:

Strategy objects are often good flyweights.

A flyweight is a shared object that can be used in multiple contexts at the same time.

Sharing is recommended to reduce the cost of creating a new concrete strategy object when the same strategy is used repeatedly in each new context – in our example, each new order instance. Thus, to overcome a disadvantage of the strategy pattern – its runtime cost – the authors recommend the use of another pattern. In the meantime, the amount of code and the maintenance costs pile up.

Tip

In a more difficult use case with complex concrete strategies that contain an internal state, all parts of the strategy and flyweight pattern can be combined. But often concrete strategies do not have an internal state; they only process data from the context. In this case, you should definitely use simple functions instead of coding one-method classes that implement a one-method interface declared in another class. A function is more lightweight than an instance of a user-defined class, and there is no need for the flyweight strategy since each strategy function is only created once by Python when the module <../../modules/index> is compiled. A simple function is also a shared object that can be used in multiple contexts at the same time.

It can be helpful that the built-in function globals() within a function or method always refers to the module in which this function or method is defined – and not to the module from which it is called.

In this way, globals() can be used to automatically find all special_promo functions available in the module:

60promos = [globals()[name] for name in globals() if name.endswith("_promo")]

This iterates over every name in the dictionary returned by globals() and selects only those names that end with the _promo suffix.

To find the special_promo functions in another module, the inspect library can be used:

1import promos
2
3promotions = [func for name, func in inspect.getmembers(promos, inspect.isfunction)]

The inspect.getmembers() function returns the attributes of an object – in this case the promos. We then use inspect.isfunction() to get only the functions of the module. This example works regardless of the names of the functions; the only important thing is that the promos module contains the relevant functions.