handbookНаписать

patterns/behavioral/command/practice/README.md · практика · 4 языка · 51 тест

Практика: редактор с 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 держит список команд и курсор: слева от курсора — то, что можно отменить, справа — то, что можно повторить.

Структура ​

history

edits

«interface»

Command

+execute(doc)

+undo(doc)

«interface»

Mergeable

+merge(next) : Command

Insert

+pos: int

+text: string

+typed: bool

Delete

-pos: int

-length: int

-removed: string

Replace

-pos: int

-length: int

-text: string

-removed: string

Document

+text

+insert(pos, text)

+delete(pos, length) : string

Editor

-history: list

-cursor: int

-canMerge: bool

+run(command)

+undo() : bool

+redo() : bool

Контракт команды обещает одно важное свойство: если 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. Отличается способ сказать «команда может склеиваться»: где-то это второй интерфейс, где-то необязательный метод.

ЯзыкОсобенность
PHPMergeable extends Command и проверка instanceof; Insert — final readonly, Delete и Replace — изменяемые из-за removed
GoНеобязательный интерфейс Merger и проверка приведением типа, как io.WriterTo; ошибки через error и сентинел ErrInvalidRange
PythonProtocol для команды и @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.

Связанные темы ​

© 2014-2026 Тимур ТурдыевСайт и справочник работают на VPS от AdminVPS