Состояние (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 — контекст: снаружи у него обычные кнопки, внутри каждая из них — одна строка делегирования. Позицию он не хранит: её знает состояние, потому что у остановленного плеера позиции нет вовсе, а у Playing и Paused она своя.
Как это собирается
AudioPlayer создаёт приложение плеера, а нажатия приходят из слоя ввода: медиаклавиши, кнопки на экране, разъём наушников. Слой ввода держит карту "команда -> кнопка плеера" и ничего не знает о режимах: событие unplug (выдернули наушники) он просто превращает в pause, как это делают телефоны. Решать, что значит пауза в текущем режиме, будет состояние.
В примере поток событий проигрывается по порядку, и после каждого видно режим и позицию. Повторная пауза ничего не меняет, play из паузы продолжает с 30-й секунды, stop сбрасывает позицию. Команды seek у плеера нет: слой ввода её не находит в карте и пропускает с пометкой, а не роняет плеер.
Сценарий целиком с выводом: пример на PHP, он же на Go, Python и TypeScript в панели кода.
Реализации
Состояния неизменяемы: вместо «переключи меня» каждое возвращает новое состояние или само себя, если кнопка в этом режиме ничего не делает. Данные, которые нужны только в части режимов, живут внутри них: Playing и Paused несут позицию at, Stopped — нет, поэтому «stop» сбрасывает позицию сам собой, без отдельной строки position = 0.
| Язык | Особенность |
|---|---|
| PHP | interface PlayerState и три класса; Playing и Paused — final readonly с позицией |
| Go | Состояния — значения-структуры с методами, контекст меняет поле state у *AudioPlayer |
| Python | Protocol для контракта и @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 — ту же машину в виде таблицы для сравнения.
- Практика: плата на производстве показывает обратную форму: четыре действия сканера одинаковы на всех этапах, а куда ведёт "передать дальше" или "вернуть на доработку", решает этап. Общее подсостояние "ждёт / в работе" держит базовый класс этапа, конечные этапы запрещают всё, а лимит доработок считается по истории переходов платы.
Связанные темы
- Стратегия — та же схема делегирования, но объект выбирают снаружи
- Практика: жизненный цикл заказа — переходы, запреты, действия и история
- Практика: плата на производстве - общие действия сканера, вложенное "ждёт / в работе" и правило по истории