Как стать автором
Обновить

Немного C++ной шаблонной магии и CRTP для контроля за корректностью действий программиста в компайл-тайм

Время на прочтение11 мин
Количество просмотров4.3K

Недавно, работая над новой версией SObjectizer-а, довелось столкнуться задачей контроля в компайл-тайм действий разработчика. Суть была в том, что ранее программист мог делать вызовы вида:


receive(from(ch).empty_timeout(150ms), ...);
receive(from(ch).handle_n(2).no_wait_on_empty(), ...);
receive(from(ch).empty_timeout(2s).extract_n(20).stop_on(...), ...);
receive(from(ch).no_wait_on_empty().stop_on(...), ...);

Операция receive() требовала набор параметров, для указания которых использовались цепочки методов, вроде показанных выше from(ch).empty_timeout(150ms) или from(ch).handle_n(2).no_wait_on_empty(). При этом вызов методов handle_n()/extract_n(), ограничивающих количество извлекаемых/обрабатываемых сообщений, был необязательным. Поэтому все показанные выше цепочки были корректными.


Но в новой версии потребовалось заставить пользователя обязательно явно указывать количество сообщений для извлечения и/или обработки. Т.е. цепочка вида from(ch).empty_timeout(150ms) теперь становилась некорректной. Её следовало заменить на from(ch).handle_all().empty_timeout(150ms).


Причем хотелось сделать так, чтобы именно компилятор бил бы программиста по рукам, если программист забыл сделать вызов handle_all(), handle_n() или extract_n().


Может ли C++ помочь в этом?


Да. И если кому-то интересно как именно, то милости прошу под кат.


Есть не только функция receive()


Выше была показана функция receive(), параметры для которой задавались посредством цепочки вызовов (также известной как builder pattern). Но в наличии была еще и функция select(), которая получала практически такой же набор параметров:


select(from_all().empty_timeout(150ms), case_(...), case_(...), ...);
select(from_all().handle_n(2).no_wait_on_empty(), case_(...), case_(...), ...);
select(from_all().empty_timeout(2s).extract_n(20).stop_on(...), case_(...), case_(...), ...);
select(from_all().no_wait_on_empty().stop_on(...), case_(...), case_(...), ...);

Соответственно, хотелось получить одно решение, которое подходило бы и для select(), и для receive(). Тем более, что сами параметры для select() и receive() уже представлялись в коде так, чтобы избежать copy-and-paste. Но об этом речь зайдет ниже.


Возможные варианты решения


Итак, задача в том, чтобы пользователь в обязательном порядке вызвал handle_all(), handle_n() или extract_n().


В принципе, этого можно достичь не прибегая к каким-либо сложным решениям. Например, можно было бы ввести дополнительный аргумент для select() и receive():


receive(handle_all(), from(ch).empty_timeout(150ms), ...);
select(handle_n(20), from_all().no_wait_on_empty(), ...);

Либо можно было бы заставить пользователя оформлять вызов receive()/select() по-другому:


receive(handle_all(from(ch).empty_timeout(150ms)), ...);
select(handle_n(20, from_all().no_wait_on_empty()), ...);

Но проблема здесь в том, что при переходе на новую версию SObjectizer-а пользователю пришлось бы переделывать свой код. Даже если код в принципе не требовал переделки. Скажем, вот в такой ситуации:


receive(from(ch).handle_n(2).no_wait_on_empty(), ...);
select(from_all().empty_timeout(2s).extract_n(20).stop_on(...), case_(...), case_(...), ...);

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


Так причем здесь CRTP?


В заголовке статьи был упомянут CRTP. Он же Curiously Recurring Template Pattern (желающие ознакомится с этой интересной, но слегка выносящей мозг техникой, могут начать с этой серии постов в блоге Fluent C++).


Упомянут CRTP был потому, что посредством CRTP у нас была реализована работа с параметрами функций receive() и select(). Поскольку львиная доля параметров для receive() и select() была одинаковой, то в коде использовалось что-то вроде вот такого:


template<typename Derived>
class bulk_processing_params_t
   {
      ...; // Общие для всех операций параметры.

      Derived & self_reference() { return static_cast<Derived &>(*this); }
      ...
   public:
      auto & handle_n(int v)
         {
            to_handle_ = v;
            return self_reference();
         }
      ...
      auto & extract_n(int v)
         {
            to_extract_ = v;
            return self_reference();
         }
      ...
   };

class receive_processing_params_t final
   : public bulk_processing_params_t<receive_processing_params_t>
   {
      ...; // Специфические для receive параметры.
   };

