Zum Inhalt springen

17. Entwurfsmuster II – Verhalten

Zu Zen-Modus wechseln

Nach diesem Kapitel kannst du:

  • Observer, Strategy und Command mit ihrem jeweiligen Problem und einem Diagramm erklären.
  • die drei Muster in TypeScript implementieren, inklusive An- und Abmeldung beim Observer und undo beim Command.
  • die drei Muster an ihrer Kernfrage unterscheiden und in fremdem Code wiedererkennen.
  • für ein gegebenes Verhaltensproblem das passende Muster auswählen und die Wahl begründen.
  • ein Verhaltensmuster im eigenen Projekt bewusst einsetzen und die Entscheidung dokumentieren.

Im Fußballteam aus den Kapiteln 11 bis 16 ging es bisher um Rollen und Aufstellung: Der Schiedsrichter war der Singleton, die Auswechselbank die Factory. Jetzt geht es um das Zusammenspiel während des Spiels. Fällt ein Tor, reagieren Anzeigetafel, Stadionsprecher und Statistik, ohne dass der Torschütze einen von ihnen kennt. Zur Halbzeit stellt der Trainer von Pressing auf Konter um: dieselbe Mannschaft, ein anderes Verfahren. Und der Videobeweis behandelt eine Entscheidung als festgehaltenes Ding, das man prüfen und zurücknehmen kann.

flowchart LR
  accTitle: Drei Situationen im Fußballspiel als Bilder für Observer, Strategy und Command
  G["Tor fällt"] -->|benachrichtigt| A["Anzeigetafel, Stadionsprecher, Statistik"]
  H["Trainer zur Halbzeit"] -->|tauscht| T["Taktik: Pressing oder Konter"]
  V["Videobeweis"] -->|nimmt zurück| E["Entscheidung: Tor"]
  A -.->|im Code| O["Observer"]
  T -.->|im Code| S["Strategy"]
  E -.->|im Code| C["Command"]

Verhaltensmuster ordnen genau diese Zusammenarbeit von Objekten: wer wen benachrichtigt, wer welches Verhalten austauscht, wie eine Aktion zu einem Ding wird, das man aufheben und rückgängig machen kann. Alle drei Muster dieses Kapitels lösen ein wiederkehrendes Kopplungsproblem: Sie trennen, was passiert, von wer es auslöst oder wann es passiert.

Jedes Muster folgt hier demselben Aufbau: das Problem, die Lösung als Diagramm, eine lauffähige TypeScript-Implementierung und der Ort, an dem dir das Muster ohnehin schon begegnet ist.

  • Verhaltensmuster (behavioral pattern): Muster, das regelt, wie Objekte zusammenarbeiten und Aufgaben untereinander verteilen.
  • Subject (auch Publisher): das beobachtete Objekt, das bei einer Änderung seine Interessenten benachrichtigt.
  • Observer (auch Subscriber, Listener): ein Interessent, der sich beim Subject an- und wieder abmeldet und bei jedem Ereignis aufgerufen wird.
  • Strategie: ein austauschbares Verfahren hinter einem gemeinsamen Interface, das zur Laufzeit gewechselt werden kann.
  • Kommando (Command): eine Aktion als Objekt mit execute() und undo().
  • Historie: Stapel der ausgeführten Kommandos, aus dem undo() das jeweils letzte zurücknimmt.
  • Memory Leak: Speicher, der nicht freigegeben wird, weil noch eine Referenz auf ein nicht mehr gebrauchtes Objekt besteht.

Problem: Ein Objekt ändert seinen Zustand, und mehrere andere müssen darauf reagieren - aber das erste soll nicht wissen müssen, wer alles zuhört. Ein Upload ist fertig; daraufhin sollen ein Vorschaubild erzeugt, ein Log-Eintrag geschrieben und die Oberfläche aktualisiert werden. Würde die Upload-Funktion diese drei direkt aufrufen, kennt sie sie alle - und muss geändert werden, sobald ein vierter Interessent dazukommt. Im Spiel ist das das Tor: Der Torschütze ruft nicht einzeln die Anzeigetafel, den Stadionsprecher und die Statistik an.

