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.
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
Orderthat 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,QuantityDiscountandBulkDiscountare 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.