class select_processing_params_t final
   : public bulk_processing_params_t<select_processing_params_t>
   {
      ...;
   };

Зачем здесь вообще CRTP?


CRTP здесь пришлось применить чтобы методы-setter-ы, которые были определены в базовом классе, могли возвращать ссылку не на базовый тип, а на производный.


Т.е., если бы использовалось не CRTP, а обычное наследование, то мы могли бы написать разве что вот так:


class bulk_processing_params_t
   {
   public:
      // Можем возвратить только ссылку на bulk_processing_params_t,
      // а не на производный класс.
      bulk_processing_params_t & handle_n(int v) {...}
      bulk_processing_params_t & extract_n(int v) {...}
      ...
   };

class receive_processing_params_t final
   : public bulk_processing_params_t
   {
   public:
      // Здесь унаследованные методы будут возвращать
      // ссылку на bulk_processing_params_t, а не на
      // receive_processing_params_t.
      ...
      // Собственные методы могут возвращать ссылку на
      // класс receive_processing_params_t.
      receive_processing_params_t & receive_payload(int v) {...}
   };

class select_processing_params_t final
   : public bulk_processing_params_t
   {
   public:
      // Здесь унаследованные методы будут возвращать
      // ссылку на bulk_processing_params_t, а не на
      // select_processing_params_t.
      ...
   };

Но такой примитивный механизм не позволит нам использовать тот самый builder pattern, поскольку:


receive_processing_params_t{}.handle_n(20).receive_payload(0)

не скомпилируется. Метод handle_n() возвратит ссылку на bulk_processing_params_t, а там метод receive_payload() еще не определен.


А вот с CRTP у нас проблем с builder pattern нет.


Итоговое решение


Итоговое решение состоит в том, чтобы финальные типы, вроде receive_processing_params_t и select_processing_params_t, сами так же стали шаблонными типами. Чтобы они параметризовались скаляром следующего вида:


enum class msg_count_status_t
   {
      undefined,
      defined
   };

И чтобы финальный тип мог конвертироваться из T<msg_count_status_t::undefined> в T<msg_count_status_t::defined>.


Это позволит, например, в функции receive() получать receive_processing_params_t и проверять значение Status в компайл-тайм. Что-то вроде:

template<
   msg_count_status_t Msg_Count_Status,
   typename... Handlers >
inline mchain_receive_result_t
receive(
   const mchain_receive_params_t<Msg_Count_Status> & params,
   Handlers &&... handlers )
   {
      static_assert(
         Msg_Count_Status == msg_count_status_t::defined,
         "message count to be processed/extracted should be defined "
         "by using handle_all()/handle_n()/extract_n() methods" );

В общем-то, все просто, как обычно: взять и сделать ;)


Описание сделанного решения


Давайте посмотрим на минимальном примере, отвязанном от специфики SObjectizer-а, как это выглядит.


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


enum class msg_count_status_t
   {
      undefined,
      defined
   };

Далее нам потребуется структура, в которой будут храниться все общие параметры:


struct basic_data_t
   {
      int to_extract_{};
      int to_handle_{};

      int common_payload_{};
   };

Вообще не суть что за содержимое будет у basic_data_t. Для примера подойдет тот минимальный набор полей, который показан выше.


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


struct receive_specific_data_t final : public basic_data_t
   {
      int receive_payload_{};

      receive_specific_data_t() = default;

      receive_specific_data_t(int v) : receive_payload_{v} {}
   };

Будем считать, что структура basic_data_t и её наследники сложностей не вызывают. Поэтому перейдем к более сложным частям решения.


Сейчас нам потребуется обертка вокруг basic_data_t, которая будет предоставлять методы-getter-ы. Это будет шаблонный класс следующего вида:


template<typename Basic_Data>
class basic_data_holder_t
   {
   private :
      Basic_Data data_;

   protected :
      void set_to_extract(int v) { data_.to_extract_ = v; }
      void set_to_handle(int v) { data_.to_handle_ = v; }

      void set_common_payload(int v) { data_.common_payload_ = v; }

      const auto & data() const { return data_; }

   public :
      basic_data_holder_t() = default;

      basic_data_holder_t(Basic_Data data) : data_{std::move(data)} {}

      int to_extract() const { return data_.to_extract_; }
      int to_handle() const { return data_.to_handle_; }

      int common_payload() const { return data_.common_payload_; }
   };

