вторник, 24 сентября 2013 г.

[prog.c++] Очередной подход к обеспечению null safety для сообщений в SObjectizer

Главным средством взаимодействия между агентами в SObjectizer являются сообщения. Но сами сообщения можно разделить на два фундаментальных подвида: собственно сообщения, которые переносят информацию, и сигналы, т.е. уведомления которые не несут больше никакой информации кроме самого факта своего существования. Например, пусть есть два агента, Master и Slave. Когда нужно что-то сделать, скажем, обработать порцию данных, Master отсылает Slave сообщение msg_process_data, в котором находятся подлежащие обработке данные. Когда работу нужно завершить, агент Master отсылает агенту Slave сигнал msg_shutdown. Этот сигнал никакой другой информации не переносит, важен сам факт возникновения этого сигнала.

В SObjectizer-4 первоначально был очень простой подход к сообщениям. Обработчик события должен был получать экземпляр сообщения по указателю, поэтому обработчик события у агента должен был иметь формат:

void a_slave_t::evt_process_data( const msg_process_data * msg ) { ... }

void a_slave_t::evt_shutdown( const msg_shutdown * ) { ... }

Если нужно было отослать сообщение, то динамически создавался его экземпляр, который отсылался агенту. Если же нужно было отослать сигнал, то выполнялись те же действия, что и при отсылке сообщения, но отсылался нулевой указатель на данные сообщения:

// Отсылка сообщения.
msg_process_data * msg = new msg_process_data(...);
so_4::api::send_msg( "a_slave""msg_process_data", msg );

// Отсылка сигнала.
so_4::api::send_msg( "a_slave""msg_shutdown"0 );

Однако, уже в самом начале использования SO4 стало понятно, что для сообщений, которые обязаны переносить данные, нужно обеспечивать null safety, т.е. на уровне SObjectizer-а запрещать попытки передачи нулевых указателей в качестве экземпляра такого сообщения.

Здесь нужно сказать пару слов о том, почему null safety для сообщений важен. Думаю, многие C++ программисты сталкивались с ситуацией, когда по ошибке передавали nullptr в std::string(const char *). Хотя все знают, что делать этого не следует, класс basic_string не содержит обработки нулевого указателя в своем конструкторе, но тем не менее, время от времени такое происходит. Приложение валится от access violation и потом приходится искать в отладчике причины краха. Аналогичные вещи иногда случаются и с агентами в SObjectizer, особенно когда подготовка объекта-сообщения записывается не одной-двумя строчками кода. Если подготовка экземпляра сообщения нетривиальна и задействует сторонний API, то запросто можно получить нулевой указатель, даже не заметив этого. Мне самому приходилось наступать на эти грабли и доводилось видеть последствия у коллег. Так что null safety окупается.

Первоначально null safety обеспечивалась слишком уж в духе Plain C: для сообщения можно было назначить т.н. checker -- функцию, которая проверяет экземпляр сообщения и говорит, корректен ли он или нет. Поскольку в рассматриваемом здесь примере обработчик msg_process_data не должен получать нулевых указателей на экземпляр сообщения, сообщение msg_process_data должно было бы быть снабжено checker-ом хотя бы следующего вида:

bool msg_process_data_checker( const msg_process_data * msg )
{
   // Не должно быть нулевым.
   return 0 != msg;
}

Вся эта затея с checker-ами в SO4 появилась из-за того, что в SO4 можно было практически любой тип использовать в качестве типа сообщения. И функция-checker могла проверять не только само наличие экземпляра сообщения, но и его инварианты (т.е. корректность значений внутри сообщения). Но на практике вышло, что в качестве типов сообщения задействовались только специально написанные для этих целей структуры данных. В которых корректность инвариантов гораздо проще обеспечивалась другими средствами, например, выполнением всех проверок непосредственно в конструкторе объекта-сообщения. Поэтому со временем выяснилось, что чуть ли не 99% всех checker-ов выполняют всего одно действие: проверку на равенство нулю указателя на экземпляр сообщения.

Естественно, заставлять разработчика писать для одной проверки отдельную функцию-checker, а потом еще и регистрировать ее для типа сообщения, было не правильно. Поэтому в SO4 был добавлен еще один тип формата обработчика событий:

void a_slave_t::evt_process_data( const msg_process_data & msg ) { ... }

Т.е. если SO4 видел, что обработчик события получает объект сообщения по ссылке, то сам SO4 делал проверку наличия экземпляра сообщения. Что существенно упростило работу с сообщениями в SO4. Сигналы же в SO4, после введения таких проверок, продолжали обрабатываться как и раньше, через получающие указатели обработчики событий.

В SObjectizer-5 с сообщениями и обработчиками событий произошли серьезные изменения. Сообщения теперь должны быть наследниками специального базового типа so_5::rt::message_t, а обработчики событий получают не экземпляр сообщения непосредственно, а ссылку на специальный объект-обертку:

// Декларация сообщения.
struct msg_process_data : public so_5::rt::message_t
{ ... };

// Обработчик события.
void a_slave_t::evt_process_data(
   const so_5::rt::event_data_t< msg_process_data > & msg ) { ... }

// Отсылка сообщения.
std::unique_ptr< msg_process_data > msg( new msg_process_data(...) );
slave_mbox->deliver_message( std::move(msg) );

Обеспечению null safety для сообщений в SO5 так же было уделено внимание. Для этих целей предназначалась обертка not_null_event_data_t, которую нужно было указывать в качестве параметра обработчика события вместо event_data_t. Т.е. приведенный выше обработчик evt_process_data должен был бы быть описан так:

// Обработчик события.
void a_slave_t::evt_process_data(
   const so_5::rt::not_null_event_data_t< msg_process_data > & msg ) { ... }

Но тут я допустил два серьезных промаха. Во-первых, передача сообщений в SO-проектах происходит гораздо чаще, чем передача сигналов. Поэтому для описания обработчиков сообщений нужно было выбрать более удобное имя, а не not_null_event_data_t. Например, нужно было бы выбрать имена event_data_t и signal_data_t. Или же event_data_t и nullable_event_data_t. Мы же остановились на not_null_event_data_t и event_data_t, а я не заметил вовремя, к чему это приведет.

Во-вторых, на ранних стадиях использования SO5 я не проконтролировал корректное использование not_null_event_data_t и event_data_t в прикладом коде. Т.е. не навязал разработчикам дисциплины правильного выбора между not_null_event_data_t и event_data_t. Что привело к закономерному результату: event_data_t используется повсеместно, тогда как not_null_event_data_t можно было найти только в нескольких тестах и примерах самого SO5 (да и то в очень малых количествах).

В общем, когда дошли руки навести порядок с null safety для сообщений выяснилось, что уже есть большое количество кода с event_data_t, который просто так на not_null_event_data_t не перепишешь. Нужно было придумывать что-то другое. И вот что было сделано в разрабатываемой сейчас версии 5.2.

Во-первых, был выделен специальный класс so_5::rt::signal_t, наследник message_t. Этот класс должен стать базой для всех сигналов. Тогда как для сообщений продолжит использоваться message_t. Конструктор so_5::rt::signal_t сделал приватным, т.ч. наследника от signal_t объявить можно, а вот создать его экземпляр не получится. Так задумано специально, поскольку сигнал -- это уведомление без данных, какой-то объект создавать для него не нужно в принципе.

Во-вторых, тип not_null_event_data_t был удален вообще. Теперь все обработчики получают только ссылку на event_data_t. Вне зависимости о того, для сообщения предназначен обработчик или для сигнала. Лично мне это не очень нравится, но зато не нужно будет переделывать уже написанный на SO5 код.

В-третьих, в публичный API SO5 было добавлены специальные compile- и run-time проверки. Целью которых является: a) не дать возможности использования сигналов в качестве сообщений и b) не дать возможности отослать nullptr в качестве экземпляра сообщения. Например, посредством простой функции ensure_not_signal, использующей возможности C++11, класс event_data_t модифицирован так, что в compile-time пресекаются попытки получить доступ к внутренностям сигнала. Немного технических детали для иллюстрации:

templateclass MSG >
void
ensure_not_signal()
{
   static_assert( !std::is_base_of< signal_t, MSG >::value,
         "instance of signal_t cannot be used in place of instance of "
         "message_t" );
   static_assert( std::is_base_of< message_t, MSG >::value,
         "message class should be derived from message_t" );
}
templateclass MSG >
class event_data_t
{
   public:
      //! Constructor.
      event_data_t( const MSG * message_instance )
         :  m_message_instance( message_instance )
      {}

      //! Access to message.
      const MSG&
      operator * () const
      {
         ensure_not_signal< MSG >();

         return *m_message_instance;
      }

      //! Access to raw message pointer.
      const MSG *
      get() const
      {
         ensure_not_signal< MSG >();

         return m_message_instance;
      }

      //! Access to message via pointer.
      const MSG *
      operator -> () const
      {
         ensure_not_signal< MSG >();

         return get();
      }

   private:
      //! Message.
      const MSG * const m_message_instance;
};

Т.е. если агент Slave в обработчике сигнала msg_shutdown попробует обратиться к event_data_t<msg_shutdown>::get() или event_data_t<msg_shutdown>::operator*(), то возникнет ошибка компиляции (если, конечно, разработчик не забудет унаследовать msg_shutdown от so_5::rt::signal_t).

Методы deliver_message так же получили дополнительные compile- и run-time проверки. Теперь они выглядят так:

templateclass MSG >
void
ensure_message_with_actual_data( const MSG * m )
{
   ensure_not_signal< MSG >();

   if( !m )
      throw so_5::exception_t(
            "an attempt to send message via nullptr",
            so_5::rc_null_message_data );
}
templateclass MESSAGE >
void
mbox_t::deliver_message(
   std::unique_ptr< MESSAGE > && msg_unique_ptr )
{
   ensure_message_with_actual_data( msg_unique_ptr.get() );

   deliver_message(
      type_wrapper_t( typeid( MESSAGE ) ),
      message_ref_t( msg_unique_ptr.release() ) );
}

Т.е. теперь deliver_message во время компиляции проверяет, что пытаются оправить именно экземпляр message_t (т.ч. сейчас signal_t в deliver_message не засунешь). А в run-time проверяется, чтобы указатель на message_t был отличен от nullptr.

Для отсылки же сигнала в SObjectizer-5.2 один из вариантов deliver_message был переименован в deliver_signal, который в compile-time проверяет, чтобы в качестве сигнала не попробовали отослать message_t.

Вот как-то так получилось. Вроде бы выглядит логичнее и удобнее, чем в SObjectizer-5.1. Но как это пойдет на практике, нужно будет еще посмотреть. В любом случае, новые возможности C++11, в частности, type_traits, оказались очень к месту.

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