Представьте объект «заказ» с тремя булевыми полями:

public class Order {
    private boolean isPaid;
    private boolean isShipped;
    private boolean isCancelled;
}

Поначалу удобно: оплатили — ставим isPaid = true, отправили — isShipped = true. Но флаги множатся. Три булевых поля дают восемь комбинаций, и часть из них — бессмыслица. Что значит isPaid = false, isShipped = true — отправили неоплаченный заказ? А isCancelled = true, isShipped = true — отменённый, но уже уехавший? Такие состояния не должны существовать, но код их разрешает: ничто не мешает выставить любую комбинацию.

Дальше хуже. Проверки расползаются по всему коду:

if (order.isPaid() && !order.isShipped() && !order.isCancelled()) {
    // можно отправлять
}
if (!order.isCancelled() && (order.isPaid() || order.isShipped())) {
    // нельзя отменять? или можно? уже не помню
}

С каждым новым правилом эти if растут, дублируются и начинают противоречить друг другу. Проблема в том, что настоящего понятия «состояние заказа» в коде нет — оно размазано по флагам, и целостность держится только на дисциплине программиста. Конечные автоматы решают ровно это: делают состояние явным, а недопустимые переходы — невозможными.

Что такое конечный автомат

Конечный автомат (или стейт-машина, от англ. state machine) — это модель, в которой объект в каждый момент находится ровно в одном из заранее перечисленных состояний и переходит между ними по чётким правилам. Звучит абстрактно, но вы пользуетесь автоматами каждый день.

Возьмём турникет в метро. У него всего два состояния: «закрыт» и «открыт». В закрытом он ждёт оплаты; приложили карту — перешёл в «открыт»; прошли — вернулся в «закрыт». Толкать закрытый турникет бесполезно: он не реагирует на «толчок», только на «оплату». Это и есть суть автомата — на каждое состояние разрешён свой набор действий, остальные игнорируются.

Или светофор: зелёный → жёлтый → красный → зелёный. Порядок жёсткий, светофор не прыгает с зелёного сразу на красный.

Разберём словарь автомата на этих примерах:

  • Состояние (state) — положение, в котором объект сейчас находится. У турникета — «закрыт» / «открыт». У заказа — «новый», «оплачен», «отправлен».
  • Событие (event) — то, что происходит снаружи и может запустить переход: «приложили карту», «оплатили заказ», «нажали отмену».
  • Переход (transition) — смена одного состояния на другое в ответ на событие. «Закрыт» + событие «оплата» → «открыт».
  • Начальное состояние — то, с которого объект стартует. У заказа — «новый».
  • Конечные состояния — те, из которых выхода уже нет. Заказ «доставлен» или «отменён» — дальше двигаться некуда.
  • Guard (условие перехода) — дополнительная проверка, без которой переход не случится, даже если событие пришло. Например, оплатить заказ можно, только если сумма больше нуля.
  • Действие при переходе (action) — что нужно сделать в момент перехода: списать деньги, отправить письмо, записать время. Действие привязано к переходу, а не разбросано по коду.

Главная идея: весь набор допустимых переходов известен заранее и собран в одном месте. Если события «отправить» нет для состояния «отменён», значит отправить отменённый заказ нельзя — и это правило записано явно, а не подразумевается.

Пример: жизненный цикл заказа

Вернёмся к заказу, но теперь опишем его как автомат. Состояния:

  • NEW — создан, ждёт оплаты (начальное);
  • PAID — оплачен, готов к отправке;
  • SHIPPED — отправлен покупателю;
  • DELIVERED — доставлен (конечное);
  • CANCELLED — отменён (конечное).

Разрешённые переходы:

  • NEW → PAID — покупатель оплатил;
  • PAID → SHIPPED — склад отправил;
  • SHIPPED → DELIVERED — курьер довёз;
  • NEW → CANCELLED — отменили неоплаченный заказ;
  • PAID → CANCELLED — отменили оплаченный (с возвратом денег).

Наглядно это выглядит так:

NEW PAID SHIPPED DELIVERED CANCELLED оплата отправка доставка отмена отмена, возврат

Жизненный цикл заказа как конечный автомат: стрелки — разрешённые переходы. Отправить можно только оплаченный заказ (NEW → PAID → SHIPPED), а отменить — лишь до отправки. Отправленный заказ отменить нельзя: такого перехода в автомате просто нет.

