Към съдържанието
wannacode

Command

Накратко: Command превръща всяко действие в обект с execute и undo, затова действията се пазят в история и могат да се отменят (undo) и да се повторят (redo).

Проблем

Undo, който помни само едно

Малкият редактор за бележки пише текст и трие от края. Undo е направен така: редакторът пази в текст какво е било последното действие и какъв текст е участвал в него.

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("Купи")
editor.type(" мляко")
editor.undo()
print(editor.text)
editor.undo()
print(editor.text)
Изход
Купи
Купи

Първото undo махна „ мляко“. Второто не направи нищо: „Купи“ остана, защото редакторът помни само едно действие, а след undo и него го няма. Redo изобщо няма: текстът, който undo махна, е изгубен.

Всяко ново действие (удебеляване, замяна, поставяне) е още един клон в undo() и още полета в редактора. Знанието как се връща действието живее далеч от самото действие.

Идеята

Command: действието като обект

Вместо „последното действие беше type“ всяко действие става обект, който знае две неща: как да се изпълни (execute()) и как да се върне (undo()). Каквото му трябва за връщането, го пази в своите полета.

Историята пази командите в два стека:

  • undo стекът: изпълнените команди. Undo взима най-горната, вика undo() и я слага в redo стека;
  • redo стекът: върнатите команди. Redo взима най-горната, изпълнява я отново и я връща в undo стека;
  • нова команда изчиства redo стека. Върнатите команди са били върху друг текст, и няма къде да се повторят.

Това е шаблонът Command (команда). Ролите са три: командата (действието като обект), получателят (документът, върху който работи) и историята, която вика командите, без да знае какво правят.

Виж го

Два стека

Документът знае само да добавя и да трие от края. remove_last връща изтрития текст:

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

Команда за писане. Тя пази документа и текста си, за да знае колко да изтрие при undo:

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))

Историята не знае какви са командите. Тя само ги вика и ги мести между стековете:

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, "Купи"))
history.run(TypeText(doc, " мляко"))
history.run(TypeText(doc, " и хляб"))
history.undo()
history.undo()
history.redo()
history.run(TypeText(doc, " и яйца"))
print(doc.text)
Изход
Купи мляко и яйца

doc = Document()
history = History()
history.run(TypeText(doc, "Купи"))
history.run(TypeText(doc, " мляко"))
history.run(TypeText(doc, " и хляб"))
history.undo()
history.undo()
history.redo()
history.run(TypeText(doc, " и яйца"))
print(doc.text)
Стъпка по стъпка, с думи
  1. run вика execute() на командата: документът получава „Купи“. После командата отива в undo стека.
  2. Втора команда, върху първата. Най-горе в стека е винаги последната изпълнена.
  3. Трета. Undo стекът помни реда на всичко, което е станало.
  4. undo взима най-горната команда и вика нейния undo(): „ и хляб“ се маха. Командата отива в redo стека.
  5. Още едно undo: и „ мляко“ се маха. Назад се връщаш толкова стъпки, колкото са командите.
  6. redo взима най-горната от redo стека и вика execute() отново: „ мляко“ се връща. Командата отива пак в undo стека.
  7. Нова команда. „ и хляб“ беше върнато върху друг текст, затова redo_stack.clear() изпразва redo стека.
  8. Документът е „Купи мляко и яйца“. Това печата програмата.

Undo на колкото искаш стъпки и redo идват от два стека и три кратки метода. Новият вид действие е нов клас, а историята не се пипа.

Виж го

Команда, която помни

Изтриването не може да се върне по броя на буквите: трябва да знаеш какво е изтрито. Командата го запомня при 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, "Купи мляко"))
history.run(DeleteLast(doc, 6))
print(doc.text)
history.undo()
print(doc.text)
history.redo()
print(doc.text)
Изход
Купи
Купи мляко
Купи

В History и в TypeText не е сменен нито ред.

Проверката в началото на undo() не е за красота. Без нея undo в празна история спира програмата:

