Zum Inhalt springen

3. Entwurfsmuster I - Erzeugung

Zu Zen-Modus wechseln

Ein Entwurfsmuster ist keine Bibliothek und kein Codebaustein zum Kopieren. Es ist eine benannte, bewährte Lösung für ein wiederkehrendes Entwurfsproblem - eine Beschreibung, wie man ein Problem sinnvoll strukturiert, unabhängig von Programmiersprache und Projekt. Der Nutzen liegt zu gleichen Teilen in der Lösung und in ihrem Namen.

Der praktische Wert von Mustern zeigt sich in Gesprächen. “Wir brauchen an dieser Stelle eine Factory, die je nach Medientyp den passenden Verarbeiter liefert” - dieser eine Satz ersetzt eine halbe Stunde Erklärung, wenn beide Seiten das Muster kennen. Muster sind Fachvokabeln unter Entwicklern; wer sie beherrscht, kann Entwürfe schnell und präzise besprechen. Genau deshalb sind sie prüfungsrelevant: In einem Fachgespräch ist “das habe ich als Strategy gelöst” eine dichtere Aussage als drei Minuten Code-Vorlesen.

Historisch gehen die klassischen Muster auf ein Buch von vier Autoren zurück, die als Gang of Four bekannt wurden. Deren Einteilung nach Zweck ist bis heute die übliche Landkarte:

KategorieFrageBeispiele in diesem und den nächsten Kapiteln
ErzeugungWie werden Objekte erzeugt?Singleton, Factory
VerhaltenWie arbeiten Objekte zusammen?Observer, Strategy, Command
StrukturWie werden Objekte zusammengesetzt?Adapter, Decorator

Dieses Kapitel behandelt die Erzeugungsmuster. Ihr gemeinsames Thema: Die Entscheidung, welches Objekt entsteht, wird von der Stelle getrennt, die das Objekt benutzt.

Bevor wir einzelne Muster ansehen, lohnt der Blick auf die Grundsätze, denen sie dienen. SOLID ist ein Merkwort für fünf Prinzipien objektorientierten Entwurfs; Robert C. Martin fasste sie unter diesem Akronym zusammen. Es sind keine Ja/Nein-Regeln, sondern Leitlinien, die Code änderbar und testbar halten:

BuchstabePrinzipKerngedanke
SSingle ResponsibilityEine Klasse hat genau einen Grund, sich zu ändern - eine Zuständigkeit, nicht drei.
OOpen-ClosedOffen für Erweiterung, geschlossen für Änderung: Neues kommt hinzu, Bestehendes bleibt unangetastet.
LLiskov SubstitutionEin Untertyp muss überall einsetzbar sein, wo sein Obertyp erwartet wird, ohne das Programm zu überraschen.
IInterface SegregationViele kleine, gezielte Schnittstellen sind besser als eine große, die ihre Nutzer zu ungenutzten Methoden zwingt.
DDependency InversionCode hängt von Abstraktionen ab, nicht von konkreten Klassen; die konkrete Wahl wird hereingereicht.

Zwei dieser Prinzipien sind Ihnen bereits begegnet, nur ohne das Merkwort: das O bei Vererbung und Interfaces (neue Formen ohne Änderung am Bestehenden) und das L als Ersetzbarkeitsregel der Vererbung. Neu sind vor allem S, I und D.

Der Zusammenhang mit diesem Kapitel ist direkt: Entwurfsmuster sind großteils Bauanleitungen, die ein oder mehrere SOLID-Prinzipien umsetzen. Die Factory weiter unten stützt das O (ein neuer Typ ist eine neue Zeile, keine Änderung am Aufrufer), und die Kritik am Singleton mündet ins D (die Abhängigkeit hereinreichen, statt sie sich global zu besorgen). SOLID ist die Begründung, das Muster die Form - wer die Prinzipien kennt, kann beurteilen, ob ein Muster ein Problem wirklich löst oder nur verschiebt.

Problem: Von einer Sache soll es im ganzen Programm genau eine geben - eine Konfiguration, eine Verbindung zu einem externen Dienst, eine zentrale Warteschlange. Mehrere Instanzen wären falsch oder verschwenderisch.

Lösung: Eine Klasse kontrolliert selbst, dass es nur eine Instanz gibt, und stellt einen Zugriffspunkt darauf bereit.

