Strategiemuster¶
Im Entwurfsmuster-Buch wird das Strategiemuster definiert als eine Familie von Algorithmen, die gekapselt und austauschbar sein sollen. Dabei variieren die Algorithmen unabhängig von den Klienten.
Das Strategiemuster ist ein gutes Beispiel für ein Entwurfsmuster, das in Python einfacher sein kann, wenn Funktionen als First-Class-Objekte benutzt werden. Hierfür implementieren wir zunächst die klassische Struktur dieses Musters und refaktorisieren anschließend diesen Code mithilfe von Funktionen.
Ein anschauliches Beispiel für die Anwendung des Strategiemusters ist die Berechnung von Rabatten auf Bestellungen in Abhängigkeit von den Eigenschaften der Kund*innen und der bestellten Artikel.
Nehmen wir ein Online-Geschäft mit den folgenden Rabattregeln:
Kunden mit tausend oder mehr Treuepunkten erhalten einen globalen Rabatt von 5 % pro Bestellung.
Ein Rabatt von 10 % wird auf jede Position mit zehn oder mehr Einheiten in derselben Bestellung gewährt.
Auf Bestellungen mit mindestens zehn verschiedenen Artikeln wird ein Rabatt von 7 % gewährt.
Dabei kann nur ein Rabatt auf eine Bestellung angewendet werden.
- Kontext
hält eine Variable von Strategie, die auf eine konkrete Strategie referenziert. In unserem E-Commerce-Beispiel ist der Kontext eine Bestellung (engl.:
Order, die so konfiguriert ist, dass sie einen Aktionsrabatt nach einem von mehreren Algorithmen anwendet.- Strategie
ist die gemeinsame Schnittstelle für die Komponenten, die die verschiedenen Algorithmen implementieren. In unserem Beispiel wird diese Rolle von einer abstrakten Klasse namens
Discountübernommen.- Konkrete Strategie
ist eine der konkreten Unterklassen der abstrakten Strategie.
LoyaltyDiscount,QuantityDiscountundBulkDiscountsind die drei implementierten konkreten Strategien.
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())
Funktionsorientierte Strategie¶
Jede konkrete Strategie im vorigen Beispiel ist eine Klasse mit einer einzigen
Methode, discount(). Darüber hinaus haben die Strategieinstanzen keinen
Zustand (keine Instanzattribute). Im folgenden Beispiel machen wir ein
Refactoring, wobei die konkreten Strategien durch einfache Funktionen ersetzt
werden und die abstrakte Klasse Promotion entfernt wird.
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
- Zeile 33:
Um einen Rabatt zu berechnen, ruft einfach die Funktion
self.promotion()auf.- Zeile 40:
Jede Strategie ist eine Funktion und keine Klasse.
Die Autoren des Entwurfsmuster-Buch schlagen die gemeinsame Nutzung mit dem Fliegengewicht-Entwurfsmuster vor:
Strategieobjekte sind oft gute Fliegengewichte.
Ein Fliegengewicht ist ein gemeinsam genutztes Objekt, das in mehreren Kontexten gleichzeitig verwendet werden kann.
Die gemeinsame Nutzung wird empfohlen, um die Kosten für die Erstellung eines neuen konkreten Strategieobjekts zu verringern, wenn dieselbe Strategie immer wieder in jedem neuen Kontext angewendet wird – in unserem Beispiel bei jeder neuen Bestellinstanz. Um also einen Nachteil des Strategiemusters zu überwinden – seine Laufzeitkosten – empfehlen die Autoren die Anwendung eines weiteren Musters. In der Zwischenzeit türmen sich die Codemenge und die Wartungskosten.
Tipp
In einem schwierigeren Anwendungsfall mit komplexen konkreten Strategien, die einen internen Zustand enthalten, können alle Teile des Strategie- und Fliegengewichtmusters kombiniert werden. Aber oft haben konkrete Strategien keinen internen Zustand; sie verarbeiten nur Daten aus dem Kontext. In diesem Fall solltet Sie auf jeden Fall einfache Funktionen verwenden, anstatt Ein-Methoden-Klassen zu kodieren, die eine Ein-Methoden-Schnittstelle implementieren, die in einer anderen Klasse deklariert ist. Eine Funktion ist leichtgewichtiger als eine Instanz einer benutzerdefinierten Klasse, und es besteht keine Notwendigkeit für die Fliegengewicht-Strategie, da jede Strategie-Funktion nur einmal von Python erstellt wird, wenn das Modul kompiliert wird. Eine einfache Funktion ist auch ein gemeinsam genutztes Objekt, das in mehreren Kontexten gleichzeitig verwendet werden kann.
Dabei kann hilfreich sein, dass sich die eingebaute Funktion globals()
innerhalb einer Funktion oder Methode immer auf das Modul, in dem diese Funktion
oder Methode definiert ist, bezieht – und nicht auf das Modul, aus dem sie
aufgerufen wird.
So kann globals() dazu verwendet werden, um alle im Modul verfügbaren
special_promo-Funktionen automatisch zu finden:
60promos = [globals()[name] for name in globals() if name.endswith("_promo")]
Dies iteriert über jeden Namen im Dictionary, das von
globals() zurückgegeben wird und wählt nur diejenigen Namen aus, die
mit dem Suffix _promo enden.
Um die special_promo-Funktionen in einem anderen Modul zu finden, kann
die inspect-Bibliothek verwendet werden:
1import promos
2
3promotions = [func for name, func in inspect.getmembers(promos, inspect.isfunction)]
Die Funktion inspect.getmembers() gibt die Attribute eines Objekts
zurück – in diesem Fall das promos. Anschließend verwenden wir
inspect.isfunction(), um nur die Funktionen des Moduls zu erhalten.
Dieses Beispiel funktioniert unabhängig von den Namen der Funktionen; wichtig
ist nur, dass das promos-Modul die relevanten Funktionen enthält.