Как шаблон «Команда» может сэкономить вам много времени

В свободное время я работаю над универсальным 2D-редактором игрового уровня. В течение некоторого времени я пренебрегал фундаментальной функцией, которая должна быть в любом редакторе, — отменой и повтором действий. Как и следовало ожидать, внедрение такой функции в существующий проект может оказаться ужасным опытом. Вот почему я хочу показать вам, как этого избежать, правильно используя проверенный в бою шаблон проектирования — команду.

Остановитесь на некоторое время и подумайте о последней интерактивной программе, над которой вы работали. Подумайте о каждом месте, изменившем состояние системы, которое пользователь может захотеть вернуть с помощью быстрого нажатия Ctrl+Z. В моем редакторе пользователь размещает плитки, элементы и триггеры сценариев. Они могут изменять многочисленные атрибуты этих объектов, перемещать их или удалять.

Существуют различия в рабочем процессе для плиток и рабочем процессе для элементов, поэтому каждый тип объекта реализуется отдельным классом. В довершение ко всему, существуют также глобальные операции, такие как изменение размера уровня, которые влияют на все типы объектов одновременно.

Таким образом, в коде есть десятки мест, которые изменяют состояние системы. Если бы я использовал наивный подход к реализации истории, я бы, скорее всего, распространил бы какой-то обратный вызов или интерфейс, который я бы сигнализировал перед внесением изменений (какой-то наблюдатель), чтобы он делал снимок состояния системы (возможно, используя метод Шаблон Memento) и сохраните этот снимок в буфере, откуда его можно будет «легко» применить повторно, отменив действие.

Это неоптимальный подход по двум причинам:

  • Поскольку вы знаете только, что произойдет изменение, но не знаете, какого рода изменение произойдет, вам нужно сделать снимок всего состояния. Это потенциально дорого как с точки зрения производительности, так и с точки зрения потребления памяти.
  • Если кто-то представляет новое место, где могут произойти изменения, ему необходимо не забыть уведомить об этом систему создания снимков. Таким образом, потенциальная возможность для ошибок.

Шаблон команды

Решение этой проблемы называется шаблоном команды. Объект команды представляет собой операцию (например, удаление элемента или вставку плитки), которую можно выполнить в любое время. Объект Command также является обратимым — вы можете создать инверсию любой команды, которая затем представляет собой операцию отмены:

class CommandInterface {
public:
  ~CommandInterface() = default;
  virtual void execute() = 0;
};

class UndoableCommandInterface : public CommandInterface {
public:
  virtual Box<CommandInterface> getInverse() const = 0;
};

ПРИМЕЧАНИЕ. Во фрагментах кода используется Box вместо std::unique_ptr и Rc вместо простой ссылки или std::shared_ptr. Мне больше нравится это имя Rust.

Вы можете реализовать шаблон с помощью всего одного класса, имеющего методы execute и getInverse. Я разбил его на две части, чтобы API не позволял вам (даже косвенно) вызывать цепочку из getInverse()→getInverse()→getInverse() вызовов.

Когда код создает команду, она помещается в очередь обработки, откуда ее можно получить в любой момент, выполнить и переместить в буфер истории. Хотя вы можете экспериментировать с нелинейными историями, чаще всего используется линейный буфер:

class CommandHistory {
public:
  void add(const Rc<UndoableCommandInterface>& command)
  {
    if (index < commands.size())
    {
      commands.erase(commands.begin() + index, commands.end());
    }

    commands.push_back(command);
    ++index;
  }

  void undo() {
    if (index == 0) return;
    --index;
    commands[index]->getInverse()->exec();
  }

  void redo() {
    if (index == commands.size()) return;
    commands[index]->exec();
    ++index;
  }

private:
  std::vector<Rc<UndoableCommandInterface>> commands;
  std::size_t index;
};

Использование шаблона

Полный рабочий процесс с командой может выглядеть так:

CommandHistory history;
std::queue<Box<UndoableCommandInterface>> queue;

// Insert a command into the queue
queue.push_back(Box<DeleteItemCommand>(items, indexToDelete));

// Commands are processed at some other place in the code
for (auto&& command : queue)
{
  command->execute();
  history.add(command);
}
queue.clear();

// Commands can be undoed and redoed
history.undo();
history.redo();
  1. Пользователь запускает операцию, которая изменяет систему. Объект команды создается и помещается в какую-то очередь обработки:
  2. Очередь обрабатывается в конце кадра (итеративная система) или сразу (реактивная система).
  3. Команда выполняется и сохраняется в буфере истории.
  4. Когда операция отменяется, команда извлекается из истории, инвертируется и выполняется.

Рекомендации по реализации

В моем конкретном случае у меня не было другого выбора, кроме как провести рефакторинг всего связанного кода, в основном извлекая каждую логику модификации в объект Command. Если бы я использовал этот шаблон с самого начала, он сэкономил бы мне много времени (а на предприятии время равно деньгам).

Шаблон позволяет оптимизировать изменения состояния — если вы можете легко создать инверсию своей команды, вы можете это сделать. Если инверсию выполнить будет сложно, то ничто не мешает вам использовать Memento для резервного копирования предыдущего состояния.

Этот шаблон также не является специфичным для ООП-программирования — у вас может быть фабричный метод, который будет возвращать пару обратных вызовов: один для нормального выполнения, другой для отмены, если вы увлекаетесь функциональным программированием. Вам не обязательно использовать наследование, если ваш язык допускает шаблонный код, не ищите ничего, кроме std::variant в стандартной библиотеке C++.

Хотя использование команды приводит к увеличению количества классов (как это происходит с каждым шаблоном проектирования ООП), эти новые классы придерживаются SRP (каждый представляет собой одно преобразование) и разделяют задачи — преобразования отделены от данных и от кода, который запускает трансформация. В моем редакторе уровней есть код, который отслеживает сочетания клавиш, кнопки и жесты мыши, такие как перетаскивание. Каждый из них приводит к созданию команды, которая, в свою очередь, преобразует базовые данные.

Краткое содержание

Начиная новый проект, который интерактивно преобразует данные, по крайней мере, подумайте о том, чтобы сразу начать с шаблона «Команда». Простое его использование означает, что вы можете реализовать отмену/повтор действия в любой момент времени, а также другие функции, такие как проверка того, сохранил ли пользователь изменения перед выходом из программы. Еще одним преимуществом является помощь в правильной архитектуре кода и разделении задач.

Этот шаблон также полезен в любой момент, когда вам нужно отложить обработку операции на более поздний срок или когда вам нужно относиться к операциям как к данным (например, когда вы хотите сохранить их для последующего воспроизведения или когда вам это нужно ( де)сериализовать его и отправить по сети, как в случае с RPC).