Lösung: Das beobachtete Objekt (Subject, Publisher) führt eine Liste von Interessenten (Observer, Subscriber) und benachrichtigt sie, ohne sie einzeln zu kennen. Interessenten melden sich an und wieder ab.

classDiagram
  class EventEmitter~T~ {
    -listeners: Listener[]
    +subscribe(listener) unsubscribe
    +emit(event) void
  }
  class Subscriber {
    <<function>>
    +(event) void
  }
  EventEmitter~T~ o--> Subscriber : benachrichtigt

Eine typsichere, allgemein verwendbare Implementierung:

type Listener<T> = (event: T) => void;
class EventEmitter<T> {
private listeners: Listener<T>[] = [];
subscribe(listener: Listener<T>): () => void {
this.listeners.push(listener);
return () => { // unsubscribe function
this.listeners = this.listeners.filter((l) => l !== listener);
};
}
emit(event: T): void {
for (const listener of [...this.listeners]) listener(event);
}
}

Zwei Details sind bewusst gesetzt. subscribe gibt eine Abmeldefunktion zurück - das ist bequemer und weniger fehlerträchtig, als sich den Listener zum späteren Entfernen zu merken. Und emit iteriert über eine Kopie der Liste ([...this.listeners]), damit ein Listener, der sich während der Benachrichtigung abmeldet, die laufende Schleife nicht durcheinanderbringt.

Genauer erklärt: Warum die Abmeldung überhaupt so wichtig ist, zeigt sich erst im Betrieb: Ein EventEmitter, der nie vergessene Listener loswird, hält über subscribe eine Referenz auf jeden Abonnenten - auch auf Objekte, die längst nicht mehr gebraucht werden. Das nennt man einen Memory Leak: Der Speicher wird nicht frei, weil noch jemand “zuhört”. Deshalb gehört zu jedem subscribe im echten Code ein Gegenstück, das auch tatsächlich aufgerufen wird - in Oberflächen-Frameworks meist automatisch beim Entfernen einer Komponente, hier von Hand über die zurückgegebene Funktion.

Im Gebrauch:

interface UploadDone { filename: string; sizeMb: number; }
const uploads = new EventEmitter<UploadDone>();
const stopLogging = uploads.subscribe((e) => console.log(`log: ${e.filename}`));
uploads.subscribe((e) => console.log(`thumbnail for: ${e.filename}`));
uploads.emit({ filename: "intro.mp4", sizeMb: 12 }); // both react
stopLogging();
uploads.emit({ filename: "outro.mp4", sizeMb: 8 }); // only the thumbnail reacts

Beispielprojekt: 17_entwurfsmuster_verhalten_examples/ex01_observer_event_emitter

Auflösung

An beiden änderst du nichts: Der Virenscanner ist ein weiterer subscribe-Aufruf an der Stelle, an der Beobachter angemeldet werden. Die entfernte Komponente ohne Abmeldung bleibt dagegen über ihren Listener in der Liste des Emitters referenziert. Sie wird weiter benachrichtigt und ihr Speicher nicht frei, das ist der Memory Leak.

Problem: Für dieselbe Aufgabe gibt es mehrere austauschbare Verfahren, und die Wahl soll zur Laufzeit möglich sein. Eine Medienliste lässt sich nach Name, Größe oder Datum sortieren; ein Preis wird je nach Kundengruppe anders berechnet. Im Spiel ist das die Taktik: Der Trainer stellt zur Halbzeit von Pressing auf Konter um, ohne die Mannschaft auszutauschen. Der naheliegende erste Versuch sieht so aus:

// the problem: one method that grows with every new sort order
function ordered(files: MediaFile[], by: string): string[] {
switch (by) {
case "name": return [...files].sort((a, b) => a.name.localeCompare(b.name)).map((f) => f.name);
case "size": return [...files].sort((a, b) => b.sizeMb - a.sizeMb).map((f) => f.name);
// a third sort order means: open this function again and add a case
default: throw new Error(`unknown order ${by}`);
}
}

