2 Structural Design Patterns die du als Pythonentwickler kennen solltest – Nützliche Strukturmuster für Python: Composite & Decorator

2 Structural Design Patterns die du als Pythonentwickler kennen solltest – Nützliche Strukturmuster für Python: Composite & Decorator

Structural Design Patterns (dt. Strukturmuster) sind Design Patterns (dt. Entwurfsmuster), die das Design von Beziehungen zwischen Softwareteilen (wie Klassen, Funktionen oder Objekten) vereinfachen.

Anders als bei den Creational Pattern geht es dabei nicht darum Objekte zu erstellen. Sondern eher dazu Verknüpfungen zwischen bestehenden Strukturen zu beschreiben und zu vereinfachen.

In diesem Beitrag lernst du diese 2 Design Pattern:

  • Composite Pattern
  • Decorator Pattern

Composite Pattern

“A mechanism for treating individual (scalar) objects and compositions of objects in a uniform manner.”
– Dmitri Nesteruk

Das Ziel des Composite Pattern (dt. Kompositum/Zusammenstellungsmuster) ist es, Komponenten bzw. Gruppen von Objekten in gleicher weise anzusprechen. Du brauchst es also, wenn du keine Unterscheidung zwischen einem Objekt und einer Gruppe von Objekten treffen willst. Dadurch wird ein standardisiertes Interface sichergestellt.

Im Allgemeinen können Objekte andere Objekte durch inheritance (dt. Vererbung) oder durch composition (dt. Komposition) beinhalten. Bei Objekten mit composition spricht man auch von compound objects (dt. Zusammengesetzte Objekte). Das bedeutet sie beinhalten Objekte von anderen Klassen.

Das Composite Pattern wird verwendet um sowohl einzelne Objekte (engl. individual objects) als auch zusammengesetzte Objekte gleich benutzen zu können. Typischerweise ist das bei Bildern bzw. Grafiken der Fall. Sie sind oft aus kleineren Bildern zusammen gesetzt.

Composite Pattern
Composite Pattern als Klassendiagramm

Im folgenden Beispiel siehst du das Composite Pattern in Aktion. Die Grafik ist die Komponente. Sie kann mehrere Komposita (Kreis und Rechteck) beinhalten die ihrerseits von der Komponente erben und nur den Namen überschreiben.

class Grafik:
    def __init__(self):
        self.children = []

    @property
    def name(self):
        return "Leinwand"

    def _print(self, items, depth):
        items.append("*" * depth)
        items.append(f"{self.name}\n")
        for child in self.children:
            child._print(items, depth + 1)

    def __str__(self):
        items = []
        self._print(items, 0)
        return "".join(items)

class Kreis(Grafik):
    @property
    def name(self):
        return "Kreis"

class Rechteck(Grafik):
    @property
    def name(self):
        return "Rechteck"

Dadurch lassen sich die Objekte ineinander verschachteln. Im Folgenden siehst du, wie eine Grafik aus einem Rechteck, einem Kreis und einer weiteren Leinwand besteht.

if __name__ == "__main__":
    zeichnung = Grafik()
    zeichnung.children.append(Rechteck())
    zeichnung.children.append(Kreis())

    unterzeichnung = Grafik()
    unterzeichnung.children.append(Kreis())
    unterzeichnung.children.append(Rechteck())
    zeichnung.children.append(unterzeichnung)

    print(zeichnung)
Leinwand
*Rechteck
*Kreis
*Leinwand
**Kreis
**Rechteck

In diesem Beispiel verwende ich keine Blätter. Das wären Grafikobjekte, die keine eigenen Kinder haben können.

Decorator Pattern

“Facilitates the addition of behaviors to individual objects without inheriting from them.”
– Dmitri Nesteruk

Mit dem Decorator Pattern (dt. Dekorierer) kannst du das Verhalten von Funktionen und Klassen verändern. Und zwar ohne ihren Code zu verändern oder zu überschreiben.

Das funktioniert, indem du einen Wrapper um das jeweilige Objekt schreibst der zum einen das originale Objekt aufrufen kann. Und zum anderen ihn seinerseits noch um eigene Codezeilen ergänzt.

Wenn du den Code direkt veränderst, würde das gegen das Open Close Prinzip (OCP) und eventuell dem Single Responsibility Prinzip (SPR) verstoßen. Das wollen wir natürlich nicht.

Generell stehen dir für diese Veränderung zwei Optionen offen:

  • vom zu verändernden Objekt erben und Methoden überschreiben
  • einen Decorator bauen der auf das zu verändernden Objekt verweist

Das Decorator Pattern nutzt wie schon der Name sagt die zweite Option.

Funktion verändern

Wenn es sich beim zu verändernden Objekt um eine Funktion handelt, brauchst du den Functional Decorator. Hier ein Beispiel für ein Decorator der die Ausführdauer einer Funktion misst.

