С конечными автоматами я впервые познакомился во времена своего увлечения геймдевом. В разработке игр все поголовно используют эту абстракцию. Однако, это далеко не единственная их сфера применения.

Конечные автоматы повсюду вокруг нас, даже если мы их не замечаем, или не знаем, что это такое. Тикет в jira, транзакция в базе данных, страница регистрации пользователя в соцсети. Всё перечисленное объединяет одно — состояние.

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

Не хочу углубляться в математические абстракции, поэтому буду краток.

Конечный автомат определяется следующими компонентами:

  • Конечное множество состояний.

  • Нахождение только в одном из состояний в определённый момент времени.

  • Правила, описывающие возможные переходы между состояниями.

Непонятно? Вот пара примеров

Светофор:

  • Обладает конечным множеством состояний: «Красный», «Жёлтый», «Зелёный»

  • Имеет правила переходов между состояниями:

    • красный → жёлтый

    • жёлтый → зелёный

    • зелёный → красный

      Диаграмма конечного автомата "Светофор"
      Диаграмма конечного автомата "Светофор"

Страница логина в систему:

  • Конечное множество состояний: «Ввод логина», «Ввод пароля», «Доступ разрешен», «Доступ запрещен»

  • Правила переходов:

    • Ввод логина → Ввод пароля (логин введен)

    • Ввод логина → Доступ запрещён (юзер не найден)

    • Ввод пароля → Доступ разрешен (пароль верный)

    • Ввод пароля → Доступ запрещен (пароль неверный)

    • Доступ запрещен → Ввод логина (повторная попытка)

      Диаграмма конечного автомата "Страница логина"
      Диаграмма конечного автомата "Страница логина"

Выделяйте состояние явно, а не используйте косвенные признаки

Нам, как программистам нужно уметь вовремя замечать этот паттерн в системе, над которой мы работаем и явно выделить состояние.

Взгляните на этот сниппет:

if (
	support_ticket.last_message.from == 'support'
	and (
		datetime.now() - support_ticket.last_message.time
	).hours > 1
) or (
	  support_ticket.answer_rating is not None
	  and support_ticket.answer_rating >= 3
  ):
    #do some logic with ticket

Вы понимаете, что в нём происходит?
У меня требует когнитивных усилий разобраться в нём.
Но ведь это всего лишь проверка на то, что обращение в поддержку закрыто.

А теперь взгляните сюда:

if support_ticket.status is SupportTicketStatus.Closed:
	# do some logic with ticket

Всё, что я сделал — это выделил явное состояние. Конечный автомат в системе от этого не появился, он существовал и до этого, просто теперь он явный.

Преимущества такого подхода очевидны:

  1. Теперь у меня меньше кода и мне не нужно поддерживать все встречающиеся косвенные признаки состояний. Достаточно в одном месте описать условия перехода.

  2. Глядя на объект, я сразу же могу сказать, в каком он состоянии. Мне не нужно держать в голове все косвенные признаки состояния.

Реализация fsm на python

Напишем минимальный конечный автомат на python.
А чтобы было веселее, придумаем способ описать его декларативно.
Что такое «декларативно»?
Для примера возьмём задачу из таск‑трекера.

1. Перечислим все возможные состояния FSM

  1. Для этого идеально подойдёт Enum

from enum import Enum, auto

class TaskStatus(Enum):
    CREATED = auto()
    QUEUED = auto()
    IN_PROGRESS = auto()
    CANCELLED = auto()
    FINISHED = auto()
  1. Я хочу иметь описать состояние любого объекта и указать начальное состояние.

class Task:
    status = State(TaskStatus, initial_state=TaskStatus.CREATED)
  1. При создании объекта, его состояние должно соответствовать initial_state

task = Task()
assert.task.status is TaskStatus.CREATED
  1. Давайте реализуем требуемую логику. Для задуманного будем использовать протокол дескриптора

from enum import Enum


