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

Command

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

Проблем

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

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

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

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

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

Идеята

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

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

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

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

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

Виж го

Два стека

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

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;
}
}

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

class TypeText {
constructor(doc, text) {
this.doc = doc;
this.text = text;
}
execute() {
this.doc.append(this.text);
}
undo() {
this.doc.removeLast(this.text.length);
}
}

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

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

const doc = new Document();
const 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, " и яйца"));
console.log(doc.text);
Стъпка по стъпка, с думи
  1. run вика execute() на командата: документът получава „Купи“. После командата отива в undo стека.
  2. Втора команда, върху първата. Най-горе в стека е винаги последната изпълнена.
  3. Трета. Undo стекът помни реда на всичко, което е станало.
  4. undo взима най-горната команда и вика нейния undo(): „ и хляб“ се маха. Командата отива в redo стека.
  5. Още едно undo: и „ мляко“ се маха. Назад се връщаш толкова стъпки, колкото са командите.
  6. redo взима най-горната от redo стека и вика execute() отново: „ мляко“ се връща. Командата отива пак в undo стека.
  7. Нова команда. „ и хляб“ беше върнато върху друг текст, затова redoStack.length = 0 изпразва redo стека.
  8. Документът е „Купи мляко и яйца“. Това печата програмата.

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

Виж го

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

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

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

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

undo() {
const command = this.undoStack.pop();
command.undo();
this.redoStack.push(command);
}
const history = new History();
history.undo();
Грешка
TypeError: Cannot read properties of undefined (reading 'undo')

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

Виж го

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

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

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

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

Пробвай

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

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

const doc = new Document();
const 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();
console.log(doc.text);
Отговор

Пробвай

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

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

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, "Купи"));
history.run(new TypeText(doc, " мляко"));
history.undo();
history.run(new TypeText(doc, " хляб"));
history.redo();
console.log(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 за достъпно обучение по програмиране ·