Снимок

Снимок (Memento) — это поведенческий паттерн проектирования, который позволяет сохранять и восстанавливать прошлое состояние объекта, не раскрывая его внутреннее устройство объекту, который эту историю хранит.
Проблема
Менеджер салона собирает комплектацию автомобиля для клиента в конфигураторе — выбирает двигатель, цвет, пакеты опций:
final class CarConfigurator
{
public string $engine = 'base';
public string $color = 'white';
/** @var string[] */
public array $packages = [];
}Клиент часто передумывает по ходу подбора, и менеджеру нужна кнопка «Отменить последние правки» — вернуться к состоянию, сохранённому чуть раньше. Простейшая реализация истории копирует значения полей CarConfigurator напрямую:
final class ConfiguratorHistory
{
/** @var array<array{engine:string,color:string,packages:string[]}> */
private array $snapshots = [];
public function save(CarConfigurator $configurator): void
{
$this->snapshots[] = [
'engine' => $configurator->engine,
'color' => $configurator->color,
'packages' => $configurator->packages,
];
}
public function restore(CarConfigurator $configurator): void
{
$snapshot = array_pop($this->snapshots);
if ($snapshot === null) {
return;
}
$configurator->engine = $snapshot['engine'];
$configurator->color = $snapshot['color'];
$configurator->packages = $snapshot['packages'];
}
}Это работает, но платит за это дорого. Все поля CarConfigurator объявлены публичными исключительно ради того, чтобы ConfiguratorHistory могла их прочитать и переписать, — инкапсуляция принесена в жертву механизму отмены. Появится новая опция — например, интерьер салона, — и придётся синхронно править два разных класса: добавлять поле в CarConfigurator и добавлять его же в структуру снимка внутри ConfiguratorHistory, хотя история изменений концептуально не должна знать, из чего вообще состоит конфигурация.
Решение
Снимок перекладывает создание и восстановление состояния на сам объект — только он и знает, из чего это состояние состоит. Хранитель истории работает со снимками как с непрозрачным значением, не заглядывая внутрь.
final class ConfiguratorMemento
{
public function __construct(
private readonly string $engine,
private readonly string $color,
private readonly array $packages,
) {}
// Геттеры технически публичны — PHP не умеет ограничивать доступ только
// для CarConfigurator, — но по соглашению их вызывает исключительно он.
public function getEngine(): string { return $this->engine; }
public function getColor(): string { return $this->color; }
public function getPackages(): array { return $this->packages; }
}CarConfigurator — оригинатор: поля снова приватные, а создавать и восстанавливать снимки умеет только он сам:
final class CarConfigurator
{
private string $engine = 'base';
private string $color = 'white';
/** @var string[] */
private array $packages = [];
public function setEngine(string $engine): void { $this->engine = $engine; }
public function setColor(string $color): void { $this->color = $color; }
public function addPackage(string $package): void { $this->packages[] = $package; }
public function save(): ConfiguratorMemento
{
return new ConfiguratorMemento($this->engine, $this->color, $this->packages);
}
public function restore(ConfiguratorMemento $memento): void
{
$this->engine = $memento->getEngine();
$this->color = $memento->getColor();
$this->packages = $memento->getPackages();
}
}Хранитель истории теперь просто складывает объекты ConfiguratorMemento в массив, не зная и не заботясь о том, что внутри:
final class ConfiguratorHistory
{
/** @var ConfiguratorMemento[] */
private array $checkpoints = [];
public function checkpoint(CarConfigurator $configurator): void
{
$this->checkpoints[] = $configurator->save();
}
public function rollbackTo(CarConfigurator $configurator, int $index): void
{
$configurator->restore($this->checkpoints[$index]);
}
}Использование выглядит так же просто, как и раньше:
$configurator = new CarConfigurator();
$history = new ConfiguratorHistory();
$configurator->setEngine('2.0T');
$configurator->setColor('black');
$history->checkpoint($configurator); // точка A
$configurator->addPackage('sport');
$configurator->setColor('red');
// клиент передумал — красный не подошёл
$history->rollbackTo($configurator, 0); // вернулись к точке A: 2.0T, чёрный, без пакетовПоявится новая опция вроде интерьера — правится только CarConfigurator и ConfiguratorMemento. ConfiguratorHistory не меняется вообще, потому что никогда не знал, из каких полей состоит снимок.
Из чего состоит паттерн
- Оригинатор (
CarConfigurator) — объект, чьё состояние сохраняется; единственный, кто умеет создавать и восстанавливать собственные снимки. - Снимок (
ConfiguratorMemento) — неизменяемый слепок состояния оригинатора на момент создания. - Хранитель (
ConfiguratorHistory) — хранит снимки и решает, к какому из них когда вернуться, но не заглядывает внутрь них. - Клиент — инициирует создание точек сохранения и откаты, работая и с оригинатором, и с хранителем.
Снимок vs Команда
Оба паттерна часто применяются вместе для реализации отмены действий, но фиксируют разное. Команда (см. отдельную статью) фиксирует само действие и знает, как его обратить, — RaiseLiftCommand::undo() в примере из статьи про Команду прицельно знает, что подъём отменяется спуском. Снимок фиксирует не действие, а состояние целиком, каким оно было до изменений, и восстанавливает его напрямую, вообще не интересуясь, какое именно действие к этому состоянию привело.
| Снимок | Команда | |
|---|---|---|
| Что фиксирует | Состояние объекта целиком на момент сохранения | Отдельное действие и способ его обратить |
| Как происходит отмена | Прямое восстановление сохранённого состояния | Повторный вызов, обратный уже выполненному действию |
| Когда удобнее | Много мелких изменений подряд, откат нужен к точке в истории, а не к шагу | Каждое действие атомарно и само знает, как себя обратить |
Когда пользователь двадцать раз подряд поменял цвет и пакеты опций, откатиться к сохранённому снимку проще, чем прогонять двадцать вызовов undo() в обратном порядке, — Снимок подходит для отката к точке, Команда — для отмены конкретных шагов.
Когда применять
- Нужно сохранять и восстанавливать полное состояние объекта (отмена, откат к чекпоинту) без нарушения его инкапсуляции — поля не становятся публичными ради истории изменений.
- Хранителю истории не нужно знать формат состояния — при изменении полей оригинатора хранитель не приходится трогать.
- Нужна точка возврата в прошлое, а не пошаговая отмена каждого отдельного изменения по порядку.
Плюсы и минусы
Снимок сохраняет инкапсуляцию объекта — детали его состояния не утекают в хранителя истории, — а сам хранитель работает с любым числом снимков одинаково, не зная их внутреннего устройства и не завися от того, как меняется оригинатор.
Минус — снимки состояния целиком могут оказаться тяжёлыми по памяти, если объект большой или снимков накапливается много. В PHP к тому же нет настоящей защиты доступа геттеров снимка только для оригинатора — приходится полагаться на соглашение между разработчиками, а не на возможности языка.
Итог
Снимок нужен, когда важно уметь вернуть объект к прежнему состоянию целиком, не заставляя ни сам объект раскрывать внутренности ради истории изменений, ни хранителя истории — знать, что именно он хранит. Это стоит памяти на каждый снимок и дисциплины разработчика — единственного в PHP, кто способен удержать геттеры снимка от вызова снаружи оригинатора.