Шаблонным этот класс является для того, чтобы он мог содержать любого наследника от basic_data_t, хотя методы-getter-ы он реализует только для тех полей, которые находятся в basic_data_t.


Прежде чем мы перейдем к еще более сложным частям решения, следует обратить внимание на метод data() в basic_data_holder_t. Это важный метод и мы столкнемся с ним позже.


Теперь мы можем перейти к ключевому шаблонному классу, который может выглядеть довольно-таки страшно для людей, не сильно посвященных в современный C++:


template<typename Data, typename Derived>
class basic_params_t : public basic_data_holder_t<Data>
   {
      using base_type = basic_data_holder_t<Data>;

   public :
      using actual_type = Derived;
      using data_type = Data;

   protected :
      actual_type & self_reference()
         { return static_cast<actual_type &>(*this); }

      decltype(auto) clone_as_defined()
         {
            return self_reference().template clone_if_necessary<
                  msg_count_status_t::defined >();
         }

   public :
      basic_params_t() = default;

      basic_params_t(data_type data) : base_type{std::move(data)} {}

      decltype(auto) handle_all()
         {
            this->set_to_handle(0);
            return clone_as_defined();
         }

      decltype(auto) handle_n(int v)
         {
            this->set_to_handle(v);
            return clone_as_defined();
         }

      decltype(auto) extract_n(int v)
         {
            this->set_to_extract(v);
            return clone_as_defined();
         }

      actual_type & common_payload(int v)
         {
            this->set_common_payload(v);
            return self_reference();
         }
      using base_type::common_payload;
   };

Вот этот вот basic_params_t — это и есть основной CRTP-шный шаблон. Только теперь он параметризуется двумя параметрами.


Первый параметр — это тип данных, который должен содержаться внутри. Например, receive_specific_data_t или select_specific_data_t.


Второй параметр — это привычный для CRTP тип наследника. Он используется в методе self_reference() для получения ссылки на производный тип.


Ключевой момент в реализации шаблона basic_params_t — это его метод clone_as_defined(). Данный метод рассчитывает на то, что наследник реализует у себя метод clone_if_necessary(). А этот clone_if_necessary() как раз и предназначен для того, чтобы можно было трансформировать объект T<msg_count_status_t::undefined> в объект T<msg_count_status_t::defined>. И такая трансформация инициируется в методах-setter-ах handle_all(), handle_n() и extract_n().


Причем можно обратить внимание на то, что clone_as_defined(), handle_all(), handle_n() и extract_n() определяют тип своего возвращаемого значения как decltype(auto). В этом кроется еще один фокус, о котором мы скоро поговорим.


Сейчас мы уже можем взглянуть на один из финальных типов, для которого это все затевалось:


template< msg_count_status_t Msg_Count_Status >
class receive_specific_params_t final
   : public basic_params_t<
         receive_specific_data_t,
         receive_specific_params_t<Msg_Count_Status> >
   {
      using base_type = basic_params_t<
            receive_specific_data_t,
            receive_specific_params_t<Msg_Count_Status> >;

   public :
      template<msg_count_status_t New_Msg_Count_Status>
      std::enable_if_t<
            New_Msg_Count_Status != Msg_Count_Status,
            receive_specific_params_t<New_Msg_Count_Status> >
      clone_if_necessary() const
         {
            return { this->data() };
         }

      template<msg_count_status_t New_Msg_Count_Status>
      std::enable_if_t<
            New_Msg_Count_Status == Msg_Count_Status,
            receive_specific_params_t& >
      clone_if_necessary()
         {
            return *this;
         }

      receive_specific_params_t(int receive_payload)
         :  base_type{ typename base_type::data_type{receive_payload} }
         {}

      receive_specific_params_t(typename base_type::data_type data)
         :  base_type{ std::move(data) }
         {}

      int receive_payload() const { return this->data().receive_payload_; }
   };

Первое, на что следует обратить здесь внимание, — это на конструктор, который принимает base_type::data_type. Посредством этого конструктора производится передача текущих значений параметров при трансформации из T<msg_count_status_t::undefined> в T<msg_count_status_t::defined>.


По большому счету, этот receive_specific_params_t представляет из себя что-то вот такое:


template<typename V, int K>
class holder_t {
  V v_;
public:
  holder_t() = default;
  holder_t(V v) : v_{std::move(v)} {}
  const V & value() const { return v_; }
};

holder_t<std::string, 0> v1{"Hello!"};
holder_t<std::string, 1> v2;
v2 = v1; // Так не получится, поскольку у v1 и v2 формально разные типы.

