Конечные автоматы — это, пожалуй, одно из самых основополагающих и широко используемых понятий в программировании. Конечные автоматы (КА) активно применяются во множестве прикладных ниш. В частности, в таких нишах, как АСУТП и телеком, с которыми доводилось иметь дело, КА встречаются чуть реже, чем на каждом шагу.
Поэтому в данной статье мы попробуем поговорить о КА, в первую очередь об иерархических конечных автоматах и их продвинутых возможностях. И немного рассказать о поддержке КА в SObjectizer-5, «акторном» фреймворке для C++. Одном из техдвухнескольких, которые открыты, бесплатны, кросс-платформенны, и все еще живы.
Даже если вам не интересен SObjectizer, но вы, скажем, никогда не слышали про иерархические конечные автоматы или о том, насколько полезны такие продвинутые возможности КА, как обработчики входа-выхода для состояний или история состояния, то вам может быть интересно заглянуть под кат и прочесть хотя бы первую часть статьи.
Мы не будем пробовать провести в статье полный ликбез на тему автоматов и такой их разновидности, как конечные автоматы. Читателю необходимо иметь хотя бы базовое представление об этих типах сущностей.
У КА есть несколько «продвинутых» возможностей, которые многократно повышают удобство работы с КА в программе. Давайте кратко пройдемся по этим «продвинутым» возможностям.
Disclaimer: если читатель хорошо знаком с диаграммами состояний из UML, то ничего нового он здесь для себя не найдет.
Пожалуй, самая важная и ценная возможность — это организация иерархии/вложенности состояний. Поскольку именно возможность вложить состояния друг в друга устраняет «взрыв» количества переходов из состояния в состояние по мере увеличения сложности КА.
Объяснить это словами труднее, чем показать на примере. Поэтому давайте представим, что у нас есть инфокиоск, на экране которого сперва отображается приветственное сообщение. Пользователь может выбрать пункт «Услуги» и перейти в раздел выбора нужных ему услуг. Либо может выбрать пункт «Личный кабинет» и перейти в раздел работы со своими персональными данными и сервисами. Либо может выбрать раздел «Справка». Пока все вроде бы просто и может быть представлено следующей диаграммой состояний (максимально упрощенной):
Но давайте попробуем сделать так, чтобы по нажатии на кнопку «Отмена» пользователь мог вернуться из любого раздела на стартовую страницу с приветственным сообщением:
Схема усложняется, но все еще под контролем. Однако, давайте вспомним, что в разделе «Услуги» у нас может быть еще несколько подразделов, например, «Популярные услуги», «Новые услуги» и «Полный перечень». И из каждого из этих разделов также нужно возвращаться на стартовую страницу. Наш простой КА становится все более и более непростым:
Но и это еще далеко не все. Мы ведь пока еще не принимали во внимание кнопку «Назад», по которой нам нужно вернутся в предыдущий раздел. Давайте добавим еще и реакцию на кнопку «Назад» и посмотрим, что у нас получается:
Да, теперь-то мы видим путь к настоящему веселью. А ведь мы еще даже не рассматривали подразделы в разделах «Личный кабинет» и «Справка»… Если начнем, то практически сразу же наш простой, по началу, КА превратится во что-то невообразимое.
Вот тут нам на помощь и приходит вложенность состояний. Давайте представим, что у нас есть всего два состояния верхнего уровня: WelcomeScreen и UserSelection. Все наши разделы (т.е. «Услуги», «Личный кабинет» и «Справка») будут «вложены» в состояние UserSelection. Можно сказать, что состояния ServicesScreen, ProfileScreen и HelpScreen будут дочерними для UserSelection. А раз они дочерние, то они будут наследовать реакцию на какие-то сигналы из своего родительского состояния. Поэтому реакцию на кнопку «Отмена» мы можем определить в UserSelection. Но нам незачем определять эту реакцию во всех дочерних подсостояниях. Что делает наш КА более лаконичным и понятным:
Здесь можно обратить внимание, что реакцию для «Отмена» и «Назад» мы определили в UserSelection. И эта реакция на кнопку «Отмена» работает для всех без исключения подсостояний UserSelection (включая еще одно составное подсостояние ServicesSelection). Но вот в подсостоянии ServicesSelection реакция на кнопку «Назад» уже своя — возврат происходит не в WelcomScreen, а в ServicesScreen.
КА, в которых используется иерархия/вложенность состояний, называются иерархическими конечными автоматами (ИКА).
Очень полезной возможностью является возможность назначить реакцию на вход в конкретное состояние, а также реакцию на выход из состояния. Так, в приведенном выше примере с инфокиоском на вход в каждое из состояний можно повесить обработчик, который будет менять содержимое экрана инфокиоска.
Предыдущий пример можно немного расширить. Допустим, в WelcomScreen у нас будет два подсостояния: BrightWelcomScreen, в котором экран будет подсвечен нормально, а также DarkWelcomScreen, в котором яркость экрана будет уменьшена. Мы можем сделать обработчик входа в DarkWelcomScreen, который будет уменьшать яркость экрана. И обработчик выхода из DarkWelcomScreen, который будет восстанавливать нормальную яркость.
Временами бывает необходимо ограничить время пребывания КА в конкретном состоянии. Так, в примере выше мы можем ограничить время пребывания нашего ИКА в состоянии BrightWelcomScreen одной минутой. Как только минута истекает, ИКА автоматически переводится в состояние DarkWelcomScreen.
Еще одной очень полезной фичей ИКА является история состояния КА.
Давайте представим себе, что у нас есть некий абстрактный ИКА вот такого вида:
Этот наш ИКА может переходить из TopLevelState1 в TopLevelState2 и обратно. Но внутри TopLevelState1 есть несколько вложенных состояний. Если ИКА просто переходит из TopLevelState2 в TopLevelState1, то активируются сразу два состояния: TopLevelState1 и NestedState1. NestedState1 активируется потому, что это начальное подсостояние состояния TopLevelState1.
Теперь представим, что далее наш ИКА менял свое состояние от NestedState1 к NestedState2. Внутри NestedState2 активировалось подсостояние InternalState1 (поскольку оно начальное для NestedState2). А из InternalState1 мы прешли в InternalState2. Таким образом, у нас одновременно активны следующие состояния: TopLevelState1, NestedState2 и InternalState2. И тут мы переходим в TopLevelState2 (т.е. мы вообще ушли из TopLevelState1).
Активным становится TopLevelState2. После чего мы хотим вернутся в TopLevelState1. Именно в TopLevelState1, а не в какое-то конкретное подсостояние в TopLevelState1.
Итак, из TopLevelState2 мы идем в TopLevelState1 и куда же мы попадаем?
Если у TopLevelState1 нет истории, то мы придем в TopLevelState1 и NestedState1 (поскольку NestedState1 — это начальное подсостояние для TopLevelState1). Т.е. вся история о переходах внутри TopLevelState1, которые осуществлялись до ухода в TopLevelState2, полностью потерялась.
Если же у TopLevelState1 есть т.н. поверхностная история (shallow history), то при возврате из TopLevelState2 в TopLevelState1 мы попадаем в NestedState2 и InternalState1. В NestedState2 мы попадаем потому, что это записано в истории состояния TopLevelState1. А в InternalState1 мы попадаем потому, что оно является начальным для NestedState2. Получается, что в поверхностной истории для TopLevelState1 сохраняется информация только о подсостояниях самого первого уровня. История вложенных состояний в этих подсостояниях не сохраняется.
А вот если у TopLevelState1 есть глубокая история (deep history), то при возврате из TopLevelState2 в TopLevelState1 мы попадаем в NestedState2 и InternalState2. Потому, что в глубокой истории сохраняется полная информация об активных подсостояниях, вне зависимости от их глубины.
До сих пор мы рассматривали ИКА у которых внутри состояния могло быть активным только одно из подсостояний. Но временами могут быть ситуации, когда в конкретном состоянии ИКА должно быть несколько одновременно активных подсостояния. Такие подсостояния называются ортогональными состояниями.
Классический пример, на котором демонстрируют ортогональные состояния — это привычная нам клавиатура компьютера и ее режимы NumLock, CapsLock и ScrollLock. Можно сказать, что работа с NumLock/CapsLock/ScrollLock описывается ортогональными подсостояниями внутри состояния Active:
Вообще, есть основополагающая статья на тему формальной нотации для диаграмм состояний от Девида Харела: Statecharts: A Visual Formalism For Complex Systems (1987).
Там разбираются различные ситуации, которые могут встретиться при работе с конечными автоматами на примере управления обычными электронными часами. Если кто-то не читал ее, то очень рекомендую. В принципе, все, что описывал Харел затем перешло в нотацию UML. Но когда читаешь описание диаграмм состояний из UML-я, то не всегда понимаешь что, для чего и когда нужно. А вот в статье Харела изложение идет от простых ситуаций к более сложным. И ты лучше осознаешь всю мощь, которую скрывают в себе конечные автоматы.
Дальше мы будем говорить о SObjectizer-е и его специфике. Если вам не совсем понятны приводимые ниже примеры, то может иметь смысл узнать о SObjectizer-е побольше. Например, из нашей обзорной статьи про SObjecizer и нескольких последующих, которые знакомят читателей с SObjectizer-ом переходя от простого к сложному (первая статья, вторая и третья).
Агенты в SObjectizer с самого начала были конечными автоматами с явно выраженными состояниями. Даже если разработчик агента не описывал никаких собственных состояний в своей классе агента, все равно у агента было дефолтное состояние, которое и использовалось по умолчанию. Например, если разработчик сделал вот такого тривиального агента:
то он может даже не подозревать, что в реальности все сделанные им подписки сделаны для дефолтного состояния. Но если разработчик добавляет агенту свои собственные состояния, то уже приходится задумываться о том, чтобы правильно подписать агента в правильном состоянии. Вот, скажем, простая (и, как водится) неправильная модификация показанного выше агента:
Мы задали два разных обработчика для сигнала how_are_you, каждый для своего состояния.
А ошибка в данной модификации агента simple_demo в том, что находясь в st_free или st_busy агент не будет реагировать на quit вообще, т.к. мы оставили подписку на quit в дефолтном состоянии, но не сделали соответствующих подписок для st_free и st_busy. Простой и очевидный способ исправить эту проблему — это добавить соответствующие подписки в st_free и st_busy:
Правда, этот способ попахивает копипастой, что не есть хорошо. От копипасты можно избавиться введя общее родительское состояние для st_free и st_busy:
Ради справедливости нужно добавить, что изначально в SObjectizer агенты могли быть только простыми конечными автоматами. Поддержка иерархических КА появилась относительно недавно, в январе 2016-го года.
У этого вопроса очень простой ответ:так получилось корни у SObjectizer-а растут из мира АСУТП, а там конечные автоматы используются очень часто. Поэтому мы посчитали нужным, чтобы агенты в SObjectizer также были конечными автоматами. Это очень удобно если в прикладной задаче, для которой SObjectizer пытаются применить, КА используются. А дефолтное состояние, которое есть у всех агентов, позволяет не думать о КА, если использование КА не требуется.
В принципе, если посмотреть на саму Модель Акторов, и на принципы, на которых эта модель построена:
То можно найти сильное сходство между простыми КА и акторами. Можно даже сказать, что акторы — это простые конечные автоматы и есть.
Из перечисленных выше возможностей продвинутых конечных автоматов SObjectizer поддерживает все, за исключением ортогональных состояний. Остальные вкусности, вроде вложенных состояний, обработчиков входа/выхода, ограничений на время пребывания в состоянии, истории для состояний, поддерживаются.
С поддержкой ортогональных состояний с первого раза не срослось. С одной стороны, внутренняя архитектура SObjectizer-а не была предназначена для поддержки нескольких независимых и одновременно активных состояний у агента. С другой стороны, есть идеологические вопросы о том, как себя должен вести агент, у которого есть ортогональные состояния. Клубок этих вопросов оказался слишком сложным, а полезный выхлоп слишком небольшим для того, чтобы решить данную проблему. Да и в нашей практике не встречалось пока еще ситуаций, где обязательно бы требовались ортогональные состояния, но при этом нельзя было бы обойтись, например, разделением работы между несколькими агентами, привязанными к одному общему рабочему контексту.
Однако, если кому-то такая фича, как ортогональные состояния, действительно нужна и у вас есть реальные примеры задач, где это востребовано, то давайте пообщаемся. Возможно, имея конкретные примеры перед глазами, мы сможем добавить в SObjectizer и эту фичу.
В этой части рассказа мы попробуем быстренько пробежаться по API SObjectizer-5 для работы с ИКА. Без глубокого погружения в детали, просто для того, чтобы у читателя возникло представление о том, что есть и как это выглядит. Более подробную информации, буде такое желание, можно разыскать в официальной документации.
Для того, чтобы объявить вложенное состояние, нужно передать в конструктор соответствующего объекта state_t выражение initial_substate_of или substate_of:
Если у состояния S есть несколько подсостояний C1, C2, ..., Cn, то одно из них (и только одно) должно быть помечено как initial_substate_of. Нарушение этого правила диагностируется в run-time.
Максимальная глубина вложенности состояний в SObjectizer-5 ограничена. В версиях 5.5 — это 16 уровней. Нарушение этого правила диагностируется в run-time.
Самый главный фокус со вложенными состояниями в том, что когда активируется состояние у которого есть вложенные состояния, то активируется сразу несколько состояний. Допустим, есть состояние A, у которого есть подсостояния B и C, а в подсостоянии B есть подсостояния D и E:
Когда активируется состояние A, то, на самом деле, активируется сразу три состояния: A, A.B и A.B.D.
Тот факт, что может быть активно сразу несколько состояний, оказывает самое серьезное влияние на две архиважных вещи. Во-первых, на поиск обработчика для очередного входящего сообщения. Так, в показанном только что примере обработчик для сообщения будет сперва искаться в состоянии A.B.D. Если там подходящий обработчик не будет найден, то поиск продолжится в его родительском состоянии, т.е. в A.B. И уже задет, если нужно, поиск будет продолжен в состоянии A.
Во-вторых, наличие нескольких активных состояний сказывается на порядке вызова обработчиков входа-выхода для состояний. Но об этом речь пойдет ниже.
Для состояния могут быть заданы обработчики входа в состояние и выхода из состояния. Делается это посредством методов state_t::on_enter и state_t::on_exit. Обычно эти методы вызываются в методе so_define_agent() (или прямо в конструкторе агента, если агент тривиальный и наследование от него не предусмотрено).
Вероятно, самый сложный момент с on_enter/on_exit обработчиками — это использование их для вложенных состояний. Давайте еще раз вернемся к примеру с состояниями A, B, C, D и E.
Предположим, что у каждого состояния есть on_enter и on_exit обработчик.
Пусть текущим состоянием агента становится A. Т.е. активируются состояния A, A.B и A.B.D. В процессе смены состояния агента будут вызваны: A.on_enter, A.B.on_enter и A.B.D.on_enter. И именно в таком порядке.
Допустим, затем происходит переход в A.B.E. Будут вызваны: A.B.D.on_exit и A.B.E.on_enter.
Если затем мы переведем агента в состояние A.C, то будут вызваны A.B.E.on_exit, A.B.on_exit, A.C.on_enter.
Если агент, находясь в состоянии A.C будет дерегистрирован, то сразу после завершения метода so_evt_finish() будут вызваны обработчики A.C.on_exit и A.on_exit.
Лимит времени на пребывание агента в конкретном состоянии задается посредством метода state_t::time_limit. Как и в случае с on_enter/on_exit, методы time_limit обычно вызываются там, где агент настраивается для своей работы внутри SObjectizer:
Если лимит времени для состояния задан, то как только агент входит в это состояние, SObjectizer начинает отсчет времени пребывания в состоянии. Если же агент покидает состояние, а затем возвращается в это состояние вновь, то отсчет времени начинается заново.
Если лимиты времени задаются для вложенных состояний, то нужно быть внимательным, т.к. возможны любопытные фокусы:
Допустим, агент входит в состояние A. Т.е. активируются состояния A и C. И для A, и для C начинается отсчет времени. Раньше он закончится для состояния C и агент перейдет в состояние D. При этом начнется отсчет времени для пребывания в состоянии D. Но продолжится отсчет времени пребывания в A! Поскольку при переходе из C в D агент продолжил оставаться в состоянии A. И спустя пять секунд после принудительного перехода из C в D агент уйдет в состояние B.
По умолчанию состояния агента не имеют историю. Чтобы активировать сохранение истории для состояния нужно передать в конструктор state_t константу shallow_history (у состояния будет поверхностная история) или deep_history (у состояния будет сохраняться глубокая история). Например:
История для состояний — это непростая тема, особенно когда используется приличная глубина вложенности состояний и у подсостояний имеется своя история. Поэтому за более полной информацией на эту тему лучше обратиться к документации, поэкспериментировать. Ну и расспросить нас, если самостоятельно разобраться не получается ;)
У класса state_t есть ряд наиболее часто используемых методов, которые уже были показаны выше: event() для подписки события на сообщение, on_enter() и on_exit() для установки обработчиков входа-выхода, time_limit() задания лимита на время пребывания в состоянии.
Наряду с этими методами при работе с ИКА очень полезными оказываются следующие методы класса state_t:
Метод just_switch_to(), который предназначен для случая, когда единственной реакций на входящее сообщение является перевод агента в новое состояние. Можно написать:
вместо:
Метод transfer_to_state() оказывается очень полезен, когда у нас некоторое сообщение M обрабатывается одинаковым образом в двух или более состояниях S1, S2, ..., Sn. Но, если мы находимся в состояниях S2,...,Sn, то нам сперва приходится вернуться в S1, а уже затем делать обработку M.
Если это звучит мудрено, то, возможно, в примере кода эта ситуация будет понятна лучше:
Вот вместо того, чтобы для S2,...,Sn определять очень похожие обработчики событий можно использовать transfer_to_state:
Метод suppress() подавляет поиск обработчика события для текущего подсостояния и всех его родительских подсостояний. Пусть у нас есть родительское состояние A, в котором на сообщение M вызывается std::abort(). И есть дочернее состояние B, в котором M можно безопасно проигнорировать. Мы должны определить реакцию на M в подсостоянии B, ведь если мы этого не сделаем, то обработчик для B будет найден в A. Поэтому нам нужно будет написать что-то вроде:
Метод suppress() позволяет записать эту ситуацию в коде более явно и наглядно:
В состав штатных примеров SObjectizer v.5.5 входит простой пример blinking_led, который имитирует работу мигающего светодиодного индикатора. Диаграмма состояний агента из этого примера выглядит следующим образом:
А вот как выглядит полный код агента из этого примера:
Тут вся фактическая работа выполняется внутри обработчиков входа-выхода для подсостояния blink_on. Ну и, плюс к тому, работают лимиты на время пребывания в подсостояниях blink_on и blink_off.
В состав штатных примеров SObjectizer v.5.5 входит также гораздо более сложный пример, intercom_statechart, который имитирует поведение панели домофона. И диаграмма состояний главного агента в этом примере выглядит приблизительно так:
Все так сурово потому, что эта имитация поддерживает не только вызов квартиры по номеру, но и такие штуки, как уникальный секретный код для каждой квартиры, а так же особы сервисный код. Эти коды позволяют открыть замок двери никуда не дозваниваясь.
В данном примере есть еще интересные штуки. Но он слишком большой, чтобы расписывать его в деталях (на это даже отдельной статьи может быть мало). Так что если интересно, как в SObjectizer-е выглядят действительно сложные ИКА, то можно посмотреть в этом примере. А если что-то будет непонятно, то можно задать вопрос нам. Например, в комментариях к данной статье.
Итак, в SObjectizer-5 есть встроенная поддержка ИКА с весьма широким набором поддерживаемых фич. Сделана эта поддержка, понятное дело, для того, чтобы этим пользовались. В частности, отладочные механизмы SObjectizer-а, вроде message delivery tracing-а, знают про состояния агента и отображают текущее состояния в своих соответствующих отладочных сообщениях.
Тем не менее, если разработчик не хочет по каким-то причинам использовать встроенные средства SObjectizer-5, то он может этого не делать.
Например, отказаться от применения SObjectizer-овских state_t и иже с ними можно из-за того, что state_t — это довольно таки тяжеловесный объект, у которого внутри std::string, пара std::function, несколько счетчиков типа std::size_t, пять указателей на различные объекты и еще какая-то мелочь. Все вместе это на 64-х битовом Linux-е и GCC-5.5, например, дает 160 байт на один state_t (не считая того, что может быть размещено в динамической памяти).
Если вам в приложении требуется, скажем, миллион агентов, у каждого из которых будет по 10 состояний, то накладные расходы на SObjectizer-овские state_t могут быть неприемлемыми. В этом случае можно использовать какой-либо другой механизм работы с конечными автоматами, вручную делегируя обработку сообщений этому механизму. Что-то вроде:
В этом случае вы расплачиваетесь за эффективность увеличением количества ручной работы и отсутствием помощи со стороны отладочных механизмов SObjectizer-а. Но тут уж разработчику решать.
Статья получилось объемной, гораздо больше, чем планировалось изначально. Спасибо всем, кто дочитал до этого места. Если кто-нибудь из читателей сочтет возможным оставить свой фидбэк в комментариях к статье, то это будет здорово.
Если что-то осталось непонятно, то задавайте вопросы, мы с удовольствием ответим.
Также пользуясь случаем, хочется обратить внимание тех, кто интересуется SObjectizer-ом, что началась работа над следующей версией SObjectizer-а в рамках ветки 5.5. Кратко о том, что рассматривается к реализации в 5.5.23, описано здесь. Более полно, но на английском, здесь. Вы можете оставить свое мнение о любой из предлагаемых к реализации фич, либо предложить что-то еще. Т.е. есть реальная возможность повлиять на развитие SObjectizer-а. Тем более, что после релиза v.5.5.23 возможна пауза в работах над SObjectizer-ом и следующей возможности включить в состав SObjectizer-а что-нибудь полезного в 2018-ом может и не быть.
Поэтому в данной статье мы попробуем поговорить о КА, в первую очередь об иерархических конечных автоматах и их продвинутых возможностях. И немного рассказать о поддержке КА в SObjectizer-5, «акторном» фреймворке для C++. Одном из тех
Даже если вам не интересен SObjectizer, но вы, скажем, никогда не слышали про иерархические конечные автоматы или о том, насколько полезны такие продвинутые возможности КА, как обработчики входа-выхода для состояний или история состояния, то вам может быть интересно заглянуть под кат и прочесть хотя бы первую часть статьи.
Общими словами про конечные автоматы
Мы не будем пробовать провести в статье полный ликбез на тему автоматов и такой их разновидности, как конечные автоматы. Читателю необходимо иметь хотя бы базовое представление об этих типах сущностей.
Продвинутые конечные автоматы и их возможности
У КА есть несколько «продвинутых» возможностей, которые многократно повышают удобство работы с КА в программе. Давайте кратко пройдемся по этим «продвинутым» возможностям.
Disclaimer: если читатель хорошо знаком с диаграммами состояний из UML, то ничего нового он здесь для себя не найдет.
Иерархические конечные автоматы
Пожалуй, самая важная и ценная возможность — это организация иерархии/вложенности состояний. Поскольку именно возможность вложить состояния друг в друга устраняет «взрыв» количества переходов из состояния в состояние по мере увеличения сложности КА.
Объяснить это словами труднее, чем показать на примере. Поэтому давайте представим, что у нас есть инфокиоск, на экране которого сперва отображается приветственное сообщение. Пользователь может выбрать пункт «Услуги» и перейти в раздел выбора нужных ему услуг. Либо может выбрать пункт «Личный кабинет» и перейти в раздел работы со своими персональными данными и сервисами. Либо может выбрать раздел «Справка». Пока все вроде бы просто и может быть представлено следующей диаграммой состояний (максимально упрощенной):
Но давайте попробуем сделать так, чтобы по нажатии на кнопку «Отмена» пользователь мог вернуться из любого раздела на стартовую страницу с приветственным сообщением:
Схема усложняется, но все еще под контролем. Однако, давайте вспомним, что в разделе «Услуги» у нас может быть еще несколько подразделов, например, «Популярные услуги», «Новые услуги» и «Полный перечень». И из каждого из этих разделов также нужно возвращаться на стартовую страницу. Наш простой КА становится все более и более непростым:
Но и это еще далеко не все. Мы ведь пока еще не принимали во внимание кнопку «Назад», по которой нам нужно вернутся в предыдущий раздел. Давайте добавим еще и реакцию на кнопку «Назад» и посмотрим, что у нас получается:
Да, теперь-то мы видим путь к настоящему веселью. А ведь мы еще даже не рассматривали подразделы в разделах «Личный кабинет» и «Справка»… Если начнем, то практически сразу же наш простой, по началу, КА превратится во что-то невообразимое.
Вот тут нам на помощь и приходит вложенность состояний. Давайте представим, что у нас есть всего два состояния верхнего уровня: WelcomeScreen и UserSelection. Все наши разделы (т.е. «Услуги», «Личный кабинет» и «Справка») будут «вложены» в состояние UserSelection. Можно сказать, что состояния ServicesScreen, ProfileScreen и HelpScreen будут дочерними для UserSelection. А раз они дочерние, то они будут наследовать реакцию на какие-то сигналы из своего родительского состояния. Поэтому реакцию на кнопку «Отмена» мы можем определить в UserSelection. Но нам незачем определять эту реакцию во всех дочерних подсостояниях. Что делает наш КА более лаконичным и понятным:
Здесь можно обратить внимание, что реакцию для «Отмена» и «Назад» мы определили в UserSelection. И эта реакция на кнопку «Отмена» работает для всех без исключения подсостояний UserSelection (включая еще одно составное подсостояние ServicesSelection). Но вот в подсостоянии ServicesSelection реакция на кнопку «Назад» уже своя — возврат происходит не в WelcomScreen, а в ServicesScreen.
КА, в которых используется иерархия/вложенность состояний, называются иерархическими конечными автоматами (ИКА).
Реакция на вход/выход в/из состояния
Очень полезной возможностью является возможность назначить реакцию на вход в конкретное состояние, а также реакцию на выход из состояния. Так, в приведенном выше примере с инфокиоском на вход в каждое из состояний можно повесить обработчик, который будет менять содержимое экрана инфокиоска.
Предыдущий пример можно немного расширить. Допустим, в WelcomScreen у нас будет два подсостояния: BrightWelcomScreen, в котором экран будет подсвечен нормально, а также DarkWelcomScreen, в котором яркость экрана будет уменьшена. Мы можем сделать обработчик входа в DarkWelcomScreen, который будет уменьшать яркость экрана. И обработчик выхода из DarkWelcomScreen, который будет восстанавливать нормальную яркость.
Автоматическая смена состояния по истечению заданного времени
Временами бывает необходимо ограничить время пребывания КА в конкретном состоянии. Так, в примере выше мы можем ограничить время пребывания нашего ИКА в состоянии BrightWelcomScreen одной минутой. Как только минута истекает, ИКА автоматически переводится в состояние DarkWelcomScreen.
История состояния КА
Еще одной очень полезной фичей ИКА является история состояния КА.
Давайте представим себе, что у нас есть некий абстрактный ИКА вот такого вида:
Этот наш ИКА может переходить из TopLevelState1 в TopLevelState2 и обратно. Но внутри TopLevelState1 есть несколько вложенных состояний. Если ИКА просто переходит из TopLevelState2 в TopLevelState1, то активируются сразу два состояния: TopLevelState1 и NestedState1. NestedState1 активируется потому, что это начальное подсостояние состояния TopLevelState1.
Теперь представим, что далее наш ИКА менял свое состояние от NestedState1 к NestedState2. Внутри NestedState2 активировалось подсостояние InternalState1 (поскольку оно начальное для NestedState2). А из InternalState1 мы прешли в InternalState2. Таким образом, у нас одновременно активны следующие состояния: TopLevelState1, NestedState2 и InternalState2. И тут мы переходим в TopLevelState2 (т.е. мы вообще ушли из TopLevelState1).
Активным становится TopLevelState2. После чего мы хотим вернутся в TopLevelState1. Именно в TopLevelState1, а не в какое-то конкретное подсостояние в TopLevelState1.
Итак, из TopLevelState2 мы идем в TopLevelState1 и куда же мы попадаем?
Если у TopLevelState1 нет истории, то мы придем в TopLevelState1 и NestedState1 (поскольку NestedState1 — это начальное подсостояние для TopLevelState1). Т.е. вся история о переходах внутри TopLevelState1, которые осуществлялись до ухода в TopLevelState2, полностью потерялась.
Если же у TopLevelState1 есть т.н. поверхностная история (shallow history), то при возврате из TopLevelState2 в TopLevelState1 мы попадаем в NestedState2 и InternalState1. В NestedState2 мы попадаем потому, что это записано в истории состояния TopLevelState1. А в InternalState1 мы попадаем потому, что оно является начальным для NestedState2. Получается, что в поверхностной истории для TopLevelState1 сохраняется информация только о подсостояниях самого первого уровня. История вложенных состояний в этих подсостояниях не сохраняется.
А вот если у TopLevelState1 есть глубокая история (deep history), то при возврате из TopLevelState2 в TopLevelState1 мы попадаем в NestedState2 и InternalState2. Потому, что в глубокой истории сохраняется полная информация об активных подсостояниях, вне зависимости от их глубины.
Ортогональные состояния
До сих пор мы рассматривали ИКА у которых внутри состояния могло быть активным только одно из подсостояний. Но временами могут быть ситуации, когда в конкретном состоянии ИКА должно быть несколько одновременно активных подсостояния. Такие подсостояния называются ортогональными состояниями.
Классический пример, на котором демонстрируют ортогональные состояния — это привычная нам клавиатура компьютера и ее режимы NumLock, CapsLock и ScrollLock. Можно сказать, что работа с NumLock/CapsLock/ScrollLock описывается ортогональными подсостояниями внутри состояния Active:
Все, что вы хотели знать про конечные автоматы, но...
Вообще, есть основополагающая статья на тему формальной нотации для диаграмм состояний от Девида Харела: Statecharts: A Visual Formalism For Complex Systems (1987).
Там разбираются различные ситуации, которые могут встретиться при работе с конечными автоматами на примере управления обычными электронными часами. Если кто-то не читал ее, то очень рекомендую. В принципе, все, что описывал Харел затем перешло в нотацию UML. Но когда читаешь описание диаграмм состояний из UML-я, то не всегда понимаешь что, для чего и когда нужно. А вот в статье Харела изложение идет от простых ситуаций к более сложным. И ты лучше осознаешь всю мощь, которую скрывают в себе конечные автоматы.
Конечные автоматы в SObjectizer
Дальше мы будем говорить о SObjectizer-е и его специфике. Если вам не совсем понятны приводимые ниже примеры, то может иметь смысл узнать о SObjectizer-е побольше. Например, из нашей обзорной статьи про SObjecizer и нескольких последующих, которые знакомят читателей с SObjectizer-ом переходя от простого к сложному (первая статья, вторая и третья).
Агенты в SObjectizer — это конечные автоматы
Агенты в SObjectizer с самого начала были конечными автоматами с явно выраженными состояниями. Даже если разработчик агента не описывал никаких собственных состояний в своей классе агента, все равно у агента было дефолтное состояние, которое и использовалось по умолчанию. Например, если разработчик сделал вот такого тривиального агента:
class simple_demo final : public so_5::agent_t {
public:
// Сигнал для того, чтобы агент напечатал на консоль свой статус.
struct how_are_you final : public so_5::signal_t {};
// Сигнал для того, чтобы агент завершил свою работу.
struct quit final : public so_5::signal_t {};
// Т.к. агент очень простой, то делаем все подписки в конструкторе.
simple_demo(context_t ctx) : so_5::agent_t{std::move(ctx)} {
so_subscribe_self()
.event<how_are_you>([]{ std::cout << "I'm fine!" << std::endl; })
.event<quit>([this]{ so_deregister_agent_coop_normally(); });
}
};
то он может даже не подозревать, что в реальности все сделанные им подписки сделаны для дефолтного состояния. Но если разработчик добавляет агенту свои собственные состояния, то уже приходится задумываться о том, чтобы правильно подписать агента в правильном состоянии. Вот, скажем, простая (и, как водится) неправильная модификация показанного выше агента:
class simple_demo final : public so_5::agent_t {
// Состояние, которое означает, что агент свободен.
state_t st_free{this};
// Состояние, которое означает, что агент занят.
state_t st_busy{this};
public:
// Сигнал для того, чтобы агент напечатал на консоль свой статус.
struct how_are_you final : public so_5::signal_t {};
// Сигнал для того, чтобы агент завершил свою работу.
struct quit final : public so_5::signal_t {};
// Т.к. агент очень простой, то делаем все подписки в конструкторе.
simple_demo(context_t ctx) : so_5::agent_t{std::move(ctx)} {
so_subscribe_self()
.event<quit>([this]{ so_deregister_agent_coop_normally(); });
// На сообщение how_are_you реагируем по разному, в зависимости от состояния.
st_free.event([]{ std::cout << "I'm free" << std::endl; });
st_busy.event([]{ std::cout << "I'm busy" << std::endl; });
// Начинаем работать в состоянии st_free.
this >>= st_free;
}
};
Мы задали два разных обработчика для сигнала how_are_you, каждый для своего состояния.
А ошибка в данной модификации агента simple_demo в том, что находясь в st_free или st_busy агент не будет реагировать на quit вообще, т.к. мы оставили подписку на quit в дефолтном состоянии, но не сделали соответствующих подписок для st_free и st_busy. Простой и очевидный способ исправить эту проблему — это добавить соответствующие подписки в st_free и st_busy:
simple_demo(context_t ctx) : so_5::agent_t{std::move(ctx)} {
// На сообщение how_are_you реагируем по разному, в зависимости от состояния.
st_free
.event([]{ std::cout << "I'm free" << std::endl; })
.event<quit>([this]{ so_deregister_agent_coop_normally(); });
st_busy
.event([]{ std::cout << "I'm busy" << std::endl; })
.event<quit>([this]{ so_deregister_agent_coop_normally(); });
// Начинаем работать в состоянии st_free.
this >>= st_free;
}
Правда, этот способ попахивает копипастой, что не есть хорошо. От копипасты можно избавиться введя общее родительское состояние для st_free и st_busy:
class simple_demo final : public so_5::agent_t {
// Общее родительское состояние для всех подсостояний.
state_t st_basic{this};
// Состояние, которое означает, что агент свободен.
// Является также начальным подсостоянием для st_basic.
state_t st_free{initial_substate_of{st_basic}};
// Состояние, которое означает, что агент занят.
// Является обычным подсостоянием для st_basic.
state_t st_busy{substate_of{st_basic}};
public:
// Сигнал для того, чтобы агент напечатал на консоль свой статус.
struct how_are_you final : public so_5::signal_t {};
// Сигнал для того, чтобы агент завершил свою работу.
struct quit final : public so_5::signal_t {};
// Т.к. агент очень простой, то делаем все подписки в конструкторе.
simple_demo(context_t ctx) : so_5::agent_t{std::move(ctx)} {
// Обработчик для quit определяем в st_basic и этот обработчик
// будет "унаследован" вложенными подсостояниями.
st_basic.event<quit>([this]{ so_deregister_agent_coop_normally(); });
// На сообщение how_are_you реагируем по разному, в зависимости от состояния.
st_free.event([]{ std::cout << "I'm free" << std::endl; });
st_busy.event([]{ std::cout << "I'm busy" << std::endl; });
// Начинаем работать в состоянии st_free.
this >>= st_free;
}
};
Ради справедливости нужно добавить, что изначально в SObjectizer агенты могли быть только простыми конечными автоматами. Поддержка иерархических КА появилась относительно недавно, в январе 2016-го года.
Почему в SObjectizer-е агенты являются конечными автоматами?
У этого вопроса очень простой ответ:
В принципе, если посмотреть на саму Модель Акторов, и на принципы, на которых эта модель построена:
- актор – это некая сущность, обладающая поведением;
- акторы реагируют на входящие сообщения;
- получив сообщение актор может:
- отослать некоторое количество сообщений другим акторам;
- создать некоторое количество новых акторов;
- определить для себя новое поведение для обработки последующих сообщений.
То можно найти сильное сходство между простыми КА и акторами. Можно даже сказать, что акторы — это простые конечные автоматы и есть.
Какие возможности продвинутых конечных автоматов SObjectizer поддерживает?
Из перечисленных выше возможностей продвинутых конечных автоматов SObjectizer поддерживает все, за исключением ортогональных состояний. Остальные вкусности, вроде вложенных состояний, обработчиков входа/выхода, ограничений на время пребывания в состоянии, истории для состояний, поддерживаются.
С поддержкой ортогональных состояний с первого раза не срослось. С одной стороны, внутренняя архитектура SObjectizer-а не была предназначена для поддержки нескольких независимых и одновременно активных состояний у агента. С другой стороны, есть идеологические вопросы о том, как себя должен вести агент, у которого есть ортогональные состояния. Клубок этих вопросов оказался слишком сложным, а полезный выхлоп слишком небольшим для того, чтобы решить данную проблему. Да и в нашей практике не встречалось пока еще ситуаций, где обязательно бы требовались ортогональные состояния, но при этом нельзя было бы обойтись, например, разделением работы между несколькими агентами, привязанными к одному общему рабочему контексту.
Однако, если кому-то такая фича, как ортогональные состояния, действительно нужна и у вас есть реальные примеры задач, где это востребовано, то давайте пообщаемся. Возможно, имея конкретные примеры перед глазами, мы сможем добавить в SObjectizer и эту фичу.
Как поддержка продвинутых возможностей ИКА выглядит в коде
В этой части рассказа мы попробуем быстренько пробежаться по API SObjectizer-5 для работы с ИКА. Без глубокого погружения в детали, просто для того, чтобы у читателя возникло представление о том, что есть и как это выглядит. Более подробную информации, буде такое желание, можно разыскать в официальной документации.
Вложенные состояния
Для того, чтобы объявить вложенное состояние, нужно передать в конструктор соответствующего объекта state_t выражение initial_substate_of или substate_of:
class demo : public so_5::agent_t {
state_t st_parent{this}; // Родительское состояние.
state_t st_first_child{initial_substate_of{st_parent}}; // Первое дочернее подсостояние.
// К тому же начальное.
state_t st_second_child{substate_of{st_parent}}; // Второе дочернее подстостояние.
state_t st_third_child{substate_of{st_parent}}; // Третье дочернее подсостояние.
state_t st_first_grandchild{initial_substate_of{st_third_child}}; // Еще один уровень вложенности.
state_t st_second_grandchild{substate_of{st_third_child]};
...
};
Если у состояния S есть несколько подсостояний C1, C2, ..., Cn, то одно из них (и только одно) должно быть помечено как initial_substate_of. Нарушение этого правила диагностируется в run-time.
Максимальная глубина вложенности состояний в SObjectizer-5 ограничена. В версиях 5.5 — это 16 уровней. Нарушение этого правила диагностируется в run-time.
Самый главный фокус со вложенными состояниями в том, что когда активируется состояние у которого есть вложенные состояния, то активируется сразу несколько состояний. Допустим, есть состояние A, у которого есть подсостояния B и C, а в подсостоянии B есть подсостояния D и E:
Когда активируется состояние A, то, на самом деле, активируется сразу три состояния: A, A.B и A.B.D.
Тот факт, что может быть активно сразу несколько состояний, оказывает самое серьезное влияние на две архиважных вещи. Во-первых, на поиск обработчика для очередного входящего сообщения. Так, в показанном только что примере обработчик для сообщения будет сперва искаться в состоянии A.B.D. Если там подходящий обработчик не будет найден, то поиск продолжится в его родительском состоянии, т.е. в A.B. И уже задет, если нужно, поиск будет продолжен в состоянии A.
Во-вторых, наличие нескольких активных состояний сказывается на порядке вызова обработчиков входа-выхода для состояний. Но об этом речь пойдет ниже.
Обработчики входа-выхода для состояний
Для состояния могут быть заданы обработчики входа в состояние и выхода из состояния. Делается это посредством методов state_t::on_enter и state_t::on_exit. Обычно эти методы вызываются в методе so_define_agent() (или прямо в конструкторе агента, если агент тривиальный и наследование от него не предусмотрено).
class demo : public so_5::agent_t {
state_t st_free{this};
state_t st_busy{this};
...
void so_define_agent() override {
// Важно: обработчики входа и выхода определяем до того,
// как состояние агента будет изменено.
st_free.on_enter([]{ ... });
st_busy.on_exit([]{ ...});
...
this >>= st_free;
}
...
};
Вероятно, самый сложный момент с on_enter/on_exit обработчиками — это использование их для вложенных состояний. Давайте еще раз вернемся к примеру с состояниями A, B, C, D и E.
Предположим, что у каждого состояния есть on_enter и on_exit обработчик.
Пусть текущим состоянием агента становится A. Т.е. активируются состояния A, A.B и A.B.D. В процессе смены состояния агента будут вызваны: A.on_enter, A.B.on_enter и A.B.D.on_enter. И именно в таком порядке.
Допустим, затем происходит переход в A.B.E. Будут вызваны: A.B.D.on_exit и A.B.E.on_enter.
Если затем мы переведем агента в состояние A.C, то будут вызваны A.B.E.on_exit, A.B.on_exit, A.C.on_enter.
Если агент, находясь в состоянии A.C будет дерегистрирован, то сразу после завершения метода so_evt_finish() будут вызваны обработчики A.C.on_exit и A.on_exit.
Лимиты времени
Лимит времени на пребывание агента в конкретном состоянии задается посредством метода state_t::time_limit. Как и в случае с on_enter/on_exit, методы time_limit обычно вызываются там, где агент настраивается для своей работы внутри SObjectizer:
class led_indicator : public so_5::agent_t {
state_t inactive{this};
state_t active{this};
...
void so_define_agent() override {
// Разрешаем находится в этом состоянии не более 15s.
// По истечении заданного времени нужно перейти в inactive.
active.time_limit(15s, inactive);
...
}
...
};
Если лимит времени для состояния задан, то как только агент входит в это состояние, SObjectizer начинает отсчет времени пребывания в состоянии. Если же агент покидает состояние, а затем возвращается в это состояние вновь, то отсчет времени начинается заново.
Если лимиты времени задаются для вложенных состояний, то нужно быть внимательным, т.к. возможны любопытные фокусы:
class demo : public so_5::agent_t {
// Состояния верхнего уровня.
state_t A{this}, B{this};
// Вложенные в first состояния.
state_t C{initial_substate_of{A}}, st_D{substate_of{A}};
...
void so_define_agent() override {
A.time_limit(15s, B);
C.time_limit(10s, D);
D.time_limit(20s, C);
...
}
...
};
Допустим, агент входит в состояние A. Т.е. активируются состояния A и C. И для A, и для C начинается отсчет времени. Раньше он закончится для состояния C и агент перейдет в состояние D. При этом начнется отсчет времени для пребывания в состоянии D. Но продолжится отсчет времени пребывания в A! Поскольку при переходе из C в D агент продолжил оставаться в состоянии A. И спустя пять секунд после принудительного перехода из C в D агент уйдет в состояние B.
История для состояния
По умолчанию состояния агента не имеют историю. Чтобы активировать сохранение истории для состояния нужно передать в конструктор state_t константу shallow_history (у состояния будет поверхностная история) или deep_history (у состояния будет сохраняться глубокая история). Например:
class demo : public so_5::agent_t {
state_t A{this, shallow_history};
state_t B{this, deep_history};
...
};
История для состояний — это непростая тема, особенно когда используется приличная глубина вложенности состояний и у подсостояний имеется своя история. Поэтому за более полной информацией на эту тему лучше обратиться к документации, поэкспериментировать. Ну и расспросить нас, если самостоятельно разобраться не получается ;)
just_switch_to, transfer_to_state, suppress
У класса state_t есть ряд наиболее часто используемых методов, которые уже были показаны выше: event() для подписки события на сообщение, on_enter() и on_exit() для установки обработчиков входа-выхода, time_limit() задания лимита на время пребывания в состоянии.
Наряду с этими методами при работе с ИКА очень полезными оказываются следующие методы класса state_t:
Метод just_switch_to(), который предназначен для случая, когда единственной реакций на входящее сообщение является перевод агента в новое состояние. Можно написать:
some_state.just_switch_to<some_msg>(another_state);
вместо:
some_state.event([this](mhood_t<some_msg>) {
this >>= another_state;
});
Метод transfer_to_state() оказывается очень полезен, когда у нас некоторое сообщение M обрабатывается одинаковым образом в двух или более состояниях S1, S2, ..., Sn. Но, если мы находимся в состояниях S2,...,Sn, то нам сперва приходится вернуться в S1, а уже затем делать обработку M.
Если это звучит мудрено, то, возможно, в примере кода эта ситуация будет понятна лучше:
class demo : public so_5::agent_t {
state_t S1{this}, S2{this}, ..., Sn{this};
...
void actual_M_handler(mhood_t<M> cmd) {...}
...
void so_define_agent() override {
S1.event(&demo::actual_M_handler);
...
// Во всех остальных состояниях мы должны сперва перевести агента в S1,
// а уже затем делегировать обработку M реальному обработчику.
S2.event([this](mhood_t<M> cmd) {
this >>= S1;
actual_M_handler(cmd);
});
... // И так для всех остальных состояний.
Sn.event([this](mhood_t<M> cmd) {
this >>= S1;
actual_M_handler(cmd);
});
}
...
};
Вот вместо того, чтобы для S2,...,Sn определять очень похожие обработчики событий можно использовать transfer_to_state:
class demo : public so_5::agent_t {
state_t S1{this}, S2{this}, ..., Sn{this};
...
void actual_M_handler(mhood_t<M> cmd) {...}
...
void so_define_agent() override {
S1.event(&demo::actual_M_handler);
...
// Во всех остальных состояниях мы должны сперва перевести агента в S1,
// а уже затем делегировать обработку M реальному обработчику.
S2.transfer_to_state<M>(S1);
... // И так для всех остальных состояний.
Sn.transfer_to_state<M>(Sn);
}
...
};
Метод suppress() подавляет поиск обработчика события для текущего подсостояния и всех его родительских подсостояний. Пусть у нас есть родительское состояние A, в котором на сообщение M вызывается std::abort(). И есть дочернее состояние B, в котором M можно безопасно проигнорировать. Мы должны определить реакцию на M в подсостоянии B, ведь если мы этого не сделаем, то обработчик для B будет найден в A. Поэтому нам нужно будет написать что-то вроде:
void so_define_agent() override {
A.event([](mhood_t<M>) { std::abort(); });
...
B.event([](mhood_t<M>) {}); // Сами ничего не делаем, но и не разрешаем искать
// обработчик для M в родительских состояниях.
...
}
Метод suppress() позволяет записать эту ситуацию в коде более явно и наглядно:
void so_define_agent() override {
A.event([](mhood_t<M>) { std::abort(); });
...
B.suppress<M>(); // Сами ничего не делаем, но и не разрешаем искать
// обработчик для M в родительских состояниях.
...
}
Очень простой пример
В состав штатных примеров SObjectizer v.5.5 входит простой пример blinking_led, который имитирует работу мигающего светодиодного индикатора. Диаграмма состояний агента из этого примера выглядит следующим образом:
А вот как выглядит полный код агента из этого примера:
class blinking_led final : public so_5::agent_t
{
state_t off{ this }, blinking{ this },
blink_on{ initial_substate_of{ blinking } },
blink_off{ substate_of{ blinking } };
public :
struct turn_on_off : public so_5::signal_t {};
blinking_led( context_t ctx ) : so_5::agent_t{ ctx }
{
this >>= off;
off.just_switch_to< turn_on_off >( blinking );
blinking.just_switch_to< turn_on_off >( off );
blink_on
.on_enter( []{ std::cout << "ON" << std::endl; } )
.on_exit( []{ std::cout << "off" << std::endl; } )
.time_limit( std::chrono::milliseconds{1250}, blink_off );
blink_off
.time_limit( std::chrono::milliseconds{750}, blink_on );
}
};
Тут вся фактическая работа выполняется внутри обработчиков входа-выхода для подсостояния blink_on. Ну и, плюс к тому, работают лимиты на время пребывания в подсостояниях blink_on и blink_off.
Не очень простой пример
В состав штатных примеров SObjectizer v.5.5 входит также гораздо более сложный пример, intercom_statechart, который имитирует поведение панели домофона. И диаграмма состояний главного агента в этом примере выглядит приблизительно так:
Все так сурово потому, что эта имитация поддерживает не только вызов квартиры по номеру, но и такие штуки, как уникальный секретный код для каждой квартиры, а так же особы сервисный код. Эти коды позволяют открыть замок двери никуда не дозваниваясь.
В данном примере есть еще интересные штуки. Но он слишком большой, чтобы расписывать его в деталях (на это даже отдельной статьи может быть мало). Так что если интересно, как в SObjectizer-е выглядят действительно сложные ИКА, то можно посмотреть в этом примере. А если что-то будет непонятно, то можно задать вопрос нам. Например, в комментариях к данной статье.
Можно ли не использовать встроенную в SObjectizer-5 поддержку КА?
Итак, в SObjectizer-5 есть встроенная поддержка ИКА с весьма широким набором поддерживаемых фич. Сделана эта поддержка, понятное дело, для того, чтобы этим пользовались. В частности, отладочные механизмы SObjectizer-а, вроде message delivery tracing-а, знают про состояния агента и отображают текущее состояния в своих соответствующих отладочных сообщениях.
Тем не менее, если разработчик не хочет по каким-то причинам использовать встроенные средства SObjectizer-5, то он может этого не делать.
Например, отказаться от применения SObjectizer-овских state_t и иже с ними можно из-за того, что state_t — это довольно таки тяжеловесный объект, у которого внутри std::string, пара std::function, несколько счетчиков типа std::size_t, пять указателей на различные объекты и еще какая-то мелочь. Все вместе это на 64-х битовом Linux-е и GCC-5.5, например, дает 160 байт на один state_t (не считая того, что может быть размещено в динамической памяти).
Если вам в приложении требуется, скажем, миллион агентов, у каждого из которых будет по 10 состояний, то накладные расходы на SObjectizer-овские state_t могут быть неприемлемыми. В этом случае можно использовать какой-либо другой механизм работы с конечными автоматами, вручную делегируя обработку сообщений этому механизму. Что-то вроде:
class external_fsm_demo : public so_5::agent_t {
some_fsm_type my_fsm_;
...
void so_define_agent() override {
so_subscribe_self()
.event([this](mhood_t<msg_one> cmd) { my_fsm_.handle(*cmd); })
.event([this](mhood_t<msg_two> cmd) { my_fsm_.handle(*cmd); })
.event([this](mhood_t<msg_three> cmd) { my_fsm_.handle(*cmd); });
...
}
...
};
В этом случае вы расплачиваетесь за эффективность увеличением количества ручной работы и отсутствием помощи со стороны отладочных механизмов SObjectizer-а. Но тут уж разработчику решать.
Заключение
Статья получилось объемной, гораздо больше, чем планировалось изначально. Спасибо всем, кто дочитал до этого места. Если кто-нибудь из читателей сочтет возможным оставить свой фидбэк в комментариях к статье, то это будет здорово.
Если что-то осталось непонятно, то задавайте вопросы, мы с удовольствием ответим.
Также пользуясь случаем, хочется обратить внимание тех, кто интересуется SObjectizer-ом, что началась работа над следующей версией SObjectizer-а в рамках ветки 5.5. Кратко о том, что рассматривается к реализации в 5.5.23, описано здесь. Более полно, но на английском, здесь. Вы можете оставить свое мнение о любой из предлагаемых к реализации фич, либо предложить что-то еще. Т.е. есть реальная возможность повлиять на развитие SObjectizer-а. Тем более, что после релиза v.5.5.23 возможна пауза в работах над SObjectizer-ом и следующей возможности включить в состав SObjectizer-а что-нибудь полезного в 2018-ом может и не быть.