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.

title UML-Klassendiagramm für das Strategie-Entwurfsmuster

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

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, QuantityDiscount und BulkDiscount sind 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.