class State:
    def __init__(self, states: type[Enum], initial_state: Enum):
        self._all_states = states
        self._initial_state = initial_state

    def __set_name__(self, owner: type, attr_name: str) -> None:
        """
        Вызывается при создании класса, которому принадлежит.
        """

        self._attr_name = "_" + attr_name

    def __get__(self, instance: object, objtype: type | None):
        """
        Взять напрямую из объекта-хозяина текущее состояние.
        В качестве ключа используется имя, которое мы записали в __set_name__
        """
		if instance is None:  # При обращении из класса вернём сам дескриптор
			return self
        return instance.__dict__.get(
            self._attr_name,
            self._initial_state # значение по умолчанию
        )

2. Перечислим переходы

  1. Раз мы решили идти по декларативному пути, опишем переходы в теле класса как атрибуты:

class Task:
    status = State(TaskStatus, initial_state=TaskStatus.CREATED)

    enqueue = status.transition(
	    source=TaskStatus.CREATED,
	    dest=TaskStatus.QUEUED,
	)
    proceed = status.transition(
	    source=TaskStatus.QUEUED,
	    dest=TaskStatus.IN_PROGRESS,
	)
    prioritize = status.transition(
	    source=TaskStatus.CREATED,
	    dest=TaskStatus.IN_PROGRESS,
	)
    cancel = status.transition(
	    source=[
		    TaskStatus.CREATED,
		    TaskStatus.QUEUED,
		    TaskStatus.IN_PROGRESS
		],
		dest=TaskStatus.CANCELLED,
	)
    finish = status.transition(
	    source=TaskStatus.IN_PROGRESS,
	    dest=TaskStatus.FINISHED,
	)

Переход "cancel" возможен из разных статусов, поэтому в качестве source передадим список значений.

Таким образом мы имеем вот такую схему переходов:

  1. Я хочу чтобы нельзя было вручную переключить состояние. Только через указанные переходы:

try:
    task.status = TaskStatus.IN_PROGRESS
except AttributeError:
    ...
  1. Я хочу чтобы описанные переходы работали как методы:

task = Task()
assert task.status is TaskStatus.CREATED

task.enqueue()
assert task.status is TaskStatus.QUEUED

task.proceed()
assert task.status is TaskStatus.IN_PROGRESS

task.finish()
assert task.status is TaskStatus.FINISHED
  1. State должен контролировать, какой переход вызывается и не позволять нам перейти по некорректному маршруту.

task = Task()
assert task.status is TaskStatus.CREATED

try:
    task.finish()
except ImpossibleTransitionError:
    ...

assert task.status is TaskStatus.CREATED
  1. Реализуем логику переходов. Для этого дополним класс-дескриптор State :

class ImpossibleTransitionError(Exception):
    pass

class State:
    def __set__(self, instance: object, value: Any):
        """
        Поднять исключение при попытке обновить состояние напрямую.
        """
        raise AttributeError()

    def transition(self, source: Enum | Collection[Enum], dest: Enum):
        """
        Этот метод создаёт замыкание _update_state, которое делает переход
        """
        # Проверка, что передан корректный Enum
        if not dest in self._all_states:
            raise ValueError(f'Destination state {repr(dest)} not found')
        # Для удобства рассматривать одиночный source как единичный список
        if not isinstance(source, Collection):
            source = [source]
        for source_state in source:
            # Такая же проверка как для dest
            if not source_state in self._all_states:
                raise ValueError(f'Source state {repr(source_state)} not found')

        def _update_state(instance):
            """
            Получает текущее состояние объекта и проверяет, возможен ли переход.
            Либо поднимает исключение либо обновляет состояние
            """
            state = self.__get__(instance, None)
            if state in source:
                instance.__dict__[self._attr_name] = dest
            else:
                raise ImpossibleTransitionError()

        return _update_state

3. Соберём код воедино:

  1. fsm.py

from collections.abc import Collection
from typing import Any
from enum import Enum

class ImpossibleTransitionError(Exception):
    pass