def undo(self):
command = self.undo_stack.pop()
command.undo()
self.redo_stack.append(command)
history = History()
history.undo()
Грешка
IndexError: pop from empty list

pop() на празен списък хвърля IndexError. Човек натиска Ctrl+Z повече пъти, отколкото е писал, и това е нормално, затова празен стек значи „нищо за връщане“.

Виж го

Команда от две функции

Командата за писане има две действия и нищо друго. В Python това са две функции: командата може да е именуван кортеж (namedtuple) от две ламбди. History вика command.execute() и command.undo() и не забелязва разликата:
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, "Купи"))
history.run(type_text(doc, " мляко"))
history.undo()
print(doc.text)
history.redo()
print(doc.text)
Изход
Купи
Купи мляко

Ламбдите затварят (помнят) doc и text, както полетата на класа ги пазеха. Това е обичайният начин в Python, когато командата е кратка. В Java същото става с анонимен клас, а в C# с два Action.

Клас си заслужава, когато командата помни нещо, което се сменя между execute() и undo(), като изтрития текст. С функции и това става, но състоянието се крие в затварянето, и се чете по-трудно.

Пробвай

Предвиди изхода

Бележката за среща се пише, връща и пише наново. Какво печата програмата?

doc = Document()
history = History()
history.run(TypeText(doc, "Среща"))
history.run(TypeText(doc, " в 10:00"))
history.undo()
history.run(TypeText(doc, " в 11:00"))
history.redo()
history.undo()
print(doc.text)
Отговор

Пробвай

Намери грешката

Някой пише историята наново:

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, "Купи"))
history.run(TypeText(doc, " мляко"))
history.undo()
history.run(TypeText(doc, " хляб"))
history.redo()
print(doc.text)
Изход
Купи хляб мляко

Redo сложи „ мляко“ в бележка, от която „ мляко“ беше върнато, преди да се напише „ хляб“.

Избери реда, след който трябваше да има още един.

Идеята

Кога не ти трябва

Command има цена: клас (или две функции) за всяко действие, вместо да викаш метода направо. Не си струва, когато:

  • действието се вика веднага, няма undo, не се нарежда на опашка и не се записва. Тогава командата е обвивка около едно извикване;
  • състоянието е малко. Ако документът е кратък текст, историята може да пази копия на текста преди всяко действие, и undo е „върни предишното копие“. Това е по-просто и не може да сбърка обратното действие;
  • undo трябва само на една стъпка, на едно място.

Струва си, когато действията трябва да се връщат на много стъпки, да се повтарят, да чакат на опашка (задачи за изпращане), да се записват и пускат пак (макрос) или да се изпълняват по-късно.

Упражнение

Главни букви

Реши задачата в редактора си.

Главни букви. Добави команда Upper, която прави целия текст с главни букви, а undo връща текста, какъвто е бил. Document и History не се пипат. Напиши „купи мляко“, изпълни Upper, после undo и redo, и печатай текста след всяка от трите стъпки:

Изход
КУПИ МЛЯКО
купи мляко
КУПИ МЛЯКО

Още задачи

  • Макрос. Команда, която държи списък от команди: execute() ги изпълнява по ред, а undo() ги връща в обратен ред. Това е Composite от команди. Защо редът при undo е обратен?
  • Само 3 стъпки назад. Историята помни най-много 3 команди за undo. Най-старата се изхвърля.
  • Количката. Команди „добави продукт“ и „махни продукт“ за количка в магазин, с undo и redo. Какво трябва да помни „махни“?

Тест

Провери се

1. Какво пази историята при Command?
2. Пишеш „Купи мляко“, връщаш „ мляко“ с undo и пишеш „ хляб“. Какво трябва да стане с redo стека?
3. Защо изтриването пази изтрития текст в поле?
4. Кога Command не ти трябва?

Създаден от Иво Гергов като част от кампанията на Evo Tech за достъпно обучение по програмиране ·