Beispielprojekt: 17_entwurfsmuster_verhalten_examples/ex02_strategy_switch_problem

Das funktioniert, aber ordered kennt jedes Verfahren persönlich. Eine dritte Sortierung heißt: diese eine Funktion wieder öffnen und ändern - derselbe Verstoß gegen das Open-Closed-Prinzip wie bei der Factory in Kapitel 16, nur jetzt bei Verhalten statt bei Erzeugung.

Lösung: Jedes Verfahren wird ein eigenes Objekt hinter einem gemeinsamen Interface. Das nutzende Objekt hält eine Strategie und ruft sie auf, ohne ihr Innenleben zu kennen; die Strategie ist jederzeit austauschbar.

classDiagram
  class SortStrategy {
    <<interface>>
    +sort(files) MediaFile[]
  }
  class ByName
  class BySize
  class MediaGallery {
    -strategy: SortStrategy
    +setStrategy(s) void
    +ordered() string[]
  }
  SortStrategy <|.. ByName
  SortStrategy <|.. BySize
  MediaGallery o--> SortStrategy : verwendet
interface MediaFile { name: string; sizeMb: number; createdAt: string; }
interface SortStrategy {
readonly name: string;
sort(files: MediaFile[]): MediaFile[];
}
const byName: SortStrategy = {
name: "name",
sort: (files) => [...files].sort((a, b) => a.name.localeCompare(b.name)),
};
const bySize: SortStrategy = {
name: "size",
sort: (files) => [...files].sort((a, b) => b.sizeMb - a.sizeMb),
};
class MediaGallery {
constructor(private files: MediaFile[], private strategy: SortStrategy) {}
setStrategy(strategy: SortStrategy): void {
this.strategy = strategy; // swap behavior at runtime
}
ordered(): string[] {
return this.strategy.sort(this.files).map((file) => file.name);
}
}
const gallery = new MediaGallery(files, byName);
gallery.ordered(); // sorted by name
gallery.setStrategy(bySize);
gallery.ordered(); // now sorted by size - no change to MediaGallery

Beispielprojekt: 17_entwurfsmuster_verhalten_examples/ex03_strategy_media_gallery

Eine neue Sortierung (etwa nach Datum) ist ein neues Objekt, das dem Interface genügt - kein Eingriff in MediaGallery. Das ist derselbe Open-Closed-Gewinn wie bei der Factory, hier auf Verhalten statt auf Erzeugung angewendet.

Genauer erklärt: Der Gewinn hängt daran, dass MediaGallery die Strategie wirklich nur aufruft. Fragt sie dagegen den Namen ab, etwa if (this.strategy.name === "size"), kennt sie das Verfahren wieder persönlich, und jede neue Strategie führt erneut in diese Klasse. Das Feld name ist deshalb nur zur Anzeige da, zum Beispiel für die Beschriftung einer Auswahlliste, nie für Entscheidungen.

Problem: Eine Aktion soll nicht sofort und direkt ausgeführt, sondern als Objekt behandelt werden - um sie aufzuheben, in eine Warteschlange zu legen, zu protokollieren oder rückgängig zu machen. Das klassische Beispiel ist Undo/Redo: Dafür muss jede Aktion wissen, wie sie sich selbst umkehrt. Im Spiel ist das der Videobeweis: Das Tor ist eine festgehaltene Entscheidung, die geprüft und zurückgenommen werden kann.

Lösung: Jede Aktion wird ein Objekt mit execute() und undo(). Eine Historie führt die ausgeführten Kommandos und kann sie in umgekehrter Reihenfolge zurücknehmen.

classDiagram
  class Command {
    <<interface>>
    +execute() void
    +undo() void
  }
  class AppendCommand
  class CommandHistory {
    -done: Command[]
    +run(command) void
    +undo() void
  }
  Command <|.. AppendCommand
  CommandHistory o--> Command : verwaltet
