Zum Inhalt
wannacode

Decorator

Kurz gesagt: Decorator umhüllt ein Objekt mit einem anderen Objekt derselben Schnittstelle und fügt Verhalten hinzu, ohne die Klasse zu ändern und ohne eine Klasse für jede Kombination.

Problem

Die Karte, die mit jedem Extra wächst

Das Café verkauft Espresso für 2 €. Der Kunde kann Milch (+0,40 €), Sirup (+0,60 €) und einen zusätzlichen Shot (+0,80 €) dazunehmen. Jemand schreibt für jede Kombination eine Klasse:

class Espresso
{
public virtual decimal Cost() => 2.00m;
public virtual string Description() => "Espresso";
}
class EspressoWithMilk : Espresso
{
public override decimal Cost() => base.Cost() + 0.40m;
public override string Description() => base.Description() + ", Milch";
}
class EspressoWithMilkAndSyrup : EspressoWithMilk
{
public override decimal Cost() => base.Cost() + 0.60m;
public override string Description() => base.Description() + ", Sirup";
}
Espresso drink = new Espresso();
Console.WriteLine($"{drink.Description()}: {drink.Cost():F2} €");
drink = new EspressoWithMilk();
Console.WriteLine($"{drink.Description()}: {drink.Cost():F2} €");
drink = new EspressoWithMilkAndSyrup();
Console.WriteLine($"{drink.Description()}: {drink.Cost():F2} €");
Ausgabe
Espresso: 2.00 €
Espresso, Milch: 2.40 €
Espresso, Milch, Sirup: 3.00 €

Für die drei Getränke auf der Karte funktioniert das. Aber drei Extras ergeben acht Kombinationen, und mit Cappuccino und Tee sind es 24 Klassen. Ein weiteres Extra verdoppelt die Zahl.

EspressoWithSyrup (ohne Milch) kann den Sirup nicht von EspressoWithMilkAndSyrup erben, weil eine Klasse nur eine Basisklasse hat. Der Sirup-Code wird kopiert, und wenn der Sirup teurer wird, ändert er sich an mehreren Stellen.

Und der Kunde möchte zweimal Sirup. Eine solche Klasse gibt es nicht. Eine Klasse für einen, zwei und drei Sirups ist keine Lösung.

Die Idee

Decorator: eine Hülle mit derselben Form

Ein Extra ist keine Art von Getränk. Es ist etwas, das ein Getränk umhüllt: Es nimmt das Getränk und fügt seinen eigenen Teil hinzu.