class State:
    def __init__(self, states: type[Enum], initial_state: Enum):
        self._all_states = states
        self._initial_state = initial_state

    def __set_name__(self, owner: type, attr_name: str) -> None:
        """
        Вызывается при создании класса, которому принадлежит.
        """
        self._attr_name = "_" + attr_name

    def __get__(self, instance: object, objtype: type | None):
        """
        Взять напрямую из объекта-хозяина текущее состояние.
        В качестве ключа используется имя, которое мы записали в __set_name__
        """
        if instance is None:  # При обращении из класса вернём сам дескриптор
            return self
        return instance.__dict__.get(
            self._attr_name,
            self._initial_state # значение по умолчанию
        )

    def __set__(self, instance: object, value: Any):
        """
        Поднять исключение при попытке обновить состояние напрямую.
        """
        raise AttributeError()

    def transition(self, source: Enum | Collection[Enum], dest: Enum):
        """
        Этот метод создаёт замыкание _update_state, которое делает переход
        """
        # Проверка, что передан корректный Enum
        if not dest in self._all_states:
            raise ValueError(f'Destination state {repr(dest)} not found')
        # Для удобства рассматривать одиночный source как единичный список
        if not isinstance(source, Collection):
            source = [source]
        for source_state in source:
            # Такая же проверка как для dest
            if not source_state in self._all_states:
                raise ValueError(f'Source state {repr(source_state)} not found')

        def _update_state(instance):
            """
            Получает текущее состояние объекта и проверяет, возможен ли переход.
            Либо поднимает исключение, либо обновляет состояние
            """
            state = self.__get__(instance, None)
            if state in source:
                instance.__dict__[self._attr_name] = dest
            else:
                raise ImpossibleTransitionError()

        return _update_state
  1. task.py

from enum import Enum, auto
from fsm import State, ImpossibleTransitionError

class TaskStatus(Enum):
    CREATED = auto()
    QUEUED = auto()
    IN_PROGRESS = auto()
    CANCELLED = auto()
    FINISHED = auto()

class Task:
    status = State(TaskStatus, initial_state=TaskStatus.CREATED)

    enqueue = status.transition(
        source=TaskStatus.CREATED,
        dest=TaskStatus.QUEUED,
    )
    proceed = status.transition(
        source=TaskStatus.QUEUED,
        dest=TaskStatus.IN_PROGRESS,
    )
    prioritize = status.transition(
        source=TaskStatus.CREATED,
        dest=TaskStatus.IN_PROGRESS,
    )
    cancel = status.transition(
        source=[TaskStatus.CREATED, TaskStatus.QUEUED, TaskStatus.IN_PROGRESS],
        dest=TaskStatus.CANCELLED,
    )
    finish = status.transition(
        source=TaskStatus.IN_PROGRESS,
        dest=TaskStatus.FINISHED,
    )

task_a = Task()
assert task_a.status is TaskStatus.CREATED

task_a.enqueue()
assert task_a.status is TaskStatus.QUEUED

task_a.proceed()
assert task_a.status is TaskStatus.IN_PROGRESS

task_a.finish()
assert task_a.status is TaskStatus.FINISHED


task_b = Task()
assert task_b.status is TaskStatus.CREATED

try:
    task_b.finish()
except ImpossibleTransitionError as e:
    assert True
else:
    assert False, "Нельзя совершить переход CREATED -> FINISHED"

# Статус не изменился
assert task_b.status == TaskStatus.CREATED

try:
    task_b.status = TaskStatus.IN_PROGRESS
except AttributeError as e:
    assert True
else:
    assert False, "Нельзя менять состояние вручную"

# Статус не изменился
assert task_b.status == TaskStatus.CREATED

4. Что дальше?

Вот несколько путей, куда можно развить этот код. Предлагаю тебе, читатель, размяться и реализовать эту функциональность:

  1. Переключатели и циклы

class LeverState(Enum):
    ON = auto()
    OFF = auto()

class Lever:
    state = State(LeverState, initial_state = LeverState.OFF)
    switch = state.cycle(SwitchState.OFF, SwitchState.ON)

lever = Lever()
assert lever.state is LeverState.OFF

lever.switch()
assert lever.state is LeverState.ON

