Команда (Command)
Превращает запрос в объект: его можно сохранить, передать, выполнить позже и отменить, а тот, кто его запускает, не знает, что именно он делает.
Проблема
Пульт умного дома: кнопки включают свет, делают теплее, делают холоднее. Если пульт сам вызывает light.toggle() и thermostat.adjust(+1), он знает обо всех устройствах дома, а каждая новая кнопка правит его код. Хуже с отменой: чтобы откатить последнее нажатие, пульт должен помнить, какое устройство он трогал и как вернуть его назад, то есть ещё раз повторить ту же развилку if button == ..., только в обратную сторону.
Решение
Каждое действие становится объектом с общим интерфейсом — командой Command с двумя методами: execute() и undo(). Команда держит ссылку на получателя (Light, Thermostat), который делает настоящую работу, и параметры вызова (delta).
Вызывающий Remote знает только интерфейс. Кнопка привязывается к любой команде через bind, нажатие вызывает execute() и кладёт команду в историю, undo() снимает последнюю и откатывает её. Новое устройство — новый получатель и новая команда, пульт не меняется.
Команда нужна, когда запрос надо отделить от момента и места исполнения: отменить, повторить, поставить в очередь, записать в журнал. Если нужно просто вызвать метод, хватит прямого вызова.
Структура
Интерфейс команды — два метода без аргументов. Всё, что нужно для выполнения, команда получила при создании: получателя и параметры. Поэтому вызывающему нечего ей передавать, и он одинаково обращается с любой командой.
Как это собирается
Пульт и команды собирает приложение умного дома при запуске. Получатели (Light, Thermostat) - это устройства из списка дома, а раскладка кнопок приходит из настроек пользователя: кнопка A - свет, B - теплее на 2, C - холоднее на 1. По названию действия из настроек создаётся нужная команда с готовым получателем и параметром, и Remote получает её через bind. Сам пульт не знает, что такое свет и градусы.
Дальше нажатия идут с устройства. Нажатие на пустую кнопку D - не авария: UnknownButton ловит обработчик нажатий и показывает "not bound", история при этом не меняется. Отмена идёт по истории назад, а на пустой истории undo() честно возвращает false.
Сценарий целиком с выводом: пример на PHP, он же на Go, Python и TypeScript в панели кода.
Реализации
Команды здесь обратимы сами по себе: повторный toggle() возвращает свет, adjust(-delta) отменяет adjust(delta). Они не хранят состояния, кроме получателя и параметров, поэтому одну и ту же команду можно привязать к кнопке и нажимать сколько угодно раз: в истории окажется один объект несколько раз, и каждый undo() откатит одно нажатие. Если отмене нужно помнить «что было до» (удалённый текст, прежнее значение), команду создают заново на каждое действие: это показывает практика.
| Язык | Особенность |
|---|---|
| PHP | Интерфейс и final readonly команды; ненайденная кнопка — ?? throw прямо в выражении |
| Go | Команды — структуры-значения с указателем на получателя; ошибка кнопки — сентинел ErrUnknownButton |
| Python | Protocol вместо интерфейса, команды — frozen dataclass, получатели — обычные dataclass |
| TypeScript | Интерфейс и классы с private readonly параметрами; история — массив, pop() и ?. для пустой |
Запуск: make test T=patterns/behavioral/command
Плюсы и минусы
Тесты одинаковы во всех языках: к кнопкам light, warmer (+1) и cooler (-2) привязаны команды, сценарий нажатий проверяется по состоянию получателей. Нажатие доходит до нужного устройства; одна кнопка дважды даёт 23 градуса; undo откатывает последнее нажатие и шаг за шагом возвращает дом к исходным 21 градусу и выключенному свету; undo на пустой истории ничего не делает. Отдельный тест — нажатие пустой кнопки: ошибка с её именем.
- ✅ Вызывающий не зависит от получателей: новое действие — новый класс, пульт не меняется.
- ✅ Запрос — данные: его можно хранить в истории, ставить в очередь, писать в журнал, повторять.
- ✅ Отмена и повтор строятся поверх общего интерфейса, а не развилкой по видам действий.
- ❌ На каждое действие — класс; для простых вызовов это лишний слой.
- ❌ Отмена корректна, только если команда умеет вернуть ровно то состояние, что было. Обратная операция работает не всегда (ограничения, побочные эффекты), тогда команде приходится хранить снимок.
- ❌ История держит ссылки на получателей и данные команд: на длинной сессии это память.
Когда не применять
- Действие одно и отменять его не нужно: вызовите метод напрямую.
- Язык даёт функции первого класса, а нужен только отложенный вызов без отмены: хватит замыкания или колбэка.
- Отменяемое состояние проще целиком сохранить и восстановить (небольшой объект настроек): снимок состояния (Memento) проще пары
execute/undoдля каждого действия.
Отличия между языками
- PHP — классический вариант GoF: интерфейс,
final readonlyкоманды, массив истории сarray_pop. Без отмены команду можно было бы заменитьClosure, но пара «выполнить и отменить» требует объекта. - Go — интерфейс с двумя методами и команды-значения, которые держат указатель на получателя: копировать их дёшево, а изменения уходят в устройство. Для команды без отмены в Go обычно хватает
func(); пара функций в структуре{Do, Undo func()}— тоже рабочая идиома. - Python —
Protocolпроверяет форму без наследования;frozen=Trueу команд подчёркивает, что они не меняются, меняются только получатели. Для отложенного вызова без отмены идиоматичнее функция илиfunctools.partial. - TypeScript — структурная типизация: любой объект с
executeиundoужеCommand, поэтому команду можно собрать литералом{ execute: () => ..., undo: () => ... }. Классы оставлены, чтобы роли было видно в коде.
Практика
- Учебные команды обратимы без памяти: повторный
toggleвозвращает свет. В редакторе документа так не выйдет: чтобы отменить удаление, команда должна запомнить удалённый текст, а история должна уметь не толькоundo, но иredo, обрезать ветку повтора и склеивать набранные подряд символы в один шаг. Практика: редактор с undo/redo показывает историю с курсором, слияние команд и правило «неудачная команда в историю не попадает». - Учебный получатель под рукой и отвечает сразу. Прибор учёта на другом конце радиоканала спит и теряет кадры, поэтому команду ставят в очередь и ждут от него ответа. Практика: команды прибору учёта по радио показывает очередь с одной командой в эфире, подтверждение по ожидаемому ответу, повтор по таймауту не больше N раз и отмену команды, которая ещё не ушла.
Связанные темы
- Практика: редактор с undo/redo — история с курсором, обрезка redo, склейка набора, неудачные команды вне истории
- Практика: команды прибору учёта по радио - очередь отправки, подтверждение от прибора, повтор по таймауту, отмена неотправленной команды
- Стратегия (Strategy) — тоже объект с одним поведением, но стратегия отвечает «как сделать», а команда — «что сделать и как это отменить»
- Компоновщик (Composite) — макрокоманда из нескольких команд с общим
executeи отменой в обратном порядке - Декоратор (Decorator) — обёртка над командой добавляет журнал или проверку прав, не меняя её