пятница, 25 декабря 2015 г.

[prog.c++11] Первая попытка представить, как SObjectizer-овский агент может реализовать иерархический конечный автомат

В рамках дальнейшего развития SObjectizer обдумываю такую тему, как иерархические конечные автоматы. Агенты в SO-5 сейчас являются КА, но простыми, их состояния не могут быть вложены друг в друга. Интересно рассмотреть вариант, когда у агентов появляются вложенные состояния и другие плюшки, присущие более продвинутым КА.

В качестве маленькой тестовой задачки использую крайне упрощенный пример с домофоном. Т.е. есть устройство с 10-ью цифровыми кнопками, кнопкой сброса "С", кнопкой с решеткой "#" и кнопкой со звонком "B". Большую часть времени устройство проводит в неактивном состоянии, в котором не светится ни дисплей, ни кнопки. Когда пользователь нажимает любую кнопку устройство должно активизироваться (т.е. перейти в режим активного ожидания действий пользователя, включив при этом подсветку дисплея и кнопок). Если пользователь ничего не делает в течении 30 секунд, то устройство должно вернуться в неактивное состояние (при этом сбросив весь предыдущий ввод пользователя и погасив подсветку).

Если пользователь вводит комбинацию вида "dddB" (т.е. нажимает несколько цифр и кнопку звонка), то должен быть выполнен звонок в квартиру с указанным номером. Если вводит комбинацию "#ddd#dddddB", то это должно рассматриваться как предъявление секретного кода "ddddd" для квартиры с номером "ddd", если код предъявлен правильно, то замок должен быть открыт. Если вводит комбинацию "##ddddd#", то "ddddd" должен быть уникальным сервисным кодом, открывающим замок.

Если в процессе ввода пользователь нажимает "C", то весь ввод пользователя должен быть выброшен, а устройство должно опять начать активное ожидание ввода.

В виде диаграммы состояний это может выглядеть приблизительно вот так:

Итак, на верхнем уровне есть всего два состояния -- inactive и active. Первоначально устройство находится в состоянии inactive. Нажатие на любую кнопку переводит устройство в состояние active.

При входе в состояние active должна быть включена подсветка и должен начаться отсчет тайм-аута отсутствия действий пользователя.

Состояние active составное. Внутри него находится несколько вложенных состояний: wait_selection, number_selection, special_code_selection. При этом, в каком бы из вложенных подсостояний устройство не находилось, нажатие на "C" должно перевести устройство в подсостояние wait_selection. Так же wait_selection является начальным подсостоянием для active (т.е. перевод устройства в active автоматически означает перевод в wait_selection).

Если в wait_selection пользователь вводит цифру, то выполняется переход в number_selection. Если решетку -- то в special_code_selection.

В состоянии number_selection введенные цифры накапливаются. При нажатии "B" инициируется звонок в квартиру с указанным номером.

В состоянии special_code_selection анализируется следующая нажатая пользователем кнопка. Если это цифра -- значит происходит ввод секретного кода для квартиры. Тогда происходит переход во вложенное состояние user_key_selection_1. Если же пользователь нажимает решетку, то происходит переход во вложенное состояние service_key_selection.

Из состояния user_key_selection_1 по кнопке "#" происходит переход в user_key_selection_2. Т.е. в состоянии user_key_selection_1 накапливается номер квартиры, а в состоянии user_key_selection_2 -- секретный код для этой квартиры.

В общем, получается как-то так. Понятно, что это крайне грубое приближение к реальной задаче, но для того, чтобы понять, как будет выглядеть описание иерархического КА уже достаточно. Поэтому пора перейти к коду, который мог бы описать данный КА в программе.

Пробовал разные варианты, вот этот показался более-менее вменяемым. Реализуем ли он на практике пока точно не знаю. Надеюсь на это. Но сейчас главное -- это придумать что-то вроде формата внутреннего DSL. А когда такой формат будет найден, то решать уже следующую проблему, т.е. находить способ воплощения этого DSL в жизнь (возможно с тщательной доработкой напильником и серьезной творческой переработкой).

Итак, вот что пока получается:

class intercom_t : public so_5::agent_t
{
   state_t st_inactive;
   state_t st_active;

   state_t st_wait_selection;

   state_t st_number_selection;
   state_t st_special_code_selection;
   state_t st_user_key_selection_1;
   state_t st_user_key_selection_2;
   state_t st_service_key_selection;