v2 = holder_t<std::string, 1>{v1.value()}; // А вот так запросто.

И как раз упомянутый конструктор receive_specific_params_t позволяет проинициализировать receive_specific_params_t<msg_count_status_t::defined> значениями из receive_specific_params_t<msg_count_status_t::undefined>.


Вторая важная штука в receive_specific_params_t — это два метода clone_if_necessary().


Почему их два? И что означает вся эта SFINAE-вская магия в их определении?


Сделано два метода clone_if_necessary() для того, чтобы избежать лишних трансформаций. Допустим, программист вызвал метод handle_n() и уже получил receive_specific_params_t<msg_count_status_t::defined>. А потом вызвал extract_n(). Это разрешено, handle_n() и extract_n() задают несколько разные ограничения. Вызов extract_n() также должен дать нам receive_specific_params_t<msg_count_status_t::defined>. Но у нас такой уже есть. Так почему бы не переиспользовать уже существующий?


Вот поэтому здесь два метода clone_if_necessary(). Первый будет работать тогда, когда трансформация реально нужна:


      template<msg_count_status_t New_Msg_Count_Status>
      std::enable_if_t<
            New_Msg_Count_Status != Msg_Count_Status,
            receive_specific_params_t<New_Msg_Count_Status> >
      clone_if_necessary() const
         {
            return { this->data() };
         }

Компилятор будет выбирать его, например, когда статус меняется с undefined на defined. И возвращать этот метод будет новый объект. И да, в реализации этого метода мы обращаем внимание на вызов data(), который был определен еще в basic_data_holder_t.


Второй же метод:


      template<msg_count_status_t New_Msg_Count_Status>
      std::enable_if_t<
            New_Msg_Count_Status == Msg_Count_Status,
            receive_specific_params_t& >
      clone_if_necessary()
         {
            return *this;
         }

будет вызываться когда менять статус не нужно. И этот метод возвращает ссылку на уже существующий объект.


Теперь должно стать понятно, почему в basic_params_t для ряда методов тип возвращаемого значения определялся как decltype(auto). Ведь эти методы зависят от того, какая именно версия clone_if_necessary() будет вызвана в производном типе, а там может возвращаться либо объект, либо ссылка… Заранее не предугадаешь. И вот здесь decltype(auto) приходит на помощь.


Небольшой дисклаймер


Описанный минималистичный пример преследовал цель максимально простой и понятной демонстрации выбранного решения. Поэтому в нем нет вполне очевидных вещей, которые напрашиваются быть включенными в код.


Например, метод basic_data_holder_t::data() возвращает константную ссылку на данные. Что ведет к копированию значений параметров при трансформации T<msg_count_status_t::undefined> в T<msg_count_status_t::defined>. Если копирование параметров является дорогой операций, то следовало бы озадачится move-семантикой и метод data() мог бы иметь следующий вид:


auto data() { return std::move(data_); }

Также сейчас в каждый финальный тип (вроде receive_specific_params_t и select_specific_params_t) приходится включать реализации методов clone_if_necessary. Т.е. в этом месте мы все еще пользуемся копипастой. Возможно, здесь также следовало бы что-то придумать, чтобы избежать дублирования однотипного кода.


Ну и да, в коде нигде не проставлены noexcept дабы сократить "синтаксический оверхэд" (с).


Вот и все


Исходный код обсуждавшегося здесь минималистичного примера можно найти здесь. А поиграться в on-line компиляторе можно, например, здесь (можно закомментировать вызов handle_all() в строке 163 и посмотреть, что получится).


Не хочу сказать, что реализованный мной подход единственно правильный. Но, во-первых, альтернативу я видел разве что в копипасте. И, во-вторых, сделать это оказалось совсем не сложно и много времени это, к счастью, не заняло. А вот удары компилятора по рукам очень сильно помогли сразу же, по мере адаптации старых тестов и примеров к новым возможностям свежей версии SObjectizer-а.


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


PS. Если кто-то из читателей следит за SObjectizer-ом, то могу сказать, что новая версия 5.6, в которой была существенно нарушена совместимость с веткой 5.5, уже вполне себе задышала. Найти ее можно на BitBucket-е. До релиза еще далеко, но SObjectizer-5.6 уже стал таким, каким задумывался. Можно брать, пробовать и делиться своими впечатлениями.

Теги:
Хабы:
Всего голосов 16: ↑16 и ↓0+16
Комментарии7

Публикации

Истории

Работа

Программист C++
133 вакансии
QT разработчик
8 вакансий

Ближайшие события