Zum Inhalt
wannacode

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);
Ausgabe
Kaufe
Kaufe

Das 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);
Ausgabe
Kaufe Milch und Eier

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);
Schritt für Schritt, in Worten
  1. run ruft execute() der Command auf: Das Dokument bekommt „Kaufe“. Dann kommt die Command auf den Undo-Stapel.
  2. Eine zweite Command, auf der ersten. Ganz oben im Stapel liegt immer die zuletzt ausgeführte.
  3. Eine dritte. Der Undo-Stapel merkt sich die Reihenfolge von allem, was passiert ist.
  4. undo nimmt die oberste Command und ruft ihr undo() auf: „ und Brot“ wird entfernt. Die Command wandert auf den Redo-Stapel.
  5. Noch ein undo: Auch „ Milch“ verschwindet. Du kannst so viele Schritte zurückgehen, wie es Commands gibt.
  6. redo nimmt die oberste vom Redo-Stapel und ruft execute() erneut auf: „ Milch“ ist zurück. Die Command geht wieder auf den Undo-Stapel.
  7. Eine neue Command. „ und Brot“ wurde auf einem anderen Text rückgängig gemacht, deshalb leert redoStack.length = 0 den Redo-Stapel.
  8. 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);
Ausgabe
Kaufe
Kaufe Milch
Kaufe

Keine 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();
Fehler
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

Die Tipp-Command hat zwei Aktionen und sonst nichts. In JavaScript ist das ein Objekt mit zwei Pfeilfunktionen. 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);
Ausgabe
Kaufe
Kaufe Milch

Die 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);
Antwort

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);
Ausgabe
Kaufe Brot Milch

Redo 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.

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:

Ausgabe
MILCH KAUFEN
Milch kaufen
MILCH KAUFEN

Weitere Aufgaben

  • Ein Makro. Eine Command, die eine Liste von Commands hält: execute() führt sie der Reihe nach aus, und undo() 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

1. Was hält der Verlauf bei Command fest?
2. Du tippst „Kaufe Milch“, machst „ Milch“ rückgängig und tippst „ Brot“. Was soll mit dem Redo-Stapel passieren?
3. Warum behält die Lösch-Command den gelöschten Text in einem Feld?
4. Wann brauchst du Command nicht?

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