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

Command

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

Проблем

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

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

class Editor {
private String text = "";
private String lastAction = "";
private String lastText = "";
public void type(String s) {
text += s;
lastAction = "type";
lastText = s;
}
public void deleteLast(int count) {
lastText = text.substring(text.length() - count);
text = text.substring(0, text.length() - count);
lastAction = "delete";
}
public void undo() {
if (lastAction.equals("type")) {
text = text.substring(0, text.length() - lastText.length());
} else if (lastAction.equals("delete")) {
text += lastText;
}
lastAction = "";
}
public String text() {
return text;
}
}
Editor editor = new Editor();
editor.type("Купи");
editor.type(" мляко");
editor.undo();
System.out.println(editor.text());
editor.undo();
System.out.println(editor.text());
Изход
Купи
Купи

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

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

Идеята

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

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

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

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

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

Виж го

Два стека

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

class Document {
private String text = "";
public void append(String s) {
text += s;
}
public String removeLast(int count) {
String removed = text.substring(text.length() - count);
text = text.substring(0, text.length() - count);
return removed;
}
public String text() {
return text;
}
}

Общата форма на всяка команда:

interface Command {
void execute();
void undo();
}

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

class TypeText implements Command {
private final Document doc;
private final String text;
public TypeText(Document doc, String text) {
this.doc = doc;
this.text = text;
}
@Override
public void execute() {
doc.append(text);
}
@Override
public void undo() {
doc.removeLast(text.length());
}
}

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

class History {
private final Deque<Command> undoStack = new ArrayDeque<>();
private final Deque<Command> redoStack = new ArrayDeque<>();
public void run(Command command) {
command.execute();
undoStack.push(command);
redoStack.clear();
}
public void undo() {
if (undoStack.isEmpty()) {
return;
}
Command command = undoStack.pop();
command.undo();
redoStack.push(command);
}
public void redo() {
if (redoStack.isEmpty()) {
return;
}
Command command = redoStack.pop();
command.execute();
undoStack.push(command);
}
}
Document doc = new Document();
History history = new History();
history.run(new TypeText(doc, "Купи"));
history.run(new TypeText(doc, " мляко"));
history.run(new TypeText(doc, " и хляб"));
history.undo();
history.undo();
history.redo();
history.run(new TypeText(doc, " и яйца"));
System.out.println(doc.text());
Изход
Купи мляко и яйца

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

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

Виж го

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

Изтриването не може да се върне по броя на буквите: трябва да знаеш какво е изтрито. Командата го запомня при execute():

class DeleteLast implements Command {
private final Document doc;
private final int count;
private String removed = "";
public DeleteLast(Document doc, int count) {
this.doc = doc;
this.count = count;
}
@Override
public void execute() {
removed = doc.removeLast(count);
}
@Override
public void undo() {
doc.append(removed);
}
}
Document doc = new Document();
History history = new History();
history.run(new TypeText(doc, "Купи мляко"));
history.run(new DeleteLast(doc, 6));
System.out.println(doc.text());
history.undo();
System.out.println(doc.text());
history.redo();
System.out.println(doc.text());
Изход
Купи
Купи мляко
Купи

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

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

public void undo() {
Command command = undoStack.pop();
command.undo();
redoStack.push(command);
}
History history = new History();
history.undo();
Грешка
Exception in thread "main" java.util.NoSuchElementException

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

Виж го

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

Командата за писане има две действия и нищо друго. Command има два метода, затова не е функционален интерфейс и ламбда не става. Анонимен клас прави същото като две ламбди: вижда doc и text от метода, който го създава, и History не забелязва разликата:
static Command typeText(Document doc, String text) {
return new Command() {
@Override
public void execute() {
doc.append(text);
}
@Override
public void undo() {
doc.removeLast(text.length());
}
};
}
Document doc = new Document();
History history = new History();
history.run(typeText(doc, "Купи"));
history.run(typeText(doc, " мляко"));
history.undo();
System.out.println(doc.text());
history.redo();
System.out.println(doc.text());
Изход
Купи
Купи мляко

Ламбдите затварят (помнят) doc и text, както полетата на класа ги пазеха. В Python и JavaScript това е обичайният начин, когато командата е кратка: командата е просто две функции.

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

Пробвай

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

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

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

Пробвай

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

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

class History {
private final Deque<Command> undoStack = new ArrayDeque<>();
private final Deque<Command> redoStack = new ArrayDeque<>();
public void run(Command command) {
command.execute();
undoStack.push(command);
}
public void undo() {
if (undoStack.isEmpty()) {
return;
}
Command command = undoStack.pop();
command.undo();
redoStack.push(command);
}
public void redo() {
if (redoStack.isEmpty()) {
return;
}
Command command = redoStack.pop();
command.execute();
undoStack.push(command);
}
}
Document doc = new Document();
History history = new History();
history.run(new TypeText(doc, "Купи"));
history.run(new TypeText(doc, " мляко"));
history.undo();
history.run(new TypeText(doc, " хляб"));
history.redo();
System.out.println(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 за достъпно обучение по програмиране ·