Давайте попробуем поговорить про иерархические конечные автоматы вообще и их поддержку в SObjectizer-5 в частности +13


Конечные автоматы — это, пожалуй, одно из самых основополагающих и широко используемых понятий в программировании. Конечные автоматы (КА) активно применяются во множестве прикладных ниш. В частности, в таких нишах, как АСУТП и телеком, с которыми доводилось иметь дело, КА встречаются чуть реже, чем на каждом шагу.

Поэтому в данной статье мы попробуем поговорить о КА, в первую очередь об иерархических конечных автоматах и их продвинутых возможностях. И немного рассказать о поддержке КА в 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 поддерживает?


Из перечисленных выше возможностей продвинутых конечных автоматов 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-ом может и не быть.




К сожалению, не доступен сервер mySQL