Deshalb haben alle eine gemeinsame Form: das Interface Drink (IDrink in C#) mit Preis und Beschreibung. Der Espresso implementiert es und kennt seinen eigenen Preis. Auch Milk implementiert es, hält aber in einem Feld ein anderes Getränk, das ein Drink ist. Wird es nach dem Preis gefragt, fragt es das innere Getränk und addiert 0,40 €.

Das ist das Decorator-Muster. Es hat drei Rollen:

  • die gemeinsame Form: das Interface, das die Getränke und die Hüllen gemeinsam haben;
  • die Basis: der Espresso, der nichts umhüllt;
  • die Decorators: Milch, Sirup, der zusätzliche Shot. Jeder hält ein inneres Getränk, ruft darauf dieselbe Methode auf und fügt seinen eigenen Teil hinzu.

Weil die Hülle dieselbe Form hat wie das Getränk, kann eine Hülle eine Hülle umhüllen. Doppelter Sirup sind zwei Schichten Sirup. Der Code, der nach dem Preis fragt, weiß nicht, wie viele Schichten es gibt.

Sieh es dir an

Schicht für Schicht

Die gemeinsame Form:

interface IDrink
{
decimal Cost();
string Description();
}

Der Espresso und zwei Extras. Jedes Extra bekommt das innere Getränk im Konstruktor:

class Espresso : IDrink
{
public decimal Cost() => 2.00m;
public string Description() => "Espresso";
}
class Milk : IDrink
{
private readonly IDrink inner;
public Milk(IDrink inner)
{
this.inner = inner;
}
public decimal Cost() => inner.Cost() + 0.40m;
public string Description() => inner.Description() + ", Milch";
}
class Syrup : IDrink
{
private readonly IDrink inner;
public Syrup(IDrink inner)
{
this.inner = inner;
}
public decimal Cost() => inner.Cost() + 0.60m;
public string Description() => inner.Description() + ", Sirup";
}

Die Bestellung wird von innen nach außen zusammengesetzt: zuerst der Espresso, dann umhüllt jede Schicht das, was es bisher gibt.

IDrink drink = new Espresso();
drink = new Milk(drink);
drink = new Syrup(drink);
drink = new Syrup(drink);
Console.WriteLine($"{drink.Description()}: {drink.Cost():F2} €");
Ausgabe
Espresso, Milch, Sirup, Sirup: 3.60 €

IDrink drink = new Espresso();
drink = new Milk(drink);
drink = new Syrup(drink);
drink = new Syrup(drink);
Console.WriteLine($"{drink.Description()}: {drink.Cost():F2} €");
Schritt für Schritt, in Worten
  1. Der Espresso ist die Basis. Er umhüllt nichts und kennt seinen Preis: 2.00.
  2. Milch umhüllt den Espresso und hält ihn im Feld inner. drink zeigt jetzt auf die Milch, die ebenfalls ein IDrink ist.
  3. Der Sirup umhüllt die Milch. Die Milch und der Espresso darin werden nicht angefasst.
  4. Noch einmal Sirup: eine zweite Schicht derselben Klasse. Das ist der doppelte Sirup.
  5. Cost() geht an die äußerste Schicht. Sie addiert noch nichts: Zuerst ruft sie inner.Cost() auf, und so macht es jede Schicht auf dem Weg nach innen.
  6. Der Espresso antwortet 2.00. Auf dem Weg nach außen addiert jede Schicht ihren Teil zu dem, was von innen kommt.
  7. Aus der äußersten Schicht kommt 3.60 heraus. Description() nimmt denselben Weg und sammelt die Wörter.

Der Aufruf von Cost() geht Schicht für Schicht nach innen bis zum Espresso, und die Summe wächst auf dem Weg nach außen. Keine der drei Klassen weiß etwas von den anderen.

Sieh es dir an

Eine neue Schicht, ohne die alten anzufassen

Der zusätzliche Shot und der Happy-Hour-Rabatt sind zwei neue Klassen. Der Rabatt addiert nicht, er multipliziert alles, was innen ist, mit 0,9:

class ExtraShot : IDrink
{
private readonly IDrink inner;
public ExtraShot(IDrink inner)
{
this.inner = inner;
}
public decimal Cost() => inner.Cost() + 0.80m;
public string Description() => inner.Description() + ", Extra-Shot";
}
class Discount : IDrink
{
private readonly IDrink inner;
public Discount(IDrink inner)
{
this.inner = inner;
}
public decimal Cost() => inner.Cost() * 0.9m;
public string Description() => inner.Description() + ", 10 % Rabatt";
}
IDrink order = new Discount(new ExtraShot(new Milk(new Espresso())));
Console.WriteLine($"{order.Description()}: {order.Cost():F2} €");
Ausgabe
Espresso, Milch, Extra-Shot, 10 % Rabatt: 2.88 €

Espresso, Milk und Syrup haben sich nicht geändert. Die neue Schicht legt sich um jedes Getränk, auch um eines, das schon umhüllt ist.

Du liest new Discount(new ExtraShot(new Milk(new Espresso()))) von innen nach außen: ein Espresso, umhüllt von Milch, umhüllt von einem Shot, umhüllt von einem Rabatt. Die Reihenfolge zählt: Der Rabatt gilt nur für das, was innen ist.

Eine Hülle um etwas, das kein Getränk ist, lässt sich nicht kompilieren:

IDrink drink = new Milk("Espresso");
Fehler
error CS1503: Argument 1: cannot convert from 'string' to 'IDrink'

Der Konstruktor will ein IDrink, und der Compiler stoppt vor der ersten Bestellung.

Sieh es dir an

Eine Hülle um eine Methode

Dieselbe Idee funktioniert auch außerhalb des Kaffees. Der Bestellservice läuft, und jetzt soll jeder Aufruf sichtbar sein: was gefragt wurde und was zurückkam. Der Code des Services soll nicht angefasst werden: Er ist getestet, und die Logs sind vorübergehend.

Der Service steht hinter einem Interface:

interface IOrderService
{
int Place(string drink, int quantity);
}
class CafeOrderService : IOrderService
{
private int next = 1001;
public int Place(string drink, int quantity)
{
Console.WriteLine($"Zubereitung: {quantity} × {drink}");
return next++;
}
}

Die Hülle hat dasselbe Interface, schreibt das Log und ruft den echten Service auf:

class LoggingOrderService : IOrderService
{
private readonly IOrderService inner;
public LoggingOrderService(IOrderService inner)
{
this.inner = inner;
}
public int Place(string drink, int quantity)
{
Console.WriteLine($"[Log] Place({drink}, {quantity})");
int number = inner.Place(drink, quantity);
Console.WriteLine($"[Log] Place lieferte {number}");
return number;
}
}
IOrderService orders = new LoggingOrderService(new CafeOrderService());
orders.Place("Cappuccino", 2);
orders.Place("Espresso", 1);
Ausgabe
[Log] Place(Cappuccino, 2)
Zubereitung: 2 × Cappuccino
[Log] Place lieferte 1001
[Log] Place(Espresso, 1)
Zubereitung: 1 × Espresso
[Log] Place lieferte 1002

Der Code, der Bestellungen annimmt, bekommt einen IOrderService und weiß nicht, dass er umhüllt ist. Du entfernst die Logs, indem du den Service ohne die Hülle übergibst. Dasselbe gilt für Zeitmessung, einen Cache oder einen neuen Versuch nach einem Fehler: Jedes davon ist eine eigene Schicht.

Genauer, für Neugierige

.NET hat Decorators in der eigenen Bibliothek: GZipStream und BufferedStream umhüllen einen anderen Stream und nutzen ihn von innen. In ASP.NET Core bekommt jede Middleware die Anfrage, tut ihren Teil und ruft die nächste auf, und ein DelegatingHandler umhüllt den Handler, über den HttpClient seine Anfragen sendet.

Probier es aus

Was kommt heraus?

Der Rabatt und der Sirup sind in beiden Fällen dieselben, nur in anderer Reihenfolge. Was gibt das Programm aus?

IDrink a = new Discount(new Syrup(new Espresso()));
IDrink b = new Syrup(new Discount(new Espresso()));
Console.WriteLine($"{a.Cost():F2} €");
Console.WriteLine($"{b.Cost():F2} €");
Antwort

Probier es aus

Finde den Fehler

Jemand schreibt den Sirup um:

class Syrup : IDrink
{
private readonly IDrink inner;
public Syrup(IDrink inner)
{
this.inner = inner;
}
public decimal Cost() => 0.60m;
public string Description() => inner.Description() + ", Sirup";
}
IDrink drink = new Syrup(new Milk(new Espresso()));
Console.WriteLine($"{drink.Description()}: {drink.Cost():F2} €");
Ausgabe
Espresso, Milch, Sirup: 0.60 €

Die Beschreibung stimmt, und die Summe ist 0,60 €. In welcher Zeile steckt der Fehler?

Wähle die Zeile mit dem Fehler.

Die Idee

Wann du es nicht brauchst

Decorator hat seinen Preis: eine Klasse für jede Schicht, und die Reihenfolge ist ein Objekt in einem Objekt in einem Objekt. Es lohnt sich nicht, wenn:

  • jedes Extra nur einen Preis und einen Namen hinzufügt. Dann sind die Extras Daten: eine Liste von Paaren „Name, Preis“, und die Summe ist der Preis der Basis plus die Summe der Liste. Ein neuer Sirup ist eine neue Zeile in der Karte, keine neue Klasse;
  • es eine einzige Schicht gibt, die nie entfernt wird. Ihr Code kann in der Klasse selbst stehen;
  • das umhüllte Interface zwanzig Methoden hat. Jede Hülle muss alle zwanzig weiterreichen, auch wenn sie nur eine ändert.

Achte auch auf tiefe Stapel: Sechs Schichten sind sechs Zeilen in einem Stacktrace, und ihre Reihenfolge ist nur dort sichtbar, wo sie zusammengesetzt werden.

Es lohnt sich, wenn die Schichten Verschiedenes tun (einen Rabatt, Logging, einen Cache, einen neuen Versuch), für verschiedene Fälle unterschiedlich zusammengesetzt werden und du das, was du umhüllst, nicht anfassen willst.

Übung

Cappuccino mit Sahne

Löse die Aufgabe in deinem Editor.

Cappuccino mit Sahne. Das Café nimmt einen Cappuccino für 2,80 € und Sahne für 0,50 € dazu. Schreibe die Klassen Cappuccino und WhippedCream und bestelle einen Cappuccino mit zweimal Sahne. Die alten Klassen werden nicht angefasst.

Ausgabe
Cappuccino, Sahne, Sahne: 3.80 €

Weitere Aufgaben

  • Wie lange es dauert. Schreibe einen TimingOrderService, der die Zeit jedes place misst und ausgibt, wie viele Millisekunden es gedauert hat. Nutze es zusammen mit dem Logging. Welche Schicht liegt außen, und warum ist das wichtig?
  • Ein großer Becher. Ein großer Becher kostet 30 % mehr. Füge eine Schicht Large hinzu. Lege sie einmal um den Espresso und einmal um die ganze Bestellung mit Milch und Sirup. Welche Variante ist für den Kunden fair?
  • Ein Zähler. Eine Hülle um den Service zählt, wie viele Bestellungen durchgegangen sind, und gibt die Zahl am Ende aus. Überlege, wo der Zähler lebt: in der Hülle oder im Service.

Test

Teste dich

1. Ein Kunde möchte einen Espresso mit Milch und zweimal Sirup. Was schreibst du mit Decorator?
2. Was tut Milk, wenn es nach cost() gefragt wird?
3. LoggingOrderService umhüllt CafeOrderService. Was ändert sich in CafeOrderService, damit es Logs gibt?
4. Wann brauchst du Decorator nicht?

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