Composite
Kurz gesagt: Composite gibt einem einzelnen Teil und einer Gruppe dieselbe Form, sodass ein Baum aus Produkten und Boxen mit einem Aufruf behandelt wird, egal wie tief er ist.
Problem
Eine Box in einer Box in einer Box
Der Shop verkauft Geschenkboxen. Die Weihnachtsbox enthält Tee, Honig und eine kleinere Box mit Bonbons und Schokolade. Jede Box hat außerdem einen Preis für ihre Verpackung. Die Bestellung enthält eine Kerze und die Weihnachtsbox. Die Daten sehen so aus:
from dataclasses import dataclass
@dataclassclass Product: name: str price: float
@dataclassclass Box: name: str packaging: float items: listDie Summe wird berechnet, indem man prüft, was jedes Teil ist: ein Produkt oder eine Box. Für eine Box gibt es eine zweite Schleife:
def total(order): result = 0 for item in order: if isinstance(item, Product): result += item.price elif isinstance(item, Box): result += item.packaging for inner in item.items: if isinstance(inner, Product): result += inner.price return resultorder = [ Product("Kerze", 5.00), Box("Weihnachtsbox", 2.00, [ Product("Tee", 4.50), Product("Honig", 6.00), Box("Kleine Box", 1.00, [ Product("Bonbons", 3.50), Product("Schokolade", 2.50)])])]print(f"Gesamt: {total(order):.2f} €")Gesamt: 17.50 €Die Bestellung kostet 24,50 €, und das Programm sagt 17,50 €. Die kleine Box liegt auf der dritten Ebene. Die zweite Schleife sieht sie, aber sie ist kein Produkt und verschwindet zusammen mit den Bonbons und der Schokolade aus der Summe. Es gibt keinen Fehler.
Eine dritte verschachtelte Schleife repariert diese Bestellung, aber nicht eine Box auf der vierten Ebene. Und dieselbe Kette von Prüfungen steckt auch im Zählen der Produkte und im Gewicht für den Kurier.
Die Idee
Composite: Teil und Ganzes mit einer Form
Das Produkt und die Box werden dasselbe gefragt: Wie viel kostest du? Das Produkt antwortet mit seinem Preis. Die Box antwortet mit ihrer Verpackung plus den Antworten von allem, was in ihr liegt.
Also haben beide dieselbe Methodetotal(). Die Box hält eine Liste von Teilen und fragt nicht, was sie sind: Sie ruft bei jedem total() auf. Ein Interface ist nicht nötig, solange jedes Teil diese Methode hat.Das ist das Muster Composite. Es hat drei Rollen:
- die gemeinsame Form: die Methode
total(); - das Blatt: das Produkt, das selbst rechnet;
- der Container: die Box, die Kinder mit derselben Form hält und jedes fragt.
Eine Box in der Box ist einfach ein Kind, das auch seine Kinder fragt. So gehen die Aufrufe bis zu den Produkten hinunter, egal wie viele Ebenen es gibt: Das ist Rekursion durch Objekte. Der Code, der nach der Summe fragt, weiß nicht, ob er ein Produkt, eine Box oder einen ganzen Baum in der Hand hat.
Ein Ordner mit Dateien hat dieselbe Form: Die Größe einer Datei ist ihre eigene, und die Größe eines Ordners ist die Summe von allem darin, auch der Ordner darin.
Sieh es dir an
Die Summe geht bis ganz unten und kommt zurück
Das Produkt und die Box. Das Produkt ist eine dataclass, und die Box nimmt beliebig viele Teile (*items):
@dataclassclass Product: name: str price: float
def total(self): return self.price
class Box: def __init__(self, name, packaging, *items): self.name = name self.packaging = packaging self.items = list(items)
def total(self): return self.packaging + sum(item.total() for item in self.items)Der Baum wird als geschachtelte Konstruktoren geschrieben, ein Teil pro Zeile:
order = Box("Bestellung", 0, Product("Kerze", 5.00), Box("Weihnachtsbox", 2.00, Product("Tee", 4.50), Product("Honig", 6.00), Box("Kleine Box", 1.00, Product("Bonbons", 3.50), Product("Schokolade", 2.50))))print(f"Gesamt: {order.total():.2f} €")Gesamt: 24.50 €order = Box("Bestellung", 0, Product("Kerze", 5.00), Box("Weihnachtsbox", 2.00, Product("Tee", 4.50), Product("Honig", 6.00), Box("Kleine Box", 1.00, Product("Bonbons", 3.50), Product("Schokolade", 2.50))))print(f"Gesamt: {order.total():.2f} €")Schritt für Schritt, in Worten
- Der Baum wird zusammengesetzt: Jeder Artikel steht in einer eigenen Zeile, und jede Box hält ihre Kinder.
- total() fragt die Wurzel. Die Bestellung weiß nicht, was drin ist: Sie beginnt bei ihrer Verpackung und ruft total() bei jedem Kind auf.
- Kerze ist ein Produkt: Es antwortet mit seinem Preis, 5.00, und die Antwort geht wieder nach oben.
- Weihnachtsbox ist eine Box: Sie macht genau dasselbe, erst die Verpackung, dann ein Kind nach dem anderen.
- Kleine Box ist auf der dritten Ebene. Hier verschwindet nichts: Sie fragt ihre eigenen Kinder.
- Kleine Box: 1.00 + 3.50 + 2.50 = 7.00. Ihre Summe geht hoch zum Elternteil.
- Weihnachtsbox: 2.00 + 4.50 + 6.00 + 7.00 = 19.50.
- Bestellung: 0.00 + 5.00 + 19.50 = 24.50. Das gibt das Programm aus.
Nirgends gibt es eine Typprüfung und nirgends eine Zahl von Ebenen. Die Box tut auf jeder Ebene dasselbe: die Verpackung plus die Summe der Kinder.
Sieh es dir an
Eine neue Art von Teil, ohne die Box anzufassen
Der Shop verkauft jetzt Kaffeebohnen für 24 € pro Kilo. Ihr Preis ist der Preis pro Kilo mal die Kilos. Das ist eine neue Klasse mit derselben Form:
@dataclassclass WeighedProduct: name: str per_kg: float kg: float
def total(self): return self.per_kg * self.kgcoffee = Box("Kaffeebox", 1.00, WeighedProduct("Kaffeebohnen", 24.00, 0.25), Product("Tasse", 8.00))print(f"Kaffeebox: {coffee.total():.2f} €")Kaffeebox: 15.00 €Keine einzige Zeile von Box hat sich geändert. Die Box ruft total() beim Kaffee auf wie bei jedem anderen Teil.
Hier nimmt die Box alles. Text an der Stelle eines Produkts geht ohne Fehler hinein, und das Programm bricht erst bei der Summe ab:
box = Box("Box", 1.00, "Tee")print(box.total())AttributeError: 'str' object has no attribute 'total'Das ist immer noch besser als im Problem, wo so ein Teil stillschweigend übersprungen wurde: Hier zeigt sich der Fehler, sobald jemand nach der Summe fragt.
Genauer, für Neugierige
Eine neue Art von Teil ist leicht: eine Klasse. Eine neue Operation kostet mehr: Brauchst du neben der Summe auch ein Gewicht für den Kurier, kommt die Methode weight() in jede Klasse. Composite ist stark dort, wo die Arten von Teilen wachsen und die Operationen wenige sind.
typing.Protocol „etwas mit einer Methode total()“, und Werkzeuge wie mypy prüfen die Box, bevor das Programm startet. Python selbst prüft es zur Laufzeit nicht.Probier es aus
Sag die Ausgabe voraus
Die Box enthält eine leere Box und eine Kerze. Was gibt das Programm aus?
box = Box("Box", 1.00, Box("Leer", 0.50), Product("Kerze", 5.00))print(f"Box: {box.total():.2f} €")Probier es aus
Finde den Fehler
Jemand schreibt die Box um, damit sie „sicherer“ wird:
class Box: def __init__(self, name, packaging, *items): self.name = name self.packaging = packaging self.items = list(items)
def total(self): return self.packaging + sum( item.total() for item in self.items if isinstance(item, Product) )gift = Box("Weihnachtsbox", 2.00, Product("Tee", 4.50), Product("Honig", 6.00), Box("Kleine Box", 1.00, Product("Bonbons", 3.50), Product("Schokolade", 2.50)))print(f"Weihnachtsbox: {gift.total():.2f} €")Weihnachtsbox: 12.50 €Die Weihnachtsbox kostet 19,50 €, und das Programm sagt 12,50 €. In welcher Zeile steckt der Fehler?
Wähle die Zeile mit dem Fehler.
Die Prüfung lässt nur Produkte durch. Die kleine Box darin ist kein Produkt, also verschwinden sie, die Bonbons und die Schokolade (7 €) aus der Summe. Das ist derselbe Fehler wie im Problem, nur im Inneren der Box versteckt. Der Container in Composite fragt nicht, was das Kind ist: Er ruft bei jedem total() auf. Entferne die Prüfung, und die Summe ist 19,50 €.
Die Idee
Wann du es nicht brauchst
Composite hat einen Preis: eine gemeinsame Form und eine Rekursion, die du lesen musst, um eine Summe zu verstehen. Es lohnt sich nicht, wenn:
- die Dinge in einer flachen Liste liegen. Ein Warenkorb ohne Boxen ist eine Liste, und die Summe ist eine Schleife;
- es genau zwei Ebenen gibt und es dabei bleibt: eine Bestellung mit Positionen. Eine Bestellposition enthält keine Bestellung;
- die Operationen zahlreich werden und es nur zwei Arten von Teilen gibt. Jede neue Operation wird in jeder Klasse geschrieben. Dann ist es manchmal klarer, wenn eine Funktion den Baum mit einer Prüfung der Art durchläuft, aber an einer Stelle.
Es lohnt sich, wenn Teile und Ganze auf beliebig vielen Ebenen ineinander geschachtelt sind: Boxen, Ordner, Kategorien in einem Katalog, Menüs mit Untermenüs, Elemente auf einer Seite.
Übung
Der Kassenbon
Löse die Aufgabe in deinem Editor.
Der Kassenbon als Baum. Füge dem Produkt und der Box eine Methode print(self, indent="") hinzu. Das Produkt gibt seinen Namen und seinen Preis aus. Die Box gibt ihren Namen und ihre Summe aus und ruft dann print bei jedem Kind mit zwei Leerzeichen mehr auf. Gib die Bestellung aus der Karte „Die Summe geht bis ganz unten und kommt zurück“ aus:
Bestellung: 24.50 € Kerze: 5.00 € Weihnachtsbox: 19.50 € Tee: 4.50 € Honig: 6.00 € Kleine Box: 7.00 € Bonbons: 3.50 € Schokolade: 2.50 €Weitere Aufgaben
- Wie viele Produkte. Füge
count()hinzu: Ein Produkt ist 1, und eine Box ist die Summe ihrer Kinder. Die Verpackung ist kein Produkt. Die Bestellung hat 5. - Ordner und Dateien. Baue eine Datei mit Namen und Größe in KB und einen Ordner mit Dateien und Ordnern. Gib die Größe jedes Ordners aus. Was hat das mit den Boxen gemeinsam?
- Das teuerste Produkt. Finde den Preis des teuersten Produkts im ganzen Baum. Überlege, was eine leere Box zurückgibt.
Test
Teste dich
Verwandte Lektionen
Abschnitt „Verwandte Lektionen“- Baut auf: Exceptions · Listen: append, remove und sort · dataclass, match und Muster · Entwurfsmuster
- Geht weiter in: Command
- Aufgaben zum Thema: Entwurfsmuster