Zum Inhalt
wannacode

Decorator

Kurz gesagt: Decorator umhüllt ein Objekt mit einem anderen derselben Form und fügt Verhalten hinzu, ohne die Klasse anzufassen; in JavaScript umhüllt eine Funktion eine andere Funktion auf dieselbe Weise.

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 {
cost() {
return 2;
}
description() {
return "Espresso";
}
}
class EspressoWithMilk extends Espresso {
cost() {
return super.cost() + 0.4;
}
description() {
return `${super.description()}, Milch`;
}
}
class EspressoWithMilkAndSyrup extends EspressoWithMilk {
cost() {
return super.cost() + 0.6;
}
description() {
return `${super.description()}, Sirup`;
}
}
let drink = new Espresso();
console.log(`${drink.description()}: ${drink.cost().toFixed(2)} €`);
drink = new EspressoWithMilk();
console.log(`${drink.description()}: ${drink.cost().toFixed(2)} €`);
drink = new EspressoWithMilkAndSyrup();
console.log(`${drink.description()}: ${drink.cost().toFixed(2)} €`);
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: die Methoden cost() und description(). Der Espresso hat sie und kennt seinen eigenen Preis. Auch Milk hat sie, hält aber in einem Feld ein anderes Getränk. Wird es nach dem Preis gefragt, fragt es das innere Getränk und addiert 0,40 €. Ein Interface ist nicht nötig: Jedes Objekt mit diesen beiden Methoden lässt sich umhüllen.

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

  • die gemeinsame Form: die Methoden cost() und description();
  • 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

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

class Espresso {
cost() {
return 2;
}
description() {
return "Espresso";
}
}
class Milk {
constructor(inner) {
this.inner = inner;
}
cost() {
return this.inner.cost() + 0.4;
}
description() {
return `${this.inner.description()}, Milch`;
}
}
class Syrup {
constructor(inner) {
this.inner = inner;
}
cost() {
return this.inner.cost() + 0.6;
}
description() {
return `${this.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.

let drink = new Espresso();
drink = new Milk(drink);
drink = new Syrup(drink);
drink = new Syrup(drink);
console.log(`${drink.description()}: ${drink.cost().toFixed(2)} €`);
Ausgabe
Espresso, Milch, Sirup, Sirup: 3.60 €

let drink = new Espresso();
drink = new Milk(drink);
drink = new Syrup(drink);
drink = new Syrup(drink);
console.log(`${drink.description()}: ${drink.cost().toFixed(2)} €`);
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.
  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 this.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 {
constructor(inner) {
this.inner = inner;
}
cost() {
return this.inner.cost() + 0.8;
}
description() {
return `${this.inner.description()}, Extra-Shot`;
}
}
class Discount {
constructor(inner) {
this.inner = inner;
}
cost() {
return this.inner.cost() * 0.9;
}
description() {
return `${this.inner.description()}, 10 % Rabatt`;
}
}
const order = new Discount(new ExtraShot(new Milk(new Espresso())));
console.log(`${order.description()}: ${order.cost().toFixed(2)} €`);
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.

Hier prüft niemand, was umhüllt wird. Eine Hülle um ein Stück Text wird ohne Fehler erzeugt, und das Programm bricht erst ab, wenn jemand nach dem Preis fragt:

const drink = new Milk("Espresso");
console.log(drink.cost());
Fehler
TypeError: this.inner.cost is not a function

Der Fehler zeigt auf die Zeile in Milk, nicht auf die Zeile, in der die falsche Bestellung zusammengesetzt wurde. Darum wird jede neue Hülle mindestens einmal mit einem echten Getränk ausgeführt.

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.

In JavaScript ist eine Funktion ein Wert, also ist die Hülle eine Funktion, die eine Funktion nimmt und eine neue Funktion zurückgibt. logged(fn) gibt eine Pfeilfunktion zurück, die das Log schreibt, fn mit denselben Argumenten (...args) aufruft und ihr Ergebnis zurückgibt:

function logged(fn) {
return (...args) => {
console.log(`[Log] ${fn.name}(${args.join(", ")})`);
const result = fn(...args);
console.log(`[Log] ${fn.name} lieferte ${result}`);
return result;
};
}
let next = 1001;
const place = logged(function place(drink, quantity) {
console.log(`Zubereitung: ${quantity} × ${drink}`);
return next++;
});

Das ist dasselbe Muster wie die Milch um den Espresso, nur dass die Schicht um eine Funktion liegt.

place("Cappuccino", 2);
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 place aufruft, weiß nicht, dass es umhüllt ist. Du entfernst die Logs, indem du die Hülle entfernst. Dasselbe gilt für Zeitmessung, einen Cache oder einen neuen Versuch nach einem Fehler: Jedes davon ist eine eigene Schicht.

Genauer, für Neugierige

Für Klassen gibt es einen Vorschlag für eine @decorator-Syntax, die TypeScript schon unterstützt. Node 22 hat sie noch nicht, darum ist die Hülle hier ein einfacher Funktionsaufruf. Auch Middleware in Express ist eine Hülle: Jede bekommt die Anfrage, tut ihren Teil und ruft die nächste auf.

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?

const a = new Discount(new Syrup(new Espresso()));
const b = new Syrup(new Discount(new Espresso()));
console.log(`${a.cost().toFixed(2)} €`);
console.log(`${b.cost().toFixed(2)} €`);
Antwort

Probier es aus

Finde den Fehler

Jemand schreibt den Sirup um:

class Syrup {
constructor(inner) {
this.inner = inner;
}
cost() {
return 0.6;
}
description() {
return `${this.inner.description()}, Sirup`;
}
}
const drink = new Syrup(new Milk(new Espresso()));
console.log(`${drink.description()}: ${drink.cost().toFixed(2)} €`);
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;
  • du ein Objekt mit vielen Methoden umhüllst und eine änderst. Jede Hülle muss auch die übrigen weiterreichen, sonst fehlen sie.

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 timed(fn), das die Zeit mit performance.now() misst und ausgibt, wie viele Millisekunden der Aufruf 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. place wird mit @logged umhüllt (in JavaScript: logged(function place…)). Was ändert sich im Rumpf von place, 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 ·