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());Стъпка по стъпка, с думи
- run вика execute() на командата: документът получава „Купи“. После командата отива в undo стека.
- Втора команда, върху първата. Най-горе в стека е винаги последната изпълнена.
- Трета. Undo стекът помни реда на всичко, което е станало.
- undo взима най-горната команда и вика нейния undo(): „ и хляб“ се маха. Командата отива в redo стека.
- Още едно undo: и „ мляко“ се маха. Назад се връщаш толкова стъпки, колкото са командите.
- redo взима най-горната от redo стека и вика execute() отново: „ мляко“ се връща. Командата отива пак в undo стека.
- Нова команда. „ и хляб“ беше върнато върху друг текст, затова redoStack.clear() изпразва redo стека.
- Документът е „Купи мляко и яйца“. Това печата програмата.
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.NoSuchElementExceptionpop() на празен 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 сложи „ мляко“ в бележка, от която „ мляко“ беше върнато, преди да се напише „ хляб“.
Избери реда, след който трябваше да има още един.
run слага новата команда в undo стека, но не изчиства redo стека. „ мляко“ остава там, и redo го повтаря върху текст, в който то вече няма място. С по-сложни команди е още по-лошо: изтриване, повторено върху друг текст, трие грешните букви. След този ред трябва redoStack.clear();. Тогава redo след нова команда не прави нищо, и бележката е „Купи хляб“.
Идеята
Кога не ти трябва
Command има цена: клас (или две функции) за всяко действие, вместо да викаш метода направо. Не си струва, когато:
- действието се вика веднага, няма undo, не се нарежда на опашка и не се записва. Тогава командата е обвивка около едно извикване;
- състоянието е малко. Ако документът е кратък текст, историята може да пази копия на текста преди всяко действие, и undo е „върни предишното копие“. Това е по-просто и не може да сбърка обратното действие;
- undo трябва само на една стъпка, на едно място.
Струва си, когато действията трябва да се връщат на много стъпки, да се повтарят, да чакат на опашка (задачи за изпращане), да се записват и пускат пак (макрос) или да се изпълняват по-късно.
Упражнение
Главни букви
Реши задачата в редактора си.
Главни букви. Добави команда Upper, която прави целия текст с главни букви, а undo връща текста, какъвто е бил. Document и History не се пипат. Напиши „купи мляко“, изпълни Upper, после undo и redo, и печатай текста след всяка от трите стъпки:
КУПИ МЛЯКОкупи млякоКУПИ МЛЯКООще задачи
- Макрос. Команда, която държи списък от команди:
execute()ги изпълнява по ред, аundo()ги връща в обратен ред. Това е Composite от команди. Защо редът при undo е обратен? - Само 3 стъпки назад. Историята помни най-много 3 команди за undo. Най-старата се изхвърля.
- Количката. Команди „добави продукт“ и „махни продукт“ за количка в магазин, с undo и redo. Какво трябва да помни „махни“?
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Вложени класове · Ламбди · Шаблони за дизайн · Composite
- Продължава в: Магазинът: кой шаблон къде
- Задачи по темата: Шаблони за дизайн