lever.switch()
assert lever.state is LeverState.OFF
class TrafficLightColor(Enum):
    GREEN = auto()
    RED = auto()
    YELLOW = auto()

class TrafficLight:
    color = State(TrafficLightColor, initial_state=TrafficLightColor.RED)
    change_color = color.cycle(
        TrafficLightColor.RED,
        TrafficLightColor.GREEN,
        TrafficLightColor.YELLOW
    )

traffic_light = TrafficLight()
assert traffic_light.color is TrafficLightColor.RED

traffic_light.change_color()
assert traffic_light.color is TrafficLightColor.GREEN

...  # and so on
  1. Коллбэки

class Task:
    status = State(TaskStatus, initial_state=TaskStatus.CREATED)

    enqueue = status.transition(
        source=TaskStatus.CREATED,
        dest=TaskStatus.QUEUED,
    )

    proceed = status.transition(
        source=TaskStatus.QUEUED,
        dest=TaskStatus.IN_PROGRESS,
    )

    @status.on_transition
    def on_transition(source: TaskStatus, dest: TaskStatus):
        print("Transitioning from {source} to {destination}")

    @status.on_transition(proceed)
    def on_proceed(source: TaskStatus, dest: TaskStatus):
        print("Proceeding task")

Резюме

Мы разобрались, что такое конечные автоматы и почему важно и полезно их замечать в системах, с которыми работаем.

Мы научились описывать на python конечный автомат декларативным способом и наметили пути развития этой системы.

Библиография