interface Command {
execute(): void;
undo(): void;
}
class TextDocument {
private text = "";
append(part: string): void { this.text += part; }
removeLast(length: number): void { this.text = this.text.slice(0, -length); }
get content(): string { return this.text; }
}
class AppendCommand implements Command {
constructor(private doc: TextDocument, private part: string) {}
execute(): void { this.doc.append(this.part); }
undo(): void { this.doc.removeLast(this.part.length); } // knows how to reverse itself
}
class CommandHistory {
private done: Command[] = [];
run(command: Command): void {
command.execute();
this.done.push(command);
}
undo(): void {
const command = this.done.pop();
if (command) command.undo();
}
}
const doc = new TextDocument();
const history = new CommandHistory();
history.run(new AppendCommand(doc, "Hello"));
history.run(new AppendCommand(doc, ", world"));
doc.content; // "Hello, world"
history.undo();
doc.content; // "Hello"

Beispielprojekt: 17_entwurfsmuster_verhalten_examples/ex04_command_undo

Der Kern ist, dass jedes Kommando sein eigenes Rückgängigmachen kennt. Die Historie muss über die Aktionen nichts wissen; sie ruft nur undo() in umgekehrter Reihenfolge auf.

Genauer erklärt: Redo ergänzt man, indem zurückgenommene Kommandos auf einem zweiten Stapel landen: undo() verschiebt das oberste Kommando vom done- auf den undone-Stapel, redo() den Weg zurück und führt execute() erneut aus. Genau deshalb ist es wichtig, dass ein Kommando seine Parameter (hier part) beim Erzeugen festhält und nicht nachträglich verändert - sonst wüsste undo() gar nicht mehr, was es eigentlich rückgängig macht.

flowchart LR
  accTitle: Undo verschiebt das letzte Kommando auf den Redo-Stapel, Redo führt es erneut aus
  R["run: execute()"] --> D["done-Stapel"]
  D -->|"undo: undo()"| U["undone-Stapel"]
  U -->|"redo: execute()"| D
Auflösung

(a) Neu ist nur ein Objekt byDate, das SortStrategy erfüllt. MediaGallery und ihre Aufrufer bleiben unverändert. (b) undo() entfernt so viele Zeichen, wie der Text lang ist. Hat sich das Eingabefeld seit dem Ausführen geändert, entfernt undo() die falsche Anzahl an Zeichen. Ein Kommando muss festhalten, was es getan hat, damit es sich selbst umkehren kann.

Die drei Muster lassen sich an ihrer Kernfrage auseinanderhalten - genau diese Zuordnung braucht das Fachgespräch:

MusterKernfrageErkennungszeichen
ObserverWie reagieren mehrere auf ein Ereignis, ohne dass der Auslöser sie kennt?anmelden / benachrichtigen, “wenn X passiert, dann …”
StrategyWie tausche ich ein Verfahren gegen ein gleichwertiges aus?mehrere Wege zum selben Ziel, zur Laufzeit wählbar
CommandWie mache ich aus einer Aktion ein Objekt?rückgängig, Warteschlange, Protokoll, später ausführen

Im Klassendiagramm sehen Strategy und Command fast gleich aus: ein Interface, mehrere Implementierungen, ein Objekt, das sie hält. Die Struktur unterscheidet sie also nicht, nur der Zweck. Deshalb führt der Weg zum passenden Muster immer über die Frage, welches Problem vorliegt:

flowchart TD
  accTitle: Vom Verhaltensproblem über drei Fragen zum passenden Muster
  P["Verhaltensproblem"] --> Q1{"Sollen mehrere auf ein Ereignis reagieren?"}
  Q1 -->|ja| O["Observer"]
  Q1 -->|nein| Q2{"Gibt es mehrere gleichwertige Verfahren?"}
  Q2 -->|ja| S["Strategy"]
  Q2 -->|nein| Q3{"Soll eine Aktion aufbewahrt, rückgängig gemacht oder später ausgeführt werden?"}
  Q3 -->|ja| C["Command"]
  Q3 -->|nein| E["kein Verhaltensmuster nötig"]