In JavaScript und TypeScript ist die einfachste Form ein Modul mit einer gekapselten Variablen - Module werden nur einmal ausgewertet, das erledigt die Einmaligkeit von selbst:

interface AppConfig {
mediaRoot: string;
maxUploadMb: number;
}
let config: AppConfig | null = null;
export function loadConfig(): AppConfig {
if (config === null) {
console.log("reading configuration ..."); // runs exactly once
config = { mediaRoot: "/srv/media", maxUploadMb: 512 };
}
return config;
}

Die klassische, sprachübergreifende Form mit einer Klasse sieht so aus (und ist die, die in Prüfungen gezeichnet wird):

class RenderQueue {
private static instance: RenderQueue | null = null;
private jobs: string[] = [];
private constructor() {} // no "new" from outside
static getInstance(): RenderQueue {
if (RenderQueue.instance === null) {
RenderQueue.instance = new RenderQueue();
}
return RenderQueue.instance;
}
add(job: string): void { this.jobs.push(job); }
size(): number { return this.jobs.length; }
}

Zwei Bausteine tragen das Muster: der private Konstruktor (verhindert new RenderQueue() von außen) und die statische Zugriffsmethode, die die eine Instanz erzeugt und wiedergibt.

classDiagram
  class RenderQueue {
    -static instance: RenderQueue
    -jobs: string[]
    -constructor()
    +static getInstance() RenderQueue
    +add(job) void
    +size() number
  }
  RenderQueue --> RenderQueue : haelt eine Instanz

Singleton ist das umstrittenste der klassischen Muster, und die Kritik gehört zum Wissen dazu:

  • Ein Singleton ist globaler Zustand mit schönem Namen. Jeder Code, der getInstance() aufruft, hängt heimlich an dieser einen Instanz, ohne dass es in seiner Signatur steht.
  • Das rächt sich im Test: Der Zustand des Singletons überlebt von einem Testfall in den nächsten. Test A legt zwei Jobs in die Queue, Test B erwartet eine leere Queue und schlägt fehl - abhängig von der Reihenfolge, in der die Tests laufen. Solche Fehler sind zäh zu finden.

Ein Ausweg für Tests ist eine Reset-Methode, aber schon ihre Notwendigkeit ist ein Warnzeichen:

static resetForTests(): void {
RenderQueue.instance = null;
}

Die bessere Antwort ist oft, das Singleton gar nicht zu holen, sondern die Abhängigkeit hereinzureichen (Dependency Injection - die praktische Form des D aus SOLID): Die Warteschlange wird als Parameter übergeben, statt sich über getInstance() selbst besorgt zu werden. Dann ist im Test eine frische Instanz übergebbar, und der globale Zustand verschwindet. Wann ein Singleton bequem ist und was er im Test kostet, ist genau die Abwägung, die Sie treffen können sollen.

Problem: An einer Stelle muss ein Objekt erzeugt werden, aber welche konkrete Klasse es sein soll, hängt von einer Eingabe ab - dem Medientyp, dem Ausgabeformat, der Konfiguration. Schreibt man das direkt hin, entsteht überall dieselbe Fallunterscheidung.

Der Ausgangspunkt ist eine switch-Anweisung, die sich durch den Code zieht:

// the problem: this switch appears wherever a thumbnail is needed
function createThumbnail(kind: string, source: string): string {
switch (kind) {
case "image": return `${source}: scaled still image`;
case "video": return `${source}: frame at second 1, then scaled`;
case "audio": return `${source}: waveform image`;
default: throw new Error(`unknown kind ${kind}`);
}
}

Kommt ein vierter Medientyp dazu, muss jede solche Stelle gefunden und geändert werden. Das verstößt gegen das Open-Closed-Prinzip (das O aus SOLID): offen für Erweiterung, geschlossen für Änderung.

Die Lösung: Ein gemeinsames Interface, eine Implementierung je Variante, und eine Fabrik, die anhand der Eingabe die passende liefert:

type MediaKind = "image" | "video" | "audio";
interface Thumbnailer {
readonly kind: MediaKind;
create(source: string): string;
}
class ImageThumbnailer implements Thumbnailer {
readonly kind = "image" as const;
create(source: string): string { return `${source}: scaled still image`; }
}
class VideoThumbnailer implements Thumbnailer {
readonly kind = "video" as const;
create(source: string): string { return `${source}: frame at second 1, then scaled`; }
}
// ... AudioThumbnailer analog