Комментарии (18)


  1. milssky
    04.01.2025 09:35

    Смотрели на https://github.com/pytransitions/transitions?
    Чем ваша реализация лучше?


    1. 1ort Автор
      04.01.2025 09:35

      Да, видел эту библиотеку.

      Не пытался сделать "убийцу" какого-то популярного решения, однако могу отметить ключевые различия:

      - Transitions имеет императивный интерфейс (Создать fsm, добавить переход и тд)
      - В transitions fsm создаётся каждый раз при создании объекта, т.е. by-desing возможна ситуация, когда у двух объектов одного и того же класса будут разные fsm. в моей реализации за счёт протокола дескриптора этого удалось избежать


    1. cupraer
      04.01.2025 09:35

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

      Я уж молчу про to_«state», но этот кошмар хотя бы отключаемый.

      Конечные автоматы полезны не как уровень абстракции, а как математически доказанная гарантия невозможности оказаться в неконсистентном состоянии. Не все «наборы состояний с переходами» — являются валидной FSM, и именно валидность имеет смысл проверять перед использованием (в компилируемых языках — на стадии компиляции, а питоне придется экспортировать метод validate или типа того, и бросать эксепшены при попытке использования до его вызова).


  1. cupraer
    04.01.2025 09:35

    Сигнатура функции transition неверная. В традиционных имплементациях переходы осуществляются ивентами, причем FSM должна уметь адекватно принимать любой ивент и, опционально, выбирать конечное состояние в зависимости от бизнес-логики в колбэке.

    Ваш код оперирует статусами и по сути ничем не отличается от изменения статуса с проверками. Правильные сигнатуры:

    def transition(self, event: Collection[Enum]) → TaskStatus
    
    @status.on_transition
    def on_transition(source: TaskStatus, event: Collection[Enum]) → TaskStatus


    1. 1ort Автор
      04.01.2025 09:35

      В вашем понимании "Верная" это то же, что и "Традиционная"?

      Я считаю, что в данном случае верной реализации нет, т.к. fsm это абстракция


      1. cupraer
        04.01.2025 09:35

        An FSM is defined by a list of its states, its initial state, and the inputs that trigger each transition.

        — https://en.wikipedia.org/wiki/Finite-state_machine

        Вы можете считать как угодно, мне всё равно, я думал, вы хотите чему-то научиться и заодно понять, как правильно на самом деле.


    1. lolikandr
      04.01.2025 09:35

      Вижу 2 разных подхода:

      1. в статье из-за декларативности пользователь вынужден именовать каждый transition, но обработка входов пока не ясно как, наверное будет "как обычно", например if InputVoltage > 14

      2. в вашем случае пользователь вынужден именовать каждый event, но обработка transition не закфиксирована (можно присвоить любое состояние, какое захочется). Интересно, возможно ли совместить?


      1. cupraer
        04.01.2025 09:35

        в статье из-за декларативности пользователь вынужден именовать каждый transition

        В статье по сути нет понятия «transition», и прибивание гвоздями конечного состояния к пользовательскому вызову просто убивает саму идею FSM, превращая её в набор статусов с ad-hoc проверками. Типа поля в БД с триггером-валидатором. Это не FSM.

        обработка transition не закфиксирована (можно присвоить любое состояние, какое захочется)

        Нет конечно, не какое захочется, а тоже только оговоренное правилами. Я написал об этом выше.


  1. MasterMentor
    04.01.2025 09:35

    Вроде, в разделе "математика", а ни одной формулы. Грустно...

    Кстати, в чём отличие "математики" от "алгоритмов"? слова-то разные, и вроде не синонимы...

    (вопрос риторический)


    1. cupraer
      04.01.2025 09:35

      Если вам интересна математика конечных автоматов, и позволяет образование — крайне рекомендую книжку Michael Sipser, Introduction to the Theory of Computation.

      Её, к сожалению, не переводили на русский (насколько я знаю), но она абсолютно бесподобна в смысле «понять, как устроены конечные автоматы и перестать путать «флаги с ограниченным набором значений» и «состояния конечного автомата».


      1. MasterMentor
        04.01.2025 09:35

        Спасибо, забрал (2-е издание). Курс с заданиями, эталон книги науки, на дискретной математике. Специализована для инженеров по вычислительной математике (компиляторы, разработчики алгоритмов, ...). Прикладникам читать до Часть 3. Теория сложности. (Дальше - теоретическая математика, если ей не заниматься - будет сложно и в практике не пригодится).

        В ответку из моей селекции по теме.

        Это разнобойчик, здесь конечные автоматы строят над разными физико-математических пространствами: от электронных дискретных устройств - до моделей на системах дифуров.

        По последним особенно рекомендую Сю Д., Мейер А. Современная теория автоматического управления и ее применение. 1972. - вещь!

        Книги по направлению Конечные автоматы

        Дискретка

        Гилл А. Введение в теорию конечных автоматов - 1966.pdf
        Арбиб М.А. (ред.) Алгебраическая теория автоматов, языков и полугрупп. 1975.djvu
        Коршунов Ю.М. Математические основы кибернетики. 1987.djvu
        Мелихов А.Н. Ориентированные графы и конечные автоматы - 1971.djvu
        Мелихов А.Н., Берштейн Л.С., Курейчик В.М. Применение графов для проектирования дискретных устройств - 1974.djv
        Портер У. Современные основания общей теории систем - 1971.djvu
        Эббинхауз Г.-Д., Якобс К., Ман Ф.-К., Хермес Г. - Машины Тьюринга и рекурсивные фукции - 1972.djvu

        Физика

        Сю Д., Мейер А. Современная теория автоматического управления и ее применение. 1972.djvu

        ТАУ/САУ

        Солодовников В.В. (ред.) Теория автоматического регулирования. Книга 3. Часть 2. Техническая кибернетика. Теория нестационарных, нелинейных и самонастраивающихся систем автоматического регулирования - 1969.pdf

        Бердоносов В.Д. Теория систем и системный анализ - 2003.pdf
        Заде Л., Дезоер Ч. Теория линейных систем (Метод пространства состояний).pdf
        Месарович М., Такахара Я. Общая теория систем математические основы - 1978.djvu
        Месарович М., Такахара Я. Общая теория систем математические основы - 1978.pdf
        Мороз А.И. Курс теории систем - 1987.djvu
        Швыдкий B.C. и др. Элементы теории систем и численные методы моделирования процессов тепломассопереноса - 1999.djvu


        1. paules
          04.01.2025 09:35

          спасибо за литературу