Mindestens ein Verhaltensmuster soll im Jahresprojekt bewusst eingesetzt und dokumentiert werden - nicht, weil ein Muster Pflicht ist, sondern weil die Begründung die Kompetenz zeigt. Die Dokumentation dazu ist kurz und beantwortet drei Fragen: Welches Problem lag vor? Warum dieses Muster? Was wäre die Alternative gewesen? Ein Satz je Frage genügt; entscheidend ist, dass die Wahl eine Entscheidung war und kein Zufall.

flowchart LR
  accTitle: Drei Fragen, je ein Satz, ergeben die Dokumentation eines bewusst eingesetzten Musters
  P["Welches Problem lag vor?"] --> W["Warum dieses Muster?"]
  W --> A["Was wäre die Alternative gewesen?"]
  A --> D["Dokumentation: drei Sätze"]
Auflösung

(a) Strategy: mehrere gleichwertige Verfahren zum selben Ziel, dem Preis. (b) Command: Jeder Pinselstrich wird ein Objekt mit undo(). (c) Observer: Mehrere reagieren auf ein Ereignis, und das Speichern kennt sie nicht.

  • Gamma, Helm, Johnson, Vlissides, Design Patterns (GoF, 1994) - die drei Muster im Original, Kapitel “Behavioral Patterns”. Die dort verwendeten Diagramme sind die historische Vorlage für die Mermaid-Diagramme in diesem Kapitel.
  • refactoring.guru: Behavioral Patterns - Übersichtsseite mit allen Verhaltensmustern, nicht nur den drei hier behandelten (u. a. auch State, das Strategy sehr ähnlich sieht, aber den aktuellen Zustand statt ein austauschbares Verfahren modelliert - ein guter Vergleich für alle, die die Abgrenzung vertiefen wollen).
  • refactoring.guru: State - lohnt sich direkt nach Strategy, weil die beiden Klassendiagramme fast gleich sind und die Seite den Unterschied im Zweck erklärt.
  • MDN: EventTarget - die Referenz zu addEventListener und removeEventListener; hier siehst du Observer so, wie der Browser ihn tatsächlich anbietet.
  • Die jeweiligen “Weiterlesen”-Hinweise bei Observer, Strategy und Command oben führen direkt zur passenden Detailseite.

Du kannst dir diesen Text mit der Vorlesefunktion deines Browsers oder Betriebssystems kostenlos anhören, etwa mit “Laut vorlesen” in Microsoft Edge. Das eignet sich gut zum Wiederholen vor der Schularbeit.

Lernergebnisse: Was du nach diesem Kapitel können solltest

Abschnitt betitelt „Lernergebnisse: Was du nach diesem Kapitel können solltest“
  • Erklären: Observer, Strategy und Command mit ihrem jeweiligen Problem und einem Diagramm erklären.
  • Anwenden: die drei Muster in TypeScript implementieren, inklusive An- und Abmeldung beim Observer und undo beim Command.
  • Analysieren: die drei Muster an ihrer Kernfrage unterscheiden und in fremdem Code wiedererkennen.
  • Beurteilen: für ein gegebenes Verhaltensproblem das passende Muster auswählen und die Wahl begründen.
  • Anwenden: ein Verhaltensmuster im eigenen Projekt bewusst einsetzen und die Entscheidung dokumentieren.
  • Aufgabe 11 - Jahresprojekt Sprint 3 - Verhaltensmuster: Kata: fünf Situationen mit dem Entscheidungsbaum zuordnen und einen typisierten EventEmitter bauen (Wesentlich_überwiegend); ein Verhaltensmuster für eine Story des Projekts bewusst einsetzen und mit den drei Fragen, Steckbrief und Diagramm dokumentieren (Wesentlich_vollständig). PlusPlus: typischen Fehler des Musters absichern, zweite Variante; MandalorianPlus: zweites Muster und Abgrenzung über den Zweck.

GAM

CC-BY-SA

https://qiz.at/rules