Перейти к содержимому
Фёдор Башинский

Состояние

6 min read
Обложка статьи «Состояние»

Состояние (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 со множеством мелких статусов это может оказаться больше кода, чем было изначально.

Итог

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