Перейти к содержимому
Фёдор Башинский

Снимок

5 min read
Обложка статьи «Снимок»

Снимок (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, кто способен удержать геттеры снимка от вызова снаружи оригинатора.