   virtual void so_define_agent() override
   {
      st_inactive
         // По любой кнопке переходит в active.
         .defer_event_for_state< digit >( st_active )
         .defer_event_for_state< key_c >( st_active )
         .defer_event_for_state< key_grid >( st_active )
         .defer_event_for_state< key_bell >( st_active )
         // При возврате в inactive отключаем подсветку.
         .on_enter( &intercom_t::on_switch_to_inactive );

      st_active
         // Обрабатываем таймерное сообщение для того, чтобы проверить,
         // не пора ли возвращаться в inactive.
         .event( &intercom_t::activity_timeout )
         // По кнопке "С" сбрасываем все, что было введено.
         .event( &intercom_t::key_c_when_active )
         // Делегируем обработку остальных кнопок вложенному состоянию.
         .defer_event_for_state< digit >( st_wait_selection )
         .defer_event_for_state< key_grid >( st_wait_selection )
         .defef_event_for_state< key_bell >( st_wait_selection )
         // При входе в active нужно включить подсветку.
         .on_enter( &intercom_t::on_switch_to_active )
         // Описываем подсостояния для active.
         .initial_substate( st_wait_selection )
         .substates(
               st_number_selection,
               st_special_code_selection,
               st_service_key_selection );

      st_wait_selection
         // Делегируем обработку цифры другому состоянию.
         .defer_event_for_state< digit >( st_number_selection )
         // Если нажата "#", то нужно будет войти в special_code_selection.
         .event( &intercom_t::key_grid_when_wait_selection );

      st_number_selection
         // Очередная цифра для получения номера квартиры.
         .event( &intercom_t::digit_when_number_selection )
         // По кнопке "B" пытаемся позвонить в указанную квартиру.
         .event( &intercom_t::key_bell_when_number_selection )
         // При входе в number_selection сбрасываем старый номер квартиры,
         // если таковой был, чтобы начать заново.
         .on_enter( &intercom_t::on_switch_to_number_selection );

      st_special_code_selection
         // Если пользователь вводит цифру, значит это набор секретного
         // кода для конкретной квартиры.
         .defer_event_for_state< digit >( st_user_key_selection_1 )
         // Введена "#", значит нужно ожидать ввода сервисного кода.
         .event( &intercom_t::key_grid_when_special_code_selection )
         .substates(
               st_user_key_selection_1,
               st_user_key_selection_2,
               st_service_key_selection );

      st_user_key_selection_1
         // Очередная цифра для получения номера квартиры.
         .event( &intercom_t::digit_when_user_key_selection_1 )
         // Введена "#", нужно переходить к накапливанию секретного кода.
         .event( &intercom_t::key_grid_when_user_key_selection_1 )
         // При входе в user_key_selection_1 сбрасываем старый номер квартиры,
         // если таковой был, чтобы начать заново.
         .on_enter( &intercom_t::on_switch_to_key_selection_1 );

      st_user_key_selection_2
         // Очередная цифра для получения секретного кода.
         .event( &intercom_t::digit_when_user_key_selection_2 )
         // По кнопке "B" нужно проверить секретный код для квартиры.
         .event( &intercom_t::key_bell_when_user_key_selection_2 )
         // При входе в user_key_selection_2 сбрасываем старый секретный код,
         // если таковой был, чтобы начать заново.
         .on_enter( &intercom_t::on_switch_to_key_selection_2 );

      st_service_key_selection
         // Очередная цифра для получения сервисного кода.
         .event( &intercom_t::digit_when_service_key_selection )
         // По кнопке "#" нужно проверить сервисный код.
         .event( &intercom_t::key_grid_when_service_key_selection )
         // При входе в service_key_selection сбрасываем старый сервисный код,
         // если таковой был, чтобы начать заново.
         .on_enter( &intercom_t::on_switch_to_service_key_selection );
   }
};

Тут появилось несколько вещей для настройки состояний агента, которых пока нет в SO-5: on_enter, initial_substate и substates. Их смысл, думаю, понятен.

А вот такая штука, как defer_event_for_state, будет похитрее. Ее смысл в том, чтобы описать сообщение/сигнал, которое не должно обрабатываться в текущем состоянии. Вместо этого агент должен перейти в новое состояние и искать обработчик для этого сообщения/сигнала в новом состоянии.

Лучше всего логику defer_event_for_state можно проследить для сообщения digit для состояний st_inactive, st_active и st_wait_selection:

st_inactive
   .defer_event_for_state< digit >( st_active )
   ...;

st_active
   .defer_event_for_state< digit >( st_wait_selection )
   ...;

st_wait_selection
   .defer_event_for_state< digit >( st_number_selection )
   ...;

st_number_selection
   .event( &intercom_t::digit_when_number_selection )

Т.е. когда агент находится в состоянии st_inactive и к нему прилетает digit, то агент переходит в состояние st_active и уже в этом состоянии пытается найти обработчик для digit. Но в состоянии st_active так же нет конечного обработчика. Зато есть указание перейти в состояние st_wait_selection и искать обработчик там. Но и в состоянии st_wait_selection так же нет конечного обработчика, зато есть указание перейти в состояние st_number_selection. И уже для st_number_selection задан обработчик.

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

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

Как бы это выглядело на Boost.MSM или Boost.Statechart не берусь судить, т.к. на практике эти инструменты никогда не использовал. Беглое знакомство с ними наводит на мысль, что там кода было бы не сильно меньше, а тот, что был бы, оказался бы нашпигован шаблонами по самое нехочу :)

Тем не менее, один из самых важных вопросов сейчас -- это стоит ли вообще в SO-5 городить огород с агентами в виде иерархических КА? Или же проще оставить такие навороты на откуп Boost-овским библиотекам (и другим подобным инструментам)? В принципе, ничего же не запрещает сделать что-то вроде:

class intercom_t : public so_5::agent_t
{
   struct fsm_front_t : public boost::msm::front::state_machine_def< fsm_front_t > {
      ...
   };
   using fsm_t = boost::msm::back::state_machine< fsm_front_t >;

   fsm_t m_fsm;

   virtual void so_define_agent() override
   {
      so_subscribe_self().
         .event( [&]( const digit & msg ) { m_fsm.process_event(msg); } )
         .event( [&]( const key_c & msg ) { m_fsm.process_event(msg); } )
         .event( [&]( const key_grid & msg ) { m_fsm.process_event(msg); } )
         .event( [&]( const key_bell & msg ) { m_fsm.process_event(msg); } )
         .event< timer >( [&] { m_fsm.process_event(timer{}); } );
   }
}

Не знаю, правда, насколько это получится жизнеспособно, насколько вообще удобны Boost.MSM и Boost.Statechart в реальной работе, насколько просто будет учитывать в Boost-овских реализациях КА какие-то особенности SO-5 (вроде поступления сообщений из разных mbox-ов)... В общем, есть над чем подумать.

Посему интересно мнение читателей об увиденном: не слишком ли страшно получается?

PS. Какие есть бесплатные хорошие инструменты под Windows для рисования диаграмм состояний?

Комментариев нет: