Command
Kurz gesagt: Command macht jede Aktion zu einem Objekt mit execute und undo, sodass Aktionen in einem Verlauf liegen und rückgängig gemacht und wiederholt werden können.
Problem
Ein Undo, das sich nur eine Sache merkt
Der kleine Notizeditor tippt Text und löscht vom Ende her. Undo funktioniert so: Der Editor merkt sich als Text, was die letzte Aktion war und welcher Text daran beteiligt war.
class Editor { constructor() { this.text = ""; this.lastAction = ""; this.lastText = ""; }
type(s) { this.text += s; this.lastAction = "type"; this.lastText = s; }
deleteLast(count) { this.lastText = this.text.slice(-count); this.text = this.text.slice(0, -count); this.lastAction = "delete"; }
undo() { if (this.lastAction === "type") { this.text = this.text.slice(0, -this.lastText.length); } else if (this.lastAction === "delete") { this.text += this.lastText; } this.lastAction = ""; }}const editor = new Editor();editor.type("Kaufe");editor.type(" Milch");editor.undo();console.log(editor.text);editor.undo();console.log(editor.text);KaufeKaufeDas erste Undo hat „ Milch“ entfernt. Das zweite hat nichts getan: „Kaufe“ blieb stehen, weil der Editor sich nur eine Aktion merkt und nach dem Undo auch die weg ist. Ein Redo gibt es gar nicht: Der Text, den Undo entfernt hat, ist verloren.
Jede neue Aktion (fett, ersetzen, einfügen) ist ein weiterer Zweig in undo() und weitere Felder im Editor. Das Wissen, wie man eine Aktion rückgängig macht, lebt weit weg von der Aktion selbst.
Die Idee
Command: die Aktion als Objekt
Statt „die letzte Aktion war tippen“ wird jede Aktion zu einem Objekt, das zwei Dinge weiß: wie man sie ausführt (execute()) und wie man zurückgeht (undo()). Was sie zum Zurückgehen braucht, hält sie in ihren eigenen Feldern.
Der Verlauf hält die Commands auf zwei Stapeln:
- der Undo-Stapel: die Commands, die gelaufen sind. Undo nimmt die oberste, ruft
undo()auf und legt sie auf den Redo-Stapel; - der Redo-Stapel: die Commands, die rückgängig gemacht wurden. Redo nimmt die oberste, führt sie erneut aus und legt sie zurück auf den Undo-Stapel;
- eine neue Command leert den Redo-Stapel. Die rückgängig gemachten Commands sind auf einem anderen Text entstanden, und es gibt keinen Ort, an dem man sie wiederholen könnte.
Das ist das Muster Command. Es hat drei Rollen: die Command (die Aktion als Objekt), der Empfänger (das Dokument, auf dem sie arbeitet) und der Verlauf, der die Commands aufruft, ohne zu wissen, was sie tun.
Sieh es dir an
Zwei Stapel
Das Dokument kann nur anhängen und vom Ende her löschen. removeLast gibt den gelöschten Text zurück:
class Document { constructor() { this.text = ""; }
append(s) { this.text += s; }
removeLast(count) { const removed = this.text.slice(-count); this.text = this.text.slice(0, -count); return removed; }}Eine Command zum Tippen. Sie hält das Dokument und seinen Text, also weiß sie, wie viel sie beim Undo löschen muss:
class TypeText { constructor(doc, text) { this.doc = doc; this.text = text; }
execute() { this.doc.append(this.text); }
undo() { this.doc.removeLast(this.text.length); }}Der Verlauf weiß nicht, was die Commands sind. Er ruft sie nur auf und schiebt sie zwischen den Stapeln hin und her:
class History { constructor() { this.undoStack = []; this.redoStack = []; }
run(command) { command.execute(); this.undoStack.push(command); this.redoStack.length = 0; }
undo() { if (this.undoStack.length === 0) { return; } const command = this.undoStack.pop(); command.undo(); this.redoStack.push(command); }
redo() { if (this.redoStack.length === 0) { return; } const command = this.redoStack.pop(); command.execute(); this.undoStack.push(command); }}const doc = new Document();const history = new History();history.run(new TypeText(doc, "Kaufe"));history.run(new TypeText(doc, " Milch"));history.run(new TypeText(doc, " und Brot"));history.undo();history.undo();history.redo();history.run(new TypeText(doc, " und Eier"));console.log(doc.text);Kaufe Milch und Eierconst doc = new Document();const history = new History();history.run(new TypeText(doc, "Kaufe"));history.run(new TypeText(doc, " Milch"));history.run(new TypeText(doc, " und Brot"));history.undo();history.undo();history.redo();history.run(new TypeText(doc, " und Eier"));console.log(doc.text);Schritt für Schritt, in Worten
- run ruft execute() der Command auf: Das Dokument bekommt „Kaufe“. Dann kommt die Command auf den Undo-Stapel.
- Eine zweite Command, auf der ersten. Ganz oben im Stapel liegt immer die zuletzt ausgeführte.
- Eine dritte. Der Undo-Stapel merkt sich die Reihenfolge von allem, was passiert ist.
- undo nimmt die oberste Command und ruft ihr undo() auf: „ und Brot“ wird entfernt. Die Command wandert auf den Redo-Stapel.
- Noch ein undo: Auch „ Milch“ verschwindet. Du kannst so viele Schritte zurückgehen, wie es Commands gibt.
- redo nimmt die oberste vom Redo-Stapel und ruft execute() erneut auf: „ Milch“ ist zurück. Die Command geht wieder auf den Undo-Stapel.
- Eine neue Command. „ und Brot“ wurde auf einem anderen Text rückgängig gemacht, deshalb leert redoStack.length = 0 den Redo-Stapel.
- Das Dokument ist „Kaufe Milch und Eier“. Das gibt das Programm aus.
Undo über beliebig viele Schritte und Redo kommen aus zwei Stapeln und drei kurzen Methoden. Eine neue Art von Aktion ist eine neue Klasse, und der Verlauf bleibt unberührt.
Sieh es dir an
Eine Command, die sich etwas merkt
Löschen kann man nicht über die Anzahl der Buchstaben rückgängig machen: Man muss wissen, was gelöscht wurde. Die Command merkt es sich in execute():
class DeleteLast { constructor(doc, count) { this.doc = doc; this.count = count; this.removed = ""; }
execute() { this.removed = this.doc.removeLast(this.count); }
undo() { this.doc.append(this.removed); }}const doc = new Document();const history = new History();history.run(new TypeText(doc, "Kaufe Milch"));history.run(new DeleteLast(doc, 6));console.log(doc.text);history.undo();console.log(doc.text);history.redo();console.log(doc.text);KaufeKaufe MilchKaufeKeine einzige Zeile von History oder TypeText hat sich geändert.
Die Prüfung am Anfang von undo() ist keine Dekoration. Ohne sie stoppt ein Undo bei leerem Verlauf das Programm:
undo() { const command = this.undoStack.pop(); command.undo(); this.redoStack.push(command);}const history = new History();history.undo();TypeError: Cannot read properties of undefined (reading 'undo')pop() auf einem leeren Array wirft nichts, es gibt undefined zurück. Der Fehler zeigt sich in der nächsten Zeile, wenn undefined keine Methode undo hat. Menschen drücken Strg+Z öfter, als sie getippt haben, und das ist normal, also heißt ein leerer Stapel „nichts rückgängig zu machen“.
Sieh es dir an
Eine Command aus zwei Funktionen
History ruft command.execute() und command.undo() auf und merkt keinen Unterschied:function typeText(doc, text) { return { execute: () => doc.append(text), undo: () => doc.removeLast(text.length), };}const doc = new Document();const history = new History();history.run(typeText(doc, "Kaufe"));history.run(typeText(doc, " Milch"));history.undo();console.log(doc.text);history.redo();console.log(doc.text);KaufeKaufe MilchDie Lambdas schließen doc und text ein (merken sie sich), so wie es die Felder der Klasse getan haben. In JavaScript ist das der übliche Weg, wenn die Command kurz ist. In Java macht man dasselbe mit einer anonymen Klasse und in C# mit zwei Actions.
Eine Klasse lohnt sich, wenn die Command sich etwas merkt, das sich zwischen execute() und undo() ändert, wie den gelöschten Text. Funktionen können das auch, aber der Zustand versteckt sich im Closure und ist schwerer zu lesen.
Probier es aus
Sag die Ausgabe voraus
Eine Notiz über ein Treffen wird getippt, rückgängig gemacht und noch einmal getippt. Was gibt das Programm aus?
const doc = new Document();const history = new History();history.run(new TypeText(doc, "Treffen"));history.run(new TypeText(doc, " um 10:00"));history.undo();history.run(new TypeText(doc, " um 11:00"));history.redo();history.undo();console.log(doc.text);Probier es aus
Finde den Fehler
Jemand schreibt den Verlauf neu:
class History { constructor() { this.undoStack = []; this.redoStack = []; }
run(command) { command.execute(); this.undoStack.push(command); }
undo() { if (this.undoStack.length === 0) { return; } const command = this.undoStack.pop(); command.undo(); this.redoStack.push(command); }
redo() { if (this.redoStack.length === 0) { return; } const command = this.redoStack.pop(); command.execute(); this.undoStack.push(command); }}const doc = new Document();const history = new History();history.run(new TypeText(doc, "Kaufe"));history.run(new TypeText(doc, " Milch"));history.undo();history.run(new TypeText(doc, " Brot"));history.redo();console.log(doc.text);Kaufe Brot MilchRedo hat „ Milch“ in eine Notiz gesetzt, in der „ Milch“ rückgängig gemacht wurde, bevor „ Brot“ getippt wurde.
Wähle die Zeile, nach der noch eine weitere hätte folgen müssen.
run legt die neue Command auf den Undo-Stapel, leert aber den Redo-Stapel nicht. „ Milch“ bleibt dort, und Redo wiederholt sie auf einem Text, zu dem sie nicht mehr passt. Bei komplizierteren Commands ist es noch schlimmer: Ein Löschen, auf einem anderen Text wiederholt, löscht die falschen Buchstaben. Nach dieser Zeile muss this.redoStack.length = 0; stehen. Dann tut Redo nach einer neuen Command nichts, und die Notiz lautet „Kaufe Brot“.
Die Idee
Wann du es nicht brauchst
Command hat einen Preis: eine Klasse (oder zwei Funktionen) für jede Aktion, statt die Methode direkt aufzurufen. Es lohnt sich nicht, wenn:
- die Aktion sofort aufgerufen wird, es kein Undo gibt, sie nicht in eine Warteschlange kommt und nicht aufgezeichnet wird. Dann ist die Command eine Hülle um einen Aufruf;
- der Zustand klein ist. Ist das Dokument ein kurzer Text, kann der Verlauf Kopien des Textes vor jeder Aktion halten, und Undo heißt „zurück zur vorigen Kopie“. Das ist einfacher und kann die umgekehrte Aktion nicht falsch machen;
- Undo nur für einen Schritt an einer Stelle gebraucht wird.
Es lohnt sich, wenn Aktionen über viele Schritte rückgängig gemacht, wiederholt, in einer Warteschlange gehalten (zu sendende Jobs), aufgezeichnet und wieder abgespielt (ein Makro) oder später ausgeführt werden sollen.
Übung
Großbuchstaben
Löse die Aufgabe in deinem Editor.
Großbuchstaben. Füge eine Command Upper hinzu, die den ganzen Text in Großbuchstaben setzt und deren Undo den Text so zurückbringt, wie er war. Document und History bleiben unberührt. Tippe „Milch kaufen“, führe Upper aus, mache dann Undo und Redo und gib den Text nach jedem der drei Schritte aus:
MILCH KAUFENMilch kaufenMILCH KAUFENWeitere Aufgaben
- Ein Makro. Eine Command, die eine Liste von Commands hält:
execute()führt sie der Reihe nach aus, undundo()macht sie in umgekehrter Reihenfolge rückgängig. Das ist ein Composite aus Commands. Warum ist die Reihenfolge beim Undo umgekehrt? - Nur 3 Schritte zurück. Der Verlauf merkt sich höchstens 3 Commands für Undo. Die älteste wird weggeworfen.
- Der Warenkorb. Commands „Produkt hinzufügen“ und „Produkt entfernen“ für einen Warenkorb im Shop, mit Undo und Redo. Was muss „entfernen“ sich merken?
Test
Teste dich
Verwandte Lektionen
Abschnitt „Verwandte Lektionen“- Baut auf: Klassen und Daten in Klassen · Pfeilfunktionen · Entwurfsmuster · Composite
- Geht weiter in: Der Shop: welches Muster wohin
- Aufgaben zum Thema: Entwurfsmuster