Снимок (Memento)
Объект сохраняет своё состояние в непрозрачный снимок и потом восстанавливается из него, не раскрывая внутренностей тому, кто снимки хранит.
Проблема
В графическом редакторе есть кнопка «Отменить». Проще всего сделать её снаружи: перед каждой операцией история копирует поля холста (штрихи, текущий цвет) к себе, а при отмене записывает их обратно. Для этого холст должен открыть все поля на чтение и запись. Теперь история знает устройство холста: добавили слой или масштаб — правим историю, забыли — отмена восстанавливает холст наполовину. А раз поля открыты, их может поменять кто угодно, не только отмена.
Решение
Холст сам упаковывает своё состояние в снимок CanvasSnapshot и сам из него восстанавливается. Снимок неизменяем и непрозрачен: снаружи видна только подпись, состояние прочитать может только холст. История хранит снимки и в нужный момент отдаёт их холсту обратно, ни разу не заглянув внутрь.
Три роли: создатель (Canvas) делает и принимает снимки, снимок хранит состояние, опекун (History) решает, когда снимать и к какому снимку вернуться. Главный вопрос приёма — как закрыть снимок от всех, кроме создателя, и здесь у каждого языка свой ответ.
Структура
Canvas — создатель. save() копирует штрихи в снимок, restore() копирует их обратно, а не берёт ссылку: иначе следующий штрих после отмены дописался бы прямо в снимок, и второй откат к нему вернул бы уже испорченное состояние. Геттеры strokes() и color() нужны тестам и интерфейсу, истории они не нужны.
Как это собирается
Canvas и History создаёт окно редактора при открытии рисунка, и история получает холст в конструктор. Когда снимать, решает код приложения, а не холст и не история: в примере правило "снимок перед каждым штрихом", поэтому backup зовёт обработчик кнопки рисования, а смена цвета снимка не делает. Подпись снимка (before circle) - то, что потом покажет пункт меню "Отменить".
Ctrl+Z зовёт undo: история отдаёт холсту последний снимок, холст сам раскладывает его обратно в штрихи и цвет. Когда снимков больше нет, история бросает ошибку "nothing to undo", и обработчик просто сообщает об этом пользователю: в приложении в этот момент кнопка отмены становится неактивной.
Сценарий целиком с выводом: пример на PHP, он же на Go, Python и TypeScript в панели кода.
Реализации
History — опекун-стек: backup() просит у холста снимок и кладёт его наверх, undo() снимает верхний и отдаёт холсту. Сам он видит только подписи (labels()), этого хватает, чтобы показать пользователю список «Отменить: круг».
| Язык | Как закрыт снимок |
|---|---|
| PHP | Узкий интерфейс Snapshot для опекуна и широкий final readonly class CanvasSnapshot для холста |
| Go | Неэкспортируемые поля: из другого пакета снимок не прочитать и не подделать, внутри пакета memento холст читает их напрямую |
| Python | Соглашение: поля _strokes и _color с подчёркиванием, frozen=True от случайной записи, кортеж вместо списка |
| TypeScript | Настоящие приватные поля #; модуль открывает их себе через static-блок и неэкспортируемую функцию readState |
Запуск: make test T=patterns/behavioral/memento
Плюсы и минусы
Тесты одинаковы во всех языках. Отмена возвращает и штрихи, и цвет, хотя история ни про то, ни про другое не знает. Две отмены подряд проходят стек до пустого холста. Опекун видит только подписи. Отмена на пустой истории — ошибка, а не молчаливое ничего.
- ✅ Инкапсуляция цела: устройство холста знает только холст, история работает с любым состоянием.
- ✅ Новое поле холста попадает в отмену правкой
save()/restore()в одном месте. - ✅ Снимок неизменяем, его можно хранить, передавать и восстанавливать сколько угодно раз.
- ❌ Память: каждый снимок — полная копия состояния. Для большого документа это дорого, нужен лимит истории или хранение разниц.
- ❌ Языки без «дружественного» доступа заставляют выбирать между строгостью и простотой (см. отличия).
Когда не применять
- Состояние маленькое и открытое (форма из трёх полей) — копия значения проще отдельного класса снимка.
- Состояние огромное, а изменения точечные — дешевле хранить операции и откатывать их обратными действиями.
- Объект и так неизменяемый: каждая версия уже снимок, истории достаточно хранить сами версии.
Отличия между языками
- PHP — видимость в PHP на уровне класса, дружественных классов нет. Поэтому использован вариант авторов паттерна: узкий интерфейс
Snapshot(толькоlabel()) для опекуна и широкий классCanvasSnapshotдля создателя. Опекун типизирован узким интерфейсом и до штрихов не дотягивается, аreadonlyне даёт их поменять. Обойти это можно привязанным замыканием (Closure::bind), но такой трюк прячет чтение от PHPStan. Массивы PHP копируются по значению, так что отдельно копировать штрихи не нужно. - Go — граница видимости — пакет, и это ровно «дружественный» доступ: создатель и снимок в одном пакете, опекун из любого другого видит только
Label(). Срезы делят массив, поэтомуSaveиRestoreклонируют их черезslices.Clone. - Python — приватности нет, есть соглашение: подчёркнутые поля не трогают снаружи, линтеры это подсвечивают. Если нужна настоящая закрытость, состояние можно спрятать в замыкание:
save()возвращает объект с подписью и функцией, которая восстанавливает холст из захваченных значений. - TypeScript — поля
#закрыты на уровне движка, даже от другого класса в том же файле. Мостик —static-блок, который кладёт читатель в неэкспортируемую переменную модуля: читать снимок может только код этого модуля. МодификаторprivateTS был бы проверкой только компилятора.
Практика
Учебная история хранит снимки без счёта и только в памяти. В приложении снимков много, они сохраняются по таймеру, переживают перезагрузку страницы и должны отличать свой формат от старого. Практика: черновики анкеты показывает автосохранение не чаще раза в N секунд на подменяемых часах, именованные точки и откат к ним, лимит истории и снимок, сериализованный в строку с версией формата.
Связанные темы
- Практика: черновики анкеты — автосохранение, точки отката, лимит и сериализация с версией
- Прототип (Prototype) — тоже копия объекта, но ради нового экземпляра, а не возврата к старому состоянию