Zum Inhalt
wannacode

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:

record Product(string Name, decimal Price);
record Box(string Name, decimal Packaging, List<object> Items);

Die 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:

static decimal Total(List<object> order)
{
decimal sum = 0;
foreach (object item in order)
{
if (item is Product p)
{
sum += p.Price;
}
else if (item is Box box)
{
sum += box.Packaging;
foreach (object inner in box.Items)
{
if (inner is Product q)
{
sum += q.Price;
}
}
}
}
return sum;
}
List<object> order = [
new Product("Kerze", 5.00m),
new Box("Weihnachtsbox", 2.00m, [
new Product("Tee", 4.50m),
new Product("Honig", 6.00m),
new Box("Kleine Box", 1.00m, [
new Product("Bonbons", 3.50m),
new Product("Schokolade", 2.50m)])])];
Console.WriteLine($"Gesamt: {Total(order):F2} €");
Ausgabe
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 eine Form: das Interface Item (IItem in C#) mit einer Methode Total(). Die Box hält eine Liste von Item und weiß nicht, welches davon ein Produkt und welches eine Box ist.

Das ist das Muster Composite. Es hat drei Rollen:

  • die gemeinsame Form: das Interface mit 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

Die gemeinsame Form:

interface IItem
{
decimal Total();
}

Das Produkt und die Box. Der Konstruktor der Box nimmt beliebig viele Teile (params IItem[] items):

record Product(string Name, decimal Price) : IItem
{
public decimal Total() => Price;
}
class Box : IItem
{
private readonly List<IItem> items;
public string Name { get; }
public decimal Packaging { get; }
public Box(string name, decimal packaging, params IItem[] items)
{
Name = name;
Packaging = packaging;
this.items = new List<IItem>(items);
}
public decimal Total()
{
decimal sum = Packaging;
foreach (IItem item in items)
{
sum += item.Total();
}
return sum;
}
}

Der Baum wird als geschachtelte Konstruktoren geschrieben, ein Teil pro Zeile:

IItem order = new Box("Bestellung", 0,
new Product("Kerze", 5.00m),
new Box("Weihnachtsbox", 2.00m,
new Product("Tee", 4.50m),
new Product("Honig", 6.00m),
new Box("Kleine Box", 1.00m,
new Product("Bonbons", 3.50m),
new Product("Schokolade", 2.50m))));
Console.WriteLine($"Gesamt: {order.Total():F2} €");
Ausgabe
Gesamt: 24.50 €

IItem order = new Box("Bestellung", 0,
new Product("Kerze", 5.00m),
new Box("Weihnachtsbox", 2.00m,
new Product("Tee", 4.50m),
new Product("Honig", 6.00m),
new Box("Kleine Box", 1.00m,
new Product("Bonbons", 3.50m),
new Product("Schokolade", 2.50m))));
Console.WriteLine($"Gesamt: {order.Total():F2} €");
Schritt für Schritt, in Worten
  1. Der Baum wird zusammengesetzt: Jeder Artikel steht in einer eigenen Zeile, und jede Box hält ihre Kinder.
  2. Total() fragt die Wurzel. Die Bestellung weiß nicht, was drin ist: Sie beginnt bei ihrer Verpackung und ruft Total() bei jedem Kind auf.
  3. Kerze ist ein Produkt: Es antwortet mit seinem Preis, 5.00, und die Antwort geht wieder nach oben.
  4. Weihnachtsbox ist eine Box: Sie macht genau dasselbe, erst die Verpackung, dann ein Kind nach dem anderen.
  5. Kleine Box ist auf der dritten Ebene. Hier verschwindet nichts: Sie fragt ihre eigenen Kinder.
  6. Kleine Box: 1.00 + 3.50 + 2.50 = 7.00. Ihre Summe geht hoch zum Elternteil.
  7. Weihnachtsbox: 2.00 + 4.50 + 6.00 + 7.00 = 19.50.
  8. 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:

record WeighedProduct(string Name, decimal PerKg, decimal Kg) : IItem
{
public decimal Total() => PerKg * Kg;
}
IItem coffee = new Box("Kaffeebox", 1.00m,
new WeighedProduct("Kaffeebohnen", 24.00m, 0.25m),
new Product("Tasse", 8.00m));
Console.WriteLine($"Kaffeebox: {coffee.Total():F2} €");
Ausgabe
Kaffeebox: 15.00 €

Keine einzige Zeile von Box hat sich geändert. Die Box ruft Total() beim Kaffee auf wie bei jedem anderen Teil.

Im Problem war die Liste eine List<object> und nahm alles. Jetzt nimmt die Box nur noch ein IItem, und Text an der Stelle eines Produkts kompiliert nicht:

IItem box = new Box("Box", 1.00m, "Tee");
Fehler
error CS1503: Argument 3: cannot convert from 'string' to 'IItem'
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.

Probier es aus

Sag die Ausgabe voraus

Die Box enthält eine leere Box und eine Kerze. Was gibt das Programm aus?

IItem box = new Box("Box", 1.00m,
new Box("Leer", 0.50m),
new Product("Kerze", 5.00m));
Console.WriteLine($"Box: {box.Total():F2} €");
Antwort

Probier es aus

Finde den Fehler

Jemand schreibt die Box um, damit sie „sicherer“ wird:

class Box : IItem
{
private readonly List<IItem> items;
public string Name { get; }
public decimal Packaging { get; }
public Box(string name, decimal packaging, params IItem[] items)
{
Name = name;
Packaging = packaging;
this.items = new List<IItem>(items);
}
public decimal Total()
{
decimal sum = Packaging;
foreach (IItem item in items)
{
if (item is Product)
{
sum += item.Total();
}
}
return sum;
}
}
IItem gift = new Box("Weihnachtsbox", 2.00m,
new Product("Tee", 4.50m),
new Product("Honig", 6.00m),
new Box("Kleine Box", 1.00m,
new Product("Bonbons", 3.50m),
new Product("Schokolade", 2.50m)));
Console.WriteLine($"Weihnachtsbox: {gift.Total():F2} €");
Ausgabe
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 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(string indent) hinzu, auch dem Interface. 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:

Ausgabe
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

1. Der Shop verkauft jetzt Kaffee nach Kilo. Was schreibst du mit Composite?
2. Wie viele Ebenen darf der Baum haben, damit die Summe stimmt?
3. Eine Box addiert ihre Verpackung und die Preise nur der Produkte, die direkt in ihr liegen. Was passiert mit einer Box in der Box?
4. Wann brauchst du Composite nicht?

Erstellt von Ivo Gergov im Rahmen der Kampagne von Evo Tech für zugängliche Programmierausbildung ·