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:
def __init__(self):
self.text = ""
self.last_action = ""
self.last_text = ""
def type(self, s):
self.text += s
self.last_action = "type"
self.last_text = s
def delete_last(self, count):
self.last_text = self.text[-count:]
self.text = self.text[:-count]
self.last_action = "delete"
def undo(self):
if self.last_action == "type":
self.text = self.text[: -len(self.last_text)]
elif self.last_action == "delete":
self.text += self.last_text
self.last_action = ""
editor = Editor()
editor.type("Kaufe")
editor.type(" Milch")
editor.undo()
print(editor.text)
editor.undo()
print(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. remove_last gibt den gelöschten Text zurück:

class Document:
def __init__(self):
self.text = ""
def append(self, s):
self.text += s
def remove_last(self, count):
removed = self.text[-count:]
self.text = self.text[:-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:
def __init__(self, doc, text):
self.doc = doc
self.text = text
def execute(self):
self.doc.append(self.text)
def undo(self):
self.doc.remove_last(len(self.text))

Der Verlauf weiß nicht, was die Commands sind. Er ruft sie nur auf und schiebt sie zwischen den Stapeln hin und her:

class History:
def __init__(self):
self.undo_stack = []
self.redo_stack = []
def run(self, command):
command.execute()
self.undo_stack.append(command)
self.redo_stack.clear()
def undo(self):
if not self.undo_stack:
return
command = self.undo_stack.pop()
command.undo()
self.redo_stack.append(command)
def redo(self):
if not self.redo_stack:
return
command = self.redo_stack.pop()
command.execute()
self.undo_stack.append(command)
doc = Document()
history = History()
history.run(TypeText(doc, "Kaufe"))
history.run(TypeText(doc, " Milch"))
history.run(TypeText(doc, " und Brot"))
history.undo()
history.undo()
history.redo()
history.run(TypeText(doc, " und Eier"))
print(doc.text)
Ausgabe
Kaufe Milch und Eier

doc = Document()
history = History()
history.run(TypeText(doc, "Kaufe"))
history.run(TypeText(doc, " Milch"))
history.run(TypeText(doc, " und Brot"))
history.undo()
history.undo()
history.redo()
history.run(TypeText(doc, " und Eier"))
print(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 redo_stack.clear() 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:
def __init__(self, doc, count):
self.doc = doc
self.count = count
self.removed = ""
def execute(self):
self.removed = self.doc.remove_last(self.count)
def undo(self):
self.doc.append(self.removed)
doc = Document()
history = History()
history.run(TypeText(doc, "Kaufe Milch"))
history.run(DeleteLast(doc, 6))
print(doc.text)
history.undo()
print(doc.text)
history.redo()
print(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:

def undo(self):
command = self.undo_stack.pop()
command.undo()
self.redo_stack.append(command)
history = History()
history.undo()
Fehler
IndexError: pop from empty list

pop() auf einer leeren Liste löst IndexError aus. 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 Python sind das zwei Funktionen: Die Command kann ein benanntes Tupel (namedtuple) aus zwei Lambdas sein. History ruft command.execute() und command.undo() auf und merkt keinen Unterschied:
from collections import namedtuple
Command = namedtuple("Command", ["execute", "undo"])
def type_text(doc, text):
return Command(
execute=lambda: doc.append(text),
undo=lambda: doc.remove_last(len(text)),
)
doc = Document()
history = History()
history.run(type_text(doc, "Kaufe"))
history.run(type_text(doc, " Milch"))
history.undo()
print(doc.text)
history.redo()
print(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 Python 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?

doc = Document()
history = History()
history.run(TypeText(doc, "Treffen"))
history.run(TypeText(doc, " um 10:00"))
history.undo()
history.run(TypeText(doc, " um 11:00"))
history.redo()
history.undo()
print(doc.text)
Antwort

Probier es aus

Finde den Fehler

Jemand schreibt den Verlauf neu:

class History:
def __init__(self):
self.undo_stack = []
self.redo_stack = []
def run(self, command):
command.execute()
self.undo_stack.append(command)
def undo(self):
if not self.undo_stack:
return
command = self.undo_stack.pop()
command.undo()
self.redo_stack.append(command)
def redo(self):
if not self.redo_stack:
return
command = self.redo_stack.pop()
command.execute()
self.undo_stack.append(command)
doc = Document()
history = History()
history.run(TypeText(doc, "Kaufe"))
history.run(TypeText(doc, " Milch"))
history.undo()
history.run(TypeText(doc, " Brot"))
history.redo()
print(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 ·