Die Fabrik selbst lässt sich als switch schreiben - oder, meist besser, als Registry: eine Tabelle, in die sich Varianten eintragen. Dann ist das Hinzufügen eines Typs genau eine neue Zeile, ohne Änderung am Fabrik-Code:

class UnknownMediaKindError extends Error {
constructor(kind: string) {
super(`no thumbnailer registered for media kind "${kind}"`);
this.name = "UnknownMediaKindError";
}
}
const thumbnailers = new Map<MediaKind, () => Thumbnailer>([
["image", () => new ImageThumbnailer()],
["video", () => new VideoThumbnailer()],
["audio", () => new AudioThumbnailer()],
]);
function createThumbnailer(kind: string): Thumbnailer {
const factory = thumbnailers.get(kind as MediaKind);
if (!factory) throw new UnknownMediaKindError(kind);
return factory();
}

Der entscheidende Gewinn zeigt sich am Aufrufer: Er kennt keine einzige konkrete Klasse mehr und ändert sich nie wieder, egal wie viele Medientypen dazukommen:

function buildThumbnails(sources: { kind: string; path: string }[]): string[] {
return sources.map((source) => createThumbnailer(source.kind).create(source.path));
}
classDiagram
  class Thumbnailer {
    <<interface>>
    +create(source) string
  }
  class ImageThumbnailer
  class VideoThumbnailer
  class AudioThumbnailer
  class Factory {
    +createThumbnailer(kind) Thumbnailer
  }
  Thumbnailer <|.. ImageThumbnailer
  Thumbnailer <|.. VideoThumbnailer
  Thumbnailer <|.. AudioThumbnailer
  Factory ..> Thumbnailer : liefert

Beide Muster stecken in Software, die Sie täglich benutzen, meist ohne dass es dransteht:

  • Singleton-artig: die zentrale Konfiguration eines Frameworks, ein Datenbank-Verbindungspool, ein Logger - jeweils eine Instanz, überall verfügbar.
  • Factory-artig: überall dort, wo ein Aufruf ein Objekt “passend zur Eingabe” zurückgibt, ohne dass der Aufrufer die konkrete Klasse nennt - etwa Funktionen, die je nach Dateiendung den passenden Parser liefern, oder je nach angefragtem Format den passenden Serialisierer.

Die Übung, Muster in fremdem Code aufzuspüren, ist mehr als ein Spiel: Sie ist die Vorstufe dazu, sich in einer unbekannten Codebasis zurechtzufinden. Wer die Muster erkennt, versteht die Struktur schneller als jemand, der Zeile für Zeile liest.

Lernergebnisse: Was Sie nach diesem Kapitel können sollten

Abschnitt betitelt „Lernergebnisse: Was Sie nach diesem Kapitel können sollten“

Nach Abschluss dieses Kapitels sollten Schülerinnen und Schüler in der Lage sein:

  • Erklären: erklären, was ein Entwurfsmuster ist, und den Nutzen von Mustern als gemeinsames Vokabular begründen.
  • Nennen: die fünf SOLID-Prinzipien nennen und ihren Kerngedanken in einem Satz wiedergeben.
  • Analysieren: an einem Muster zeigen, welches SOLID-Prinzip es umsetzt (Factory zu Open-Closed, Dependency Injection zu Dependency Inversion).
  • Nennen: die drei Musterkategorien (Erzeugung, Verhalten, Struktur) nennen und Beispiele zuordnen.
  • Anwenden: Singleton und Factory in TypeScript implementieren, mit privatem Konstruktor beziehungsweise Interface und Registry.
  • Beurteilen: die Kritik am Singleton (globaler Zustand, Testabhängigkeit) erklären und Dependency Injection als Alternative benennen.
  • Analysieren: begründen, warum eine Factory das Open-Closed-Prinzip stützt und den Aufrufer von konkreten Klassen entkoppelt.
  • Analysieren: Singleton und Factory in bestehendem Code und in Frameworks wiedererkennen.
  • Aufgabe 04 - Mustererkennung
  • Aufgabe 05 - Factory im Einsatz