С конечными автоматами я впервые познакомился во времена своего увлечения геймдевом. В разработке игр все поголовно используют эту абстракцию. Однако, это далеко не единственная их сфера применения.
Конечные автоматы повсюду вокруг нас, даже если мы их не замечаем, или не знаем, что это такое. Тикет в 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
Всё, что я сделал — это выделил явное состояние. Конечный автомат в системе от этого не появился, он существовал и до этого, просто теперь он явный.
Преимущества такого подхода очевидны:
Теперь у меня меньше кода и мне не нужно поддерживать все встречающиеся косвенные признаки состояний. Достаточно в одном месте описать условия перехода.
Глядя на объект, я сразу же могу сказать, в каком он состоянии. Мне не нужно держать в голове все косвенные признаки состояния.
Реализация fsm на python
Напишем минимальный конечный автомат на python.
А чтобы было веселее, придумаем способ описать его декларативно.
Что такое «декларативно»?
Для примера возьмём задачу из таск‑трекера.
1. Перечислим все возможные состояния FSM
Для этого идеально подойдёт Enum
from enum import Enum, auto
class TaskStatus(Enum):
CREATED = auto()
QUEUED = auto()
IN_PROGRESS = auto()
CANCELLED = auto()
FINISHED = auto()
Я хочу иметь описать состояние любого объекта и указать начальное состояние.
class Task:
status = State(TaskStatus, initial_state=TaskStatus.CREATED)
При создании объекта, его состояние должно соответствовать initial_state
task = Task()
assert.task.status is TaskStatus.CREATED
Давайте реализуем требуемую логику. Для задуманного будем использовать протокол дескриптора
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. Перечислим переходы
Раз мы решили идти по декларативному пути, опишем переходы в теле класса как атрибуты:
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 передадим список значений.
Таким образом мы имеем вот такую схему переходов:
Я хочу чтобы нельзя было вручную переключить состояние. Только через указанные переходы:
try:
task.status = TaskStatus.IN_PROGRESS
except AttributeError:
...
Я хочу чтобы описанные переходы работали как методы:
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
State должен контролировать, какой переход вызывается и не позволять нам перейти по некорректному маршруту.
task = Task()
assert task.status is TaskStatus.CREATED
try:
task.finish()
except ImpossibleTransitionError:
...
assert task.status is TaskStatus.CREATED
Реализуем логику переходов. Для этого дополним класс-дескриптор 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. Соберём код воедино:
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
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. Что дальше?
Вот несколько путей, куда можно развить этот код. Предлагаю тебе, читатель, размяться и реализовать эту функциональность:
Переключатели и циклы
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
Коллбэки
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)
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
1ort Автор
04.01.2025 09:35В вашем понимании "Верная" это то же, что и "Традиционная"?
Я считаю, что в данном случае верной реализации нет, т.к. fsm это абстракцияcupraer
04.01.2025 09:35An FSM is defined by a list of its states, its initial state, and the inputs that trigger each transition.
Вы можете считать как угодно, мне всё равно, я думал, вы хотите чему-то научиться и заодно понять, как правильно на самом деле.
lolikandr
04.01.2025 09:35Вижу 2 разных подхода:
в статье из-за декларативности пользователь вынужден именовать каждый transition, но обработка входов пока не ясно как, наверное будет "как обычно", например
if InputVoltage > 14
в вашем случае пользователь вынужден именовать каждый event, но обработка transition не закфиксирована (можно присвоить любое состояние, какое захочется). Интересно, возможно ли совместить?
cupraer
04.01.2025 09:35в статье из-за декларативности пользователь вынужден именовать каждый transition
В статье по сути нет понятия «transition», и прибивание гвоздями конечного состояния к пользовательскому вызову просто убивает саму идею FSM, превращая её в набор статусов с ad-hoc проверками. Типа поля в БД с триггером-валидатором. Это не FSM.
обработка transition не закфиксирована (можно присвоить любое состояние, какое захочется)
Нет конечно, не какое захочется, а тоже только оговоренное правилами. Я написал об этом выше.
MasterMentor
04.01.2025 09:35Вроде, в разделе "математика", а ни одной формулы. Грустно...
Кстати, в чём отличие "математики" от "алгоритмов"? слова-то разные, и вроде не синонимы...
(вопрос риторический)
cupraer
04.01.2025 09:35Если вам интересна математика конечных автоматов, и позволяет образование — крайне рекомендую книжку Michael Sipser, Introduction to the Theory of Computation.
Её, к сожалению, не переводили на русский (насколько я знаю), но она абсолютно бесподобна в смысле «понять, как устроены конечные автоматы и перестать путать «флаги с ограниченным набором значений» и «состояния конечного автомата».
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
milssky
Смотрели на https://github.com/pytransitions/transitions?
Чем ваша реализация лучше?
1ort Автор
Да, видел эту библиотеку.
Не пытался сделать "убийцу" какого-то популярного решения, однако могу отметить ключевые различия:
- Transitions имеет императивный интерфейс (Создать fsm, добавить переход и тд)
- В transitions fsm создаётся каждый раз при создании объекта, т.е. by-desing возможна ситуация, когда у двух объектов одного и того же класса будут разные fsm. в моей реализации за счёт протокола дескриптора этого удалось избежать
cupraer
При всем уважении, всерьез относиться к библиотеке, которая в туториале предлагает создать три состояния и переходить по ним циклом — просто смешно. Это вообще ни разу не конечный автомат.
Я уж молчу про
to_«state»
, но этот кошмар хотя бы отключаемый.Конечные автоматы полезны не как уровень абстракции, а как математически доказанная гарантия невозможности оказаться в неконсистентном состоянии. Не все «наборы состояний с переходами» — являются валидной FSM, и именно валидность имеет смысл проверять перед использованием (в компилируемых языках — на стадии компиляции, а питоне придется экспортировать метод
validate
или типа того, и бросать эксепшены при попытке использования до его вызова).