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

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

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

    Комментарии 2

      +1
      Но вот в подсостоянии ServicesSelection реакция на кнопку «Назад» уже своя — возврат происходит не в WelcomScreen, а в ServicesSelection.

      Опечатка у вас вроде, должно быть UserSelection.
        0
        Спасибо, исправил.

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

      Самое читаемое