Command
Накратко: Command превръща всяко действие в обект с execute и undo, затова действията се пазят в история и могат да се отменят (undo) и да се повторят (redo).
Проблем
Undo, който помни само едно
Малкият редактор за бележки пише текст и трие от края. Undo е направен така: редакторът пази в текст какво е било последното действие и какъв текст е участвал в него.
class Editor{ private string lastAction = ""; private string lastText = "";
public string Text { get; private set; } = "";
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 == "type") { Text = Text.Substring(0, Text.Length - lastText.Length); } else if (lastAction == "delete") { Text += lastText; } lastAction = ""; }}var editor = new Editor();editor.Type("Купи");editor.Type(" мляко");editor.Undo();Console.WriteLine(editor.Text);editor.Undo();Console.WriteLine(editor.Text);КупиКупиПървото undo махна „ мляко“. Второто не направи нищо: „Купи“ остана, защото редакторът помни само едно действие, а след undo и него го няма. Redo изобщо няма: текстът, който undo махна, е изгубен.
Всяко ново действие (удебеляване, замяна, поставяне) е още един клон в Undo() и още полета в редактора. Знанието как се връща действието живее далеч от самото действие.
Идеята
Command: действието като обект
Вместо „последното действие беше type“ всяко действие става обект, който знае две неща: как да се изпълни (Execute()) и как да се върне (Undo()). Каквото му трябва за връщането, го пази в своите полета.
Историята пази командите в два стека:
- undo стекът: изпълнените команди. Undo взима най-горната, вика
Undo()и я слага в redo стека; - redo стекът: върнатите команди. Redo взима най-горната, изпълнява я отново и я връща в undo стека;
- нова команда изчиства redo стека. Върнатите команди са били върху друг текст, и няма къде да се повторят.
Това е шаблонът Command (команда). Ролите са три: командата (действието като обект), получателят (документът, върху който работи) и историята, която вика командите, без да знае какво правят.
Виж го
Два стека
Документът знае само да добавя и да трие от края. RemoveLast връща изтрития текст:
class Document{ public string Text { get; private set; } = "";
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; }}Общата форма на всяка команда:
interface ICommand{ void Execute(); void Undo();}Команда за писане. Тя пази документа и текста си, за да знае колко да изтрие при undo:
class TypeText : ICommand{ private readonly Document doc; private readonly string text;
public TypeText(Document doc, string text) { this.doc = doc; this.text = text; }
public void Execute() => doc.Append(text); public void Undo() => doc.RemoveLast(text.Length);}Историята не знае какви са командите. Тя само ги вика и ги мести между стековете:
class History{ private readonly Stack<ICommand> undoStack = new(); private readonly Stack<ICommand> redoStack = new();
public void Run(ICommand command) { command.Execute(); undoStack.Push(command); redoStack.Clear(); }
public void Undo() { if (undoStack.Count == 0) { return; } ICommand command = undoStack.Pop(); command.Undo(); redoStack.Push(command); }
public void Redo() { if (redoStack.Count == 0) { return; } ICommand command = redoStack.Pop(); command.Execute(); undoStack.Push(command); }}var doc = new Document();var 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.WriteLine(doc.Text);Купи мляко и яйцаvar doc = new Document();var 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.WriteLine(doc.Text);Стъпка по стъпка, с думи
- run вика Execute() на командата: документът получава „Купи“. После командата отива в undo стека.
- Втора команда, върху първата. Най-горе в стека е винаги последната изпълнена.
- Трета. Undo стекът помни реда на всичко, което е станало.
- undo взима най-горната команда и вика нейния Undo(): „ и хляб“ се маха. Командата отива в redo стека.
- Още едно undo: и „ мляко“ се маха. Назад се връщаш толкова стъпки, колкото са командите.
- redo взима най-горната от redo стека и вика Execute() отново: „ мляко“ се връща. Командата отива пак в undo стека.
- Нова команда. „ и хляб“ беше върнато върху друг текст, затова redoStack.Clear() изпразва redo стека.
- Документът е „Купи мляко и яйца“. Това печата програмата.
Undo на колкото искаш стъпки и redo идват от два стека и три кратки метода. Новият вид действие е нов клас, а историята не се пипа.
Виж го
Команда, която помни
Изтриването не може да се върне по броя на буквите: трябва да знаеш какво е изтрито. Командата го запомня при Execute():
class DeleteLast : ICommand{ private readonly Document doc; private readonly int count; private string removed = "";
public DeleteLast(Document doc, int count) { this.doc = doc; this.count = count; }
public void Execute() => removed = doc.RemoveLast(count); public void Undo() => doc.Append(removed);}var doc = new Document();var history = new History();history.Run(new TypeText(doc, "Купи мляко"));history.Run(new DeleteLast(doc, 6));Console.WriteLine(doc.Text);history.Undo();Console.WriteLine(doc.Text);history.Redo();Console.WriteLine(doc.Text);КупиКупи млякоКупиВ History и в TypeText не е сменен нито ред.
Проверката в началото на Undo() не е за красота. Без нея undo в празна история спира програмата:
public void Undo(){ ICommand command = undoStack.Pop(); command.Undo(); redoStack.Push(command);}var history = new History();history.Undo();Unhandled exception. System.InvalidOperationException: Stack empty.Pop() на празен Stack хвърля InvalidOperationException. Човек натиска Ctrl+Z повече пъти, отколкото е писал, и това е нормално, затова празен стек значи „нищо за връщане“.
Виж го
Команда от две функции
Action: един общ клас ActionCommand ги пази, а всяка команда е две ламбди. History не забелязва разликата:class ActionCommand : ICommand{ private readonly Action execute; private readonly Action undo;
public ActionCommand(Action execute, Action undo) { this.execute = execute; this.undo = undo; }
public void Execute() => execute(); public void Undo() => undo();}static ICommand TypeText(Document doc, string text) => new ActionCommand(() => doc.Append(text), () => doc.RemoveLast(text.Length));var doc = new Document();var history = new History();history.Run(TypeText(doc, "Купи"));history.Run(TypeText(doc, " мляко"));history.Undo();Console.WriteLine(doc.Text);history.Redo();Console.WriteLine(doc.Text);КупиКупи млякоЛамбдите затварят (помнят) doc и text, както полетата на класа ги пазеха. В Python и JavaScript това е обичайният начин, когато командата е кратка: командата е просто две функции.
Клас си заслужава, когато командата помни нещо, което се сменя между Execute() и Undo(), като изтрития текст. С функции и това става, но състоянието се крие в затварянето, и се чете по-трудно.
Пробвай
Предвиди изхода
Бележката за среща се пише, връща и пише наново. Какво печата програмата?
var doc = new Document();var 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.WriteLine(doc.Text);Пробвай
Намери грешката
Някой пише историята наново:
class History{ private readonly Stack<ICommand> undoStack = new(); private readonly Stack<ICommand> redoStack = new();
public void Run(ICommand command) { command.Execute(); undoStack.Push(command); }
public void Undo() { if (undoStack.Count == 0) { return; } ICommand command = undoStack.Pop(); command.Undo(); redoStack.Push(command); }
public void Redo() { if (redoStack.Count == 0) { return; } ICommand command = redoStack.Pop(); command.Execute(); undoStack.Push(command); }}var doc = new Document();var history = new History();history.Run(new TypeText(doc, "Купи"));history.Run(new TypeText(doc, " мляко"));history.Undo();history.Run(new TypeText(doc, " хляб"));history.Redo();Console.WriteLine(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
- Продължава в: Магазинът: кой шаблон къде
- Задачи по темата: Шаблони за дизайн