Состояние

Состояние (State) — это поведенческий паттерн проектирования, который позволяет объекту менять своё поведение в зависимости от внутреннего статуса так, будто у него подменяется класс, — при этом каждый статус и его переходы описаны в собственном классе, а не условиями внутри объекта.
Проблема
Заказ-наряд проходит через статусы: новый, в работе, готов, выдан, отменён. Поведение методов зависит от текущего статуса, и это выражено проверками в начале каждого метода:
final class WorkOrder
{
private string $status = 'new';
public function start(): void
{
if ($this->status !== 'new') {
throw new \RuntimeException("Нельзя начать работу из статуса {$this->status}");
}
$this->status = 'in_progress';
}
public function complete(): void
{
if ($this->status !== 'in_progress') {
throw new \RuntimeException("Нельзя завершить работу из статуса {$this->status}");
}
$this->status = 'done';
}
public function issue(): void
{
if ($this->status !== 'done') {
throw new \RuntimeException("Нельзя выдать автомобиль из статуса {$this->status}");
}
$this->status = 'issued';
}
public function cancel(): void
{
if ($this->status === 'issued') {
throw new \RuntimeException('Выданный заказ-наряд отменить нельзя');
}
$this->status = 'cancelled';
}
}Появляется требование: отмена должна вести себя по-разному в зависимости от того, из какого статуса она вызвана. Если заказ-наряд в работе — при отмене нужно вернуть на склад зарезервированные запчасти. Если он уже готов — отмена требует подтверждения от начальника сервиса. Каждое из этих правил превращается в ещё одну ветку внутри cancel(), и метод, отвечавший всего лишь за смену статуса, начинает разбираться в логистике склада и правах согласования.
Хуже то, что все методы WorkOrder теперь вынуждены знать обо всех статусах сразу, даже о тех, что к ним отношения не имеют, — а появление нового статуса, скажем «ожидает запчасти», означает правку elseif-цепочки в каждом из них по очереди. Легко поправить одну и забыть про другую.
Решение
Состояние выносит каждый статус в отдельный класс с общим интерфейсом. WorkOrder перестаёт хранить строку и держит вместо неё объект текущего состояния, которому делегирует все вызовы:
interface WorkOrderState
{
public function start(WorkOrder $order): void;
public function complete(WorkOrder $order): void;
public function issue(WorkOrder $order): void;
public function cancel(WorkOrder $order): void;
public function canEdit(): bool;
}Базовый класс запрещает по умолчанию все переходы — конкретным состояниям останется переопределить только те, что действительно возможны из них:
abstract class BaseWorkOrderState implements WorkOrderState
{
public function start(WorkOrder $order): void { $this->reject('начать работу'); }
public function complete(WorkOrder $order): void { $this->reject('завершить работу'); }
public function issue(WorkOrder $order): void { $this->reject('выдать автомобиль'); }
public function cancel(WorkOrder $order): void { $this->reject('отменить заказ-наряд'); }
public function canEdit(): bool { return false; }
private function reject(string $action): void
{
throw new \RuntimeException(sprintf('Нельзя %s в статусе «%s»', $action, static::class));
}
}Каждое конкретное состояние знает только свои собственные переходы:
final class NewState extends BaseWorkOrderState
{
public function start(WorkOrder $order): void
{
$order->setState(new InProgressState());
}
public function cancel(WorkOrder $order): void
{
$order->setState(new CancelledState());
}
public function canEdit(): bool { return true; }
}
final class InProgressState extends BaseWorkOrderState
{
public function complete(WorkOrder $order): void
{
$order->setState(new DoneState());
}
public function cancel(WorkOrder $order): void
{
$order->releaseReservedParts(); // отмена в работе — возвращаем запчасти на склад
$order->setState(new CancelledState());
}
}
final class DoneState extends BaseWorkOrderState
{
public function issue(WorkOrder $order): void
{
$order->setState(new IssuedState());
}
public function cancel(WorkOrder $order): void
{
$order->requireSupervisorApproval(); // готовый заказ-наряд отменяется только с подтверждением
$order->setState(new CancelledState());
}
}
final class IssuedState extends BaseWorkOrderState
{
// выданный автомобиль — конечное состояние, отмена остаётся запрещённой по умолчанию
}
final class CancelledState extends BaseWorkOrderState
{
// отменённый заказ-наряд — тоже конечное состояние
}WorkOrder сводится к делегированию:
final class WorkOrder
{
private WorkOrderState $state;
public function __construct()
{
$this->state = new NewState();
}
public function setState(WorkOrderState $state): void
{
$this->state = $state;
}
public function start(): void { $this->state->start($this); }
public function complete(): void { $this->state->complete($this); }
public function issue(): void { $this->state->issue($this); }
public function cancel(): void { $this->state->cancel($this); }
public function canEdit(): bool { return $this->state->canEdit(); }
public function releaseReservedParts(): void { /* ... */ }
public function requireSupervisorApproval(): void { /* ... */ }
}Правило «отмена по-разному зависит от статуса» теперь лежит ровно там, где ему место, — в InProgressState::cancel() и DoneState::cancel(), а не в виде очередной ветки в разрастающемся методе. Новый статус «ожидает запчасти» — это новый класс, а не правка каждого существующего метода WorkOrder.
Из чего состоит паттерн
- Контекст (
WorkOrder) — хранит ссылку на текущее состояние и делегирует ему вызовы. - Состояние (
WorkOrderState) — общий интерфейс поведения, специфичного для каждого статуса. - Конкретное состояние (
NewState,InProgressState,DoneState,IssuedState,CancelledState) — реализует поведение своего статуса и само решает, в какое состояние перейти дальше.
Состояние vs Стратегия
В коде оба паттерна выглядят почти одинаково — интерфейс и семейство реализующих его классов, подключаемых к контексту через композицию, а не наследование. Разница в том, кто выбирает реализацию и меняется ли она сама. Стратегию клиент выбирает один раз и обычно не переключает на лету — например, стратегию расчёта цены для конкретного счёта выбирают при его создании и больше не трогают. Состояние переключает себя само: в примере выше WorkOrder ни разу не вызывает setState() напрямую — это делает каждое конкретное состояние в тот момент, когда его собственный метод решает, что переход должен произойти. Переходы разворачиваются автоматически по ходу вызовов методов объекта, а не управляются извне.
| Состояние | Стратегия | |
|---|---|---|
| Кто выбирает реализацию | Текущее состояние само переключает контекст на следующее | Клиент выбирает и обычно не меняет её на лету |
| Знают ли реализации друг о друге | Да — состояние явно переключает на конкретное следующее состояние | Нет — стратегии независимы друг от друга |
| Задача | Смоделировать конечный автомат — поведение, зависящее от истории объекта | Подменить один взаимозаменяемый алгоритм другим |
Когда применять
- Поведение объекта существенно меняется в зависимости от его внутреннего статуса, и это выражается разрастающимися условиями по полю статуса в каждом методе.
- Переходы между статусами подчиняются чётким правилам — не из любого статуса можно перейти в любой, — и эти правила стоит хранить рядом с самим статусом, а не размазывать по контексту.
- Статусов и правил перехода между ними достаточно много, чтобы охватить их одним взглядом на код контекста стало невозможно.
Плюсы и минусы
Состояние собирает правила конкретного статуса в одном классе, а не размазывает их по всем методам контекста. Новый статус добавляется новым классом, без правки существующих методов, а невозможные переходы просто не реализуются в классе состояния, вместо того чтобы проверяться условием на каждый вызов.
Минус — для объекта с двумя-тремя простыми статусами это явный оверинжиниринг: условие по строковому статусу читается быстрее, чем прыжки между несколькими классами состояний. Количество классов растёт линейно с числом статусов, и для system со множеством мелких статусов это может оказаться больше кода, чем было изначально.
Итог
Состояние нужно, когда поведение объекта определяется его собственной историей переходов, а не просто параметром, который выбрал вызывающий код. Оно превращает конечный автомат, размазанный по условиям в каждом методе, в набор классов, каждый из которых отвечает за один статус и сам знает, куда из него можно перейти дальше.