Алгоритмы

Command: инкапсуляция действия как объекта (undo/redo)

Введение

Большинство интерактивных приложений (текстовые редакторы, графические редакторы) нуждаются в функциональности отмены/повтора действий (undo/redo) — Command решает эту задачу, превращая каждое отдельное действие пользователя в самостоятельный объект, который можно сохранить в истории, выполнить, и, что особенно важно, отменить позже.

Концепция

Command инкапсулирует запрос на выполнение определённого действия (включая всю информацию, необходимую для его выполнения) как самостоятельный объект с единым интерфейсом (обычно — методы execute() и undo()) — вместо непосредственного, прямого вызова метода, выполняющего действие, код создаёт объект команды и передаёт его инициатору выполнения (часто называемому Invoker), который вызывает execute() для реального выполнения и может сохранить выполненную команду в истории для последующего, возможного вызова undo(), отменяющего это конкретное действие.

Пример кода

#include <iostream>
#include <memory>
#include <vector>
#include <string>

class Document
{
public:
    void insertText(const std::string &text, size_t position) { m_content.insert(position, text); }
    void deleteText(size_t position, size_t length) { m_content.erase(position, length); }
    const std::string &content() const { return m_content; }

private:
    std::string m_content;
};

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

class InsertTextCommand : public Command // КОНКРЕТНАЯ команда — хранит ВСЮ информацию для execute()/undo()
{
public:
    InsertTextCommand(Document &doc, std::string text, size_t pos)
        : m_document(doc), m_text(std::move(text)), m_position(pos) {}

    void execute() override { m_document.insertText(m_text, m_position); }
    void undo() override { m_document.deleteText(m_position, m_text.length()); } // ОБРАТНАЯ операция

private:
    Document &m_document;
    std::string m_text;
    size_t m_position;
};

class CommandHistory // Invoker — выполняет команды И хранит историю для undo/redo
{
public:
    void executeCommand(std::unique_ptr<Command> command)
    {
        command->execute();
        m_history.push_back(std::move(command)); // сохранение для ВОЗМОЖНОГО последующего undo
    }

    void undoLast()
    {
        if (m_history.empty()) return;
        m_history.back()->undo();
        m_history.pop_back();
    }

private:
    std::vector<std::unique_ptr<Command>> m_history;
};

int main()
{
    Document doc;
    CommandHistory history;

    history.executeCommand(std::make_unique<InsertTextCommand>(doc, "Привет", 0));
    history.executeCommand(std::make_unique<InsertTextCommand>(doc, ", мир", 6));
    std::cout << doc.content() << std::endl; // "Привет, мир"

    history.undoLast(); // отмена ВТОРОЙ команды
    std::cout << doc.content() << std::endl; // "Привет" — ", мир" корректно отменено

    return 0;
}

Пояснения к коду

InsertTextCommand, хранящий m_document, m_text, m_position как поля, показывает суть инкапсуляции действия — вся информация, необходимая и для выполнения (execute()), и для последующей отмены (undo()) этого конкретного действия, хранится непосредственно в самом объекте команды, что позволяет CommandHistory вызвать undo() для ЛЮБОЙ ранее выполненной команды значительно позже, без необходимости знать детали конкретного действия — достаточно общего интерфейса Command. Метод undo(), вызывающий deleteText (операцию, обратную выполненной в execute()), показывает ключевой принцип реализации отмены — каждая конкретная команда должна явно реализовать корректную, симметричную обратную операцию, и CommandHistory::undoLast() показывает простейшую реализацию истории как стека (алгоритмического цикла) — последняя выполненная команда отменяется первой (LIFO-порядок), что соответствует естественному, интуитивно ожидаемому поведению undo.

Подводные камни

  • Реализация undo(), не являющегося точной, корректной обратной операцией относительно соответствующего execute() — если обратная операция реализована неточно (например, удаляет неверное количество символов или с неверной позиции), последовательность execute/undo приведёт к документу, отличающемуся от исходного, до выполнения команды, состояния, что является серьёзной, подрывающей доверие пользователя ошибкой для функциональности отмены, и тщательное тестирование (основной цикл статей про тестирование) пары execute/undo для каждой конкретной команды особенно важно именно для этого паттерна.
  • Неограниченный рост истории команд без какого-либо предела, особенно для долгоработающих приложений с интенсивным использованием (например, текстовый редактор, открытый много часов с непрерывным редактированием) — каждая сохранённая команда занимает память, и без явного ограничения максимального размера истории (с удалением самых старых команд при превышении этого предела) история может неограниченно расти, потребляя всё больше памяти со временем без видимой пользователю причины.
  • Сложность реализации undo() для команд, чьё выполнение зависит от состояния, изменившегося между моментом execute() и моментом последующего undo() (например, удаление текста, который пользователь впоследствии отредактировал другими, не связанными командами в той же позиции) — для таких случаев простое, «наивное» хранение лишь параметров исходной операции может быть недостаточным для корректной отмены, и более надёжная реализация может потребовать сохранения дополнительного, более полного снимка релевантного состояния на момент выполнения (что концептуально пересекается с паттерном Memento, не входящим в данный курс статей, но решающим связанную задачу сохранения снимков состояния).
  • Игнорирование того, что некоторые действия принципиально необратимы или крайне сложны для корректной отмены (отправка сетевого запроса с реальными, внешними побочными эффектами, удаление файла с диска без сохранения резервной копии) — для таких действий попытка реализовать полноценный, надёжный undo() может быть нецелесообразной или вовсе невозможной, и архитектура, основанная на Command, должна явно учитывать эту категорию необратимых действий, возможно, предупреждая пользователя о невозможности отмены перед реальным выполнением такого, принципиально необратимого действия.