import time

def time_it(func):
    def wrapper():
        start = time.time()
        func()
        end = time.time()
        print(f"{func.__name__} lief {int((end-start)*1000)} ms")

    return wrapper

Diesen Decorator kannst ihn auf zwei Arten ausführen. Zum einen in der Kurzschreibweise mit dem @ Zeichen vor der Funktionsdefinition.

@time_it
def funktion_mit_decorator():
    print("Starte")
    time.sleep(1)
    print("Fertig")
    return

def funktion_ohne_decorator():
    print("Starte")
    time.sleep(1)
    print("Fertig")
    return

Aber da wir – wie eingangs erwähnt – die Funktionsdefinition nicht verändern wollen (oder können) gibt es noch die Möglichkeit den Decorator beim Funktionsaufruf mitzugeben.

Hier siehst du die beiden Varianten.

if __name__ == "__main__":
    funktion_mit_decorator()
    time_it(funktion_ohne_decorator)()
Starte
Fertig
funktion_mit_decorator lief 1008 ms
Starte
Fertig
funktion_ohne_decorator lief 1009 ms

Klasse inklusive Ableitungen verändern

Du kannst auch das Verhalten einer ganzen Klasse inklusive deren Ableitungen verändern. Dazu schreibst du einen Wrapper für das Interface.

Im Beispiel ein Form-Interface mit einer Implementation eines Rechtecks.

from abc import ABC

class Form(ABC):
    def __str__(self):
        return ""

class Quadrat(Form):
    def __init__(self, seite):
        self.seite = seite

    def vergroessern(self, faktor):
        self.seite *= faktor

    def __str__(self):
        return f"ein Quadrat mit der Größe {self.seite}"

Und jetzt kommt der Decorator. Dieser erbt von derselben Basisklasse wie das Rechteck. Dadurch stellst du sicher, dass dein Decorator auch mit anderen Ableitungen funktioniert. Theoretisch kannst du dir vorstellen, dass es noch eine Kreis-Klasse geben könnte die ebenfalls von der Form-Klasse abhängt.

class FarbigeForm(Form):
    def __init__(self, form, farbe):
        self.form = form
        self.farbe = farbe

    def __str__(self):
        return f"{self.form} hat die Farbe {self.farbe}"

Diesen Klassendekorator setzt du ein, indem du ihn neu Instanziierst und das zu verändernde Objekt als Parameter reingibst.

if __name__ == "__main__":
    quadrat = Quadrat(2)
    print(quadrat)
    rotes_quadrat = FarbigeForm(quadrat, "rot")
    print(rotes_quadrat)
    rotes_quadrat.form.vergroessern(3)
    print(rotes_quadrat)
ein Quadrat mit der Größe 2
ein Quadrat mit der Größe 2 hat die Farbe rot
ein Quadrat mit der Größe 6 hat die Farbe rot

Zu beachten ist, dass Funktionen die in der Konkretisierung (also hier in der Quadrat-Klasse) definiert sind nicht direkt verwendet werden können. Schließlich erbt der Decorator von der Basisklasse. Das ist bei der vergroessern() Funktion der Fall.

Dynamischer Decorator

Zu guter letzte noch mal eine Variation des letzten Beispiels. Wenn man direkt auf vergroessern (und allen anderen Attributen von Quadrat) zugreifen möchte, kann man den Decorator noch dynamischer gestalten. Das würde dann wie folgt aussehen.

class FarbigeForm(Form):
    def __init__(self, form, farbe):
        self.form = form
        self.farbe = farbe

    def __str__(self):
        return f"{self.form} hat die Farbe {self.farbe}"

    def __iter__(self):
        return self.form.__iter__()

    def __next__(self):
        return self.form.__next__()

    def __getattr__(self, item):
        return getattr(self.form, item)

    def __setattr__(self, key, value):
        if key == "form":
            self.__dict__[key] = value
        else:
            setattr(self.__dict__["form"], key, value)

    def __delattr__(self, item):
        delattr(self.__dict__["form"], item)

if __name__ == "__main__":
    quadrat = Quadrat(2)
    print(quadrat)
    rotes_quadrat = FarbigeForm(quadrat, "rot")
    print(rotes_quadrat)
    rotes_quadrat.vergroessern(3)
    print(rotes_quadrat)
ein Quadrat mit der Größe 2
ein Quadrat mit der Größe 2 hat die Farbe rot
ein Quadrat mit der Größe 6 hat die Farbe rot

Dies ist nicht mehr die klassische Variante des Decorators, aber kann durchaus nützlich sein! Der Nachteil ist, dass durch die zusätzlichen Methodenaufrufe eine höhere Last entsteht. Dadurch kann sich die Performance reduzieren.

Schreibe einen Kommentar