Практика: редактор с undo/redo (Undo history)
История правок документа из команд: отмена и повтор с курсором, обрезка ветки повтора, склейка набранных подряд символов и правило, что неудачная правка в историю не попадает.
Базовый пример: Команда. Там команды обратимы без памяти и отмена — просто стек. Здесь команде нужно помнить, что она удалила, история умеет и undo, и redo, а «одна правка» для пользователя — не то же самое, что «один вызов».
Задача
В админке магазина есть редактор описаний товаров. Первая версия отмены хранила копию всего текста после каждой клавиши: на длинном описании история съедала память, а «отменить» откатывало по одной букве, и чтобы убрать слово, приходилось жать Ctrl+Z восемь раз. Потом добавили «повторить», и выяснилось, что после отмены и новой правки повтор возвращает текст, который к новой правке уже не относится. Последней каплей стала ошибка: вставка за пределами текста падала, но попадала в историю, и следующая отмена ломала документ.
Команда раскладывает это на объекты-правки: каждая знает, как себя выполнить и отменить, и хранит только то, что нужно для отмены. История — список команд с курсором; на ней и живут правила, которых нет в учебном примере.
Что урезано
- Текст — строка ASCII, позиции — индексы символов; в настоящем редакторе это UTF-8, графемы и позиции в строках и колонках.
- Нет курсора и выделения пользователя: позиция передаётся в команду явно. В редакторе её берут из выделения и восстанавливают при отмене.
- Склейка по одному правилу: набранные подряд символы без пробелов. Нет таймаута «пауза дольше секунды начинает новый шаг» и ограничения длины истории.
- Нет сохранения истории между сессиями и совместного редактирования.
Решение
Получатель — Document с двумя операциями, insert и delete. Обе проверяют диапазон до изменения и бросают InvalidRange, поэтому неудачная операция оставляет текст нетронутым.
Команда Command получает документ в execute и undo, а не хранит его: история владеет документом, команды — просто данные о правке. Необязательная способность — merge: команда может поглотить следующую и вернуть одну, которая делает обе.
Команды: Insert обратима сама (отмена удаляет вставленное), Delete и Replace в execute запоминают удалённый текст, чтобы undo его вернул. Поэтому команда создаётся заново на каждую правку, а не привязывается к кнопке, как в базовом примере.
История Editor держит список команд и курсор: слева от курсора — то, что можно отменить, справа — то, что можно повторить.
Структура
Контракт команды обещает одно важное свойство: если execute упал, документ не изменился. На нём держится правило истории «неудачная команда не попадает в список»: откатывать нечего, значит, и помнить нечего. Слияние — отдельная способность, а не обязанность: удаление и замена её не имеют.
История с курсором
История — список и индекс в нём. Правила, которые реализует Editor.run:
- Сначала выполнить, потом записать. Если
executeбросил ошибку,runеё пробрасывает, а история не меняется: ни новой записи, ни обрезки ветки повтора. - Новая правка обрезает повтор. После двух отмен справа от курсора лежат две команды. Новая правка выбрасывает их: повтор старой ветки поверх нового текста дал бы мусор.
- Набор склеивается. Если последняя команда умеет
mergeи согласна поглотить новую, в истории остаётся одна запись.Insertсклеивается, когда обе вставки набраны с клавиатуры (typed), новая стоит ровно в конце предыдущей и не является пробелом. Такhello worldстановится двумя шагами отмены,helloиworld, а вставка из буфера остаётся отдельным шагом. - Отмена и повтор прерывают склейку. Флаг
canMergeсбрасывается наundoиredo, иначе символ, набранный после отмены, приклеился бы к старой группе, и следующая отмена унесла бы больше, чем ждёт пользователь.
Как это собирается
Editor создаётся один раз на открытый документ и живёт вместе с окном редактора. Команды создаются заново на каждое событие интерфейса: обработчик клавиатуры превращает каждый набранный символ в Insert с флагом typed, пункт меню "Заменить" - в Replace, выделение и Delete - в Delete, а Ctrl+Z и Ctrl+Shift+Z зовут undo и redo напрямую. Сама история о клавишах и меню ничего не знает.
В примере семь событий подряд. Набор Hi и Bob по символам склеивается в два шага отмены. Удаление по устаревшим координатам (в окне уже меньше текста, чем думал обработчик) бросает InvalidRange: обработчик ловит ошибку и показывает текст как есть, а история не получила ни записи, ни обрезки повтора. Дальше замена, две отмены и повтор идут по той же истории.
Сценарий целиком с выводом: пример на PHP, он же на Go, Python и TypeScript в панели кода.
Реализации
Команды одинаковы по смыслу во всех языках: позиция, длина и текст задаются при создании, удалённый текст появляется в execute. Отличается способ сказать «команда может склеиваться»: где-то это второй интерфейс, где-то необязательный метод.
| Язык | Особенность |
|---|---|
| PHP | Mergeable extends Command и проверка instanceof; Insert — final readonly, Delete и Replace — изменяемые из-за removed |
| Go | Необязательный интерфейс Merger и проверка приведением типа, как io.WriterTo; ошибки через error и сентинел ErrInvalidRange |
| Python | Protocol для команды и @runtime_checkable протокол Mergeable; слияние через match с образцом Insert(...) |
| TypeScript | Необязательный метод merge? в интерфейсе и вызов ?.merge?.(command); InvalidRange extends RangeError |
Запуск: make test T=patterns/behavioral/command/practice
Что проверяют тесты
Тесты одинаковы во всех языках: одна таблица сеансов правки, каждый — начальный текст, шаги, ожидаемый текст и число неудачных шагов.
- Правки и отмена:
hello world-> удалитьworld-> заменитьhнаjдаётjello; две отмены возвращаютhello world, повтор —hello. Пустая история:undoиredoнаdraftничего не меняют. - Обрезка повтора: вставить
hello, удалитьh, отменить, дописать!. Повтор после этого ничего не делает, текстhello!. Без обрезки он вернул бы удаление и далello!. - Склейка: набранное
helloотменяется одним шагом; вhello worldпробел начинает новый шаг, отмена оставляетhello; вставкаhiи набранный!— разные шаги. - Отмена прерывает склейку: набрать
ab, удалитьb, отменить, набратьcв конце. Отмена убирает толькоc, а не всю группуabc. - Неудачная команда: удаление за концом текста после отмены не трогает ветку повтора,
redoвсё ещё работает (ello); замена за концом текста не попадает в историю, и следующая отмена снимает предыдущую вставку. - Отдельный тест: ошибка
InvalidRangeс понятным текстом (range 3..4 is outside document of length 3), документ и история не изменились.
Где граница
- Команда хороша, пока правка дешёво описывается и обращается: вставка, удаление, замена. Если отмена требует снимка большого состояния (применить фильтр к картинке), проще хранить снимки (Memento) или чередовать: команды плюс периодический снимок.
- Совместное редактирование ломает простую историю: правки других людей сдвигают позиции, и чужие команды нельзя отменять как свои. Это уже операционные преобразования или CRDT, команды там остаются только как формат правки.
- Для одноуровневой отмены («отменить последнее действие» в форме настроек) история с курсором избыточна: хватит одной сохранённой команды или старого значения поля.
- Если склеек больше одной (по времени, по словам, по типу правки), правило из
Insert.mergeстоит вынести в отдельную политику истории, а не учить каждую команду всем правилам.
Отличия между языками
- PHP — второй интерфейс
Mergeableиinstanceofв истории: тип говорит, кто умеет склеиваться.final readonlyподходит толькоInsert:DeleteиReplaceпишутremovedвexecute. Поиск по истории через?? nullвместо проверок индексов — PHPStan видит, что пустая история обработана. - Go — необязательный интерфейс
Mergerиif m, ok := cmd.(Merger)— стандартная идиома (io.WriterTo,http.Flusher).Undoошибки не возвращает: он выполняется на том состоянии, которое оставилExecute.Redoс ошибкой — нарушение инварианта, поэтомуpanic, а неerror. - Python — команда —
Protocol, склейка — второй протокол с@runtime_checkable, чтобыisinstanceработал без наследования.matchс образцом класса (case Insert(pos=..., typed=True)) делает условие склейки читаемым.DeleteиReplace— обычные dataclass с полемremovedвне конструктора (field(init=False)). - TypeScript — необязательный метод
merge?прямо в интерфейсе и цепочкаthis.history.at(-1)?.merge?.(command): одна строка вместо проверки типа. Обрезка ветки повтора —history.length = cursor.
Связанные темы
- Команда (базовый пример)
- Компоновщик (Composite) — склеенную группу можно хранить и как макрокоманду из нескольких правок
- Практика Фасада: оформление заказа — откат уже сделанных шагов при сбое, но без истории и повтора