А теперь — что запрещено, и почему это ценно:

  • SHIPPED → CANCELLED — отправленный заказ отменить нельзя, товар уже в пути. Автомат просто не знает такого перехода.
  • NEW → SHIPPED — нельзя отправить неоплаченный заказ, минуя PAID.
  • DELIVERED → что угодно — доставленный заказ — конечное состояние, из него нет выхода.
  • Повторная оплата PAID → PAID — заказ уже оплачен, второе списание невозможно.

Ценность в том, что запрет — не забытая проверка в каком-то одном if, а свойство самой модели. Если код попытается перевести SHIPPED в CANCELLED, автомат ответит ошибкой: такого перехода не существует. Ошибка вылезает сразу и в одном месте, а не превращается в тихо испорченные данные, которые всплывут через неделю в отчёте.

Флаги против стейт-машины

Сравним два подхода на одной задаче — «можно ли отправить заказ».

С флагами условие приходится собирать вручную и повторять везде, где оно нужно:

if (order.isPaid() && !order.isShipped() && !order.isCancelled()) {
    order.setShipped(true);
}

Что тут плохо:

  • Невозможные состояния разрешены. Три булевых поля дают восемь комбинаций, а осмысленных — меньше половины. Ничто не мешает выставить isPaid = false, isShipped = true.
  • Правило дублируется. Каждый раз, когда нужно понять «в каком заказ состоянии», условие пишут заново — и легко ошибиться в одной из копий.
  • Нет единого списка правил. Чтобы узнать, какие переходы вообще возможны, придётся вычитать весь код и держать логику в голове.

С автоматом заказ хранит одно поле-состояние, а правила собраны в одном месте:

public enum OrderStatus { NEW, PAID, SHIPPED, DELIVERED, CANCELLED }

public void ship() {
    if (status != OrderStatus.PAID) {
        throw new IllegalStateException(
            "Отправить можно только оплаченный заказ, а он в статусе " + status);
    }
    this.status = OrderStatus.SHIPPED;
}

Что даёт этот подход:

  • Состояние явное. В любой момент заказ — ровно в одном из перечисленных enum-состояний. Невозможных комбинаций больше нет: «оплачен и отменён одновременно» просто не выразить.
  • Запрещённый переход = ошибка. Попытка отправить неоплаченный заказ бросает исключение сразу, а не портит данные молча.
  • Единый список правил. Все допустимые переходы описаны в одном месте. Чтобы понять поведение заказа, достаточно посмотреть на таблицу состояний, а не вычитывать разрозненные if.

Для сложных случаев проверку перехода выносят в отдельную структуру или готовый инструмент (например, Spring Statemachine или движок процессов вроде Camunda с нотацией BPMN), но идея та же: одно поле-состояние и один свод правил вместо россыпи флагов.

Когда хватит if, а когда нужен автомат

Автомат — не всегда правильный ответ. Иногда обычные if проще и честнее, и раздувать код стейт-машиной незачем. Ориентиры простые.

Хватит обычных проверок, если:

  • состояний два-три и они меняются линейно, без ветвлений («черновик» → «опубликовано»);
  • запрещённых переходов по сути нет — двигаться можно только вперёд;
  • логика не растёт и переходы не важны сами по себе (не нужно знать, кто и когда перевёл объект).

Пора вводить автомат, если:

  • состояний много и между ними сложная сеть переходов, часть которых запрещена;
  • важен запрет конкретных переходов, и цена ошибки высока (деньги, отгрузка, юридические статусы);
  • нужен аудит переходов — история «кто, когда и из какого состояния перевёл объект»;
  • логика постоянно растёт: новые состояния и правила добавляют регулярно, а if уже начали противоречить друг другу.

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

Коротко

  • Конечный автомат (стейт-машина) — модель, где объект всегда находится ровно в одном из перечисленных состояний и меняет их по чётким переходам в ответ на события; аналогия — турникет или светофор.
  • Словарь автомата: состояние, событие, переход, начальное и конечные состояния, guard (условие перехода) и действие при переходе.
  • Жизненный цикл заказа NEW → PAID → SHIPPED → DELIVEREDCANCELLED) явно задаёт, какие переходы разрешены, а запрещённые (SHIPPED → CANCELLED, NEW → SHIPPED) делает невозможными.
  • Булевы флаги разрешают невозможные комбинации, дублируют правила и прячут их по коду; автомат даёт явное состояние, ошибку на запрещённый переход и единый список правил.
  • Пара линейных состояний — хватит if; много состояний с запрещёнными переходами, важным аудитом и растущей логикой — нужен автомат.