handbookНаписать

patterns/behavioral/state/README.md · тема · 4 языка · 27 тестов

Состояние (State) ​

Объект меняет поведение вместе со своим состоянием: каждое состояние — отдельный объект, и вместо if (state === ...) в каждом методе контекст просто передаёт вызов текущему состоянию.

Проблема ​

У аудиоплеера три кнопки и таймер: «play», «pause», «stop» и тик, который двигает позицию трека. Что делает кнопка, зависит от режима: «play» в остановленном плеере начинает трек с начала, в паузе — продолжает с того же места, во время воспроизведения — ничего не делает. Тик двигает позицию только при воспроизведении.

Если держать режим в поле status у AudioPlayer, каждый метод превращается в switch ($this->status), и одни и те же три ветки повторяются четыре раза. Новый режим (например, «буферизация») — правка всех методов сразу, и легко забыть одну ветку.

Решение ​

Каждый режим становится отдельным объектом с общим контрактом PlayerState: у него есть все кнопки, и каждая возвращает следующее состояние. Состояния Stopped, Playing и Paused знают только свои правила: Paused на «play» отдаёт Playing с той же позиции, Playing на «stop» отдаёт Stopped.

AudioPlayer хранит текущее состояние и на каждую кнопку делает одно и то же: state = state.play(). Ни одного if по режиму в контексте не остаётся, а таблица «режим x кнопка» оказывается разложена по классам: строка таблицы — класс, клетка — метод.

Структура ​

AudioPlayer

-state: PlayerState

+play()

+pause()

+stop()

+tick(seconds)

+status() : string

+position() : int

«interface»

PlayerState

+name() : string

+position() : int

+play() : PlayerState

+pause() : PlayerState

+stop() : PlayerState

+tick(seconds) : PlayerState

Stopped

Playing

-at: int

Paused

-at: int

play

pause

play

stop

stop

Stopped

Playing

Paused

AudioPlayer — контекст: снаружи у него обычные кнопки, внутри каждая из них — одна строка делегирования. Позицию он не хранит: её знает состояние, потому что у остановленного плеера позиции нет вовсе, а у Playing и Paused она своя.

Как это собирается ​

AudioPlayer создаёт приложение плеера, а нажатия приходят из слоя ввода: медиаклавиши, кнопки на экране, разъём наушников. Слой ввода держит карту "команда -> кнопка плеера" и ничего не знает о режимах: событие unplug (выдернули наушники) он просто превращает в pause, как это делают телефоны. Решать, что значит пауза в текущем режиме, будет состояние.

В примере поток событий проигрывается по порядку, и после каждого видно режим и позицию. Повторная пауза ничего не меняет, play из паузы продолжает с 30-й секунды, stop сбрасывает позицию. Команды seek у плеера нет: слой ввода её не находит в карте и пропускает с пометкой, а не роняет плеер.

Сценарий целиком с выводом: пример на PHP, он же на Go, Python и TypeScript в панели кода.

Реализации ​

Состояния неизменяемы: вместо «переключи меня» каждое возвращает новое состояние или само себя, если кнопка в этом режиме ничего не делает. Данные, которые нужны только в части режимов, живут внутри них: Playing и Paused несут позицию at, Stopped — нет, поэтому «stop» сбрасывает позицию сам собой, без отдельной строки position = 0.

ЯзыкОсобенность
PHPinterface PlayerState и три класса; Playing и Paused — final readonly с позицией
GoСостояния — значения-структуры с методами, контекст меняет поле state у *AudioPlayer
PythonProtocol для контракта и @dataclass(frozen=True) для состояний; имя — ClassVar
TypeScriptСостояния — объектные литералы и фабрики playing(at), paused(at); классы не нужны

Запуск: make test T=patterns/behavioral/state

Плюсы и минусы ​

Тесты одинаковы во всех языках: последовательность нажатий строкой ("play tick pause tick", тик — 10 секунд) и ожидаемые режим и позиция. Шесть случаев показывают главное: одна и та же кнопка ведёт себя по-разному. «play» после паузы продолжает с 10 секунд, «pause» и тик в остановленном плеере ничего не меняют, «stop» обнуляет позицию.

  • ✅ Правила одного режима собраны в одном месте; чтобы понять, что делает Paused, не нужно читать весь плеер.
  • ✅ Новый режим — новый класс и правки только в тех состояниях, откуда в него можно попасть; контекст не меняется.
  • ✅ Недопустимые комбинации видны сразу: метод состояния либо переводит дальше, либо явно ничего не делает.
  • ❌ Число классов растёт с числом режимов, а методов в каждом — с числом событий.
  • ❌ Полную картину переходов приходится собирать по нескольким файлам; диаграмма или таблица в тесте становится обязательной документацией.
  • ❌ Состояния знают друг о друге (Paused создаёт Playing), это связность внутри семейства.

Когда не применять ​

  • Режима два и поведение отличается в одном месте — хватит bool и одного if.
  • Поведение одинаково, меняется только набор разрешённых переходов — проще таблица переходов (map «состояние -> событие -> состояние»), см. практику.
  • Состояние нужно хранить и показывать, а реакции на него нет — это просто поле-перечисление.

Состояние и Стратегия ​

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

Практический тест: если вызов метода может поменять объект, к которому идёт следующий вызов, — это Состояние. Если объект подставлен один раз при создании или по настройке — Стратегия.

Отличия между языками ​

  • PHP — классический вариант: интерфейс, final классы и readonly для состояний с данными. Альтернатива на enum с методами и match ($this) компактнее, но это уже таблица переходов в одном месте, а не состояния-классы.
  • Go — интерфейс с методами и состояния-значения: Playing{at: 10} копируется, а не делится, поэтому неизменяемость получается бесплатно. Методы без данных объявлены на получателе без имени (func (Stopped) Name()).
  • Python — Protocol описывает контракт структурно, состояния ничего не наследуют. Замороженные dataclass дают неизменяемость и сравнение по значению; name вынесен в ClassVar, чтобы не попасть в конструктор.
  • TypeScript — структурная типизация позволяет обойтись без классов: состояние — объект с нужными методами, а фабрика playing(at) замыканием держит позицию. Ссылки stopped и playing друг на друга работают, потому что вызываются только после загрузки модуля.

Практика ​

Учебный пример обходит вопросы, без которых State в проекте не живёт: что делать с запрещённым переходом, где выполнять действие на переходе, как восстановить состояние из строки в базе и как убедиться, что ни одна пара «состояние x событие» не забыта.

  • Практика: жизненный цикл заказа показывает заказ из шести состояний с ошибкой «нельзя X из Y», возвратом денег при отмене оплаченного заказа, историей переходов и тестом по всей таблице переходов, а в Go и TypeScript — ту же машину в виде таблицы для сравнения.
  • Практика: плата на производстве показывает обратную форму: четыре действия сканера одинаковы на всех этапах, а куда ведёт "передать дальше" или "вернуть на доработку", решает этап. Общее подсостояние "ждёт / в работе" держит базовый класс этапа, конечные этапы запрещают всё, а лимит доработок считается по истории переходов платы.

Связанные темы ​

© 2014-2026 Тимур ТурдыевСайт и справочник работают на VPS от AdminVPS